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}