[config] make configuration value parser available for export (#2703)

* Refactor and expose `ConfigMapper` to `ConfigParser`

Signed-off-by: Jan N. Klug <github@klug.nrw>
pull/2709/head
J-N-K 2022-01-26 08:27:25 +01:00 committed by GitHub
parent 59bc10a2af
commit 5a067ec55b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 385 additions and 176 deletions

View File

@ -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<String, Object>} 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<String, Class<?>> 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:
*
* <pre>
* {@code
* public void modified(Map<String, Object> properties) {
* YourConfig config = ConfigParser.configurationAs(properties, YourConfig.class);
* }
* }
* </pre>
*
*
* @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 <T> @Nullable T configurationAs(Map<String, @Nullable Object> properties,
Class<T> configurationClass) {
T configuration;
try {
configuration = configurationClass.getConstructor().newInstance();
} catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException
| IllegalArgumentException | InvocationTargetException e) {
return null;
}
List<Field> 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<int>, List<Double>, List<String> 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<Field> getAllFields(Class<?> clazz) {
List<Field> 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> T valueAsOrElse(@Nullable Object value, Class<T> 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 <T> @Nullable T valueAs(@Nullable Object value, Class<T> 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<? extends Enum> enumType = (Class<? extends Enum>) 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;
}
}

View File

@ -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<String, Object> 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<String, Object> properties) {
this(properties == null ? emptyMap() : properties, false);
this(properties == null ? Map.of() : properties, false);
}
/**
@ -77,7 +76,7 @@ public class Configuration {
public <T> T as(Class<T> configurationClass) {
synchronized (properties) {
return ConfigMapper.as(properties, configurationClass);
return ConfigParser.configurationAs(properties, configurationClass);
}
}

View File

@ -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<String, Object>} 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:
*
* <pre>
* {@code
* public void modified(Map<String, Object> properties) {
* YourConfig config = ConfigMapper.as(properties, YourConfig.class);
* }
* }
* </pre>
*
*
* @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 <T> @Nullable T as(Map<String, Object> properties, Class<T> configurationClass) {
T configuration = null;
try {
configuration = configurationClass.getConstructor().newInstance();
} catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException
| IllegalArgumentException | InvocationTargetException e) {
return null;
}
List<Field> 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<int>, List<Double>, List<String> etc
if (value instanceof Collection) {
Collection<?> c = (Collection<?>) value;
Class<?> innerClass = (Class<?>) ((ParameterizedType) field.getGenericType())
.getActualTypeArguments()[0];
final List<Object> 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<Field> getAllFields(Class<?> clazz) {
List<Field> 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<? extends Enum> enumType = (Class<? extends Enum>) 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;
}
}

View File

@ -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<TestParameter<?>> 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<TestParameter<?>> 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<T> {
public final @Nullable Object input;
public final Class<T> type;
public final @Nullable T result;
public TestParameter(@Nullable Object input, Class<T> 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 + "}";
}
}
}

View File

@ -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<String> listField;
public @NonNullByDefault({}) List<String> listField;
public @NonNullByDefault({}) Set<String> 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<String, Object> properties = new HashMap<>();
Map<String, @Nullable Object> properties = new HashMap<>();
properties.put("stringField", null);
Configuration configuration = new Configuration(properties);