diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e41420..120000b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,10 +39,40 @@ jobs: for k in totals: totals[k]+=int(r.get(k,'0')) except Exception: pass - exp_tests=1354 + exp_tests=1355 exp_skipped=0 if totals['tests']!=exp_tests or totals['skipped']!=exp_skipped: print(f"Unexpected test totals: {totals} != expected tests={exp_tests}, skipped={exp_skipped}") sys.exit(1) print(f"OK totals: {totals}") PY + + docker-build: + name: Build Docker image (Java 25) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: oracle + java-version: '25' + cache: 'maven' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: jdt2jar/Dockerfile + push: false + load: true + tags: jdt2jar:ci + + - name: Smoke test + run: | + docker run --rm jdt2jar:ci --help diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 380b704..08f63de 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -52,4 +52,35 @@ jobs: restore-keys: ${{ runner.os }}-m2-java25 - name: Test full project (Java 25) - run: mvn clean test \ No newline at end of file + run: mvn clean test + + docker-build: + name: Build Docker image (Java 25) + runs-on: ubuntu-latest + needs: test-java25 + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: 'oracle' + cache: 'maven' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: jdt2jar/Dockerfile + push: false + load: true + tags: jdt2jar:ci + + - name: Smoke test + run: | + docker run --rm jdt2jar:ci --help diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index f859049..e7d32b0 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -6,8 +6,9 @@ on: - 'release/[0-9]*.[0-9]*.[0-9]*' permissions: - contents: write # push tags, push commits + contents: write pull-requests: write + packages: write concurrency: group: release-${{ github.ref }} @@ -34,11 +35,26 @@ jobs: gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#release/}" >> "$GITHUB_OUTPUT" + - name: Create GitHub Release with notes uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} generate_release_notes: true + body: | + ## Docker Image + + Pre-built distroless container image available on GitHub Container Registry: + + ```bash + docker pull ghcr.io/${{ github.repository_owner }}/java.util.json.java21/jdt2jar:${{ steps.version.outputs.version }} + docker pull ghcr.io/${{ github.repository_owner }}/java.util.json.java21/jdt2jar:latest + ``` + + See [jdt2jar/README.md](https://github.com/${{ github.repository }}/blob/main/jdt2jar/README.md) for usage. - name: Build and Deploy to Central (release profile) env: @@ -76,3 +92,42 @@ jobs: --base main \ --head "${{ steps.prbranch.outputs.branch }}" \ || echo "PR already exists or nothing to compare" + + docker-publish: + name: Publish Docker image to GHCR + runs-on: ubuntu-latest + needs: release + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: oracle + java-version: '25' + cache: 'maven' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#release/}" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: jdt2jar/Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/java.util.json.java21/jdt2jar:${{ steps.version.outputs.version }} + ghcr.io/${{ github.repository_owner }}/java.util.json.java21/jdt2jar:latest diff --git a/README.md b/README.md index 90bc2c2..29b72b8 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ This repo is organized into the following modules: | Module | What it is | JDK | | --- | --- | --- | | `json-java21` | Core `java.util.json` backport (parser, immutable types, `Json` API) | 21+ | -| `json-java21-jtd` | JSON Type Definition (JTD) validator implementing RFC 8927, with stack-machine interpreter and optional bytecode codegen interface (`JtdValidator`) | 21+ | -| `json-java21-jtd-codegen` | Bytecode code generator for JTD schemas using JDK 24+ ClassFile API (JEP 484); generates Java 21-compatible `.class` files for ~9x faster validation | 24+ (auto-skipped on JDK 21) | +| `json-java21-jtd` | JTD (RFC 8927) stack-machine interpreter — ideal for infrequent config parsing and one-time validation | 21+ | +| `json-java21-jtd-codegen` | Bytecode code generator for JTD schemas — ahead-of-time compiled validators for repeated hot-path validation | 24+ (auto-skipped on JDK 21) | +| `jdt2jar` | CLI + distroless container to pre-compile JTD schemas into standalone validator JARs (eliminates JDK 24+ runtime requirement) | 24+ (auto-skipped on JDK 21) | | `json-java21-jsonpath` | JsonPath query engine over `jdk.sandbox.java.util.json` values (Goessner-style: filters, slices, recursive descent, unions) | 21+ | | `json-compatibility-suite` | JSON Test Suite conformance reporter (tests against [nst/JSONTestSuite](https://github.com/nst/JSONTestSuite)) | 21+ | | `json-java21-api-tracker` | Daily upstream API drift detector — fetches OpenJDK sandbox sources, compares public API signatures, reports differences | 25+ | @@ -335,9 +336,12 @@ Such vulnerabilities existed at one point in the upstream OpenJDK sandbox implem ## JSON Type Definition (JTD) Validator -This repo contains an incubating JTD validator that has the core JSON API as its only dependency. This sub-project demonstrates how to build realistic JSON heavy logic using the API. It follows Data Oriented Programming principles: it compiles JTD schemas into an immutable structure of records. For validation it parses the JSON document to the generic structure and uses the thread-safe parsed schema and a stack to visit and validate the parsed JSON. +This repo includes two JTD validation paths for different use cases: -A complete JSON Type Definition validator is included (module: json-java21-jtd). +- **Interpreter** ([`json-java21-jtd`](json-java21-jtd/README.md)) — stack-machine validator for infrequent config parsing and one-time validation. Runs on JDK 21+ with zero extra dependencies. +- **Bytecode codegen** ([`json-java21-jtd-codegen`](json-java21-jtd-codegen/README.md)) — generates dedicated validator classes for repeated hot-path validation (~9x faster). Requires JDK 24+ at build time; generated classes run on JDK 21+. + +> `java.util.json` has entered the JDK incubator (`jdk.incubator.json`). Once the API stabilises in the JDK itself, generated bytecode validators can depend directly on future JDK classes with zero library overhead. ### Empty Schema `{}` Semantics (RFC 8927) @@ -349,25 +353,12 @@ Per **RFC 8927 (JSON Typedef)**, the empty schema `{}` is the **empty form** and > `empty = {}` > **Empty form:** A schema in the empty form accepts all JSON values and produces no errors. -⚠️ Note: Some tools or in-house validators mistakenly interpret `{}` as "object with no -properties allowed." **That is not JTD.** This implementation follows RFC 8927 strictly. - ```java import json.java21.jtd.Jtd; import jdk.sandbox.java.util.json.*; -// Compile JTD schema -JsonValue schema = Json.parse(""" - { - "properties": { - "name": {"type": "string"}, - "age": {"type": "int32"} - } - } -"""); - -// Validate JSON -JsonValue data = Json.parse("{\"name\":\"Alice\",\"age\":30}"); +JsonValue schema = Json.parse("{\"properties\":{\"name\":{\"type\":\"string\"}}}"); +JsonValue data = Json.parse("{\"name\":\"Alice\"}"); Jtd validator = new Jtd(); Jtd.Result result = validator.validate(schema, data); // result.isValid() => true @@ -375,17 +366,6 @@ Jtd.Result result = validator.validate(schema, data); ### JTD RFC 8927 Compliance -The validator provides full RFC 8927 compliance with comprehensive test coverage: - -```bash -# Run all JTD compliance tests -./mvnw test -pl json-java21-jtd -Dtest=JtdSpecIT - -# Run with detailed logging -./mvnw test -pl json-java21-jtd -Djava.util.logging.ConsoleHandler.level=FINE -``` - -Features: - ✅ Eight mutually-exclusive schema forms (RFC 8927 §2.2) - ✅ Standardized error format with instance and schema paths - ✅ Primitive type validation with proper ranges @@ -394,6 +374,12 @@ Features: - ✅ Discriminator tag exemption from additional properties - ✅ Stack-based validation preventing StackOverflowError +## JTD to JAR Compiler (Optional) + +An optional `jdt2jar` CLI tool and distroless Docker image are available for pre-compiling JTD schemas into standalone validator JARs at build time. This eliminates the JDK 24+ runtime requirement for generated validators — the JARs run on JDK 21+. + +See [`jdt2jar/README.md`](jdt2jar/README.md) for build instructions, container usage, and the pre-built image on GitHub Container Registry (`ghcr.io`). + ## Building Requires JDK 21 or later. Build with Maven: diff --git a/jdt2jar/.dockerignore b/jdt2jar/.dockerignore new file mode 100644 index 0000000..10675a4 --- /dev/null +++ b/jdt2jar/.dockerignore @@ -0,0 +1,21 @@ +# Build outputs +**/target/ + +# IDE +.idea/ +*.iml +.vscode/ +.eclipse/ + +# OS +.DS_Store + +# Git +.git/ +.gitignore + +# Env +.env + +# Local test artifacts +/tmp/jdt2jar-*/ diff --git a/jdt2jar/Dockerfile b/jdt2jar/Dockerfile new file mode 100644 index 0000000..19433ff --- /dev/null +++ b/jdt2jar/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 + +FROM eclipse-temurin:24-jdk AS build +WORKDIR /build +COPY . . +RUN ["./mvnw", "-pl", "jdt2jar", "-am", "package", "-DskipTests", "-Dsurefire.failIfNoSpecifiedTests=false"] +RUN ["java", "-cp", "/build/jdt2jar/target/jdt2jar.jar", "json.java21.jdt2jar.build.DockerImageBuilder", "/build/jdt2jar/target/jdt2jar.jar", "/opt/jre"] +RUN ["mkdir", "-p", "/empty-work/tmp", "/empty-app"] + +FROM gcr.io/distroless/base-debian13:nonroot +COPY --from=build --chown=65532:65532 /empty-work /work +COPY --from=build --chown=65532:65532 /empty-app /app +ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Djava.io.tmpdir=/work/tmp -XX:+ExitOnOutOfMemoryError" +COPY --from=build /opt/jre /jre +COPY --from=build /build/jdt2jar/target/jdt2jar.jar /app/jdt2jar.jar +ENTRYPOINT ["/jre/bin/java","-jar","/app/jdt2jar.jar"] diff --git a/jdt2jar/README.md b/jdt2jar/README.md new file mode 100644 index 0000000..5768c7b --- /dev/null +++ b/jdt2jar/README.md @@ -0,0 +1,90 @@ +# jdt2jar + +`jdt2jar` compiles a JTD schema into a standalone validator JAR at build time. The generated JAR runs on JDK 21+ with no JDK 24+ runtime dependency. + +## Use Case + +This tool bridges the gap between the interpreter and codegen paths: + +- **Interpreter** ([`json-java21-jtd`](../json-java21-jtd/README.md)): ideal for infrequent config parsing — simple, no build step, runs on JDK 21+. +- **Codegen** ([`json-java21-jtd-codegen`](../json-java21-jtd-codegen/README.md)): ideal for repeated hot-path validation — ~9x faster, but requires JDK 24+ at runtime. +- **jdt2jar**: pre-compiles schemas into validator JARs at build time (using JDK 24+), then deploys them to any JDK 21+ runtime. Best for CI/CD pipelines, distroless containers, or environments where you want JIT-optimised validators without shipping a JDK 24+ runtime. + +> **Future note**: `java.util.json` has entered the JDK incubator (`jdk.incubator.json`). Once the API stabilises in the JDK itself, generated bytecode validators can depend directly on future JDK classes rather than this backport, making them even more efficient with zero library overhead. + +## CLI + +```bash +jdt2jar [options] +``` + +Options: + +- `--output `: output JAR path (default: `-validator.jar`) +- `--package `: generated package name (default: `jtd.generated`) +- `--class `: validator class name (default: `SchemaValidator`) +- `--main`: include a standalone `java -jar` entry point +- `--runtime `: target bytecode version (default: 21) +- `--include-sources`: write a companion `.java` file next to the JAR +- `--help`: show usage + +## Container Image + +A minimal distroless container image is available for offline schema compilation without a full JDK. + +### Pre-built Image (GitHub Container Registry) + +```bash +# Pull the latest image +docker pull ghcr.io/simbo1905/java.util.json.java21/jdt2jar:latest + +# Pull a specific release version +docker pull ghcr.io/simbo1905/java.util.json.java21/jdt2jar:2026.02.05 +``` + +### Build Locally + +Requires Docker and JDK 24+ (for the build stage). Build from the repository root: + +```bash +docker build -t jdt2jar -f jdt2jar/Dockerfile . +``` + +### Usage + +```bash +# Show help +docker run --rm ghcr.io/simbo1905/java.util.json.java21/jdt2jar:latest --help + +# Compile a schema to a validator JAR (using docker cp for file I/O) +cid=$(docker create --name jdt2jar-build ghcr.io/simbo1905/java.util.json.java21/jdt2jar:latest /work/person.jtd.json --output /work/person-validator.jar --main) +docker cp person.jtd.json jdt2jar-build:/work/person.jtd.json +docker start -a jdt2jar-build +docker cp jdt2jar-build:/work/person-validator.jar . +docker rm jdt2jar-build + +# Validate a payload with the generated JAR +java -jar person-validator.jar --validate payload.json +# Or validate inside a container +cid=$(docker create --name jdt2jar-validate --entrypoint /jre/bin/java ghcr.io/simbo1905/java.util.json.java21/jdt2jar:latest -jar /work/person-validator.jar --validate /work/payload.json) +docker cp person-validator.jar jdt2jar-validate:/work/person-validator.jar +docker cp payload.json jdt2jar-validate:/work/payload.json +docker start -a jdt2jar-validate +docker rm jdt2jar-validate +``` + +### Image Properties + +- **Base**: `gcr.io/distroless/base-debian13:nonroot` +- **Runtime**: jlink-minimized JDK 24 (~40 MB) +- **Total size**: ~111 MB disk / ~31 MB content +- **User**: `nonroot` (uid 65532) +- **Shell**: none (distroless) +- **Writable directories**: `/work` (for schema input and JAR output) + +### Security Scanning + +```bash +syft packages image:ghcr.io/simbo1905/java.util.json.java21/jdt2jar:latest +grype ghcr.io/simbo1905/java.util.json.java21/jdt2jar:latest +``` diff --git a/jdt2jar/pom.xml b/jdt2jar/pom.xml new file mode 100644 index 0000000..3b748fa --- /dev/null +++ b/jdt2jar/pom.xml @@ -0,0 +1,109 @@ + + + 4.0.0 + + + io.github.simbo1905.json + parent + 0.1.9 + + + jdt2jar + jar + jdt2jar CLI + Offline JTD-to-JAR compiler that packages generated validators into standalone JAR files. + + + UTF-8 + 24 + jdt2jar + + + + + io.github.simbo1905.json + java.util.json + ${project.version} + + + io.github.simbo1905.json + java.util.json.jtd + ${project.version} + + + io.github.simbo1905.json + java.util.json.jtd.codegen + ${project.version} + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 24 + true + true + + -Xlint:all + -Werror + -Xdiags:verbose + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + false + jdt2jar + + + json.java21.jdt2jar.Jdt2Jar + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 24 + none + + + + + diff --git a/jdt2jar/src/main/java/json/java21/jdt2jar/Jdt2Jar.java b/jdt2jar/src/main/java/json/java21/jdt2jar/Jdt2Jar.java new file mode 100644 index 0000000..364f8d8 --- /dev/null +++ b/jdt2jar/src/main/java/json/java21/jdt2jar/Jdt2Jar.java @@ -0,0 +1,334 @@ +package json.java21.jdt2jar; + +import jdk.sandbox.java.util.json.Json; +import json.java21.jtd.codegen.JtdCodegen; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.Properties; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.logging.Logger; + +/// Offline JTD-to-JAR compiler. +/// +/// Builds a standalone validator JAR from a schema file and can optionally +/// include a `java -jar` entry point and a companion source file. +public final class Jdt2Jar { + + static final Logger LOG = Logger.getLogger(Jdt2Jar.class.getName()); + + private static final int DEFAULT_RUNTIME = 21; + private static final String DEFAULT_PACKAGE = "jtd.generated"; + private static final String DEFAULT_CLASS = "SchemaValidator"; + private static final String PROPERTIES_ENTRY = "jdt2jar.properties"; + private static final String SCHEMA_ENTRY = "jtd/schema.json"; + private static final String MAIN_CLASS = "json.java21.jdt2jar.runtime.ValidatorMain"; + + private Jdt2Jar() {} + + public static void main(String[] args) { + System.exit(run(args)); + } + + public static int run(String[] args) { + try { + return new Jdt2Jar().execute(args); + } catch (UsageException e) { + System.err.println(e.getMessage()); + System.err.println(); + printUsage(); + return 2; + } catch (Exception e) { + System.err.println("ERROR: " + e.getMessage()); + return 1; + } + } + + int execute(String[] args) throws IOException { + final var options = parseOptions(args); + if (options.help()) { + printUsage(); + return 0; + } + + final var schemaJson = Files.readString(options.schemaPath()); + final var schema = Json.parse(schemaJson); + final var generatedBytes = JtdCodegen.compileBytes(schema, options.packageName(), options.className()); + + if (options.runtime() != DEFAULT_RUNTIME) { + throw new UsageException("Unsupported runtime version: " + options.runtime() + + " (only 21 is currently emitted)"); + } + + final var output = options.output() != null ? options.output() : defaultOutput(options.schemaPath()); + writeValidatorJar(output, options, schemaJson, generatedBytes); + + if (options.includeSources()) { + writeSourceFile(sourcePathFor(output), options); + } + + LOG.info(() -> "Wrote validator jar: " + output.toAbsolutePath()); + return 0; + } + + private static void writeValidatorJar(Path output, Options options, String schemaJson, byte[] validatorBytes) + throws IOException { + final var manifest = new Manifest(); + final var attrs = manifest.getMainAttributes(); + attrs.putValue("Manifest-Version", "1.0"); + attrs.putValue("Created-By", "jdt2jar"); + if (options.main()) { + attrs.putValue("Main-Class", MAIN_CLASS); + } + + createParentDirectories(output); + + final var written = new HashSet(); + try (final var jar = new JarOutputStream(Files.newOutputStream(output), manifest)) { + copyRuntimeEntries(jar, written); + writeEntry(jar, written, toInternalName(options.packageName(), options.className()) + ".class", validatorBytes); + writeEntry(jar, written, SCHEMA_ENTRY, schemaJson.getBytes(StandardCharsets.UTF_8)); + writeEntry(jar, written, PROPERTIES_ENTRY, propertiesBytes(options)); + } + } + + private static void copyRuntimeEntries(JarOutputStream out, Set written) throws IOException { + final var classPath = System.getProperty("java.class.path", ""); + if (classPath.isBlank()) { + return; + } + + for (final var entry : classPath.split(java.io.File.pathSeparator)) { + if (entry.isBlank()) { + continue; + } + final var path = Path.of(entry); + if (Files.isDirectory(path)) { + copyDirectoryEntries(out, written, path); + } else if (entry.endsWith(".jar")) { + copyJarEntries(out, written, path); + } + } + } + + private static void copyDirectoryEntries(JarOutputStream out, Set written, Path root) throws IOException { + try (final var stream = Files.walk(root)) { + stream.filter(Files::isRegularFile) + .forEach(path -> { + final var rel = root.relativize(path).toString().replace('\\', '/'); + if (!shouldCopyRuntime(rel)) { + return; + } + try { + writeEntry(out, written, rel, Files.readAllBytes(path)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + final var cause = e.getCause(); + throw cause instanceof IOException io ? io : new IOException(cause); + } + } + + private static void copyJarEntries(JarOutputStream out, Set written, Path jarPath) throws IOException { + try (final var jar = new JarFile(jarPath.toFile())) { + final var entries = jar.entries(); + while (entries.hasMoreElements()) { + final var entry = entries.nextElement(); + final var name = entry.getName(); + if (!shouldCopyRuntime(name) || entry.isDirectory()) { + continue; + } + try (final var in = jar.getInputStream(entry)) { + writeEntry(out, written, name, in.readAllBytes()); + } + } + } + } + + private static boolean shouldCopyRuntime(String path) { + return (path.startsWith("jdk/sandbox/java/util/json/") + || path.startsWith("jdk/sandbox/internal/util/json/") + || path.startsWith("json/java21/jtd/")) + && !path.startsWith("json/java21/jtd/codegen/") + || path.startsWith("json/java21/jtd/codegen/JtdValidator.class") + || path.startsWith("json/java21/jdt2jar/runtime/"); + } + + private static void writeEntry(JarOutputStream out, Set written, String name, byte[] bytes) + throws IOException { + if (!written.add(name)) { + return; + } + final var entry = new JarEntry(name); + entry.setTime(0L); + out.putNextEntry(entry); + out.write(bytes); + out.closeEntry(); + } + + private static byte[] propertiesBytes(Options options) { + final var props = new Properties(); + props.setProperty("validatorClass", toQualifiedName(options.packageName(), options.className())); + props.setProperty("schemaEntry", SCHEMA_ENTRY); + props.setProperty("runtime", Integer.toString(options.runtime())); + try (final var out = new ByteArrayOutputStream()) { + props.store(out, "jdt2jar"); + return out.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static void createParentDirectories(Path output) throws IOException { + final var parent = output.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + } + + private static void writeSourceFile(Path sourcePath, Options options) throws IOException { + final var source = """ + package %s; + + import jdk.sandbox.java.util.json.JsonValue; + import json.java21.jtd.JtdValidationResult; + import json.java21.jdt2jar.runtime.ValidatorMain; + + public final class %s implements json.java21.jtd.JtdValidator { + private final String schemaJson; + + public %s(String schemaJson) { + this.schemaJson = schemaJson; + } + + @Override + public JtdValidationResult validate(JsonValue instance) { + return ValidatorMain.validate(schemaJson, instance); + } + + @Override + public String toString() { + return schemaJson; + } + } + """.formatted(options.packageName(), options.className(), options.className()); + Files.writeString(sourcePath, source, StandardCharsets.UTF_8); + } + + private static Path sourcePathFor(Path output) { + final var name = output.getFileName().toString(); + final var sourceName = name.endsWith(".jar") ? name.substring(0, name.length() - 4) + ".java" : name + ".java"; + final var parent = output.getParent(); + return parent == null ? Path.of(sourceName) : parent.resolve(sourceName); + } + + private static Path defaultOutput(Path schemaPath) { + final var fileName = schemaPath.getFileName().toString(); + final var base = fileName.endsWith(".jtd.json") + ? fileName.substring(0, fileName.length() - ".jtd.json".length()) + : fileName.endsWith(".json") + ? fileName.substring(0, fileName.length() - ".json".length()) + : fileName; + final var parent = schemaPath.getParent(); + final var output = base + "-validator.jar"; + return parent == null ? Path.of(output) : parent.resolve(output); + } + + private static Options parseOptions(String[] args) { + var schema = (Path) null; + var output = (Path) null; + var packageName = DEFAULT_PACKAGE; + var className = DEFAULT_CLASS; + var main = false; + var runtime = DEFAULT_RUNTIME; + var includeSources = false; + var help = false; + + final var remaining = new ArrayDeque<>(java.util.List.of(args)); + while (!remaining.isEmpty()) { + final var arg = remaining.removeFirst(); + switch (arg) { + case "--help" -> help = true; + case "--main" -> main = true; + case "--include-sources" -> includeSources = true; + case "--output" -> output = Path.of(requireValue(remaining, "--output")); + case "--package" -> packageName = requireValue(remaining, "--package"); + case "--class" -> className = requireValue(remaining, "--class"); + case "--runtime" -> runtime = Integer.parseInt(requireValue(remaining, "--runtime")); + default -> { + if (arg.startsWith("--")) { + throw new UsageException("Unknown option: " + arg); + } + if (schema != null) { + throw new UsageException("Multiple schema paths provided: " + schema + " and " + arg); + } + schema = Path.of(arg); + } + } + } + + if (help) { + return new Options(null, null, packageName, className, main, runtime, includeSources, true); + } + if (schema == null) { + throw new UsageException("Missing schema path"); + } + if (!Files.exists(schema)) { + throw new UsageException("Schema file not found: " + schema.toAbsolutePath()); + } + return new Options(schema, output, packageName, className, main, runtime, includeSources, false); + } + + private static String requireValue(ArrayDeque args, String option) { + if (args.isEmpty()) { + throw new UsageException("Missing value for " + option); + } + return args.removeFirst(); + } + + private static String toInternalName(String packageName, String className) { + return packageName.replace('.', '/') + "/" + className; + } + + private static String toQualifiedName(String packageName, String className) { + return packageName + "." + className; + } + + private static void printUsage() { + System.out.println(""" + jdt2jar [options] + + Options: + --output Output JAR path (default: -validator.jar) + --package Java package for generated classes (default: jtd.generated) + --class Validator class name (default: SchemaValidator) + --main Include a main() for standalone CLI validation + --runtime Target bytecode version (default: 21) + --include-sources Also output generated .java files alongside the JAR + --help Show help + """); + } + + record Options(Path schemaPath, Path output, String packageName, String className, + boolean main, int runtime, boolean includeSources, boolean help) {} + + static final class UsageException extends RuntimeException { + private static final long serialVersionUID = 1L; + + UsageException(String message) { + super(message); + } + } +} diff --git a/jdt2jar/src/main/java/json/java21/jdt2jar/build/DockerImageBuilder.java b/jdt2jar/src/main/java/json/java21/jdt2jar/build/DockerImageBuilder.java new file mode 100644 index 0000000..311cca7 --- /dev/null +++ b/jdt2jar/src/main/java/json/java21/jdt2jar/build/DockerImageBuilder.java @@ -0,0 +1,74 @@ +package json.java21.jdt2jar.build; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/// Builds the minimal jlink runtime used in the container image. +/// +/// The Dockerfile invokes this helper so the container build stays in exec form. +public final class DockerImageBuilder { + + private DockerImageBuilder() {} + + public static void main(String[] args) throws Exception { + if (args.length != 2) { + throw new IllegalArgumentException("Usage: DockerImageBuilder "); + } + final var jar = Path.of(args[0]); + final var jreOutput = Path.of(args[1]); + final var modules = jdepsModules(jar); + jlink(modules, jreOutput); + } + + private static String jdepsModules(Path jar) throws IOException, InterruptedException { + final var command = List.of( + Path.of(System.getProperty("java.home"), "bin", "jdeps").toString(), + "--ignore-missing-deps", + "--recursive", + "--multi-release", + "21", + "--print-module-deps", + jar.toString()); + final var result = run(command); + if (result.exitCode() != 0) { + throw new IllegalStateException("jdeps failed:\n" + result.output()); + } + return result.output().trim(); + } + + private static void jlink(String modules, Path jreOutput) throws IOException, InterruptedException { + final var parent = jreOutput.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + final var command = new ArrayList(); + command.add(Path.of(System.getProperty("java.home"), "bin", "jlink").toString()); + command.add("--add-modules"); + command.add(modules); + command.add("--strip-debug"); + command.add("--compress=2"); + command.add("--no-header-files"); + command.add("--no-man-pages"); + command.add("--output"); + command.add(jreOutput.toString()); + final var result = run(command); + if (result.exitCode() != 0) { + throw new IllegalStateException("jlink failed:\n" + result.output()); + } + } + + private static Result run(List command) throws IOException, InterruptedException { + final var process = new ProcessBuilder(command) + .redirectErrorStream(true) + .start(); + final var output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + final var exitCode = process.waitFor(); + return new Result(exitCode, output); + } + + private record Result(int exitCode, String output) {} +} diff --git a/jdt2jar/src/main/java/json/java21/jdt2jar/runtime/ValidatorMain.java b/jdt2jar/src/main/java/json/java21/jdt2jar/runtime/ValidatorMain.java new file mode 100644 index 0000000..fe9db37 --- /dev/null +++ b/jdt2jar/src/main/java/json/java21/jdt2jar/runtime/ValidatorMain.java @@ -0,0 +1,177 @@ +package json.java21.jdt2jar.runtime; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonParseException; +import jdk.sandbox.java.util.json.JsonString; +import jdk.sandbox.java.util.json.JsonValue; +import json.java21.jtd.JtdValidationResult; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.Properties; + +/// Runtime launcher for compiled validator JARs. +/// +/// The CLI jar copies this class into the generated output when `--main` is +/// requested and points the manifest `Main-Class` at it. +public final class ValidatorMain { + private static final String CONFIG_RESOURCE = "jdt2jar.properties"; + + private ValidatorMain() {} + + public static void main(String[] args) throws Exception { + System.exit(run(args)); + } + + public static int run(String[] args) { + try { + final var config = loadConfig(); + final var validator = instantiate(config.validatorClass(), readResourceString(config.schemaEntry())); + return run(validator, args); + } catch (IllegalArgumentException e) { + System.err.println("ERROR: " + e.getMessage()); + return 2; + } catch (IOException e) { + System.err.println("ERROR: " + e.getMessage()); + return 2; + } catch (RuntimeException e) { + System.err.println("ERROR: " + e.getMessage()); + return 1; + } + } + + public static int run(json.java21.jtd.codegen.JtdValidator validator, String[] args) throws IOException { + final CliOptions options; + try { + options = parseArgs(args); + } catch (IllegalArgumentException e) { + System.err.println("ERROR: " + e.getMessage()); + printUsage(); + return 2; + } + if (options.help()) { + printUsage(); + return 0; + } + if (options.input() == null) { + printUsage(); + return 2; + } + + final JsonValue instance; + try { + instance = Json.parse(Files.readString(options.input(), StandardCharsets.UTF_8)); + } catch (JsonParseException e) { + System.err.println("ERROR: Failed to parse payload: " + e.getMessage()); + return 2; + } + + final var result = validator.validate(instance); + if (options.json()) { + System.out.println(toJson(result).toString()); + } else if (result.isValid()) { + System.out.println("valid"); + } else { + for (final var error : result.errors()) { + System.out.println(error.instancePath() + ": " + error.schemaPath()); + } + } + return result.isValid() ? 0 : 1; + } + + public static JtdValidationResult validate(String schemaJson, JsonValue instance) { + return json.java21.jtd.JtdValidator.compileInterpreter(Json.parse(schemaJson)).validate(instance); + } + + private static RuntimeConfig loadConfig() throws IOException { + final var props = new Properties(); + try (final var in = ValidatorMain.class.getClassLoader().getResourceAsStream(CONFIG_RESOURCE)) { + if (in != null) { + props.load(in); + } + } + return new RuntimeConfig( + props.getProperty("validatorClass", "jtd.generated.SchemaValidator"), + props.getProperty("schemaEntry", "jtd/schema.json")); + } + + private static json.java21.jtd.codegen.JtdValidator instantiate(String validatorClassName, String schemaJson) { + try { + final var clazz = Class.forName(validatorClassName); + final var ctor = clazz.getConstructor(String.class); + return (json.java21.jtd.codegen.JtdValidator) ctor.newInstance(schemaJson); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Could not instantiate validator class: " + validatorClassName, e); + } + } + + private static String readResourceString(String name) throws IOException { + try (final var in = ValidatorMain.class.getClassLoader().getResourceAsStream(name)) { + if (in == null) { + throw new IOException("Missing resource: " + name); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static JsonObject toJson(JtdValidationResult result) { + return JsonObject.of(java.util.Map.of( + "valid", jdk.sandbox.java.util.json.JsonBoolean.of(result.isValid()), + "errors", jdk.sandbox.java.util.json.JsonArray.of(result.errors().stream() + .map(error -> JsonObject.of(java.util.Map.of( + "instancePath", JsonString.of(error.instancePath()), + "schemaPath", JsonString.of(error.schemaPath())))) + .toList()))); + } + + private static CliOptions parseArgs(String[] args) { + Path input = null; + boolean json = false; + boolean help = false; + + final var remaining = new ArrayDeque<>(java.util.List.of(args)); + while (!remaining.isEmpty()) { + final var arg = remaining.removeFirst(); + switch (arg) { + case "--help" -> help = true; + case "--validate" -> input = Path.of(requireValue(remaining, "--validate")); + case "--format" -> { + final var value = requireValue(remaining, "--format"); + if ("json".equalsIgnoreCase(value)) { + json = true; + } else { + throw new IllegalArgumentException("Unsupported format: " + value); + } + } + default -> throw new IllegalArgumentException("Unknown option: " + arg); + } + } + + return new CliOptions(input, json, help); + } + + private static String requireValue(ArrayDeque args, String option) { + if (args.isEmpty()) { + throw new IllegalArgumentException("Missing value for " + option); + } + return args.removeFirst(); + } + + private static void printUsage() { + System.out.println(""" + usage: java -jar validator.jar --validate [--format json] + + Options: + --validate Validate the JSON payload at the given path + --format json Emit RFC 8927 error pairs as JSON + --help Show help + """); + } + + private record CliOptions(Path input, boolean json, boolean help) {} + private record RuntimeConfig(String validatorClass, String schemaEntry) {} +} diff --git a/jdt2jar/src/test/java/json/java21/jdt2jar/Jdt2JarCliTest.java b/jdt2jar/src/test/java/json/java21/jdt2jar/Jdt2JarCliTest.java new file mode 100644 index 0000000..75ab5dc --- /dev/null +++ b/jdt2jar/src/test/java/json/java21/jdt2jar/Jdt2JarCliTest.java @@ -0,0 +1,97 @@ +package json.java21.jdt2jar; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarFile; + +import static org.assertj.core.api.Assertions.assertThat; + +class Jdt2JarCliTest extends Jdt2JarTestBase { + + @TempDir + Path tempDir; + + @Test + void compilesStandaloneValidatorJar() throws Exception { + final var schema = tempDir.resolve("user.jtd.json"); + Files.writeString(schema, """ + {"properties":{"name":{"type":"string"}}} + """, StandardCharsets.UTF_8); + final var output = tempDir.resolve("user-validator.jar"); + final var payload = tempDir.resolve("payload.json"); + Files.writeString(payload, """ + {"name":"Alice"} + """, StandardCharsets.UTF_8); + + assertThat(Jdt2Jar.run(new String[] {schema.toString(), "--output", output.toString(), "--main"})).isZero(); + + assertThat(output).exists(); + try (final var jar = new JarFile(output.toFile())) { + assertThat(jar.getEntry("jtd/generated/SchemaValidator.class")).isNotNull(); + assertThat(jar.getEntry("jtd/schema.json")).isNotNull(); + assertThat(jar.getEntry("jdt2jar.properties")).isNotNull(); + assertThat(jar.getEntry("json/java21/jdt2jar/runtime/ValidatorMain.class")).isNotNull(); + assertThat(jar.getManifest().getMainAttributes().getValue("Main-Class")) + .isEqualTo("json.java21.jdt2jar.runtime.ValidatorMain"); + } + + final var validResult = runJavaJar(output, "--validate", payload.toString()); + assertThat(validResult.exitCode()).isZero(); + assertThat(validResult.output()).contains("valid"); + + Files.writeString(payload, """ + {"name":1} + """, StandardCharsets.UTF_8); + final var invalidResult = runJavaJar(output, "--validate", payload.toString(), "--format", "json"); + assertThat(invalidResult.exitCode()).isEqualTo(1); + assertThat(invalidResult.output()).contains("\"instancePath\""); + assertThat(invalidResult.output()).contains("\"schemaPath\""); + } + + @Test + void writesCompanionSourceWhenRequested() throws Exception { + final var schema = tempDir.resolve("widget.jtd.json"); + Files.writeString(schema, """ + {"type":"string"} + """, StandardCharsets.UTF_8); + final var output = tempDir.resolve("widget-validator.jar"); + + assertThat(Jdt2Jar.run(new String[] { + schema.toString(), + "--output", output.toString(), + "--package", "demo.validator", + "--class", "WidgetValidator", + "--include-sources" + })).isZero(); + + final var source = tempDir.resolve("widget-validator.java"); + assertThat(source).exists(); + assertThat(Files.readString(source, StandardCharsets.UTF_8)) + .contains("package demo.validator;") + .contains("class WidgetValidator") + .contains("ValidatorMain.validate"); + } + + private static RunResult runJavaJar(Path jar, String... args) throws IOException, InterruptedException { + final var javaBin = Path.of(System.getProperty("java.home"), "bin", "java"); + final var builder = new ProcessBuilder(); + final var command = new java.util.ArrayList(); + command.add(javaBin.toString()); + command.add("-jar"); + command.add(jar.toString()); + command.addAll(java.util.List.of(args)); + builder.command(command); + builder.redirectErrorStream(true); + final var process = builder.start(); + final var output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + final var exitCode = process.waitFor(); + return new RunResult(exitCode, output); + } + + private record RunResult(int exitCode, String output) {} +} diff --git a/jdt2jar/src/test/java/json/java21/jdt2jar/Jdt2JarTestBase.java b/jdt2jar/src/test/java/json/java21/jdt2jar/Jdt2JarTestBase.java new file mode 100644 index 0000000..30cd414 --- /dev/null +++ b/jdt2jar/src/test/java/json/java21/jdt2jar/Jdt2JarTestBase.java @@ -0,0 +1,51 @@ +package json.java21.jdt2jar; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; + +import java.util.Locale; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; + +/// Shared JUL bootstrap for jdt2jar tests. +public class Jdt2JarTestBase { + + static final Logger LOG = Logger.getLogger("json.java21.jdt2jar"); + + @BeforeAll + static void configureJul() { + final var root = Logger.getLogger(""); + final var levelProp = System.getProperty("java.util.logging.ConsoleHandler.level"); + var targetLevel = Level.INFO; + if (levelProp != null) { + try { + targetLevel = Level.parse(levelProp.trim()); + } catch (IllegalArgumentException ex) { + try { + targetLevel = Level.parse(levelProp.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + System.err.println("Unrecognized logging level from 'java.util.logging.ConsoleHandler.level': " + levelProp); + } + } + } + if (root.getLevel() == null || root.getLevel().intValue() > targetLevel.intValue()) { + root.setLevel(targetLevel); + } + for (final var handler : root.getHandlers()) { + final var handlerLevel = handler.getLevel(); + if (handlerLevel == null || handlerLevel.intValue() > targetLevel.intValue()) { + handler.setLevel(targetLevel); + } + } + } + + @BeforeEach + void announce(TestInfo testInfo) { + final var cls = testInfo.getTestClass().map(Class::getSimpleName).orElse("UnknownTest"); + final var name = testInfo.getTestMethod().map(java.lang.reflect.Method::getName) + .orElseGet(testInfo::getDisplayName); + LOG.info(() -> "TEST: " + cls + "#" + name); + } +} diff --git a/json-java21-jtd-codegen/README.md b/json-java21-jtd-codegen/README.md index 4260aa3..e85c5a2 100644 --- a/json-java21-jtd-codegen/README.md +++ b/json-java21-jtd-codegen/README.md @@ -2,6 +2,14 @@ JTD schema-to-bytecode compiler using the JDK 24+ ClassFile API (JEP 484). Generates Java 21-compatible `.class` files at runtime for ~9x faster validation on hot paths. +## Use Case + +This codegen module is optimised for **repeated hot-path validation** — event processing pipelines, API gateways, message brokers, or any scenario where the same schema validates thousands or millions of documents. The generated validator classes contain only the checks the schema requires (no interpreter, no AST, no runtime stack), and after sufficient invocations the JIT compiler inlines and optimises them to near-native speed. + +For **infrequent validation** (config loading, startup checks, one-off validation), the [interpreter-based validator](../json-java21-jtd/README.md) is simpler and avoids the codegen overhead entirely. + +> **Future note**: `java.util.json` has entered the JDK incubator (`jdk.incubator.json`). Once the API stabilises in the JDK itself, generated bytecode validators can depend directly on future JDK classes rather than this backport, making them even more efficient with zero library overhead. + ## Requirements - **Build**: JDK 24+ (uses `ClassFile` API, JEP 484) @@ -19,8 +27,7 @@ JTD schema-to-bytecode compiler using the JDK 24+ ClassFile API (JEP 484). Gener ```java import jdk.sandbox.java.util.json.*; -import json.java21.jtd.JtdValidator; -import json.java21.jtd.codegen.JtdCodegen; +import json.java21.jtd.codegen.JtdValidator; JsonValue schema = Json.parse(""" {"properties": {"name": {"type": "string"}, "age": {"type": "uint8"}}} @@ -28,12 +35,12 @@ JsonValue schema = Json.parse(""" JsonValue doc = Json.parse("{\"name\":\"Alice\",\"age\":30}"); // Codegen path (JDK 24+ only) -JtdValidator validator = JtdCodegen.compile(schema); +JtdValidator validator = JtdValidator.compileGenerated(schema); var result = validator.validate(doc); System.out.println(result.isValid()); // true -// Falls back to interpreter automatically via JtdValidator.compile() -JtdValidator interp = JtdValidator.compile(schema); +// Falls back to interpreter automatically via JtdValidator.compileInterpreter() +JtdValidator interp = json.java21.jtd.JtdValidator.compileInterpreter(schema); ``` ## Performance diff --git a/json-java21-jtd-codegen/pom.xml b/json-java21-jtd-codegen/pom.xml index 3185af8..9a67d57 100644 --- a/json-java21-jtd-codegen/pom.xml +++ b/json-java21-jtd-codegen/pom.xml @@ -73,8 +73,11 @@ 3.13.0 24 + true + true -Xlint:all + -Werror -Xdiags:verbose diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/Descriptors.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/Descriptors.java index 13d147a..fa1317f 100644 --- a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/Descriptors.java +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/Descriptors.java @@ -42,7 +42,7 @@ private Descriptors() {} // -- Validation result types -- static final ClassDesc CD_JtdValidationError = ClassDesc.of("json.java21.jtd.JtdValidationError"); static final ClassDesc CD_JtdValidationResult = ClassDesc.of("json.java21.jtd.JtdValidationResult"); - static final ClassDesc CD_JtdValidator = ClassDesc.of("json.java21.jtd.JtdValidator"); + static final ClassDesc CD_JtdValidator = ClassDesc.of("json.java21.jtd.codegen.JtdValidator"); // -- Common method type descriptors -- static final MethodTypeDesc MTD_String = MethodTypeDesc.of(CD_String); diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdCodegen.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdCodegen.java index 93ce5c0..4697ea0 100644 --- a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdCodegen.java +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdCodegen.java @@ -10,25 +10,28 @@ import java.util.logging.Logger; import jdk.sandbox.java.util.json.JsonValue; -import json.java21.jtd.*; +import json.java21.jtd.Jtd; /// Compiles a JTD schema into a bytecode-generated [JtdValidator]. /// /// The generated class targets Java 21 (class file version 65) and /// contains only the checks the schema requires. /// -/// Entry point for the `JtdValidator.compileGenerated()` reflection call. +/// Use via {@code JtdValidator.compileGenerated(schema)} in this package, +/// or call {@link #compile(JsonValue)} directly. public final class JtdCodegen { static final Logger LOG = Logger.getLogger(JtdCodegen.class.getName()); private static final AtomicLong COUNTER = new AtomicLong(); + private static final String DEFAULT_PACKAGE = "json.java21.jtd.codegen"; private JtdCodegen() {} /// Result of compilation including the validator and generated class statistics. public record CompileResult(JtdValidator validator, int classfileBytes) {} - /// Public factory invoked by [JtdValidator.compileGenerated] via reflection. + /// Compiles a JTD schema into a bytecode-generated validator. + /// Requires JDK 24+ (ClassFile API). Generated classes target Java 21 bytecode. public static JtdValidator compile(JsonValue schema) { return compileWithStats(schema).validator(); } @@ -36,16 +39,44 @@ public static JtdValidator compile(JsonValue schema) { /// Compiles the schema and returns both the validator and the generated /// classfile size in bytes. Useful for benchmarking and diagnostics. public static CompileResult compileWithStats(JsonValue schema) { - final var jtd = new Jtd(); - final var compiled = jtd.compileToSchema(schema); + final var className = "Generated_" + COUNTER.incrementAndGet(); + final var bytes = buildBytes(schema, DEFAULT_PACKAGE, className); + return instantiate(schema, bytes, DEFAULT_PACKAGE, className); + } + + /// Compiles the schema into a named validator class and returns the raw bytes. + /// + /// @param schema the JTD schema as a parsed [JsonValue] + /// @param packageName Java package for the generated validator + /// @param className Generated validator class name + /// @return the generated class bytes + public static byte[] compileBytes(JsonValue schema, String packageName, String className) { + return buildBytes(schema, packageName, className); + } + + private static CompileResult instantiate(JsonValue schema, byte[] bytes, String packageName, String className) { + final var internalName = packageName.replace('.', '/') + "/" + className; final var schemaJson = schema.toString(); + try { + final var lookup = MethodHandles.lookup(); + final var clazz = lookup.defineClass(bytes); + final var ctor = clazz.getConstructor(String.class); + final var validator = (JtdValidator) ctor.newInstance(schemaJson); + return new CompileResult(validator, bytes.length); + } catch (Exception e) { + throw new RuntimeException("Failed to load generated validator: " + internalName, e); + } + } - final var className = "json/java21/jtd/codegen/Generated_" + COUNTER.incrementAndGet(); - final var classDesc = ClassDesc.ofInternalName(className); + private static byte[] buildBytes(JsonValue schema, String packageName, String className) { + final var jtd = new Jtd(); + final var compiled = jtd.compileToSchema(schema); + final var internalName = packageName.replace('.', '/') + "/" + className; + final var classDesc = ClassDesc.ofInternalName(internalName); - LOG.fine(() -> "Generating validator class: " + className); + LOG.fine(() -> "Generating validator class: " + internalName); - final var bytes = ClassFile.of().build(classDesc, clb -> { + return ClassFile.of().build(classDesc, clb -> { clb.withVersion(ClassFile.JAVA_21_VERSION, 0); clb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_FINAL); clb.withSuperclass(Descriptors.CD_Object); @@ -59,15 +90,5 @@ public static CompileResult compileWithStats(JsonValue schema) { EmitScaffold.emitToString(clb, classDesc); EmitScaffold.emitValidateMethod(clb, classDesc, compiled); }); - - try { - final var lookup = MethodHandles.lookup(); - final var clazz = lookup.defineClass(bytes); - final var ctor = clazz.getConstructor(String.class); - final var validator = (JtdValidator) ctor.newInstance(schemaJson); - return new CompileResult(validator, bytes.length); - } catch (Exception e) { - throw new RuntimeException("Failed to load generated validator: " + className, e); - } } } diff --git a/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdValidator.java b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdValidator.java new file mode 100644 index 0000000..26c6137 --- /dev/null +++ b/json-java21-jtd-codegen/src/main/java/json/java21/jtd/codegen/JtdValidator.java @@ -0,0 +1,38 @@ +package json.java21.jtd.codegen; + +import jdk.sandbox.java.util.json.JsonValue; +import json.java21.jtd.JtdValidationResult; + +import java.util.Objects; + +/// Functional interface for validating a JSON instance against a JTD schema +/// using bytecode-generated validators. +/// +/// Requires JDK 24+ at build time (ClassFile API, JEP 484). +/// Generated bytecode targets Java 21 (classfile version 65) so it runs on any JDK 21+ runtime. +/// +/// For the interpreter-based validator (always available, JDK 21+), see +/// {@code json.java21.jtd.JtdValidator} in the {@code json-java21-jtd} module. +/// +/// Obtain an instance via: +/// - [#compileGenerated(JsonValue)] -- bytecode-generated path, requires JDK 24+. +@FunctionalInterface +public interface JtdValidator { + + /// Validates an instance against the compiled schema. + /// + /// @param instance the JSON value to validate + /// @return the validation result with RFC 8927 error pairs + JtdValidationResult validate(JsonValue instance); + + /// Compiles a JTD schema into a bytecode-generated validator. + /// Requires JDK 24+ (ClassFile API). Generated classes target Java 21 bytecode. + /// + /// @param schema the JTD schema as a parsed [JsonValue] + /// @return a reusable [JtdValidator] backed by generated bytecode + /// @throws IllegalArgumentException if the schema is invalid per RFC 8927 + static JtdValidator compileGenerated(JsonValue schema) { + Objects.requireNonNull(schema, "schema must not be null"); + return JtdCodegen.compile(schema); + } +} diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/BenchmarkTest.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/BenchmarkTest.java index 5317caa..e8f34b8 100644 --- a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/BenchmarkTest.java +++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/BenchmarkTest.java @@ -2,7 +2,6 @@ import jdk.sandbox.java.util.json.Json; import jdk.sandbox.java.util.json.JsonValue; -import json.java21.jtd.JtdValidator; import org.junit.jupiter.api.Test; import java.util.LinkedHashMap; @@ -137,7 +136,7 @@ void benchmarkAll() { final var codegenResult = JtdCodegen.compileWithStats(schema); final var codegen = codegenResult.validator(); final var classfileBytes = codegenResult.classfileBytes(); - final var interpreter = JtdValidator.compile(schema); + final var interpreter = json.java21.jtd.JtdValidator.compileInterpreter(schema); assertThat(codegen.validate(validDoc).isValid()).isTrue(); assertThat(codegen.validate(invalidDoc).isValid()).isFalse(); @@ -149,8 +148,8 @@ void benchmarkAll() { final var codegenValidNs = measure(codegen, validDoc); final var codegenInvalidNs = measure(codegen, invalidDoc); - final var interpValidNs = measure(interpreter, validDoc); - final var interpInvalidNs = measure(interpreter, invalidDoc); + final var interpValidNs = measureInterpreter(interpreter, validDoc); + final var interpInvalidNs = measureInterpreter(interpreter, invalidDoc); final var speedupValid = (double) interpValidNs / codegenValidNs; final var speedupInvalid = (double) interpInvalidNs / codegenInvalidNs; @@ -200,6 +199,16 @@ private long measure(JtdValidator validator, JsonValue doc) { return elapsed / MEASURED_ITERATIONS; } + private long measureInterpreter(json.java21.jtd.JtdValidator validator, JsonValue doc) { + IntStream.range(0, WARMUP_ITERATIONS).forEach(_ -> validator.validate(doc)); + + final var start = System.nanoTime(); + IntStream.range(0, MEASURED_ITERATIONS).forEach(_ -> validator.validate(doc)); + final var elapsed = System.nanoTime() - start; + + return elapsed / MEASURED_ITERATIONS; + } + record BenchResult(int classfileBytes, int schemaJsonChars, long codegenValidNs, long interpValidNs, long codegenInvalidNs, long interpInvalidNs) {} diff --git a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CrossValidationTest.java b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CrossValidationTest.java index 121580b..1d00392 100644 --- a/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CrossValidationTest.java +++ b/json-java21-jtd-codegen/src/test/java/json/java21/jtd/codegen/CrossValidationTest.java @@ -228,7 +228,7 @@ void interpreterAndCodegenAgree(String name, String schemaJson, String instanceJ final var schema = Json.parse(schemaJson); final var instance = Json.parse(instanceJson); - final var interpreter = json.java21.jtd.JtdValidator.compile(schema); + final var interpreter = json.java21.jtd.JtdValidator.compileInterpreter(schema); final var codegen = JtdCodegen.compile(schema); final var interpResult = interpreter.validate(instance); diff --git a/json-java21-jtd/README.md b/json-java21-jtd/README.md index ca901dc..56c2472 100644 --- a/json-java21-jtd/README.md +++ b/json-java21-jtd/README.md @@ -2,6 +2,14 @@ A Java implementation of the JSON Type Definition (JTD) specification (RFC 8927). JTD is a schema language for JSON that provides simple, predictable validation with eight mutually-exclusive schema forms. +## Use Case + +This interpreter-based validator is optimised for **infrequent validation** — loading configuration files, validating resources at startup, or one-off schema checks. Because these operations run rarely, the validated classes are unlikely to be JIT-compiled, and the interpreter's simplicity keeps startup overhead low. After validation completes, the schema and document objects are typically discarded, so the JVM is not encumbered with loaded validator classes and can focus JIT effort on the application's hot path. + +For **repeated hot-path validation** (e.g., event processing, API gateways), consider the [bytecode codegen module](../json-java21-jtd-codegen/README.md) which generates dedicated validator classes that benefit from JIT optimisation over many invocations. + +> **Future note**: `java.util.json` has entered the JDK incubator (`jdk.incubator.json`). Once the API stabilises in the JDK itself, generated bytecode validators can depend directly on future JDK classes rather than this backport, making them even more efficient with zero library overhead. + ## Features - **RFC 8927 Compliant**: Full implementation of the JSON Type Definition specification @@ -202,7 +210,7 @@ String schemaJson = """ JsonValue schema = Json.parse(schemaJson); // Compile to a reusable validator (interpreter path, always available) -JtdValidator validator = JtdValidator.compile(schema); +JtdValidator validator = JtdValidator.compileInterpreter(schema); JtdValidationResult result = validator.validate(Json.parse("\"hello\"")); assert result.isValid(); @@ -223,24 +231,9 @@ result.errors().forEach(e -> // Output: "" -> "/type" ``` -### Generated Validators (optional, JDK 24+) - -When the `json-java21-jtd-codegen` module is on the classpath **and** the build -runs on JDK 24+, the factory can generate optimised bytecode validators that -contain only the checks the schema requires -- no interpreter, no AST, no -runtime stack: - -```java -// Throws if codegen module is not on the classpath -JtdValidator fast = JtdValidator.compileGenerated(schema); -``` - -The generated classfiles target Java 21 (class version 65) so they run on any -JDK 21+ runtime. The `toString()` of a generated validator returns the original -JTD schema JSON. +### Bytecode-Generated Validators (optional, JDK 24+) -If you do not need the generated path, the interpreter path (`JtdValidator.compile`) -works everywhere with zero extra dependencies. +For repeated hot-path validation, the [`json-java21-jtd-codegen`](../json-java21-jtd-codegen/README.md) module generates dedicated validator classes via the JDK 24+ ClassFile API. Use its own `JtdValidator.compileGenerated(schema)` factory — a separate functional interface in the `json.java21.jtd.codegen` package. ## Architecture diff --git a/json-java21-jtd/pom.xml b/json-java21-jtd/pom.xml index ec250d8..65bd9a9 100644 --- a/json-java21-jtd/pom.xml +++ b/json-java21-jtd/pom.xml @@ -82,6 +82,8 @@ 3.11.0 21 + true + true -Xlint:all -Werror diff --git a/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidator.java b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidator.java index 915901f..829f853 100644 --- a/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidator.java +++ b/json-java21-jtd/src/main/java/json/java21/jtd/JtdValidator.java @@ -2,17 +2,18 @@ import jdk.sandbox.java.util.json.JsonValue; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.Objects; import java.util.logging.Logger; -/// Functional interface for validating a JSON instance against a compiled JTD schema. +/// Functional interface for validating a JSON instance against a compiled JTD schema +/// using the stack-machine interpreter. /// -/// Obtain an instance via the static factory methods: -/// - [#compile(JsonValue)] -- interpreter path, always available. -/// - [#compileGenerated(JsonValue)] -- bytecode-generated path, requires -/// `json-java21-jtd-codegen` on the classpath (JDK 24+ build). +/// Works on any JDK 21+ runtime with zero extra dependencies. +/// For bytecode-generated validators (JDK 24+ build), see +/// {@code json.java21.jtd.codegen.JtdValidator} in the {@code json-java21-jtd-codegen} module. +/// +/// Obtain an instance via: +/// - [#compileInterpreter(JsonValue)] -- interpreter path, always available. @FunctionalInterface public interface JtdValidator { @@ -24,54 +25,27 @@ public interface JtdValidator { /// @return the validation result with RFC 8927 error pairs JtdValidationResult validate(JsonValue instance); - // ------------------------------------------------------------------ - // Factory: interpreter path (always available) - // ------------------------------------------------------------------ - /// Compiles a JTD schema into a reusable validator using the stack-machine /// interpreter. Works on any JDK 21+ runtime with zero extra dependencies. /// /// @param schema the JTD schema as a parsed [JsonValue] /// @return a reusable [JtdValidator] /// @throws IllegalArgumentException if the schema is invalid per RFC 8927 - static JtdValidator compile(JsonValue schema) { + static JtdValidator compileInterpreter(JsonValue schema) { Objects.requireNonNull(schema, "schema must not be null"); final var jtd = new Jtd(); final var compiled = jtd.compileToSchema(schema); return new InterpreterValidator(compiled, jtd, schema.toString()); } - // ------------------------------------------------------------------ - // Factory: codegen path (optional, requires extra module) - // ------------------------------------------------------------------ - - String CODEGEN_CLASS = "json.java21.jtd.codegen.JtdCodegen"; - String CODEGEN_METHOD = "compile"; - - /// Compiles a JTD schema into a bytecode-generated validator. - /// Requires the `json-java21-jtd-codegen` module on the classpath. + /// Compiles a JTD schema into a reusable validator using the stack-machine + /// interpreter. /// /// @param schema the JTD schema as a parsed [JsonValue] - /// @return a reusable [JtdValidator] backed by generated bytecode - /// @throws UnsupportedOperationException if the codegen module is not on the classpath + /// @return a reusable [JtdValidator] /// @throws IllegalArgumentException if the schema is invalid per RFC 8927 - static JtdValidator compileGenerated(JsonValue schema) { - Objects.requireNonNull(schema, "schema must not be null"); - try { - final var clazz = Class.forName(CODEGEN_CLASS); - final var method = clazz.getMethod(CODEGEN_METHOD, JsonValue.class); - return (JtdValidator) method.invoke(null, schema); - } catch (ClassNotFoundException e) { - throw new UnsupportedOperationException( - "Codegen module not on classpath. Add json-java21-jtd-codegen dependency, " - + "or use JtdValidator.compile() for the interpreter path.", e); - } catch (InvocationTargetException e) { - final var cause = e.getCause(); - if (cause instanceof IllegalArgumentException iae) throw iae; - if (cause instanceof RuntimeException re) throw re; - throw new RuntimeException("Codegen compilation failed", cause); - } catch (ReflectiveOperationException e) { - throw new UnsupportedOperationException("Codegen module found but incompatible", e); - } + @Deprecated(since = "1.0.0", forRemoval = true) + static JtdValidator compile(JsonValue schema) { + return compileInterpreter(schema); } } diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecConformanceTest.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecConformanceTest.java index 77a25bf..667d88a 100644 --- a/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecConformanceTest.java +++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdSpecConformanceTest.java @@ -49,7 +49,7 @@ void interpreterMatchesSpecSuite(String name, JsonValue caseValue) { final var instance = caseObj.members().get("instance"); final var expectedErrors = (JsonArray) caseObj.members().get("errors"); - final var validator = JtdValidator.compile(schema); + final var validator = JtdValidator.compileInterpreter(schema); final var result = validator.validate(instance); final var expected = expectedErrors.elements().stream() diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdValidatorTest.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdValidatorTest.java index 719ea39..be49bc7 100644 --- a/json-java21-jtd/src/test/java/json/java21/jtd/JtdValidatorTest.java +++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdValidatorTest.java @@ -24,19 +24,11 @@ class JtdValidatorTest extends JtdTestBase { @Test void compileReturnsValidatorForTypeSchema() { LOG.info("EXECUTING: compileReturnsValidatorForTypeSchema"); - final var validator = JtdValidator.compile(Json.parse("{\"type\": \"string\"}")); + final var validator = JtdValidator.compileInterpreter(Json.parse("{\"type\": \"string\"}")); assertThat(validator).isNotNull(); assertThat(validator.validate(Json.parse("\"hello\"")).isValid()).isTrue(); } - @Test - void compileGeneratedThrowsWhenCodegenNotOnClasspath() { - LOG.info("EXECUTING: compileGeneratedThrowsWhenCodegenNotOnClasspath"); - assertThatThrownBy(() -> JtdValidator.compileGenerated(Json.parse("{\"type\": \"string\"}"))) - .isInstanceOf(UnsupportedOperationException.class) - .hasMessageContaining("Codegen module not on classpath"); - } - // ------------------------------------------------------------------ // Empty form // ------------------------------------------------------------------ @@ -44,7 +36,7 @@ void compileGeneratedThrowsWhenCodegenNotOnClasspath() { @Test void emptySchemaAcceptsAnything() { LOG.info("EXECUTING: emptySchemaAcceptsAnything"); - final var v = JtdValidator.compile(Json.parse("{}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{}")); assertThat(v.validate(Json.parse("null")).isValid()).isTrue(); assertThat(v.validate(Json.parse("42")).isValid()).isTrue(); assertThat(v.validate(Json.parse("\"hi\"")).isValid()).isTrue(); @@ -59,7 +51,7 @@ void emptySchemaAcceptsAnything() { @Test void typeStringRejectsNumberWithCorrectPaths() { LOG.info("EXECUTING: typeStringRejectsNumberWithCorrectPaths"); - final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\"}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"type\": \"string\"}")); final var result = v.validate(Json.parse("42")); assertThat(result.isValid()).isFalse(); assertThat(result.errors()).hasSize(1); @@ -70,7 +62,7 @@ void typeStringRejectsNumberWithCorrectPaths() { @Test void typeBooleanValid() { LOG.info("EXECUTING: typeBooleanValid"); - final var v = JtdValidator.compile(Json.parse("{\"type\": \"boolean\"}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"type\": \"boolean\"}")); assertThat(v.validate(Json.parse("true")).isValid()).isTrue(); assertThat(v.validate(Json.parse("false")).isValid()).isTrue(); } @@ -78,7 +70,7 @@ void typeBooleanValid() { @Test void typeUint8OutOfRangeErrors() { LOG.info("EXECUTING: typeUint8OutOfRangeErrors"); - final var v = JtdValidator.compile(Json.parse("{\"type\": \"uint8\"}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"type\": \"uint8\"}")); final var result = v.validate(Json.parse("300")); assertThat(result.isValid()).isFalse(); assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/type"); @@ -91,7 +83,7 @@ void typeUint8OutOfRangeErrors() { @Test void enumRejectsUnknownValueWithEnumPath() { LOG.info("EXECUTING: enumRejectsUnknownValueWithEnumPath"); - final var v = JtdValidator.compile(Json.parse("{\"enum\": [\"a\", \"b\"]}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"enum\": [\"a\", \"b\"]}")); final var result = v.validate(Json.parse("\"c\"")); assertThat(result.isValid()).isFalse(); assertThat(result.errors().getFirst().instancePath()).isEqualTo(""); @@ -101,7 +93,7 @@ void enumRejectsUnknownValueWithEnumPath() { @Test void enumRejectsNonStringWithEnumPath() { LOG.info("EXECUTING: enumRejectsNonStringWithEnumPath"); - final var v = JtdValidator.compile(Json.parse("{\"enum\": [\"a\", \"b\"]}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"enum\": [\"a\", \"b\"]}")); final var result = v.validate(Json.parse("42")); assertThat(result.isValid()).isFalse(); assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/enum"); @@ -114,7 +106,7 @@ void enumRejectsNonStringWithEnumPath() { @Test void elementsRejectsNonArrayAtRootPath() { LOG.info("EXECUTING: elementsRejectsNonArrayAtRootPath"); - final var v = JtdValidator.compile(Json.parse("{\"elements\": {\"type\": \"string\"}}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"elements\": {\"type\": \"string\"}}")); final var result = v.validate(Json.parse("42")); assertThat(result.isValid()).isFalse(); assertThat(result.errors().getFirst().instancePath()).isEqualTo(""); @@ -124,7 +116,7 @@ void elementsRejectsNonArrayAtRootPath() { @Test void elementsReportsChildErrorsWithCorrectPaths() { LOG.info("EXECUTING: elementsReportsChildErrorsWithCorrectPaths"); - final var v = JtdValidator.compile(Json.parse("{\"elements\": {\"type\": \"string\"}}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"elements\": {\"type\": \"string\"}}")); final var result = v.validate(Json.parse("[\"ok\", 42, \"fine\", true]")); assertThat(result.isValid()).isFalse(); LOG.fine(() -> "Errors: " + result.errors()); @@ -142,7 +134,7 @@ void elementsReportsChildErrorsWithCorrectPaths() { @Test void propertiesRejectsNonObjectWithPropertiesPath() { LOG.info("EXECUTING: propertiesRejectsNonObjectWithPropertiesPath"); - final var v = JtdValidator.compile(Json.parse( + final var v = JtdValidator.compileInterpreter(Json.parse( "{\"properties\": {\"name\": {\"type\": \"string\"}}}")); final var result = v.validate(Json.parse("42")); assertThat(result.isValid()).isFalse(); @@ -153,7 +145,7 @@ void propertiesRejectsNonObjectWithPropertiesPath() { @Test void optionalPropertiesOnlyRejectsNonObjectWithOptionalPath() { LOG.info("EXECUTING: optionalPropertiesOnlyRejectsNonObjectWithOptionalPath"); - final var v = JtdValidator.compile(Json.parse( + final var v = JtdValidator.compileInterpreter(Json.parse( "{\"optionalProperties\": {\"email\": {\"type\": \"string\"}}}")); final var result = v.validate(Json.parse("42")); assertThat(result.isValid()).isFalse(); @@ -167,7 +159,7 @@ void propertiesMissingRequiredKeyError() { final var schema = Json.parse(""" {"properties": {"name": {"type": "string"}, "age": {"type": "uint8"}}} """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); final var result = v.validate(Json.parse("{\"name\": \"Alice\"}")); assertThat(result.isValid()).isFalse(); assertThat(result.errors()).anyMatch(e -> @@ -180,7 +172,7 @@ void propertiesAdditionalPropertyError() { final var schema = Json.parse(""" {"properties": {"name": {"type": "string"}}} """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); final var result = v.validate(Json.parse("{\"name\": \"Alice\", \"extra\": true}")); assertThat(result.isValid()).isFalse(); assertThat(result.errors()).anyMatch(e -> @@ -193,7 +185,7 @@ void propertiesChildValueError() { final var schema = Json.parse(""" {"properties": {"age": {"type": "uint8"}}} """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); final var result = v.validate(Json.parse("{\"age\": \"not a number\"}")); assertThat(result.isValid()).isFalse(); assertThat(result.errors()).anyMatch(e -> @@ -206,7 +198,7 @@ void optionalPropertiesChildValueError() { final var schema = Json.parse(""" {"optionalProperties": {"email": {"type": "string"}}} """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); final var result = v.validate(Json.parse("{\"email\": 42}")); assertThat(result.isValid()).isFalse(); assertThat(result.errors()).anyMatch(e -> @@ -221,7 +213,7 @@ void optionalPropertiesChildValueError() { @Test void valuesRejectsNonObjectAtRootPath() { LOG.info("EXECUTING: valuesRejectsNonObjectAtRootPath"); - final var v = JtdValidator.compile(Json.parse("{\"values\": {\"type\": \"string\"}}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"values\": {\"type\": \"string\"}}")); final var result = v.validate(Json.parse("42")); assertThat(result.isValid()).isFalse(); assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/values"); @@ -230,7 +222,7 @@ void valuesRejectsNonObjectAtRootPath() { @Test void valuesReportsChildErrors() { LOG.info("EXECUTING: valuesReportsChildErrors"); - final var v = JtdValidator.compile(Json.parse("{\"values\": {\"type\": \"string\"}}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"values\": {\"type\": \"string\"}}")); final var result = v.validate(Json.parse("{\"a\": \"ok\", \"b\": 42}")); assertThat(result.isValid()).isFalse(); assertThat(result.errors()).anyMatch(e -> @@ -247,7 +239,7 @@ void discriminatorNotObjectError() { final var schema = Json.parse(""" {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}} """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); final var result = v.validate(Json.parse("42")); assertThat(result.isValid()).isFalse(); assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/discriminator"); @@ -259,7 +251,7 @@ void discriminatorMissingTagError() { final var schema = Json.parse(""" {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}} """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); final var result = v.validate(Json.parse("{\"x\": 1}")); assertThat(result.isValid()).isFalse(); assertThat(result.errors().getFirst().instancePath()).isEqualTo(""); @@ -272,7 +264,7 @@ void discriminatorTagNotStringError() { final var schema = Json.parse(""" {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}} """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); final var result = v.validate(Json.parse("{\"type\": 42}")); assertThat(result.isValid()).isFalse(); assertThat(result.errors().getFirst().instancePath()).isEqualTo("/type"); @@ -285,7 +277,7 @@ void discriminatorTagNotInMappingError() { final var schema = Json.parse(""" {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}} """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); final var result = v.validate(Json.parse("{\"type\": \"unknown\"}")); assertThat(result.isValid()).isFalse(); assertThat(result.errors().getFirst().instancePath()).isEqualTo("/type"); @@ -298,7 +290,7 @@ void discriminatorVariantValidationErrors() { final var schema = Json.parse(""" {"discriminator": "type", "mapping": {"a": {"properties": {"x": {"type": "string"}}}}} """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); final var result = v.validate(Json.parse("{\"type\": \"a\", \"x\": 42}")); assertThat(result.isValid()).isFalse(); assertThat(result.errors()).anyMatch(e -> @@ -313,7 +305,7 @@ void discriminatorVariantValidationErrors() { @Test void nullableAcceptsNull() { LOG.info("EXECUTING: nullableAcceptsNull"); - final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\", \"nullable\": true}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"type\": \"string\", \"nullable\": true}")); assertThat(v.validate(Json.parse("null")).isValid()).isTrue(); assertThat(v.validate(Json.parse("\"hi\"")).isValid()).isTrue(); } @@ -321,7 +313,7 @@ void nullableAcceptsNull() { @Test void nullableStillRejectsWrongType() { LOG.info("EXECUTING: nullableStillRejectsWrongType"); - final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\", \"nullable\": true}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"type\": \"string\", \"nullable\": true}")); final var result = v.validate(Json.parse("42")); assertThat(result.isValid()).isFalse(); assertThat(result.errors().getFirst().schemaPath()).isEqualTo("/type"); @@ -337,7 +329,7 @@ void refValidatesViaDefinition() { final var schema = Json.parse(""" {"definitions": {"addr": {"type": "string"}}, "ref": "addr"} """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); assertThat(v.validate(Json.parse("\"hello\"")).isValid()).isTrue(); final var result = v.validate(Json.parse("42")); assertThat(result.isValid()).isFalse(); @@ -352,7 +344,7 @@ void refValidatesViaDefinition() { void toStringReturnsSchemaJson() { LOG.info("EXECUTING: toStringReturnsSchemaJson"); final var schemaJson = "{\"type\": \"string\"}"; - final var v = JtdValidator.compile(Json.parse(schemaJson)); + final var v = JtdValidator.compileInterpreter(Json.parse(schemaJson)); assertThat(v.toString()).isNotEmpty(); LOG.fine(() -> "toString: " + v); } @@ -364,7 +356,7 @@ void toStringReturnsSchemaJson() { @Test void usableInStreamPipeline() { LOG.info("EXECUTING: usableInStreamPipeline"); - final var v = JtdValidator.compile(Json.parse("{\"type\": \"string\"}")); + final var v = JtdValidator.compileInterpreter(Json.parse("{\"type\": \"string\"}")); final var docs = java.util.List.of( Json.parse("\"a\""), Json.parse("42"), Json.parse("\"b\""), Json.parse("true")); final var invalid = docs.stream() @@ -400,7 +392,7 @@ void workedExampleFromSpec() { "extra": true } """); - final var v = JtdValidator.compile(schema); + final var v = JtdValidator.compileInterpreter(schema); final var result = v.validate(instance); assertThat(result.isValid()).isFalse(); diff --git a/pom.xml b/pom.xml index 8bd46fa..6f983d9 100644 --- a/pom.xml +++ b/pom.xml @@ -106,6 +106,13 @@ org.apache.maven.plugins maven-compiler-plugin ${maven-compiler-plugin.version} + + true + true + + -Werror + + org.apache.maven.plugins @@ -221,6 +228,7 @@ json-java21-jtd-codegen + jdt2jar