From 3b42dec35fc347156c68e2c4e9dbcd1ad25ba8a7 Mon Sep 17 00:00:00 2001 From: el211 Date: Wed, 3 Jun 2026 23:09:54 +0200 Subject: [PATCH 01/13] feat: add structura-writers module Adds the structura-writers module with YAML serialization support: - StructuraWriters: static API to write a Loadable to a file or generate defaults - LoadableSerializer: converts a record to YAML (kebab-case keys, nested records, collections, custom writers) - DefaultInstanceFactory: instantiates a record from @Default* annotations and zero-values - CustomWriterRegistry: singleton registry for custom Writer serializers - Basic test coverage for the write/saveDefault/serializer/factory paths --- .claude/settings.local.json | 10 ++ settings.gradle.kts | 4 +- structura-writers/build.gradle.kts | 45 +++++++ .../structura/writers/StructuraWriters.java | 57 +++++++++ .../exceptions/StructuraWriterException.java | 15 +++ .../factory/DefaultInstanceFactory.java | 82 +++++++++++++ .../registries/CustomWriterRegistry.java | 63 ++++++++++ .../serializer/LoadableSerializer.java | 110 ++++++++++++++++++ .../structura/writers/writer/Writer.java | 15 +++ .../writers/StructuraWritersTest.java | 61 ++++++++++ .../factory/DefaultInstanceFactoryTest.java | 42 +++++++ .../writers/fixtures/WriterTestModels.java | 82 +++++++++++++ .../serializer/LoadableSerializerTest.java | 51 ++++++++ 13 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.local.json create mode 100644 structura-writers/build.gradle.kts create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/writer/Writer.java create mode 100644 structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java create mode 100644 structura-writers/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java create mode 100644 structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java create mode 100644 structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..60b3047 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew :structura-writers:test)", + "Bash(./gradlew test:*)", + "Bash(./gradlew :structura-writers:compileJava)", + "Bash(./gradlew :structura-writers:test --info)" + ] + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 08842a3..fec4e94 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,4 +8,6 @@ pluginManagement { } gradlePluginPortal() } -} \ No newline at end of file +} + +include("structura-writers") \ No newline at end of file diff --git a/structura-writers/build.gradle.kts b/structura-writers/build.gradle.kts new file mode 100644 index 0000000..5a67b77 --- /dev/null +++ b/structura-writers/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("java-library") +} + +group = "fr.traqueur" +version = rootProject.property("version") as String + +repositories { + mavenCentral() +} + +dependencies { + api(project(":")) + compileOnly("org.yaml:snakeyaml:2.4") + + testImplementation(project(":")) + testImplementation("org.yaml:snakeyaml:2.4") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") +} + +val targetJavaVersion = 21 +java { + val javaVersion = JavaVersion.toVersion(targetJavaVersion) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion.set(JavaLanguageVersion.of(targetJavaVersion)) + } +} + +tasks.test { + useJUnitPlatform() + jvmArgs = listOf("-XX:+EnableDynamicAgentLoading") + + reports { + html.required.set(true) + junitXml.required.set(true) + } + + testLogging { + showStandardStreams = true + events("passed", "skipped", "failed", "standardOut", "standardError") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java new file mode 100644 index 0000000..484375d --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java @@ -0,0 +1,57 @@ +package fr.traqueur.structura.writers; + +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; +import fr.traqueur.structura.writers.factory.DefaultInstanceFactory; +import fr.traqueur.structura.writers.serializer.LoadableSerializer; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Objects; + +/** + * Main entry point for the structura-writers module. + * Provides static methods to write a {@link Loadable} instance to a YAML file, + * or to generate a default file from {@code @Default*} annotations. + */ +public final class StructuraWriters { + + private static final LoadableSerializer SERIALIZER = new LoadableSerializer(); + private static final DefaultInstanceFactory DEFAULT_FACTORY = new DefaultInstanceFactory(); + + private StructuraWriters() {} + + /** + * Serializes {@code config} to YAML and writes it to {@code file}. + * Creates the file if it does not exist, overwrites it otherwise. + * + * @throws StructuraWriterException if the write fails + */ + public static void write(Path file, Loadable config) { + Objects.requireNonNull(file, "file cannot be null"); + Objects.requireNonNull(config, "config cannot be null"); + + String yaml = SERIALIZER.toYaml(config); + try { + Files.writeString(file, yaml, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException e) { + throw new StructuraWriterException("Failed to write configuration to: " + file.toAbsolutePath(), e); + } + } + + /** + * Creates a default instance of {@code configClass} using {@code @Default*} annotations, + * then writes it to {@code file}. Does not check whether the file already exists. + * + * @throws StructuraWriterException if instance creation or the write fails + */ + public static void saveDefault(Path file, Class configClass) { + Objects.requireNonNull(file, "file cannot be null"); + Objects.requireNonNull(configClass, "configClass cannot be null"); + + T defaultInstance = DEFAULT_FACTORY.createDefault(configClass); + write(file, defaultInstance); + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java new file mode 100644 index 0000000..800f078 --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java @@ -0,0 +1,15 @@ +package fr.traqueur.structura.writers.exceptions; + +import fr.traqueur.structura.exceptions.StructuraException; + +/** Thrown by the structura-writers module when serialization or instance creation fails. */ +public class StructuraWriterException extends StructuraException { + + public StructuraWriterException(String message) { + super(message); + } + + public StructuraWriterException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java new file mode 100644 index 0000000..89a9ae0 --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java @@ -0,0 +1,82 @@ +package fr.traqueur.structura.writers.factory; + +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.registries.DefaultValueRegistry; +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; +import java.lang.reflect.RecordComponent; +import java.util.*; + +/** + * Creates a default instance of a {@link Loadable} record by resolving + * values from {@code @Default*} annotations and Java zero-values. + */ +public class DefaultInstanceFactory { + + /** + * Instantiates {@code configClass} with default values. + * Resolution order: {@code @Default*} annotations → nested records → empty collections → zero-values. + * + * @throws StructuraWriterException if the class is not a record or instantiation fails + */ + public T createDefault(Class configClass) { + if (configClass == null || !configClass.isRecord()) { + throw new StructuraWriterException( + (configClass == null ? "null" : configClass.getName()) + " is not a record type."); + } + + RecordComponent[] components = configClass.getRecordComponents(); + Constructor constructor = configClass.getDeclaredConstructors()[0]; + Parameter[] parameters = constructor.getParameters(); + Object[] args = new Object[components.length]; + + for (int i = 0; i < components.length; i++) { + args[i] = resolveDefault(components[i], parameters[i]); + } + + try { + constructor.setAccessible(true); + return configClass.cast(constructor.newInstance(args)); + } catch (ReflectiveOperationException e) { + throw new StructuraWriterException("Failed to create default instance of " + configClass.getName(), e); + } + } + + private Object resolveDefault(RecordComponent component, Parameter parameter) { + Class type = component.getType(); + List annotations = Arrays.asList(parameter.getAnnotations()); + + Object registryDefault = DefaultValueRegistry.getInstance().getDefaultValue(type, annotations); + if (registryDefault != null) return registryDefault; + + if (type.isRecord() && Loadable.class.isAssignableFrom(type)) { + @SuppressWarnings("unchecked") + Class nested = (Class) type; + return createDefault(nested); + } + + if (List.class.isAssignableFrom(type)) return new ArrayList<>(); + if (Set.class.isAssignableFrom(type)) return new HashSet<>(); + if (Map.class.isAssignableFrom(type)) return new LinkedHashMap<>(); + + if (type == int.class || type == Integer.class) return 0; + if (type == long.class || type == Long.class) return 0L; + if (type == double.class || type == Double.class) return 0.0; + if (type == float.class || type == Float.class) return 0.0f; + if (type == boolean.class || type == Boolean.class) return false; + if (type == byte.class || type == Byte.class) return (byte) 0; + if (type == short.class || type == Short.class) return (short) 0; + if (type == char.class || type == Character.class) return '\0'; + + Options options = parameter.getAnnotation(Options.class); + if (options != null && options.optional()) return null; + + if (type == String.class) return ""; + + return null; + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java new file mode 100644 index 0000000..779ac7b --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java @@ -0,0 +1,63 @@ +package fr.traqueur.structura.writers.registries; + +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; +import fr.traqueur.structura.writers.writer.Writer; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Singleton registry for custom {@link Writer} instances. + * Allows serializing types not natively supported by {@link fr.traqueur.structura.writers.serializer.LoadableSerializer}. + * + *
{@code
+ * CustomWriterRegistry.getInstance().register(
+ *     Component.class,
+ *     c -> MiniMessage.miniMessage().serialize(c)
+ * );
+ * }
+ */ +public class CustomWriterRegistry { + + private static final CustomWriterRegistry INSTANCE = new CustomWriterRegistry(); + + private final Map, Writer> writers = new HashMap<>(); + + private CustomWriterRegistry() {} + + public static CustomWriterRegistry getInstance() { + return INSTANCE; + } + + public void register(Class type, Writer writer) { + writers.put(type, writer); + } + + public boolean unregister(Class type) { + return writers.remove(type) != null; + } + + public boolean hasWriter(Class type) { + return writers.containsKey(type); + } + + public void clear() { + writers.clear(); + } + + @SuppressWarnings("unchecked") + /** + * Attempts to serialize {@code value} using the registered {@link Writer} for {@code type}. + * Returns {@link Optional#empty()} if no writer is registered for that type. + */ + public Optional write(Object value, Class type) { + Writer writer = writers.get(type); + if (writer == null) return Optional.empty(); + try { + return Optional.of(((Writer) writer).write(value)); + } catch (Exception e) { + throw new StructuraWriterException("Writer failed for " + type.getName() + ": " + e.getMessage(), e); + } + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java new file mode 100644 index 0000000..8e5d58e --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -0,0 +1,110 @@ +package fr.traqueur.structura.writers.serializer; + +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.mapping.FieldMapper; +import fr.traqueur.structura.references.Reference; +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; +import fr.traqueur.structura.writers.registries.CustomWriterRegistry; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Serializes a {@link Loadable} record to a YAML string. + * camelCase field names are converted to kebab-case automatically. + * Custom types can be handled by registering a {@link fr.traqueur.structura.writers.writer.Writer} + * in {@link CustomWriterRegistry}. + */ +public class LoadableSerializer { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE; + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + private final FieldMapper fieldMapper; + private final Yaml yaml; + + public LoadableSerializer() { + this.fieldMapper = new FieldMapper(); + DumperOptions opts = new DumperOptions(); + opts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + opts.setIndent(2); + opts.setExplicitStart(false); + this.yaml = new Yaml(opts); + } + + /** Serializes {@code config} to a block-style YAML string. */ + public String toYaml(Loadable config) { + Objects.requireNonNull(config, "config cannot be null"); + return yaml.dump(toMap(config)); + } + + private Map toMap(Object obj) { + Class clazz = obj.getClass(); + if (!clazz.isRecord()) { + throw new StructuraWriterException("Cannot serialize non-record type: " + clazz.getName()); + } + + RecordComponent[] components = clazz.getRecordComponents(); + Constructor constructor = clazz.getDeclaredConstructors()[0]; + Parameter[] parameters = constructor.getParameters(); + + Map result = new LinkedHashMap<>(); + for (int i = 0; i < components.length; i++) { + RecordComponent component = components[i]; + Parameter parameter = parameters[i]; + String key = fieldMapper.getEffectiveFieldName(parameter, component.getName()); + Object value = read(component, obj); + result.put(key, serializeValue(value, component.getGenericType())); + } + return result; + } + + private Object serializeValue(Object value, Type genericType) { + if (value == null) return null; + + if (value instanceof Reference ref) return ref.key(); + + Optional custom = CustomWriterRegistry.getInstance().write(value, value.getClass()); + if (custom.isPresent()) return custom.get(); + + if (value.getClass().isRecord()) return toMap(value); + + if (genericType instanceof ParameterizedType paramType) { + if (value instanceof List list) { + return list.stream().map(e -> serializeValue(e, Object.class)).collect(Collectors.toList()); + } + if (value instanceof Set set) { + return set.stream().map(e -> serializeValue(e, Object.class)).collect(Collectors.toList()); + } + if (value instanceof Map map) { + Map r = new LinkedHashMap<>(); + map.forEach((k, v) -> r.put(k.toString(), serializeValue(v, Object.class))); + return r; + } + } + + if (value instanceof Enum e) return fieldMapper.convertSnakeCaseToKebabCase(e.name()); + if (value instanceof LocalDate d) return d.format(DATE_FORMATTER); + if (value instanceof LocalDateTime dt) return dt.format(DATE_TIME_FORMATTER); + + return value; + } + + private Object read(RecordComponent component, Object obj) { + try { + return component.getAccessor().invoke(obj); + } catch (ReflectiveOperationException e) { + throw new StructuraWriterException("Cannot read field: " + component.getName(), e); + } + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/writer/Writer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/writer/Writer.java new file mode 100644 index 0000000..e80557d --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/writer/Writer.java @@ -0,0 +1,15 @@ +package fr.traqueur.structura.writers.writer; + +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; + +/** + * Converts a Java value to its YAML-serializable form. + * Symmetric counterpart to {@link fr.traqueur.structura.readers.Reader}. + * + * @param the Java type to serialize + */ +@FunctionalInterface +public interface Writer { + + Object write(T value) throws StructuraWriterException; +} diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java new file mode 100644 index 0000000..a740d5b --- /dev/null +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java @@ -0,0 +1,61 @@ +package fr.traqueur.structura.writers; + +import fr.traqueur.structura.api.Structura; +import fr.traqueur.structura.writers.fixtures.WriterTestModels.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class StructuraWritersTest { + + @TempDir + Path tempDir; + + @Test + void shouldWriteAndReadBack() throws Exception { + Path file = tempDir.resolve("config.yml"); + PlainConfig original = new PlainConfig("RoundTrip", 99); + + StructuraWriters.write(file, original); + PlainConfig loaded = Structura.load(file, PlainConfig.class); + + assertEquals(original.name(), loaded.name()); + assertEquals(original.value(), loaded.value()); + } + + @Test + void shouldWriteKebabCaseKeys() throws Exception { + Path file = tempDir.resolve("config.yml"); + StructuraWriters.write(file, new CamelCaseConfig("srv", 9090)); + + String content = Files.readString(file); + assertTrue(content.contains("server-name:")); + assertTrue(content.contains("http-port:")); + } + + @Test + void saveDefaultShouldGenerateFromAnnotations() throws Exception { + Path file = tempDir.resolve("default.yml"); + StructuraWriters.saveDefault(file, SimpleDefaultConfig.class); + + SimpleDefaultConfig loaded = Structura.load(file, SimpleDefaultConfig.class); + assertEquals("Afelia", loaded.serverName()); + assertEquals(25565, loaded.port()); + assertTrue(loaded.debug()); + } + + @Test + void shouldSerializeNestedRecord() throws Exception { + Path file = tempDir.resolve("nested.yml"); + StructuraWriters.write(file, new NestedDefaultConfig("MyApp", new ServerBlock("db.local", 5432))); + + String content = Files.readString(file); + assertTrue(content.contains("app-name: MyApp")); + assertTrue(content.contains("server:")); + assertTrue(content.contains("host: db.local")); + } +} diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java new file mode 100644 index 0000000..6deaf91 --- /dev/null +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java @@ -0,0 +1,42 @@ +package fr.traqueur.structura.writers.factory; + +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; +import fr.traqueur.structura.writers.fixtures.WriterTestModels.*; +import org.junit.jupiter.api.*; + +import static org.junit.jupiter.api.Assertions.*; + +class DefaultInstanceFactoryTest { + + private DefaultInstanceFactory factory; + + @BeforeEach + void setUp() { + factory = new DefaultInstanceFactory(); + } + + @Test + void shouldResolveDefaultAnnotations() { + SimpleDefaultConfig config = factory.createDefault(SimpleDefaultConfig.class); + assertEquals("Afelia", config.serverName()); + assertEquals(25565, config.port()); + assertTrue(config.debug()); + } + + @Test + void shouldCreateNestedDefaults() { + NestedDefaultConfig config = factory.createDefault(NestedDefaultConfig.class); + assertNotNull(config.server()); + assertEquals("localhost", config.server().host()); + } + + @Test + void shouldThrowForNonRecord() { + var nonRecord = new fr.traqueur.structura.api.Loadable() {}; + @SuppressWarnings("unchecked") + Class clazz = + (Class) nonRecord.getClass(); + + assertThrows(StructuraWriterException.class, () -> factory.createDefault(clazz)); + } +} diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java new file mode 100644 index 0000000..faac29c --- /dev/null +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -0,0 +1,82 @@ +package fr.traqueur.structura.writers.fixtures; + +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.defaults.*; +import fr.traqueur.structura.api.Loadable; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class WriterTestModels { + + private WriterTestModels() {} + + public record PlainConfig(String name, int value) implements Loadable {} + + public record SimpleDefaultConfig( + @DefaultString("Afelia") String serverName, + @DefaultInt(25565) int port, + @DefaultBool(true) boolean debug + ) implements Loadable {} + + public record AllDefaultTypesConfig( + @DefaultString("hello") String str, + @DefaultInt(42) int intVal, + @DefaultLong(1000L) long longVal, + @DefaultDouble(3.14) double doubleVal, + @DefaultBool(false) boolean flag + ) implements Loadable {} + + public record ServerBlock( + @DefaultString("localhost") String host, + @DefaultInt(8080) int port + ) implements Loadable {} + + public record NestedDefaultConfig( + @DefaultString("MyApp") String appName, + ServerBlock server + ) implements Loadable {} + + public record CamelCaseConfig( + @DefaultString("test") String serverName, + @DefaultInt(9090) int httpPort + ) implements Loadable {} + + public record OptionalFieldConfig( + @DefaultString("required") String required, + @Options(optional = true) String optional + ) implements Loadable {} + + public record ConnectionBlock( + @DefaultString("db.local") String host, + @DefaultInt(5432) int port + ) implements Loadable {} + + public record InlineConfig( + @DefaultString("InlineApp") String appName, + @Options(inline = true) ConnectionBlock connection + ) implements Loadable {} + + public record CollectionConfig( + @DefaultString("test") String name, + List tags, + Set ports, + Map properties + ) implements Loadable {} + + public static final class Color { + private final int r, g, b; + + public Color(int r, int g, int b) { this.r = r; this.g = g; this.b = b; } + + public int r() { return r; } + public int g() { return g; } + public int b() { return b; } + } + + public record ColorConfig( + @DefaultString("palette") String name, + Color color + ) implements Loadable {} +} diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java new file mode 100644 index 0000000..43142fe --- /dev/null +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -0,0 +1,51 @@ +package fr.traqueur.structura.writers.serializer; + +import fr.traqueur.structura.writers.fixtures.WriterTestModels.*; +import fr.traqueur.structura.writers.registries.CustomWriterRegistry; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class LoadableSerializerTest { + + private LoadableSerializer serializer; + + @BeforeEach + void setUp() { + serializer = new LoadableSerializer(); + } + + @Test + void camelCaseToKebabCase() { + String yaml = serializer.toYaml(new CamelCaseConfig("test", 9090)); + assertTrue(yaml.contains("server-name:")); + assertFalse(yaml.contains("serverName:")); + } + + @Test + void nestedRecord() { + String yaml = serializer.toYaml(new NestedDefaultConfig("MyApp", new ServerBlock("db.local", 5432))); + assertTrue(yaml.contains("app-name: MyApp")); + assertTrue(yaml.contains("server:")); + assertTrue(yaml.contains("host: db.local")); + } + + @Test + void plainList() { + String yaml = serializer.toYaml(new CollectionConfig("x", List.of("alpha", "beta"), java.util.Set.of(), java.util.Map.of())); + assertTrue(yaml.contains("alpha")); + assertTrue(yaml.contains("beta")); + } + + @Test + void customWriterInvoked() { + CustomWriterRegistry.getInstance().register(Color.class, c -> c.r() + "," + c.g() + "," + c.b()); + + String yaml = serializer.toYaml(new ColorConfig("red", new Color(255, 0, 0))); + assertTrue(yaml.contains("255,0,0") || yaml.contains("'255,0,0'")); + + CustomWriterRegistry.getInstance().unregister(Color.class); + } +} From b6f4cf3ba3118794711681bd75a6e9f21a1d4b4d Mon Sep 17 00:00:00 2001 From: el211 Date: Wed, 3 Jun 2026 23:11:06 +0200 Subject: [PATCH 02/13] chore: remove .claude/settings.local.json from tracking --- .claude/settings.local.json | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 60b3047..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(./gradlew :structura-writers:test)", - "Bash(./gradlew test:*)", - "Bash(./gradlew :structura-writers:compileJava)", - "Bash(./gradlew :structura-writers:test --info)" - ] - } -} From efdf645d2650230ee9cc8b361ecf8402084f364f Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 00:02:22 +0200 Subject: [PATCH 03/13] feat: expand serializer with polymorphic + inline support, wire SPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoadableSerializer now handles @Options(inline=true) record flattening, standard @Polymorphic (discriminator inside nested map), @Polymorphic(inline=true) (discriminator at parent level), and fully-inline polymorphic (@Options(inline=true) + @Polymorphic(inline=true)) - useKey and isKey serialization left as TODO for next iteration - Add StructuraWriter SPI interface in core; Structura.write() and Structura.saveDefault() discover the implementation via ServiceLoader — throws StructuraException if structura-writers is absent from classpath - Add YamlStructuraWriter as the SPI provider + META-INF/services registration - Expand WriterTestModels with Animal/DbEngine polymorphic fixtures - Add inline and polymorphic unit tests to LoadableSerializerTest --- .../fr/traqueur/structura/api/Structura.java | 68 ++++++ .../structura/api/StructuraWriter.java | 38 ++++ .../writers/YamlStructuraWriter.java | 32 +++ .../serializer/LoadableSerializer.java | 215 +++++++++++++++--- .../fr.traqueur.structura.api.StructuraWriter | 1 + .../writers/fixtures/WriterTestModels.java | 80 ++++++- .../serializer/LoadableSerializerTest.java | 100 +++++++- 7 files changed, 498 insertions(+), 36 deletions(-) create mode 100644 src/main/java/fr/traqueur/structura/api/StructuraWriter.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java create mode 100644 structura-writers/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter diff --git a/src/main/java/fr/traqueur/structura/api/Structura.java b/src/main/java/fr/traqueur/structura/api/Structura.java index 18ec2ef..71d8988 100644 --- a/src/main/java/fr/traqueur/structura/api/Structura.java +++ b/src/main/java/fr/traqueur/structura/api/Structura.java @@ -8,6 +8,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ServiceLoader; /** * Structura is a library for parsing and validating YAML configurations. @@ -40,6 +41,30 @@ public class Structura { Updater.checkUpdates(); } + /** + * Lazy, thread-safe holder for the optional {@link StructuraWriter} SPI. + * The JVM guarantees that the inner class is initialized only once, + * and only when first accessed. + */ + private static final class WriterHolder { + static final StructuraWriter INSTANCE = ServiceLoader + .load(StructuraWriter.class, Structura.class.getClassLoader()) + .findFirst() + .orElse(null); + } + + /** Returns the {@link StructuraWriter} resolved by the SPI, or throws if absent. */ + private static StructuraWriter requireWriter() { + StructuraWriter writer = WriterHolder.INSTANCE; + if (writer == null) { + throw new StructuraException( + "Structura write operations require the 'structura-writers' module on the classpath. " + + "Add it as a dependency to use Structura.write() or Structura.saveDefault()." + ); + } + return writer; + } + /** * Private constructor to prevent instantiation. * Use the {@link StructuraBuilder} to create an instance of {@link StructuraProcessor}. @@ -269,4 +294,47 @@ private static T loadInternal(Object source, Class confi throw new StructuraException("Unable to read file: " + getSourceName(source), e); } } + + // ------------------------------------------------------------------------- + // Write API (requires structura-writers on the classpath) + // ------------------------------------------------------------------------- + + /** + * Serializes {@code config} to YAML and writes it to {@code file}. + * Creates the file if it does not exist, overwrites it otherwise. + * + *

Requires the {@code structura-writers} module to be present on the classpath. + * Throws {@link StructuraException} if the module is absent.

+ * + * @param file destination path + * @param config the {@link Loadable} record to serialize + * @throws StructuraException if the writers module is absent or the write fails + */ + public static void write(Path file, Loadable config) { + requireWriter().write(file, config); + } + + /** + * Builds a default instance of {@code configClass} from its {@code @Default*} annotations, + * then writes it to {@code file}. + * + *

Does not check whether {@code file} already exists — + * that responsibility belongs to the caller:

+ *
+     * if (!Files.exists(configFile)) {
+     *     Structura.saveDefault(configFile, MyConfig.class);
+     * }
+     * 
+ * + *

Requires the {@code structura-writers} module to be present on the classpath. + * Throws {@link StructuraException} if the module is absent.

+ * + * @param file destination path + * @param configClass the record class to instantiate with default values + * @param a record type implementing {@link Loadable} + * @throws StructuraException if the writers module is absent or the write fails + */ + public static void saveDefault(Path file, Class configClass) { + requireWriter().saveDefault(file, configClass); + } } diff --git a/src/main/java/fr/traqueur/structura/api/StructuraWriter.java b/src/main/java/fr/traqueur/structura/api/StructuraWriter.java new file mode 100644 index 0000000..f684ef8 --- /dev/null +++ b/src/main/java/fr/traqueur/structura/api/StructuraWriter.java @@ -0,0 +1,38 @@ +package fr.traqueur.structura.api; + +import java.nio.file.Path; + +/** + * SPI contract for serializing {@link Loadable} records back to YAML. + * + *

Defined in the core module but not implemented here. + * The optional {@code structura-writers} module provides an implementation + * discovered at runtime via {@link java.util.ServiceLoader}.

+ * + *

If {@code structura-writers} is absent from the classpath, + * calls to {@link Structura#write} and {@link Structura#saveDefault} + * throw a {@link fr.traqueur.structura.exceptions.StructuraException}.

+ */ +public interface StructuraWriter { + + /** + * Serializes {@code config} to YAML and writes it to {@code file}. + * + * @param file destination path (created or overwritten) + * @param config the record to serialize + */ + void write(Path file, Loadable config); + + /** + * Builds a default instance of {@code configClass} from its {@code @Default*} + * annotations, then writes it to {@code file}. + * + *

Does not check whether {@code file} already exists — + * that responsibility belongs to the caller.

+ * + * @param file destination path + * @param configClass the record class to instantiate with default values + * @param a record type implementing {@link Loadable} + */ + void saveDefault(Path file, Class configClass); +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java new file mode 100644 index 0000000..73f6180 --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java @@ -0,0 +1,32 @@ +package fr.traqueur.structura.writers; + +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.api.StructuraWriter; + +import java.nio.file.Path; + +/** + * SPI implementation of {@link StructuraWriter} provided by the {@code structura-writers} module. + * + *

Registered via {@code META-INF/services/fr.traqueur.structura.api.StructuraWriter} + * so that {@link fr.traqueur.structura.api.Structura} discovers it automatically through + * {@link java.util.ServiceLoader} whenever this module is on the classpath.

+ * + *

All actual logic is delegated to {@link StructuraWriters}, keeping this class + * as a thin bridge between the SPI contract and the existing public API.

+ */ +public final class YamlStructuraWriter implements StructuraWriter { + + /** No-arg constructor required by {@link java.util.ServiceLoader}. */ + public YamlStructuraWriter() {} + + @Override + public void write(Path file, Loadable config) { + StructuraWriters.write(file, config); + } + + @Override + public void saveDefault(Path file, Class configClass) { + StructuraWriters.saveDefault(file, configClass); + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 8e5d58e..1b4451f 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -1,8 +1,11 @@ package fr.traqueur.structura.writers.serializer; +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.Polymorphic; import fr.traqueur.structura.api.Loadable; import fr.traqueur.structura.mapping.FieldMapper; import fr.traqueur.structura.references.Reference; +import fr.traqueur.structura.registries.PolymorphicRegistry; import fr.traqueur.structura.writers.exceptions.StructuraWriterException; import fr.traqueur.structura.writers.registries.CustomWriterRegistry; import org.yaml.snakeyaml.DumperOptions; @@ -21,9 +24,25 @@ /** * Serializes a {@link Loadable} record to a YAML string. - * camelCase field names are converted to kebab-case automatically. - * Custom types can be handled by registering a {@link fr.traqueur.structura.writers.writer.Writer} - * in {@link CustomWriterRegistry}. + * + *

Handles:

+ *
    + *
  • camelCase → kebab-case key conversion
  • + *
  • {@code @Options(inline = true)} — flattens a sub-record's fields into the parent map
  • + *
  • {@code @Polymorphic} standard — discriminator written inside the nested map
  • + *
  • {@code @Polymorphic(inline = true)} — discriminator written at the parent level
  • + *
  • {@code @Polymorphic(inline = true)} + {@code @Options(inline = true)} — fully inline: + * discriminator and all concrete fields at parent level
  • + *
+ * + *

Not yet handled (TODO):

+ *
    + *
  • {@code @Polymorphic(useKey = true)}
  • + *
  • {@code @Options(isKey = true)}
  • + *
+ * + *

Custom types can be handled by registering a {@link fr.traqueur.structura.writers.writer.Writer} + * in {@link CustomWriterRegistry}.

*/ public class LoadableSerializer { @@ -31,7 +50,7 @@ public class LoadableSerializer { private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; private final FieldMapper fieldMapper; - private final Yaml yaml; + private final Yaml yaml; public LoadableSerializer() { this.fieldMapper = new FieldMapper(); @@ -48,27 +67,88 @@ public String toYaml(Loadable config) { return yaml.dump(toMap(config)); } + // ------------------------------------------------------------------------- + // Core record → Map conversion + // ------------------------------------------------------------------------- + private Map toMap(Object obj) { Class clazz = obj.getClass(); if (!clazz.isRecord()) { throw new StructuraWriterException("Cannot serialize non-record type: " + clazz.getName()); } - RecordComponent[] components = clazz.getRecordComponents(); + RecordComponent[] components = clazz.getRecordComponents(); Constructor constructor = clazz.getDeclaredConstructors()[0]; - Parameter[] parameters = constructor.getParameters(); + Parameter[] parameters = constructor.getParameters(); Map result = new LinkedHashMap<>(); for (int i = 0; i < components.length; i++) { - RecordComponent component = components[i]; - Parameter parameter = parameters[i]; - String key = fieldMapper.getEffectiveFieldName(parameter, component.getName()); - Object value = read(component, obj); - result.put(key, serializeValue(value, component.getGenericType())); + contributeToMap(result, components[i], parameters[i], readField(components[i], obj)); } return result; } + // ------------------------------------------------------------------------- + // Per-field contribution + // ------------------------------------------------------------------------- + + private void contributeToMap(Map result, RecordComponent component, + Parameter parameter, Object value) { + Options opts = parameter.getAnnotation(Options.class); + boolean isInline = opts != null && opts.inline(); + Class type = component.getType(); + String key = fieldMapper.getEffectiveFieldName(parameter, component.getName()); + + if (value == null) { + result.put(key, null); + return; + } + + if (isInline) { + if (type.isRecord() && Loadable.class.isAssignableFrom(type)) { + // Flatten sub-record fields at parent level + result.putAll(toMap(value)); + } else if (isPolymorphicInterface(type)) { + Polymorphic poly = type.getAnnotation(Polymorphic.class); + if (poly.inline()) { + // Fully inline: discriminator + all concrete fields at parent level + result.put(poly.key(), lookupRegisteredName(value.getClass(), type)); + result.putAll(toMap(value)); + } else { + result.put(key, serializeValue(value, component.getGenericType())); + } + } else { + result.put(key, serializeValue(value, component.getGenericType())); + } + return; + } + + if (isPolymorphicInterface(type)) { + Polymorphic poly = type.getAnnotation(Polymorphic.class); + String discValue = lookupRegisteredName(value.getClass(), type); + + if (poly.inline()) { + // Discriminator at parent level, concrete fields nested under key + result.put(poly.key(), discValue); + result.put(key, toMap(value)); + } else { + // Standard: discriminator first, inside the nested map + // TODO: handle useKey = true + Map nested = new LinkedHashMap<>(); + nested.put(poly.key(), discValue); + nested.putAll(toMap(value)); + result.put(key, nested); + } + return; + } + + result.put(key, serializeValue(value, component.getGenericType())); + } + + // ------------------------------------------------------------------------- + // Value serialization + // ------------------------------------------------------------------------- + private Object serializeValue(Object value, Type genericType) { if (value == null) return null; @@ -77,30 +157,113 @@ private Object serializeValue(Object value, Type genericType) { Optional custom = CustomWriterRegistry.getInstance().write(value, value.getClass()); if (custom.isPresent()) return custom.get(); - if (value.getClass().isRecord()) return toMap(value); - if (genericType instanceof ParameterizedType paramType) { - if (value instanceof List list) { - return list.stream().map(e -> serializeValue(e, Object.class)).collect(Collectors.toList()); - } - if (value instanceof Set set) { - return set.stream().map(e -> serializeValue(e, Object.class)).collect(Collectors.toList()); - } - if (value instanceof Map map) { - Map r = new LinkedHashMap<>(); - map.forEach((k, v) -> r.put(k.toString(), serializeValue(v, Object.class))); - return r; - } + Type[] typeArgs = paramType.getActualTypeArguments(); + Type elemType = typeArgs.length > 0 ? typeArgs[0] : Object.class; + if (value instanceof List list) return serializeCollection(list, elemType); + if (value instanceof Set set) return serializeCollection(new ArrayList<>(set), elemType); + if (value instanceof Map map) return serializeMap(map, typeArgs); } - if (value instanceof Enum e) return fieldMapper.convertSnakeCaseToKebabCase(e.name()); + if (value.getClass().isRecord()) return toMap(value); + if (value instanceof Enum e) return fieldMapper.convertSnakeCaseToKebabCase(e.name()); if (value instanceof LocalDate d) return d.format(DATE_FORMATTER); if (value instanceof LocalDateTime dt) return dt.format(DATE_TIME_FORMATTER); return value; } - private Object read(RecordComponent component, Object obj) { + private Object serializeCollection(List elements, Type elementGenericType) { + if (elements.isEmpty()) return new ArrayList<>(); + + Class elementRawType = getRawClass(elementGenericType); + + // Polymorphic list: discriminator inside each element map + // TODO: handle useKey = true (map-keyed format) + // TODO: handle isKey records (map-keyed format) + if (isPolymorphicInterface(elementRawType)) { + Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); + List resultList = new ArrayList<>(); + for (Object elem : elements) { + Map entry = new LinkedHashMap<>(); + entry.put(poly.key(), lookupRegisteredName(elem.getClass(), elementRawType)); + entry.putAll(toMap(elem)); + resultList.add(entry); + } + return resultList; + } + + return elements.stream() + .map(e -> serializeValue(e, elementGenericType)) + .collect(Collectors.toList()); + } + + private Object serializeMap(Map map, Type[] typeArgs) { + Type valueGenericType = typeArgs.length > 1 ? typeArgs[1] : Object.class; + Class valueRawType = getRawClass(valueGenericType); + + Map result = new LinkedHashMap<>(); + + // Polymorphic map values: discriminator inside each value + // TODO: handle useKey = true + if (isPolymorphicInterface(valueRawType)) { + Polymorphic poly = valueRawType.getAnnotation(Polymorphic.class); + for (Map.Entry e : map.entrySet()) { + if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } + Map valueMap = new LinkedHashMap<>(); + valueMap.put(poly.key(), lookupRegisteredName(e.getValue().getClass(), valueRawType)); + valueMap.putAll(toMap(e.getValue())); + result.put(e.getKey().toString(), valueMap); + } + return result; + } + + for (Map.Entry e : map.entrySet()) { + result.put(e.getKey().toString(), serializeValue(e.getValue(), valueGenericType)); + } + return result; + } + + // ------------------------------------------------------------------------- + // Polymorphic registry reverse-lookup + // ------------------------------------------------------------------------- + + @SuppressWarnings("unchecked") + private String lookupRegisteredName(Class concreteClass, Class polymorphicInterface) { + PolymorphicRegistry registry = + (PolymorphicRegistry) PolymorphicRegistry.get( + (Class) polymorphicInterface); + + for (String name : registry.availableNames()) { + if (registry.get(name).filter(c -> c == concreteClass).isPresent()) { + return name; + } + } + throw new StructuraWriterException( + "No registered name for '" + concreteClass.getName() + + "' in polymorphic registry of '" + polymorphicInterface.getName() + "'" + ); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private boolean isPolymorphicInterface(Class type) { + return type.isInterface() + && Loadable.class.isAssignableFrom(type) + && type.isAnnotationPresent(Polymorphic.class); + } + + private Class getRawClass(Type type) { + return switch (type) { + case Class c -> c; + case ParameterizedType pt -> (Class) pt.getRawType(); + default -> Object.class; + }; + } + + private Object readField(RecordComponent component, Object obj) { try { return component.getAccessor().invoke(obj); } catch (ReflectiveOperationException e) { diff --git a/structura-writers/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter b/structura-writers/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter new file mode 100644 index 0000000..84a0434 --- /dev/null +++ b/structura-writers/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter @@ -0,0 +1 @@ +fr.traqueur.structura.writers.YamlStructuraWriter diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index faac29c..4ddc7b9 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -1,6 +1,7 @@ package fr.traqueur.structura.writers.fixtures; import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.Polymorphic; import fr.traqueur.structura.annotations.defaults.*; import fr.traqueur.structura.api.Loadable; @@ -12,6 +13,10 @@ public final class WriterTestModels { private WriterTestModels() {} + // ========================================================================= + // Basic models (from initial commit) + // ========================================================================= + public record PlainConfig(String name, int value) implements Loadable {} public record SimpleDefaultConfig( @@ -53,11 +58,6 @@ public record ConnectionBlock( @DefaultInt(5432) int port ) implements Loadable {} - public record InlineConfig( - @DefaultString("InlineApp") String appName, - @Options(inline = true) ConnectionBlock connection - ) implements Loadable {} - public record CollectionConfig( @DefaultString("test") String name, List tags, @@ -67,9 +67,7 @@ public record CollectionConfig( public static final class Color { private final int r, g, b; - public Color(int r, int g, int b) { this.r = r; this.g = g; this.b = b; } - public int r() { return r; } public int g() { return g; } public int b() { return b; } @@ -79,4 +77,72 @@ public record ColorConfig( @DefaultString("palette") String name, Color color ) implements Loadable {} + + // ========================================================================= + // @Options(inline = true) — concrete record flattening + // ========================================================================= + + public record InlineConfig( + @DefaultString("InlineApp") String appName, + @Options(inline = true) ConnectionBlock connection + ) implements Loadable {} + + // ========================================================================= + // @Polymorphic standard (discriminator inside the nested map) + // ========================================================================= + + @Polymorphic(key = "kind") + public interface Animal extends Loadable {} + + public record Dog( + @DefaultString("Rex") String name, + @DefaultString("lab") String breed + ) implements Animal {} + + public record Cat( + @DefaultString("Whiskers") String name, + @DefaultBool(true) boolean indoor + ) implements Animal {} + + public record AnimalConfig( + @DefaultString("Zoo") String appName, + Animal pet + ) implements Loadable {} + + public record AnimalListConfig( + List animals + ) implements Loadable {} + + public record AnimalMapConfig( + Map animalsByName + ) implements Loadable {} + + // ========================================================================= + // @Polymorphic(inline = true) — discriminator at parent level + // ========================================================================= + + @Polymorphic(key = "engine", inline = true) + public interface DbEngine extends Loadable {} + + public record MySQLEngine( + @DefaultString("localhost") String host, + @DefaultInt(3306) int port + ) implements DbEngine {} + + public record PostgreSQLEngine( + @DefaultString("localhost") String host, + @DefaultInt(5432) int port + ) implements DbEngine {} + + /** Discriminator at parent level, concrete fields nested under "db". */ + public record InlineDbConfig( + @DefaultString("App") String appName, + DbEngine db + ) implements Loadable {} + + /** Both inline flags — ALL fields (including discriminator) at parent level. */ + public record FullyInlineDbConfig( + @DefaultString("App") String appName, + @Options(inline = true) DbEngine db + ) implements Loadable {} } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index 43142fe..64e4463 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -1,10 +1,15 @@ package fr.traqueur.structura.writers.serializer; +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.registries.PolymorphicRegistry; import fr.traqueur.structura.writers.fixtures.WriterTestModels.*; import fr.traqueur.structura.writers.registries.CustomWriterRegistry; import org.junit.jupiter.api.*; +import java.lang.reflect.Field; import java.util.List; +import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -12,11 +17,26 @@ class LoadableSerializerTest { private LoadableSerializer serializer; + @BeforeAll + static void setupRegistries() { + tryCreate(Animal.class, r -> { r.register("dog", Dog.class); r.register("cat", Cat.class); }); + tryCreate(DbEngine.class, r -> { r.register("mysql", MySQLEngine.class); r.register("postgres", PostgreSQLEngine.class); }); + } + + @AfterAll + static void tearDownRegistries() throws Exception { + Field f = PolymorphicRegistry.class.getDeclaredField("REGISTRIES"); + f.setAccessible(true); + ((Map) f.get(null)).clear(); + } + @BeforeEach void setUp() { serializer = new LoadableSerializer(); } + // ── Basic ──────────────────────────────────────────────────────────────── + @Test void camelCaseToKebabCase() { String yaml = serializer.toYaml(new CamelCaseConfig("test", 9090)); @@ -34,7 +54,7 @@ void nestedRecord() { @Test void plainList() { - String yaml = serializer.toYaml(new CollectionConfig("x", List.of("alpha", "beta"), java.util.Set.of(), java.util.Map.of())); + String yaml = serializer.toYaml(new CollectionConfig("x", List.of("alpha", "beta"), Set.of(), Map.of())); assertTrue(yaml.contains("alpha")); assertTrue(yaml.contains("beta")); } @@ -42,10 +62,84 @@ void plainList() { @Test void customWriterInvoked() { CustomWriterRegistry.getInstance().register(Color.class, c -> c.r() + "," + c.g() + "," + c.b()); - String yaml = serializer.toYaml(new ColorConfig("red", new Color(255, 0, 0))); assertTrue(yaml.contains("255,0,0") || yaml.contains("'255,0,0'")); - CustomWriterRegistry.getInstance().unregister(Color.class); } + + // ── @Options(inline = true) ─────────────────────────────────────────────── + + @Test + void inlineConcreteRecordFlattensFields() { + String yaml = serializer.toYaml(new InlineConfig("MyApp", new ConnectionBlock("db.local", 5432))); + + assertTrue(yaml.contains("app-name: MyApp")); + assertTrue(yaml.contains("host: db.local"), "host must be flattened to root"); + assertTrue(yaml.contains("port: 5432"), "port must be flattened to root"); + assertFalse(yaml.contains("connection:"), "'connection' key must not appear when inline"); + } + + // ── @Polymorphic standard ───────────────────────────────────────────────── + + @Test + void polymorphicDiscriminatorWrittenInsideNestedMap() { + String yaml = serializer.toYaml(new AnimalConfig("Farm", new Dog("Buddy", "poodle"))); + + assertTrue(yaml.contains("pet:"), "field key must be present"); + assertTrue(yaml.contains("kind:"), "discriminator must be inside nested map"); + assertTrue(yaml.contains("dog"), "discriminator value must be present"); + assertTrue(yaml.contains("name: Buddy")); + } + + @Test + void polymorphicList() { + String yaml = serializer.toYaml(new AnimalListConfig( + List.of(new Dog("Rex", "lab"), new Cat("Mia", true)) + )); + + assertTrue(yaml.contains("kind: dog")); + assertTrue(yaml.contains("kind: cat")); + } + + @Test + void polymorphicMap() { + String yaml = serializer.toYaml(new AnimalMapConfig( + Map.of("a", new Dog("Rex", "lab"), "b", new Cat("Luna", false)) + )); + + assertTrue(yaml.contains("kind: dog")); + assertTrue(yaml.contains("kind: cat")); + } + + // ── @Polymorphic(inline = true) ─────────────────────────────────────────── + + @Test + void inlinePolymorphicDiscriminatorAtParentLevel() { + String yaml = serializer.toYaml(new InlineDbConfig("App", new MySQLEngine("db.local", 3306))); + + assertTrue(yaml.contains("engine: mysql"), "discriminator must be at root level"); + assertTrue(yaml.contains("db:"), "concrete fields nested under 'db'"); + assertTrue(yaml.contains("host: db.local")); + } + + @Test + void fullyInlinePolymorphicFlattensEverything() { + String yaml = serializer.toYaml(new FullyInlineDbConfig("App", new MySQLEngine("db.local", 3306))); + + assertTrue(yaml.contains("engine: mysql"), "discriminator at root"); + assertTrue(yaml.contains("host: db.local"), "concrete field at root"); + assertFalse(yaml.contains("db:"), "'db' key must not appear when fully inline"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private static void tryCreate( + Class clazz, java.util.function.Consumer> cfg) { + try { + PolymorphicRegistry.get(clazz); + } catch (Exception ignored) { + PolymorphicRegistry.create(clazz, cfg); + } + } } From 1dad16bace0ad783b89fdb8d1f1e76572960cfab Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 00:38:16 +0200 Subject: [PATCH 04/13] feat: implement useKey polymorphic serialization in LoadableSerializer Resolves the useKey TODO in the serializer: when @Polymorphic(useKey = true) is declared on an interface, the discriminator value becomes the YAML key rather than a field written under a separate discriminator key. Three cases handled: - Single polymorphic field: discriminator value replaces the field name as key - List: serialized as a YAML map keyed by discriminator value - Map: concrete fields written directly under the existing map key, no redundant type discriminator injected Added ItemMeta/FoodMeta/PotionMeta fixtures and three new tests covering each of the above cases. --- .../serializer/LoadableSerializer.java | 33 +++++++++++---- .../writers/fixtures/WriterTestModels.java | 31 ++++++++++++++ .../serializer/LoadableSerializerTest.java | 42 +++++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 1b4451f..2bf0c35 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -127,13 +127,15 @@ private void contributeToMap(Map result, RecordComponent compone Polymorphic poly = type.getAnnotation(Polymorphic.class); String discValue = lookupRegisteredName(value.getClass(), type); - if (poly.inline()) { + if (poly.useKey()) { + // useKey: discriminator value becomes the YAML key; field name is dropped + result.put(discValue, toMap(value)); + } else if (poly.inline()) { // Discriminator at parent level, concrete fields nested under key result.put(poly.key(), discValue); result.put(key, toMap(value)); } else { // Standard: discriminator first, inside the nested map - // TODO: handle useKey = true Map nested = new LinkedHashMap<>(); nested.put(poly.key(), discValue); nested.putAll(toMap(value)); @@ -179,10 +181,19 @@ private Object serializeCollection(List elements, Type elementGenericType) { Class elementRawType = getRawClass(elementGenericType); // Polymorphic list: discriminator inside each element map - // TODO: handle useKey = true (map-keyed format) // TODO: handle isKey records (map-keyed format) if (isPolymorphicInterface(elementRawType)) { - Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); + Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); + + if (poly.useKey()) { + // useKey list: becomes a YAML map — discriminator value is the key + Map resultMap = new LinkedHashMap<>(); + for (Object elem : elements) { + resultMap.put(lookupRegisteredName(elem.getClass(), elementRawType), toMap(elem)); + } + return resultMap; + } + List resultList = new ArrayList<>(); for (Object elem : elements) { Map entry = new LinkedHashMap<>(); @@ -205,15 +216,19 @@ private Object serializeMap(Map map, Type[] typeArgs) { Map result = new LinkedHashMap<>(); // Polymorphic map values: discriminator inside each value - // TODO: handle useKey = true if (isPolymorphicInterface(valueRawType)) { Polymorphic poly = valueRawType.getAnnotation(Polymorphic.class); for (Map.Entry e : map.entrySet()) { if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } - Map valueMap = new LinkedHashMap<>(); - valueMap.put(poly.key(), lookupRegisteredName(e.getValue().getClass(), valueRawType)); - valueMap.putAll(toMap(e.getValue())); - result.put(e.getKey().toString(), valueMap); + if (poly.useKey()) { + // useKey map: the map key IS the discriminator — just write concrete fields + result.put(e.getKey().toString(), toMap(e.getValue())); + } else { + Map valueMap = new LinkedHashMap<>(); + valueMap.put(poly.key(), lookupRegisteredName(e.getValue().getClass(), valueRawType)); + valueMap.putAll(toMap(e.getValue())); + result.put(e.getKey().toString(), valueMap); + } } return result; } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index 4ddc7b9..c6173c7 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -145,4 +145,35 @@ public record FullyInlineDbConfig( @DefaultString("App") String appName, @Options(inline = true) DbEngine db ) implements Loadable {} + + // ========================================================================= + // @Polymorphic(useKey = true) — discriminator value becomes the YAML key + // ========================================================================= + + @Polymorphic(key = "type", useKey = true) + public interface ItemMeta extends Loadable {} + + public record FoodMeta( + @DefaultInt(8) int nutrition + ) implements ItemMeta {} + + public record PotionMeta( + @DefaultString("#FF0000") String color + ) implements ItemMeta {} + + /** Single useKey polymorphic field — field name is replaced by the discriminator value. */ + public record UseKeyItemConfig( + @DefaultString("Apple") String name, + ItemMeta meta + ) implements Loadable {} + + /** List of useKey polymorphic elements — serialized as a YAML map. */ + public record UseKeyItemListConfig( + List metadata + ) implements Loadable {} + + /** Map whose values are useKey polymorphic — map key IS the discriminator. */ + public record UseKeyItemMapConfig( + Map bySlot + ) implements Loadable {} } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index 64e4463..04155f4 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -21,6 +21,7 @@ class LoadableSerializerTest { static void setupRegistries() { tryCreate(Animal.class, r -> { r.register("dog", Dog.class); r.register("cat", Cat.class); }); tryCreate(DbEngine.class, r -> { r.register("mysql", MySQLEngine.class); r.register("postgres", PostgreSQLEngine.class); }); + tryCreate(ItemMeta.class, r -> { r.register("food", FoodMeta.class); r.register("potion", PotionMeta.class); }); } @AfterAll @@ -131,6 +132,47 @@ void fullyInlinePolymorphicFlattensEverything() { assertFalse(yaml.contains("db:"), "'db' key must not appear when fully inline"); } + // ── @Polymorphic(useKey = true) ─────────────────────────────────────────── + + @Test + void useKeyPolymorphicSingleFieldDropsFieldName() { + String yaml = serializer.toYaml(new UseKeyItemConfig("Apple", new FoodMeta(10))); + + // discriminator value "food" becomes the key; field name "meta" must not appear + assertTrue(yaml.contains("food:"), "discriminator value must be the YAML key"); + assertTrue(yaml.contains("nutrition: 10")); + assertFalse(yaml.contains("meta:"), "'meta' field name must not appear with useKey"); + } + + @Test + void useKeyPolymorphicListBecomesYamlMap() { + String yaml = serializer.toYaml(new UseKeyItemListConfig( + List.of(new FoodMeta(8), new PotionMeta("#00FF00")) + )); + + assertTrue(yaml.contains("food:"), "food discriminator key missing"); + assertTrue(yaml.contains("nutrition: 8")); + assertTrue(yaml.contains("potion:"), "potion discriminator key missing"); + assertTrue(yaml.contains("#00FF00")); + // must not contain the standard list-item discriminator pattern "type: food" + assertFalse(yaml.contains("type: food"), "standard discriminator must not appear with useKey"); + } + + @Test + void useKeyPolymorphicMapOmitsExtraDiscriminator() { + String yaml = serializer.toYaml(new UseKeyItemMapConfig( + Map.of("slot1", new FoodMeta(5), "slot2", new PotionMeta("#0000FF")) + )); + + assertTrue(yaml.contains("slot1:")); + assertTrue(yaml.contains("slot2:")); + assertTrue(yaml.contains("nutrition: 5")); + assertTrue(yaml.contains("#0000FF")); + // concrete fields must NOT be wrapped with a redundant "type:" key + assertFalse(yaml.contains("type: food"), "useKey map must not embed type discriminator"); + assertFalse(yaml.contains("type: potion"), "useKey map must not embed type discriminator"); + } + // ── Helpers ─────────────────────────────────────────────────────────────── @SuppressWarnings("unchecked") From 6fde614d03e8ee5892fc9a56efc4ac97cb071155 Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 09:43:30 +0200 Subject: [PATCH 05/13] feat: implement @Options(isKey=true) list serialization Records annotated with @Options(isKey=true) on one of their components are now serialized as YAML maps when they appear inside a List field. The key component's value becomes the map key, and the remaining fields are written as a nested map under it. Added Permission/PermissionConfig (single non-key field) and Route/RouteConfig (multiple non-key fields) fixtures, plus two tests verifying that the key field is correctly promoted and not duplicated inside the nested map. --- .../serializer/LoadableSerializer.java | 17 +++++++++- .../writers/fixtures/WriterTestModels.java | 27 ++++++++++++++++ .../serializer/LoadableSerializerTest.java | 32 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 2bf0c35..8bffb38 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -180,8 +180,23 @@ private Object serializeCollection(List elements, Type elementGenericType) { Class elementRawType = getRawClass(elementGenericType); + // isKey records: list becomes a YAML map keyed by the @Options(isKey=true) field value + if (elementRawType.isRecord()) { + RecordComponent keyComp = fieldMapper.findKeyComponent(elementRawType.getRecordComponents()); + if (keyComp != null) { + Map resultMap = new LinkedHashMap<>(); + for (Object elem : elements) { + Object keyValue = readField(keyComp, elem); + String mapKey = keyValue == null ? "null" : keyValue.toString(); + Map fields = toMap(elem); + fields.remove(fieldMapper.convertCamelCaseToKebabCase(keyComp.getName())); + resultMap.put(mapKey, fields); + } + return resultMap; + } + } + // Polymorphic list: discriminator inside each element map - // TODO: handle isKey records (map-keyed format) if (isPolymorphicInterface(elementRawType)) { Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index c6173c7..a1564b3 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -176,4 +176,31 @@ public record UseKeyItemListConfig( public record UseKeyItemMapConfig( Map bySlot ) implements Loadable {} + + // ========================================================================= + // @Options(isKey = true) — record field as map key in collections + // ========================================================================= + + /** Record where 'id' is the map key when serialized inside a collection. */ + public record Permission( + @Options(isKey = true) String id, + @DefaultInt(1) int level + ) implements Loadable {} + + /** Wraps a list of Permission — serialized as a map keyed by id. */ + public record PermissionConfig( + @DefaultString("MyApp") String appName, + List permissions + ) implements Loadable {} + + /** Record with two non-key fields — verifies the full nested map is preserved. */ + public record Route( + @Options(isKey = true) String path, + @DefaultString("GET") String method, + @DefaultBool(true) boolean enabled + ) implements Loadable {} + + public record RouteConfig( + List routes + ) implements Loadable {} } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index 04155f4..ca4e999 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -173,6 +173,38 @@ void useKeyPolymorphicMapOmitsExtraDiscriminator() { assertFalse(yaml.contains("type: potion"), "useKey map must not embed type discriminator"); } + // ── @Options(isKey = true) ──────────────────────────────────────────────── + + @Test + void isKeyListBecomesYamlMap() { + String yaml = serializer.toYaml(new PermissionConfig("MyApp", + List.of(new Permission("admin", 10), new Permission("user", 1)) + )); + + assertTrue(yaml.contains("admin:"), "isKey value must become the YAML key"); + assertTrue(yaml.contains("user:"), "isKey value must become the YAML key"); + assertTrue(yaml.contains("level: 10")); + assertTrue(yaml.contains("level: 1")); + // the key field itself must not appear as a nested field + assertFalse(yaml.contains("id: admin"), "'id' must not be written as a nested field"); + assertFalse(yaml.contains("id: user"), "'id' must not be written as a nested field"); + } + + @Test + void isKeyRecordWithMultipleNonKeyFieldsKeepsNestedMap() { + String yaml = serializer.toYaml(new RouteConfig( + List.of(new Route("/api/users", "GET", true), new Route("/api/admin", "POST", false)) + )); + + assertTrue(yaml.contains("/api/users:")); + assertTrue(yaml.contains("/api/admin:")); + assertTrue(yaml.contains("method: GET")); + assertTrue(yaml.contains("method: POST")); + assertTrue(yaml.contains("enabled: true")); + assertTrue(yaml.contains("enabled: false")); + assertFalse(yaml.contains("path:"), "'path' must not appear as a nested field"); + } + // ── Helpers ─────────────────────────────────────────────────────────────── @SuppressWarnings("unchecked") From a581ffd149bfd60661fac601dd2a190cf0875e8d Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 12:41:50 +0200 Subject: [PATCH 06/13] fix: omit optional null fields; add coverage for name, enum, date types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional null fields annotated with @Options(optional=true) were being written as explicit nulls in YAML. They are now silently skipped, matching the reader's behaviour of treating absent keys as optional. Also adds test coverage for three previously untested paths in the serializer: @Options(name=...) custom key override, enum → kebab-case conversion, and LocalDate/LocalDateTime ISO-8601 formatting. Corresponding fixtures (MixedOptionalConfig, CustomNameConfig, EnvConfig, ScheduleConfig) added to WriterTestModels. --- .../serializer/LoadableSerializer.java | 6 ++- .../writers/fixtures/WriterTestModels.java | 41 ++++++++++++++++ .../serializer/LoadableSerializerTest.java | 49 +++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 8bffb38..4755bf5 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -100,7 +100,10 @@ private void contributeToMap(Map result, RecordComponent compone String key = fieldMapper.getEffectiveFieldName(parameter, component.getName()); if (value == null) { - result.put(key, null); + // Optional null fields are silently omitted; non-optional nulls are written explicitly + if (opts == null || !opts.optional()) { + result.put(key, null); + } return; } @@ -301,3 +304,4 @@ private Object readField(RecordComponent component, Object obj) { } } } + diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index a1564b3..132db7e 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -5,6 +5,8 @@ import fr.traqueur.structura.annotations.defaults.*; import fr.traqueur.structura.api.Loadable; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Set; @@ -203,4 +205,43 @@ public record Route( public record RouteConfig( List routes ) implements Loadable {} + + // ========================================================================= + // @Options(optional = true) — null field omission + // ========================================================================= + + public record MixedOptionalConfig( + @DefaultString("required") String required, + @Options(optional = true) String maybeNull, + @Options(optional = true) Integer maybeInt + ) implements Loadable {} + + // ========================================================================= + // @Options(name = "...") — custom YAML key + // ========================================================================= + + public record CustomNameConfig( + @Options(name = "server-address") String serverAddress, + @DefaultInt(8080) int port + ) implements Loadable {} + + // ========================================================================= + // Enum serialization + // ========================================================================= + + public enum Environment { DEVELOPMENT, STAGING, PRODUCTION_READY } + + public record EnvConfig( + @DefaultString("App") String name, + Environment env + ) implements Loadable {} + + // ========================================================================= + // LocalDate / LocalDateTime serialization + // ========================================================================= + + public record ScheduleConfig( + LocalDate startDate, + LocalDateTime createdAt + ) implements Loadable {} } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index ca4e999..6452dfd 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.*; import java.lang.reflect.Field; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Set; @@ -68,6 +70,53 @@ void customWriterInvoked() { CustomWriterRegistry.getInstance().unregister(Color.class); } + // ── @Options(optional = true) ──────────────────────────────────────────── + + @Test + void optionalNullFieldIsOmitted() { + String yaml = serializer.toYaml(new MixedOptionalConfig("hello", null, null)); + assertTrue(yaml.contains("required: hello")); + assertFalse(yaml.contains("maybe-null:"), "null optional field must not appear in YAML"); + assertFalse(yaml.contains("maybe-int:"), "null optional field must not appear in YAML"); + } + + @Test + void optionalPresentFieldIsWritten() { + String yaml = serializer.toYaml(new MixedOptionalConfig("hello", "world", 42)); + assertTrue(yaml.contains("maybe-null: world")); + assertTrue(yaml.contains("maybe-int: 42")); + } + + // ── @Options(name = "...") ──────────────────────────────────────────────── + + @Test + void customFieldNameOverridesDefault() { + String yaml = serializer.toYaml(new CustomNameConfig("192.168.1.1", 9090)); + assertTrue(yaml.contains("server-address: 192.168.1.1"), "custom name must be used as YAML key"); + assertFalse(yaml.contains("server-address-field:")); + assertFalse(yaml.contains("serverAddress:"), "camelCase field name must not appear"); + } + + // ── Enum serialization ──────────────────────────────────────────────────── + + @Test + void enumFieldIsConvertedToKebabCase() { + String yaml = serializer.toYaml(new EnvConfig("App", Environment.PRODUCTION_READY)); + assertTrue(yaml.contains("production-ready"), "enum value must be written in kebab-case"); + assertFalse(yaml.contains("PRODUCTION_READY"), "raw enum name must not appear"); + } + + // ── LocalDate / LocalDateTime serialization ─────────────────────────────── + + @Test + void localDateIsIsoFormatted() { + String yaml = serializer.toYaml(new ScheduleConfig( + LocalDate.of(2024, 6, 15), LocalDateTime.of(2024, 6, 15, 10, 30, 0) + )); + assertTrue(yaml.contains("2024-06-15"), "LocalDate must use ISO-8601 date format"); + assertTrue(yaml.contains("2024-06-15T10:30"), "LocalDateTime must use ISO-8601 datetime format"); + } + // ── @Options(inline = true) ─────────────────────────────────────────────── @Test From f2b398d2bef8533ec3e488a543e00ea63224be1f Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 15:11:29 +0200 Subject: [PATCH 07/13] fix: isKey in maps, parent dirs on write; round-trip and coverage tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoadableSerializer: handle @Options(isKey=true) inside Map values — the key component is stripped from each nested field map, symmetric with the existing List behaviour. Update class Javadoc to reflect all currently supported features (was still listing useKey/isKey as TODO). - StructuraWriters.write(): create parent directories before writing so callers can pass deep paths (e.g. plugins/foo/config.yml) without having to create the directory tree themselves. - StructuraWritersTest: add polymorphic standard + inline round-trip tests (write then Structura.load and assert concrete type), a test verifying parent directory creation, and saveDefault coverage for optional fields with no @Default* annotation. - WriterTestModels: add Endpoint/EndpointMapConfig for Map isKey tests and OptionalOnlyConfig for the saveDefault optional-omission test. --- .../structura/writers/StructuraWriters.java | 2 + .../serializer/LoadableSerializer.java | 28 ++++-- .../writers/StructuraWritersTest.java | 89 +++++++++++++++++-- .../writers/fixtures/WriterTestModels.java | 24 +++++ .../serializer/LoadableSerializerTest.java | 15 ++++ 5 files changed, 146 insertions(+), 12 deletions(-) diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java index 484375d..4f7874b 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java @@ -35,6 +35,8 @@ public static void write(Path file, Loadable config) { String yaml = SERIALIZER.toYaml(config); try { + Path parent = file.getParent(); + if (parent != null) Files.createDirectories(parent); Files.writeString(file, yaml, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } catch (IOException e) { throw new StructuraWriterException("Failed to write configuration to: " + file.toAbsolutePath(), e); diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 4755bf5..1f96ff2 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -29,16 +29,17 @@ *
    *
  • camelCase → kebab-case key conversion
  • *
  • {@code @Options(inline = true)} — flattens a sub-record's fields into the parent map
  • + *
  • {@code @Options(optional = true)} — null fields are silently omitted
  • + *
  • {@code @Options(name = "...")} — overrides the YAML key for a field
  • + *
  • {@code @Options(isKey = true)} — record field used as map key in List and Map values
  • *
  • {@code @Polymorphic} standard — discriminator written inside the nested map
  • *
  • {@code @Polymorphic(inline = true)} — discriminator written at the parent level
  • *
  • {@code @Polymorphic(inline = true)} + {@code @Options(inline = true)} — fully inline: * discriminator and all concrete fields at parent level
  • - *
- * - *

Not yet handled (TODO):

- *
    - *
  • {@code @Polymorphic(useKey = true)}
  • - *
  • {@code @Options(isKey = true)}
  • + *
  • {@code @Polymorphic(useKey = true)} — discriminator value becomes the YAML key
  • + *
  • {@link java.time.LocalDate} / {@link java.time.LocalDateTime} — ISO-8601 strings
  • + *
  • {@link java.lang.Enum} — snake_case name converted to kebab-case
  • + *
  • {@link fr.traqueur.structura.references.Reference} — serialized as its key string
  • *
* *

Custom types can be handled by registering a {@link fr.traqueur.structura.writers.writer.Writer} @@ -233,6 +234,21 @@ private Object serializeMap(Map map, Type[] typeArgs) { Map result = new LinkedHashMap<>(); + // isKey records as map values: strip the key component from each nested map + if (valueRawType.isRecord()) { + RecordComponent keyComp = fieldMapper.findKeyComponent(valueRawType.getRecordComponents()); + if (keyComp != null) { + String keyField = fieldMapper.convertCamelCaseToKebabCase(keyComp.getName()); + for (Map.Entry e : map.entrySet()) { + if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } + Map fields = toMap(e.getValue()); + fields.remove(keyField); + result.put(e.getKey().toString(), fields); + } + return result; + } + } + // Polymorphic map values: discriminator inside each value if (isPolymorphicInterface(valueRawType)) { Polymorphic poly = valueRawType.getAnnotation(Polymorphic.class); diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java index a740d5b..05dd5ef 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java @@ -1,12 +1,15 @@ package fr.traqueur.structura.writers; import fr.traqueur.structura.api.Structura; +import fr.traqueur.structura.registries.PolymorphicRegistry; import fr.traqueur.structura.writers.fixtures.WriterTestModels.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; +import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -15,6 +18,21 @@ class StructuraWritersTest { @TempDir Path tempDir; + @BeforeAll + static void setupRegistries() { + tryCreate(Animal.class, r -> { r.register("dog", Dog.class); r.register("cat", Cat.class); }); + tryCreate(DbEngine.class, r -> { r.register("mysql", MySQLEngine.class); r.register("postgres", PostgreSQLEngine.class); }); + } + + @AfterAll + static void tearDownRegistries() throws Exception { + Field f = PolymorphicRegistry.class.getDeclaredField("REGISTRIES"); + f.setAccessible(true); + ((Map) f.get(null)).clear(); + } + + // ── Basic write / read-back ─────────────────────────────────────────────── + @Test void shouldWriteAndReadBack() throws Exception { Path file = tempDir.resolve("config.yml"); @@ -37,6 +55,31 @@ void shouldWriteKebabCaseKeys() throws Exception { assertTrue(content.contains("http-port:")); } + @Test + void shouldSerializeNestedRecord() throws Exception { + Path file = tempDir.resolve("nested.yml"); + StructuraWriters.write(file, new NestedDefaultConfig("MyApp", new ServerBlock("db.local", 5432))); + + String content = Files.readString(file); + assertTrue(content.contains("app-name: MyApp")); + assertTrue(content.contains("server:")); + assertTrue(content.contains("host: db.local")); + } + + // ── Parent directory creation ───────────────────────────────────────────── + + @Test + void writeCreatesParentDirectoriesIfAbsent() throws Exception { + Path file = tempDir.resolve("a/b/c/config.yml"); + assertFalse(Files.exists(file.getParent()), "parent must not exist before write"); + + StructuraWriters.write(file, new PlainConfig("deep", 1)); + + assertTrue(Files.exists(file), "file must be created including parent dirs"); + } + + // ── saveDefault ─────────────────────────────────────────────────────────── + @Test void saveDefaultShouldGenerateFromAnnotations() throws Exception { Path file = tempDir.resolve("default.yml"); @@ -49,13 +92,47 @@ void saveDefaultShouldGenerateFromAnnotations() throws Exception { } @Test - void shouldSerializeNestedRecord() throws Exception { - Path file = tempDir.resolve("nested.yml"); - StructuraWriters.write(file, new NestedDefaultConfig("MyApp", new ServerBlock("db.local", 5432))); + void saveDefaultOmitsOptionalFieldsWithNoDefault() throws Exception { + Path file = tempDir.resolve("optional.yml"); + StructuraWriters.saveDefault(file, OptionalOnlyConfig.class); String content = Files.readString(file); - assertTrue(content.contains("app-name: MyApp")); - assertTrue(content.contains("server:")); - assertTrue(content.contains("host: db.local")); + assertTrue(content.contains("required:")); + assertFalse(content.contains("never-default:"), "optional field with no default must be absent"); + } + + // ── Polymorphic round-trip ──────────────────────────────────────────────── + + @Test + void polymorphicStandardRoundTrip() throws Exception { + Path file = tempDir.resolve("animal.yml"); + StructuraWriters.write(file, new AnimalConfig("Farm", new Dog("Buddy", "poodle"))); + + AnimalConfig loaded = Structura.load(file, AnimalConfig.class); + assertInstanceOf(Dog.class, loaded.pet()); + assertEquals("Buddy", ((Dog) loaded.pet()).name()); + assertEquals("poodle", ((Dog) loaded.pet()).breed()); + } + + @Test + void polymorphicInlineRoundTrip() throws Exception { + Path file = tempDir.resolve("db.yml"); + StructuraWriters.write(file, new InlineDbConfig("App", new MySQLEngine("db.local", 3306))); + + InlineDbConfig loaded = Structura.load(file, InlineDbConfig.class); + assertInstanceOf(MySQLEngine.class, loaded.db()); + assertEquals("db.local", ((MySQLEngine) loaded.db()).host()); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private static void tryCreate( + Class clazz, java.util.function.Consumer> cfg) { + try { + PolymorphicRegistry.get(clazz); + } catch (Exception ignored) { + PolymorphicRegistry.create(clazz, cfg); + } } } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index 132db7e..ab7c872 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -244,4 +244,28 @@ public record ScheduleConfig( LocalDate startDate, LocalDateTime createdAt ) implements Loadable {} + + // ========================================================================= + // @Options(isKey = true) inside a Map value + // ========================================================================= + + /** Same as Route but used to test Map serialization. */ + public record Endpoint( + @Options(isKey = true) String path, + @DefaultString("GET") String method, + @DefaultInt(200) int statusCode + ) implements Loadable {} + + public record EndpointMapConfig( + Map endpoints + ) implements Loadable {} + + // ========================================================================= + // saveDefault with optional fields (no @Default* annotation) + // ========================================================================= + + public record OptionalOnlyConfig( + @DefaultString("required") String required, + @Options(optional = true) String neverDefault + ) implements Loadable {} } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index 6452dfd..7a3b5b3 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -239,6 +239,21 @@ void isKeyListBecomesYamlMap() { assertFalse(yaml.contains("id: user"), "'id' must not be written as a nested field"); } + @Test + void isKeyInsideMapValueStripsKeyField() { + String yaml = serializer.toYaml(new EndpointMapConfig(Map.of( + "/health", new Endpoint("/health", "GET", 200), + "/login", new Endpoint("/login", "POST", 201) + ))); + + assertTrue(yaml.contains("/health:")); + assertTrue(yaml.contains("/login:")); + assertTrue(yaml.contains("method: GET")); + assertTrue(yaml.contains("method: POST")); + assertTrue(yaml.contains("status-code: 200")); + assertFalse(yaml.contains("path:"), "'path' must not appear as a nested field"); + } + @Test void isKeyRecordWithMultipleNonKeyFieldsKeepsNestedMap() { String yaml = serializer.toYaml(new RouteConfig( From 8f710744c67ef42a549b7f95aa061fe9fc593534 Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 15:26:30 +0200 Subject: [PATCH 08/13] refactor: centralize isKey logic in toMap(); add complex key support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move @Options(isKey=true) handling from serializeCollection/serializeMap into toMap() itself via toMapWithKeyComponent(). This mirrors the approach in the parallel implementation and fixes two gaps: - Simple key (String/primitive): toMap() now returns { keyValue: {fields} } so serializeCollection() just putAll(), and serializeMap() extracts the inner value — no more manual field stripping spread across helpers. - Complex key (record type as isKey field): key sub-record fields are flattened alongside other fields at the same level. Lists of complex-key records are serialized as YAML lists (not maps) since there is no unique scalar key to distinguish entries. Also adds isPolymorphicWithUseKey() and findComponentIndex() helpers, improves the lookupRegisteredName() error message, and covers the new complex key cases with two tests + ServerCoordinates/ComplexKeyEntry fixtures. --- .../serializer/LoadableSerializer.java | 199 ++++++++++++++---- .../writers/fixtures/WriterTestModels.java | 23 +- .../serializer/LoadableSerializerTest.java | 34 +++ 3 files changed, 209 insertions(+), 47 deletions(-) diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 1f96ff2..d73a053 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -25,13 +25,14 @@ /** * Serializes a {@link Loadable} record to a YAML string. * - *

Handles:

+ *

Supports the full symmetric set of features that the Structura reader understands:

*
    *
  • camelCase → kebab-case key conversion
  • *
  • {@code @Options(inline = true)} — flattens a sub-record's fields into the parent map
  • *
  • {@code @Options(optional = true)} — null fields are silently omitted
  • *
  • {@code @Options(name = "...")} — overrides the YAML key for a field
  • - *
  • {@code @Options(isKey = true)} — record field used as map key in List and Map values
  • + *
  • {@code @Options(isKey = true)} simple — record serialized as {@code {keyValue: {otherFields}}}
  • + *
  • {@code @Options(isKey = true)} complex — key sub-record's fields flattened at same level
  • *
  • {@code @Polymorphic} standard — discriminator written inside the nested map
  • *
  • {@code @Polymorphic(inline = true)} — discriminator written at the parent level
  • *
  • {@code @Polymorphic(inline = true)} + {@code @Options(inline = true)} — fully inline: @@ -72,6 +73,12 @@ public String toYaml(Loadable config) { // Core record → Map conversion // ------------------------------------------------------------------------- + /** + * Converts any record to a {@code Map} that SnakeYAML can dump. + * + *

    When the record has a component marked {@code @Options(isKey = true)}, delegates to + * {@link #toMapWithKeyComponent} which handles both simple-key and complex-key variants.

    + */ private Map toMap(Object obj) { Class clazz = obj.getClass(); if (!clazz.isRecord()) { @@ -82,6 +89,11 @@ private Map toMap(Object obj) { Constructor constructor = clazz.getDeclaredConstructors()[0]; Parameter[] parameters = constructor.getParameters(); + RecordComponent keyComponent = fieldMapper.findKeyComponent(components); + if (keyComponent != null) { + return toMapWithKeyComponent(obj, components, parameters, keyComponent); + } + Map result = new LinkedHashMap<>(); for (int i = 0; i < components.length; i++) { contributeToMap(result, components[i], parameters[i], readField(components[i], obj)); @@ -89,10 +101,67 @@ private Map toMap(Object obj) { return result; } + /** + * Serializes a record that has a component marked {@code @Options(isKey = true)}. + * + *
      + *
    • Simple key (String / primitive): returns {@code {keyValue: {otherFields}}}
    • + *
    • Complex key (record type): flattens the key sub-record's fields alongside the + * other fields at the same level
    • + *
    + */ + private Map toMapWithKeyComponent(Object obj, RecordComponent[] components, + Parameter[] parameters, + RecordComponent keyComponent) { + Class keyType = keyComponent.getType(); + + if (keyType.isRecord()) { + // Complex key: flatten key-record fields alongside other fields + Map result = new LinkedHashMap<>(); + for (int i = 0; i < components.length; i++) { + RecordComponent comp = components[i]; + Object value = readField(comp, obj); + if (comp.getName().equals(keyComponent.getName())) { + if (value != null) result.putAll(toMap(value)); + } else { + contributeToMap(result, comp, parameters[i], value); + } + } + return result; + } + + // Simple key: { keyValue → { otherFields } } + Object keyValue = readField(keyComponent, obj); + Map otherFields = new LinkedHashMap<>(); + int keyIdx = findComponentIndex(components, keyComponent); + for (int i = 0; i < components.length; i++) { + if (i != keyIdx) { + contributeToMap(otherFields, components[i], parameters[i], readField(components[i], obj)); + } + } + + Map result = new LinkedHashMap<>(); + result.put(keyValue != null ? keyValue.toString() : "", otherFields.isEmpty() ? null : otherFields); + return result; + } + // ------------------------------------------------------------------------- // Per-field contribution // ------------------------------------------------------------------------- + /** + * Adds the serialized representation of a single record component to {@code result}. + * + *

    Dispatch (in priority order):

    + *
      + *
    1. {@code @Options(inline=true)} + concrete Loadable record → flatten sub-fields
    2. + *
    3. {@code @Options(inline=true)} + {@code @Polymorphic(inline=true)} → fully inline
    4. + *
    5. {@code @Polymorphic(useKey=true)} → discriminator value becomes the YAML key
    6. + *
    7. {@code @Polymorphic(inline=true)} → discriminator at parent, fields under key
    8. + *
    9. {@code @Polymorphic} standard → discriminator inside nested map
    10. + *
    11. Everything else → normal key/value
    12. + *
    + */ private void contributeToMap(Map result, RecordComponent component, Parameter parameter, Object value) { Options opts = parameter.getAnnotation(Options.class); @@ -108,9 +177,9 @@ private void contributeToMap(Map result, RecordComponent compone return; } + // ── @Options(inline = true) ────────────────────────────────────────── if (isInline) { if (type.isRecord() && Loadable.class.isAssignableFrom(type)) { - // Flatten sub-record fields at parent level result.putAll(toMap(value)); } else if (isPolymorphicInterface(type)) { Polymorphic poly = type.getAnnotation(Polymorphic.class); @@ -127,12 +196,13 @@ private void contributeToMap(Map result, RecordComponent compone return; } + // ── Polymorphic interface ──────────────────────────────────────────── if (isPolymorphicInterface(type)) { Polymorphic poly = type.getAnnotation(Polymorphic.class); String discValue = lookupRegisteredName(value.getClass(), type); if (poly.useKey()) { - // useKey: discriminator value becomes the YAML key; field name is dropped + // Discriminator value becomes the YAML key; field name is dropped result.put(discValue, toMap(value)); } else if (poly.inline()) { // Discriminator at parent level, concrete fields nested under key @@ -179,40 +249,53 @@ private Object serializeValue(Object value, Type genericType) { return value; } + /** + * Serializes a {@code List} or (flattened) {@code Set}. + * + *
      + *
    • {@code @Polymorphic(useKey=true)} elements → map keyed by registered name
    • + *
    • {@code @Options(isKey=true)} record elements → {@code toMap()} already wraps each + * element as {@code {keyValue: {otherFields}}}; results are merged into one map
    • + *
    • Standard polymorphic elements → list with discriminator inside each entry
    • + *
    • Otherwise → plain list
    • + *
    + */ private Object serializeCollection(List elements, Type elementGenericType) { if (elements.isEmpty()) return new ArrayList<>(); Class elementRawType = getRawClass(elementGenericType); - // isKey records: list becomes a YAML map keyed by the @Options(isKey=true) field value + // useKey polymorphic list → map { registeredName: { fields } } + if (isPolymorphicWithUseKey(elementRawType)) { + Map resultMap = new LinkedHashMap<>(); + for (Object elem : elements) { + resultMap.put(lookupRegisteredName(elem.getClass(), elementRawType), toMap(elem)); + } + return resultMap; + } + + // isKey record list if (elementRawType.isRecord()) { RecordComponent keyComp = fieldMapper.findKeyComponent(elementRawType.getRecordComponents()); if (keyComp != null) { + if (keyComp.getType().isRecord()) { + // Complex key: toMap() flattens the key sub-record; serialize as a YAML list + return elements.stream() + .map(e -> toMap(e)) + .collect(Collectors.toList()); + } + // Simple key: toMap() returns { keyValue: { otherFields } }; merge into one map Map resultMap = new LinkedHashMap<>(); for (Object elem : elements) { - Object keyValue = readField(keyComp, elem); - String mapKey = keyValue == null ? "null" : keyValue.toString(); - Map fields = toMap(elem); - fields.remove(fieldMapper.convertCamelCaseToKebabCase(keyComp.getName())); - resultMap.put(mapKey, fields); + resultMap.putAll(toMap(elem)); } return resultMap; } } - // Polymorphic list: discriminator inside each element map + // Standard polymorphic list → list with discriminator inside each entry if (isPolymorphicInterface(elementRawType)) { - Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); - - if (poly.useKey()) { - // useKey list: becomes a YAML map — discriminator value is the key - Map resultMap = new LinkedHashMap<>(); - for (Object elem : elements) { - resultMap.put(lookupRegisteredName(elem.getClass(), elementRawType), toMap(elem)); - } - return resultMap; - } - + Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); List resultList = new ArrayList<>(); for (Object elem : elements) { Map entry = new LinkedHashMap<>(); @@ -228,41 +311,53 @@ private Object serializeCollection(List elements, Type elementGenericType) { .collect(Collectors.toList()); } + /** + * Serializes a {@code Map}. + * + *
      + *
    • {@code @Polymorphic(useKey=true)} values → outer key is the discriminator, + * just write concrete fields (no extra discriminator key)
    • + *
    • {@code @Options(isKey=true)} record values → {@code toMap()} wraps as + * {@code {keyValue: {otherFields}}}; extract the value so the outer map key is kept
    • + *
    • Standard polymorphic values → discriminator inside each value map
    • + *
    • Otherwise → normal map
    • + *
    + */ private Object serializeMap(Map map, Type[] typeArgs) { Type valueGenericType = typeArgs.length > 1 ? typeArgs[1] : Object.class; Class valueRawType = getRawClass(valueGenericType); Map result = new LinkedHashMap<>(); - // isKey records as map values: strip the key component from each nested map - if (valueRawType.isRecord()) { - RecordComponent keyComp = fieldMapper.findKeyComponent(valueRawType.getRecordComponents()); - if (keyComp != null) { - String keyField = fieldMapper.convertCamelCaseToKebabCase(keyComp.getName()); - for (Map.Entry e : map.entrySet()) { - if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } - Map fields = toMap(e.getValue()); - fields.remove(keyField); - result.put(e.getKey().toString(), fields); - } - return result; + // useKey map: outer key IS the discriminator — write concrete fields only + if (isPolymorphicWithUseKey(valueRawType)) { + for (Map.Entry e : map.entrySet()) { + result.put(e.getKey().toString(), e.getValue() != null ? toMap(e.getValue()) : null); + } + return result; + } + + // isKey record values: toMap() wraps as { keyValue: { otherFields } }; + // the outer map key is already provided, so we extract the inner value + if (valueRawType.isRecord() + && fieldMapper.findKeyComponent(valueRawType.getRecordComponents()) != null) { + for (Map.Entry e : map.entrySet()) { + if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } + Map wrapped = toMap(e.getValue()); + result.put(e.getKey().toString(), wrapped.values().iterator().next()); } + return result; } - // Polymorphic map values: discriminator inside each value + // Standard polymorphic map values: discriminator inside each value if (isPolymorphicInterface(valueRawType)) { Polymorphic poly = valueRawType.getAnnotation(Polymorphic.class); for (Map.Entry e : map.entrySet()) { if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } - if (poly.useKey()) { - // useKey map: the map key IS the discriminator — just write concrete fields - result.put(e.getKey().toString(), toMap(e.getValue())); - } else { - Map valueMap = new LinkedHashMap<>(); - valueMap.put(poly.key(), lookupRegisteredName(e.getValue().getClass(), valueRawType)); - valueMap.putAll(toMap(e.getValue())); - result.put(e.getKey().toString(), valueMap); - } + Map valueMap = new LinkedHashMap<>(); + valueMap.put(poly.key(), lookupRegisteredName(e.getValue().getClass(), valueRawType)); + valueMap.putAll(toMap(e.getValue())); + result.put(e.getKey().toString(), valueMap); } return result; } @@ -290,7 +385,9 @@ private String lookupRegisteredName(Class concreteClass, Class polymorphic } throw new StructuraWriterException( "No registered name for '" + concreteClass.getName() + - "' in polymorphic registry of '" + polymorphicInterface.getName() + "'" + "' in polymorphic registry of '" + polymorphicInterface.getName() + "'. " + + "Register it via PolymorphicRegistry.get(" + polymorphicInterface.getSimpleName() + + ".class).register(\"name\", " + concreteClass.getSimpleName() + ".class)" ); } @@ -304,6 +401,10 @@ private boolean isPolymorphicInterface(Class type) { && type.isAnnotationPresent(Polymorphic.class); } + private boolean isPolymorphicWithUseKey(Class type) { + return isPolymorphicInterface(type) && type.getAnnotation(Polymorphic.class).useKey(); + } + private Class getRawClass(Type type) { return switch (type) { case Class c -> c; @@ -319,5 +420,11 @@ private Object readField(RecordComponent component, Object obj) { throw new StructuraWriterException("Cannot read field: " + component.getName(), e); } } -} + private int findComponentIndex(RecordComponent[] components, RecordComponent target) { + for (int i = 0; i < components.length; i++) { + if (components[i].getName().equals(target.getName())) return i; + } + return -1; + } +} diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index ab7c872..b215050 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -180,7 +180,7 @@ public record UseKeyItemMapConfig( ) implements Loadable {} // ========================================================================= - // @Options(isKey = true) — record field as map key in collections + // @Options(isKey = true) simple — String/primitive key // ========================================================================= /** Record where 'id' is the map key when serialized inside a collection. */ @@ -206,6 +206,27 @@ public record RouteConfig( List routes ) implements Loadable {} + // ========================================================================= + // @Options(isKey = true) complex — record type as key (fields flattened) + // ========================================================================= + + /** Sub-record used as a complex key — its fields are flattened at the same level. */ + public record ServerCoordinates( + @DefaultString("localhost") String host, + @DefaultInt(8080) int port + ) implements Loadable {} + + /** Record whose key component is itself a record — coords fields are flattened. */ + public record ComplexKeyEntry( + @Options(isKey = true) ServerCoordinates coords, + @DefaultString("default") String label, + @DefaultBool(false) boolean active + ) implements Loadable {} + + public record ComplexKeyListConfig( + List entries + ) implements Loadable {} + // ========================================================================= // @Options(optional = true) — null field omission // ========================================================================= diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index 7a3b5b3..660e643 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -239,6 +239,40 @@ void isKeyListBecomesYamlMap() { assertFalse(yaml.contains("id: user"), "'id' must not be written as a nested field"); } + @Test + void isKeyComplexKeyRecordFlattensSubRecordFields() { + String yaml = serializer.toYaml(new ComplexKeyEntry( + new ServerCoordinates("db.example.com", 5432), "primary", true + )); + + assertTrue(yaml.contains("host: db.example.com"), "host must be at root level"); + assertTrue(yaml.contains("port: 5432"), "port must be at root level"); + assertTrue(yaml.contains("label: primary")); + assertTrue(yaml.contains("active: true")); + assertFalse(yaml.contains("coords:"), "coords field name must not appear when flattened"); + } + + @Test + void isKeyComplexKeyListProducesYamlListWithFlattenedFields() { + String yaml = serializer.toYaml(new ComplexKeyListConfig(List.of( + new ComplexKeyEntry(new ServerCoordinates("primary.db", 5432), "primary", true), + new ComplexKeyEntry(new ServerCoordinates("replica.db", 5433), "replica", false) + ))); + + // Each entry is a YAML list item with the key sub-record flattened at item level + assertTrue(yaml.contains("host: primary.db")); + assertTrue(yaml.contains("host: replica.db")); + assertTrue(yaml.contains("port: 5432")); + assertTrue(yaml.contains("port: 5433")); + assertTrue(yaml.contains("label: primary")); + assertTrue(yaml.contains("label: replica")); + // coords key must not appear (sub-record is flattened) + assertFalse(yaml.contains("coords:")); + // Must be a list (dashes), not a map — complex keys cannot act as unique map keys + assertTrue(yaml.contains("- host:") || yaml.contains("-\n") || yaml.contains("- "), + "output must be a YAML list, not a map"); + } + @Test void isKeyInsideMapValueStripsKeyField() { String yaml = serializer.toYaml(new EndpointMapConfig(Map.of( From 89f240925e3797b739f429cd2dc84989c9ae47a6 Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 15:42:02 +0200 Subject: [PATCH 09/13] feat: add McTest example plugin using Structura + structura-writers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates the full read/write cycle in a real Paper plugin: - MainConfig — standard config with @Default* and an optional field - DatabaseConfig — @Polymorphic(inline=true) driver (MySQL / SQLite) - RewardsConfig — List with @Polymorphic (item/money/command) On first enable, saveDefault() generates all three config files from annotations. /mctest reload re-reads from disk, /mctest save writes the current in-memory state back, /mctest info prints all values in chat. PolymorphicRegistry setup is done once in onEnable() before any load. --- McTest/build.gradle.kts | 44 +++++ .../java/fr/traqueur/mctest/McTestPlugin.java | 155 ++++++++++++++++++ .../mctest/config/DatabaseConfig.java | 50 ++++++ .../fr/traqueur/mctest/config/MainConfig.java | 26 +++ .../traqueur/mctest/config/RewardsConfig.java | 54 ++++++ McTest/src/main/resources/plugin.yml | 12 ++ settings.gradle.kts | 3 +- 7 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 McTest/build.gradle.kts create mode 100644 McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java create mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java create mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java create mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java create mode 100644 McTest/src/main/resources/plugin.yml diff --git a/McTest/build.gradle.kts b/McTest/build.gradle.kts new file mode 100644 index 0000000..0c00dd5 --- /dev/null +++ b/McTest/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("java") + id("com.gradleup.shadow") version "9.0.0-beta11" +} + +group = "fr.traqueur" +version = rootProject.property("version") as String + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") +} + +dependencies { + // Paper API — provided at runtime by the server + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") + // SnakeYAML is bundled by Paper, so compileOnly here too + compileOnly("org.yaml:snakeyaml:2.4") + + // Structura core + writers — shaded into the jar + implementation(project(":")) + implementation(project(":structura-writers")) +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +tasks.processResources { + filesMatching("plugin.yml") { + expand("version" to project.version) + } +} + +tasks.shadowJar { + archiveClassifier.set("") + // Relocate Structura so it doesn't conflict if another plugin uses it + relocate("fr.traqueur.structura", "fr.traqueur.mctest.libs.structura") +} + +tasks.build { + dependsOn(tasks.shadowJar) +} diff --git a/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java b/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java new file mode 100644 index 0000000..516f396 --- /dev/null +++ b/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java @@ -0,0 +1,155 @@ +package fr.traqueur.mctest; + +import fr.traqueur.mctest.config.DatabaseConfig; +import fr.traqueur.mctest.config.DatabaseConfig.DatabaseDriver; +import fr.traqueur.mctest.config.DatabaseConfig.MySQLDriver; +import fr.traqueur.mctest.config.DatabaseConfig.SQLiteDriver; +import fr.traqueur.mctest.config.MainConfig; +import fr.traqueur.mctest.config.RewardsConfig; +import fr.traqueur.mctest.config.RewardsConfig.CommandReward; +import fr.traqueur.mctest.config.RewardsConfig.ItemReward; +import fr.traqueur.mctest.config.RewardsConfig.MoneyReward; +import fr.traqueur.mctest.config.RewardsConfig.Reward; +import fr.traqueur.structura.api.Structura; +import fr.traqueur.structura.registries.PolymorphicRegistry; +import fr.traqueur.structura.writers.StructuraWriters; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.java.JavaPlugin; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public final class McTestPlugin extends JavaPlugin { + + private MainConfig mainConfig; + private DatabaseConfig databaseConfig; + private RewardsConfig rewardsConfig; + + @Override + public void onEnable() { + setupRegistries(); + loadConfigs(); + getLogger().info("McTest enabled — plugin name: " + mainConfig.pluginName()); + } + + @Override + public void onDisable() { + getLogger().info("McTest disabled."); + } + + // ------------------------------------------------------------------------- + // Config + // ------------------------------------------------------------------------- + + private void setupRegistries() { + // Register all polymorphic types before loading any config that uses them + PolymorphicRegistry.create(DatabaseDriver.class, r -> { + r.register("mysql", MySQLDriver.class); + r.register("sqlite", SQLiteDriver.class); + }); + + PolymorphicRegistry.create(Reward.class, r -> { + r.register("item", ItemReward.class); + r.register("money", MoneyReward.class); + r.register("command", CommandReward.class); + }); + } + + private void loadConfigs() { + Path folder = getDataFolder().toPath(); + + mainConfig = loadOrDefault(folder.resolve("config.yml"), MainConfig.class); + databaseConfig = loadOrDefault(folder.resolve("database.yml"), DatabaseConfig.class); + rewardsConfig = loadOrDefault(folder.resolve("rewards.yml"), RewardsConfig.class); + } + + /** + * Loads a config file if it exists, otherwise generates it from @Default* annotations. + * This is the standard Structura pattern for plugin startup. + */ + private T loadOrDefault(Path file, Class type) { + if (!Files.exists(file)) { + getLogger().info("Generating default " + file.getFileName() + " ..."); + StructuraWriters.saveDefault(file, type); + } + return Structura.load(file, type); + } + + // ------------------------------------------------------------------------- + // Command: /mctest + // ------------------------------------------------------------------------- + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length == 0) { + sender.sendMessage("Usage: /mctest "); + return true; + } + + switch (args[0].toLowerCase()) { + + case "reload" -> { + loadConfigs(); + sender.sendMessage("[McTest] Configs reloaded from disk."); + } + + case "save" -> { + // Demonstrate Structura.write() — persists the current in-memory config to disk + Path folder = getDataFolder().toPath(); + StructuraWriters.write(folder.resolve("config.yml"), mainConfig); + StructuraWriters.write(folder.resolve("database.yml"), databaseConfig); + StructuraWriters.write(folder.resolve("rewards.yml"), rewardsConfig); + sender.sendMessage("[McTest] Configs saved to disk."); + } + + case "info" -> { + sender.sendMessage("=== McTest Info ==="); + sender.sendMessage("Plugin name : " + mainConfig.pluginName()); + sender.sendMessage("Language : " + mainConfig.language()); + sender.sendMessage("Debug : " + mainConfig.debug()); + sender.sendMessage("Join radius : " + mainConfig.joinMessageRadius()); + sender.sendMessage("Discord webhook: " + (mainConfig.discordWebhook() != null + ? mainConfig.discordWebhook() : "(not set)")); + + sender.sendMessage("--- Database ---"); + DatabaseDriver driver = databaseConfig.driver(); + if (driver instanceof MySQLDriver mysql) { + sender.sendMessage("Driver : MySQL"); + sender.sendMessage("Host : " + mysql.host() + ":" + mysql.port()); + sender.sendMessage("DB name : " + mysql.database()); + sender.sendMessage("Pool : " + mysql.poolSize()); + } else if (driver instanceof SQLiteDriver sqlite) { + sender.sendMessage("Driver : SQLite"); + sender.sendMessage("File : " + sqlite.file()); + } + + sender.sendMessage("--- Daily rewards ---"); + printRewards(sender, rewardsConfig.daily()); + sender.sendMessage("--- Weekly rewards ---"); + printRewards(sender, rewardsConfig.weekly()); + } + + default -> sender.sendMessage("Unknown subcommand. Usage: /mctest "); + } + + return true; + } + + private void printRewards(CommandSender sender, List rewards) { + if (rewards == null || rewards.isEmpty()) { + sender.sendMessage(" (none)"); + return; + } + for (Reward reward : rewards) { + String desc = switch (reward) { + case ItemReward r -> "item " + r.amount() + "x " + r.material(); + case MoneyReward r -> "money " + r.amount(); + case CommandReward r -> "command " + r.command(); + default -> reward.toString(); + }; + sender.sendMessage(" - " + desc); + } + } +} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java new file mode 100644 index 0000000..6e1c1db --- /dev/null +++ b/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java @@ -0,0 +1,50 @@ +package fr.traqueur.mctest.config; + +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.Polymorphic; +import fr.traqueur.structura.annotations.defaults.DefaultBool; +import fr.traqueur.structura.annotations.defaults.DefaultInt; +import fr.traqueur.structura.annotations.defaults.DefaultString; +import fr.traqueur.structura.api.Loadable; + +/** + * Database configuration — database.yml + * + * The driver is polymorphic with inline=true, meaning the "type" discriminator + * appears at the root of the file rather than nested inside a "driver" block. + * + * MySQL example (database.yml): + * type: mysql + * driver: + * host: localhost + * port: 3306 + * database: mctest + * username: root + * password: secret + * pool-size: 10 + * + * SQLite example (database.yml): + * type: sqlite + * driver: + * file: plugins/McTest/data.db + */ +public record DatabaseConfig( + DatabaseDriver driver +) implements Loadable { + + @Polymorphic(key = "type", inline = true) + public interface DatabaseDriver extends Loadable {} + + public record MySQLDriver( + @DefaultString("localhost") String host, + @DefaultInt(3306) int port, + @DefaultString("mctest") String database, + @DefaultString("root") String username, + @DefaultString("") String password, + @DefaultInt(10) int poolSize + ) implements DatabaseDriver {} + + public record SQLiteDriver( + @DefaultString("plugins/McTest/data.db") String file + ) implements DatabaseDriver {} +} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java new file mode 100644 index 0000000..ad80239 --- /dev/null +++ b/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java @@ -0,0 +1,26 @@ +package fr.traqueur.mctest.config; + +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.defaults.DefaultBool; +import fr.traqueur.structura.annotations.defaults.DefaultInt; +import fr.traqueur.structura.annotations.defaults.DefaultString; +import fr.traqueur.structura.api.Loadable; + +/** + * Main plugin configuration — config.yml + * + * Example YAML produced by Structura.saveDefault(): + * + * plugin-name: McTest + * language: en + * debug: false + * join-message-radius: 20 + * # discord-webhook is optional — omitted when null + */ +public record MainConfig( + @DefaultString("McTest") String pluginName, + @DefaultString("en") String language, + @DefaultBool(false) boolean debug, + @DefaultInt(20) int joinMessageRadius, + @Options(optional = true) String discordWebhook +) implements Loadable {} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java new file mode 100644 index 0000000..4623409 --- /dev/null +++ b/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java @@ -0,0 +1,54 @@ +package fr.traqueur.mctest.config; + +import fr.traqueur.structura.annotations.Polymorphic; +import fr.traqueur.structura.annotations.defaults.DefaultDouble; +import fr.traqueur.structura.annotations.defaults.DefaultInt; +import fr.traqueur.structura.annotations.defaults.DefaultString; +import fr.traqueur.structura.api.Loadable; + +import java.util.List; + +/** + * Rewards configuration — rewards.yml + * + * Each reward is polymorphic: the "type" key inside each list entry + * determines which concrete record is instantiated. + * + * Example rewards.yml: + * + * daily: + * - type: item + * material: DIAMOND + * amount: 1 + * - type: money + * amount: 500.0 + * weekly: + * - type: item + * material: NETHERITE_INGOT + * amount: 1 + * - type: command + * command: give %player% golden_apple 5 + * message: Enjoy your apples! + */ +public record RewardsConfig( + List daily, + List weekly +) implements Loadable { + + @Polymorphic(key = "type") + public interface Reward extends Loadable {} + + public record ItemReward( + @DefaultString("DIAMOND") String material, + @DefaultInt(1) int amount + ) implements Reward {} + + public record MoneyReward( + @DefaultDouble(100.0) double amount + ) implements Reward {} + + public record CommandReward( + @DefaultString("say Hello %player%!") String command, + @DefaultString("") String message + ) implements Reward {} +} diff --git a/McTest/src/main/resources/plugin.yml b/McTest/src/main/resources/plugin.yml new file mode 100644 index 0000000..9dc4119 --- /dev/null +++ b/McTest/src/main/resources/plugin.yml @@ -0,0 +1,12 @@ +name: McTest +version: "${version}" +main: fr.traqueur.mctest.McTestPlugin +api-version: "1.21" +description: Example plugin demonstrating Structura config read/write +authors: [ Traqueur ] + +commands: + mctest: + description: McTest main command + usage: /mctest + permission: mctest.admin diff --git a/settings.gradle.kts b/settings.gradle.kts index fec4e94..a6d37a0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,4 +10,5 @@ pluginManagement { } } -include("structura-writers") \ No newline at end of file +include("structura-writers") +include("McTest") \ No newline at end of file From 941f1e078f1d10c8b786c3e1f3a97264f7955898 Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 15:46:01 +0200 Subject: [PATCH 10/13] fix: use explicit default instances for polymorphic configs in McTest saveDefault() cannot determine which concrete type to instantiate for polymorphic interface fields (DatabaseDriver, Reward). Split loadOrDefault into two helpers: loadOrSaveDefault for fully-annotated configs and loadOrWrite for configs that require an explicit fallback instance. --- .../java/fr/traqueur/mctest/McTestPlugin.java | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java b/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java index 516f396..d14ed52 100644 --- a/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java +++ b/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java @@ -60,16 +60,28 @@ private void setupRegistries() { private void loadConfigs() { Path folder = getDataFolder().toPath(); - mainConfig = loadOrDefault(folder.resolve("config.yml"), MainConfig.class); - databaseConfig = loadOrDefault(folder.resolve("database.yml"), DatabaseConfig.class); - rewardsConfig = loadOrDefault(folder.resolve("rewards.yml"), RewardsConfig.class); + // MainConfig: all fields have @Default* — saveDefault() works fine + mainConfig = loadOrSaveDefault(folder.resolve("config.yml"), MainConfig.class); + + // DatabaseConfig: driver field is a polymorphic interface — we must provide a concrete + // default instance explicitly (saveDefault cannot pick a concrete type on its own) + databaseConfig = loadOrWrite(folder.resolve("database.yml"), databaseConfig, + new DatabaseConfig(new DatabaseConfig.MySQLDriver("localhost", 3306, "mctest", "root", "", 10))); + + // RewardsConfig: list fields have no @Default* — same issue, provide explicit defaults + rewardsConfig = loadOrWrite(folder.resolve("rewards.yml"), rewardsConfig, + new RewardsConfig( + List.of(new RewardsConfig.ItemReward("DIAMOND", 1), + new RewardsConfig.MoneyReward(500.0)), + List.of(new RewardsConfig.ItemReward("NETHERITE_INGOT", 1), + new RewardsConfig.CommandReward("give %player% golden_apple 5", "Enjoy!")))); } /** - * Loads a config file if it exists, otherwise generates it from @Default* annotations. - * This is the standard Structura pattern for plugin startup. + * Loads a config whose every field has a {@code @Default*} annotation. + * Generates the file from annotations on first run. */ - private T loadOrDefault(Path file, Class type) { + private T loadOrSaveDefault(Path file, Class type) { if (!Files.exists(file)) { getLogger().info("Generating default " + file.getFileName() + " ..."); StructuraWriters.saveDefault(file, type); @@ -77,6 +89,19 @@ private T loadOrDefault(Path file return Structura.load(file, type); } + /** + * Loads a config that contains polymorphic or unannotated fields. + * Uses {@code fallback} as the written default when the file doesn't exist yet. + */ + @SuppressWarnings("unchecked") + private T loadOrWrite(Path file, T current, T fallback) { + if (!Files.exists(file)) { + getLogger().info("Generating default " + file.getFileName() + " ..."); + StructuraWriters.write(file, fallback); + } + return Structura.load(file, (Class) fallback.getClass()); + } + // ------------------------------------------------------------------------- // Command: /mctest // ------------------------------------------------------------------------- From 53bf33c768d1de37bd7ef48efaadce70ad20db7f Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 16:18:19 +0200 Subject: [PATCH 11/13] feat: add standalone Java example demonstrating Structura read/write cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5-step console demo: saveDefault, load, modify+write, round-trip verify, polymorphic backend switch (Local → S3). No Minecraft dependency. --- example/build.gradle.kts | 33 +++++ .../main/java/fr/traqueur/example/Main.java | 135 ++++++++++++++++++ .../fr/traqueur/example/config/AppConfig.java | 25 ++++ .../example/config/StorageConfig.java | 49 +++++++ settings.gradle.kts | 3 +- 5 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 example/build.gradle.kts create mode 100644 example/src/main/java/fr/traqueur/example/Main.java create mode 100644 example/src/main/java/fr/traqueur/example/config/AppConfig.java create mode 100644 example/src/main/java/fr/traqueur/example/config/StorageConfig.java diff --git a/example/build.gradle.kts b/example/build.gradle.kts new file mode 100644 index 0000000..b1e920c --- /dev/null +++ b/example/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id("java") + id("com.gradleup.shadow") version "9.0.0-beta11" +} + +group = "fr.traqueur" +version = rootProject.property("version") as String + +repositories { + mavenCentral() +} + +dependencies { + implementation(project(":")) + implementation(project(":structura-writers")) + implementation("org.yaml:snakeyaml:2.4") +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +tasks.shadowJar { + archiveClassifier.set("") + manifest { + attributes["Main-Class"] = "fr.traqueur.example.Main" + } +} + +tasks.build { + dependsOn(tasks.shadowJar) +} diff --git a/example/src/main/java/fr/traqueur/example/Main.java b/example/src/main/java/fr/traqueur/example/Main.java new file mode 100644 index 0000000..859b55d --- /dev/null +++ b/example/src/main/java/fr/traqueur/example/Main.java @@ -0,0 +1,135 @@ +package fr.traqueur.example; + +import fr.traqueur.example.config.AppConfig; +import fr.traqueur.example.config.StorageConfig; +import fr.traqueur.example.config.StorageConfig.LocalBackend; +import fr.traqueur.example.config.StorageConfig.S3Backend; +import fr.traqueur.example.config.StorageConfig.StorageBackend; +import fr.traqueur.structura.api.Structura; +import fr.traqueur.structura.registries.PolymorphicRegistry; +import fr.traqueur.structura.writers.StructuraWriters; + +import java.nio.file.Files; +import java.nio.file.Path; + +public class Main { + + private static final Path CONFIG_DIR = Path.of("example-configs"); + + public static void main(String[] args) throws Exception { + + // ── 1. Setup polymorphic registries ─────────────────────────────────── + PolymorphicRegistry.create(StorageBackend.class, r -> { + r.register("local", LocalBackend.class); + r.register("s3", S3Backend.class); + }); + + System.out.println("╔══════════════════════════════════════╗"); + System.out.println("║ Structura — Java pure example ║"); + System.out.println("╚══════════════════════════════════════╝\n"); + + // ── 2. saveDefault — generate files from @Default* annotations ──────── + section("Step 1 — saveDefault()"); + + Path appFile = CONFIG_DIR.resolve("app.yml"); + Path storageFile = CONFIG_DIR.resolve("storage.yml"); + + if (!Files.exists(appFile)) { + StructuraWriters.saveDefault(appFile, AppConfig.class); + System.out.println("Generated: " + appFile); + } else { + System.out.println("Already exists: " + appFile); + } + + // StorageConfig has a polymorphic field — must use write() with an explicit default + if (!Files.exists(storageFile)) { + StorageConfig defaultStorage = new StorageConfig(new LocalBackend("./data", 100)); + StructuraWriters.write(storageFile, defaultStorage); + System.out.println("Generated: " + storageFile); + } else { + System.out.println("Already exists: " + storageFile); + } + + System.out.println("\nContents of app.yml:"); + System.out.println(Files.readString(appFile).indent(2).stripTrailing()); + + System.out.println("\nContents of storage.yml:"); + System.out.println(Files.readString(storageFile).indent(2).stripTrailing()); + + // ── 3. load — read config from disk ────────────────────────────────── + section("Step 2 — Structura.load()"); + + AppConfig appConfig = Structura.load(appFile, AppConfig.class); + StorageConfig storageConfig = Structura.load(storageFile, StorageConfig.class); + + System.out.println("app-name : " + appConfig.appName()); + System.out.println("version : " + appConfig.version()); + System.out.println("debug : " + appConfig.debug()); + System.out.println("max-connections : " + appConfig.maxConnections()); + System.out.println("admin-email : " + (appConfig.adminEmail() != null + ? appConfig.adminEmail() : "(not set — optional field)")); + + System.out.println(); + StorageBackend backend = storageConfig.backend(); + if (backend instanceof LocalBackend local) { + System.out.println("storage type : local"); + System.out.println("path : " + local.path()); + System.out.println("max-file-size : " + local.maxFileSizeMb() + " MB"); + } else if (backend instanceof S3Backend s3) { + System.out.println("storage type : s3"); + System.out.println("bucket : " + s3.bucket()); + System.out.println("region : " + s3.region()); + System.out.println("use-ssl : " + s3.useSsl()); + } + + // ── 4. modify in memory + write() ──────────────────────────────────── + section("Step 3 — modify + StructuraWriters.write()"); + + AppConfig updated = new AppConfig( + appConfig.appName(), + appConfig.version(), + true, // enable debug + appConfig.maxConnections(), + "admin@example.com" // set optional field + ); + StructuraWriters.write(appFile, updated); + System.out.println("Saved updated config (debug=true, admin-email set)."); + + System.out.println("\nNew contents of app.yml:"); + System.out.println(Files.readString(appFile).indent(2).stripTrailing()); + + // ── 5. reload from disk — verify round-trip ─────────────────────────── + section("Step 4 — reload and verify round-trip"); + + AppConfig reloaded = Structura.load(appFile, AppConfig.class); + System.out.println("debug after reload : " + reloaded.debug()); + System.out.println("admin-email : " + reloaded.adminEmail()); + System.out.println(); + + if (reloaded.debug() && "admin@example.com".equals(reloaded.adminEmail())) { + System.out.println("✓ Round-trip OK — write() then load() produces the same values."); + } else { + System.out.println("✗ Round-trip FAILED."); + } + + // ── 6. switch storage backend + write ──────────────────────────────── + section("Step 5 — switch storage backend to S3"); + + StorageConfig s3Config = new StorageConfig( + new S3Backend("prod-bucket", "us-east-1", "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI", true) + ); + StructuraWriters.write(storageFile, s3Config); + System.out.println("Switched to S3. New contents of storage.yml:"); + System.out.println(Files.readString(storageFile).indent(2).stripTrailing()); + + StorageConfig reloadedStorage = Structura.load(storageFile, StorageConfig.class); + System.out.println("Reloaded backend type : " + + (reloadedStorage.backend() instanceof S3Backend ? "S3 ✓" : "unexpected type ✗")); + + System.out.println("\nDone. Config files are in: " + CONFIG_DIR.toAbsolutePath()); + } + + private static void section(String title) { + System.out.println("\n── " + title + " " + "─".repeat(Math.max(0, 40 - title.length()))); + } +} diff --git a/example/src/main/java/fr/traqueur/example/config/AppConfig.java b/example/src/main/java/fr/traqueur/example/config/AppConfig.java new file mode 100644 index 0000000..b4fc3d8 --- /dev/null +++ b/example/src/main/java/fr/traqueur/example/config/AppConfig.java @@ -0,0 +1,25 @@ +package fr.traqueur.example.config; + +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.defaults.DefaultBool; +import fr.traqueur.structura.annotations.defaults.DefaultInt; +import fr.traqueur.structura.annotations.defaults.DefaultString; +import fr.traqueur.structura.api.Loadable; + +/** + * General application config — app.yml + * + * Generated file: + * app-name: MyApp + * version: 1.0.0 + * debug: false + * max-connections: 50 + * # admin-email omitted (optional, no default) + */ +public record AppConfig( + @DefaultString("MyApp") String appName, + @DefaultString("1.0.0") String version, + @DefaultBool(false) boolean debug, + @DefaultInt(50) int maxConnections, + @Options(optional = true) String adminEmail +) implements Loadable {} diff --git a/example/src/main/java/fr/traqueur/example/config/StorageConfig.java b/example/src/main/java/fr/traqueur/example/config/StorageConfig.java new file mode 100644 index 0000000..31ef2ad --- /dev/null +++ b/example/src/main/java/fr/traqueur/example/config/StorageConfig.java @@ -0,0 +1,49 @@ +package fr.traqueur.example.config; + +import fr.traqueur.structura.annotations.Polymorphic; +import fr.traqueur.structura.annotations.defaults.DefaultBool; +import fr.traqueur.structura.annotations.defaults.DefaultInt; +import fr.traqueur.structura.annotations.defaults.DefaultString; +import fr.traqueur.structura.api.Loadable; + +/** + * Storage backend config — storage.yml + * + * The backend is polymorphic with inline=true: the "type" key appears at + * the root of the file, not nested inside a "backend" block. + * + * Local example: + * type: local + * backend: + * path: ./data + * max-file-size-mb: 100 + * + * S3 example: + * type: s3 + * backend: + * bucket: my-bucket + * region: eu-west-1 + * access-key: AKIAIOSFODNN7EXAMPLE + * secret-key: wJalrXUtnFEMI + * use-ssl: true + */ +public record StorageConfig( + StorageBackend backend +) implements Loadable { + + @Polymorphic(key = "type", inline = true) + public interface StorageBackend extends Loadable {} + + public record LocalBackend( + @DefaultString("./data") String path, + @DefaultInt(100) int maxFileSizeMb + ) implements StorageBackend {} + + public record S3Backend( + @DefaultString("my-bucket") String bucket, + @DefaultString("eu-west-1") String region, + @DefaultString("CHANGE_ME") String accessKey, + @DefaultString("CHANGE_ME") String secretKey, + @DefaultBool(true) boolean useSsl + ) implements StorageBackend {} +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a6d37a0..360ba7f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,4 +11,5 @@ pluginManagement { } include("structura-writers") -include("McTest") \ No newline at end of file +include("McTest") +include("example") \ No newline at end of file From d8b541375e2336ed3eeac158ea08f4d37e73216c Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 16:24:48 +0200 Subject: [PATCH 12/13] chore: remove McTest plugin, keep standalone Java example only --- McTest/build.gradle.kts | 44 ----- .../java/fr/traqueur/mctest/McTestPlugin.java | 180 ------------------ .../mctest/config/DatabaseConfig.java | 50 ----- .../fr/traqueur/mctest/config/MainConfig.java | 26 --- .../traqueur/mctest/config/RewardsConfig.java | 54 ------ McTest/src/main/resources/plugin.yml | 12 -- settings.gradle.kts | 1 - 7 files changed, 367 deletions(-) delete mode 100644 McTest/build.gradle.kts delete mode 100644 McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java delete mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java delete mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java delete mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java delete mode 100644 McTest/src/main/resources/plugin.yml diff --git a/McTest/build.gradle.kts b/McTest/build.gradle.kts deleted file mode 100644 index 0c00dd5..0000000 --- a/McTest/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - id("java") - id("com.gradleup.shadow") version "9.0.0-beta11" -} - -group = "fr.traqueur" -version = rootProject.property("version") as String - -repositories { - mavenCentral() - maven("https://repo.papermc.io/repository/maven-public/") -} - -dependencies { - // Paper API — provided at runtime by the server - compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") - // SnakeYAML is bundled by Paper, so compileOnly here too - compileOnly("org.yaml:snakeyaml:2.4") - - // Structura core + writers — shaded into the jar - implementation(project(":")) - implementation(project(":structura-writers")) -} - -java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 -} - -tasks.processResources { - filesMatching("plugin.yml") { - expand("version" to project.version) - } -} - -tasks.shadowJar { - archiveClassifier.set("") - // Relocate Structura so it doesn't conflict if another plugin uses it - relocate("fr.traqueur.structura", "fr.traqueur.mctest.libs.structura") -} - -tasks.build { - dependsOn(tasks.shadowJar) -} diff --git a/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java b/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java deleted file mode 100644 index d14ed52..0000000 --- a/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java +++ /dev/null @@ -1,180 +0,0 @@ -package fr.traqueur.mctest; - -import fr.traqueur.mctest.config.DatabaseConfig; -import fr.traqueur.mctest.config.DatabaseConfig.DatabaseDriver; -import fr.traqueur.mctest.config.DatabaseConfig.MySQLDriver; -import fr.traqueur.mctest.config.DatabaseConfig.SQLiteDriver; -import fr.traqueur.mctest.config.MainConfig; -import fr.traqueur.mctest.config.RewardsConfig; -import fr.traqueur.mctest.config.RewardsConfig.CommandReward; -import fr.traqueur.mctest.config.RewardsConfig.ItemReward; -import fr.traqueur.mctest.config.RewardsConfig.MoneyReward; -import fr.traqueur.mctest.config.RewardsConfig.Reward; -import fr.traqueur.structura.api.Structura; -import fr.traqueur.structura.registries.PolymorphicRegistry; -import fr.traqueur.structura.writers.StructuraWriters; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.bukkit.plugin.java.JavaPlugin; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -public final class McTestPlugin extends JavaPlugin { - - private MainConfig mainConfig; - private DatabaseConfig databaseConfig; - private RewardsConfig rewardsConfig; - - @Override - public void onEnable() { - setupRegistries(); - loadConfigs(); - getLogger().info("McTest enabled — plugin name: " + mainConfig.pluginName()); - } - - @Override - public void onDisable() { - getLogger().info("McTest disabled."); - } - - // ------------------------------------------------------------------------- - // Config - // ------------------------------------------------------------------------- - - private void setupRegistries() { - // Register all polymorphic types before loading any config that uses them - PolymorphicRegistry.create(DatabaseDriver.class, r -> { - r.register("mysql", MySQLDriver.class); - r.register("sqlite", SQLiteDriver.class); - }); - - PolymorphicRegistry.create(Reward.class, r -> { - r.register("item", ItemReward.class); - r.register("money", MoneyReward.class); - r.register("command", CommandReward.class); - }); - } - - private void loadConfigs() { - Path folder = getDataFolder().toPath(); - - // MainConfig: all fields have @Default* — saveDefault() works fine - mainConfig = loadOrSaveDefault(folder.resolve("config.yml"), MainConfig.class); - - // DatabaseConfig: driver field is a polymorphic interface — we must provide a concrete - // default instance explicitly (saveDefault cannot pick a concrete type on its own) - databaseConfig = loadOrWrite(folder.resolve("database.yml"), databaseConfig, - new DatabaseConfig(new DatabaseConfig.MySQLDriver("localhost", 3306, "mctest", "root", "", 10))); - - // RewardsConfig: list fields have no @Default* — same issue, provide explicit defaults - rewardsConfig = loadOrWrite(folder.resolve("rewards.yml"), rewardsConfig, - new RewardsConfig( - List.of(new RewardsConfig.ItemReward("DIAMOND", 1), - new RewardsConfig.MoneyReward(500.0)), - List.of(new RewardsConfig.ItemReward("NETHERITE_INGOT", 1), - new RewardsConfig.CommandReward("give %player% golden_apple 5", "Enjoy!")))); - } - - /** - * Loads a config whose every field has a {@code @Default*} annotation. - * Generates the file from annotations on first run. - */ - private T loadOrSaveDefault(Path file, Class type) { - if (!Files.exists(file)) { - getLogger().info("Generating default " + file.getFileName() + " ..."); - StructuraWriters.saveDefault(file, type); - } - return Structura.load(file, type); - } - - /** - * Loads a config that contains polymorphic or unannotated fields. - * Uses {@code fallback} as the written default when the file doesn't exist yet. - */ - @SuppressWarnings("unchecked") - private T loadOrWrite(Path file, T current, T fallback) { - if (!Files.exists(file)) { - getLogger().info("Generating default " + file.getFileName() + " ..."); - StructuraWriters.write(file, fallback); - } - return Structura.load(file, (Class) fallback.getClass()); - } - - // ------------------------------------------------------------------------- - // Command: /mctest - // ------------------------------------------------------------------------- - - @Override - public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (args.length == 0) { - sender.sendMessage("Usage: /mctest "); - return true; - } - - switch (args[0].toLowerCase()) { - - case "reload" -> { - loadConfigs(); - sender.sendMessage("[McTest] Configs reloaded from disk."); - } - - case "save" -> { - // Demonstrate Structura.write() — persists the current in-memory config to disk - Path folder = getDataFolder().toPath(); - StructuraWriters.write(folder.resolve("config.yml"), mainConfig); - StructuraWriters.write(folder.resolve("database.yml"), databaseConfig); - StructuraWriters.write(folder.resolve("rewards.yml"), rewardsConfig); - sender.sendMessage("[McTest] Configs saved to disk."); - } - - case "info" -> { - sender.sendMessage("=== McTest Info ==="); - sender.sendMessage("Plugin name : " + mainConfig.pluginName()); - sender.sendMessage("Language : " + mainConfig.language()); - sender.sendMessage("Debug : " + mainConfig.debug()); - sender.sendMessage("Join radius : " + mainConfig.joinMessageRadius()); - sender.sendMessage("Discord webhook: " + (mainConfig.discordWebhook() != null - ? mainConfig.discordWebhook() : "(not set)")); - - sender.sendMessage("--- Database ---"); - DatabaseDriver driver = databaseConfig.driver(); - if (driver instanceof MySQLDriver mysql) { - sender.sendMessage("Driver : MySQL"); - sender.sendMessage("Host : " + mysql.host() + ":" + mysql.port()); - sender.sendMessage("DB name : " + mysql.database()); - sender.sendMessage("Pool : " + mysql.poolSize()); - } else if (driver instanceof SQLiteDriver sqlite) { - sender.sendMessage("Driver : SQLite"); - sender.sendMessage("File : " + sqlite.file()); - } - - sender.sendMessage("--- Daily rewards ---"); - printRewards(sender, rewardsConfig.daily()); - sender.sendMessage("--- Weekly rewards ---"); - printRewards(sender, rewardsConfig.weekly()); - } - - default -> sender.sendMessage("Unknown subcommand. Usage: /mctest "); - } - - return true; - } - - private void printRewards(CommandSender sender, List rewards) { - if (rewards == null || rewards.isEmpty()) { - sender.sendMessage(" (none)"); - return; - } - for (Reward reward : rewards) { - String desc = switch (reward) { - case ItemReward r -> "item " + r.amount() + "x " + r.material(); - case MoneyReward r -> "money " + r.amount(); - case CommandReward r -> "command " + r.command(); - default -> reward.toString(); - }; - sender.sendMessage(" - " + desc); - } - } -} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java deleted file mode 100644 index 6e1c1db..0000000 --- a/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java +++ /dev/null @@ -1,50 +0,0 @@ -package fr.traqueur.mctest.config; - -import fr.traqueur.structura.annotations.Options; -import fr.traqueur.structura.annotations.Polymorphic; -import fr.traqueur.structura.annotations.defaults.DefaultBool; -import fr.traqueur.structura.annotations.defaults.DefaultInt; -import fr.traqueur.structura.annotations.defaults.DefaultString; -import fr.traqueur.structura.api.Loadable; - -/** - * Database configuration — database.yml - * - * The driver is polymorphic with inline=true, meaning the "type" discriminator - * appears at the root of the file rather than nested inside a "driver" block. - * - * MySQL example (database.yml): - * type: mysql - * driver: - * host: localhost - * port: 3306 - * database: mctest - * username: root - * password: secret - * pool-size: 10 - * - * SQLite example (database.yml): - * type: sqlite - * driver: - * file: plugins/McTest/data.db - */ -public record DatabaseConfig( - DatabaseDriver driver -) implements Loadable { - - @Polymorphic(key = "type", inline = true) - public interface DatabaseDriver extends Loadable {} - - public record MySQLDriver( - @DefaultString("localhost") String host, - @DefaultInt(3306) int port, - @DefaultString("mctest") String database, - @DefaultString("root") String username, - @DefaultString("") String password, - @DefaultInt(10) int poolSize - ) implements DatabaseDriver {} - - public record SQLiteDriver( - @DefaultString("plugins/McTest/data.db") String file - ) implements DatabaseDriver {} -} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java deleted file mode 100644 index ad80239..0000000 --- a/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java +++ /dev/null @@ -1,26 +0,0 @@ -package fr.traqueur.mctest.config; - -import fr.traqueur.structura.annotations.Options; -import fr.traqueur.structura.annotations.defaults.DefaultBool; -import fr.traqueur.structura.annotations.defaults.DefaultInt; -import fr.traqueur.structura.annotations.defaults.DefaultString; -import fr.traqueur.structura.api.Loadable; - -/** - * Main plugin configuration — config.yml - * - * Example YAML produced by Structura.saveDefault(): - * - * plugin-name: McTest - * language: en - * debug: false - * join-message-radius: 20 - * # discord-webhook is optional — omitted when null - */ -public record MainConfig( - @DefaultString("McTest") String pluginName, - @DefaultString("en") String language, - @DefaultBool(false) boolean debug, - @DefaultInt(20) int joinMessageRadius, - @Options(optional = true) String discordWebhook -) implements Loadable {} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java deleted file mode 100644 index 4623409..0000000 --- a/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java +++ /dev/null @@ -1,54 +0,0 @@ -package fr.traqueur.mctest.config; - -import fr.traqueur.structura.annotations.Polymorphic; -import fr.traqueur.structura.annotations.defaults.DefaultDouble; -import fr.traqueur.structura.annotations.defaults.DefaultInt; -import fr.traqueur.structura.annotations.defaults.DefaultString; -import fr.traqueur.structura.api.Loadable; - -import java.util.List; - -/** - * Rewards configuration — rewards.yml - * - * Each reward is polymorphic: the "type" key inside each list entry - * determines which concrete record is instantiated. - * - * Example rewards.yml: - * - * daily: - * - type: item - * material: DIAMOND - * amount: 1 - * - type: money - * amount: 500.0 - * weekly: - * - type: item - * material: NETHERITE_INGOT - * amount: 1 - * - type: command - * command: give %player% golden_apple 5 - * message: Enjoy your apples! - */ -public record RewardsConfig( - List daily, - List weekly -) implements Loadable { - - @Polymorphic(key = "type") - public interface Reward extends Loadable {} - - public record ItemReward( - @DefaultString("DIAMOND") String material, - @DefaultInt(1) int amount - ) implements Reward {} - - public record MoneyReward( - @DefaultDouble(100.0) double amount - ) implements Reward {} - - public record CommandReward( - @DefaultString("say Hello %player%!") String command, - @DefaultString("") String message - ) implements Reward {} -} diff --git a/McTest/src/main/resources/plugin.yml b/McTest/src/main/resources/plugin.yml deleted file mode 100644 index 9dc4119..0000000 --- a/McTest/src/main/resources/plugin.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: McTest -version: "${version}" -main: fr.traqueur.mctest.McTestPlugin -api-version: "1.21" -description: Example plugin demonstrating Structura config read/write -authors: [ Traqueur ] - -commands: - mctest: - description: McTest main command - usage: /mctest - permission: mctest.admin diff --git a/settings.gradle.kts b/settings.gradle.kts index 360ba7f..f8fc1fc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,5 +11,4 @@ pluginManagement { } include("structura-writers") -include("McTest") include("example") \ No newline at end of file From 8aa8730e5b2a3248dcd3e1b51ddf76b6d08a8709 Mon Sep 17 00:00:00 2001 From: Traqueur_ Date: Sat, 6 Jun 2026 18:43:10 +0200 Subject: [PATCH 13/13] feat!: multi-module split, publish under fr.traqueur.structura (2.0.0) Restructure into published modules: - core (root) -> fr.traqueur.structura:structura-core - writer module -> fr.traqueur.structura:structura-writer - new bom module -> fr.traqueur.structura:structura-bom Build/publishing: - manual maven-publish (groupez repo) + publishAll task; example excluded from publishing - snakeyaml promoted to api (transitive), aligned to 2.6 - Gradle 8.14 -> 9.5.1, JUnit 6.1.0 + explicit junit-platform-launcher - version 1.7.0 -> 2.0.0 (coordinates changed: breaking) Writer API: - remove the static StructuraWriters facade; Structura is the sole entry point (logic folded into the YamlStructuraWriter SPI implementation) --- .github/workflows/build.yml | 2 +- .gitignore | 1 + README.md | 22 +- bom/build.gradle.kts | 55 +++++ build.gradle.kts | 188 ++++++++++++------ example/build.gradle.kts | 3 +- .../main/java/fr/traqueur/example/Main.java | 17 +- gradle.properties | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 47 +++-- gradlew.bat | 41 ++-- settings.gradle.kts | 7 +- src/main/resources/structura.properties | 2 +- structura-writers/build.gradle.kts | 45 ----- .../structura/writers/StructuraWriters.java | 59 ------ .../writers/YamlStructuraWriter.java | 32 --- writer/build.gradle.kts | 48 +++++ .../writers/YamlStructuraWriter.java | 57 ++++++ .../exceptions/StructuraWriterException.java | 0 .../factory/DefaultInstanceFactory.java | 0 .../registries/CustomWriterRegistry.java | 0 .../serializer/LoadableSerializer.java | 0 .../structura/writers/writer/Writer.java | 0 .../fr.traqueur.structura.api.StructuraWriter | 0 .../writers/StructuraWritersTest.java | 16 +- .../factory/DefaultInstanceFactoryTest.java | 0 .../writers/fixtures/WriterTestModels.java | 0 .../serializer/LoadableSerializerTest.java | 0 29 files changed, 388 insertions(+), 261 deletions(-) create mode 100644 bom/build.gradle.kts delete mode 100644 structura-writers/build.gradle.kts delete mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java delete mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java create mode 100644 writer/build.gradle.kts create mode 100644 writer/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java rename {structura-writers => writer}/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java (100%) rename {structura-writers => writer}/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java (100%) rename {structura-writers => writer}/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java (100%) rename {structura-writers => writer}/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java (100%) rename {structura-writers => writer}/src/main/java/fr/traqueur/structura/writers/writer/Writer.java (100%) rename {structura-writers => writer}/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter (100%) rename {structura-writers => writer}/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java (89%) rename {structura-writers => writer}/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java (100%) rename {structura-writers => writer}/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java (100%) rename {structura-writers => writer}/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c80b256..5e21da6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: project-name: "Structura" publish: true publish-on-discord: false - project-to-publish: "publish" + project-to-publish: "publishAll" secrets: MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} diff --git a/.gitignore b/.gitignore index 559cbb1..055f745 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ build/ !**/src/test/**/build/ CLAUDE.md target/ +example-configs/ ### IntelliJ IDEA ### .idea/modules.xml diff --git a/README.md b/README.md index 7512995..e1d05c2 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,27 @@ Add Structura to your project: ```gradle -repository { - maven { url = "https://repo.groupez.dev/releases" } // Add Structura repository replace releases with snapshots if needed +repositories { + maven { url = "https://repo.groupez.dev/releases" } // Add Structura repository, replace releases with snapshots if needed } dependencies { - implementation("fr.traqueur:structura:") // Replace with the latest release - implementation("org.yaml:snakeyaml:2.4") // Required for YAML parsing + // Core (reading YAML). SnakeYAML is pulled in transitively. + implementation("fr.traqueur.structura:structura-core:") // Replace with the latest release + + // Optional: reverse serialization (writing records back to YAML) + implementation("fr.traqueur.structura:structura-writer:") +} +``` + +Or use the BOM to align module versions: + +```gradle +dependencies { + implementation(platform("fr.traqueur.structura:structura-bom:")) + + implementation("fr.traqueur.structura:structura-core") + implementation("fr.traqueur.structura:structura-writer") } ``` diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts new file mode 100644 index 0000000..0c9582a --- /dev/null +++ b/bom/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + `java-platform` + `maven-publish` +} + +description = "Structura BOM (Bill of Materials)" + +javaPlatform { + allowDependencies() +} + +dependencies { + constraints { + api(project(":")) // fr.traqueur.structura:structura-core + api(project(":writer")) // fr.traqueur.structura:structura-writer + } +} + +publishing { + publications { + create("maven") { + from(components["javaPlatform"]) + + groupId = project.group.toString() + artifactId = "structura-bom" + version = project.version.toString() + + pom { + name.set("structura-bom") + description.set("Structura Bill of Materials — aligns versions across Structura modules") + url.set("https://github.com/Traqueur-dev/Structura") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("traqueur") + name.set("Traqueur") + } + } + + scm { + connection.set("scm:git:git://github.com/Traqueur-dev/Structura.git") + developerConnection.set("scm:git:ssh://github.com/Traqueur-dev/Structura.git") + url.set("https://github.com/Traqueur-dev/Structura") + } + } + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index c69f7e4..c407a2d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,88 +2,158 @@ import java.util.* plugins { id("java-library") - id("re.alwyn974.groupez.publish") version "1.0.0" - id("com.gradleup.shadow") version "9.0.0-beta11" + id("maven-publish") } -group = "fr.traqueur" -version = property("version") as String +allprojects { + group = "fr.traqueur.structura" + version = property("version") as String -extra.set("targetFolder", file("target/")) -extra.set("classifier", System.getProperty("archive.classifier")) -extra.set("sha", System.getProperty("github.sha")) - -rootProject.extra.properties["sha"]?.let { sha -> - version = sha -} - -repositories { - mavenCentral() -} - -dependencies { - compileOnly("org.yaml:snakeyaml:2.4") + apply { + if (project.name != "bom") + plugin("java-library") + // example is a runnable demo, not a published artifact + if (project.name != "example") + plugin("maven-publish") + } - testImplementation("org.yaml:snakeyaml:2.4") - testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") -} + // CI override: version becomes the short commit sha when building from the pipeline + System.getProperty("github.sha")?.let { version = it } -tasks.test { - useJUnitPlatform() - jvmArgs = listOf("-XX:+EnableDynamicAgentLoading") + repositories { + mavenCentral() + } - reports { - html.required.set(true) - junitXml.required.set(true) + if (project.name != "bom") { + + dependencies { + testImplementation("org.yaml:snakeyaml:2.6") + testImplementation("org.junit.jupiter:junit-jupiter:6.1.0") + // Gradle 9 no longer auto-provisions the JUnit Platform launcher; declare it explicitly. + testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.1.0") + } + + val targetJavaVersion = 21 + java { + val javaVersion = JavaVersion.toVersion(targetJavaVersion) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion.set(JavaLanguageVersion.of(targetJavaVersion)) + } + withSourcesJar() + withJavadocJar() + } + + tasks.test { + useJUnitPlatform() + jvmArgs = listOf("-XX:+EnableDynamicAgentLoading") + + reports { + html.required.set(true) + junitXml.required.set(true) + } + + testLogging { + showStandardStreams = true + events("passed", "skipped", "failed", "standardOut", "standardError") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + } } - testLogging { - showStandardStreams = true - events("passed", "skipped", "failed", "standardOut", "standardError") - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + // Centralized groupez publishing repository for any module applying maven-publish + plugins.withId("maven-publish") { + configure { + repositories { + maven { + val repo = System.getProperty("repository.name", "snapshots") + name = "groupez${repo.replaceFirstChar { it.uppercase() }}" + url = uri("https://repo.groupez.dev/${repo.lowercase()}") + credentials { + username = (findProperty("${name}Username") as String?) ?: System.getenv("MAVEN_USERNAME") + password = (findProperty("${name}Password") as String?) ?: System.getenv("MAVEN_PASSWORD") + } + authentication { + create("basic", BasicAuthentication::class) + } + } + } + } } } +// ───────────────────────────── Core module (root project) ───────────────────────────── - -val targetJavaVersion = 21 -java { - val javaVersion = JavaVersion.toVersion(targetJavaVersion) - sourceCompatibility = javaVersion - targetCompatibility = javaVersion - if (JavaVersion.current() < javaVersion) { - toolchain.languageVersion.set(JavaLanguageVersion.of(targetJavaVersion)) - } +dependencies { + api("org.yaml:snakeyaml:2.6") } +// Generates src/main/resources/structura.properties — read at runtime by Updater.getVersion() tasks.register("generateVersionProperties") { + // Capture values at configuration time so doLast does not touch `project` + // (required for the Gradle configuration cache / Gradle 10 compatibility). + val propertiesFile = project.file( + "src/main/resources/${project.name.lowercase(Locale.getDefault())}.properties" + ) + val versionString = project.version.toString() doLast { - val name = project.name.lowercase(Locale.getDefault()) - val file = project.file("src/main/resources/$name.properties") - file.parentFile?.mkdirs() - file.writeText("version=${project.version}") + propertiesFile.parentFile?.mkdirs() + propertiesFile.writeText("version=$versionString") } } -tasks.build { - dependsOn(tasks.shadowJar) -} - -tasks.shadowJar { - archiveClassifier.set("") - destinationDirectory.set(rootProject.extra["targetFolder"] as File) -} - tasks.processResources { dependsOn("generateVersionProperties") } -java { - withSourcesJar() - withJavadocJar() +publishing { + publications { + create("maven") { + from(components["java"]) + + groupId = project.group.toString() + artifactId = "structura-core" + version = project.version.toString() + + pom { + name.set("structura-core") + description.set("Type-safe YAML configuration library for Java 21+ (core)") + url.set("https://github.com/Traqueur-dev/Structura") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("traqueur") + name.set("Traqueur") + } + } + + scm { + connection.set("scm:git:git://github.com/Traqueur-dev/Structura.git") + developerConnection.set("scm:git:ssh://github.com/Traqueur-dev/Structura.git") + url.set("https://github.com/Traqueur-dev/Structura") + } + } + } + } } -publishConfig { - githubOwner = "Traqueur-dev" - useRootProjectName = true +// Aggregates the publish task of the root project and every publishable subproject +tasks.register("publishAll") { + description = "Publishes all modules (core, writers, bom) to the Maven repository" + group = "publishing" + + dependsOn(tasks.named("publish")) + subprojects.forEach { sub -> + sub.plugins.withId("maven-publish") { + dependsOn(sub.tasks.named("publish")) + } + } } diff --git a/example/build.gradle.kts b/example/build.gradle.kts index b1e920c..3125d6c 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -12,8 +12,7 @@ repositories { dependencies { implementation(project(":")) - implementation(project(":structura-writers")) - implementation("org.yaml:snakeyaml:2.4") + implementation(project(":writer")) } java { diff --git a/example/src/main/java/fr/traqueur/example/Main.java b/example/src/main/java/fr/traqueur/example/Main.java index 859b55d..5b419c7 100644 --- a/example/src/main/java/fr/traqueur/example/Main.java +++ b/example/src/main/java/fr/traqueur/example/Main.java @@ -7,7 +7,6 @@ import fr.traqueur.example.config.StorageConfig.StorageBackend; import fr.traqueur.structura.api.Structura; import fr.traqueur.structura.registries.PolymorphicRegistry; -import fr.traqueur.structura.writers.StructuraWriters; import java.nio.file.Files; import java.nio.file.Path; @@ -35,7 +34,7 @@ public static void main(String[] args) throws Exception { Path storageFile = CONFIG_DIR.resolve("storage.yml"); if (!Files.exists(appFile)) { - StructuraWriters.saveDefault(appFile, AppConfig.class); + Structura.saveDefault(appFile, AppConfig.class); System.out.println("Generated: " + appFile); } else { System.out.println("Already exists: " + appFile); @@ -44,7 +43,7 @@ public static void main(String[] args) throws Exception { // StorageConfig has a polymorphic field — must use write() with an explicit default if (!Files.exists(storageFile)) { StorageConfig defaultStorage = new StorageConfig(new LocalBackend("./data", 100)); - StructuraWriters.write(storageFile, defaultStorage); + Structura.write(storageFile, defaultStorage); System.out.println("Generated: " + storageFile); } else { System.out.println("Already exists: " + storageFile); @@ -71,10 +70,10 @@ public static void main(String[] args) throws Exception { System.out.println(); StorageBackend backend = storageConfig.backend(); - if (backend instanceof LocalBackend local) { + if (backend instanceof LocalBackend(String path, int maxFileSizeMb)) { System.out.println("storage type : local"); - System.out.println("path : " + local.path()); - System.out.println("max-file-size : " + local.maxFileSizeMb() + " MB"); + System.out.println("path : " + path); + System.out.println("max-file-size : " + maxFileSizeMb + " MB"); } else if (backend instanceof S3Backend s3) { System.out.println("storage type : s3"); System.out.println("bucket : " + s3.bucket()); @@ -83,7 +82,7 @@ public static void main(String[] args) throws Exception { } // ── 4. modify in memory + write() ──────────────────────────────────── - section("Step 3 — modify + StructuraWriters.write()"); + section("Step 3 — modify + Structura.write()"); AppConfig updated = new AppConfig( appConfig.appName(), @@ -92,7 +91,7 @@ public static void main(String[] args) throws Exception { appConfig.maxConnections(), "admin@example.com" // set optional field ); - StructuraWriters.write(appFile, updated); + Structura.write(appFile, updated); System.out.println("Saved updated config (debug=true, admin-email set)."); System.out.println("\nNew contents of app.yml:"); @@ -118,7 +117,7 @@ public static void main(String[] args) throws Exception { StorageConfig s3Config = new StorageConfig( new S3Backend("prod-bucket", "us-east-1", "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI", true) ); - StructuraWriters.write(storageFile, s3Config); + Structura.write(storageFile, s3Config); System.out.println("Switched to S3. New contents of storage.yml:"); System.out.println(Files.readString(storageFile).indent(2).stripTrailing()); diff --git a/gradle.properties b/gradle.properties index e04df79..e997a9a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=1.7.0 \ No newline at end of file +version=2.0.0 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f090a2944b7473328c07c9755baa3196..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch literal 43764 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 /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -133,22 +133,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,18 +200,28 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index ac1b06f..5eed7ee 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,34 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle.kts b/settings.gradle.kts index f8fc1fc..77263a7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,13 +2,10 @@ rootProject.name = "Structura" pluginManagement { repositories { - maven { - name = "groupezReleases" - url = uri("https://repo.groupez.dev/releases") - } gradlePluginPortal() } } -include("structura-writers") +include("writer") +include("bom") include("example") \ No newline at end of file diff --git a/src/main/resources/structura.properties b/src/main/resources/structura.properties index e04df79..e997a9a 100644 --- a/src/main/resources/structura.properties +++ b/src/main/resources/structura.properties @@ -1 +1 @@ -version=1.7.0 \ No newline at end of file +version=2.0.0 \ No newline at end of file diff --git a/structura-writers/build.gradle.kts b/structura-writers/build.gradle.kts deleted file mode 100644 index 5a67b77..0000000 --- a/structura-writers/build.gradle.kts +++ /dev/null @@ -1,45 +0,0 @@ -plugins { - id("java-library") -} - -group = "fr.traqueur" -version = rootProject.property("version") as String - -repositories { - mavenCentral() -} - -dependencies { - api(project(":")) - compileOnly("org.yaml:snakeyaml:2.4") - - testImplementation(project(":")) - testImplementation("org.yaml:snakeyaml:2.4") - testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") -} - -val targetJavaVersion = 21 -java { - val javaVersion = JavaVersion.toVersion(targetJavaVersion) - sourceCompatibility = javaVersion - targetCompatibility = javaVersion - if (JavaVersion.current() < javaVersion) { - toolchain.languageVersion.set(JavaLanguageVersion.of(targetJavaVersion)) - } -} - -tasks.test { - useJUnitPlatform() - jvmArgs = listOf("-XX:+EnableDynamicAgentLoading") - - reports { - html.required.set(true) - junitXml.required.set(true) - } - - testLogging { - showStandardStreams = true - events("passed", "skipped", "failed", "standardOut", "standardError") - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java deleted file mode 100644 index 4f7874b..0000000 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java +++ /dev/null @@ -1,59 +0,0 @@ -package fr.traqueur.structura.writers; - -import fr.traqueur.structura.api.Loadable; -import fr.traqueur.structura.writers.exceptions.StructuraWriterException; -import fr.traqueur.structura.writers.factory.DefaultInstanceFactory; -import fr.traqueur.structura.writers.serializer.LoadableSerializer; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.Objects; - -/** - * Main entry point for the structura-writers module. - * Provides static methods to write a {@link Loadable} instance to a YAML file, - * or to generate a default file from {@code @Default*} annotations. - */ -public final class StructuraWriters { - - private static final LoadableSerializer SERIALIZER = new LoadableSerializer(); - private static final DefaultInstanceFactory DEFAULT_FACTORY = new DefaultInstanceFactory(); - - private StructuraWriters() {} - - /** - * Serializes {@code config} to YAML and writes it to {@code file}. - * Creates the file if it does not exist, overwrites it otherwise. - * - * @throws StructuraWriterException if the write fails - */ - public static void write(Path file, Loadable config) { - Objects.requireNonNull(file, "file cannot be null"); - Objects.requireNonNull(config, "config cannot be null"); - - String yaml = SERIALIZER.toYaml(config); - try { - Path parent = file.getParent(); - if (parent != null) Files.createDirectories(parent); - Files.writeString(file, yaml, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - } catch (IOException e) { - throw new StructuraWriterException("Failed to write configuration to: " + file.toAbsolutePath(), e); - } - } - - /** - * Creates a default instance of {@code configClass} using {@code @Default*} annotations, - * then writes it to {@code file}. Does not check whether the file already exists. - * - * @throws StructuraWriterException if instance creation or the write fails - */ - public static void saveDefault(Path file, Class configClass) { - Objects.requireNonNull(file, "file cannot be null"); - Objects.requireNonNull(configClass, "configClass cannot be null"); - - T defaultInstance = DEFAULT_FACTORY.createDefault(configClass); - write(file, defaultInstance); - } -} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java deleted file mode 100644 index 73f6180..0000000 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java +++ /dev/null @@ -1,32 +0,0 @@ -package fr.traqueur.structura.writers; - -import fr.traqueur.structura.api.Loadable; -import fr.traqueur.structura.api.StructuraWriter; - -import java.nio.file.Path; - -/** - * SPI implementation of {@link StructuraWriter} provided by the {@code structura-writers} module. - * - *

    Registered via {@code META-INF/services/fr.traqueur.structura.api.StructuraWriter} - * so that {@link fr.traqueur.structura.api.Structura} discovers it automatically through - * {@link java.util.ServiceLoader} whenever this module is on the classpath.

    - * - *

    All actual logic is delegated to {@link StructuraWriters}, keeping this class - * as a thin bridge between the SPI contract and the existing public API.

    - */ -public final class YamlStructuraWriter implements StructuraWriter { - - /** No-arg constructor required by {@link java.util.ServiceLoader}. */ - public YamlStructuraWriter() {} - - @Override - public void write(Path file, Loadable config) { - StructuraWriters.write(file, config); - } - - @Override - public void saveDefault(Path file, Class configClass) { - StructuraWriters.saveDefault(file, configClass); - } -} diff --git a/writer/build.gradle.kts b/writer/build.gradle.kts new file mode 100644 index 0000000..8b6c2c2 --- /dev/null +++ b/writer/build.gradle.kts @@ -0,0 +1,48 @@ +repositories { + mavenCentral() +} + +dependencies { + // Brings the core API and snakeyaml (declared as `api` in the core) transitively + api(project(":")) + + testImplementation(project(":")) +} + +publishing { + publications { + create("maven") { + from(components["java"]) + + groupId = project.group.toString() + artifactId = "structura-writer" + version = project.version.toString() + + pom { + name.set("structura-writer") + description.set("Reverse YAML serialization (writer) for Structura") + url.set("https://github.com/Traqueur-dev/Structura") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("traqueur") + name.set("Traqueur") + } + } + + scm { + connection.set("scm:git:git://github.com/Traqueur-dev/Structura.git") + developerConnection.set("scm:git:ssh://github.com/Traqueur-dev/Structura.git") + url.set("https://github.com/Traqueur-dev/Structura") + } + } + } + } +} diff --git a/writer/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java b/writer/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java new file mode 100644 index 0000000..616eaa1 --- /dev/null +++ b/writer/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java @@ -0,0 +1,57 @@ +package fr.traqueur.structura.writers; + +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.api.StructuraWriter; +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; +import fr.traqueur.structura.writers.factory.DefaultInstanceFactory; +import fr.traqueur.structura.writers.serializer.LoadableSerializer; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Objects; + +/** + * SPI implementation of {@link StructuraWriter} provided by the {@code structura-writers} module. + * + *

    Registered via {@code META-INF/services/fr.traqueur.structura.api.StructuraWriter} + * so that {@link fr.traqueur.structura.api.Structura} discovers it automatically through + * {@link java.util.ServiceLoader} whenever this module is on the classpath.

    + * + *

    This class is the implementation behind the public write API; it is not + * meant to be used directly. The only supported entry point is + * {@link fr.traqueur.structura.api.Structura#write} / {@link fr.traqueur.structura.api.Structura#saveDefault}.

    + */ +public final class YamlStructuraWriter implements StructuraWriter { + + private final LoadableSerializer serializer = new LoadableSerializer(); + private final DefaultInstanceFactory defaultFactory = new DefaultInstanceFactory(); + + /** No-arg constructor required by {@link java.util.ServiceLoader}. */ + public YamlStructuraWriter() {} + + @Override + public void write(Path file, Loadable config) { + Objects.requireNonNull(file, "file cannot be null"); + Objects.requireNonNull(config, "config cannot be null"); + + String yaml = serializer.toYaml(config); + try { + Path parent = file.getParent(); + if (parent != null) Files.createDirectories(parent); + Files.writeString(file, yaml, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException e) { + throw new StructuraWriterException("Failed to write configuration to: " + file.toAbsolutePath(), e); + } + } + + @Override + public void saveDefault(Path file, Class configClass) { + Objects.requireNonNull(file, "file cannot be null"); + Objects.requireNonNull(configClass, "configClass cannot be null"); + + T defaultInstance = defaultFactory.createDefault(configClass); + write(file, defaultInstance); + } +} \ No newline at end of file diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java b/writer/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java similarity index 100% rename from structura-writers/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java rename to writer/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java b/writer/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java similarity index 100% rename from structura-writers/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java rename to writer/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java b/writer/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java similarity index 100% rename from structura-writers/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java rename to writer/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/writer/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java similarity index 100% rename from structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java rename to writer/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/writer/Writer.java b/writer/src/main/java/fr/traqueur/structura/writers/writer/Writer.java similarity index 100% rename from structura-writers/src/main/java/fr/traqueur/structura/writers/writer/Writer.java rename to writer/src/main/java/fr/traqueur/structura/writers/writer/Writer.java diff --git a/structura-writers/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter b/writer/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter similarity index 100% rename from structura-writers/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter rename to writer/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java b/writer/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java similarity index 89% rename from structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java rename to writer/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java index 05dd5ef..365f654 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java +++ b/writer/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java @@ -38,7 +38,7 @@ void shouldWriteAndReadBack() throws Exception { Path file = tempDir.resolve("config.yml"); PlainConfig original = new PlainConfig("RoundTrip", 99); - StructuraWriters.write(file, original); + Structura.write(file, original); PlainConfig loaded = Structura.load(file, PlainConfig.class); assertEquals(original.name(), loaded.name()); @@ -48,7 +48,7 @@ void shouldWriteAndReadBack() throws Exception { @Test void shouldWriteKebabCaseKeys() throws Exception { Path file = tempDir.resolve("config.yml"); - StructuraWriters.write(file, new CamelCaseConfig("srv", 9090)); + Structura.write(file, new CamelCaseConfig("srv", 9090)); String content = Files.readString(file); assertTrue(content.contains("server-name:")); @@ -58,7 +58,7 @@ void shouldWriteKebabCaseKeys() throws Exception { @Test void shouldSerializeNestedRecord() throws Exception { Path file = tempDir.resolve("nested.yml"); - StructuraWriters.write(file, new NestedDefaultConfig("MyApp", new ServerBlock("db.local", 5432))); + Structura.write(file, new NestedDefaultConfig("MyApp", new ServerBlock("db.local", 5432))); String content = Files.readString(file); assertTrue(content.contains("app-name: MyApp")); @@ -73,7 +73,7 @@ void writeCreatesParentDirectoriesIfAbsent() throws Exception { Path file = tempDir.resolve("a/b/c/config.yml"); assertFalse(Files.exists(file.getParent()), "parent must not exist before write"); - StructuraWriters.write(file, new PlainConfig("deep", 1)); + Structura.write(file, new PlainConfig("deep", 1)); assertTrue(Files.exists(file), "file must be created including parent dirs"); } @@ -83,7 +83,7 @@ void writeCreatesParentDirectoriesIfAbsent() throws Exception { @Test void saveDefaultShouldGenerateFromAnnotations() throws Exception { Path file = tempDir.resolve("default.yml"); - StructuraWriters.saveDefault(file, SimpleDefaultConfig.class); + Structura.saveDefault(file, SimpleDefaultConfig.class); SimpleDefaultConfig loaded = Structura.load(file, SimpleDefaultConfig.class); assertEquals("Afelia", loaded.serverName()); @@ -94,7 +94,7 @@ void saveDefaultShouldGenerateFromAnnotations() throws Exception { @Test void saveDefaultOmitsOptionalFieldsWithNoDefault() throws Exception { Path file = tempDir.resolve("optional.yml"); - StructuraWriters.saveDefault(file, OptionalOnlyConfig.class); + Structura.saveDefault(file, OptionalOnlyConfig.class); String content = Files.readString(file); assertTrue(content.contains("required:")); @@ -106,7 +106,7 @@ void saveDefaultOmitsOptionalFieldsWithNoDefault() throws Exception { @Test void polymorphicStandardRoundTrip() throws Exception { Path file = tempDir.resolve("animal.yml"); - StructuraWriters.write(file, new AnimalConfig("Farm", new Dog("Buddy", "poodle"))); + Structura.write(file, new AnimalConfig("Farm", new Dog("Buddy", "poodle"))); AnimalConfig loaded = Structura.load(file, AnimalConfig.class); assertInstanceOf(Dog.class, loaded.pet()); @@ -117,7 +117,7 @@ void polymorphicStandardRoundTrip() throws Exception { @Test void polymorphicInlineRoundTrip() throws Exception { Path file = tempDir.resolve("db.yml"); - StructuraWriters.write(file, new InlineDbConfig("App", new MySQLEngine("db.local", 3306))); + Structura.write(file, new InlineDbConfig("App", new MySQLEngine("db.local", 3306))); InlineDbConfig loaded = Structura.load(file, InlineDbConfig.class); assertInstanceOf(MySQLEngine.class, loaded.db()); diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java b/writer/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java similarity index 100% rename from structura-writers/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java rename to writer/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/writer/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java similarity index 100% rename from structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java rename to writer/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/writer/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java similarity index 100% rename from structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java rename to writer/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java