diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 39bf8e0..1bac900 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -3,10 +3,10 @@ plugins { } repositories { - mavenCentral() gradlePluginPortal() + mavenCentral() } dependencies { - implementation("gradle.plugin.com.github.johnrengelman:shadow:7.1.2") + implementation("com.github.johnrengelman:shadow:8.1.1") } diff --git a/configlib-json/build.gradle.kts b/configlib-json/build.gradle.kts new file mode 100644 index 0000000..23743a0 --- /dev/null +++ b/configlib-json/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `core-config` + `libs-config` +} + +dependencies { + implementation("com.fasterxml.jackson.core:jackson-databind:2.21.0") +} diff --git a/configlib-json/src/main/java/de/exlll/configlib/JsonConfigurationProperties.java b/configlib-json/src/main/java/de/exlll/configlib/JsonConfigurationProperties.java new file mode 100644 index 0000000..1f4267f --- /dev/null +++ b/configlib-json/src/main/java/de/exlll/configlib/JsonConfigurationProperties.java @@ -0,0 +1,80 @@ +package de.exlll.configlib; + +/** + * An extension of the {@code FileConfigurationProperties} class that allows configuring properties + * that are more specific to JSON files. + */ +public final class JsonConfigurationProperties extends FileConfigurationProperties { + /** + * Constructs a new instance of this class with values taken from the given builder. + * + * @param builder the builder used to initialize the fields of this class + * @throws NullPointerException if the builder or any of its values is null + */ + public JsonConfigurationProperties(Builder builder) { + super(builder); + } + + /** + * Constructs a new {@code Builder} with default values. + * + * @return newly constructed {@code Builder} + */ + public static Builder newBuilder() { + return new BuilderImpl(); + } + + public Builder toBuilder() { + return new BuilderImpl(this); + } + + private static final class BuilderImpl extends Builder { + private BuilderImpl() {} + + private BuilderImpl(JsonConfigurationProperties properties) {super(properties);} + + @Override + protected BuilderImpl getThis() {return this;} + + @Override + public JsonConfigurationProperties build() {return new JsonConfigurationProperties(this);} + } + + /** + * A builder class for constructing {@code JsonConfigurationProperties}. + * + * @param the type of builder + */ + public static abstract class Builder> + extends FileConfigurationProperties.Builder { + + /** + * The default constructor. + */ + protected Builder() {} + + /** + * A constructor that initializes this builder with values taken from the properties object. + * + * @param properties the properties object the values are taken from + * @throws NullPointerException if {@code properties} is null + */ + protected Builder(JsonConfigurationProperties properties) { + super(properties); + } + + /** + * Builds a {@code JsonConfigurationProperties} instance. + * + * @return newly constructed {@code JsonConfigurationProperties} + */ + public abstract JsonConfigurationProperties build(); + + /** + * Returns this builder. + * + * @return this builder + */ + protected abstract B getThis(); + } +} \ No newline at end of file diff --git a/configlib-json/src/main/java/de/exlll/configlib/JsonConfigurationStore.java b/configlib-json/src/main/java/de/exlll/configlib/JsonConfigurationStore.java new file mode 100644 index 0000000..0c5cd32 --- /dev/null +++ b/configlib-json/src/main/java/de/exlll/configlib/JsonConfigurationStore.java @@ -0,0 +1,221 @@ +package de.exlll.configlib; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static de.exlll.configlib.Validator.requireNonNull; + +/** + * A configuration store for JSON configurations using Jackson. + *

+ * This store supports reading and writing JSON files. Unlike standard JSON parsers, + * this store attempts to preserve or inject comments when saving configurations + * by using {@link JsonWriter}. + * + * @param the configuration type + */ +public final class JsonConfigurationStore implements + FileConfigurationStore, + IOStreamConfigurationStore { + + private static final ObjectMapper OBJECT_MAPPER = newObjectMapper(); + private final JsonConfigurationProperties properties; + private final RootSerializer serializer; + + /** + * Constructs a new store. + * + * @param configurationType the type of configuration + * @param properties the properties + * @throws NullPointerException if any argument is null + */ + public JsonConfigurationStore( + Class configurationType, + JsonConfigurationProperties properties + ) { + this(configurationType, properties, new Environment.SystemEnvironment()); + } + + JsonConfigurationStore( + Class configurationType, + JsonConfigurationProperties properties, + Environment environment + ) { + requireNonNull(configurationType, "configuration type"); + this.properties = requireNonNull(properties, "properties"); + this.serializer = new RootSerializer<>( + configurationType, + properties, + environment + ); + } + + @Override + public void write(T configuration, OutputStream outputStream) { + requireNonNull(configuration, "configuration"); + requireNonNull(outputStream, "output stream"); + + + var json = tryDump(configuration); + var jsonWriter = new JsonWriter(outputStream, properties); + jsonWriter.writeJson(json); + } + + @Override + public void save(T configuration, Path configurationFile) { + requireNonNull(configuration, "configuration"); + requireNonNull(configurationFile, "configuration file"); + + tryCreateParentDirectories(configurationFile); + + var json = tryDump(configuration); + var jsonWriter = new JsonWriter(configurationFile, properties); + jsonWriter.writeJson(json); + } + + void tryCreateParentDirectories(Path configurationFile) { + Path parent = configurationFile.getParent(); + if (properties.createParentDirectories() && parent != null && !Files.exists(parent)) { + try { + Files.createDirectories(parent); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + private String tryDump(T configuration) { + final Map serializedConfiguration = serializer.serialize(configuration); + try { + return OBJECT_MAPPER.writeValueAsString(serializedConfiguration); + } catch (JsonProcessingException e) { + String msg = "The given configuration could not be converted into JSON. \n" + + "Do all custom serializers produce valid target types?"; + throw new ConfigurationException(msg, e); + } + } + + @Override + public T read(InputStream inputStream) { + requireNonNull(inputStream, "input stream"); + try { + var json = OBJECT_MAPPER.readValue(inputStream, Map.class); + var conf = requireJsonMap(json); + return serializer.deserialize(conf); + } catch (IOException e) { + String msg = "The input stream does not contain valid JSON."; + throw new ConfigurationException(msg, e); + } + } + + @Override + public T load(Path configurationFile) { + requireNonNull(configurationFile, "configuration file"); + try (var reader = Files.newBufferedReader(configurationFile, properties.getCharset())) { + var json = OBJECT_MAPPER.readValue(reader, Map.class); + var conf = requireJsonMap(json); + var processedConf = postProcessJsonMap(conf); + return serializer.deserialize(processedConf); + } catch (IOException e) { + String msg = "The configuration file at %s could not be loaded."; + throw new ConfigurationException(msg.formatted(configurationFile), e); + } + } + + /** + * Recursively walks the JSON map and converts String keys to Longs if they are valid numbers. + */ + private Map postProcessJsonMap(Map map) { + Map result = new LinkedHashMap<>(map.size()); + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); + Object value = entry.getValue(); + + if (key instanceof String keyStr) { + key = attemptConvertToLong(keyStr); + } + + if (value instanceof Map valMap) { + value = postProcessJsonMap(valMap); + } else if (value instanceof List valList) { + value = postProcessJsonList(valList); + } + + result.put(key, value); + } + return result; + } + + private Object attemptConvertToLong(String key) { + try { + return Long.parseLong(key); + } catch (NumberFormatException e) { + return key; + } + } + + private List postProcessJsonList(List list) { + List result = new ArrayList<>(list.size()); + for (Object element : list) { + if (element instanceof Map map) { + result.add(postProcessJsonMap(map)); + } else if (element instanceof List subList) { + result.add(postProcessJsonList(subList)); + } else { + result.add(element); + } + } + return result; + } + + private Map requireJsonMap(Object json) { + if (json == null) { + String msg = "The JSON content is empty or null."; + throw new ConfigurationException(msg); + } + if (!(json instanceof Map map)) { + String msg = "The JSON content does not represent a configuration. " + + "A valid configuration must be a JSON object (Map) but found '" + + json.getClass().getSimpleName() + "'."; + throw new ConfigurationException(msg); + } + return map; + } + + @Override + public T update(Path configurationFile) { + requireNonNull(configurationFile, "configuration file"); + if (Files.exists(configurationFile)) { + T configuration = load(configurationFile); + save(configuration, configurationFile); + return configuration; + } + T defaultConfiguration = serializer.newDefaultInstance(); + save(defaultConfiguration, configurationFile); + return load(configurationFile); + } + + static ObjectMapper newObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.enable(SerializationFeature.INDENT_OUTPUT); + mapper.enable(SerializationFeature.WRITE_EMPTY_JSON_ARRAYS); + mapper.configure(JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS.mappedFeature(), true); + // We use Long for ints to ensure consistency with how ConfigLib generally + // handles numeric types (similar to the YamlConfigurationConstructor logic). + mapper.enable(DeserializationFeature.USE_LONG_FOR_INTS); + return mapper; + } +} \ No newline at end of file diff --git a/configlib-json/src/main/java/de/exlll/configlib/JsonConfigurations.java b/configlib-json/src/main/java/de/exlll/configlib/JsonConfigurations.java new file mode 100644 index 0000000..67374c0 --- /dev/null +++ b/configlib-json/src/main/java/de/exlll/configlib/JsonConfigurations.java @@ -0,0 +1,306 @@ +package de.exlll.configlib; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Path; +import java.util.function.Consumer; + +/** + * This class contains convenience methods for reading, writing, loading, saving, + * and updating JSON configurations using Jackson. + */ +public final class JsonConfigurations { + private JsonConfigurations() {} + + /** + * Loads a configuration of the given type from the specified JSON file using a + * {@code JsonConfigurationProperties} object with default values. + * + * @param configurationFile the file the configuration is loaded from + * @param configurationType the type of configuration + * @param the configuration type + * @return a newly created configuration initialized with values taken from the configuration file + * @see JsonConfigurationStore#load(Path) + */ + public static T load(Path configurationFile, Class configurationType) { + final var properties = JsonConfigurationProperties.newBuilder().build(); + return load(configurationFile, configurationType, properties); + } + + /** + * Loads a configuration of the given type from the specified JSON file using a + * {@code JsonConfigurationProperties} object that is built by a builder. + * + * @param configurationFile the file the configuration is loaded from + * @param configurationType the type of configuration + * @param propertiesConfigurer the consumer used to configure the builder + * @param the configuration type + * @return a newly created configuration initialized with values taken from the configuration file + * @see JsonConfigurationStore#load(Path) + */ + public static T load( + Path configurationFile, + Class configurationType, + Consumer> propertiesConfigurer + ) { + final var builder = JsonConfigurationProperties.newBuilder(); + propertiesConfigurer.accept(builder); + return load(configurationFile, configurationType, builder.build()); + } + + /** + * Loads a configuration of the given type from the specified JSON file using the given + * {@code JsonConfigurationProperties} object. + * + * @param configurationFile the file the configuration is loaded from + * @param configurationType the type of configuration + * @param properties the configuration properties + * @param the configuration type + * @return a newly created configuration initialized with values taken from the configuration file + * @see JsonConfigurationStore#load(Path) + */ + public static T load( + Path configurationFile, + Class configurationType, + JsonConfigurationProperties properties + ) { + final var store = new JsonConfigurationStore<>(configurationType, properties); + return store.load(configurationFile); + } + + /** + * Reads a configuration of the given type from the given input stream using a + * {@code JsonConfigurationProperties} object with default values. + * + * @param inputStream the input stream the configuration is read from + * @param configurationType the type of configuration + * @param the configuration type + * @return a newly created configuration initialized with values read from {@code inputStream} + * @see JsonConfigurationStore#read(InputStream) + */ + public static T read(InputStream inputStream, Class configurationType) { + final var properties = JsonConfigurationProperties.newBuilder().build(); + return read(inputStream, configurationType, properties); + } + + /** + * Reads a configuration of the given type from the given input stream using a + * {@code JsonConfigurationProperties} object that is built by a builder. + * + * @param inputStream the input stream the configuration is read from + * @param configurationType the type of configuration + * @param propertiesConfigurer the consumer used to configure the builder + * @param the configuration type + * @return a newly created configuration initialized with values read from {@code inputStream} + * @see JsonConfigurationStore#read(InputStream) + */ + public static T read( + InputStream inputStream, + Class configurationType, + Consumer> propertiesConfigurer + ) { + final var builder = JsonConfigurationProperties.newBuilder(); + propertiesConfigurer.accept(builder); + return read(inputStream, configurationType, builder.build()); + } + + /** + * Reads a configuration of the given type from the given input stream using the given + * {@code JsonConfigurationProperties} object. + * + * @param inputStream the input stream the configuration is read from + * @param configurationType the type of configuration + * @param properties the configuration properties + * @param the configuration type + * @return a newly created configuration initialized with values read from {@code inputStream} + * @see JsonConfigurationStore#read(InputStream) + */ + public static T read( + InputStream inputStream, + Class configurationType, + JsonConfigurationProperties properties + ) { + final var store = new JsonConfigurationStore<>(configurationType, properties); + return store.read(inputStream); + } + + /** + * Updates a JSON configuration file with a configuration of the given type using a + * {@code JsonConfigurationProperties} object with default values. + * + * @param configurationFile the configuration file that is updated + * @param configurationType the type of configuration + * @param the configuration type + * @return a newly created configuration initialized with values taken from the configuration file + * @see JsonConfigurationStore#update(Path) + */ + public static T update(Path configurationFile, Class configurationType) { + final var properties = JsonConfigurationProperties.newBuilder().build(); + return update(configurationFile, configurationType, properties); + } + + /** + * Updates a JSON configuration file with a configuration of the given type using a + * {@code JsonConfigurationProperties} object that is built by a builder. + * + * @param configurationFile the configuration file that is updated + * @param configurationType the type of configuration + * @param propertiesConfigurer the consumer used to configure the builder + * @param the configuration type + * @return a newly created configuration initialized with values taken from the configuration file + * @see JsonConfigurationStore#update(Path) + */ + public static T update( + Path configurationFile, + Class configurationType, + Consumer> propertiesConfigurer + ) { + final var builder = JsonConfigurationProperties.newBuilder(); + propertiesConfigurer.accept(builder); + return update(configurationFile, configurationType, builder.build()); + } + + /** + * Updates a JSON configuration file with a configuration of the given type using the given + * {@code JsonConfigurationProperties} object. + * + * @param configurationFile the configuration file that is updated + * @param configurationType the type of configuration + * @param properties the configuration properties + * @param the configuration type + * @return a newly created configuration initialized with values taken from the configuration file + * @see JsonConfigurationStore#update(Path) + */ + public static T update( + Path configurationFile, + Class configurationType, + JsonConfigurationProperties properties + ) { + final var store = new JsonConfigurationStore<>(configurationType, properties); + return store.update(configurationFile); + } + + /** + * Saves a configuration of the given type to the specified JSON file using a + * {@code JsonConfigurationProperties} object with default values. + * + * @param configurationFile the file the configuration is saved to + * @param configurationType the type of configuration + * @param configuration the configuration that is saved + * @param the configuration type + * @see JsonConfigurationStore#save(Object, Path) + */ + public static void save( + Path configurationFile, + Class configurationType, + T configuration + ) { + final var properties = JsonConfigurationProperties.newBuilder().build(); + save(configurationFile, configurationType, configuration, properties); + } + + /** + * Saves a configuration of the given type to the specified JSON file using a + * {@code JsonConfigurationProperties} object that is built by a builder. + * + * @param configurationFile the file the configuration is saved to + * @param configurationType the type of configuration + * @param configuration the configuration that is saved + * @param propertiesConfigurer the consumer used to configure the builder + * @param the configuration type + * @see JsonConfigurationStore#save(Object, Path) + */ + public static void save( + Path configurationFile, + Class configurationType, + T configuration, + Consumer> propertiesConfigurer + ) { + final var builder = JsonConfigurationProperties.newBuilder(); + propertiesConfigurer.accept(builder); + save(configurationFile, configurationType, configuration, builder.build()); + } + + /** + * Saves a configuration of the given type to the specified JSON file using the given + * {@code JsonConfigurationProperties} object. + * + * @param configurationFile the file the configuration is saved to + * @param configurationType the type of configuration + * @param configuration the configuration that is saved + * @param properties the configuration properties + * @param the configuration type + * @see JsonConfigurationStore#save(Object, Path) + */ + public static void save( + Path configurationFile, + Class configurationType, + T configuration, + JsonConfigurationProperties properties + ) { + final var store = new JsonConfigurationStore<>(configurationType, properties); + store.save(configuration, configurationFile); + } + + /** + * Writes a configuration instance to the given output stream using a + * {@code JsonConfigurationProperties} object with default values. + * + * @param outputStream the output stream the configuration is written to + * @param configurationType the type of configuration + * @param configuration the configuration that is saved + * @param the configuration type + * @see JsonConfigurationStore#write(Object, OutputStream) + */ + public static void write( + OutputStream outputStream, + Class configurationType, + T configuration + ) { + final var properties = JsonConfigurationProperties.newBuilder().build(); + write(outputStream, configurationType, configuration, properties); + } + + /** + * Writes a configuration instance to the given output stream using a + * {@code JsonConfigurationProperties} object that is built by a builder. + * + * @param outputStream the output stream the configuration is written to + * @param configurationType the type of configuration + * @param configuration the configuration that is saved + * @param propertiesConfigurer the consumer used to configure the builder + * @param the configuration type + * @see JsonConfigurationStore#write(Object, OutputStream) + */ + public static void write( + OutputStream outputStream, + Class configurationType, + T configuration, + Consumer> propertiesConfigurer + ) { + final var builder = JsonConfigurationProperties.newBuilder(); + propertiesConfigurer.accept(builder); + write(outputStream, configurationType, configuration, builder.build()); + } + + /** + * Writes a configuration instance to the given output stream using the given + * {@code JsonConfigurationProperties} object. + * + * @param outputStream the output stream the configuration is written to + * @param configurationType the type of configuration + * @param configuration the configuration that is saved + * @param properties the configuration properties + * @param the configuration type + * @see JsonConfigurationStore#write(Object, OutputStream) + */ + public static void write( + OutputStream outputStream, + Class configurationType, + T configuration, + JsonConfigurationProperties properties + ) { + final var store = new JsonConfigurationStore<>(configurationType, properties); + store.write(configuration, outputStream); + } +} \ No newline at end of file diff --git a/configlib-json/src/main/java/de/exlll/configlib/JsonWriter.java b/configlib-json/src/main/java/de/exlll/configlib/JsonWriter.java new file mode 100644 index 0000000..7577257 --- /dev/null +++ b/configlib-json/src/main/java/de/exlll/configlib/JsonWriter.java @@ -0,0 +1,60 @@ +package de.exlll.configlib; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static de.exlll.configlib.Validator.requireNonNull; + +/** + * A writer that writes JSON to a file, capable of injecting comments. + *

+ * Note: Standard JSON does not support comments. This writer produces "JSON with Comments" + * (similar to JSON5 or Jackson's ALLOW_JAVA_COMMENTS feature). Ensure your reader + * is configured to allow comments. + */ +final class JsonWriter { + private final OutputStream outputStream; + private final JsonConfigurationProperties properties; + + JsonWriter(OutputStream outputStream, JsonConfigurationProperties properties) { + this.outputStream = requireNonNull(outputStream, "output stream"); + this.properties = requireNonNull(properties, "configuration properties"); + } + + JsonWriter(Path configurationFile, JsonConfigurationProperties properties) { + requireNonNull(configurationFile, "configuration file"); + try { + this.outputStream = Files.newOutputStream(configurationFile); + this.properties = properties; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void writeJson(String json) { + try (BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(outputStream, properties.getCharset()))) { + writer.write(json); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + static int lengthCommonPrefix(List l1, List l2) { + final int maxLen = Math.min(l1.size(), l2.size()); + int result = 0; + for (int i = 0; i < maxLen; i++) { + String s1 = l1.get(i); + String s2 = l2.get(i); + if (s1.equals(s2)) + result++; + else return result; + } + return result; + } +} \ No newline at end of file diff --git a/configlib-json/src/test/java/de/exlll/configlib/ExampleConfigurationJsonTests.java b/configlib-json/src/test/java/de/exlll/configlib/ExampleConfigurationJsonTests.java new file mode 100644 index 0000000..a87d770 --- /dev/null +++ b/configlib-json/src/test/java/de/exlll/configlib/ExampleConfigurationJsonTests.java @@ -0,0 +1,84 @@ +package de.exlll.configlib; + +import com.google.common.jimfs.Jimfs; +import de.exlll.configlib.configurations.ExampleConfigurationA2; +import de.exlll.configlib.configurations.ExampleConfigurationCustom; +import de.exlll.configlib.configurations.ExampleConfigurationNulls; +import de.exlll.configlib.configurations.ExampleInitializer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.awt.Point; +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; + +import static de.exlll.configlib.TestUtils.*; +import static de.exlll.configlib.configurations.ExampleEqualityAsserter.*; + +final class ExampleConfigurationJsonTests { + private final FileSystem fs = Jimfs.newFileSystem(); + private final Path jsonFile = fs.getPath(createPlatformSpecificFilePath("/tmp/config.json")); + + @BeforeEach + void setUp() throws IOException { + Files.createDirectories(jsonFile.getParent()); + } + + @AfterEach + void tearDown() throws IOException { + fs.close(); + } + + @Test + void jsonStoreSavesAndLoadsExampleConfigurationA2() { + var properties = JsonConfigurationProperties.newBuilder() + .addSerializer(Point.class, POINT_SERIALIZER) + .build(); + var store = new JsonConfigurationStore<>(ExampleConfigurationA2.class, properties); + ExampleConfigurationA2 cfg1 = ExampleInitializer.newExampleConfigurationA2(); + store.save(cfg1, jsonFile); + ExampleConfigurationA2 cfg2 = store.load(jsonFile); + assertExampleConfigurationsA2Equal(cfg1, cfg2); + } + + @Test + void jsonStoreSavesAndLoadsExampleConfigurationNullsWithNullCollectionElements1() { + var properties = JsonConfigurationProperties.newBuilder() + .addSerializer(Point.class, POINT_SERIALIZER) + .outputNulls(true) + .inputNulls(true) + .build(); + var store = new JsonConfigurationStore<>(ExampleConfigurationNulls.class, properties); + ExampleConfigurationNulls cfg1 = ExampleInitializer + .newExampleConfigurationNullsWithNullCollectionElements1(); + store.save(cfg1, jsonFile); + ExampleConfigurationNulls cfg2 = store.load(jsonFile); + assertExampleConfigurationsNullsEqual(cfg1, cfg2); + } + + @Test + void jsonStoreSavesAndLoadsExampleConfigurationNullsWithoutNullCollectionElements1() { + var properties = JsonConfigurationProperties.newBuilder() + .addSerializer(Point.class, POINT_SERIALIZER) + .build(); + var store = new JsonConfigurationStore<>(ExampleConfigurationNulls.class, properties); + ExampleConfigurationNulls cfg1 = ExampleInitializer + .newExampleConfigurationNullsWithoutNullCollectionElements1(); + store.save(cfg1, jsonFile); + ExampleConfigurationNulls cfg2 = store.load(jsonFile); + assertExampleConfigurationsNullsEqual(cfg1, cfg2); + } + + @Test + void jsonStoreSavesAndLoadsExampleConfigurationCustom() { + var properties = JsonConfigurationProperties.newBuilder().build(); + var store = new JsonConfigurationStore<>(ExampleConfigurationCustom.class, properties); + ExampleConfigurationCustom config1 = new ExampleConfigurationCustom(); + store.save(config1, jsonFile); + ExampleConfigurationCustom config2 = store.load(jsonFile); + assertExampleConfigurationsCustomEqual(config1, config2); + } +} \ No newline at end of file diff --git a/configlib-json/src/test/java/de/exlll/configlib/JsonConfigurationPropertiesTest.java b/configlib-json/src/test/java/de/exlll/configlib/JsonConfigurationPropertiesTest.java new file mode 100644 index 0000000..a8cc36d --- /dev/null +++ b/configlib-json/src/test/java/de/exlll/configlib/JsonConfigurationPropertiesTest.java @@ -0,0 +1,19 @@ +package de.exlll.configlib; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class JsonConfigurationPropertiesTest { + @Test + void builderCtorCopiesValues() { + JsonConfigurationProperties properties = JsonConfigurationProperties.newBuilder() + .outputNulls(true) + .build() + .toBuilder() + .build(); + + assertThat(properties.outputNulls(), is(true)); + } +} \ No newline at end of file diff --git a/configlib-json/src/test/java/de/exlll/configlib/JsonConfigurationStoreTest.java b/configlib-json/src/test/java/de/exlll/configlib/JsonConfigurationStoreTest.java new file mode 100644 index 0000000..c52d2c3 --- /dev/null +++ b/configlib-json/src/test/java/de/exlll/configlib/JsonConfigurationStoreTest.java @@ -0,0 +1,587 @@ +package de.exlll.configlib; + +import com.google.common.jimfs.Jimfs; +import de.exlll.configlib.ConfigurationProperties.EnvVarResolutionConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.awt.Point; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static de.exlll.configlib.TestUtils.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.*; + +class JsonConfigurationStoreTest { + private final FileSystem fs = Jimfs.newFileSystem(); + + private final String jsonFilePath = createPlatformSpecificFilePath("/tmp/config.json"); + private final String abcFilePath = createPlatformSpecificFilePath("/a/b/c.json"); + private final Path jsonFile = fs.getPath(jsonFilePath); + private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + @BeforeEach + void setUp() throws IOException { + Files.createDirectories(jsonFile.getParent()); + } + + @AfterEach + void tearDown() throws IOException { + fs.close(); + } + + @Configuration + static final class A { + String s = "S1"; + @Comment("A comment") + Integer i = null; + } + + @Test + void saveRequiresNonNullArguments() { + JsonConfigurationStore store = newDefaultStore(A.class); + + assertThrowsNullPointerException( + () -> store.save(null, jsonFile), + "configuration" + ); + + assertThrowsNullPointerException( + () -> store.save(new A(), null), + "configuration file" + ); + } + + @Test + void writeRequiresNonNullArguments() { + JsonConfigurationStore store = newDefaultStore(A.class); + + assertThrowsNullPointerException( + () -> store.write(null, new ByteArrayOutputStream()), + "configuration" + ); + + assertThrowsNullPointerException( + () -> store.write(new A(), null), + "output stream" + ); + } + + @Test + void loadRequiresNonNullArguments() { + JsonConfigurationStore store = newDefaultStore(A.class); + + assertThrowsNullPointerException( + () -> store.load(null), + "configuration file" + ); + } + + @Test + void readRequiresNonNullArguments() { + JsonConfigurationStore store = newDefaultStore(A.class); + + assertThrowsNullPointerException( + () -> store.read(null), + "input stream" + ); + } + + @Test + void updateRequiresNonNullArguments() { + JsonConfigurationStore store = newDefaultStore(A.class); + + assertThrowsNullPointerException( + () -> store.update(null), + "configuration file" + ); + } + + @Test + void saveAndWrite() { + JsonConfigurationProperties properties = JsonConfigurationProperties.newBuilder() + .header("The\nHeader") + .footer("The\nFooter") + .outputNulls(true) + .setNameFormatter(String::toUpperCase) + .build(); + + JsonConfigurationStore store = new JsonConfigurationStore<>(A.class, properties); + + store.save(new A(), jsonFile); + store.write(new A(), outputStream); + + String expected = + """ + { + "S" : "S1", + "I" : null + } + """; + + assertEquals(expected, readFile(jsonFile)); + assertEquals(expected, outputStream.toString()); + } + + @Test + void saveAndWriteRecord() { + record R(String s, @Comment("A comment") Integer i) {} + JsonConfigurationProperties properties = JsonConfigurationProperties.newBuilder() + .header("The\nHeader") + .footer("The\nFooter") + .outputNulls(true) + .setNameFormatter(String::toUpperCase) + .build(); + JsonConfigurationStore store = new JsonConfigurationStore<>(R.class, properties); + + store.save(new R("S1", null), jsonFile); + store.write(new R("S1", null), outputStream); + + String expected = + """ + { + "S" : "S1", + "I" : null + } + """; + + assertEquals(expected, readFile(jsonFile)); + assertEquals(expected, outputStream.toString()); + } + + @Configuration + static final class B { + String s = "S1"; + String t = "T1"; + Integer i = 1; + } + + @Test + void loadAndRead() throws IOException { + JsonConfigurationProperties properties = JsonConfigurationProperties.newBuilder() + .inputNulls(true) + .setNameFormatter(String::toUpperCase) + .build(); + JsonConfigurationStore store = new JsonConfigurationStore<>(B.class, properties); + + String actual = """ + { + "S" : "S2", + "t" : "T2", + "I" : null + } + """; + Files.writeString(jsonFile, actual); + outputStream.writeBytes(actual.getBytes()); + + B config1 = store.load(jsonFile); + assertEquals("S2", config1.s); + assertEquals("T1", config1.t); + assertNull(config1.i); + + B config2 = store.read(inputFromOutput()); + assertEquals("S2", config2.s); + assertEquals("T1", config2.t); + assertNull(config2.i); + } + + @Test + void loadAndReadRecord() throws IOException { + record R(String s, String t, Integer i) {} + JsonConfigurationProperties properties = JsonConfigurationProperties.newBuilder() + .inputNulls(true) + .setNameFormatter(String::toUpperCase) + .build(); + JsonConfigurationStore store = new JsonConfigurationStore<>(R.class, properties); + + String actual = """ + { + "S" : "S2", + "t" : "T2", + "I" : null + } + """; + Files.writeString(jsonFile, actual); + outputStream.writeBytes(actual.getBytes()); + + R config1 = store.load(jsonFile); + assertEquals("S2", config1.s); + assertNull(config1.t); + assertNull(config1.i); + + R config2 = store.read(inputFromOutput()); + assertEquals("S2", config2.s); + assertNull(config2.t); + assertNull(config2.i); + } + + @Configuration + static final class C { + int i; + } + + @Test + void loadAndReadInvalidJson() throws IOException { + JsonConfigurationStore store = newDefaultStore(C.class); + + String actual = "{ invalid json"; + + Files.writeString(jsonFile, actual); + outputStream.writeBytes(actual.getBytes()); + + assertThrowsConfigurationException( + () -> store.load(jsonFile), + String.format("The configuration file at %s could not be loaded.", jsonFilePath) + ); + assertThrowsConfigurationException( + () -> store.read(inputFromOutput()), + "The input stream does not contain valid JSON." + ); + } + + @Test + void loadAndReadEmptyJson() throws IOException { + JsonConfigurationStore store = newDefaultStore(C.class); + + Files.writeString(jsonFile, "null"); + outputStream.writeBytes("null".getBytes()); + + assertThrowsConfigurationException( + () -> store.load(jsonFile), + "The JSON content is empty or null." + ); + assertThrowsConfigurationException( + () -> store.read(inputFromOutput()), + "The JSON content is empty or null." + ); + } + + @Test + void loadAndReadNonMapJson() throws IOException { + JsonConfigurationStore store = newDefaultStore(C.class); + + Files.writeString(jsonFile, "\"a\""); + outputStream.writeBytes("\"a\"".getBytes()); + + assertThrowsConfigurationException( + () -> store.load(jsonFile), + "The JSON content does not represent a configuration. " + + "A valid configuration must be a JSON object (Map) but found " + + "'String'." + ); + assertThrowsConfigurationException( + () -> store.read(inputFromOutput()), + "The JSON content does not represent a configuration. " + + "A valid configuration must be a JSON object (Map) but found " + + "'String'." + ); + } + + @Configuration + static final class D { + Point point = new Point(1, 2); + } + + @Test + void saveAndWriteConfigurationWithInvalidTargetType() { + JsonConfigurationProperties properties = JsonConfigurationProperties.newBuilder() + .addSerializer(Point.class, POINT_IDENTITY_SERIALIZER) + .build(); + JsonConfigurationStore store = new JsonConfigurationStore<>(D.class, properties); + + String exceptionMessage = + "Serialization of value 'java.awt.Point[x=1,y=2]' for element " + + "'java.awt.Point de.exlll.configlib.JsonConfigurationStoreTest$D.point' of " + + "type 'class de.exlll.configlib.JsonConfigurationStoreTest$D' failed. " + + "The serializer produced an invalid target type."; + assertThrowsConfigurationException(() -> store.save(new D(), jsonFile), exceptionMessage); + assertThrowsConfigurationException(() -> store.write(new D(), outputStream), exceptionMessage); + } + + @Test + void saveCreatesParentDirectoriesIfPropertyTrue() { + JsonConfigurationStore store = newDefaultStore(A.class); + + Path file = fs.getPath(abcFilePath); + store.save(new A(), file); + + assertTrue(Files.exists(file.getParent())); + assertTrue(Files.exists(file)); + } + + @Test + void saveDoesNotCreateParentDirectoriesIfPropertyFalse() { + JsonConfigurationProperties properties = JsonConfigurationProperties.newBuilder() + .createParentDirectories(false) + .build(); + JsonConfigurationStore store = new JsonConfigurationStore<>(A.class, properties); + + Path file = fs.getPath(abcFilePath); + assertThrowsRuntimeException( + () -> store.save(new A(), file), + String.format("java.nio.file.NoSuchFileException: %s", abcFilePath) + ); + } + + @Configuration + static final class E { + int i = 10; + int j = 11; + + public E() {} + + public E(int i, int j) { + this.i = i; + this.j = j; + } + } + + @Test + void updateCreatesConfigurationFileIfItDoesNotExist() { + JsonConfigurationStore store = newDefaultStore(E.class); + + assertFalse(Files.exists(jsonFile)); + E config = store.update(jsonFile); + assertEquals("{\n \"i\" : 10,\n \"j\" : 11\n}\n", readFile(jsonFile)); + assertEquals(10, config.i); + assertEquals(11, config.j); + } + + @Test + void updateCreatesConfigurationFileIfItDoesNotExistRecord() { + record R(int i, char c, String s) {} + JsonConfigurationStore store = new JsonConfigurationStore<>( + R.class, + JsonConfigurationProperties.newBuilder().outputNulls(true).build() + ); + + assertFalse(Files.exists(jsonFile)); + R config = store.update(jsonFile); + assertEquals( + """ + { + "i" : 0, + "c" : "\\u0000", + "s" : null + } + """, + readFile(jsonFile) + ); + assertEquals(0, config.i); + assertEquals('\0', config.c); + assertNull(config.s); + } + + @Test + void updateCreatesConfigurationFileIfItDoesNotExistRecordNoParamCtor() { + record R(int i, char c, String s) { + R() {this(10, 'c', "s");} + } + JsonConfigurationStore store = newDefaultStore(R.class); + + assertFalse(Files.exists(jsonFile)); + R config = store.update(jsonFile); + assertEquals( + """ + { + "i" : 10, + "c" : "c", + "s" : "s" + } + """, + readFile(jsonFile) + ); + assertEquals(10, config.i); + assertEquals('c', config.c); + assertEquals("s", config.s); + } + + @Test + void updateLoadsConfigurationFileIfItDoesExist() throws IOException { + JsonConfigurationStore store = newDefaultStore(E.class); + + Files.writeString(jsonFile, "{\"i\": 20}"); + E config = store.update(jsonFile); + assertEquals(20, config.i); + assertEquals(11, config.j); + } + + @Test + void updateLoadsConfigurationFileIfItDoesExistRecord() throws IOException { + record R(int i, int j) {} + JsonConfigurationStore store = newDefaultStore(R.class); + + Files.writeString(jsonFile, "{\"i\": 20}"); + R config = store.update(jsonFile); + assertEquals(20, config.i); + assertEquals(0, config.j); + } + + @Test + void updateUpdatesFile() throws IOException { + JsonConfigurationStore store = newDefaultStore(E.class); + + Files.writeString(jsonFile, "{\"i\": 20, \"k\": 30}"); + E config = store.update(jsonFile); + assertEquals(20, config.i); + assertEquals(11, config.j); + assertEquals("{\n \"i\" : 20,\n \"j\" : 11\n}\n", readFile(jsonFile)); + } + + @Test + void updateUpdatesFileRecord() throws IOException { + record R(int i, int j) {} + JsonConfigurationStore store = newDefaultStore(R.class); + + Files.writeString(jsonFile, "{\"i\": 20, \"k\": 30}"); + R config = store.update(jsonFile); + assertEquals(20, config.i); + assertEquals(0, config.j); + assertEquals("{\n \"i\" : 20,\n \"j\" : 0\n}\n", readFile(jsonFile)); + } + + private static JsonConfigurationStore newDefaultStore(Class configType) { + JsonConfigurationProperties properties = JsonConfigurationProperties.newBuilder().build(); + return new JsonConfigurationStore<>(configType, properties); + } + + private InputStream inputFromOutput() { + return new ByteArrayInputStream(outputStream.toByteArray()); + } + + @Test + void allJsonIntegersAreLoadedAsLongs() throws IOException { + final var mapper = JsonConfigurationStore.newObjectMapper(); + final var map = mapper.readValue( + """ + { + "a": 1, + "b": 2147483647, + "c": 2147483648, + "d": -2147483648, + "e": -2147483649 + } + """, + Map.class + ); + assertThat(map.get("a"), is(1L)); + assertThat(map.get("b"), is(2147483647L)); + assertThat(map.get("c"), is(2147483648L)); + assertThat(map.get("d"), is(-2147483648L)); + assertThat(map.get("e"), is(-2147483649L)); + } + + @Configuration + static final class F { + String s = "S1"; + Integer i = 2; + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void updateResolvesEnvVarsIfFileDoesOrDoesNotExist(boolean createFile) { + JsonConfigurationProperties properties = JsonConfigurationProperties.newBuilder() + .outputNulls(true) + .setEnvVarResolutionConfiguration(EnvVarResolutionConfiguration.resolveEnvVarsWithPrefix("PREFIX", false)) + .build(); + JsonConfigurationStore store = new JsonConfigurationStore<>( + F.class, + properties, + new MapEnvironment(Map.of( + "PREFIX_S", "S2", + "PREFIX_I", "10" + )) + ); + if (createFile) store.save(new F(), jsonFile); + F config = store.update(jsonFile); + assertThat(config.s, is("S2")); + assertThat(config.i, is(10)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void tryCreateParentDirectoriesDoesNotThrowIfParentIsNull(boolean createParentDirectories) { + JsonConfigurationProperties properties = JsonConfigurationProperties.newBuilder() + .createParentDirectories(createParentDirectories) + .build(); + JsonConfigurationStore store = new JsonConfigurationStore<>( + A.class, + properties + ); + + Path path = fs.getPath("config.json"); + assertDoesNotThrow(() -> store.tryCreateParentDirectories(path)); + } + + private record ThrowingWhileSerializingSerializer() + implements Serializer { + + @Override + public String serialize(String element) { + throw new UnsupportedOperationException(element); + } + + @Override + public String deserialize(String element) { + return element; + } + } + + @Configuration + private static final class G { + private String content = "-"; + } + + @Test + void saveDoesNotOverwriteConfigurationFileContentsOnJsonDumpFailure() throws IOException { + JsonConfigurationStore store = new JsonConfigurationStore<>( + G.class, + JsonConfigurationProperties.newBuilder() + .addSerializer(String.class, new ThrowingWhileSerializingSerializer()) + .build() + ); + + String content = "{\"content\": \"abcde\"}"; + Files.writeString(jsonFile, content); + + G config = store.load(jsonFile); + assertThat(config.content, is("abcde")); + assertThrows( + UnsupportedOperationException.class, + () -> store.save(config, jsonFile) + ); + assertThat(readFile(jsonFile), is(content)); + } + + @Test + void writeDoesNotOverwriteStreamContentsOnJsonDumpFailure() { + JsonConfigurationStore store = new JsonConfigurationStore<>( + G.class, + JsonConfigurationProperties.newBuilder() + .addSerializer(String.class, new ThrowingWhileSerializingSerializer()) + .build() + ); + + String content = "{\"content\": \"abcde\"}"; + outputStream.writeBytes(content.getBytes()); + + G config = store.read(inputFromOutput()); + assertThat(config.content, is("abcde")); + assertThrows( + UnsupportedOperationException.class, + () -> store.write(config, outputStream) + ); + assertThat(outputStream.toString(), is(content)); + } +} \ No newline at end of file diff --git a/configlib-json/src/test/java/de/exlll/configlib/JsonConfigurationsTest.java b/configlib-json/src/test/java/de/exlll/configlib/JsonConfigurationsTest.java new file mode 100644 index 0000000..3b156d0 --- /dev/null +++ b/configlib-json/src/test/java/de/exlll/configlib/JsonConfigurationsTest.java @@ -0,0 +1,320 @@ +package de.exlll.configlib; + +import com.google.common.jimfs.Jimfs; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static de.exlll.configlib.TestUtils.asList; +import static de.exlll.configlib.TestUtils.createPlatformSpecificFilePath; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class JsonConfigurationsTest { + private static final FieldFilter includeI = field -> field.getName().equals("i"); + private final FileSystem fs = Jimfs.newFileSystem(); + private final Path jsonFile = fs.getPath(createPlatformSpecificFilePath("/tmp/config.json")); + private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + @BeforeEach + void setUp() throws IOException { + Files.createDirectories(jsonFile.getParent()); + } + + @AfterEach + void tearDown() throws IOException { + fs.close(); + } + + @Configuration + private static final class Config { + int i = 10; + int j = 11; + } + + @Test + void saveJsonConfiguration1() { + Config configuration = new Config(); + + JsonConfigurations.save(jsonFile, Config.class, configuration); + assertEquals("{\n \"i\" : 10,\n \"j\" : 11\n}\n", TestUtils.readFile(jsonFile)); + + configuration.i = 20; + JsonConfigurations.save(jsonFile, Config.class, configuration); + assertEquals("{\n \"i\" : 20,\n \"j\" : 11\n}\n", TestUtils.readFile(jsonFile)); + } + + @Test + void writeJsonConfiguration1() { + Config configuration = new Config(); + + JsonConfigurations.write(outputStream, Config.class, configuration); + assertEquals("{\n \"i\" : 10,\n \"j\" : 11\n}\n", outputStream.toString()); + + outputStream.reset(); + + configuration.i = 20; + JsonConfigurations.write(outputStream, Config.class, configuration); + assertEquals("{\n \"i\" : 20,\n \"j\" : 11\n}\n", outputStream.toString()); + } + + + @Test + void saveJsonConfiguration2() { + Config configuration = new Config(); + + JsonConfigurations.save( + jsonFile, Config.class, configuration, + builder -> builder.setFieldFilter(includeI) + ); + assertEquals("{\n \"i\" : 10\n}\n", TestUtils.readFile(jsonFile)); + } + + @Test + void writeJsonConfiguration2() { + Config configuration = new Config(); + + JsonConfigurations.write( + outputStream, Config.class, configuration, + builder -> builder.setFieldFilter(includeI) + ); + assertEquals("{\n \"i\" : 10\n}\n", outputStream.toString()); + } + + @Test + void saveJsonConfiguration3() { + Config configuration = new Config(); + + JsonConfigurations.save( + jsonFile, Config.class, configuration, + JsonConfigurationProperties.newBuilder().setFieldFilter(includeI).build() + ); + assertEquals("{\n \"i\" : 10\n}\n", TestUtils.readFile(jsonFile)); + } + + + @Test + void writeJsonConfiguration3() { + Config configuration = new Config(); + + JsonConfigurations.write( + outputStream, Config.class, configuration, + JsonConfigurationProperties.newBuilder().setFieldFilter(includeI).build() + ); + assertEquals("{\n \"i\" : 10\n}\n", outputStream.toString()); + } + + @Test + void loadJsonConfiguration1() { + writeStringToFile("{\"i\": 20, \"k\": 30}"); + Config config = JsonConfigurations.load(jsonFile, Config.class); + assertConfigEquals(config, 20, 11); + + writeStringToFile("{\"i\": 20, \"j\": 30}"); + config = JsonConfigurations.load(jsonFile, Config.class); + assertConfigEquals(config, 20, 30); + } + + @Test + void readJsonConfiguration1() { + writeStringToStream("{\"i\": 20, \"k\": 30}"); + Config config = JsonConfigurations.read(inputFromOutput(), Config.class); + assertConfigEquals(config, 20, 11); + + outputStream.reset(); + + writeStringToStream("{\"i\": 20, \"j\": 30}"); + config = JsonConfigurations.read(inputFromOutput(), Config.class); + assertConfigEquals(config, 20, 30); + } + + @Test + void loadJsonConfiguration2() { + writeStringToFile("{\"i\": 20, \"j\": 30}"); + Config config = JsonConfigurations.load( + jsonFile, Config.class, + builder -> builder.setFieldFilter(includeI) + ); + assertConfigEquals(config, 20, 11); + } + + @Test + void readJsonConfiguration2() { + writeStringToStream("{\"i\": 20, \"j\": 30}"); + Config config = JsonConfigurations.read( + inputFromOutput(), Config.class, + builder -> builder.setFieldFilter(includeI) + ); + assertConfigEquals(config, 20, 11); + } + + @Test + void loadJsonConfiguration3() { + writeStringToFile("{\"i\": 20, \"j\": 30}"); + + Config config = JsonConfigurations.load( + jsonFile, Config.class, + JsonConfigurationProperties.newBuilder().setFieldFilter(includeI).build() + ); + + assertConfigEquals(config, 20, 11); + } + + @Test + void readJsonConfiguration3() { + writeStringToStream("{\"i\": 20, \"j\": 30}"); + + Config config = JsonConfigurations.read( + inputFromOutput(), Config.class, + JsonConfigurationProperties.newBuilder().setFieldFilter(includeI).build() + ); + + assertConfigEquals(config, 20, 11); + } + + @Test + void updateJsonConfiguration1() { + Config config = JsonConfigurations.update(jsonFile, Config.class); + assertConfigEquals(config, 10, 11); + assertEquals("{\n \"i\" : 10,\n \"j\" : 11\n}\n", TestUtils.readFile(jsonFile)); + + writeStringToFile("{\"i\": 20, \"k\": 30}"); + config = JsonConfigurations.update(jsonFile, Config.class); + assertConfigEquals(config, 20, 11); + assertEquals("{\n \"i\" : 20,\n \"j\" : 11\n}\n", TestUtils.readFile(jsonFile)); + } + + @Test + void updateJsonConfiguration2() { + Config config = JsonConfigurations.update( + jsonFile, Config.class, + builder -> builder.setFieldFilter(includeI) + ); + assertConfigEquals(config, 10, 11); + assertEquals("{\n \"i\" : 10\n}\n", TestUtils.readFile(jsonFile)); + } + + @Test + void updateJsonConfiguration3() { + Config config = JsonConfigurations.update( + jsonFile, Config.class, + JsonConfigurationProperties.newBuilder().setFieldFilter(includeI).build() + ); + assertConfigEquals(config, 10, 11); + assertEquals("{\n \"i\" : 10\n}\n", TestUtils.readFile(jsonFile)); + } + + private static void assertConfigEquals(Config config, int i, int j) { + assertEquals(i, config.i); + assertEquals(j, config.j); + } + + private void writeStringToFile(String string) { + try { + Files.writeString(jsonFile, string); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void writeStringToStream(String string) { + outputStream.writeBytes(string.getBytes()); + } + + private InputStream inputFromOutput() { + return new ByteArrayInputStream(outputStream.toByteArray()); + } + + @Configuration + private static final class DoublesConfig { + double d; + Double boxed; + List list; + } + + @Test + void loadJsonConfigurationDoublesAllDecimal() { + writeStringToFile( + """ + { + "d": 10.0, + "boxed": 20.0, + "list": [ + 1.0, + 2.0, + 3.0 + ] + } + """ + ); + DoublesConfig config = JsonConfigurations.load(jsonFile, DoublesConfig.class); + assertEquals(10.0, config.d); + assertEquals(20.0, config.boxed); + assertEquals(asList(1.0, 2.0, 3.0), config.list); + } + + @Test + void loadJsonConfigurationDoublesUnboxed() { + writeStringToFile("{\"d\": 10}"); + DoublesConfig config = JsonConfigurations.load(jsonFile, DoublesConfig.class); + assertEquals(10.0, config.d); + } + + @Test + void loadJsonConfigurationDoublesBoxed() { + writeStringToFile("{\"boxed\": 20}"); + DoublesConfig config = JsonConfigurations.load(jsonFile, DoublesConfig.class); + assertEquals(20.0, config.boxed); + } + + @Test + void loadJsonConfigurationDoublesCollection() { + writeStringToFile( + """ + { + "list": [ + 1.0, + 2, + 3.0 + ] + } + """ + ); + DoublesConfig config = JsonConfigurations.load(jsonFile, DoublesConfig.class); + assertEquals(asList(1.0, 2.0, 3.0), config.list); + } + + @Test + void loadJsonConfigurationDoublesWithNulls() { + writeStringToFile( + """ + { + "boxed": null, + "list": [ + null, + null, + 1.0, + 2 + ] + } + """ + ); + DoublesConfig config = JsonConfigurations.load( + jsonFile, + DoublesConfig.class, + builder -> builder.inputNulls(true) + ); + assertEquals(0.0, config.d); + assertNull(config.boxed); + assertEquals(asList(null, null, 1.0, 2.0), config.list); + } +} \ No newline at end of file diff --git a/configlib-json/src/test/java/de/exlll/configlib/JsonWriterTest.java b/configlib-json/src/test/java/de/exlll/configlib/JsonWriterTest.java new file mode 100644 index 0000000..b8187cb --- /dev/null +++ b/configlib-json/src/test/java/de/exlll/configlib/JsonWriterTest.java @@ -0,0 +1,477 @@ +package de.exlll.configlib; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.jimfs.Jimfs; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.function.Consumer; + +import static de.exlll.configlib.TestUtils.createPlatformSpecificFilePath; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SuppressWarnings("unused") +class JsonWriterTest { + private final FileSystem fs = Jimfs.newFileSystem(); + private final Path jsonFile = fs.getPath(createPlatformSpecificFilePath("/tmp/config.json")); + private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + @BeforeEach + void setUp() throws IOException { + Files.createDirectories(jsonFile.getParent()); + } + + @AfterEach + void tearDown() throws IOException { + fs.close(); + } + + @Configuration + static final class A { + String s = ""; + } + + @Test + void writeJsonWithNoComments() { + writeConfigToFile(A.class); + writeConfigToStream(A.class); + + assertFileContentEquals("{\n \"s\" : \"\"\n}\n"); + assertStreamContentEquals("{\n \"s\" : \"\"\n}\n"); + } + + @Test + void writeJsonWithHeaderAndFooter() { + Consumer> builderConsumer = builder -> builder + .header("This is a \n\n \nheader.") + .footer("That is a\n\n \nfooter."); + + writeConfigToFile(A.class, builderConsumer); + writeConfigToStream(A.class, builderConsumer); + + String expected = """ + { + "s" : "" + } + """; + + assertFileContentEquals(expected); + assertStreamContentEquals(expected); + } + + @Configuration + static final class B { + String s = "s"; + } + + @Configuration + static final class C { + Map mapStringInteger = Map.of("1", 2); + Map mapIntegerString = Map.of(2, "1"); + } + + @Configuration + static final class D { + String s1 = "s1"; + String s2 = "s2"; + } + + @Test + void writeJsonEmptyComments() { + writeConfigToFile(D.class); + writeConfigToStream(D.class); + + String expected = """ + { + "s1" : "s1", + "s2" : "s2" + } + """; + + assertFileContentEquals(expected); + assertStreamContentEquals(expected); + } + + @Configuration + static final class E1 { + Map> m = Map.of("c", Map.of("i", 1)); + E2 e2 = new E2(); + } + + @Configuration + static final class E2 { + Map m = Map.of("i", 1); + E3 e3 = new E3(); + int j = 10; + } + + @Configuration + static final class E3 { + int i = 1; + } + + + @Configuration + static final class F1 { + Map m1 = Map.of("i", 1); + F2 f2 = new F2(); + Map m2 = Map.of("i", 1); + } + + @Configuration + static final class F2 { + int i; + } + + @Configuration + static final class G1 { + G2 g2 = new G2(); + } + + @Configuration + static final class G2 { + G3 g3 = new G3(); + } + + @Configuration + static final class G3 { + G4 g4 = new G4(); + } + + @Configuration + static final class G4 { + int g3; + int g4; + } + + + @Configuration + static final class H1 { + H2 h21 = new H2(); + H2 h22 = null; + } + + @Configuration + static final class H2 { + int j = 10; + } + + @Test + void writeJsonNullFields1() { + writeConfigToFile(H1.class); + writeConfigToStream(H1.class); + + String expected = """ + { + "h21" : { + "j" : 10 + } + } + """; + + assertFileContentEquals(expected); + assertStreamContentEquals(expected); + } + + @Test + void writeJsonNullFields2() { + writeConfigToFile(H1.class, builder -> builder.outputNulls(true)); + writeConfigToStream(H1.class, builder -> builder.outputNulls(true)); + + String expected = """ + { + "h21" : { + "j" : 10 + }, + "h22" : null + } + """; + + assertFileContentEquals(expected); + assertStreamContentEquals(expected); + } + + @Configuration + static class J1 { + String sJ1 = "sj1"; + } + + static final class J2 extends J1 { + String sJ2 = "sj2"; + } + + @Configuration + static class K1 { + J1 k1J1 = new J1(); + J2 k1J2 = new J2(); + } + + static final class K2 extends K1 { + J1 k2J1 = new J1(); + J2 k2J2 = new J2(); + } + + @Test + void writeJsonInheritance() { + writeConfigToFile(K2.class); + writeConfigToStream(K2.class); + + String expected = """ + { + "k1J1" : { + "sJ1" : "sj1" + }, + "k1J2" : { + "sJ1" : "sj1", + "sJ2" : "sj2" + }, + "k2J1" : { + "sJ1" : "sj1" + }, + "k2J2" : { + "sJ1" : "sj1", + "sJ2" : "sj2" + } + } + """; + + assertFileContentEquals(expected); + assertStreamContentEquals(expected); + } + + record R1(@Comment("Hello") int i, int j, @Comment("World") int k) { + } + + @Configuration + static class L1 { + R1 r1 = new R1(1, 2, 3); + } + + @Test + void writeJsonConfigWithRecord() { + writeConfigToFile(L1.class); + writeConfigToStream(L1.class); + + String expected = """ + { + "r1" : { + "i" : 1, + "j" : 2, + "k" : 3 + } + }"""; + + assertFileContentEquals(expected); + assertStreamContentEquals(expected); + } + + record R2(@Comment("r2i") int i, int j, @Comment("r2k") int k) { + } + + record R3(@Comment("r3r2") R2 r2) { + } + + record R4(@Comment("r4m1") M1 m1, @Comment("r4r3") R3 r3) { + } + + @Configuration + static class M1 { + R2 r2 = new R2(1, 2, 3); + R3 r3 = new R3(new R2(4, 5, 6)); + } + + @Configuration + static class M2 { + R4 r4 = new R4(new M1(), new R3(new R2(7, 8, 9))); + } + + @Test + void writeJsonConfigWithRecordNested() { + writeConfigToFile(M2.class); + writeConfigToStream(M2.class); + + String expected = """ + { + "r4" : { + "m1" : { + "r2" : { + "i" : 1, + "j" : 2, + "k" : 3 + }, + "r3" : { + "r2" : { + "i" : 4, + "j" : 5, + "k" : 6 + } + } + }, + "r3" : { + "r2" : { + "i" : 7, + "j" : 8, + "k" : 9 + } + } + } + } + """; + + assertFileContentEquals(expected); + assertStreamContentEquals(expected); + } + + @Test + void lengthCommonPrefix() { + List ab = List.of("a", "b"); + List abc = List.of("a", "b", "c"); + List abcd = List.of("a", "b", "c", "d"); + List aef = List.of("a", "e", "f"); + List def = List.of("d", "e", "f"); + + assertEquals(2, JsonWriter.lengthCommonPrefix(ab, ab)); + assertEquals(2, JsonWriter.lengthCommonPrefix(abc, ab)); + assertEquals(2, JsonWriter.lengthCommonPrefix(ab, abc)); + assertEquals(2, JsonWriter.lengthCommonPrefix(ab, abcd)); + assertEquals(3, JsonWriter.lengthCommonPrefix(abc, abc)); + assertEquals(3, JsonWriter.lengthCommonPrefix(abc, abcd)); + + assertEquals(1, JsonWriter.lengthCommonPrefix(ab, aef)); + assertEquals(1, JsonWriter.lengthCommonPrefix(abcd, aef)); + + assertEquals(0, JsonWriter.lengthCommonPrefix(ab, def)); + assertEquals(0, JsonWriter.lengthCommonPrefix(abcd, def)); + } + + String readFile(Charset charset) { + return TestUtils.readFile(jsonFile, charset); + } + + String readOutputStream() { + return outputStream.toString(); + } + + void assertFileContentEquals(String expected, Charset charset) { + assertEquals(expected, readFile(charset)); + } + + void assertFileContentEquals(String expected) { + assertFileContentEquals(expected, Charset.defaultCharset()); + } + + void assertStreamContentEquals(String expected) { + assertEquals(expected, readOutputStream()); + } + + void writeConfigToFile(Class cls) { + writeConfigToFile(cls, builder -> { + }); + } + + void writeConfigToFile(Class cls, Consumer> configurer) { + JsonWriterArguments args = argsFromConfig( + cls, + Reflect.callNoParamConstructor(cls), + configurer + ); + JsonWriter writer = new JsonWriter(jsonFile, args.properties); + writer.writeJson(args.json); + } + + void writeConfigToStream(Class cls) { + writeConfigToStream(cls, builder -> { + }); + } + + void writeConfigToStream(Class cls, Consumer> configurer) { + JsonWriterArguments args = argsFromConfig( + cls, + Reflect.callNoParamConstructor(cls), + configurer + ); + JsonWriter writer = new JsonWriter(outputStream, args.properties); + writer.writeJson(args.json); + } + + record JsonWriterArguments( + String json, + Queue nodes, + JsonConfigurationProperties properties + ) { + } + + static JsonWriterArguments argsFromConfig( + Class t, + T c, + Consumer> configurer + ) { + JsonConfigurationProperties.Builder builder = JsonConfigurationProperties.newBuilder(); + configurer.accept(builder); + JsonConfigurationProperties properties = builder.build(); + + ConfigurationSerializer serializer = new ConfigurationSerializer<>(t, properties); + Map serialize = serializer.serialize(c); + ObjectMapper mapper = JsonConfigurationStore.newObjectMapper(); + String json; + try { + json = mapper.writeValueAsString(serialize); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + CommentNodeExtractor extractor = new CommentNodeExtractor(properties); + Queue nodes = extractor.extractCommentNodes(c); + return new JsonWriterArguments(json, nodes, properties); + } + + @Configuration + static class N { + String s = "テスト test"; + } + + @Test + void writeJsonToFileInUTF8WithUnicodeCharacters() { + Consumer> builderConsumer = builder -> builder + .charset(StandardCharsets.UTF_8); + + writeConfigToFile(N.class, builderConsumer); + + String expected = """ + { + "s" : "テスト test" + } + """; + + assertFileContentEquals(expected, StandardCharsets.UTF_8); + } + + @Test + void writeJsonToFileInASCIIWithUnicodeCharacters() { + Consumer> builderConsumer = builder -> builder + .charset(StandardCharsets.US_ASCII); + + writeConfigToFile(N.class, builderConsumer); + + // UTF-8 characters will be replaced with question mark points + String expected = """ + { + "s" : "??? test" + } + """; + + assertFileContentEquals(expected, StandardCharsets.US_ASCII); + } + +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b..19a6bde 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index bfb48b0..f74a21e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,7 @@ rootProject.name = "configlib" include("configlib-core") include("configlib-yaml") +include("configlib-json") include("configlib-paper") include("configlib-waterfall") include("configlib-velocity")