From 5a067ec55b4f7becb86583f4575da52bdba13d7e Mon Sep 17 00:00:00 2001 From: J-N-K Date: Wed, 26 Jan 2022 08:27:25 +0100 Subject: [PATCH] [config] make configuration value parser available for export (#2703) * Refactor and expose `ConfigMapper` to `ConfigParser` Signed-off-by: Jan N. Klug --- .../core/config/core/ConfigParser.java | 243 ++++++++++++++++++ .../core/config/core/Configuration.java | 9 +- .../config/core/internal/ConfigMapper.java | 168 ------------ .../core/config/core/ConfigParserTest.java | 129 ++++++++++ .../core/config/core/ConfigurationTest.java | 12 +- 5 files changed, 385 insertions(+), 176 deletions(-) create mode 100644 bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/ConfigParser.java delete mode 100644 bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/internal/ConfigMapper.java create mode 100644 bundles/org.openhab.core.config.core/src/test/java/org/openhab/core/config/core/ConfigParserTest.java diff --git a/bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/ConfigParser.java b/bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/ConfigParser.java new file mode 100644 index 0000000000..356933ebfb --- /dev/null +++ b/bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/ConfigParser.java @@ -0,0 +1,243 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.config.core; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Map an OSGi configuration map {@code Map} or type-less value to an individual configuration bean or + * typed value. + * + * @author David Graeff - Initial contribution + * @author Jan N. Klug - Extended and refactored to an exposed utility class + * + */ +@NonNullByDefault +public final class ConfigParser { + private static final transient Logger LOGGER = LoggerFactory.getLogger(ConfigParser.class); + private static final Map> WRAPPER_CLASSES_MAP = Map.of(// + "float", Float.class, // + "double", Double.class, // + "long", Long.class, // + "int", Integer.class, // + "short", Short.class, // + "byte", Byte.class, // + "boolean", Boolean.class); + + private ConfigParser() { + // prevent instantiation + } + + /** + * Use this method to automatically map a configuration collection to a Configuration holder object. A common + * use-case would be within a service. Usage example: + * + *
+     * {@code
+     *   public void modified(Map properties) {
+     *     YourConfig config = ConfigParser.configurationAs(properties, YourConfig.class);
+     *   }
+     * }
+     * 
+ * + * + * @param properties The configuration map. + * @param configurationClass The configuration holder class. An instance of this will be created so make sure that + * a default constructor is available. + * @return The configuration holder object. All fields that matched a configuration option are set. If a required + * field is not set, null is returned. + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + public static @Nullable T configurationAs(Map properties, + Class configurationClass) { + T configuration; + try { + configuration = configurationClass.getConstructor().newInstance(); + } catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException e) { + return null; + } + + List fields = getAllFields(configurationClass); + for (Field field : fields) { + // Don't try to write to final fields and ignore transient fields + if (Modifier.isFinal(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) { + continue; + } + String fieldName = field.getName(); + Class type = field.getType(); + + Object value = properties.get(fieldName); + // Consider RequiredField annotations + if (value == null) { + LOGGER.trace("Skipping field '{}', because config has no entry for {}", fieldName, fieldName); + continue; + } + + // Allows to have List, List, List etc (and the corresponding Set) + if (value instanceof Collection) { + Class innerClass = (Class) ((ParameterizedType) field.getGenericType()) + .getActualTypeArguments()[0]; + Collection collection; + if (List.class.isAssignableFrom(type)) { + collection = new ArrayList<>(); + } else if (Set.class.isAssignableFrom(type)) { + collection = new HashSet<>(); + } else { + LOGGER.warn("Skipping field '{}', only List and Set is supported as target Collection", fieldName); + continue; + } + for (final Object it : (Collection) value) { + final Object normalized = valueAs(it, innerClass); + if (normalized == null) { + continue; + } + collection.add(normalized); + } + value = collection; + } + + try { + value = valueAs(value, type); + if (value == null) { + continue; + } + LOGGER.trace("Setting value ({}) {} to field '{}' in configuration class {}", type.getSimpleName(), + value, fieldName, configurationClass.getName()); + field.setAccessible(true); + field.set(configuration, value); + } catch (SecurityException | IllegalArgumentException | IllegalAccessException ex) { + LOGGER.warn("Could not set field value for field '{}': {}", fieldName, ex.getMessage(), ex); + } + } + return configuration; + } + + /** + * Return fields of the given class as well as all super classes. + * + * @param clazz The class + * @return A list of Field objects + */ + private static List getAllFields(Class clazz) { + List fields = new ArrayList<>(); + for (Class superclazz = clazz; superclazz != null; superclazz = superclazz.getSuperclass()) { + fields.addAll(Arrays.asList(superclazz.getDeclaredFields())); + } + return fields; + } + + /** + * Convert a value to a given type or return default value + * + * @param value input value or String representation of that value + * @param type desired target class + * @param defaultValue default value to be used if conversion fails or input value is null + * @return the converted value or the default value if value is null or conversion fails + */ + public static T valueAsOrElse(@Nullable Object value, Class type, T defaultValue) { + return Objects.requireNonNullElse(valueAs(value, type), defaultValue); + } + + /** + * Convert a value to a given type + * + * @param value input value or String representation of that value + * @param type desired target class + * @return the converted value or null if conversion fails or input value is null + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + public static @Nullable T valueAs(@Nullable Object value, Class type) { + if (value == null || type.isAssignableFrom(value.getClass())) { + // exit early if value is null or type is already compatible + return (T) value; + } + + // make sure primitives are converted to their respective wrapper class + Class typeClass = WRAPPER_CLASSES_MAP.getOrDefault(type.getSimpleName(), type); + + Object result = value; + // Handle the conversion case of Number to Float,Double,Long,Integer,Short,Byte,BigDecimal + if (value instanceof Number) { + Number number = (Number) value; + if (Float.class.equals(typeClass)) { + result = number.floatValue(); + } else if (Double.class.equals(typeClass)) { + result = number.doubleValue(); + } else if (Long.class.equals(typeClass)) { + result = number.longValue(); + } else if (Integer.class.equals(typeClass)) { + result = number.intValue(); + } else if (Short.class.equals(typeClass)) { + result = number.shortValue(); + } else if (Byte.class.equals(typeClass)) { + result = number.byteValue(); + } else if (BigDecimal.class.equals(typeClass)) { + result = new BigDecimal(number.toString()); + } + } else if (value instanceof String && !String.class.equals(typeClass)) { + // Handle the conversion case of String to Float,Double,Long,Integer,BigDecimal,Boolean + String strValue = (String) value; + if (Float.class.equals(typeClass)) { + result = Float.valueOf(strValue); + } else if (Double.class.equals(typeClass)) { + result = Double.valueOf(strValue); + } else if (Long.class.equals(typeClass)) { + result = Long.valueOf(strValue); + } else if (Integer.class.equals(typeClass)) { + result = Integer.valueOf(strValue); + } else if (Short.class.equals(typeClass)) { + result = Short.valueOf(strValue); + } else if (Byte.class.equals(typeClass)) { + result = Byte.valueOf(strValue); + } else if (BigDecimal.class.equals(typeClass)) { + result = new BigDecimal(strValue); + } else if (Boolean.class.equals(typeClass)) { + result = Boolean.valueOf(strValue); + } else if (type.isEnum()) { + final Class enumType = (Class) typeClass; + result = Enum.valueOf(enumType, value.toString()); + } else if (Set.class.isAssignableFrom(typeClass)) { + result = Set.of(value); + } else if (Collection.class.isAssignableFrom(typeClass)) { + result = List.of(value); + } + } + + if (result != null && typeClass.isAssignableFrom(result.getClass())) { + return (T) result; + } + + LOGGER.warn("Conversion of value '{}' with type '{}' to '{}' failed. Returning null", value, value.getClass(), + type); + + return null; + } +} diff --git a/bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/Configuration.java b/bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/Configuration.java index 764b0dcb52..54d8369b70 100644 --- a/bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/Configuration.java +++ b/bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/Configuration.java @@ -24,7 +24,6 @@ import java.util.Map; import java.util.Set; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.core.config.core.internal.ConfigMapper; /** * This class is a wrapper for configuration settings of {@link Thing}s. @@ -41,7 +40,7 @@ public class Configuration { private final Map properties; public Configuration() { - this(emptyMap(), true); + this(Map.of(), true); } /** @@ -53,7 +52,7 @@ public class Configuration { * @param configuration the configuration that should be cloned (may be null) */ public Configuration(final @Nullable Configuration configuration) { - this(configuration == null ? emptyMap() : configuration.properties, true); + this(configuration == null ? Map.of() : configuration.properties, true); } /** @@ -62,7 +61,7 @@ public class Configuration { * @param properties the properties the configuration should be filled. If null, an empty configuration is created. */ public Configuration(@Nullable Map properties) { - this(properties == null ? emptyMap() : properties, false); + this(properties == null ? Map.of() : properties, false); } /** @@ -77,7 +76,7 @@ public class Configuration { public T as(Class configurationClass) { synchronized (properties) { - return ConfigMapper.as(properties, configurationClass); + return ConfigParser.configurationAs(properties, configurationClass); } } diff --git a/bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/internal/ConfigMapper.java b/bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/internal/ConfigMapper.java deleted file mode 100644 index 94e9557605..0000000000 --- a/bundles/org.openhab.core.config.core/src/main/java/org/openhab/core/config/core/internal/ConfigMapper.java +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Copyright (c) 2010-2022 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.core.config.core.internal; - -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Modifier; -import java.lang.reflect.ParameterizedType; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.Map; - -import org.eclipse.jdt.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Map an OSGi configuration map {@code Map} to an individual configuration bean. - * - * @author David Graeff - Initial contribution - */ -public class ConfigMapper { - private static final transient Logger LOGGER = LoggerFactory.getLogger(ConfigMapper.class); - - /** - * Use this method to automatically map a configuration collection to a Configuration holder object. A common - * use-case would be within a service. Usage example: - * - *
-     * {@code
-     *   public void modified(Map properties) {
-     *     YourConfig config = ConfigMapper.as(properties, YourConfig.class);
-     *   }
-     * }
-     * 
- * - * - * @param properties The configuration map. - * @param configurationClass The configuration holder class. An instance of this will be created so make sure that - * a default constructor is available. - * @return The configuration holder object. All fields that matched a configuration option are set. If a required - * field is not set, null is returned. - */ - public static @Nullable T as(Map properties, Class configurationClass) { - T configuration = null; - try { - configuration = configurationClass.getConstructor().newInstance(); - } catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException - | IllegalArgumentException | InvocationTargetException e) { - return null; - } - - List fields = getAllFields(configurationClass); - for (Field field : fields) { - // Don't try to write to final fields and ignore transient fields - if (Modifier.isFinal(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) { - continue; - } - String fieldName = field.getName(); - String configKey = fieldName; - Class type = field.getType(); - - Object value = properties.get(configKey); - // Consider RequiredField annotations - if (value == null) { - LOGGER.trace("Skipping field '{}', because config has no entry for {}", fieldName, configKey); - continue; - } - - // Allows to have List, List, List etc - if (value instanceof Collection) { - Collection c = (Collection) value; - Class innerClass = (Class) ((ParameterizedType) field.getGenericType()) - .getActualTypeArguments()[0]; - final List lst = new ArrayList<>(c.size()); - for (final Object it : c) { - final Object normalized = objectConvert(it, innerClass); - lst.add(normalized); - } - value = lst; - } - - try { - value = objectConvert(value, type); - LOGGER.trace("Setting value ({}) {} to field '{}' in configuration class {}", type.getSimpleName(), - value, fieldName, configurationClass.getName()); - field.setAccessible(true); - field.set(configuration, value); - } catch (SecurityException | IllegalArgumentException | IllegalAccessException ex) { - LOGGER.warn("Could not set field value for field '{}': {}", fieldName, ex.getMessage(), ex); - } - } - return configuration; - } - - /** - * Return fields of the given class as well as all super classes. - * - * @param clazz The class - * @return A list of Field objects - */ - private static List getAllFields(Class clazz) { - List fields = new ArrayList<>(); - for (Class superclazz = clazz; superclazz != null; superclazz = superclazz.getSuperclass()) { - fields.addAll(Arrays.asList(superclazz.getDeclaredFields())); - } - return fields; - } - - private static Object objectConvert(Object value, Class type) { - Object result = value; - // Handle the conversion case of BigDecimal to Float,Double,Long,Integer and the respective - // primitive types - String typeName = type.getSimpleName(); - if (value instanceof BigDecimal && !BigDecimal.class.equals(type)) { - BigDecimal bdValue = (BigDecimal) value; - if (Float.class.equals(type) || "float".equals(typeName)) { - result = bdValue.floatValue(); - } else if (Double.class.equals(type) || "double".equals(typeName)) { - result = bdValue.doubleValue(); - } else if (Long.class.equals(type) || "long".equals(typeName)) { - result = bdValue.longValue(); - } else if (Integer.class.equals(type) || "int".equals(typeName)) { - result = bdValue.intValue(); - } - } else - // Handle the conversion case of String to Float,Double,Long,Integer,BigDecimal,Boolean and the respective - // primitive types - if (value instanceof String && !String.class.equals(type)) { - String bdValue = (String) value; - if (Float.class.equals(type) || "float".equals(typeName)) { - result = Float.valueOf(bdValue); - } else if (Double.class.equals(type) || "double".equals(typeName)) { - result = Double.valueOf(bdValue); - } else if (Long.class.equals(type) || "long".equals(typeName)) { - result = Long.valueOf(bdValue); - } else if (BigDecimal.class.equals(type)) { - result = new BigDecimal(bdValue); - } else if (Integer.class.equals(type) || "int".equals(typeName)) { - result = Integer.valueOf(bdValue); - } else if (Boolean.class.equals(type) || "boolean".equals(typeName)) { - result = Boolean.valueOf(bdValue); - } else if (type.isEnum()) { - @SuppressWarnings({ "rawtypes", "unchecked" }) - final Class enumType = (Class) type; - @SuppressWarnings({ "unchecked" }) - final Enum enumvalue = Enum.valueOf(enumType, value.toString()); - result = enumvalue; - } else if (Collection.class.isAssignableFrom(type)) { - result = List.of(value); - } - } - return result; - } -} diff --git a/bundles/org.openhab.core.config.core/src/test/java/org/openhab/core/config/core/ConfigParserTest.java b/bundles/org.openhab.core.config.core/src/test/java/org/openhab/core/config/core/ConfigParserTest.java new file mode 100644 index 0000000000..b0e7af746a --- /dev/null +++ b/bundles/org.openhab.core.config.core/src/test/java/org/openhab/core/config/core/ConfigParserTest.java @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.config.core; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * The {@link ConfigParserTest} contains tests for the {@link ConfigParser} + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class ConfigParserTest { + + private static final List> TEST_PARAMETERS = List.of( // + // float/Float + new TestParameter<>("7.5", float.class, 7.5f), // + new TestParameter<>("-7.5", Float.class, -7.5f), // + new TestParameter<>(-7.5, float.class, -7.5f), // + new TestParameter<>(7.5, Float.class, 7.5f), // + // double/Double + new TestParameter<>("7.5", double.class, 7.5), // + new TestParameter<>("-7.5", Double.class, -7.5), // + new TestParameter<>(-7.5, double.class, -7.5), // + new TestParameter<>(7.5, Double.class, 7.5), // + // long/Long + new TestParameter<>("1", long.class, 1L), // + new TestParameter<>("-1", Long.class, -1L), // + new TestParameter<>(-1, long.class, -1L), // + new TestParameter<>(1, Long.class, 1L), // + // int/Integer + new TestParameter<>("1", int.class, 1), // + new TestParameter<>("-1", Integer.class, -1), // + new TestParameter<>(-1, int.class, -1), // + new TestParameter<>(1, Integer.class, 1), // + // short/Short + new TestParameter<>("1", short.class, (short) 1), // + new TestParameter<>("-1", Short.class, (short) -1), // + new TestParameter<>(-1, short.class, (short) -1), // + new TestParameter<>(1, Short.class, (short) 1), // + // byte/Byte + new TestParameter<>("1", byte.class, (byte) 1), // + new TestParameter<>("-1", Byte.class, (byte) -1), // + new TestParameter<>(-1, byte.class, (byte) -1), // + new TestParameter<>(1, Byte.class, (byte) 1), // + // boolean/Boolean + new TestParameter<>("true", boolean.class, true), // + new TestParameter<>("true", Boolean.class, true), // + new TestParameter<>(false, boolean.class, false), // + new TestParameter<>(false, Boolean.class, false), // + // BigDecimal + new TestParameter<>("7.5", BigDecimal.class, BigDecimal.valueOf(7.5)), // + new TestParameter<>(BigDecimal.valueOf(-7.5), BigDecimal.class, BigDecimal.valueOf(-7.5)), // + new TestParameter<>(1, BigDecimal.class, BigDecimal.ONE), // + // String + new TestParameter<>("foo", String.class, "foo"), // + // Enum + new TestParameter<>("ENUM1", TestEnum.class, TestEnum.ENUM1), // + // List + new TestParameter<>("1", List.class, List.of("1")), // + new TestParameter<>(List.of(1, 2, 3), List.class, List.of(1, 2, 3)), + // Set + new TestParameter<>("1", Set.class, Set.of("1")), // + new TestParameter<>(Set.of(1, 2, 3), Set.class, Set.of(1, 2, 3)), // + // illegal conversion + new TestParameter<>(1, Boolean.class, null), // + // null input + new TestParameter<>(null, Object.class, null) // + ); + + @SuppressWarnings("unused") + private static Stream> valueAsTest() { + return TEST_PARAMETERS.stream(); + } + + @ParameterizedTest + @MethodSource + public void valueAsTest(TestParameter parameter) { + Object result = ConfigParser.valueAs(parameter.input, parameter.type); + Assertions.assertEquals(parameter.result, result, "Failed equals: " + parameter); + } + + @Test + public void valueAsDefaultTest() { + Object result = ConfigParser.valueAsOrElse(null, String.class, "foo"); + Assertions.assertEquals("foo", result); + } + + private enum TestEnum { + ENUM1 + } + + private static class TestParameter { + public final @Nullable Object input; + public final Class type; + public final @Nullable T result; + + public TestParameter(@Nullable Object input, Class type, @Nullable T result) { + this.input = input; + this.type = type; + this.result = result; + } + + @Override + public String toString() { + return "TestParameter{input=" + input + ", type=" + type + ", result=" + result + "}"; + } + } +} diff --git a/bundles/org.openhab.core.config.core/src/test/java/org/openhab/core/config/core/ConfigurationTest.java b/bundles/org.openhab.core.config.core/src/test/java/org/openhab/core/config/core/ConfigurationTest.java index f2748d141b..20d5ba71c6 100644 --- a/bundles/org.openhab.core.config.core/src/test/java/org/openhab/core/config/core/ConfigurationTest.java +++ b/bundles/org.openhab.core.config.core/src/test/java/org/openhab/core/config/core/ConfigurationTest.java @@ -23,6 +23,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.junit.jupiter.api.Test; /** @@ -31,6 +33,7 @@ import org.junit.jupiter.api.Test; * @author Dennis Nobel - Initial contribution * @author Wouter Born - Migrate tests from Groovy to Java */ +@NonNullByDefault public class ConfigurationTest { public static class ConfigClass { @@ -44,14 +47,15 @@ public class ConfigurationTest { public int intField; public boolean booleanField; public String stringField = "somedefault"; - public List listField; + public @NonNullByDefault({}) List listField; + public @NonNullByDefault({}) Set setField; @SuppressWarnings("unused") private static final String CONSTANT = "SOME_CONSTANT"; } public static class ExtendedConfigClass extends ConfigClass { public int additionalIntField; - public String listField; + public @NonNullByDefault({}) String listField; } @Test @@ -62,6 +66,7 @@ public class ConfigurationTest { configuration.put("stringField", "test"); configuration.put("enumField", "ON"); configuration.put("listField", List.of("one", "two", "three")); + configuration.put("setField", List.of("one", "two", "three")); configuration.put("notExisitingProperty", true); ConfigClass configClass = configuration.as(ConfigClass.class); @@ -71,6 +76,7 @@ public class ConfigurationTest { assertThat(configClass.stringField, is("test")); assertThat(configClass.enumField, is(ConfigClass.MyEnum.ON)); assertThat(configClass.listField, is(hasItems("one", "two", "three"))); + assertThat(configClass.setField, is(hasItems("one", "two", "three"))); } @Test @@ -143,7 +149,7 @@ public class ConfigurationTest { @Test public void assertToStringHandlesNullValuesGracefully() { - Map properties = new HashMap<>(); + Map properties = new HashMap<>(); properties.put("stringField", null); Configuration configuration = new Configuration(properties);