com.ocadotechnology.config.Config Maven / Gradle / Ivy
/*
* Copyright © 2017-2023 Ocado (Ocava)
*
* 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 com.ocadotechnology.config;
import java.io.Serializable;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Stream;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.ocadotechnology.physics.units.LengthUnit;
/**
* Ocava Configuration is a properties file parser where keys are backed by an Enum.
* Basic Usage
* The Enum for Config can contain nested Enumeration, i.e.
*
* public Enum MyConfig {
* ROOT_KEY;
* public Enum MyChildConfig {
* CHILD_KEY,
* SECOND_KEY;
* ...
* }
* }
*
*
* Each Key in the properties file ends with a Enum value (i.e. CHILD_KEY) and is prefixed by each class dot separated
* (i.e. MyConfig.MyChildConfig.CHILD_KEY).
*
* So a simple example properties file could look like:
*
* MyConfig.ROOT_KEY=A root key
* MyConfig.MyChildConfig.CHILD_KEY=1, SECONDS
* MyConfig.MyChildConfig.SECOND_KEY=false
*
* Data Types
* The configuration object has support for many data types (some can be seen in the example) some of these
* data types are (For the full list inspect the value parsers {@link StrictValueParser} and {@link OptionalValueParser}):
*
* - String
* - Numeric - Integer, Long, Double
* - Boolean
* - Time - Either as Long, Double or {@link Duration}
* - Speed - See {@link StrictValueParser#asSpeed()}
* - Acceleration - See {@link StrictValueParser#asAcceleration()}
* - Enum - See {@link StrictValueParser#asEnum(Class)}
* - List - See {@link StrictValueParser#asList()} and {@link ListValueParser}
* - Set - See {@link StrictValueParser#asSet()} and {@link SetValueParser}
* - Map - See {@link StrictValueParser#asMap()} and {@link MapValueParser}
* - Multimap - See {@link StrictValueParser#asSetMultimap()} and {@link SetMultimapValueParser}
*
*
* {@link #getValue} vs {@link #getIfValueDefined} and {@link #getIfKeyAndValueDefined}
* {@link #getValue} requires that the config key is present in the object, and will throw an exception if it is not. In
* some use-cases, the idea of querying the presence of a config key may seem useful, but can lead to complications due
* to the way the configuration library works by layering multiple configuration on top of each other.
*
* If the presence of a config value is used to change the flow of logic (i.e. a config flag) it might be that you wish
* to override it's presence by command line or specific additional config file. However once a config key is present it
* cannot be removed.
*
* To overcome this limitation, the method {@link #getIfKeyAndValueDefined} treats a key which maps to an empty string
* the same as a key which is entirely absent, returning {@link Optional#empty()} from all parsers in that case. {@link
* #getIfValueDefined} works similarly for an empty string, but requires that the key be present in the object, for
* those users who which to ensure that a config value was deliberately removed.
*
*
Prefixes
* Additionally to the configuration Object and the different data types it can handle there is also the concept of
* prefixes.
*
* Prefixes allow for multiple layers for a single config key, or for a single config key to be defined multiple times.
* Taking the configuration enum example from above we could write a properties file like:
*
* MyConfig.ROOT_KEY=root_key
* [email protected]_KEY=site1_key
* site1@[email protected]_KEY=node1_key
* [email protected]_KEY=site2_key
*
*
* With the above when we want to get the root key for say a specific site we can use use
* {@link #getPrefixedConfigItems(String)} and then call {@link #getValue(Enum)}
*
*
* var siteConfig = config.getPrefixedConfigItems("site10");
* var key = siteConfig.getValue(MyConfig.ROOT_KEY).asString();
*
*
* Note that in the properties file site10 is not one of the listed prefixes, in this case it falls back to the default
* value of "root_key". Prefixes can be nested, so it would be possible to do:
*
*
* var nodeConfig = config.getPrefixedConfigItems("site1").getPrefixedConfigItems("node1");
* var key = nodeConfig.getValue(MyConfig.ROOT_KEY).asString();
*
*
* In the above case key would equal node1_key, as the prefix site1 exists and the prefix node1 exists under site1
*
* @param Configuration Enumeration containing all allowed property keys
*/
public class Config> implements Serializable, Comparable> {
private static final long serialVersionUID = 1L;
public final Class cls;
private final ImmutableMap values;
private final ImmutableMap, Config>> subConfig;
private final String qualifier;
private final TimeUnit timeUnit;
private final LengthUnit lengthUnit;
private Config(
Class cls,
ImmutableMap values,
ImmutableMap, Config>> subConfig,
String qualifier,
TimeUnit timeUnit,
LengthUnit lengthUnit) {
this.cls = cls;
this.values = values;
this.subConfig = subConfig;
this.qualifier = qualifier;
this.timeUnit = timeUnit;
this.lengthUnit = lengthUnit;
}
Config(
Class cls,
ImmutableMap values,
ImmutableMap, Config>> subConfig,
String qualifier) {
this(cls, values, subConfig, qualifier, null, null);
}
/**
* Returns a new Config that has entered one layer into the prefix config values.
* This means that when multi-prefix values are being used,
* the sub-prefixes can now be accessed below the given prefix.
* For each config item, it will set the value to be that of the prefix value if present,
* otherwise it will keep its original value.
*
* @param prefix The prefix to use for the current value / the prefix tree to use for multi-prefix configs
* @return a new Config object with config values and prefixes from the given prefix
*/
public Config getPrefixedConfigItems(String prefix) {
return map(configValue -> configValue.getPrefix(prefix));
}
/**
* Returns a new Config where for each config item, it will set the value to be that of the prefix value if present,
* otherwise it will keep its original value.
* The Config returned will have the same prefix data as the original, so multi-prefixed values are not accessible
* and prefixed values with a different root are still present.
*
* @param prefix The prefix to use for the current value / the prefix tree to use for multi-prefix configs
* @return a new Config object with config values and prefixes from the given prefix
*/
public Config getPrefixBiasedConfigItems(String prefix) {
return map(configValue -> configValue.getWithPrefixBias(prefix));
}
public ImmutableSet getPrefixes() {
Stream subConfigStream = subConfig.values().stream()
.flatMap(subConfig -> subConfig.getPrefixes().stream());
Stream valuesStream = values.values().stream()
.flatMap(value -> value.getPrefixes().stream());
return Stream.concat(subConfigStream, valuesStream)
.collect(ImmutableSet.toImmutableSet());
}
public ImmutableMap getValues() {
return this.values;
}
public static > Config empty(Class c) {
return new Config<>(c, ImmutableMap.of(), ImmutableMap.of(), c.getSimpleName(), null, null);
}
public TimeUnit getTimeUnit() {
return Preconditions.checkNotNull(timeUnit, "timeUnit not set. See ConfigManager.Builder.setTimeUnit.");
}
public LengthUnit getLengthUnit() {
return Preconditions.checkNotNull(lengthUnit, "lengthUnit not set. See ConfigManager.Builder.setLengthUnit.");
}
/**
* Check if the value of key is not the empty string.
* @throws ConfigKeyNotFoundException if the key has not been explicitly defined
*/
public boolean isValueDefined(Enum> key) {
return getIfKeyDefined(key)
.map(s -> !s.isEmpty())
.orElseThrow(() -> new ConfigKeyNotFoundException(key));
}
/**
* Check that the key has been explicitly defined and not to the empty string.
* The two checks are done simultaneously because key presence on its own should not
* be used for flow control (see class javadoc for reasoning).
*/
public boolean areKeyAndValueDefined(Enum> key) {
return getIfKeyDefined(key)
.map(s -> !s.isEmpty())
.orElse(false);
}
/**
* Check that this config's enum type matches the one provided.
* For example, a Config<ExampleConfig> is not the same thing as a Config<CounterExample>.
*/
public boolean enumTypeMatches(Class extends Enum> enumClazz) {
Class> clazz = enumClazz;
while (clazz != null) {
if (clazz.equals(cls)) {
return true;
}
clazz = clazz.getEnclosingClass();
}
return false;
}
/**
* Check that this config enum type contains the enum key independently from whether the key's value is set.
* For example, ExampleConfig.VALUE does have the same enum type of Config<ExampleConfig>,
* CounterExample.VALUE does not.
*/
public boolean enumTypeIncludes(Enum> key) {
return enumTypeMatches(key.getClass());
}
@SuppressWarnings("unchecked")
public > Config getSubConfig(Class key) {
return (Config) subConfig.get(key);
}
/**
* Create a {@link StrictValueParser} object to parse the value associated with the given key.
*
* @param key the key to look up in this config object.
* @return a {@link StrictValueParser} object built from the value associated with the specified key.
* @throws ConfigKeyNotFoundException if the key is not defined in this Config object.
*/
public StrictValueParser getValue(Enum> key) {
String value = getIfKeyDefined(key)
.orElseThrow(() -> new ConfigKeyNotFoundException(key));
return new StrictValueParser(key, value, timeUnit, lengthUnit);
}
/**
* Create an {@link OptionalValueParser} object to parse the value associated with the given key. This will return
* {@link Optional#empty()} if the value is defined as an empty string.
*
* @param key the key to look up in this config object.
* @return an {@link OptionalValueParser} object built from the value associated with the specified key.
* @throws ConfigKeyNotFoundException if the key is not defined in this Config object.
*/
public OptionalValueParser getIfValueDefined(Enum> key) {
String value = getIfKeyDefined(key)
.orElseThrow(() -> new ConfigKeyNotFoundException(key));
return new OptionalValueParser(key, value, timeUnit, lengthUnit);
}
/**
* Create an {@link OptionalValueParser} object to parse the value associated with the given key. This will return
* {@link Optional#empty()} if the key is not defined in this config object or if the associated value is an empty
* string.
*
* @param key the key to look up in this config object.
* @return an {@link OptionalValueParser} object built from the value associated with the specified key.
*/
public OptionalValueParser getIfKeyAndValueDefined(Enum> key) {
String value = getIfKeyDefined(key).orElse("");
return new OptionalValueParser(key, value, timeUnit, lengthUnit);
}
public String getQualifiedKeyName(E key) {
return qualifier + "." + key.toString();
}
/**
* @deprecated - exposes secret keys. Use {@link #getKeyValueStringMapWithoutSecrets()} instead
*/
@Deprecated
public ImmutableMap getKeyValueStringMap() {
ImmutableMap.Builder map = ImmutableMap.builder();
consumeConfigValues((k, v, isSecret) -> map.put(k, v), false);
return map.build();
}
/**
* Return the full config object as a map.
* This includes prefixes and secrets
* This is a package only function
*
* @return {@link ImmutableMap} of config pairs
*/
ImmutableMap getFullMap() {
ImmutableMap.Builder map = ImmutableMap.builder();
consumeConfigValues((k, v, isSecret) -> map.put(k, v), true);
return map.build();
}
public ImmutableMap getKeyValueStringMapWithoutSecrets() {
ImmutableMap.Builder map = ImmutableMap.builder();
consumeConfigValues((k, v, isSecret) -> {
if (!isSecret) {
map.put(k, v);
}
}, false);
return map.build();
}
public ImmutableMap getKeyValueStringMapWithPrefixesWithoutSecrets() {
ImmutableMap.Builder map = ImmutableMap.builder();
consumeConfigValues((k, v, isSecret) -> {
if (!isSecret) {
map.put(k, v);
}
}, true);
return map.build();
}
/**
* @deprecated - exposes secret keys. Use {@link #getUnqualifiedKeyValueStringMapWithoutSecrets(Class)} instead
*/
@Deprecated
public > ImmutableMap getUnqualifiedKeyValueStringMap(Class key) {
Config subConfig = getSubConfig(key);
ImmutableMap.Builder map = ImmutableMap.builder();
subConfig.consumeConfigValues((k, v, isSecret) -> map.put(k.substring(subConfig.qualifier.length() + 1), v), false);
return map.build();
}
public > ImmutableMap getUnqualifiedKeyValueStringMapWithoutSecrets(Class key) {
Config subConfig = getSubConfig(key);
ImmutableMap.Builder map = ImmutableMap.builder();
subConfig.consumeConfigValues((k, v, isSecret) -> {
if (!isSecret) {
map.put(k.substring(subConfig.qualifier.length() + 1), v);
}
}, false);
return map.build();
}
private void consumeConfigValues(ToStringHelper toStringHelper, boolean includePrefixes) {
values.entrySet().stream()
.sorted(Comparator.comparing(e -> e.getKey().toString()))
.forEach(e -> consumeConfigValue(toStringHelper, includePrefixes, e.getKey(), e.getValue()));
subConfig.entrySet().stream()
.sorted(Comparator.comparing(e -> e.getKey().toString()))
.forEach(x -> x.getValue().consumeConfigValues(toStringHelper, includePrefixes));
}
private void consumeConfigValue(ToStringHelper toStringHelper, boolean includePrefixes, E key, ConfigValue value) {
// currentValue is null when a prefixed Config value has no un-prefixed equivalent
if (value.currentValue != null) {
toStringHelper.accept(
getQualifiedKeyName(key),
value.currentValue,
isSecretConfig(key));
}
if (includePrefixes) {
value.getValuesByPrefixedKeys(getQualifiedKeyName(key)).forEach((prefixedKey, prefixedValue) ->
toStringHelper.accept(
prefixedKey,
prefixedValue,
isSecretConfig(key)));
}
}
private boolean isSecretConfig(E key) {
try {
return cls.getField(key.toString()).isAnnotationPresent(SecretConfig.class);
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
return false;
}
@Override
public int compareTo(Config> o) {
return qualifier.compareTo(o.qualifier);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Config> config = (Config>) o;
return Objects.equals(cls, config.cls)
&& Objects.equals(values, config.values)
&& Objects.equals(subConfig, config.subConfig)
&& Objects.equals(qualifier, config.qualifier)
&& timeUnit == config.timeUnit
&& lengthUnit == config.lengthUnit;
}
@Override
public int hashCode() {
return Objects.hash(cls, values, subConfig, qualifier, timeUnit, lengthUnit);
}
/**
* @return a superset of all config contained in this and other, with the values from other being given priority.
*/
@SuppressWarnings("unchecked")
//this method needs to be unchecked because subConfig is unchecked.
Config merge(Config other) {
Preconditions.checkState(qualifier.equals(other.qualifier), "Mismatched qualifiers:", qualifier, other.qualifier);
Preconditions.checkState(cls.equals(other.cls), "Mismatched classes:", cls, other.cls);
HashMap tempValues = new HashMap<>(values);
other.values.forEach((e, v) -> tempValues.merge(e, v, (a, b) -> b));
HashMap tempSubConfig = new HashMap<>(subConfig);
other.subConfig.forEach((clz, conf) -> tempSubConfig.merge(clz, conf, (oldConf, newConf) -> ((Config) oldConf).merge((Config) newConf)));
return new Config<>(
cls,
ImmutableMap.copyOf(tempValues),
ImmutableMap.copyOf(tempSubConfig),
qualifier,
other.timeUnit != null ? other.timeUnit : timeUnit,
other.lengthUnit != null ? other.lengthUnit : lengthUnit);
}
Config setUnits(TimeUnit timeUnit, LengthUnit lengthUnit) {
ImmutableMap, Config>> newSubConfig = subConfig.entrySet()
.stream()
.collect(ImmutableMap.toImmutableMap(Entry::getKey, e -> e.getValue()
.setUnits(timeUnit, lengthUnit)));
return new Config<>(
cls,
values,
newSubConfig,
qualifier,
timeUnit,
lengthUnit);
}
@Override
public String toString() {
Joiner joiner = Joiner.on("\n");
return joiner.join(qualifier + '{', getStringValues(joiner), '}');
}
private String getStringValues(Joiner joiner) {
List entries = new ArrayList<>();
consumeConfigValues((k, v, isSecret) -> {
if (!isSecret) {
entries.add(k + '=' + v);
}
}, true);
return joiner.join(entries);
}
@FunctionalInterface
private interface ToStringHelper {
void accept(String key, String value, Boolean isSecret);
}
@SuppressFBWarnings(value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE", justification = "We explicitly check if the map contains the key.")
private Optional getIfKeyDefined(Enum> key) {
if (key.getClass().equals(cls) && values.containsKey(cls.cast(key))) {
return Optional.ofNullable(values.get(cls.cast(key)).currentValue)
.map(String::trim);
}
Class> declaringClass = key.getDeclaringClass();
while (declaringClass != null) {
if (subConfig.containsKey(declaringClass)) {
return subConfig.get(declaringClass).getIfKeyDefined(key);
}
declaringClass = declaringClass.getDeclaringClass();
}
return Optional.empty();
}
/**
* Perform a function on all config values in the config tree
*
* @param mutator function to apply
* @return a new config with the function applied
*/
private Config map(Function mutator) {
ImmutableMap values = this.values.entrySet().stream()
.collect(Maps.toImmutableEnumMap(Entry::getKey, e -> mutator.apply(e.getValue())));
ImmutableMap, Config>> subConfig = this.subConfig.entrySet().stream()
.collect(ImmutableMap.toImmutableMap(Entry::getKey, e -> e.getValue().map(mutator)));
return new Config<>(this.cls, values, subConfig, this.qualifier, this.timeUnit, this.lengthUnit);
}
}