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<String, String></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<String, String> and Map<String, List<String>>. 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}