package org.stagemonitor.configuration; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.core.type.TypeReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.stagemonitor.configuration.converter.BooleanValueConverter; import org.stagemonitor.configuration.converter.DoubleValueConverter; import org.stagemonitor.configuration.converter.EnumValueConverter; import org.stagemonitor.configuration.converter.IntegerValueConverter; import org.stagemonitor.configuration.converter.JsonValueConverter; import org.stagemonitor.configuration.converter.LongValueConverter; import org.stagemonitor.configuration.converter.MapValueConverter; import org.stagemonitor.configuration.converter.OptionalValueConverter; import org.stagemonitor.configuration.converter.RegexValueConverter; import org.stagemonitor.configuration.converter.SetValueConverter; import org.stagemonitor.configuration.converter.StringValueConverter; import org.stagemonitor.configuration.converter.ValueConverter; import org.stagemonitor.configuration.source.ConfigurationSource; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.regex.Pattern; /** * Represents a configuration option * * @param <T> the type of the configuration value */ public class ConfigurationOption<T> { private final Logger logger = LoggerFactory.getLogger(getClass()); private final boolean dynamic; private final boolean sensitive; private final String key; private final List<String> aliasKeys; private final List<String> allKeys; private final String label; private final String description; private final T defaultValue; private final List<String> tags; private final List<Validator<T>> validators; private final List<ChangeListener<T>> changeListeners; private final boolean required; private final String defaultValueAsString; private final String configurationCategory; @JsonIgnore private final ValueConverter<T> valueConverter; private final Class<? super T> valueType; private String valueAsString; private T value; private List<ConfigurationSource> configurationSources; private String nameOfCurrentConfigurationSource; private String errorMessage; private ConfigurationRegistry configuration; public static <T> ConfigurationOptionBuilder<T> builder(ValueConverter<T> valueConverter, Class<? super T> valueType) { return new ConfigurationOptionBuilder<T>(valueConverter, valueType); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link String} * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link String} */ public static ConfigurationOptionBuilder<String> stringOption() { return new ConfigurationOptionBuilder<String>(StringValueConverter.INSTANCE, String.class); } public static <T> ConfigurationOptionBuilder<T> jsonOption(TypeReference<T> typeReference, Class<? super T> clazz) { return new ConfigurationOptionBuilder<T>(new JsonValueConverter<T>(typeReference), clazz); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link Boolean} * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link Boolean} */ public static ConfigurationOptionBuilder<Boolean> booleanOption() { return new ConfigurationOptionBuilder<Boolean>(BooleanValueConverter.INSTANCE, Boolean.class); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link Integer} * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link Integer} */ public static ConfigurationOptionBuilder<Integer> integerOption() { return new ConfigurationOptionBuilder<Integer>(IntegerValueConverter.INSTANCE, Integer.class); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link Long} * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link Long} */ public static ConfigurationOptionBuilder<Long> longOption() { return new ConfigurationOptionBuilder<Long>(LongValueConverter.INSTANCE, Long.class); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link Double} * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link Double} */ public static ConfigurationOptionBuilder<Double> doubleOption() { return new ConfigurationOptionBuilder<Double>(DoubleValueConverter.INSTANCE, Double.class); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link List}<{@link String}> * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link List}<{@link String}> */ public static ConfigurationOptionBuilder<Collection<String>> stringsOption() { return new ConfigurationOptionBuilder<Collection<String>>(SetValueConverter.STRINGS_VALUE_CONVERTER, Collection.class) .defaultValue(Collections.<String>emptySet()); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link List}<{@link String}> * and all Strings are converted to lower case. * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link List}<{@link String}> */ public static ConfigurationOptionBuilder<Collection<String>> lowerStringsOption() { return new ConfigurationOptionBuilder<Collection<String>>(SetValueConverter.LOWER_STRINGS_VALUE_CONVERTER, Collection.class) .defaultValue(Collections.<String>emptySet()); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link Set}<{@link Integer}> * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link Set}<{@link Integer}> */ public static ConfigurationOption.ConfigurationOptionBuilder<Collection<Integer>> integersOption() { return ConfigurationOption.builder(SetValueConverter.INTEGERS, Collection.class); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link List}<{@link Pattern}> * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link List}<{@link Pattern}> */ public static ConfigurationOptionBuilder<Collection<Pattern>> regexListOption() { return new ConfigurationOptionBuilder<Collection<Pattern>>(new SetValueConverter<Pattern>(RegexValueConverter.INSTANCE), Collection.class) .defaultValue(Collections.<Pattern>emptySet()); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link Map}<{@link Pattern}, {@link String}> * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link Map}<{@link Pattern}, {@link String}> */ public static ConfigurationOptionBuilder<Map<Pattern, String>> regexMapOption() { return new ConfigurationOptionBuilder<Map<Pattern, String>>(MapValueConverter.REGEX_MAP_VALUE_CONVERTER, Map.class) .defaultValue(Collections.<Pattern, String>emptyMap()); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is of type {@link Map} * * @return a {@link ConfigurationOptionBuilder} whose value is of type {@link Map} */ public static <K, V> ConfigurationOptionBuilder<Map<K, V>> mapOption(ValueConverter<K> keyConverter, ValueConverter<V> valueConverter) { return new ConfigurationOptionBuilder<Map<K, V>>(new MapValueConverter<K, V>(keyConverter, valueConverter), Map.class) .defaultValue(Collections.<K, V>emptyMap()); } /** * Constructs a {@link ConfigurationOptionBuilder} whose value is an {@link Enum} * * @return a {@link ConfigurationOptionBuilder} whose value is an {@link Enum} */ public static <T extends Enum<T>> ConfigurationOptionBuilder<T> enumOption(Class<T> clazz) { return new ConfigurationOptionBuilder<T>(new EnumValueConverter<T>(clazz), clazz); } private ConfigurationOption(boolean dynamic, boolean sensitive, String key, String label, String description, T defaultValue, String configurationCategory, ValueConverter<T> valueConverter, Class<? super T> valueType, List<String> tags, boolean required, List<ChangeListener<T>> changeListeners, List<Validator<T>> validators, List<String> aliasKeys) { this.dynamic = dynamic; this.key = key; this.aliasKeys = aliasKeys; this.label = label; this.description = description; this.defaultValue = defaultValue; this.tags = tags; this.defaultValueAsString = valueConverter.toString(defaultValue); this.configurationCategory = configurationCategory; this.valueConverter = valueConverter; this.valueType = valueType; this.sensitive = sensitive; this.required = required; this.changeListeners = new ArrayList<ChangeListener<T>>(changeListeners); this.validators = validators; setToDefault(); final ArrayList<String> tempAllKeys = new ArrayList<String>(aliasKeys.size() + 1); tempAllKeys.add(key); tempAllKeys.addAll(aliasKeys); this.allKeys = Collections.unmodifiableList(tempAllKeys); } /** * Returns <code>true</code>, if the value can dynamically be set, <code>false</code> otherwise. * * @return <code>true</code>, if the value can dynamically be set, <code>false</code> otherwise. */ public boolean isDynamic() { return dynamic; } /** * Returns the key of the configuration option that can for example be used in a properties file * * @return the config key */ public String getKey() { return key; } /** * Returns the alternate keys of the configuration option that can for example be used in a properties file * * @return the alternate config keys */ public List<String> getAliasKeys() { return Collections.unmodifiableList(aliasKeys); } /** * Returns the display name of this configuration option * * @return the display name of this configuration option */ public String getLabel() { return label; } /** * Returns the description of the configuration option * * @return the description of the configuration option */ public String getDescription() { return description; } /** * Returns the default value in its string representation * * @return the default value as string */ public String getDefaultValueAsString() { return defaultValueAsString; } /** * Returns the current in its string representation * * @return the current value as string */ public String getValueAsString() { return valueAsString; } /** * Returns <code>true</code>, if the value is sensitive, <code>false</code> otherwise. * If a value has sensitive content (e.g. password), it should be rendered * as an input of type="password", rather then as type="text". * * @return Returns <code>true</code>, if the value is sensitive, <code>false</code> otherwise. */ public boolean isSensitive() { return sensitive; } /** * Returns the current value * * @return the current value */ @JsonIgnore public T getValue() { return value; } /** * Returns the current value * * @return the current value */ @JsonIgnore public T get() { return getValue(); } void setConfigurationSources(List<ConfigurationSource> configurationSources) { this.configurationSources = configurationSources; loadValue(); } void setConfiguration(ConfigurationRegistry configuration) { this.configuration = configuration; } /** * Returns the name of the configuration source that provided the current value * * @return the name of the configuration source that provided the current value */ public String getNameOfCurrentConfigurationSource() { return nameOfCurrentConfigurationSource; } /** * Returns the category name of this configuration option * * @return the category name of this configuration option */ public String getConfigurationCategory() { return configurationCategory; } /** * Returns the tags associated with this configuration option * * @return the tags associated with this configuration option */ public List<String> getTags() { return Collections.unmodifiableList(tags); } /** * Returns the simple type name of the value * * @return the simple type name of the value */ public String getValueType() { return valueType.getSimpleName(); } public ValueConverter<T> getValueConverter() { return valueConverter; } /** * If there was a error while trying to set value from a {@link ConfigurationSource}, this error message contains * information about the error. * * @return a error message or null if there was no error */ public String getErrorMessage() { return errorMessage; } synchronized void reload(boolean reloadNonDynamicValues) { if (dynamic || reloadNonDynamicValues) { loadValue(); } } private void loadValue() { boolean success = false; for (String key : allKeys) { ConfigValueInfo configValueInfo = loadValueFromSources(key); success = trySetValue(configValueInfo); if (success) { break; } } if (!success) { setToDefault(); } } private ConfigValueInfo loadValueFromSources(String key) { for (ConfigurationSource configurationSource : configurationSources) { String newValueAsString = configurationSource.getValue(key); if (newValueAsString != null) { return new ConfigValueInfo(newValueAsString, configurationSource.getName()); } } return new ConfigValueInfo(); } private boolean trySetValue(ConfigValueInfo configValueInfo) { final String newConfigurationSourceName = configValueInfo.getNewConfigurationSourceName(); String newValueAsString = configValueInfo.getNewValueAsString(); if (newValueAsString == null) { return false; } newValueAsString = newValueAsString.trim(); T oldValue = getValue(); if (hasChanges(newValueAsString)) { try { final T newValue = valueConverter.convert(newValueAsString); setValue(newValue, newValueAsString, newConfigurationSourceName); errorMessage = null; if (isInitialized()) { for (ChangeListener<T> changeListener : changeListeners) { try { changeListener.onChange(this, oldValue, getValue()); } catch (RuntimeException e) { logger.warn(e.getMessage() + " (this exception is ignored)", e); } } } return true; } catch (IllegalArgumentException e) { errorMessage = "Error in " + newConfigurationSourceName + ": " + e.getMessage(); logger.warn(errorMessage + " Default value '" + defaultValueAsString + "' for '" + key + "' will be applied."); return false; } } else { return true; } } private void setToDefault() { final String msg = "Missing required value for configuration option " + key; if (isInitialized() && required && defaultValue == null) { handleMissingRequiredValue(msg); } setValue(defaultValue, defaultValueAsString, "Default Value"); } private boolean isInitialized() { return configuration != null; } private void handleMissingRequiredValue(String msg) { if (configuration.isFailOnMissingRequiredValues()) { throw new IllegalStateException(msg); } else { logger.warn(msg); } } private boolean hasChanges(String property) { return !property.equals(valueAsString); } /** * Throws a {@link IllegalArgumentException} if the value is not valid * * @param valueAsString the configuration value as string * @throws IllegalArgumentException if there was a error while converting the value */ public void assertValid(String valueAsString) throws IllegalArgumentException { final T value = valueConverter.convert(valueAsString); for (Validator<T> validator : validators) { validator.assertValid(value); } } /** * Updates the existing value with a new one * * @param newValue the new value * @param configurationSourceName the name of the configuration source that the value should be saved to * @throws IOException if there was an error saving the key to the source * @throws IllegalArgumentException if there was a error processing the configuration key or value or the * configurationSourceName did not match any of the available configuration * sources * @throws UnsupportedOperationException if saving values is not possible with this configuration source */ public void update(T newValue, String configurationSourceName) throws IOException { final String newValueAsString = valueConverter.toString(newValue); configuration.save(key, newValueAsString, configurationSourceName); } private void setValue(T value, String valueAsString, String nameOfCurrentConfigurationSource) { for (Validator<T> validator : validators) { validator.assertValid(value); } this.value = value; this.valueAsString = valueAsString; this.nameOfCurrentConfigurationSource = nameOfCurrentConfigurationSource; } public void addChangeListener(ChangeListener<T> changeListener) { changeListeners.add(changeListener); } public boolean removeChangeListener(ChangeListener<T> changeListener) { return changeListeners.remove(changeListener); } /** * Notifies about configuration changes */ public interface ChangeListener<T> { /** * * @param configurationOption the configuration option which has just changed its value * @param oldValue the old value * @param newValue the new value */ void onChange(ConfigurationOption<?> configurationOption, T oldValue, T newValue); class OptionalChangeListenerAdapter<T> implements ChangeListener<Optional<T>> { private final ChangeListener<T> changeListener; public OptionalChangeListenerAdapter(ChangeListener<T> changeListener) { this.changeListener = changeListener; } @Override public void onChange(ConfigurationOption<?> configurationOption, Optional<T> oldValue, Optional<T> newValue) { changeListener.onChange(configurationOption, oldValue.orElse(null), newValue.orElse(null)); } } } public interface Validator<T> { /** * Validates a value * @param value the value to be validated * @throws IllegalArgumentException if the value is invalid */ void assertValid(T value); class OptionalValidatorAdapter<T> implements Validator<Optional<T>> { private final Validator<T> validator; public OptionalValidatorAdapter(Validator<T> validator) { this.validator = validator; } @Override public void assertValid(Optional<T> value) { validator.assertValid(value.orElse(null)); } } } public static class ConfigurationOptionBuilder<T> { private boolean dynamic = false; private boolean sensitive = false; private String key; private String label; private String description; private T defaultValue; private String configurationCategory; private ValueConverter<T> valueConverter; private Class<? super T> valueType; private String[] tags = new String[0]; private boolean required = false; private List<ChangeListener<T>> changeListeners = new ArrayList<ChangeListener<T>>(); private List<Validator<T>> validators = new ArrayList<Validator<T>>(); private String[] aliasKeys = new String[0]; private ConfigurationOptionBuilder(ValueConverter<T> valueConverter, Class<? super T> valueType) { this.valueConverter = valueConverter; this.valueType = valueType; } /** * Be aware that when using this method you might have to deal with <code>null</code> values when calling {@link * #getValue()}. * <p/> That's why this method is deprecated * * @deprecated use {@link #buildRequired()}, {@link #buildWithDefault(Object)} or {@link #buildOptional()}. The * only valid use of this method is if {@link #buildOptional()} would be the semantically correct option but you * are not using Java 8+. */ @Deprecated public ConfigurationOption<T> build() { return new ConfigurationOption<T>(dynamic, sensitive, key, label, description, defaultValue, configurationCategory, valueConverter, valueType, Arrays.asList(tags), required, Collections.unmodifiableList(changeListeners), Collections.unmodifiableList(validators), Arrays.asList(aliasKeys)); } /** * Builds the option and marks it as required. * <p/> * Use this method if you don't want to provide a default value but setting a value is still required. You * will have to make sure to provide a value is present on startup. * <p/> * When a required option does not have a value the behavior depends on * {@link ConfigurationRegistry#failOnMissingRequiredValues}. Either an {@link IllegalStateException} is raised, * which can potentially prevent the application form starting or a warning gets logged. */ public ConfigurationOption<T> buildRequired() { this.required = true; return build(); } /** * Builds the option with a default value so that {@link ConfigurationOption#getValue()} will never return * <code>null</code> * * @param defaultValue The default value which has to be non-<code>null</code> * @throws IllegalArgumentException When <code>null</code> was provided */ public ConfigurationOption<T> buildWithDefault(T defaultValue) { if (defaultValue == null) { throw new IllegalArgumentException("Default value must not be null"); } this.required = true; this.defaultValue = defaultValue; return build(); } /** * Builds the option and marks it as not required * <p/> * Use this method if setting this option is not required and to express that it may be <code>null</code>. */ public ConfigurationOption<Optional<T>> buildOptional() { required = false; final List<ChangeListener<Optional<T>>> optionalChangeListeners = new ArrayList<ChangeListener<Optional<T>>>(changeListeners.size()); for (ChangeListener<T> changeListener : changeListeners) { optionalChangeListeners.add(new ChangeListener.OptionalChangeListenerAdapter<T>(changeListener)); } final List<Validator<Optional<T>>> optionalValidators = new ArrayList<Validator<Optional<T>>>(validators.size()); for (Validator<T> validator : validators) { optionalValidators.add(new Validator.OptionalValidatorAdapter<T>(validator)); } return new ConfigurationOption<Optional<T>>(dynamic, sensitive, key, label, description, java.util.Optional.ofNullable(defaultValue), configurationCategory, new OptionalValueConverter<T>(valueConverter), java.util.Optional.class, Arrays.asList(this.tags), required, optionalChangeListeners, optionalValidators, Arrays.asList(aliasKeys)); } public ConfigurationOptionBuilder<T> dynamic(boolean dynamic) { this.dynamic = dynamic; return this; } public ConfigurationOptionBuilder<T> key(String key) { this.key = key; return this; } /** * Sets alternate keys of the configuration option which act as an alias for the primary {@link #key(String)} * * @return <code>this</code>, for chaining. */ public ConfigurationOptionBuilder<T> aliasKeys(String... aliasKeys) { this.aliasKeys = aliasKeys; return this; } public ConfigurationOptionBuilder<T> label(String label) { this.label = label; return this; } public ConfigurationOptionBuilder<T> description(String description) { this.description = description; return this; } /** * @deprecated use {@link #buildWithDefault(Object)} */ @Deprecated public ConfigurationOptionBuilder<T> defaultValue(T defaultValue) { this.defaultValue = defaultValue; return this; } public ConfigurationOptionBuilder<T> configurationCategory(String configurationCategory) { this.configurationCategory = configurationCategory; return this; } public ConfigurationOptionBuilder<T> tags(String... tags) { this.tags = tags; return this; } /** * Marks this ConfigurationOption as sensitive. * <p/> * If a value has sensitive content (e.g. password), it should be rendered * as an input of type="password", rather then as type="text". * * @return <code>this</code>, for chaining. */ public ConfigurationOptionBuilder<T> sensitive() { this.sensitive = true; return this; } /** * Marks this option as required. * <p/> * When a required option does not have a value the behavior depends on * {@link ConfigurationRegistry#failOnMissingRequiredValues}. Either an {@link IllegalStateException} is raised, * which can potentially prevent the application form starting or a warning gets logged. * * @return <code>this</code>, for chaining. * @deprecated use {@link #buildRequired()} */ @Deprecated public ConfigurationOptionBuilder<T> required() { this.required = true; return this; } public ConfigurationOptionBuilder<T> addChangeListener(ChangeListener<T> changeListener) { this.changeListeners.add(changeListener); return this; } public ConfigurationOptionBuilder<T> addValidator(Validator<T> validator) { this.validators.add(validator); return this; } } private static class ConfigValueInfo { private String newValueAsString; private String newConfigurationSourceName; private ConfigValueInfo() { } private ConfigValueInfo(String newValueAsString, String newConfigurationSourceName) { this.newValueAsString = newValueAsString; this.newConfigurationSourceName = newConfigurationSourceName; } private String getNewValueAsString() { return newValueAsString; } private String getNewConfigurationSourceName() { return newConfigurationSourceName; } } }