/* * Copyright 2018-present Open Networking Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.atomix.utils.config; import com.google.common.base.Joiner; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.typesafe.config.Config; import com.typesafe.config.ConfigException; import com.typesafe.config.ConfigFactory; import com.typesafe.config.ConfigList; import com.typesafe.config.ConfigMemorySize; import com.typesafe.config.ConfigObject; import com.typesafe.config.ConfigParseOptions; import com.typesafe.config.ConfigValue; import io.atomix.utils.memory.MemorySize; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import static com.google.common.base.Preconditions.checkNotNull; /** * Utility for applying Typesafe configurations to Atomix configuration objects. */ public class ConfigMapper { private static final Logger LOGGER = LoggerFactory.getLogger(ConfigMapper.class); private final ClassLoader classLoader; public ConfigMapper(ClassLoader classLoader) { this.classLoader = classLoader; } /** * Loads the given configuration file using the mapper, falling back to the given resources. * * @param type the type to load * @param files the files to load * @param resources the resources to which to fall back * @param the resulting type * @return the loaded configuration */ public T loadFiles(Class type, List files, List resources) { if (files == null) { return loadResources(type, resources); } Config config = ConfigFactory.systemProperties(); for (File file : files) { config = config.withFallback(ConfigFactory.parseFile(file, ConfigParseOptions.defaults().setAllowMissing(false))); } for (String resource : resources) { config = config.withFallback(ConfigFactory.load(classLoader, resource)); } return map(checkNotNull(config, "config cannot be null").resolve(), type); } /** * Loads the given resources using the configuration mapper. * * @param type the type to load * @param resources the resources to load * @param the resulting type * @return the loaded configuration */ public T loadResources(Class type, String... resources) { return loadResources(type, Arrays.asList(resources)); } /** * Loads the given resources using the configuration mapper. * * @param type the type to load * @param resources the resources to load * @param the resulting type * @return the loaded configuration */ public T loadResources(Class type, List resources) { if (resources == null || resources.isEmpty()) { throw new IllegalArgumentException("resources must be defined"); } Config config = null; for (String resource : resources) { if (config == null) { config = ConfigFactory.load(classLoader, resource); } else { config = config.withFallback(ConfigFactory.load(classLoader, resource)); } } return map(checkNotNull(config, "config cannot be null").resolve(), type); } /** * Applies the given configuration to the given type. * * @param config the configuration to apply * @param clazz the class to which to apply the configuration */ protected T map(Config config, Class clazz) { return map(config, null, null, clazz); } protected T newInstance(Config config, String key, Class clazz) { try { return clazz.newInstance(); } catch (InstantiationException | IllegalAccessException e) { throw new ConfigurationException(clazz.getName() + " needs a public no-args constructor to be used as a bean", e); } } /** * Applies the given configuration to the given type. * * @param config the configuration to apply * @param clazz the class to which to apply the configuration */ @SuppressWarnings("unchecked") protected T map(Config config, String path, String name, Class clazz) { T instance = newInstance(config, name, clazz); // Map config property names to bean properties. Map propertyNames = new HashMap<>(); for (Map.Entry configProp : config.root().entrySet()) { String originalName = configProp.getKey(); String camelName = toCamelCase(originalName); // if a setting is in there both as some hyphen name and the camel name, // the camel one wins if (!propertyNames.containsKey(camelName) || originalName.equals(camelName)) { propertyNames.put(camelName, originalName); } } // First use setters and then fall back to fields. mapSetters(instance, clazz, path, name, propertyNames, config); mapFields(instance, clazz, path, name, propertyNames, config); // If any properties present in the configuration were not found on config beans, throw an exception. if (!propertyNames.isEmpty()) { checkRemainingProperties(propertyNames.keySet(), describeProperties(instance), toPath(path, name), clazz); } return instance; } protected void checkRemainingProperties(Set missingProperties, List availableProperties, String path, Class clazz) { Properties properties = System.getProperties(); List cleanNames = missingProperties.stream() .map(propertyName -> toPath(path, propertyName)) .filter(propertyName -> !properties.containsKey(propertyName)) .filter(propertyName -> properties.entrySet().stream().noneMatch(entry -> entry.getKey().toString().startsWith(propertyName + "."))) .sorted() .collect(Collectors.toList()); if (!cleanNames.isEmpty()) { throw new ConfigurationException("Unknown properties present in configuration: " + Joiner.on(", ").join(cleanNames) + "\n" + "Available properties:\n- " + Joiner.on("\n- ").join(availableProperties)); } } private List describeProperties(Object instance) { Stream setters = getSetterDescriptors(instance.getClass()) .stream() .map(descriptor -> descriptor.name); Stream fields = getFieldDescriptors(instance.getClass()) .stream() .map(descriptor -> descriptor.name); return Stream.concat(setters, fields) .sorted() .collect(Collectors.toList()); } private void mapSetters(T instance, Class clazz, String path, String name, Map propertyNames, Config config) { try { for (SetterDescriptor descriptor : getSetterDescriptors(instance.getClass())) { Method setter = descriptor.setter; Type parameterType = setter.getGenericParameterTypes()[0]; Class parameterClass = setter.getParameterTypes()[0]; String configPropName = propertyNames.remove(descriptor.name); if (configPropName == null) { continue; } Object value = getValue(instance.getClass(), parameterType, parameterClass, config, toPath(path, name), configPropName); if (value != null) { if (descriptor.deprecated) { if (path == null) { LOGGER.warn("{}.{} is deprecated!", name, configPropName); } else { LOGGER.warn("{}.{}.{} is deprecated!", path, name, configPropName); } } setter.invoke(instance, value); } } } catch (IllegalAccessException e) { throw new ConfigurationException(instance.getClass().getName() + " getters and setters are not accessible, they must be for use as a bean", e); } catch (InvocationTargetException e) { throw new ConfigurationException("Calling bean method on " + instance.getClass().getName() + " caused an exception", e); } } private void mapFields(T instance, Class clazz, String path, String name, Map propertyNames, Config config) { try { for (FieldDescriptor descriptor : getFieldDescriptors(instance.getClass())) { Field field = descriptor.field; field.setAccessible(true); Type genericType = field.getGenericType(); Class fieldClass = field.getType(); String configPropName = propertyNames.remove(descriptor.name); if (configPropName == null) { continue; } Object value = getValue(instance.getClass(), genericType, fieldClass, config, toPath(path, name), configPropName); if (value != null) { if (descriptor.deprecated) { LOGGER.warn("{}.{} is deprecated!", path, name); } field.set(instance, value); } } } catch (IllegalAccessException e) { throw new ConfigurationException(instance.getClass().getName() + " fields are not accessible, they must be for use as a bean", e); } } protected Object getValue(Class beanClass, Type parameterType, Class parameterClass, Config config, String configPath, String configPropName) { if (parameterClass == Boolean.class || parameterClass == boolean.class) { try { return config.getBoolean(configPropName); } catch (ConfigException.WrongType e) { return Boolean.parseBoolean(config.getString(configPropName)); } } else if (parameterClass == Integer.class || parameterClass == int.class) { try { return config.getInt(configPropName); } catch (ConfigException.WrongType e) { try { return Integer.parseInt(config.getString(configPropName)); } catch (NumberFormatException e1) { throw e; } } } else if (parameterClass == Double.class || parameterClass == double.class) { try { return config.getDouble(configPropName); } catch (ConfigException.WrongType e) { try { return Double.parseDouble(config.getString(configPropName)); } catch (NumberFormatException e1) { throw e; } } } else if (parameterClass == Long.class || parameterClass == long.class) { try { return config.getLong(configPropName); } catch (ConfigException.WrongType e) { try { return Long.parseLong(config.getString(configPropName)); } catch (NumberFormatException e1) { throw e; } } } else if (parameterClass == String.class) { return config.getString(configPropName); } else if (parameterClass == Duration.class) { return config.getDuration(configPropName); } else if (parameterClass == MemorySize.class) { ConfigMemorySize size = config.getMemorySize(configPropName); return new MemorySize(size.toBytes()); } else if (parameterClass == Object.class) { return config.getAnyRef(configPropName); } else if (parameterClass == List.class || parameterClass == Collection.class) { return getListValue(beanClass, parameterType, parameterClass, config, configPath, configPropName); } else if (parameterClass == Set.class) { return getSetValue(beanClass, parameterType, parameterClass, config, configPath, configPropName); } else if (parameterClass == Map.class) { return getMapValue(beanClass, parameterType, parameterClass, config, configPath, configPropName); } else if (parameterClass == Config.class) { return config.getConfig(configPropName); } else if (parameterClass == ConfigObject.class) { return config.getObject(configPropName); } else if (parameterClass == ConfigValue.class) { return config.getValue(configPropName); } else if (parameterClass == ConfigList.class) { return config.getList(configPropName); } else if (parameterClass == Class.class) { String className = config.getString(configPropName); try { return classLoader.loadClass(className); } catch (ClassNotFoundException e) { throw new ConfigurationException("Failed to load class: " + className); } } else if (parameterClass.isEnum()) { String value = config.getString(configPropName); String enumName = value.replace("-", "_").toUpperCase(); @SuppressWarnings("unchecked") Enum enumValue = Enum.valueOf((Class) parameterClass, enumName); try { Deprecated deprecated = enumValue.getDeclaringClass().getField(enumName).getAnnotation(Deprecated.class); if (deprecated != null) { LOGGER.warn("{}.{} = {} is deprecated!", configPath, configPropName, value); } } catch (NoSuchFieldException e) { } return enumValue; } else { return map(config.getConfig(configPropName), configPath, configPropName, parameterClass); } } protected Map getMapValue(Class beanClass, Type parameterType, Class parameterClass, Config config, String configPath, String configPropName) { Type[] typeArgs = ((ParameterizedType) parameterType).getActualTypeArguments(); Type keyType = typeArgs[0]; Type valueType = typeArgs[1]; Map map = new HashMap<>(); Config childConfig = config.getConfig(configPropName); Class valueClass = (Class) (valueType instanceof ParameterizedType ? ((ParameterizedType) valueType).getRawType() : valueType); for (String key : config.getObject(configPropName).unwrapped().keySet()) { Object value = getValue(Map.class, valueType, valueClass, childConfig, toPath(configPath, configPropName), key); map.put(getKeyValue(keyType, key), value); } return map; } protected Object getKeyValue(Type keyType, String key) { if (keyType == Boolean.class || keyType == boolean.class) { return Boolean.parseBoolean(key); } else if (keyType == Integer.class || keyType == int.class) { return Integer.parseInt(key); } else if (keyType == Double.class || keyType == double.class) { return Double.parseDouble(key); } else if (keyType == Long.class || keyType == long.class) { return Long.parseLong(key); } else if (keyType == String.class) { return key; } else { throw new ConfigurationException("Invalid map key type: " + keyType); } } protected Object getSetValue(Class beanClass, Type parameterType, Class parameterClass, Config config, String configPath, String configPropName) { return new HashSet((List) getListValue(beanClass, parameterType, parameterClass, config, configPath, configPropName)); } protected Object getListValue(Class beanClass, Type parameterType, Class parameterClass, Config config, String configPath, String configPropName) { Type elementType = ((ParameterizedType) parameterType).getActualTypeArguments()[0]; if (elementType instanceof ParameterizedType) { elementType = ((ParameterizedType) elementType).getRawType(); } if (elementType == Boolean.class) { try { return config.getBooleanList(configPropName); } catch (ConfigException.WrongType e) { return config.getStringList(configPropName) .stream() .map(Boolean::parseBoolean) .collect(Collectors.toList()); } } else if (elementType == Integer.class) { try { return config.getIntList(configPropName); } catch (ConfigException.WrongType e) { return config.getStringList(configPropName) .stream() .map(value -> { try { return Integer.parseInt(value); } catch (NumberFormatException e2) { throw e; } }).collect(Collectors.toList()); } } else if (elementType == Double.class) { try { return config.getDoubleList(configPropName); } catch (ConfigException.WrongType e) { return config.getStringList(configPropName) .stream() .map(value -> { try { return Double.parseDouble(value); } catch (NumberFormatException e2) { throw e; } }).collect(Collectors.toList()); } } else if (elementType == Long.class) { try { return config.getLongList(configPropName); } catch (ConfigException.WrongType e) { return config.getStringList(configPropName) .stream() .map(value -> { try { return Long.parseLong(value); } catch (NumberFormatException e2) { throw e; } }).collect(Collectors.toList()); } } else if (elementType == String.class) { return config.getStringList(configPropName); } else if (elementType == Duration.class) { return config.getDurationList(configPropName); } else if (elementType == MemorySize.class) { List sizes = config.getMemorySizeList(configPropName); return sizes.stream() .map(size -> new MemorySize(size.toBytes())) .collect(Collectors.toList()); } else if (elementType == Class.class) { return config.getStringList(configPropName) .stream() .map(className -> { try { return classLoader.loadClass(className); } catch (ClassNotFoundException e) { throw new ConfigurationException("Failed to load class: " + className); } }) .collect(Collectors.toList()); } else if (elementType == Object.class) { return config.getAnyRefList(configPropName); } else if (((Class) elementType).isEnum()) { @SuppressWarnings("unchecked") List enumValues = config.getEnumList((Class) elementType, configPropName); return enumValues; } else { List beanList = new ArrayList<>(); List configList = config.getConfigList(configPropName); int i = 0; for (Config listMember : configList) { beanList.add(map(listMember, toPath(configPath, configPropName), String.valueOf(i), (Class) elementType)); } return beanList; } } protected String toPath(String path, String name) { return path != null ? String.format("%s.%s", path, name) : name; } protected static boolean isSimpleType(Class parameterClass) { return parameterClass == Boolean.class || parameterClass == boolean.class || parameterClass == Integer.class || parameterClass == int.class || parameterClass == Double.class || parameterClass == double.class || parameterClass == Long.class || parameterClass == long.class || parameterClass == String.class || parameterClass == Duration.class || parameterClass == MemorySize.class || parameterClass == List.class || parameterClass == Map.class || parameterClass == Class.class; } protected static String toCamelCase(String originalName) { String[] words = originalName.split("-+"); if (words.length > 1) { LOGGER.warn("Kebab case config name '" + originalName + "' is deprecated!"); StringBuilder nameBuilder = new StringBuilder(originalName.length()); for (String word : words) { if (nameBuilder.length() == 0) { nameBuilder.append(word); } else { nameBuilder.append(word.substring(0, 1).toUpperCase()); nameBuilder.append(word.substring(1)); } } return nameBuilder.toString(); } return originalName; } protected static String toSetterName(String name) { return "set" + name.substring(0, 1).toUpperCase() + name.substring(1); } protected static Collection getSetterDescriptors(Class clazz) { Map descriptors = Maps.newHashMap(); for (Method method : clazz.getMethods()) { String name = method.getName(); if (method.getParameterTypes().length == 1 && name.length() > 3 && "set".equals(name.substring(0, 3)) && name.charAt(3) >= 'A' && name.charAt(3) <= 'Z') { // Strip the "set" prefix from the property name. name = method.getName().substring(3); name = name.length() > 1 ? name.substring(0, 1).toLowerCase() + name.substring(1) : name.toLowerCase(); // Strip the "Config" suffix from the property name. if (name.endsWith("Config")) { name = name.substring(0, name.length() - "Config".length()); } // If a setter with this property name has already been registered, determine whether to override it. // We favor simpler types over more complex types (i.e. beans). SetterDescriptor descriptor = descriptors.get(name); if (descriptor != null) { Class type = method.getParameterTypes()[0]; if (isSimpleType(type)) { descriptors.put(name, new SetterDescriptor(name, method)); } } else { descriptors.put(name, new SetterDescriptor(name, method)); } } } return descriptors.values(); } protected static Collection getFieldDescriptors(Class type) { Class clazz = type; Map descriptors = Maps.newHashMap(); while (clazz != Object.class) { for (Field field : clazz.getDeclaredFields()) { // If the field is static or transient, ignore it. if (Modifier.isTransient(field.getModifiers()) || Modifier.isStatic(field.getModifiers())) { continue; } // If the field has a setter, ignore it and use the setter. Method method = Stream.of(clazz.getMethods()) .filter(m -> m.getName().equals(toSetterName(field.getName()))) .findFirst() .orElse(null); if (method != null) { continue; } // Strip the "Config" suffix from the field. String name = field.getName(); if (name.endsWith("Config")) { name = name.substring(0, name.length() - "Config".length()); } descriptors.putIfAbsent(name, new FieldDescriptor(name, field)); } clazz = clazz.getSuperclass(); } return Lists.newArrayList(descriptors.values()); } protected static class SetterDescriptor { private final String name; private final Method setter; private final boolean deprecated; SetterDescriptor(String name, Method setter) { this.name = name; this.setter = setter; this.deprecated = setter.getAnnotation(Deprecated.class) != null; } } protected static class FieldDescriptor { private final String name; private final Field field; private final boolean deprecated; FieldDescriptor(String name, Field field) { this.name = name; this.field = field; this.deprecated = field.getAnnotation(Deprecated.class) != null; } } }