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