001/* 002 * JDrupes Json-B plugins 003 * Copyright (C) 2024 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.jsonb.beans; 020 021import jakarta.json.JsonString; 022import jakarta.json.JsonValue; 023import jakarta.json.bind.JsonbException; 024import jakarta.json.bind.serializer.DeserializationContext; 025import jakarta.json.bind.serializer.JsonbDeserializer; 026import jakarta.json.stream.JsonParser; 027import jakarta.json.stream.JsonParser.Event; 028import java.beans.BeanInfo; 029import java.beans.ConstructorProperties; 030import java.beans.PropertyDescriptor; 031import java.beans.PropertyEditor; 032import java.lang.reflect.Constructor; 033import java.lang.reflect.Field; 034import java.lang.reflect.InvocationTargetException; 035import java.lang.reflect.Method; 036import java.lang.reflect.Type; 037import java.util.Arrays; 038import java.util.Collection; 039import java.util.Comparator; 040import java.util.HashMap; 041import java.util.Map; 042import java.util.Optional; 043import java.util.SortedMap; 044import java.util.TreeMap; 045import java.util.function.Function; 046 047/// 048/// Decoder for converting JSON to a Java object graph. The decoding 049/// is based on the expected type passed to the decode method. 050/// 051/// The conversion rules are as follows: 052/// * If the expected type is a primitive, an array, a {@link Collection}, 053/// a {@link Map} or a {@link JsonValue} (i.e. cannot be a JavaBean) 054/// {@link DeserializationContext#deserialize} is used for the conversion. 055/// * If the expected type is neither of the above, it is assumed 056/// to be a JavaBean and the JSON input must be a JSON object. 057/// The key/value pairs of the JSON input are interpreted as properties 058/// of the JavaBean and set if the values have been parsed successfully. 059/// The type of the properties are used as expected types when 060/// parsing the values. 061/// 062/// Constructors with {@link ConstructorProperties} 063/// are used if all required values are available. Else, if no setter is 064/// available for a key/value pair, an attempt 065/// is made to gain access to a private field with the name of the 066/// key and assign the value to that field. Note that this will fail 067/// when using Java 9 modules unless you explicitly grant the decoder 068/// access to private fields. So defining a constructor with 069/// a {@link ConstructorProperties} annotation and all immutable 070/// properties as parameters is strongly recommended. 071/// 072/// A JSON object can have a "@class" key. It must be the first key 073/// provided by the parser, i.e. the property order strategy must be 074/// lexicographic. Its value is used to instantiate the Java object 075/// in which the information of the JSON object is stored. If 076/// provided, the class specified by this key/value pair overrides 077/// the class passed as expected class. It is checked, however, that the 078/// specified class is assignable to the expected class. 079/// 080/// The value specified is first matched against the aliases that 081/// have been registered with the decoder 082/// (see {@link #addAlias(Class, String)}). If no match is found, 083/// the converter set with {@link #setClassConverter(Function)} 084/// is used to convert the name to a class. The function defaults 085/// to {@link Class#forName(String)}. If the converter does not 086/// return a result, {@link DeserializationContext#deserialize} is 087/// used for the conversion. 088/// 089public class JavaBeanDeserializer extends JavaBeanConverter 090 implements JsonbDeserializer<Object> { 091 092 @SuppressWarnings("PMD.UseConcurrentHashMap") 093 private final Map<String, Class<?>> aliases = new HashMap<>(); 094 private boolean skipUnknown; 095 private Function<String, Optional<Class<?>>> classConverter = name -> { 096 try { 097 return Optional 098 .ofNullable(getClass().getClassLoader().loadClass(name)); 099 } catch (ClassNotFoundException e) { 100 return Optional.empty(); 101 } 102 }; 103 104 /// 105 /// Creates a new instance. 106 /// 107 public JavaBeanDeserializer() { 108 // Nothing to do. 109 } 110 111 /// 112 /// Adds an alias for a class. 113 /// 114 /// @param alias the alias 115 /// @param clazz the clazz 116 /// @return the java bean serializer 117 /// 118 public JavaBeanDeserializer addAlias(Class<?> clazz, String alias) { 119 aliases.put(alias, clazz); 120 return this; 121 } 122 123 /// 124 /// Sets the converter that maps a specified "class" to an actual Java 125 /// {@link Class}. If it does not return a class, a {@link HashMap} is 126 /// used to store the data of the JSON object. 127 /// 128 /// @param converter the converter to use 129 /// @return the conversion result 130 /// 131 @SuppressWarnings("PMD.LinguisticNaming") 132 public JavaBeanDeserializer setClassConverter( 133 Function<String, Optional<Class<?>>> converter) { 134 this.classConverter = converter; 135 return this; 136 } 137 138 /// 139 /// Cause this decoder to silently skip information from the JSON source 140 /// that cannot be mapped to a property of the bean being created. 141 /// This is useful if e.g. a REST invocation returns data that you 142 /// are not interested in and therefore don't want to model in your 143 /// JavaBean. 144 /// 145 /// @return the decoder for chaining 146 /// 147 public JavaBeanDeserializer skipUnknown() { 148 skipUnknown = true; 149 return this; 150 } 151 152 @Override 153 public Object deserialize(JsonParser parser, DeserializationContext ctx, 154 Type rtType) { 155 if (!(rtType instanceof Class<?>)) { 156 return ctx.deserialize(rtType, parser); 157 } 158 Class<?> expected = (Class<?>) rtType; 159 if (expected.isEnum() || expected.isArray() 160 || JsonValue.class.isAssignableFrom(expected) 161 || Map.class.isAssignableFrom(expected) 162 || Collection.class.isAssignableFrom(expected) 163 || Boolean.class.isAssignableFrom(expected) 164 || Byte.class.isAssignableFrom(expected) 165 || Number.class.isAssignableFrom(expected)) { 166 return ctx.deserialize(rtType, parser); 167 } 168 if (parser.next() != Event.START_OBJECT) { 169 throw new JsonbException( 170 parser.getLocation() + ": Expected START_OBJECT"); 171 } 172 Class<?> actualCls = expected; 173 Event prefetched = parser.next(); 174 if (prefetched.equals(Event.KEY_NAME)) { 175 String key = parser.getString(); 176 if ("@class".equals(key)) { 177 prefetched = null; // Now it's consumed 178 parser.next(); 179 var provided = ((JsonString) parser.getValue()).getString(); 180 if (aliases.containsKey(provided)) { 181 actualCls = aliases.get(provided); 182 } else { 183 actualCls = classConverter.apply(provided) 184 .orElse(expected); 185 } 186 } 187 } 188 return objectToBean(parser, ctx, actualCls, prefetched); 189 } 190 191 private <T> T objectToBean(JsonParser parser, DeserializationContext ctx, 192 Class<T> beanCls, Event prefetched) { 193 BeanInfo beanInfo = findBeanInfo(beanCls); 194 if (beanInfo == null) { 195 throw new JsonbException( 196 parser.getLocation() + ": Cannot introspect " + beanCls); 197 } 198 @SuppressWarnings("PMD.UseConcurrentHashMap") 199 Map<String, PropertyDescriptor> beanProps = new HashMap<>(); 200 for (PropertyDescriptor p : beanInfo.getPropertyDescriptors()) { 201 beanProps.put(p.getName(), p); 202 } 203 204 // Get properties as map first. 205 Map<String, Object> propsMap 206 = parseProperties(parser, ctx, beanCls, beanProps, prefetched); 207 208 // Prepare result, using constructor with parameters if available. 209 T result = createBean(parser, beanCls, propsMap); 210 211 // Set (remaining) properties. 212 for (Map.Entry<String, ?> e : propsMap.entrySet()) { 213 PropertyDescriptor property = beanProps.get(e.getKey()); 214 if (property == null) { 215 if (skipUnknown) { 216 continue; 217 } 218 throw new JsonbException(parser.getLocation() 219 + ": No bean property for key " + e.getKey()); 220 } 221 setProperty(parser, result, property, e.getValue()); 222 } 223 return result; 224 } 225 226 private <T> T createBean(JsonParser parser, Class<T> beanCls, 227 Map<String, Object> propsMap) { 228 try { 229 SortedMap<ConstructorProperties, Constructor<T>> cons 230 = new TreeMap<>(Comparator.comparingInt( 231 (ConstructorProperties cp) -> cp.value().length) 232 .reversed()); 233 for (Constructor<?> c : beanCls.getConstructors()) { 234 ConstructorProperties[] allCps = c.getAnnotationsByType( 235 ConstructorProperties.class); 236 if (allCps.length > 0) { 237 @SuppressWarnings("unchecked") 238 Constructor<T> beanConstructor = (Constructor<T>) c; 239 cons.put(allCps[0], beanConstructor); 240 } 241 } 242 for (Map.Entry<ConstructorProperties, Constructor<T>> e : cons 243 .entrySet()) { 244 String[] conProps = e.getKey().value(); 245 if (propsMap.keySet().containsAll(Arrays.asList(conProps))) { 246 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 247 Object[] args = new Object[conProps.length]; 248 for (int i = 0; i < conProps.length; i++) { 249 args[i] = propsMap.remove(conProps[i]); 250 } 251 return e.getValue().newInstance(args); 252 } 253 } 254 255 return beanCls.getDeclaredConstructor().newInstance(); 256 } catch (InstantiationException | IllegalAccessException 257 | IllegalArgumentException | InvocationTargetException 258 | NoSuchMethodException | SecurityException e) { 259 throw new JsonbException(parser.getLocation() 260 + ": Cannot create " + beanCls.getName(), e); 261 } 262 } 263 264 private Map<String, Object> parseProperties(JsonParser parser, 265 DeserializationContext ctx, Class<?> beanCls, 266 Map<String, PropertyDescriptor> beanProps, Event prefetched) { 267 @SuppressWarnings("PMD.UseConcurrentHashMap") 268 Map<String, Object> map = new HashMap<>(); 269 whileLoop: while (true) { 270 @SuppressWarnings("PMD.ConfusingTernary") 271 Event event 272 = (prefetched != null) ? prefetched : parser.next(); 273 prefetched = null; // Consumed. 274 switch (event) { 275 case END_OBJECT: 276 break whileLoop; 277 278 case KEY_NAME: 279 String key = parser.getString(); 280 PropertyDescriptor property = beanProps.get(key); 281 Object value; 282 if (property == null) { 283 value = ctx.deserialize(Object.class, parser); 284 } else { 285 value = ctx.deserialize(property.getPropertyType(), parser); 286 } 287 if (value instanceof String text) { 288 PropertyEditor propertyEditor = findPropertyEditor(beanCls); 289 if (propertyEditor != null) { 290 propertyEditor.setAsText(text); 291 value = propertyEditor.getValue(); 292 } 293 294 } 295 map.put(key, value); 296 break; 297 298 default: 299 throw new JsonbException(parser.getLocation() 300 + ": Unexpected Json event " + event); 301 } 302 } 303 return map; 304 } 305 306 @SuppressWarnings({ "PMD.AvoidAccessibilityAlteration" }) 307 private <T> void setProperty(JsonParser parser, T obj, 308 PropertyDescriptor property, Object value) { 309 try { 310 Method writeMethod = property.getWriteMethod(); 311 if (writeMethod != null) { 312 writeMethod.invoke(obj, value); 313 return; 314 } 315 Field propField = findField(obj.getClass(), property.getName()); 316 if (!propField.canAccess(obj)) { 317 propField.setAccessible(true); 318 } 319 propField.set(obj, value); 320 } catch (IllegalAccessException | IllegalArgumentException 321 | InvocationTargetException | NoSuchFieldException e) { 322 throw new JsonbException(parser.getLocation() 323 + ": Cannot write property " + property.getName(), e); 324 } 325 } 326 327 private Field findField(Class<?> cls, String fieldName) 328 throws NoSuchFieldException { 329 if (cls.equals(Object.class)) { 330 throw new NoSuchFieldException(); 331 } 332 try { 333 return cls.getDeclaredField(fieldName); 334 } catch (NoSuchFieldException e) { 335 return findField(cls.getSuperclass(), fieldName); 336 } 337 } 338 339}