001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data;
003
004import java.io.StringReader;
005import java.io.StringWriter;
006import java.lang.annotation.Retention;
007import java.lang.annotation.RetentionPolicy;
008import java.lang.reflect.Field;
009import java.util.ArrayList;
010import java.util.Arrays;
011import java.util.Collection;
012import java.util.Collections;
013import java.util.HashMap;
014import java.util.LinkedHashMap;
015import java.util.List;
016import java.util.Map;
017import java.util.Objects;
018import java.util.Optional;
019import java.util.Set;
020import java.util.stream.Collectors;
021
022import javax.json.Json;
023import javax.json.JsonArray;
024import javax.json.JsonArrayBuilder;
025import javax.json.JsonObject;
026import javax.json.JsonObjectBuilder;
027import javax.json.JsonReader;
028import javax.json.JsonString;
029import javax.json.JsonValue;
030import javax.json.JsonWriter;
031
032import org.openstreetmap.josm.spi.preferences.IPreferences;
033import org.openstreetmap.josm.tools.JosmRuntimeException;
034import org.openstreetmap.josm.tools.Logging;
035import org.openstreetmap.josm.tools.MultiMap;
036import org.openstreetmap.josm.tools.ReflectionUtils;
037import org.openstreetmap.josm.tools.StringParser;
038import org.openstreetmap.josm.tools.Utils;
039
040/**
041 * Utility methods to convert struct-like classes to a string map and back.
042 *
043 * A "struct" is a class that has some fields annotated with {@link StructEntry}.
044 * Those fields will be respected when converting an object to a {@link Map} and back.
045 * @since 12851
046 */
047public final class StructUtils {
048
049    private static final StringParser STRING_PARSER = new StringParser(StringParser.DEFAULT)
050            .registerParser(Map.class, StructUtils::mapFromJson)
051            .registerParser(MultiMap.class, StructUtils::multiMapFromJson);
052
053    private StructUtils() {
054        // hide constructor
055    }
056
057    /**
058     * Annotation used for converting objects to String Maps and vice versa.
059     * Indicates that a certain field should be considered in the conversion process. Otherwise it is ignored.
060     *
061     * @see #serializeStruct
062     * @see #deserializeStruct(java.util.Map, java.lang.Class)
063     */
064    @Retention(RetentionPolicy.RUNTIME) // keep annotation at runtime
065    public @interface StructEntry { }
066
067    /**
068     * Annotation used for converting objects to String Maps.
069     * Indicates that a certain field should be written to the map, even if the value is the same as the default value.
070     *
071     * @see #serializeStruct
072     */
073    @Retention(RetentionPolicy.RUNTIME) // keep annotation at runtime
074    public @interface WriteExplicitly { }
075
076    /**
077     * Get a list of hashes which are represented by a struct-like class.
078     * Possible properties are given by fields of the class klass that have the @StructEntry annotation.
079     * Default constructor is used to initialize the struct objects, properties then override some of these default values.
080     * @param <T> klass type
081     * @param preferences preferences to look up the value
082     * @param key main preference key
083     * @param klass The struct class
084     * @return a list of objects of type T or an empty list if nothing was found
085     */
086    public static <T> List<T> getListOfStructs(IPreferences preferences, String key, Class<T> klass) {
087        return Optional.ofNullable(getListOfStructs(preferences, key, null, klass)).orElseGet(Collections::emptyList);
088    }
089
090    /**
091     * same as above, but returns def if nothing was found
092     * @param <T> klass type
093     * @param preferences preferences to look up the value
094     * @param key main preference key
095     * @param def default value
096     * @param klass The struct class
097     * @return a list of objects of type T or {@code def} if nothing was found
098     */
099    public static <T> List<T> getListOfStructs(IPreferences preferences, String key, Collection<T> def, Class<T> klass) {
100        List<Map<String, String>> prop =
101            preferences.getListOfMaps(key, def == null ? null : serializeListOfStructs(def, klass));
102        if (prop == null)
103            return def == null ? null : new ArrayList<>(def);
104        return prop.stream().map(p -> deserializeStruct(p, klass)).collect(Collectors.toList());
105    }
106
107    /**
108     * Convenience method that saves a MapListSetting which is provided as a collection of objects.
109     *
110     * Each object is converted to a <code>Map&lt;String, String&gt;</code> using the fields with {@link StructEntry} annotation.
111     * The field name is the key and the value will be converted to a string.
112     *
113     * Considers only fields that have the {@code @StructEntry} annotation.
114     * In addition it does not write fields with null values. (Thus they are cleared)
115     * Default values are given by the field values after default constructor has been called.
116     * Fields equal to the default value are not written unless the field has the {@link WriteExplicitly} annotation.
117     * @param <T> the class,
118     * @param preferences the preferences to save to
119     * @param key main preference key
120     * @param val the list that is supposed to be saved
121     * @param klass The struct class
122     * @return true if something has changed
123     */
124    public static <T> boolean putListOfStructs(IPreferences preferences, String key, Collection<T> val, Class<T> klass) {
125        return preferences.putListOfMaps(key, serializeListOfStructs(val, klass));
126    }
127
128    private static <T> List<Map<String, String>> serializeListOfStructs(Collection<T> l, Class<T> klass) {
129        if (l == null)
130            return null;
131        return l.stream().filter(Objects::nonNull)
132                .map(struct -> serializeStruct(struct, klass)).collect(Collectors.toList());
133    }
134
135    /**
136     * Options for {@link #serializeStruct}
137     */
138    public enum SerializeOptions {
139        /**
140         * Serialize {@code null} values
141         */
142        INCLUDE_NULL,
143        /**
144         * Serialize default values
145         */
146        INCLUDE_DEFAULT
147    }
148
149    /**
150     * Convert an object to a String Map, by using field names and values as map key and value.
151     *
152     * The field value is converted to a String.
153     *
154     * Only fields with annotation {@link StructEntry} are taken into account.
155     *
156     * Fields will not be written to the map if the value is null or unchanged
157     * (compared to an object created with the no-arg-constructor).
158     * The {@link WriteExplicitly} annotation overrides this behavior, i.e. the default value will also be written.
159     *
160     * @param <T> the class of the object <code>struct</code>
161     * @param struct the object to be converted
162     * @param klass the class T
163     * @param options optional serialization options
164     * @return the resulting map (same data content as <code>struct</code>)
165     */
166    public static <T> HashMap<String, String> serializeStruct(T struct, Class<T> klass, SerializeOptions... options) {
167        List<SerializeOptions> optionsList = Arrays.asList(options);
168        T structPrototype;
169        try {
170            structPrototype = klass.getConstructor().newInstance();
171        } catch (ReflectiveOperationException ex) {
172            throw new IllegalArgumentException(ex);
173        }
174
175        HashMap<String, String> hash = new LinkedHashMap<>();
176        for (Field f : getDeclaredFieldsInClassOrSuperTypes(klass)) {
177            if (f.getAnnotation(StructEntry.class) == null) {
178                continue;
179            }
180            try {
181                ReflectionUtils.setObjectsAccessible(f);
182                Object fieldValue = f.get(struct);
183                Object defaultFieldValue = f.get(structPrototype);
184                boolean serializeNull = optionsList.contains(SerializeOptions.INCLUDE_NULL) || fieldValue != null;
185                boolean serializeDefault = optionsList.contains(SerializeOptions.INCLUDE_DEFAULT)
186                        || f.getAnnotation(WriteExplicitly.class) != null
187                        || !Objects.equals(fieldValue, defaultFieldValue);
188                if (serializeNull && serializeDefault) {
189                    String key = f.getName().replace('_', '-');
190                    if (fieldValue instanceof Map) {
191                        hash.put(key, mapToJson((Map<?, ?>) fieldValue));
192                    } else if (fieldValue instanceof MultiMap) {
193                        hash.put(key, multiMapToJson((MultiMap<?, ?>) fieldValue));
194                    } else if (fieldValue == null) {
195                        hash.put(key, null);
196                    } else {
197                        hash.put(key, fieldValue.toString());
198                    }
199                }
200            } catch (IllegalAccessException | SecurityException ex) {
201                throw new JosmRuntimeException(ex);
202            }
203        }
204        return hash;
205    }
206
207    /**
208     * Converts a String-Map to an object of a certain class, by comparing map keys to field names of the class and assigning
209     * map values to the corresponding fields.
210     *
211     * The map value (a String) is converted to the field type. Supported types are: boolean, Boolean, int, Integer, double,
212     * Double, String, Map&lt;String, String&gt; and Map&lt;String, List&lt;String&gt;&gt;.
213     *
214     * Only fields with annotation {@link StructEntry} are taken into account.
215     * @param <T> the class
216     * @param hash the string map with initial values
217     * @param klass the class T
218     * @return an object of class T, initialized as described above
219     */
220    public static <T> T deserializeStruct(Map<String, String> hash, Class<T> klass) {
221        T struct = null;
222        try {
223            struct = klass.getConstructor().newInstance();
224        } catch (ReflectiveOperationException ex) {
225            throw new IllegalArgumentException(ex);
226        }
227        for (Map.Entry<String, String> keyValue : hash.entrySet()) {
228            Field f = getDeclaredFieldInClassOrSuperTypes(klass, keyValue.getKey().replace('-', '_'));
229
230            if (f == null || f.getAnnotation(StructEntry.class) == null) {
231                continue;
232            }
233            ReflectionUtils.setObjectsAccessible(f);
234            Object value = STRING_PARSER.parse(f.getType(), keyValue.getValue());
235            try {
236                f.set(struct, value);
237            } catch (IllegalArgumentException ex) {
238                throw new AssertionError(ex);
239            } catch (IllegalAccessException ex) {
240                throw new JosmRuntimeException(ex);
241            }
242        }
243        return struct;
244    }
245
246    private static <T> Field getDeclaredFieldInClassOrSuperTypes(Class<T> clazz, String fieldName) {
247        Class<?> tClass = clazz;
248        do {
249            try {
250                return tClass.getDeclaredField(fieldName);
251            } catch (NoSuchFieldException ex) {
252                Logging.trace(ex);
253            }
254            tClass = tClass.getSuperclass();
255        } while (tClass != null);
256        return null;
257    }
258
259    private static <T> Field[] getDeclaredFieldsInClassOrSuperTypes(Class<T> clazz) {
260        List<Field> fields = new ArrayList<>();
261        Class<?> tclass = clazz;
262        do {
263            Collections.addAll(fields, tclass.getDeclaredFields());
264            tclass = tclass.getSuperclass();
265        } while (tclass != null);
266        return fields.toArray(new Field[] {});
267    }
268
269    @SuppressWarnings("rawtypes")
270    private static String mapToJson(Map map) {
271        StringWriter stringWriter = new StringWriter();
272        try (JsonWriter writer = Json.createWriter(stringWriter)) {
273            JsonObjectBuilder object = Json.createObjectBuilder();
274            for (Object o: map.entrySet()) {
275                Map.Entry e = (Map.Entry) o;
276                Object evalue = e.getValue();
277                object.add(e.getKey().toString(), evalue.toString());
278            }
279            writer.writeObject(object.build());
280        }
281        return stringWriter.toString();
282    }
283
284    @SuppressWarnings({ "rawtypes", "unchecked" })
285    private static Map mapFromJson(String s) {
286        Map ret = null;
287        try (JsonReader reader = Json.createReader(new StringReader(s))) {
288            JsonObject object = reader.readObject();
289            ret = new HashMap(Utils.hashMapInitialCapacity(object.size()));
290            for (Map.Entry<String, JsonValue> e: object.entrySet()) {
291                JsonValue value = e.getValue();
292                if (value instanceof JsonString) {
293                    // in some cases, when JsonValue.toString() is called, then additional quotation marks are left in value
294                    ret.put(e.getKey(), ((JsonString) value).getString());
295                } else {
296                    ret.put(e.getKey(), e.getValue().toString());
297                }
298            }
299        }
300        return ret;
301    }
302
303    @SuppressWarnings("rawtypes")
304    private static String multiMapToJson(MultiMap map) {
305        StringWriter stringWriter = new StringWriter();
306        try (JsonWriter writer = Json.createWriter(stringWriter)) {
307            JsonObjectBuilder object = Json.createObjectBuilder();
308            for (Object o: map.entrySet()) {
309                Map.Entry e = (Map.Entry) o;
310                Set evalue = (Set) e.getValue();
311                JsonArrayBuilder a = Json.createArrayBuilder();
312                for (Object evo: evalue) {
313                    a.add(evo.toString());
314                }
315                object.add(e.getKey().toString(), a.build());
316            }
317            writer.writeObject(object.build());
318        }
319        return stringWriter.toString();
320    }
321
322    @SuppressWarnings({ "rawtypes", "unchecked" })
323    private static MultiMap multiMapFromJson(String s) {
324        MultiMap ret = null;
325        try (JsonReader reader = Json.createReader(new StringReader(s))) {
326            JsonObject object = reader.readObject();
327            ret = new MultiMap(object.size());
328            for (Map.Entry<String, JsonValue> e: object.entrySet()) {
329                JsonValue value = e.getValue();
330                if (value instanceof JsonArray) {
331                    for (JsonString js: ((JsonArray) value).getValuesAs(JsonString.class)) {
332                        ret.put(e.getKey(), js.getString());
333                    }
334                } else if (value instanceof JsonString) {
335                    // in some cases, when JsonValue.toString() is called, then additional quotation marks are left in value
336                    ret.put(e.getKey(), ((JsonString) value).getString());
337                } else {
338                    ret.put(e.getKey(), e.getValue().toString());
339                }
340            }
341        }
342        return ret;
343    }
344}