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.JsonValue;
022import jakarta.json.bind.annotation.JsonbAnnotation;
023import jakarta.json.bind.serializer.JsonbSerializer;
024import jakarta.json.bind.serializer.SerializationContext;
025import jakarta.json.stream.JsonGenerator;
026import java.beans.BeanInfo;
027import java.beans.PropertyDescriptor;
028import java.beans.PropertyEditor;
029import java.lang.reflect.InvocationTargetException;
030import java.lang.reflect.Method;
031import java.util.Arrays;
032import java.util.Collection;
033import java.util.HashMap;
034import java.util.HashSet;
035import java.util.Map;
036import java.util.Optional;
037import java.util.Set;
038
039///
040/// A serializer that treats all objects as JavaBeans. The JSON
041/// description of an object can have an additional key/value pair with
042/// key "@class" and a class name. This class information is generated
043/// only if it is needed, i.e. if it cannot be derived from the containing
044/// object.
045/// 
046/// Given the following classes:
047/// 
048/// ```java
049/// public static class Person {
050/// 
051///     private String name;
052///     private int age;
053///     private PhoneNumber[] numbers;
054/// 
055///     public String getName() {
056///         return name;
057///     }
058///     
059///     public void setName(String name) {
060///         this.name = name;
061///     }
062///     
063///     public int getAge() {
064///         return age;
065///     }
066///     
067///     public void setAge(int age) {
068///         this.age = age;
069///     }
070///     
071///     public PhoneNumber[] getNumbers() {
072///         return numbers;
073///     }
074///     
075///     public void setNumbers(PhoneNumber[] numbers) {
076///         this.numbers = numbers;
077///     }
078/// }
079/// 
080/// public static class PhoneNumber {
081///     private String name;
082///     private String number;
083/// 
084///     public PhoneNumber() {
085///     }
086///     
087///     public String getName() {
088///         return name;
089///     }
090///     
091///     public void setName(String name) {
092///         this.name = name;
093///     }
094///     
095///     public String getNumber() {
096///         return number;
097///     }
098///     
099///     public void setNumber(String number) {
100///         this.number = number;
101///     }
102/// }
103/// 
104/// public static class SpecialNumber extends PhoneNumber {
105/// }
106/// ```
107/// 
108/// A serialization result may look like this:
109/// 
110/// ```jsonSample1
111/// {
112///     "age": 42,
113///     "name": "Simon Sample",
114///     "numbers": [
115///         {
116///             "name": "Home",
117///             "number": "06751 51 56 57"
118///         },
119///         {
120///             "@class": "test.json.SpecialNumber",
121///             "name": "Work",
122///             "number": "030 77 35 44"
123///         }
124///     ]
125/// } 
126/// ```
127///
128/// As there is no marker interface for JavaBeans, this serializer
129/// considers all objects to be JavaBeans by default and obtains all
130/// information for serialization from the generated (or explicitly
131/// provided) {@link BeanInfo}. It is, however, possible to exclude
132/// classes from bing handled by this serializer by either annotating them
133/// with {@link JsonbAnnotation} or 
134///
135public class JavaBeanSerializer extends JavaBeanConverter
136        implements JsonbSerializer<Object> {
137
138    private static record Expected(Object[] values, Class<?> type) {
139    }
140
141    private ThreadLocal<Expected> expected = new ThreadLocal<>();
142    private boolean omitClass;
143    private final Map<Class<?>, String> aliases = new HashMap<>();
144    private final Set<Class<?>> ignored = new HashSet<>();
145
146    /// Create a new instance.
147    public JavaBeanSerializer() {
148        // Can be instantiated
149    }
150
151    ///
152    /// Adds an alias for a class.
153    ///
154    /// @param clazz the clazz
155    /// @param alias the alias
156    /// @return the java bean serializer
157    ///
158    public JavaBeanSerializer addAlias(Class<?> clazz, String alias) {
159        aliases.put(clazz, alias);
160        return this;
161    }
162
163    ///
164    /// Sets the expected class for the object passed as parameter
165    /// to {@link jakarta.json.bind.Jsonb#toJson}.
166    ///
167    /// @param type the type
168    /// @return the java bean serializer
169    ///
170    public JavaBeanSerializer setExpected(Class<?> type) {
171        expected.set(new Expected(null, type));
172        return this;
173    }
174
175    ///
176    /// Don't handle the given type(s) as JavaBeans. Pass them to the
177    /// default serialization mechanism instead.
178    ///
179    /// @param type the type(s) to ignore
180    /// @return the java bean serializer
181    ///
182    public JavaBeanSerializer addIgnored(Class<?>... type) {
183        ignored.addAll(Arrays.asList(type));
184        return this;
185    }
186
187    @Override
188    public void serialize(Object value, JsonGenerator generator,
189            SerializationContext context) {
190        if (value == null
191            || value instanceof Boolean
192            || value instanceof Byte
193            || value instanceof Number
194            || value.getClass().isArray()
195            || value instanceof Collection<?>
196            || value instanceof Map<?, ?>
197            || value instanceof JsonValue
198            || value.getClass().getAnnotation(JsonbAnnotation.class) != null
199            || ignored.contains(value.getClass())) {
200            context.serialize(value, generator);
201            return;
202        }
203        PropertyEditor propertyEditor = findPropertyEditor(value.getClass());
204        if (propertyEditor != null) {
205            propertyEditor.setValue(value);
206            generator.write(propertyEditor.getAsText());
207            return;
208        }
209        BeanInfo beanInfo = findBeanInfo(value.getClass());
210        if (beanInfo != null && beanInfo.getPropertyDescriptors().length > 0) {
211            writeJavaBean(value, beanInfo, generator, context);
212            return;
213        }
214    }
215
216    @SuppressWarnings("PMD.EmptyCatchBlock")
217    private void writeJavaBean(Object bean, BeanInfo beanInfo,
218            JsonGenerator generator, SerializationContext context) {
219        generator.writeStartObject();
220        Class<?> expectedType = Optional.ofNullable(expected.get())
221            .filter(e -> e.values() == null
222                || Arrays.stream(e.values()).anyMatch(v -> v == bean))
223            .map(e -> e.type()).orElse(null);
224        if (expectedType != null && !bean.getClass().equals(expectedType)
225            && !omitClass) {
226            context.serialize("@class", aliases.computeIfAbsent(
227                bean.getClass(), k -> k.getName()), generator);
228        }
229        for (PropertyDescriptor propDesc : beanInfo
230            .getPropertyDescriptors()) {
231            if (propDesc.getValue("transient") != null) {
232                continue;
233            }
234            Method method = propDesc.getReadMethod();
235            if (method == null) {
236                continue;
237            }
238            try {
239                Object propValue = method.invoke(bean);
240                Expected old = expected.get();
241                try {
242                    if (propDesc.getPropertyType().isArray()) {
243                        expected.set(new Expected((Object[]) propValue,
244                            propDesc.getPropertyType().componentType()));
245                    } else {
246                        expected.set(new Expected(new Object[] { propValue },
247                            propDesc.getPropertyType()));
248                    }
249                    context.serialize(propDesc.getName(), propValue, generator);
250                } finally {
251                    expected.set(old);
252                }
253                continue;
254            } catch (IllegalAccessException | IllegalArgumentException
255                    | InvocationTargetException e) {
256                // Bad luck
257            }
258        }
259        generator.writeEnd();
260    }
261
262}