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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions example/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
135 changes: 135 additions & 0 deletions example/src/main/java/fr/traqueur/example/Main.java
Original file line number Diff line number Diff line change
@@ -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())));
}
}
25 changes: 25 additions & 0 deletions example/src/main/java/fr/traqueur/example/config/AppConfig.java
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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 {}
}
5 changes: 4 additions & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ pluginManagement {
}
gradlePluginPortal()
}
}
}

include("structura-writers")
include("example")
68 changes: 68 additions & 0 deletions src/main/java/fr/traqueur/structura/api/Structura.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}.
Expand Down Expand Up @@ -269,4 +294,47 @@ private static <T extends Loadable> T loadInternal(Object source, Class<T> 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.
*
* <p>Requires the {@code structura-writers} module to be present on the classpath.
* Throws {@link StructuraException} if the module is absent.</p>
*
* @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}.
*
* <p>Does <em>not</em> check whether {@code file} already exists —
* that responsibility belongs to the caller:</p>
* <pre>
* if (!Files.exists(configFile)) {
* Structura.saveDefault(configFile, MyConfig.class);
* }
* </pre>
*
* <p>Requires the {@code structura-writers} module to be present on the classpath.
* Throws {@link StructuraException} if the module is absent.</p>
*
* @param file destination path
* @param configClass the record class to instantiate with default values
* @param <T> a record type implementing {@link Loadable}
* @throws StructuraException if the writers module is absent or the write fails
*/
public static <T extends Loadable> void saveDefault(Path file, Class<T> configClass) {
requireWriter().saveDefault(file, configClass);
}
}
38 changes: 38 additions & 0 deletions src/main/java/fr/traqueur/structura/api/StructuraWriter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package fr.traqueur.structura.api;

import java.nio.file.Path;

/**
* SPI contract for serializing {@link Loadable} records back to YAML.
*
* <p>Defined in the core module but <strong>not implemented here</strong>.
* The optional {@code structura-writers} module provides an implementation
* discovered at runtime via {@link java.util.ServiceLoader}.</p>
*
* <p>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}.</p>
*/
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}.
*
* <p>Does <em>not</em> check whether {@code file} already exists —
* that responsibility belongs to the caller.</p>
*
* @param file destination path
* @param configClass the record class to instantiate with default values
* @param <T> a record type implementing {@link Loadable}
*/
<T extends Loadable> void saveDefault(Path file, Class<T> configClass);
}
Loading
Loading