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 new file mode 100644 index 0000000..3125d6c --- /dev/null +++ b/example/build.gradle.kts @@ -0,0 +1,32 @@ +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(":writer")) +} + +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..5b419c7 --- /dev/null +++ b/example/src/main/java/fr/traqueur/example/Main.java @@ -0,0 +1,134 @@ +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 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)) { + Structura.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)); + Structura.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(String path, int maxFileSizeMb)) { + System.out.println("storage type : local"); + 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()); + System.out.println("region : " + s3.region()); + System.out.println("use-ssl : " + s3.useSsl()); + } + + // ── 4. modify in memory + write() ──────────────────────────────────── + section("Step 3 — modify + Structura.write()"); + + AppConfig updated = new AppConfig( + appConfig.appName(), + appConfig.version(), + true, // enable debug + appConfig.maxConnections(), + "admin@example.com" // set optional field + ); + Structura.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) + ); + Structura.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/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 249e583..1b33c55 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6826537..5dd3c01 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Tue Mar 25 10:59:06 CET 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c787..23d15a9 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +82,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# 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"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /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 08842a3..77263a7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,10 +2,10 @@ rootProject.name = "Structura" pluginManagement { repositories { - maven { - name = "groupezReleases" - url = uri("https://repo.groupez.dev/releases") - } gradlePluginPortal() } -} \ No newline at end of file +} + +include("writer") +include("bom") +include("example") \ No newline at end of file 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/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/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/writer/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java b/writer/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java new file mode 100644 index 0000000..800f078 --- /dev/null +++ b/writer/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/writer/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java b/writer/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java new file mode 100644 index 0000000..89a9ae0 --- /dev/null +++ b/writer/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/writer/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java b/writer/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java new file mode 100644 index 0000000..779ac7b --- /dev/null +++ b/writer/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/writer/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/writer/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java new file mode 100644 index 0000000..d73a053 --- /dev/null +++ b/writer/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -0,0 +1,430 @@ +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; +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. + * + *

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)} 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: + * discriminator and all concrete fields at parent level
  • + *
  • {@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} + * 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)); + } + + // ------------------------------------------------------------------------- + // 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()) { + throw new StructuraWriterException("Cannot serialize non-record type: " + clazz.getName()); + } + + RecordComponent[] components = clazz.getRecordComponents(); + 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)); + } + 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); + boolean isInline = opts != null && opts.inline(); + Class type = component.getType(); + String key = fieldMapper.getEffectiveFieldName(parameter, component.getName()); + + if (value == null) { + // Optional null fields are silently omitted; non-optional nulls are written explicitly + if (opts == null || !opts.optional()) { + result.put(key, null); + } + return; + } + + // ── @Options(inline = true) ────────────────────────────────────────── + if (isInline) { + if (type.isRecord() && Loadable.class.isAssignableFrom(type)) { + 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; + } + + // ── Polymorphic interface ──────────────────────────────────────────── + if (isPolymorphicInterface(type)) { + Polymorphic poly = type.getAnnotation(Polymorphic.class); + String discValue = lookupRegisteredName(value.getClass(), type); + + if (poly.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 + 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; + + if (value instanceof Reference ref) return ref.key(); + + Optional custom = CustomWriterRegistry.getInstance().write(value, value.getClass()); + if (custom.isPresent()) return custom.get(); + + if (genericType instanceof ParameterizedType paramType) { + 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.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; + } + + /** + * 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); + + // 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) { + resultMap.putAll(toMap(elem)); + } + return resultMap; + } + } + + // Standard polymorphic list → list with discriminator inside each entry + 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()); + } + + /** + * 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<>(); + + // 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; + } + + // 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; } + 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() + "'. " + + "Register it via PolymorphicRegistry.get(" + polymorphicInterface.getSimpleName() + + ".class).register(\"name\", " + concreteClass.getSimpleName() + ".class)" + ); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private boolean isPolymorphicInterface(Class type) { + return type.isInterface() + && Loadable.class.isAssignableFrom(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; + 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) { + 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/writer/src/main/java/fr/traqueur/structura/writers/writer/Writer.java b/writer/src/main/java/fr/traqueur/structura/writers/writer/Writer.java new file mode 100644 index 0000000..e80557d --- /dev/null +++ b/writer/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/writer/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter b/writer/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter new file mode 100644 index 0000000..84a0434 --- /dev/null +++ b/writer/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter @@ -0,0 +1 @@ +fr.traqueur.structura.writers.YamlStructuraWriter diff --git a/writer/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java b/writer/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java new file mode 100644 index 0000000..365f654 --- /dev/null +++ b/writer/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java @@ -0,0 +1,138 @@ +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.*; + +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"); + PlainConfig original = new PlainConfig("RoundTrip", 99); + + Structura.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"); + Structura.write(file, new CamelCaseConfig("srv", 9090)); + + String content = Files.readString(file); + assertTrue(content.contains("server-name:")); + assertTrue(content.contains("http-port:")); + } + + @Test + void shouldSerializeNestedRecord() throws Exception { + Path file = tempDir.resolve("nested.yml"); + Structura.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"); + + Structura.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"); + Structura.saveDefault(file, SimpleDefaultConfig.class); + + SimpleDefaultConfig loaded = Structura.load(file, SimpleDefaultConfig.class); + assertEquals("Afelia", loaded.serverName()); + assertEquals(25565, loaded.port()); + assertTrue(loaded.debug()); + } + + @Test + void saveDefaultOmitsOptionalFieldsWithNoDefault() throws Exception { + Path file = tempDir.resolve("optional.yml"); + Structura.saveDefault(file, OptionalOnlyConfig.class); + + String content = Files.readString(file); + 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"); + Structura.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"); + Structura.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/writer/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java b/writer/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java new file mode 100644 index 0000000..6deaf91 --- /dev/null +++ b/writer/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/writer/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/writer/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java new file mode 100644 index 0000000..b215050 --- /dev/null +++ b/writer/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -0,0 +1,292 @@ +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; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class WriterTestModels { + + private WriterTestModels() {} + + // ========================================================================= + // Basic models (from initial commit) + // ========================================================================= + + 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 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 {} + + // ========================================================================= + // @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 {} + + // ========================================================================= + // @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 {} + + // ========================================================================= + // @Options(isKey = true) simple — String/primitive key + // ========================================================================= + + /** 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 {} + + // ========================================================================= + // @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 + // ========================================================================= + + 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 {} + + // ========================================================================= + // @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/writer/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/writer/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java new file mode 100644 index 0000000..660e643 --- /dev/null +++ b/writer/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -0,0 +1,317 @@ +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.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +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); }); + tryCreate(ItemMeta.class, r -> { r.register("food", FoodMeta.class); r.register("potion", PotionMeta.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)); + 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"), Set.of(), 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); + } + + // ── @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 + 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"); + } + + // ── @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"); + } + + // ── @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 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( + "/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( + 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") + private static void tryCreate( + Class clazz, java.util.function.Consumer> cfg) { + try { + PolymorphicRegistry.get(clazz); + } catch (Exception ignored) { + PolymorphicRegistry.create(clazz, cfg); + } + } +}