001/*
002 * This file is part of the JDrupes JSON utilities project.
003 * Copyright (C) 2017, 2018  Michael N. Lipp
004 *
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Lesser General Public License as published
007 * by the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 
013 * License for more details.
014 *
015 * You should have received a copy of the GNU Lesser General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.json;
020
021import com.fasterxml.jackson.core.JsonGenerator;
022
023import java.beans.BeanInfo;
024import java.beans.PropertyDescriptor;
025import java.beans.PropertyEditor;
026import java.beans.Transient;
027import java.io.Closeable;
028import java.io.Flushable;
029import java.io.IOException;
030import java.io.StringWriter;
031import java.io.Writer;
032import java.lang.reflect.Array;
033import java.lang.reflect.InvocationTargetException;
034import java.lang.reflect.Method;
035import java.math.BigDecimal;
036import java.math.BigInteger;
037import java.util.Collection;
038import java.util.HashMap;
039import java.util.HashSet;
040import java.util.Map;
041import java.util.Set;
042
043/**
044 * Encoder for converting a Java object graph to JSON. Objects may be arrays,
045 * collections, maps and JavaBeans.
046 * 
047 * Arrays and collections are converted to JSON arrays. The
048 * type information is lost. Maps and JavaBeans are converted
049 * to JSON objects.
050 * 
051 * The generated JSON objects can have an additional key/value pair with
052 * key "class" and a class name. The class information is generated
053 * only if it is needed, i.e. if it cannot be derived from the containing
054 * object.
055 * 
056 * Given the following classes:
057 *
058 * ```java
059 * public static class Person {
060 *
061 *     private String name;
062 *     private int age;
063 *     private PhoneNumber[] numbers;
064 *
065 *     public String getName() {
066 *         return name;
067 *     }
068 *     
069 *     public void setName(String name) {
070 *         this.name = name;
071 *     }
072 *     
073 *     public int getAge() {
074 *         return age;
075 *     }
076 *     
077 *     public void setAge(int age) {
078 *         this.age = age;
079 *     }
080 *     
081 *     public PhoneNumber[] getNumbers() {
082 *         return numbers;
083 *     }
084 *     
085 *     public void setNumbers(PhoneNumber[] numbers) {
086 *         this.numbers = numbers;
087 *     }
088 * }
089 *
090 * public static class PhoneNumber {
091 *     private String name;
092 *     private String number;
093 *
094 *     public PhoneNumber() {
095 *     }
096 *     
097 *     public String getName() {
098 *         return name;
099 *     }
100 *     
101 *     public void setName(String name) {
102 *         this.name = name;
103 *     }
104 *     
105 *     public String getNumber() {
106 *         return number;
107 *     }
108 *     
109 *     public void setNumber(String number) {
110 *         this.number = number;
111 *     }
112 * }
113 *
114 * public static class SpecialNumber extends PhoneNumber {
115 * }
116 * ```
117 * 
118 * A serialization result may look like this:
119 * 
120 * ```json
121 * {
122 *     "age": 42,
123 *     "name": "Simon Sample",
124 *     "numbers": [
125 *         {
126 *             "name": "Home",
127 *             "number": "06751 51 56 57"
128 *         },
129 *         {
130 *             "class": "test.json.SpecialNumber",
131 *             "name": "Work",
132 *             "number": "030 77 35 44"
133 *         }
134 *     ]
135 * } 
136 * ```
137 * 
138 * 
139 */
140public class JsonBeanEncoder extends JsonCodec
141        implements Flushable, Closeable {
142
143    private static final Set<String> EXCLUDED_DEFAULT = new HashSet<>();
144
145    static {
146        // See https://issues.apache.org/jira/browse/GROOVY-8284
147        EXCLUDED_DEFAULT.add("groovy.lang.MetaClass");
148    }
149
150    private Map<Class<?>, String> aliases = new HashMap<>();
151    private Set<String> excluded = EXCLUDED_DEFAULT;
152    private boolean omitClass;
153    private JsonGenerator gen;
154    private StringWriter writer = null;
155
156    @Override
157    public JsonBeanEncoder addAlias(Class<?> clazz, String alias) {
158        aliases.put(clazz, alias);
159        return this;
160    }
161
162    /**
163     * Configure the encoder to not generate the `class` information
164     * even when needed to properly restore the Object graph.
165     * 
166     * While this contradicts the initial objective to provide JSON
167     * persistence for JavaBeans, this is a valid option if the generated
168     * JSON is used for transferring information to an environment where
169     * the information provided by `class` isn't useful.    
170     * 
171     * @return the encoder for easy chaining
172     */
173    public JsonBeanEncoder omitClass() {
174        omitClass = true;
175        return this;
176    }
177
178    /**
179     * Add a type to excude from encoding, usually because it cannot
180     * be converted to JSON. Properties of such types should be
181     * marked as {@link Transient}. However, sometimes base types
182     * don't follow the rules.
183     * 
184     * @param className
185     * @return the encoder for easy chaining
186     */
187    public JsonBeanEncoder addExcluded(String className) {
188        if (excluded == EXCLUDED_DEFAULT) {
189            excluded = new HashSet<>(EXCLUDED_DEFAULT);
190        }
191        excluded.add(className);
192        return this;
193    }
194
195    /**
196     * Create a new encoder using a default {@link JsonGenerator}. 
197     * 
198     * @param out the sink
199     * @return the encoder
200     */
201    public static JsonBeanEncoder create(Writer out) {
202        try {
203            return new JsonBeanEncoder(defaultFactory().createGenerator(out));
204        } catch (IOException e) {
205            throw new IllegalArgumentException();
206        }
207    }
208
209    /**
210     * Create a new encoder using a default {@link JsonGenerator}
211     * that writes to an internally created {@link StringWriter}. 
212     * The result can be obtained by invoking {@link #toJson()}.
213     * 
214     * @return the encoder
215     */
216    public static JsonBeanEncoder create() {
217        return new JsonBeanEncoder();
218    }
219
220    /**
221     * Create a new encoder using the given {@link JsonGenerator}. 
222     * 
223     * @param generator the generator
224     * @return the encoder
225     */
226    public static JsonBeanEncoder create(JsonGenerator generator) {
227        return new JsonBeanEncoder(generator);
228    }
229
230    private JsonBeanEncoder() {
231        writer = new StringWriter();
232        try {
233            gen = defaultFactory().createGenerator(writer);
234        } catch (IOException e) {
235            throw new IllegalArgumentException();
236        }
237    }
238
239    private JsonBeanEncoder(JsonGenerator generator) {
240        gen = generator;
241    }
242
243    @Override
244    public void flush() throws IOException {
245        gen.flush();
246    }
247
248    @Override
249    public void close() throws IOException {
250        gen.close();
251    }
252
253    /**
254     * Returns the text written to the output. Can only be used
255     * if the encoder has been created with {@link #JsonBeanEncoder()}.
256     * 
257     * @return the result
258     */
259    public String toJson() {
260        if (writer == null) {
261            throw new IllegalStateException(
262                "JsonBeanEncoder has been created without a known writer.");
263        }
264        try {
265            gen.flush();
266        } catch (IOException e) {
267            throw new IllegalStateException(e);
268        }
269        return writer.toString();
270    }
271
272    public JsonBeanEncoder writeArray(Object... items) throws IOException {
273        doWriteObject(items, items.getClass());
274        return this;
275    }
276
277    public JsonBeanEncoder writeObject(Object obj) throws IOException {
278        doWriteObject(obj, obj.getClass());
279        return this;
280    }
281
282    private void doWriteObject(Object obj, Class<?> expectedType)
283            throws IOException {
284        if (obj == null) {
285            gen.writeNull();
286            return;
287        }
288        if (obj instanceof Boolean) {
289            gen.writeBoolean((Boolean) obj);
290            return;
291        }
292        if (obj instanceof Byte) {
293            gen.writeNumber(((Byte) obj).intValue());
294            return;
295        }
296        if (obj instanceof Number) {
297            if (obj instanceof Short) {
298                gen.writeNumber((Short) obj);
299                return;
300            }
301            if (obj instanceof Integer) {
302                gen.writeNumber((Integer) obj);
303                return;
304            }
305            if (obj instanceof Long) {
306                gen.writeNumber((Long) obj);
307                return;
308            }
309            if (obj instanceof BigInteger) {
310                gen.writeNumber((BigInteger) obj);
311                return;
312            }
313            if (obj instanceof BigDecimal) {
314                gen.writeNumber((BigDecimal) obj);
315                return;
316            }
317            gen.writeNumber((Double) obj);
318            return;
319        }
320        PropertyEditor propertyEditor = findPropertyEditor(obj.getClass());
321        if (propertyEditor != null) {
322            propertyEditor.setValue(obj);
323            gen.writeString(propertyEditor.getAsText());
324            return;
325        }
326        if (obj.getClass().isArray()) {
327            gen.writeStartArray();
328            Class<?> compType = null;
329            if (expectedType != null && expectedType.isArray()) {
330                compType = expectedType.getComponentType();
331            }
332            for (int i = 0; i < Array.getLength(obj); i++) {
333                doWriteObject(Array.get(obj, i), compType);
334            }
335            gen.writeEndArray();
336            ;
337            return;
338        }
339        if (obj instanceof Collection) {
340            gen.writeStartArray();
341            for (Object item : (Collection<?>) obj) {
342                doWriteObject(item, null);
343            }
344            gen.writeEndArray();
345            return;
346        }
347        if (obj instanceof Map) {
348            @SuppressWarnings("unchecked")
349            Map<String, Object> map = (Map<String, Object>) obj;
350            gen.writeStartObject();
351            for (Map.Entry<String, Object> e : map.entrySet()) {
352                gen.writeFieldName(e.getKey());
353                doWriteObject(e.getValue(), null);
354            }
355            gen.writeEndObject();
356            return;
357        }
358        BeanInfo beanInfo = findBeanInfo(obj.getClass());
359        if (beanInfo != null && beanInfo.getPropertyDescriptors().length > 0) {
360            gen.writeStartObject();
361            if (!obj.getClass().equals(expectedType) && !omitClass) {
362                gen.writeStringField("class", aliases.computeIfAbsent(
363                    obj.getClass(), k -> k.getName()));
364            }
365            for (PropertyDescriptor propDesc : beanInfo
366                .getPropertyDescriptors()) {
367                if (propDesc.getValue("transient") != null) {
368                    continue;
369                }
370                if (excluded.contains(propDesc.getPropertyType().getName())) {
371                    continue;
372                }
373                Method method = propDesc.getReadMethod();
374                if (method == null) {
375                    continue;
376                }
377                try {
378                    Object value = method.invoke(obj);
379                    gen.writeFieldName(propDesc.getName());
380                    doWriteObject(value, propDesc.getPropertyType());
381                    continue;
382                } catch (IllegalAccessException | IllegalArgumentException
383                        | InvocationTargetException e) {
384                    // Bad luck
385                }
386            }
387            gen.writeEndObject();
388            return;
389        }
390        // Last resort
391        gen.writeString(obj.toString());
392    }
393
394}