Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,34 @@ constructor with one parameter of type `SerializerContext`. If such a
constructor exists, a context object is passed to it when the serializer is
instantiated by this library.

### Type coercion

When deserializing the value for a configuration element, it can happen (e.g.
due to misconfiguration) that the value the deserializer receives is of the
wrong type. For example, it can happen that the configuration element is of type
`String` but the value the deserializer receives is of type `Number` because
someone forgot to wrap the value in quotes `"` in the configuration file. In
such cases, an exception is thrown.
However, if these misconfigurations are excepted to happen often, or you simply
prefer the validation logic of this library to be less strict, you can configure
this library to perform several conversions of normally incompatible types
automatically.

By default, this library converts all number types into one another (e.g. `long`
to `double`, etc.) and this cannot be disabled. All other conversions are off by
default and must be allowed explicitly by configuring a `ConfigurationProperties`
object.

```java
YamlConfigurationProperties.newBuilder()
// enable specific type coercions (varargs argument):
.setDeserializationCoercionTypes(BOOLEAN_TO_STRING, NUMBER_TO_STRING)
// or, if you want to enable all type coercions
// (taking the risk that there might be added more in the future):
.setDeserializationCoercionTypes(DeserializationCoercionType.values())
.build();
```

### Post-processing

There are two ways to apply some post-processing to your configurations:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class ConfigurationProperties {
serializersByCondition;
private final Map<Predicate<? super ConfigurationElement<?>>, UnaryOperator<?>>
postProcessorsByCondition;
private final Set<DeserializationCoercionType> deserializationCoercionTypes;
private final NameFormatter formatter;
private final FieldFilter filter;
private final boolean outputNulls;
Expand All @@ -42,6 +43,9 @@ protected ConfigurationProperties(Builder<?> builder) {
this.postProcessorsByCondition = Collections.unmodifiableMap(
new LinkedHashMap<>(builder.postProcessorsByCondition)
);
this.deserializationCoercionTypes = Collections.unmodifiableSet(
builder.deserializationCoercionTypes
);
this.formatter = requireNonNull(builder.formatter, "name formatter");
this.filter = requireNonNull(builder.filter, "field filter");
this.outputNulls = builder.outputNulls;
Expand Down Expand Up @@ -97,6 +101,8 @@ public static abstract class Builder<B extends Builder<B>> {
serializersByCondition = new LinkedHashMap<>();
private final Map<Predicate<? super ConfigurationElement<?>>, UnaryOperator<?>>
postProcessorsByCondition = new LinkedHashMap<>();
private final Set<DeserializationCoercionType> deserializationCoercionTypes =
EnumSet.noneOf(DeserializationCoercionType.class);
private NameFormatter formatter = NameFormatters.IDENTITY;
private FieldFilter filter = FieldFilters.DEFAULT;
private boolean outputNulls = false;
Expand All @@ -119,6 +125,7 @@ protected Builder(ConfigurationProperties properties) {
this.serializerFactoriesByType.putAll(properties.serializerFactoriesByType);
this.serializersByCondition.putAll(properties.serializersByCondition);
this.postProcessorsByCondition.putAll(properties.postProcessorsByCondition);
this.deserializationCoercionTypes.addAll(properties.deserializationCoercionTypes);
this.formatter = properties.formatter;
this.filter = properties.filter;
this.outputNulls = properties.outputNulls;
Expand Down Expand Up @@ -321,6 +328,27 @@ public final B setEnvVarResolutionConfiguration(
return getThis();
}

/**
* Sets which types of coercions should be allowed during deserialization.
* <p>
* By default, no type coercions are enabled (except for type coercions
* between number types, which cannot be disabled).
*
* @param types the types of coercions to enable
* @return this builder
* @throws NullPointerException if {@code types} or any of its values is null
*/
public final B setDeserializationCoercionTypes(
DeserializationCoercionType... types
) {
requireNonNull(types, "deserialization coercion types");
for (var type : types)
requireNonNull(type, "deserialization coercion type");
this.deserializationCoercionTypes.clear();
this.deserializationCoercionTypes.addAll(Arrays.asList(types));
return getThis();
}

/**
* Builds a {@code ConfigurationProperties} instance.
*
Expand Down Expand Up @@ -527,6 +555,15 @@ public final NameFormatter getNameFormatter() {
return postProcessorsByCondition;
}

/**
* Returns an unmodifiable map of deserialization coercion types.
*
* @return deserialization coercion types
*/
public final Set<DeserializationCoercionType> getDeserializationCoercionTypes() {
return deserializationCoercionTypes;
}

/**
* Returns whether null values should be output.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package de.exlll.configlib;

public enum DeserializationCoercionType {
/** Converts booleans to strings */
BOOLEAN_TO_STRING,
/** Converts numbers to strings */
NUMBER_TO_STRING,
/** Converts lists/sets/arrays/maps (recursively) to strings */
COLLECTION_TO_STRING,
/** Converts objects that don't belong to any of the above types to strings */
OBJECT_TO_STRING
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import static de.exlll.configlib.Validator.requireNonNull;

final class SerializerSelector {
private static final Map<Class<?>, Serializer<?, ?>> DEFAULT_SERIALIZERS = Map.ofEntries(
private static final Map<Class<?>, Serializer<?, ?>> STATIC_DEFAULT_SERIALIZERS = Map.ofEntries(
Map.entry(boolean.class, new BooleanSerializer()),
Map.entry(Boolean.class, new BooleanSerializer()),
Map.entry(byte.class, new NumberSerializer(byte.class)),
Expand All @@ -37,7 +37,6 @@ final class SerializerSelector {
Map.entry(Double.class, new NumberSerializer(Double.class)),
Map.entry(char.class, new CharacterSerializer()),
Map.entry(Character.class, new CharacterSerializer()),
Map.entry(String.class, new StringSerializer()),
Map.entry(BigInteger.class, new BigIntegerSerializer()),
Map.entry(BigDecimal.class, new BigDecimalSerializer()),
Map.entry(LocalDate.class, new LocalDateSerializer()),
Expand All @@ -51,6 +50,8 @@ final class SerializerSelector {
Map.entry(URI.class, new UriSerializer())
);
private final ConfigurationProperties properties;
private final Map<Class<?>, Serializer<?, ?>> configurableDefaultSerializers;

/**
* Holds the last {@link #select}ed configuration element.
*/
Expand All @@ -66,6 +67,10 @@ final class SerializerSelector {

public SerializerSelector(ConfigurationProperties properties) {
this.properties = requireNonNull(properties, "configuration properties");
this.configurableDefaultSerializers = Map.of(
String.class,
new StringCoercingSerializer(properties.getDeserializationCoercionTypes())
);
}

public Serializer<?, ?> select(ConfigurationElement<?> element) {
Expand Down Expand Up @@ -186,8 +191,10 @@ public SerializerSelector(ConfigurationProperties properties) {

private Serializer<?, ?> selectForClass(AnnotatedType annotatedType) {
final Class<?> cls = (Class<?>) annotatedType.getType();
if (DEFAULT_SERIALIZERS.containsKey(cls))
return DEFAULT_SERIALIZERS.get(cls);
if (STATIC_DEFAULT_SERIALIZERS.containsKey(cls))
return STATIC_DEFAULT_SERIALIZERS.get(cls);
if (configurableDefaultSerializers.containsKey(cls))
return configurableDefaultSerializers.get(cls);
if (Reflect.isEnumType(cls)) {
// The following cast won't fail because we just checked that it's an enum.
@SuppressWarnings("unchecked")
Expand Down Expand Up @@ -249,7 +256,8 @@ public SerializerSelector(ConfigurationProperties properties) {
: new SetSerializer<>(elementSerializer, outputNulls, inputNulls);
} else if (Reflect.isMapType(rawType)) {
if ((typeArgs[0].getType() instanceof Class<?> cls) &&
(DEFAULT_SERIALIZERS.containsKey(cls) ||
(STATIC_DEFAULT_SERIALIZERS.containsKey(cls) ||
configurableDefaultSerializers.containsKey(cls) ||
Reflect.isEnumType(cls))) {
var keySerializer = selectForClass(typeArgs[0]);
var valSerializer = selectForType(typeArgs[1]);
Expand Down
75 changes: 70 additions & 5 deletions configlib-core/src/main/java/de/exlll/configlib/Serializers.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.util.stream.IntStream;
import java.util.stream.Stream;

import static de.exlll.configlib.DeserializationCoercionType.*;
import static de.exlll.configlib.Validator.requireConfigurationType;
import static de.exlll.configlib.Validator.requireNonNull;

Expand All @@ -34,8 +35,10 @@ private Serializers() {}
* <p>
* The serializer returned by this method respects most configuration properties of
* the given properties object. The following properties are ignored:
* - All properties of subclasses of the configuration properties object
* - Properties affecting environment variable resolution
* <ul>
* <li>All properties of subclasses of the configuration properties object</li>
* <li>Properties affecting environment variable resolution</li>
* </ul>
*
* @param configurationType the type of configurations the newly created serializer
* can convert
Expand Down Expand Up @@ -193,15 +196,77 @@ public Class<? extends Number> getNumberClass() {
}
}

static final class StringSerializer implements Serializer<String, String> {
static final class StringCoercingSerializer implements Serializer<String, Object> {
private final Set<DeserializationCoercionType> deserializationCoercionTypes;

public StringCoercingSerializer(
Set<DeserializationCoercionType> deserializationCoercionTypes
) {
this.deserializationCoercionTypes = deserializationCoercionTypes;
}

@Override
public String serialize(String element) {
return element;
}

@Override
public String deserialize(String element) {
return element;
public String deserialize(Object element) {
if (element == null) return null;
if (element instanceof String string) return string;

if (element instanceof Boolean bool) {
return coerceOrThrow("Boolean", bool, BOOLEAN_TO_STRING);
}

if (element instanceof Number number) {
return coerceOrThrow("Number", number, NUMBER_TO_STRING);
}

if (element instanceof Collection<?> collection) {
return coerceOrThrow("Collection", collection, COLLECTION_TO_STRING);
}

if (element instanceof Map<?, ?> map) {
return coerceOrThrow("Map", map, COLLECTION_TO_STRING);
}

return coerceOrThrow(
element.getClass().getSimpleName(),
element,
OBJECT_TO_STRING
);
}

private String coerceOrThrow(
String sourceTypeName,
Object value,
DeserializationCoercionType coercionType
) {
if (deserializationCoercionTypes.contains(coercionType))
return value.toString();
final String exceptionMessage = buildExceptionMessage(
sourceTypeName,
value,
coercionType
);
throw new ConfigurationException(exceptionMessage);
}

private static String buildExceptionMessage(
String sourceTypeName,
Object value,
DeserializationCoercionType coercionType
) {
return ("%s '%s' cannot be deserialized to type String because %s-to-string coercion " +
"has not been configured. If you want to allow this type of coercion, add the " +
"deserialization coercion type '%s' via a ConfigurationProperties object.")
.formatted(
sourceTypeName,
value,
sourceTypeName.toLowerCase(),
coercionType
);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -279,7 +276,10 @@ final UnaryOperator<T> createPostProcessorFromAnnotatedMethod() {
if (list.isEmpty())
return UnaryOperator.identity();
if (list.size() > 1) {
String methodNames = String.join("\n ", list.stream().map(Method::toString).toList());
String methodNames = String.join(
"\n ",
list.stream().map(Method::toString).sorted().toList()
);
String msg = "Configuration types must not define more than one method for " +
"post-processing but type '%s' defines %d:\n %s"
.formatted(type, list.size(), methodNames);
Expand Down
Loading