From b68b696e8bb2260dd1203dc4007790e1bdb873e7 Mon Sep 17 00:00:00 2001 From: Exlll Date: Sun, 21 Dec 2025 17:01:06 +0100 Subject: [PATCH] Add option to enable type coercion during deserialization --- README.md | 28 +++ .../configlib/ConfigurationProperties.java | 37 +++ .../DeserializationCoercionType.java | 12 + .../exlll/configlib/SerializerSelector.java | 18 +- .../java/de/exlll/configlib/Serializers.java | 75 +++++- .../de/exlll/configlib/TypeSerializer.java | 10 +- .../de/exlll/configlib/CoercionTests.java | 91 +++++++ .../ConfigurationPropertiesTest.java | 42 +++- .../ConfigurationSerializerTest.java | 13 +- .../exlll/configlib/RecordSerializerTest.java | 11 +- .../configlib/SerializerSelectorTest.java | 20 +- .../de/exlll/configlib/SerializersTest.java | 237 ++++++++++++++++-- .../exlll/configlib/TypeSerializerTest.java | 8 +- .../java/de/exlll/configlib/TestUtils.java | 9 + 14 files changed, 548 insertions(+), 63 deletions(-) create mode 100644 configlib-core/src/main/java/de/exlll/configlib/DeserializationCoercionType.java create mode 100644 configlib-core/src/test/java/de/exlll/configlib/CoercionTests.java diff --git a/README.md b/README.md index 9b9ecc2..d2f53c9 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/configlib-core/src/main/java/de/exlll/configlib/ConfigurationProperties.java b/configlib-core/src/main/java/de/exlll/configlib/ConfigurationProperties.java index 3b10549..f66d25b 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/ConfigurationProperties.java +++ b/configlib-core/src/main/java/de/exlll/configlib/ConfigurationProperties.java @@ -20,6 +20,7 @@ public class ConfigurationProperties { serializersByCondition; private final Map>, UnaryOperator> postProcessorsByCondition; + private final Set deserializationCoercionTypes; private final NameFormatter formatter; private final FieldFilter filter; private final boolean outputNulls; @@ -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; @@ -97,6 +101,8 @@ public static abstract class Builder> { serializersByCondition = new LinkedHashMap<>(); private final Map>, UnaryOperator> postProcessorsByCondition = new LinkedHashMap<>(); + private final Set deserializationCoercionTypes = + EnumSet.noneOf(DeserializationCoercionType.class); private NameFormatter formatter = NameFormatters.IDENTITY; private FieldFilter filter = FieldFilters.DEFAULT; private boolean outputNulls = false; @@ -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; @@ -321,6 +328,27 @@ public final B setEnvVarResolutionConfiguration( return getThis(); } + /** + * Sets which types of coercions should be allowed during deserialization. + *

+ * 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. * @@ -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 getDeserializationCoercionTypes() { + return deserializationCoercionTypes; + } + /** * Returns whether null values should be output. * diff --git a/configlib-core/src/main/java/de/exlll/configlib/DeserializationCoercionType.java b/configlib-core/src/main/java/de/exlll/configlib/DeserializationCoercionType.java new file mode 100644 index 0000000..a7a4530 --- /dev/null +++ b/configlib-core/src/main/java/de/exlll/configlib/DeserializationCoercionType.java @@ -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 +} diff --git a/configlib-core/src/main/java/de/exlll/configlib/SerializerSelector.java b/configlib-core/src/main/java/de/exlll/configlib/SerializerSelector.java index fdef927..301b347 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/SerializerSelector.java +++ b/configlib-core/src/main/java/de/exlll/configlib/SerializerSelector.java @@ -20,7 +20,7 @@ import static de.exlll.configlib.Validator.requireNonNull; final class SerializerSelector { - private static final Map, Serializer> DEFAULT_SERIALIZERS = Map.ofEntries( + private static final Map, 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)), @@ -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()), @@ -51,6 +50,8 @@ final class SerializerSelector { Map.entry(URI.class, new UriSerializer()) ); private final ConfigurationProperties properties; + private final Map, Serializer> configurableDefaultSerializers; + /** * Holds the last {@link #select}ed configuration element. */ @@ -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) { @@ -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") @@ -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]); diff --git a/configlib-core/src/main/java/de/exlll/configlib/Serializers.java b/configlib-core/src/main/java/de/exlll/configlib/Serializers.java index c336de0..673bc14 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/Serializers.java +++ b/configlib-core/src/main/java/de/exlll/configlib/Serializers.java @@ -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; @@ -34,8 +35,10 @@ private Serializers() {} *

* 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 + *

    + *
  • All properties of subclasses of the configuration properties object
  • + *
  • Properties affecting environment variable resolution
  • + *
* * @param configurationType the type of configurations the newly created serializer * can convert @@ -193,15 +196,77 @@ public Class getNumberClass() { } } - static final class StringSerializer implements Serializer { + static final class StringCoercingSerializer implements Serializer { + private final Set deserializationCoercionTypes; + + public StringCoercingSerializer( + Set 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 + ); } } diff --git a/configlib-core/src/main/java/de/exlll/configlib/TypeSerializer.java b/configlib-core/src/main/java/de/exlll/configlib/TypeSerializer.java index 3ba8f48..9a47bc1 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/TypeSerializer.java +++ b/configlib-core/src/main/java/de/exlll/configlib/TypeSerializer.java @@ -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; @@ -279,7 +276,10 @@ final UnaryOperator 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); diff --git a/configlib-core/src/test/java/de/exlll/configlib/CoercionTests.java b/configlib-core/src/test/java/de/exlll/configlib/CoercionTests.java new file mode 100644 index 0000000..7c1c858 --- /dev/null +++ b/configlib-core/src/test/java/de/exlll/configlib/CoercionTests.java @@ -0,0 +1,91 @@ +package de.exlll.configlib; + +import org.junit.jupiter.api.Test; + +import java.net.URI; + +import static de.exlll.configlib.TestUtils.asMap; +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CoercionTests { + @Configuration + static class C1 { + String s1 = "s1"; + String s2 = "s2"; + String s3 = "s3"; + String s4 = "s4"; + String s5 = "s5"; + String s6 = "s6"; + } + + @Test + void throwsIfCoercionNotEnabled() { + final var serializer = Serializers.newConfigurationTypeSerializer( + C1.class, + ConfigurationProperties.newBuilder() + .inputNulls(true) + .build() + ); + + assertThat(serializer.deserialize(asMap("s1", null)).s1, nullValue()); + assertThat(serializer.deserialize(asMap("s2", "NEW")).s2, is("NEW")); + { + final var exception = assertThrows( + ConfigurationException.class, + () -> serializer.deserialize(asMap("s3", true)) + ); + final var cause = (ConfigurationException) exception.getCause(); + assertThat(cause.getMessage(), containsString("BOOLEAN_TO_STRING")); + } + { + final var exception = assertThrows( + ConfigurationException.class, + () -> serializer.deserialize(asMap("s4", 10L)) + ); + final var cause = (ConfigurationException) exception.getCause(); + assertThat(cause.getMessage(), containsString("NUMBER_TO_STRING")); + } + { + final var exception = assertThrows( + ConfigurationException.class, + () -> serializer.deserialize(asMap("s5", asMap(1, 2, 3, 4))) + ); + final var cause = (ConfigurationException) exception.getCause(); + assertThat(cause.getMessage(), containsString("COLLECTION_TO_STRING")); + } + { + final var exception = assertThrows( + ConfigurationException.class, + () -> serializer.deserialize(asMap("s6", URI.create("https://example.com"))) + ); + final var cause = (ConfigurationException) exception.getCause(); + assertThat(cause.getMessage(), containsString("OBJECT_TO_STRING")); + } + } + + @Test + void coercionIfCoercionNotEnabled() { + final var serializer = Serializers.newConfigurationTypeSerializer( + C1.class, + ConfigurationProperties.newBuilder() + .inputNulls(true) + .setDeserializationCoercionTypes(DeserializationCoercionType.values()) + .build() + ); + + assertThat(serializer.deserialize(asMap("s1", null)).s1, nullValue()); + assertThat(serializer.deserialize(asMap("s2", "NEW")).s2, is("NEW")); + assertThat(serializer.deserialize(asMap("s3", true)).s3, is("true")); + assertThat(serializer.deserialize(asMap("s4", 10L)).s4, is("10")); + assertThat( + serializer.deserialize(asMap("s5", asMap(1, 2, 3, 4))).s5, + is("{1=2, 3=4}") + ); + assertThat( + serializer.deserialize(asMap("s6", URI.create("https://example.com"))).s6, + is("https://example.com") + ); + } +} diff --git a/configlib-core/src/test/java/de/exlll/configlib/ConfigurationPropertiesTest.java b/configlib-core/src/test/java/de/exlll/configlib/ConfigurationPropertiesTest.java index ff4f6c5..62b6de0 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/ConfigurationPropertiesTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/ConfigurationPropertiesTest.java @@ -1,12 +1,13 @@ package de.exlll.configlib; import de.exlll.configlib.ConfigurationProperties.EnvVarResolutionConfiguration; -import de.exlll.configlib.Serializers.StringSerializer; +import de.exlll.configlib.Serializers.BooleanSerializer; import de.exlll.configlib.TestUtils.PointSerializer; import org.junit.jupiter.api.Test; import java.awt.Point; import java.lang.reflect.Type; +import java.util.EnumSet; import java.util.Map; import java.util.function.Predicate; import java.util.function.UnaryOperator; @@ -35,6 +36,7 @@ class ConfigurationPropertiesTest { .outputNulls(true) .inputNulls(true) .setEnvVarResolutionConfiguration(ENV_VAR_CONFIG) + .setDeserializationCoercionTypes(DeserializationCoercionType.values()) .serializeSetsAsLists(false); @Test @@ -53,6 +55,7 @@ void builderDefaultValues() { properties.getEnvVarResolutionConfiguration(), is(EnvVarResolutionConfiguration.disabled()) ); + assertThat(properties.getDeserializationCoercionTypes(), empty()); } @Test @@ -77,7 +80,10 @@ private static void assertConfigurationProperties(ConfigurationProperties proper assertThat(properties.getNameFormatter(), sameInstance(FORMATTER)); assertThat(properties.getFieldFilter(), sameInstance(FILTER)); assertThat(properties.getEnvVarResolutionConfiguration(), sameInstance(ENV_VAR_CONFIG)); - + assertThat( + properties.getDeserializationCoercionTypes(), + is(EnumSet.allOf(DeserializationCoercionType.class)) + ); var factories = properties.getSerializerFactories(); assertThat(factories.size(), is(1)); assertThat(factories.get(Point.class).apply(null), is(SERIALIZER)); @@ -115,6 +121,19 @@ void builderPostProcessorsUnmodifiable() { ); } + @Test + void builderDeserializerCoercionTypesUnmodifiable() { + ConfigurationProperties properties = ConfigurationProperties.newBuilder().build(); + + var deserializationCoercionTypes = properties.getDeserializationCoercionTypes(); + assertThrows( + UnsupportedOperationException.class, + () -> deserializationCoercionTypes.add( + DeserializationCoercionType.COLLECTION_TO_STRING + ) + ); + } + public static final class BuilderTest { private static final ConfigurationProperties.Builder builder = ConfigurationProperties.newBuilder(); @@ -138,7 +157,7 @@ void setNameFormatterRequiresNonNull() { @Test void addSerializerByTypeRequiresNonNull() { assertThrowsNullPointerException( - () -> builder.addSerializer(null, new StringSerializer()), + () -> builder.addSerializer(null, new BooleanSerializer()), "serialized type" ); @@ -151,7 +170,7 @@ void addSerializerByTypeRequiresNonNull() { @Test void addSerializerFactoryByTypeRequiresNonNull() { assertThrowsNullPointerException( - () -> builder.addSerializerFactory(null, ignored -> new StringSerializer()), + () -> builder.addSerializerFactory(null, ignored -> new BooleanSerializer()), "serialized type" ); @@ -164,7 +183,7 @@ void addSerializerFactoryByTypeRequiresNonNull() { @Test void addSerializerByConditionRequiresNonNull() { assertThrowsNullPointerException( - () -> builder.addSerializerByCondition(null, new StringSerializer()), + () -> builder.addSerializerByCondition(null, new BooleanSerializer()), "condition" ); @@ -194,6 +213,19 @@ void setEnvVarResolutionConfigurationRequiresNonNull() { "environment variable resolution configuration" ); } + + @Test + void setDeserializationCoercionTypesRequiresNonNull() { + assertThrowsNullPointerException( + () -> builder.setDeserializationCoercionTypes((DeserializationCoercionType[]) null), + "deserialization coercion types" + ); + assertThrowsNullPointerException( + () -> builder.setDeserializationCoercionTypes((DeserializationCoercionType) null), + "deserialization coercion type" + ); + } + } public static final class EnvVarResolutionConfigurationTest { diff --git a/configlib-core/src/test/java/de/exlll/configlib/ConfigurationSerializerTest.java b/configlib-core/src/test/java/de/exlll/configlib/ConfigurationSerializerTest.java index 383660e..305f332 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/ConfigurationSerializerTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/ConfigurationSerializerTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import java.awt.Point; +import java.io.File; import java.lang.reflect.Field; import java.math.BigDecimal; import java.time.LocalDate; @@ -137,25 +138,25 @@ void deserializeNullForPrimitiveFields() { @Configuration private static final class B3 { - String s = ""; - List> l = List.of(); + File file; + List> files; } @Test void deserializeInvalidType() { ConfigurationSerializer serializer = newSerializer(B3.class); assertThrowsConfigurationException( - () -> serializer.deserialize(Map.of("s", (byte) 3)), + () -> serializer.deserialize(Map.of("file", (byte) 3)), "Deserialization of value '3' with type 'class java.lang.Byte' for field " + - "'java.lang.String de.exlll.configlib.ConfigurationSerializerTest$B3.s' " + + "'java.io.File de.exlll.configlib.ConfigurationSerializerTest$B3.file' " + "failed.\nThe type of the object to be deserialized does not match the type " + "the deserializer expects." ); assertThrowsConfigurationException( - () -> serializer.deserialize(Map.of("l", List.of(List.of(3)))), + () -> serializer.deserialize(Map.of("files", List.of(List.of(3)))), "Deserialization of value '[[3]]' with type 'class " + "java.util.ImmutableCollections$List12' for field 'java.util.List " + - "de.exlll.configlib.ConfigurationSerializerTest$B3.l' failed.\n" + + "de.exlll.configlib.ConfigurationSerializerTest$B3.files' failed.\n" + "The type of the object to be deserialized does not match the type the " + "deserializer expects." ); diff --git a/configlib-core/src/test/java/de/exlll/configlib/RecordSerializerTest.java b/configlib-core/src/test/java/de/exlll/configlib/RecordSerializerTest.java index 0a21984..bc89dd4 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/RecordSerializerTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/RecordSerializerTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import java.awt.Point; +import java.io.File; import java.lang.reflect.RecordComponent; import java.util.HashMap; import java.util.LinkedHashMap; @@ -194,20 +195,20 @@ void deserializeNullValuesAsNullIfInputNullsIsTrueFailsForPrimitiveRecordCompone @Test void deserializeInvalidType() { - record B3(String s, List> l) {} + record B3(File file, List> files) {} RecordSerializer serializer = newSerializer(B3.class); assertThrowsConfigurationException( - () -> serializer.deserialize(Map.of("s", (byte) 3)), + () -> serializer.deserialize(Map.of("file", (byte) 3)), "Deserialization of value '3' with type 'class java.lang.Byte' for component " + - "'java.lang.String s' of record 'class de.exlll.configlib.RecordSerializerTest$1B3' " + + "'java.io.File file' of record 'class de.exlll.configlib.RecordSerializerTest$1B3' " + "failed.\nThe type of the object to be deserialized does not match the type " + "the deserializer expects." ); assertThrowsConfigurationException( - () -> serializer.deserialize(Map.of("l", List.of(List.of(3)))), + () -> serializer.deserialize(Map.of("files", List.of(List.of(3)))), "Deserialization of value '[[3]]' with type " + "'class java.util.ImmutableCollections$List12' for component " + - "'java.util.List l' of record 'class de.exlll.configlib.RecordSerializerTest$1B3' " + + "'java.util.List files' of record 'class de.exlll.configlib.RecordSerializerTest$1B3' " + "failed.\nThe type of the object to be deserialized does not match the type " + "the deserializer expects." ); diff --git a/configlib-core/src/test/java/de/exlll/configlib/SerializerSelectorTest.java b/configlib-core/src/test/java/de/exlll/configlib/SerializerSelectorTest.java index ae9815d..d0fc12f 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/SerializerSelectorTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/SerializerSelectorTest.java @@ -84,7 +84,7 @@ void selectSerializerChar(Class cls) { @Test void selectSerializerString() { Serializer serializer = SELECTOR.select(findByType(String.class)); - assertThat(serializer, instanceOf(StringSerializer.class)); + assertThat(serializer, instanceOf(StringCoercingSerializer.class)); } @Test @@ -166,7 +166,7 @@ void selectSerializerArray() { var elementSerializer = (ArraySerializer) serializer.getElementSerializer(); assertThat(elementSerializer.getComponentType(), equalTo(String.class)); - assertThat(elementSerializer.getElementSerializer(), instanceOf(StringSerializer.class)); + assertThat(elementSerializer.getElementSerializer(), instanceOf(StringCoercingSerializer.class)); } @Test @@ -359,7 +359,7 @@ void selectSerializerByCustomTypeTakesPrecedenceOverCondition() { @Test void selectSerializerList() { var serializer = (ListSerializer) SELECTOR.select(findByName("a2_listString")); - assertThat(serializer.getElementSerializer(), instanceOf(StringSerializer.class)); + assertThat(serializer.getElementSerializer(), instanceOf(StringCoercingSerializer.class)); } @Test @@ -379,19 +379,19 @@ void selectSerializerSetsAsSets() { ConfigurationProperties.newBuilder().serializeSetsAsLists(false).build() ); var serializer = (SetSerializer) selector.select(findByName("a2_setString")); - assertThat(serializer.getElementSerializer(), instanceOf(StringSerializer.class)); + assertThat(serializer.getElementSerializer(), instanceOf(StringCoercingSerializer.class)); } @Test void selectSerializerSetsAsLists() { var serializer = (SetAsListSerializer) SELECTOR.select(findByName("a2_setString")); - assertThat(serializer.getElementSerializer(), instanceOf(StringSerializer.class)); + assertThat(serializer.getElementSerializer(), instanceOf(StringCoercingSerializer.class)); } @Test void selectSerializerMap() { var serializer = (MapSerializer) SELECTOR_POINT.select(findByName("a2_mapStringR1")); - var stringSerializer = (StringSerializer) serializer.getKeySerializer(); + var stringSerializer = (StringCoercingSerializer) serializer.getKeySerializer(); var recordSerializer = (RecordSerializer) serializer.getValueSerializer(); assertThat(recordSerializer.getRecordType(), equalTo(ExampleRecord1.class)); } @@ -599,7 +599,7 @@ void selectCustomSerializerForMapsWithNesting1() { void selectCustomSerializerForMapsWithNesting2() { var serializer1 = (MapSerializer) SELECTOR.select(fieldAsElement(Z.class, "map3")); var serializer2 = (MapSerializer) serializer1.getValueSerializer(); - assertThat(serializer2.getKeySerializer(), instanceOf(StringSerializer.class)); + assertThat(serializer2.getKeySerializer(), instanceOf(StringCoercingSerializer.class)); assertThat(serializer2.getValueSerializer(), instanceOf(IdentitySerializer.class)); } @@ -634,11 +634,11 @@ class A { @SerializeWith(serializer = IdentitySerializer.class, nesting = 2) List list; } - assertThat(SELECTOR.select(fieldAsElement(A.class, "s1")), instanceOf(StringSerializer.class)); + assertThat(SELECTOR.select(fieldAsElement(A.class, "s1")), instanceOf(StringCoercingSerializer.class)); assertThat(SELECTOR.select(fieldAsElement(A.class, "s2")), instanceOf(IdentitySerializer.class)); - assertThat(SELECTOR.select(fieldAsElement(A.class, "s3")), instanceOf(StringSerializer.class)); + assertThat(SELECTOR.select(fieldAsElement(A.class, "s3")), instanceOf(StringCoercingSerializer.class)); var serializer = (ListSerializer) SELECTOR.select(fieldAsElement(A.class, "list")); - assertThat(serializer.getElementSerializer(), instanceOf(StringSerializer.class)); + assertThat(serializer.getElementSerializer(), instanceOf(StringCoercingSerializer.class)); } @Test diff --git a/configlib-core/src/test/java/de/exlll/configlib/SerializersTest.java b/configlib-core/src/test/java/de/exlll/configlib/SerializersTest.java index 120f1c4..ac5e284 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/SerializersTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/SerializersTest.java @@ -19,10 +19,7 @@ import java.nio.file.Path; import java.time.*; import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import java.util.*; import static de.exlll.configlib.TestUtils.*; import static java.util.Arrays.asList; @@ -30,10 +27,12 @@ import static org.hamcrest.Matchers.*; class SerializersTest { - - private final String TMP_CONFIG_PATH = createPlatformSpecificFilePath("/tmp/config.yml"); - private final String TMP_WITH_UNDERSCORE_PATH = createPlatformSpecificFilePath("/tmp/with_underscore.yml"); - private final String TMP_PATH = createPlatformSpecificFilePath("/tmp"); + private static final String TMP_CONFIG_PATH = + createPlatformSpecificFilePath("/tmp/config.yml"); + private static final String TMP_WITH_UNDERSCORE_PATH = + createPlatformSpecificFilePath("/tmp/with_underscore.yml"); + private static final String TMP_PATH = + createPlatformSpecificFilePath("/tmp"); @Test void booleanSerializer() { @@ -333,16 +332,6 @@ private void numberSerializerDeserializeFloatingPointSpecialValues( assertThat(nanFloat, is(nan)); } - @Test - void stringSerializer() { - Serializer serializer = new Serializers.StringSerializer(); - - String random = "RANDOM"; - - assertThat(serializer.serialize(random), sameInstance(random)); - assertThat(serializer.deserialize(random), sameInstance(random)); - } - @Test void characterSerializer() { Serializer serializer = new Serializers.CharacterSerializer(); @@ -1082,4 +1071,216 @@ void newConfigurationTypeSerializerReturnsTypeSerializerInstance() { instanceOf(TypeSerializer.class) ); } + + + public static final class StringCoercingSerializerTest { + private static Serializer newStringCoercingSerializer( + DeserializationCoercionType... types + ) { + return new Serializers.StringCoercingSerializer(Set.of(types)); + } + + @Test + void serializeStringReturnsSameInstance() { + final var serializer = newStringCoercingSerializer(); + + String value = "ABCDE"; + assertThat(serializer.serialize(value), sameInstance(value)); + } + + @Test + void deserializeStringReturnsSameInstance() { + final var serializer = newStringCoercingSerializer(); + + String value = "ABCDE"; + assertThat(serializer.deserialize(value), sameInstance(value)); + } + + @Test + void deserializeNullReturnsNull() { + final var serializer = newStringCoercingSerializer(); + assertThat(serializer.deserialize(null), nullValue()); + } + + private static String buildExceptionMessage( + String sourceTypeName, + String value, + String deserializationCoercingType + ) { + 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(), deserializationCoercingType); + } + + @Test + void deserializeThrowsForBooleansIfBooleanToStringDeserializationTypeNotAdded() { + final var serializer = newStringCoercingSerializer(); + + assertThrowsConfigurationException( + () -> serializer.deserialize(true), + buildExceptionMessage("Boolean", "true", "BOOLEAN_TO_STRING") + ); + assertThrowsConfigurationException( + () -> serializer.deserialize(false), + buildExceptionMessage("Boolean", "false", "BOOLEAN_TO_STRING") + ); + } + + @Test + void deserializeThrowsForNumbersIfNumberToStringDeserializationTypeNotAdded() { + final var serializer = newStringCoercingSerializer(); + + assertThrowsConfigurationException( + () -> serializer.deserialize((byte) 1), + buildExceptionMessage("Number", "1", "NUMBER_TO_STRING") + ); + assertThrowsConfigurationException( + () -> serializer.deserialize(Byte.valueOf("2")), + buildExceptionMessage("Number", "2", "NUMBER_TO_STRING") + ); + + assertThrowsConfigurationException( + () -> serializer.deserialize((short) 3), + buildExceptionMessage("Number", "3", "NUMBER_TO_STRING") + ); + assertThrowsConfigurationException( + () -> serializer.deserialize(Short.valueOf("4")), + buildExceptionMessage("Number", "4", "NUMBER_TO_STRING") + ); + + assertThrowsConfigurationException( + () -> serializer.deserialize(5), + buildExceptionMessage("Number", "5", "NUMBER_TO_STRING") + ); + assertThrowsConfigurationException( + () -> serializer.deserialize(Integer.valueOf("6")), + buildExceptionMessage("Number", "6", "NUMBER_TO_STRING") + ); + + assertThrowsConfigurationException( + () -> serializer.deserialize((long) 7), + buildExceptionMessage("Number", "7", "NUMBER_TO_STRING") + ); + assertThrowsConfigurationException( + () -> serializer.deserialize(Long.valueOf("8")), + buildExceptionMessage("Number", "8", "NUMBER_TO_STRING") + ); + + assertThrowsConfigurationException( + () -> serializer.deserialize((float) 9), + buildExceptionMessage("Number", "9.0", "NUMBER_TO_STRING") + ); + assertThrowsConfigurationException( + () -> serializer.deserialize(Float.valueOf("10.0")), + buildExceptionMessage("Number", "10.0", "NUMBER_TO_STRING") + ); + + assertThrowsConfigurationException( + () -> serializer.deserialize(11.0), + buildExceptionMessage("Number", "11.0", "NUMBER_TO_STRING") + ); + assertThrowsConfigurationException( + () -> serializer.deserialize(Double.valueOf("12.0")), + buildExceptionMessage("Number", "12.0", "NUMBER_TO_STRING") + ); + } + + @Test + void deserializeThrowsForCollectionsIfCollectionToStringDeserializationTypeNotAdded() { + final var serializer = newStringCoercingSerializer(); + + assertThrowsConfigurationException( + () -> serializer.deserialize(Collections.emptyList()), + buildExceptionMessage("Collection", "[]", "COLLECTION_TO_STRING") + ); + assertThrowsConfigurationException( + () -> serializer.deserialize(asList(1, 2, 3)), + buildExceptionMessage("Collection", "[1, 2, 3]", "COLLECTION_TO_STRING") + ); + + assertThrowsConfigurationException( + () -> serializer.deserialize(Collections.emptySet()), + buildExceptionMessage("Collection", "[]", "COLLECTION_TO_STRING") + ); + assertThrowsConfigurationException( + () -> serializer.deserialize(asSet(4, 5, 6)), + buildExceptionMessage("Collection", "[4, 5, 6]", "COLLECTION_TO_STRING") + ); + } + + @Test + void deserializeThrowsForObjectsIfObjectToStringDeserializationTypeNotAdded() { + final var serializer = newStringCoercingSerializer(); + + assertThrowsConfigurationException( + () -> serializer.deserialize(new File(TMP_CONFIG_PATH)), + buildExceptionMessage("File", "/tmp/config.yml", "OBJECT_TO_STRING") + ); + assertThrowsConfigurationException( + () -> serializer.deserialize(URI.create("https://example.com")), + buildExceptionMessage("URI", "https://example.com", "OBJECT_TO_STRING") + ); + } + + @Test + void deserializeBooleanToStringIfDeserializationTypeAdded() { + final var serializer = newStringCoercingSerializer( + DeserializationCoercionType.BOOLEAN_TO_STRING + ); + + assertThat(serializer.deserialize(true), is("true")); + assertThat(serializer.deserialize(false), is("false")); + } + + @Test + void deserializeNumberToStringIfDeserializationTypeAdded() { + final var serializer = newStringCoercingSerializer( + DeserializationCoercionType.NUMBER_TO_STRING + ); + + assertThat(serializer.deserialize((byte) 1), is("1")); + assertThat(serializer.deserialize(Byte.valueOf("2")), is("2")); + assertThat(serializer.deserialize((short) 3), is("3")); + assertThat(serializer.deserialize(Short.valueOf("4")), is("4")); + assertThat(serializer.deserialize(5), is("5")); + assertThat(serializer.deserialize(Integer.valueOf("6")), is("6")); + assertThat(serializer.deserialize((long) 7), is("7")); + assertThat(serializer.deserialize(Long.valueOf("8")), is("8")); + assertThat(serializer.deserialize((float) 9), is("9.0")); + assertThat(serializer.deserialize(Float.valueOf("10.0")), is("10.0")); + assertThat(serializer.deserialize(11.0), is("11.0")); + assertThat(serializer.deserialize(Double.valueOf("12.0")), is("12.0")); + } + + @Test + void deserializeCollectionToStringIfDeserializationTypeAdded() { + final var serializer = newStringCoercingSerializer( + DeserializationCoercionType.COLLECTION_TO_STRING + ); + + assertThat(serializer.deserialize(Collections.emptyList()), is("[]")); + assertThat(serializer.deserialize(asList(1, 2, 3)), is("[1, 2, 3]")); + + assertThat(serializer.deserialize(Collections.emptySet()), is("[]")); + assertThat(serializer.deserialize(asSet(4, 5, 6)), is("[4, 5, 6]")); + } + + @Test + void deserializeObjectToStringIfDeserializationTypeAdded() { + final var serializer = newStringCoercingSerializer( + DeserializationCoercionType.OBJECT_TO_STRING + ); + + assertThat( + serializer.deserialize(new File(TMP_CONFIG_PATH)), + is("/tmp/config.yml") + ); + assertThat( + serializer.deserialize(URI.create("https://example.com")), + is("https://example.com") + ); + } + } } \ No newline at end of file diff --git a/configlib-core/src/test/java/de/exlll/configlib/TypeSerializerTest.java b/configlib-core/src/test/java/de/exlll/configlib/TypeSerializerTest.java index 80399c1..e026038 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/TypeSerializerTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/TypeSerializerTest.java @@ -55,7 +55,7 @@ void buildSerializerMapForConfiguration() { .buildSerializerMap(); assertThat(serializers.get("a2_primBool"), instanceOf(BooleanSerializer.class)); assertThat(serializers.get("a2_refChar"), instanceOf(CharacterSerializer.class)); - assertThat(serializers.get("a2_string"), instanceOf(StringSerializer.class)); + assertThat(serializers.get("a2_string"), instanceOf(StringCoercingSerializer.class)); assertThat(serializers.get("a2_Enm"), instanceOf(EnumSerializer.class)); ConfigurationSerializer serializerB1 = @@ -81,7 +81,7 @@ void buildSerializerMapForConfiguration() { ); assertThat(serializerList.getElementSerializer(), instanceOf(NumberSerializer.class)); - assertThat(serializerArray.getElementSerializer(), instanceOf(StringSerializer.class)); + assertThat(serializerArray.getElementSerializer(), instanceOf(StringCoercingSerializer.class)); assertThat(serializerSet.getElementSerializer(), instanceOf(BigIntegerSerializer.class)); assertThat(serializerMap.getKeySerializer(), instanceOf(LocalTimeSerializer.class)); assertThat(serializerMap.getValueSerializer(), instanceOf(LocalTimeSerializer.class)); @@ -110,7 +110,7 @@ void buildSerializerMapForRecord() { .buildSerializerMap(); assertThat(serializers.get("primBool"), instanceOf(BooleanSerializer.class)); assertThat(serializers.get("refChar"), instanceOf(CharacterSerializer.class)); - assertThat(serializers.get("string"), instanceOf(StringSerializer.class)); + assertThat(serializers.get("string"), instanceOf(StringCoercingSerializer.class)); assertThat(serializers.get("enm"), instanceOf(EnumSerializer.class)); ConfigurationSerializer serializerB1 = @@ -136,7 +136,7 @@ void buildSerializerMapForRecord() { ); assertThat(serializerList.getElementSerializer(), instanceOf(NumberSerializer.class)); - assertThat(serializerArray.getElementSerializer(), instanceOf(StringSerializer.class)); + assertThat(serializerArray.getElementSerializer(), instanceOf(StringCoercingSerializer.class)); assertThat(serializerSet.getElementSerializer(), instanceOf(BigIntegerSerializer.class)); assertThat(serializerMap.getKeySerializer(), instanceOf(UuidSerializer.class)); assertThat(serializerMap.getValueSerializer(), instanceOf(UuidSerializer.class)); diff --git a/configlib-core/src/testFixtures/java/de/exlll/configlib/TestUtils.java b/configlib-core/src/testFixtures/java/de/exlll/configlib/TestUtils.java index 870de2d..edb1f6d 100644 --- a/configlib-core/src/testFixtures/java/de/exlll/configlib/TestUtils.java +++ b/configlib-core/src/testFixtures/java/de/exlll/configlib/TestUtils.java @@ -1,5 +1,6 @@ package de.exlll.configlib; +import org.hamcrest.Matcher; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.function.Executable; @@ -59,6 +60,14 @@ public static void assertThrowsConfigurationException( assertThrowsException(ConfigurationException.class, executable, expectedExceptionMessage); } + public static void assertThrowsConfigurationException( + Executable executable, + Matcher matcher + ) { + final var exception = assertThrows(ConfigurationException.class, executable); + assertThat(exception.getMessage(), matcher); + } + public static void assertThrowsRuntimeException( Executable executable, String expectedExceptionMessage