diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd831c9..88b26fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,8 @@ jobs: for k in totals: totals[k]+=int(r.get(k,'0')) except Exception: pass - exp_tests=3668 - exp_skipped=1424 + exp_tests=3667 + exp_skipped=1425 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) diff --git a/AGENTS.md b/AGENTS.md index 9a2c732..e731976 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ - Follow the sequence plan → implement → verify; do not pivot without restating the plan. - Stop immediately on unexpected failures and ask before changing approach. - Keep edits atomic and avoid leaving mixed partial states. -- Propose options with trade-offs before invasive changes. +- Propose jsonSchemaOptions with trade-offs before invasive changes. - Prefer mechanical, reversible transforms (especially when syncing upstream sources). - Validate that outputs are non-empty before overwriting files. - Minimal shims are acceptable only when needed to keep backports compiling. @@ -287,7 +287,7 @@ git push -u origin "rel-$VERSION" && echo "✅ Success" || echo "🛑 Unable to 2. **MVF Flow (Mermaid)** ```mermaid flowchart TD - A[compile(initialDoc, initialUri, options)] --> B[Work Stack (LIFO)] + A[compile(initialDoc, initialUri, jsonSchemaOptions)] --> B[Work Stack (LIFO)] B -->|push initialUri| C{pop docUri} C -->|empty| Z[freeze Roots (immutable) → return primary root facade] C --> D[fetch/parse JSON for docUri] @@ -334,7 +334,7 @@ type RefToken = | { kind: "Local"; pointer: JsonPointer } | { kind: "Remote"; doc: DocURI; pointer: JsonPointer }; -function compile(initialDoc: unknown, initialUri: DocURI, options?: unknown): { +function compile(initialDoc: unknown, initialUri: DocURI, jsonSchemaOptions?: unknown): { primary: Root; roots: Roots; // unused by MVF runtime; ready for remote expansions } { @@ -419,3 +419,137 @@ def xform(text): print('OK') PY ``` + +# Java DOP Coding Standards #################### + +This file is a Gen AI summary of CODING_STYLE.md to use less tokens of context window. Read the original file for full details. + +IMPORTANT: We do TDD so all code must include targeted unit tests. +IMPORTANT: Never disable tests written for logic that we are yet to write we do Red-Green-Refactor coding. + +## Core Principles + +* Use Records for all data structures. Use sealed interfaces for protocols. +* Prefer static methods with Records as parameters +* Default to package-private scope +* Package-by-feature, not package-by-layer +* Create fewer, cohesive, wide packages (functionality modules or records as protocols) +* Use public only when cross-package access is required +* Use JEP 467 Markdown documentation examples: `/// good markdown` not legacy `/** bad html */` +* Apply Data-Oriented Programming principles and avoid OOP +* Use Stream operations instead of traditional loops. Never use `for(;;)` with mutable loop variables use + `Arrays.setAll` +* Prefer exhaustive destructuring switch expressions over if-else statements +* Use destructuring switch expressions that operate on Records and sealed interfaces +* Use anonymous variables in record destructuring and switch expressions +* Use `final var` for local variables, parameters, and destructured fields +* Apply JEP 371 "Local Classes and Interfaces" for cohesive files with narrow APIs + +## Data-Oriented Programming + +* Separate data (immutable Records) from behavior (never utility classes always static methods) +* Use immutable generic data structures (maps, lists, sets) and take defense copies in constructors +* Write pure functions that don't modify state +* Leverage Java 21+ features: + * Records for immutable data + * Pattern matching for structural decomposition + * Sealed classes for exhaustive switches + * Virtual threads for concurrent processing + +## Package Structure + +* Use default (package-private) access as the standard. Do not use 'private' or 'public' by default. +* Limit public to genuine cross-package APIs +* Prefer package-private static methods. Do not use 'private' or 'public' by default. +* Limit private to security-related code +* Avoid anti-patterns: boilerplate OOP, excessive layering, dependency injection overuse + +## Constants and Magic Numbers + +* **NEVER use magic numbers** - always use enum constants +* **NEVER write large if-else-if statements over known types** - will not be exhaustive and creates bugs when new types are added. Use exhaustive switch statements over bounded sets such as enum values or sealed interface permits + +## Functional Style + +* Combine Records + static methods for functional programming +* Emphasize immutability and explicit state transformations +* Reduce package count to improve testability +* Implement Algebraic Data Types pattern with Function Modules +* Modern Stream Programming +* Use Stream API instead of traditional loops +* Write declarative rather than imperative code +* Chain operations without intermediate variables +* Support immutability throughout processing +* Example: `IntStream.range(0, 100).filter(i -> i % 2 == 0).sum()` instead of counting loops +* Always use final variables in functional style. +* Prefer `final var` with self documenting names over `int i` or `String s` but its not possible to do that on a `final` variable that is not yet initialized so its a weak preference not a strong one. +* Avoid just adding new functionality to the top of a method to make an early return. It is fine to have a simple guard statement. Yet general you should pattern match over the input to do different things with the same method. Adding special case logic is a code smell that should be avoided. + +## Documentation using JEP 467 Markdown documentation + +IMPORTANT: You must not write JavaDoc comments that start with `/**` and end with `*/` +IMPORTANT: You must "JEP 467: Markdown Documentation Comments" that start all lines with `///` + +Here is an example of the correct format for documentation comments: + +```java +/// Returns a hash code value for the object. This method is +/// supported for the benefit of hash tables such as those provided by +/// [java.util.HashMap]. +/// +/// The general contract of `hashCode` is: +/// +/// - Whenever it is invoked on the same object more than once during +/// an execution of a Java application, the `hashCode` method +/// - If two objects are equal according to the +/// [equals][#equals(Object)] method, then calling the +/// - It is _not_ required that if two objects are unequal +/// according to the [equals][#equals(Object)] method, then +/// +/// @return a hash code value for this object. +/// @see java.lang.Object#equals(java.lang.Object) +``` + +## Logging + +- Use Java's built-in logging: `java.util.logging.Logger` +- Log levels: Use appropriate levels (FINE, FINER, INFO, WARNING, SEVERE) + - **FINE**: Production-level debugging, default for most debug output + - **FINER**: Verbose debugging, detailed internal flow, class resolution details + - **INFO**: Important runtime information +- LOGGER is a static field: `static final Logger LOGGER = Logger.getLogger(ClassName.class.getName());` where use the primary interface or the package as the logger name with the logger package-private and shared across the classes when the package is small enough. +- Use lambda logging for performance: `LOGGER.fine(() -> "message " + variable);` + +# Compile, Test, Debug Loop + +- **Check Compiles**: Focusing on the correct mvn module run without verbose logging and do not grep the output to see compile errors: + ```bash + $(command -v mvnd || command -v mvn || command -v ./mvnw) -pl json-java21-api-tracker -Djava.util.logging.ConsoleHandler.level=SEVERE + ``` +- **Debug with Verbose Logs**: Use `-Dtest=` to focus on just one or two test methods, or one class, using more logging to debug the code: + ```bash + $(command -v mvnd || command -v mvn || command -v ./mvnw) -pl json-java21-api-tracker -Dtest=XXX -Djava.util.logging.ConsoleHandler.level=FINER + ``` +- **No Grep Filtering**: Use logging levels to filter output, do not grep the output for compile errors, just run less test methods with the correct logging to reduce the output to a manageable size. Filtering hides problems and needs more test excution to find the same problems which wastes time. + +## Modern Java Singleton Pattern: Sealed Interfaces + +**Singleton Object Anti-Pattern**: Traditional singleton classes with private constructors and static instances are legacy should be avoided. With a functional style we can create a "package-private companion module" of small package-private methods with `sealed interfacee GoodSingletonModule permits Nothing { enum Nothing extends GoodSingletonModule{}; /* static functional methods here */ }`. + +### Assertions and Input Validation + +1. On the public API entry points use `Objects.assertNonNull()` to ensure that the inputs are legal. +2. After that on internal method that should be passed only valid data use `assert` to ensure that the data is valid. +- e.g. use `assert x==y: "unexpected x="+x+" y="+y;` as `mvn` base should be run with `-ea` to enable assertions. +3. Often there is an `orElseThrow()` which can be used so the only reason to use `assert` is to add more logging to the error message. +4. Consider using the validations of `Object` and `Arrays` and the like to ensure that the data is valid. +- e.g. `Objects.requireNonNull(type, "type must not be null")` or `Arrays.checkIndex(index, array.length)`. + +## JEP References + +[JEP 467](https://openjdk.org/jeps/467): Markdown Documentation in JavaDoc +[JEP 371](https://openjdk.org/jeps/371): Local Classes and Interfaces +[JEP 395](https://openjdk.org/jeps/395): Records +[JEP 409](https://openjdk.org/jeps/409): Sealed Classes +[JEP 440](https://openjdk.org/jeps/440): Record Patterns +[JEP 427](https://openjdk.org/jeps/427): Pattern Matching for Switch diff --git a/CODING_STYLE_LLM.md b/CODING_STYLE_LLM.md deleted file mode 100644 index dc3ce11..0000000 --- a/CODING_STYLE_LLM.md +++ /dev/null @@ -1,133 +0,0 @@ -# Java DOP Coding Standards #################### - -This file is a Gen AI summary of CODING_STYLE.md to use less tokens of context window. Read the original file for full details. - -IMPORTANT: We do TDD so all code must include targeted unit tests. -IMPORTANT: Never disable tests written for logic that we are yet to write we do Red-Green-Refactor coding. - -## Core Principles - -* Use Records for all data structures. Use sealed interfaces for protocols. -* Prefer static methods with Records as parameters -* Default to package-private scope -* Package-by-feature, not package-by-layer -* Create fewer, cohesive, wide packages (functionality modules or records as protocols) -* Use public only when cross-package access is required -* Use JEP 467 Markdown documentation examples: `/// good markdown` not legacy `/** bad html */` -* Apply Data-Oriented Programming principles and avoid OOP -* Use Stream operations instead of traditional loops. Never use `for(;;)` with mutable loop variables use - `Arrays.setAll` -* Prefer exhaustive destructuring switch expressions over if-else statements -* Use destructuring switch expressions that operate on Records and sealed interfaces -* Use anonymous variables in record destructuring and switch expressions -* Use `final var` for local variables, parameters, and destructured fields -* Apply JEP 371 "Local Classes and Interfaces" for cohesive files with narrow APIs - -## Data-Oriented Programming - -* Separate data (immutable Records) from behavior (never utility classes always static methods) -* Use immutable generic data structures (maps, lists, sets) and take defense copies in constructors -* Write pure functions that don't modify state -* Leverage Java 21+ features: - * Records for immutable data - * Pattern matching for structural decomposition - * Sealed classes for exhaustive switches - * Virtual threads for concurrent processing - -## Package Structure - -* Use default (package-private) access as the standard. Do not use 'private' or 'public' by default. -* Limit public to genuine cross-package APIs -* Prefer package-private static methods. Do not use 'private' or 'public' by default. -* Limit private to security-related code -* Avoid anti-patterns: boilerplate OOP, excessive layering, dependency injection overuse - -## Constants and Magic Numbers - -* **NEVER use magic numbers** - always use enum constants -* **NEVER write large if-else-if statements over known types** - will not be exhaustive and creates bugs when new types are added. Use exhaustive switch statements over bounded sets such as enum values or sealed interface permits - -## Functional Style - -* Combine Records + static methods for functional programming -* Emphasize immutability and explicit state transformations -* Reduce package count to improve testability -* Implement Algebraic Data Types pattern with Function Modules -* Modern Stream Programming -* Use Stream API instead of traditional loops -* Write declarative rather than imperative code -* Chain operations without intermediate variables -* Support immutability throughout processing -* Example: `IntStream.range(0, 100).filter(i -> i % 2 == 0).sum()` instead of counting loops -* Always use final variables in functional style. -* Prefer `final var` with self documenting names over `int i` or `String s` but its not possible to do that on a `final` variable that is not yet initialized so its a weak preference not a strong one. -* Avoid just adding new functionality to the top of a method to make an early return. It is fine to have a simple guard statement. Yet general you should pattern match over the input to do different things with the same method. Adding special case logic is a code smell that should be avoided. - -## Documentation using JEP 467 Markdown documentation - -IMPORTANT: You must not write JavaDoc comments that start with `/**` and end with `*/` -IMPORTANT: You must "JEP 467: Markdown Documentation Comments" that start all lines with `///` - -Here is an example of the correct format for documentation comments: - -```java -/// Returns a hash code value for the object. This method is -/// supported for the benefit of hash tables such as those provided by -/// [java.util.HashMap]. -/// -/// The general contract of `hashCode` is: -/// -/// - Whenever it is invoked on the same object more than once during -/// an execution of a Java application, the `hashCode` method -/// - If two objects are equal according to the -/// [equals][#equals(Object)] method, then calling the -/// - It is _not_ required that if two objects are unequal -/// according to the [equals][#equals(Object)] method, then -/// -/// @return a hash code value for this object. -/// @see java.lang.Object#equals(java.lang.Object) -``` - -## Logging - -- Use Java's built-in logging: `java.util.logging.Logger` -- Log levels: Use appropriate levels (FINE, FINER, INFO, WARNING, SEVERE) - - **FINE**: Production-level debugging, default for most debug output - - **FINER**: Verbose debugging, detailed internal flow, class resolution details - - **INFO**: Important runtime information -- LOGGER is a static field: `static final Logger LOGGER = Logger.getLogger(ClassName.class.getName());` where use the primary interface or the package as the logger name with the logger package-private and shared across the classes when the package is small enough. -- Use lambda logging for performance: `LOGGER.fine(() -> "message " + variable);` - -# Compile, Test, Debug Loop - -- **Check Compiles**: Focusing on the correct mvn module run without verbose logging and do not grep the output to see compile errors: - ```bash - $(command -v mvnd || command -v mvn || command -v ./mvnw) -pl json-java21-api-tracker -Djava.util.logging.ConsoleHandler.level=SEVERE - ``` -- **Debug with Verbose Logs**: Use `-Dtest=` to focus on just one or two test methods, or one class, using more logging to debug the code: - ```bash - $(command -v mvnd || command -v mvn || command -v ./mvnw) -pl json-java21-api-tracker -Dtest=XXX -Djava.util.logging.ConsoleHandler.level=FINER - ``` -- **No Grep Filtering**: Use logging levels to filter output, do not grep the output for compile errors, just run less test methods with the correct logging to reduce the output to a manageable size. Filtering hides problems and needs more test excution to find the same problems which wastes time. - -## Modern Java Singleton Pattern: Sealed Interfaces - -**Singleton Object Anti-Pattern**: Traditional singleton classes with private constructors and static instances are legacy should be avoided. With a functional style we can create a "package-private companion module" of small package-private methods with `sealed interfacee GoodSingletonModule permits Nothing { enum Nothing extends GoodSingletonModule{}; /* static functional methods here */ }`. - -### Assertions and Input Validation - -1. On the public API entry points use `Objects.assertNonNull()` to ensure that the inputs are legal. -2. After that on internal method that should be passed only valid data use `assert` to ensure that the data is valid. - - e.g. use `assert x==y: "unexpected x="+x+" y="+y;` as `mvn` base should be run with `-ea` to enable assertions. -3. Often there is an `orElseThrow()` which can be used so the only reason to use `assert` is to add more logging to the error message. -4. Consider using the validations of `Object` and `Arrays` and the like to ensure that the data is valid. - - e.g. `Objects.requireNonNull(type, "type must not be null")` or `Arrays.checkIndex(index, array.length)`. - -## JEP References - -[JEP 467](https://openjdk.org/jeps/467): Markdown Documentation in JavaDoc -[JEP 371](https://openjdk.org/jeps/371): Local Classes and Interfaces -[JEP 395](https://openjdk.org/jeps/395): Records -[JEP 409](https://openjdk.org/jeps/409): Sealed Classes -[JEP 440](https://openjdk.org/jeps/440): Record Patterns -[JEP 427](https://openjdk.org/jeps/427): Pattern Matching for Switch diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/AllOfSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/AllOfSchema.java new file mode 100644 index 0000000..efae2eb --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/AllOfSchema.java @@ -0,0 +1,18 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.Deque; +import java.util.List; + +/// AllOf composition - must satisfy all schemas +public record AllOfSchema(List schemas) implements JsonSchema { + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + // Push all subschemas onto the stack for validation + for (JsonSchema schema : schemas) { + stack.push(new ValidationFrame(path, schema, json)); + } + return ValidationResult.success(); // Actual results emerge from stack processing + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/AnyOfSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/AnyOfSchema.java new file mode 100644 index 0000000..041f121 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/AnyOfSchema.java @@ -0,0 +1,43 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +/// AnyOf composition - must satisfy at least one schema +public record AnyOfSchema(List schemas) implements JsonSchema { + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + List collected = new ArrayList<>(); + boolean anyValid = false; + + for (JsonSchema schema : schemas) { + // Create a separate validation stack for this branch + Deque branchStack = new ArrayDeque<>(); + List branchErrors = new ArrayList<>(); + + LOG.finest(() -> "BRANCH START: " + schema.getClass().getSimpleName()); + branchStack.push(new ValidationFrame(path, schema, json)); + + while (!branchStack.isEmpty()) { + ValidationFrame frame = branchStack.pop(); + ValidationResult result = frame.schema().validateAt(frame.path(), frame.json(), branchStack); + if (!result.valid()) { + branchErrors.addAll(result.errors()); + } + } + + if (branchErrors.isEmpty()) { + anyValid = true; + break; + } + collected.addAll(branchErrors); + LOG.finest(() -> "BRANCH END: " + branchErrors.size() + " errors"); + } + + return anyValid ? ValidationResult.success() : ValidationResult.failure(collected); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/AnySchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/AnySchema.java new file mode 100644 index 0000000..84b1a73 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/AnySchema.java @@ -0,0 +1,15 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.Deque; + +/// Any schema - accepts all values +public record AnySchema() implements JsonSchema { + static final io.github.simbo1905.json.schema.AnySchema INSTANCE = new io.github.simbo1905.json.schema.AnySchema(); + + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + return ValidationResult.success(); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ArraySchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ArraySchema.java new file mode 100644 index 0000000..d703e25 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ArraySchema.java @@ -0,0 +1,191 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonString; +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.*; + +/// Array schema with item validation and constraints +public record ArraySchema( + JsonSchema items, + Integer minItems, + Integer maxItems, + Boolean uniqueItems, + // NEW: Pack 2 array features + List prefixItems, + JsonSchema contains, + Integer minContains, + Integer maxContains +) implements JsonSchema { + + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + if (!(json instanceof JsonArray arr)) { + return ValidationResult.failure(List.of( + new ValidationError(path, "Expected array") + )); + } + + List errors = new ArrayList<>(); + int itemCount = arr.values().size(); + + // Check item count constraints + if (minItems != null && itemCount < minItems) { + errors.add(new ValidationError(path, "Too few items: expected at least " + minItems)); + } + if (maxItems != null && itemCount > maxItems) { + errors.add(new ValidationError(path, "Too many items: expected at most " + maxItems)); + } + + // Check uniqueness if required (structural equality) + if (uniqueItems != null && uniqueItems) { + Set seen = new HashSet<>(); + for (JsonValue item : arr.values()) { + String canonicalKey = canonicalize(item); + if (!seen.add(canonicalKey)) { + errors.add(new ValidationError(path, "Array items must be unique")); + break; + } + } + } + + // Validate prefixItems + items (tuple validation) + if (prefixItems != null && !prefixItems.isEmpty()) { + // Validate prefix items - fail if not enough items for all prefix positions + for (int i = 0; i < prefixItems.size(); i++) { + if (i >= itemCount) { + errors.add(new ValidationError(path, "Array has too few items for prefixItems validation")); + break; + } + String itemPath = path + "[" + i + "]"; + // Validate prefix items immediately to capture errors + ValidationResult prefixResult = prefixItems.get(i).validateAt(itemPath, arr.values().get(i), stack); + if (!prefixResult.valid()) { + errors.addAll(prefixResult.errors()); + } + } + // Validate remaining items with items schema if present + if (items != null && items != AnySchema.INSTANCE) { + for (int i = prefixItems.size(); i < itemCount; i++) { + String itemPath = path + "[" + i + "]"; + stack.push(new ValidationFrame(itemPath, items, arr.values().get(i))); + } + } + } else if (items != null && items != AnySchema.INSTANCE) { + // Original items validation (no prefixItems) + int index = 0; + for (JsonValue item : arr.values()) { + String itemPath = path + "[" + index + "]"; + stack.push(new ValidationFrame(itemPath, items, item)); + index++; + } + } + + // Validate contains / minContains / maxContains + if (contains != null) { + int matchCount = 0; + for (JsonValue item : arr.values()) { + // Create isolated validation to check if item matches contains schema + Deque tempStack = new ArrayDeque<>(); + List tempErrors = new ArrayList<>(); + tempStack.push(new ValidationFrame("", contains, item)); + + while (!tempStack.isEmpty()) { + ValidationFrame frame = tempStack.pop(); + ValidationResult result = frame.schema().validateAt(frame.path(), frame.json(), tempStack); + if (!result.valid()) { + tempErrors.addAll(result.errors()); + } + } + + if (tempErrors.isEmpty()) { + matchCount++; + } + } + + int min = (minContains != null ? minContains : 1); // default min=1 + int max = (maxContains != null ? maxContains : Integer.MAX_VALUE); // default max=∞ + + if (matchCount < min) { + errors.add(new ValidationError(path, "Array must contain at least " + min + " matching element(s)")); + } else if (matchCount > max) { + errors.add(new ValidationError(path, "Array must contain at most " + max + " matching element(s)")); + } + } + + return errors.isEmpty() ? ValidationResult.success() : ValidationResult.failure(errors); + } + + /// Canonicalization helper for structural equality in uniqueItems + static String canonicalize(JsonValue v) { + switch (v) { + case JsonObject o -> { + var keys = new ArrayList<>(o.members().keySet()); + Collections.sort(keys); + var sb = new StringBuilder("{"); + for (int i = 0; i < keys.size(); i++) { + String k = keys.get(i); + if (i > 0) sb.append(','); + sb.append('"').append(escapeJsonString(k)).append("\":").append(canonicalize(o.members().get(k))); + } + return sb.append('}').toString(); + } + case JsonArray a -> { + var sb = new StringBuilder("["); + for (int i = 0; i < a.values().size(); i++) { + if (i > 0) sb.append(','); + sb.append(canonicalize(a.values().get(i))); + } + return sb.append(']').toString(); + } + case JsonString s -> { + return "\"" + escapeJsonString(s.value()) + "\""; + } + case null, default -> { + // numbers/booleans/null: rely on stable toString from the Json* impls + assert v != null; + return v.toString(); + } + } + } + static String escapeJsonString(String s) { + if (s == null) return "null"; + StringBuilder result = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char ch = s.charAt(i); + switch (ch) { + case '"': + result.append("\\\""); + break; + case '\\': + result.append("\\\\"); + break; + case '\b': + result.append("\\b"); + break; + case '\f': + result.append("\\f"); + break; + case '\n': + result.append("\\n"); + break; + case '\r': + result.append("\\r"); + break; + case '\t': + result.append("\\t"); + break; + default: + if (ch < 0x20 || ch > 0x7e) { + result.append("\\u").append(String.format("%04x", (int) ch)); + } else { + result.append(ch); + } + } + } + return result.toString(); + } + +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/BooleanSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/BooleanSchema.java new file mode 100644 index 0000000..7670237 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/BooleanSchema.java @@ -0,0 +1,34 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonBoolean; +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.Deque; +import java.util.List; + +/// Boolean schema - validates boolean values +public record BooleanSchema() implements JsonSchema { + /// Singleton instances for boolean sub-schema handling + static final io.github.simbo1905.json.schema.BooleanSchema TRUE = new io.github.simbo1905.json.schema.BooleanSchema(); + static final io.github.simbo1905.json.schema.BooleanSchema FALSE = new io.github.simbo1905.json.schema.BooleanSchema(); + + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + // For boolean subschemas, FALSE always fails, TRUE always passes + if (this == FALSE) { + return ValidationResult.failure(List.of( + new ValidationError(path, "Schema should not match") + )); + } + if (this == TRUE) { + return ValidationResult.success(); + } + // Regular boolean validation for normal boolean schemas + if (!(json instanceof JsonBoolean)) { + return ValidationResult.failure(List.of( + new ValidationError(path, "Expected boolean") + )); + } + return ValidationResult.success(); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ConditionalSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ConditionalSchema.java new file mode 100644 index 0000000..a9ae321 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ConditionalSchema.java @@ -0,0 +1,32 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.Deque; + +/// If/Then/Else conditional schema +public record ConditionalSchema(JsonSchema ifSchema, JsonSchema thenSchema, + JsonSchema elseSchema) implements JsonSchema { + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + // Step 1 - evaluate IF condition (still needs direct validation) + ValidationResult ifResult = ifSchema.validate(json); + + // Step 2 - choose branch + JsonSchema branch = ifResult.valid() ? thenSchema : elseSchema; + + LOG.finer(() -> String.format( + "Conditional path=%s ifValid=%b branch=%s", + path, ifResult.valid(), + branch == null ? "none" : (ifResult.valid() ? "then" : "else"))); + + // Step 3 - if there's a branch, push it onto the stack for later evaluation + if (branch == null) { + return ValidationResult.success(); // no branch → accept + } + + // NEW: push branch onto SAME stack instead of direct call + stack.push(new ValidationFrame(path, branch, json)); + return ValidationResult.success(); // real result emerges later + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ConstSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ConstSchema.java new file mode 100644 index 0000000..da1069a --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ConstSchema.java @@ -0,0 +1,16 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.Deque; +import java.util.List; + +/// Const schema - validates that a value equals a constant +public record ConstSchema(JsonValue constValue) implements JsonSchema { + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + return json.equals(constValue) ? + ValidationResult.success() : + ValidationResult.failure(List.of(new ValidationError(path, "Value must equal const value"))); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/EnumSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/EnumSchema.java new file mode 100644 index 0000000..cd1c21a --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/EnumSchema.java @@ -0,0 +1,26 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.Deque; +import java.util.List; +import java.util.Set; + +/// Enum schema - validates that a value is in a set of allowed values +public record EnumSchema(JsonSchema baseSchema, Set allowedValues) implements JsonSchema { + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + // First validate against base schema + ValidationResult baseResult = baseSchema.validateAt(path, json, stack); + if (!baseResult.valid()) { + return baseResult; + } + + // Then check if value is in enum + if (!allowedValues.contains(json)) { + return ValidationResult.failure(List.of(new ValidationError(path, "Not in enum"))); + } + + return ValidationResult.success(); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/FetchPolicy.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/FetchPolicy.java new file mode 100644 index 0000000..e74acfa --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/FetchPolicy.java @@ -0,0 +1,59 @@ +package io.github.simbo1905.json.schema; + +import java.util.Objects; +import java.util.Set; + +/// Fetch policy settings controlling network guardrails +public record FetchPolicy( + Set allowedSchemes, + long maxDocumentBytes, + long maxTotalBytes, + java.time.Duration timeout, + int maxRedirects, + int maxDocuments, + int maxDepth +) { + public static final String HTTPS = "https"; + public static final String HTTP = "http"; + + public FetchPolicy { + Objects.requireNonNull(allowedSchemes, "allowedSchemes"); + Objects.requireNonNull(timeout, "timeout"); + if (allowedSchemes.isEmpty()) { + throw new IllegalArgumentException("allowedSchemes must not be empty"); + } + if (maxDocumentBytes <= 0L) { + throw new IllegalArgumentException("maxDocumentBytes must be > 0"); + } + if (maxTotalBytes <= 0L) { + throw new IllegalArgumentException("maxTotalBytes must be > 0"); + } + if (maxRedirects < 0) { + throw new IllegalArgumentException("maxRedirects must be >= 0"); + } + if (maxDocuments <= 0) { + throw new IllegalArgumentException("maxDocuments must be > 0"); + } + if (maxDepth <= 0) { + throw new IllegalArgumentException("maxDepth must be > 0"); + } + } + + static FetchPolicy defaults() { + return new FetchPolicy(Set.of("http", "https", "file"), 1_048_576L, 8_388_608L, java.time.Duration.ofSeconds(5), 3, 64, 64); + } + + FetchPolicy withAllowedSchemes(Set schemes) { + Objects.requireNonNull(schemes, "schemes"); + return new FetchPolicy(Set.copyOf(schemes), maxDocumentBytes, maxTotalBytes, timeout, maxRedirects, maxDocuments, maxDepth); + } + + FetchPolicy withMaxDocumentBytes() { + return new FetchPolicy(allowedSchemes, 10, maxTotalBytes, timeout, maxRedirects, maxDocuments, maxDepth); + } + + FetchPolicy withTimeout(java.time.Duration newTimeout) { + Objects.requireNonNull(newTimeout, "newTimeout"); + return new FetchPolicy(allowedSchemes, maxDocumentBytes, maxTotalBytes, newTimeout, maxRedirects, maxDocuments, maxDepth); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/FileFetcher.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/FileFetcher.java new file mode 100644 index 0000000..97ddb21 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/FileFetcher.java @@ -0,0 +1,78 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Level; + +import static io.github.simbo1905.json.schema.JsonSchema.LOG; + +/// Local file fetcher that enforces a mandatory jail root directory +record FileFetcher(Path jailRoot) implements JsonSchema.RemoteFetcher { + FileFetcher(Path jailRoot) { + this.jailRoot = Objects.requireNonNull(jailRoot, "jailRoot").toAbsolutePath().normalize(); + LOG.info(() -> "FileFetcher jailRoot=" + this.jailRoot); + } + + @Override + public String scheme() { + return "file"; + } + + @Override + public FetchResult fetch(URI uri, FetchPolicy policy) { + Objects.requireNonNull(uri, "uri"); + Objects.requireNonNull(policy, "policy"); + + if (!"file".equalsIgnoreCase(uri.getScheme())) { + LOG.severe(() -> "ERROR: FileFetcher received non-file URI " + uri); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.POLICY_DENIED, + "FileFetcher only handles file:// URIs"); + } + + Path target = toPath(uri).normalize(); + if (!target.startsWith(jailRoot)) { + LOG.fine(() -> "FETCH DENIED outside jail: uri=" + uri + " path=" + target + " jailRoot=" + jailRoot); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.POLICY_DENIED, + "Outside jail: " + target); + } + + if (!Files.exists(target) || !Files.isRegularFile(target)) { + LOG.finer(() -> "NOT_FOUND: " + target); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.NOT_FOUND, + "No such file: " + target); + } + + try { + long size = Files.size(target); + if (size > policy.maxDocumentBytes()) { + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.PAYLOAD_TOO_LARGE, + "File exceeds maxDocumentBytes: " + size); + } + byte[] bytes = Files.readAllBytes(target); + long actual = bytes.length; + if (actual != size && actual > policy.maxDocumentBytes()) { + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.PAYLOAD_TOO_LARGE, + "File exceeds maxDocumentBytes after read: " + actual); + } + JsonValue doc = Json.parse(new String(bytes, StandardCharsets.UTF_8)); + return new FetchResult(doc, actual, Optional.empty()); + } catch (IOException e) { + LOG.log(Level.SEVERE, () -> "ERROR: IO reading file " + target); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.NETWORK_ERROR, + "IO reading file: " + e.getMessage()); + } + } + + private static Path toPath(URI uri) { + // java.nio handles file URIs via Paths.get(URI) + return Path.of(uri); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/Format.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/Format.java new file mode 100644 index 0000000..4422372 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/Format.java @@ -0,0 +1,170 @@ +package io.github.simbo1905.json.schema; + +/// Built-in format validators +public enum Format implements FormatValidator { + UUID { + @Override + public boolean test(String s) { + try { + java.util.UUID.fromString(s); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + }, + + EMAIL { + @Override + public boolean test(String s) { + // Pragmatic RFC-5322-lite regex: reject whitespace, require TLD, no consecutive dots + return s.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") && !s.contains(".."); + } + }, + + IPV4 { + @Override + public boolean test(String s) { + String[] parts = s.split("\\."); + if (parts.length != 4) return false; + + for (String part : parts) { + try { + int num = Integer.parseInt(part); + if (num < 0 || num > 255) return false; + // Check for leading zeros (except for 0 itself) + if (part.length() > 1 && part.startsWith("0")) return false; + } catch (NumberFormatException e) { + return false; + } + } + return true; + } + }, + + IPV6 { + @Override + public boolean test(String s) { + try { + // Use InetAddress to validate, but also check it contains ':' to distinguish from IPv4 + //noinspection ResultOfMethodCallIgnored + java.net.InetAddress.getByName(s); + return s.contains(":"); + } catch (Exception e) { + return false; + } + } + }, + + URI { + @Override + public boolean test(String s) { + try { + java.net.URI uri = new java.net.URI(s); + return uri.isAbsolute() && uri.getScheme() != null; + } catch (Exception e) { + return false; + } + } + }, + + URI_REFERENCE { + @Override + public boolean test(String s) { + try { + new java.net.URI(s); + return true; + } catch (Exception e) { + return false; + } + } + }, + + HOSTNAME { + @Override + public boolean test(String s) { + // Basic hostname validation: labels a-zA-Z0-9-, no leading/trailing -, label 1-63, total ≤255 + if (s.isEmpty() || s.length() > 255) return false; + if (!s.contains(".")) return false; // Must have at least one dot + + String[] labels = s.split("\\."); + for (String label : labels) { + if (label.isEmpty() || label.length() > 63) return false; + if (label.startsWith("-") || label.endsWith("-")) return false; + if (!label.matches("^[a-zA-Z0-9-]+$")) return false; + } + return true; + } + }, + + DATE { + @Override + public boolean test(String s) { + try { + java.time.LocalDate.parse(s); + return true; + } catch (Exception e) { + return false; + } + } + }, + + TIME { + @Override + public boolean test(String s) { + try { + // Try OffsetTime first (with timezone) + java.time.OffsetTime.parse(s); + return true; + } catch (Exception e) { + try { + // Try LocalTime (without timezone) + java.time.LocalTime.parse(s); + return true; + } catch (Exception e2) { + return false; + } + } + } + }, + + DATE_TIME { + @Override + public boolean test(String s) { + try { + // Try OffsetDateTime first (with timezone) + java.time.OffsetDateTime.parse(s); + return true; + } catch (Exception e) { + try { + // Try LocalDateTime (without timezone) + java.time.LocalDateTime.parse(s); + return true; + } catch (Exception e2) { + return false; + } + } + } + }, + + REGEX { + @Override + public boolean test(String s) { + try { + java.util.regex.Pattern.compile(s); + return true; + } catch (Exception e) { + return false; + } + } + }; + + /// Get format validator by name (case-insensitive) + static FormatValidator byName(String name) { + try { + return Format.valueOf(name.toUpperCase().replace("-", "_")); + } catch (IllegalArgumentException e) { + return null; // Unknown format + } + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/FormatValidator.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/FormatValidator.java new file mode 100644 index 0000000..6a47a88 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/FormatValidator.java @@ -0,0 +1,9 @@ +package io.github.simbo1905.json.schema; + +/// Format validator interface for string format validation +sealed public interface FormatValidator permits Format { + /// Test if the string value matches the format + /// @param s the string to test + /// @return true if the string matches the format, false otherwise + boolean test(String s); +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/JsonSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/JsonSchema.java index 2f5511a..83366bf 100644 --- a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/JsonSchema.java +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/JsonSchema.java @@ -9,13 +9,10 @@ import jdk.sandbox.java.util.json.*; -import java.math.BigDecimal; -import java.math.BigInteger; import java.net.URI; import java.util.*; -import java.util.logging.Level; -import java.util.regex.Pattern; -import static io.github.simbo1905.json.schema.SchemaLogging.LOG; +import java.util.logging.Logger; + /// JSON Schema public API entry point /// @@ -37,133 +34,195 @@ ///} ///``` public sealed interface JsonSchema - permits JsonSchema.Nothing, - JsonSchema.ObjectSchema, - JsonSchema.ArraySchema, - JsonSchema.StringSchema, - JsonSchema.NumberSchema, - JsonSchema.BooleanSchema, - JsonSchema.NullSchema, - JsonSchema.AnySchema, - JsonSchema.RefSchema, - JsonSchema.AllOfSchema, - JsonSchema.AnyOfSchema, - JsonSchema.OneOfSchema, - JsonSchema.ConditionalSchema, - JsonSchema.ConstSchema, - JsonSchema.NotSchema, - JsonSchema.RootRef, - JsonSchema.EnumSchema { - - /// Shared logger is provided by SchemaLogging.LOG + permits ObjectSchema, + ArraySchema, + StringSchema, + NumberSchema, + BooleanSchema, + NullSchema, + AnySchema, + RefSchema, + AllOfSchema, + AnyOfSchema, + OneOfSchema, + ConditionalSchema, + ConstSchema, + NotSchema, + RootRef, + EnumSchema { + + /// Shared logger + Logger LOG = Logger.getLogger("io.github.simbo1905.json.schema"); /// Adapter that normalizes URI keys (strip fragment + normalize) for map access. - final class NormalizedUriMap implements java.util.Map { - private final java.util.Map delegate; - NormalizedUriMap(java.util.Map delegate) { this.delegate = delegate; } - private static java.net.URI norm(java.net.URI uri) { - String s = uri.toString(); - int i = s.indexOf('#'); - java.net.URI base = i >= 0 ? java.net.URI.create(s.substring(0, i)) : uri; - return base.normalize(); + record NormalizedUriMap(Map delegate) implements Map { + private static URI norm(URI uri) { + String s = uri.toString(); + int i = s.indexOf('#'); + URI base = i >= 0 ? URI.create(s.substring(0, i)) : uri; + return base.normalize(); + } + + @Override + public int size() { + return delegate.size(); } - @Override public int size() { return delegate.size(); } - @Override public boolean isEmpty() { return delegate.isEmpty(); } - @Override public boolean containsKey(Object key) { return key instanceof java.net.URI && delegate.containsKey(norm((java.net.URI) key)); } - @Override public boolean containsValue(Object value) { return delegate.containsValue(value); } - @Override public CompiledRoot get(Object key) { return key instanceof java.net.URI ? delegate.get(norm((java.net.URI) key)) : null; } - @Override public CompiledRoot put(java.net.URI key, CompiledRoot value) { return delegate.put(norm(key), value); } - @Override public CompiledRoot remove(Object key) { return key instanceof java.net.URI ? delegate.remove(norm((java.net.URI) key)) : null; } - @Override public void putAll(java.util.Map m) { for (var e : m.entrySet()) delegate.put(norm(e.getKey()), e.getValue()); } - @Override public void clear() { delegate.clear(); } - @Override public java.util.Set> entrySet() { return delegate.entrySet(); } - @Override public java.util.Set keySet() { return delegate.keySet(); } - @Override public java.util.Collection values() { return delegate.values(); } - } - // Public constants for common JSON Pointer fragments used in schemas - public static final String SCHEMA_DEFS_POINTER = "#/$defs/"; - public static final String SCHEMA_DEFS_SEGMENT = "/$defs/"; - public static final String SCHEMA_PROPERTIES_SEGMENT = "/properties/"; - public static final String SCHEMA_POINTER_PREFIX = "#/"; - public static final String SCHEMA_POINTER_ROOT = "#"; - public static final String SCHEMA_ROOT_POINTER = "#/"; + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } - /// Prevents external implementations, ensuring all schema types are inner records - enum Nothing implements JsonSchema { - ; // Empty enum - just used as a sealed interface permit + @Override + public boolean containsKey(Object key) { + return key instanceof URI && delegate.containsKey(norm((URI) key)); + } @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - LOG.severe(() -> "ERROR: SCHEMA: Nothing.validateAt invoked"); - throw new UnsupportedOperationException("Nothing enum should not be used for validation"); + public boolean containsValue(Object value) { + return delegate.containsValue(value); + } + + @Override + public CompiledRoot get(Object key) { + return key instanceof URI ? delegate.get(norm((URI) key)) : null; + } + + @Override + public CompiledRoot put(URI key, CompiledRoot value) { + return delegate.put(norm(key), value); + } + + @Override + public CompiledRoot remove(Object key) { + return key instanceof URI ? delegate.remove(norm((URI) key)) : null; + } + + @Override + public void putAll(Map m) { + for (var e : m.entrySet()) delegate.put(norm(e.getKey()), e.getValue()); + } + + @Override + public void clear() { + delegate.clear(); + } + + @Override + public Set> entrySet() { + return delegate.entrySet(); + } + + @Override + public Set keySet() { + return delegate.keySet(); } - } - /// Options for schema compilation - record Options(boolean assertFormats) { + @Override + public Collection values() { + return delegate.values(); + } + } + + // Public constants for common JSON Pointer fragments used in schemas + String SCHEMA_DEFS_POINTER = "#/$defs/"; + String SCHEMA_DEFS_SEGMENT = "/$defs/"; + String SCHEMA_PROPERTIES_SEGMENT = "/properties/"; + String SCHEMA_POINTER_PREFIX = "#/"; + String SCHEMA_POINTER_ROOT = "#"; + + /// JsonSchemaOptions for schema compilation + record JsonSchemaOptions(boolean assertFormats) { /// Default options with format assertion disabled - static final Options DEFAULT = new Options(false); + static final JsonSchemaOptions DEFAULT = new JsonSchemaOptions(false); String summary() { return "assertFormats=" + assertFormats; } } /// Compile-time options controlling remote resolution and caching record CompileOptions( - UriResolver uriResolver, RemoteFetcher remoteFetcher, RefRegistry refRegistry, FetchPolicy fetchPolicy ) { static final CompileOptions DEFAULT = - new CompileOptions(UriResolver.defaultResolver(), RemoteFetcher.disallowed(), RefRegistry.disallowed(), FetchPolicy.defaults()); + new CompileOptions(RemoteFetcher.disallowed(), RefRegistry.disallowed(), FetchPolicy.defaults()); static CompileOptions remoteDefaults(RemoteFetcher fetcher) { Objects.requireNonNull(fetcher, "fetcher"); - return new CompileOptions(UriResolver.defaultResolver(), fetcher, RefRegistry.inMemory(), FetchPolicy.defaults()); - } - - CompileOptions withRemoteFetcher(RemoteFetcher fetcher) { - Objects.requireNonNull(fetcher, "fetcher"); - return new CompileOptions(uriResolver, fetcher, refRegistry, fetchPolicy); - } - - CompileOptions withRefRegistry(RefRegistry registry) { - Objects.requireNonNull(registry, "registry"); - return new CompileOptions(uriResolver, remoteFetcher, registry, fetchPolicy); + return new CompileOptions(fetcher, RefRegistry.inMemory(), FetchPolicy.defaults()); } CompileOptions withFetchPolicy(FetchPolicy policy) { Objects.requireNonNull(policy, "policy"); - return new CompileOptions(uriResolver, remoteFetcher, refRegistry, policy); + return new CompileOptions(remoteFetcher, refRegistry, policy); } - } - - /// URI resolver responsible for base resolution and normalization - interface UriResolver { + /// Delegating fetcher selecting implementation per URI scheme + static final class DelegatingRemoteFetcher implements RemoteFetcher { + private final Map byScheme; - static UriResolver defaultResolver() { - return DefaultUriResolver.INSTANCE; - } + DelegatingRemoteFetcher(RemoteFetcher... fetchers) { + Objects.requireNonNull(fetchers, "fetchers"); + if (fetchers.length == 0) { + throw new IllegalArgumentException("At least one RemoteFetcher required"); + } + Map map = new HashMap<>(); + for (RemoteFetcher fetcher : fetchers) { + Objects.requireNonNull(fetcher, "fetcher"); + String scheme = Objects.requireNonNull(fetcher.scheme(), "fetcher.scheme()").toLowerCase(Locale.ROOT); + if (scheme.isEmpty()) { + throw new IllegalArgumentException("RemoteFetcher scheme must not be empty"); + } + if (map.putIfAbsent(scheme, fetcher) != null) { + throw new IllegalArgumentException("Duplicate RemoteFetcher for scheme: " + scheme); + } + } + this.byScheme = Map.copyOf(map); + } - enum DefaultUriResolver implements UriResolver { - INSTANCE + @Override + public String scheme() { + return "delegating"; + } + @Override + public FetchResult fetch(java.net.URI uri, FetchPolicy policy) { + Objects.requireNonNull(uri, "uri"); + String scheme = Optional.ofNullable(uri.getScheme()) + .map(s -> s.toLowerCase(Locale.ROOT)) + .orElse(""); + RemoteFetcher fetcher = byScheme.get(scheme); + if (fetcher == null) { + LOG.severe(() -> "ERROR: FETCH: " + uri + " - unsupported scheme"); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.POLICY_DENIED, + "No RemoteFetcher registered for scheme: " + scheme); + } + return fetcher.fetch(uri, policy); + } } } /// Remote fetcher SPI for loading external schema documents interface RemoteFetcher { + String scheme(); FetchResult fetch(java.net.URI uri, FetchPolicy policy) throws RemoteResolutionException; static RemoteFetcher disallowed() { - return (uri, policy) -> { - LOG.severe(() -> "ERROR: FETCH: " + uri + " - policy POLICY_DENIED"); - throw new RemoteResolutionException( - Objects.requireNonNull(uri, "uri"), - RemoteResolutionException.Reason.POLICY_DENIED, - "Remote fetching is disabled" - ); + return new RemoteFetcher() { + @Override + public String scheme() { + return ""; + } + + @Override + public FetchResult fetch(java.net.URI uri, FetchPolicy policy) { + LOG.severe(() -> "ERROR: FETCH: " + uri + " - policy POLICY_DENIED"); + throw new RemoteResolutionException( + Objects.requireNonNull(uri, "uri"), + RemoteResolutionException.Reason.POLICY_DENIED, + "Remote fetching is disabled" + ); + } }; } @@ -195,94 +254,6 @@ final class InMemoryRefRegistry implements RefRegistry { } } - /// Fetch policy settings controlling network guardrails - record FetchPolicy( - Set allowedSchemes, - long maxDocumentBytes, - long maxTotalBytes, - java.time.Duration timeout, - int maxRedirects, - int maxDocuments, - int maxDepth - ) { - public FetchPolicy { - Objects.requireNonNull(allowedSchemes, "allowedSchemes"); - Objects.requireNonNull(timeout, "timeout"); - if (allowedSchemes.isEmpty()) { - throw new IllegalArgumentException("allowedSchemes must not be empty"); - } - if (maxDocumentBytes <= 0L) { - throw new IllegalArgumentException("maxDocumentBytes must be > 0"); - } - if (maxTotalBytes <= 0L) { - throw new IllegalArgumentException("maxTotalBytes must be > 0"); - } - if (maxRedirects < 0) { - throw new IllegalArgumentException("maxRedirects must be >= 0"); - } - if (maxDocuments <= 0) { - throw new IllegalArgumentException("maxDocuments must be > 0"); - } - if (maxDepth <= 0) { - throw new IllegalArgumentException("maxDepth must be > 0"); - } - } - - static FetchPolicy defaults() { - return new FetchPolicy(Set.of("http", "https", "file"), 1_048_576L, 8_388_608L, java.time.Duration.ofSeconds(5), 3, 64, 64); - } - - FetchPolicy withAllowedSchemes(Set schemes) { - Objects.requireNonNull(schemes, "schemes"); - return new FetchPolicy(Set.copyOf(schemes), maxDocumentBytes, maxTotalBytes, timeout, maxRedirects, maxDocuments, maxDepth); - } - - FetchPolicy withMaxDocumentBytes() { - return new FetchPolicy(allowedSchemes, 10, maxTotalBytes, timeout, maxRedirects, maxDocuments, maxDepth); - } - - FetchPolicy withTimeout(java.time.Duration newTimeout) { - Objects.requireNonNull(newTimeout, "newTimeout"); - return new FetchPolicy(allowedSchemes, maxDocumentBytes, maxTotalBytes, newTimeout, maxRedirects, maxDocuments, maxDepth); - } - } - - /// Exception signalling remote resolution failures with typed reasons - final class RemoteResolutionException extends RuntimeException { - private final java.net.URI uri; - private final Reason reason; - - RemoteResolutionException(java.net.URI uri, Reason reason, String message) { - super(message); - this.uri = Objects.requireNonNull(uri, "uri"); - this.reason = Objects.requireNonNull(reason, "reason"); - } - - RemoteResolutionException(java.net.URI uri, Reason reason, String message, Throwable cause) { - super(message, cause); - this.uri = Objects.requireNonNull(uri, "uri"); - this.reason = Objects.requireNonNull(reason, "reason"); - } - - public java.net.URI uri() { - return uri; - } - - @SuppressWarnings("ClassEscapesDefinedScope") - public Reason reason() { - return reason; - } - - enum Reason { - NETWORK_ERROR, - POLICY_DENIED, - NOT_FOUND, - POINTER_MISSING, - PAYLOAD_TOO_LARGE, - TIMEOUT - } - } - /// Factory method to create schema from JSON Schema document /// /// @param schemaJson JSON Schema document as JsonValue @@ -291,76 +262,67 @@ enum Reason { static JsonSchema compile(JsonValue schemaJson) { Objects.requireNonNull(schemaJson, "schemaJson"); LOG.fine(() -> "compile: Starting schema compilation with default options, schema type: " + schemaJson.getClass().getSimpleName()); - JsonSchema result = compile(schemaJson, Options.DEFAULT, CompileOptions.DEFAULT); + JsonSchema result = compile(URI.create("urn:inmemory:root"), schemaJson, JsonSchemaOptions.DEFAULT, CompileOptions.DEFAULT); LOG.fine(() -> "compile: Completed schema compilation, result type: " + result.getClass().getSimpleName()); return result; } - /// Factory method to create schema from JSON Schema document with options + /// Factory method to create schema from JSON Schema document with jsonSchemaOptions /// /// @param schemaJson JSON Schema document as JsonValue - /// @param options compilation options + /// @param jsonSchemaOptions compilation jsonSchemaOptions /// @return Immutable JsonSchema instance /// @throws IllegalArgumentException if schema is invalid - static JsonSchema compile(JsonValue schemaJson, Options options) { + static JsonSchema compile(JsonValue schemaJson, JsonSchemaOptions jsonSchemaOptions) { Objects.requireNonNull(schemaJson, "schemaJson"); - Objects.requireNonNull(options, "options"); - LOG.fine(() -> "compile: Starting schema compilation with custom options, schema type: " + schemaJson.getClass().getSimpleName()); - JsonSchema result = compile(schemaJson, options, CompileOptions.DEFAULT); - LOG.fine(() -> "compile: Completed schema compilation with custom options, result type: " + result.getClass().getSimpleName()); + Objects.requireNonNull(jsonSchemaOptions, "jsonSchemaOptions"); + LOG.fine(() -> "compile: Starting schema compilation with custom jsonSchemaOptions, schema type: " + schemaJson.getClass().getSimpleName()); + JsonSchema result = compile(URI.create("urn:inmemory:root"), schemaJson, jsonSchemaOptions, CompileOptions.DEFAULT); + LOG.fine(() -> "compile: Completed schema compilation with custom jsonSchemaOptions, result type: " + result.getClass().getSimpleName()); return result; } - /// Factory method to create schema with explicit compile options - static JsonSchema compile(JsonValue schemaJson, Options options, CompileOptions compileOptions) { - Objects.requireNonNull(schemaJson, "schemaJson"); - Objects.requireNonNull(options, "options"); - Objects.requireNonNull(compileOptions, "compileOptions"); - LOG.fine(() -> "json-schema.compile start doc=" + java.net.URI.create("urn:inmemory:root") + " options=" + options.summary()); - LOG.fine(() -> "compile: Starting schema compilation with full options, schema type: " + schemaJson.getClass().getSimpleName() + - ", options.assertFormats=" + options.assertFormats() + ", compileOptions.remoteFetcher=" + compileOptions.remoteFetcher().getClass().getSimpleName()); - LOG.fine(() -> "compile: fetch policy allowedSchemes=" + compileOptions.fetchPolicy().allowedSchemes()); - - // Early policy enforcement for root-level remote $ref to avoid unnecessary work - if (schemaJson instanceof JsonObject rootObj) { - JsonValue refVal = rootObj.members().get("$ref"); - if (refVal instanceof JsonString refStr) { - try { - java.net.URI refUri = java.net.URI.create(refStr.value()); - String scheme = refUri.getScheme(); - if (scheme != null && !compileOptions.fetchPolicy().allowedSchemes().contains(scheme)) { - throw new RemoteResolutionException(refUri, RemoteResolutionException.Reason.POLICY_DENIED, - "Scheme not allowed by policy: " + refUri); - } - } catch (IllegalArgumentException ignore) { - // Not a URI, ignore - normal compilation will handle it - } - } - } + /// Factory method to create schema with explicit compile jsonSchemaOptions + /// @param doc URI for the root schema document (used for $id resolution and remote $ref) + /// @param schemaJson Parsed JSON Schema document as JsonValue + /// @param jsonSchemaOptions compilation jsonSchemaOptions + /// @param compileOptions compilation compileOptions + static JsonSchema compile(URI doc, JsonValue schemaJson, JsonSchemaOptions jsonSchemaOptions, CompileOptions compileOptions) { + Objects.requireNonNull(doc, "initialContext must not be null"); + Objects.requireNonNull(schemaJson, "schemaJson must not be null"); + Objects.requireNonNull(jsonSchemaOptions, "jsonSchemaOptions must not be null"); + Objects.requireNonNull(compileOptions, "compileOptions must not be null"); + LOG.fine(() -> "JsonSchema.compile start doc="+ doc + + ", jsonSchemaOptions=" + jsonSchemaOptions.summary() + + ", schema type: " + schemaJson.getClass().getSimpleName() + + ", jsonSchemaOptions.assertFormats=" + jsonSchemaOptions.assertFormats() + + ", compileOptions.remoteFetcher=" + compileOptions.remoteFetcher().getClass().getSimpleName() + + ", fetch policy allowedSchemes=" + compileOptions.fetchPolicy().allowedSchemes()); // Placeholder context (not used post-compile; schemas embed resolver contexts during build) - ResolverContext context = initResolverContext(java.net.URI.create("urn:inmemory:root"), schemaJson, compileOptions); - LOG.fine(() -> "compile: Created resolver context with roots.size=0, base uri: " + java.net.URI.create("urn:inmemory:root")); + Map emptyRoots = new LinkedHashMap<>(); + Map emptyPointerIndex = new LinkedHashMap<>(); + ResolverContext context = new ResolverContext(emptyRoots, emptyPointerIndex, AnySchema.INSTANCE); // Compile using work-stack architecture – contexts are attached once while compiling CompiledRegistry registry = compileWorkStack( schemaJson, - java.net.URI.create("urn:inmemory:root"), + doc, context, - options, + jsonSchemaOptions, compileOptions ); JsonSchema result = registry.entry().schema(); final int rootCount = registry.roots().size(); // Compile-time validation for root-level remote $ref pointer existence - if (result instanceof RefSchema ref) { - if (ref.refToken() instanceof RefToken.RemoteRef remoteRef) { + if (result instanceof RefSchema(RefToken refToken, ResolverContext resolverContext)) { + if (refToken instanceof RefToken.RemoteRef remoteRef) { String frag = remoteRef.pointer(); - if (frag != null && !frag.isEmpty()) { + if (!frag.isEmpty()) { try { // Attempt resolution now via the ref's own context to surface POINTER_MISSING during compile - ref.resolverContext().resolve(ref.refToken()); + resolverContext.resolve(refToken); } catch (IllegalArgumentException e) { throw new RemoteResolutionException( remoteRef.targetUri(), @@ -377,58 +339,23 @@ static JsonSchema compile(JsonValue schemaJson, Options options, CompileOptions return result; } - /// Normalize URI for dedup correctness - static java.net.URI normalizeUri(java.net.URI baseUri, String refString) { - LOG.fine(() -> "normalizeUri: entry with base=" + baseUri + ", refString=" + refString); - LOG.finest(() -> "normalizeUri: baseUri object=" + baseUri + ", scheme=" + baseUri.getScheme() + ", host=" + baseUri.getHost() + ", path=" + baseUri.getPath()); - try { - java.net.URI refUri = java.net.URI.create(refString); - LOG.finest(() -> "normalizeUri: created refUri=" + refUri + ", scheme=" + refUri.getScheme() + ", host=" + refUri.getHost() + ", path=" + refUri.getPath()); - java.net.URI resolved = baseUri.resolve(refUri); - LOG.finest(() -> "normalizeUri: resolved URI=" + resolved + ", scheme=" + resolved.getScheme() + ", host=" + resolved.getHost() + ", path=" + resolved.getPath()); - java.net.URI normalized = resolved.normalize(); - LOG.finer(() -> "normalizeUri: normalized result=" + normalized); - LOG.finest(() -> "normalizeUri: final normalized URI=" + normalized + ", scheme=" + normalized.getScheme() + ", host=" + normalized.getHost() + ", path=" + normalized.getPath()); - return normalized; - } catch (IllegalArgumentException e) { - LOG.severe(() -> "ERROR: SCHEMA: normalizeUri failed ref=" + refString + " base=" + baseUri); - throw new IllegalArgumentException("Invalid URI reference: " + refString); - } - } - - /// Initialize resolver context for compile-time - static ResolverContext initResolverContext(java.net.URI initialUri, JsonValue initialJson, CompileOptions compileOptions) { - LOG.fine(() -> "initResolverContext: created context for initialUri=" + initialUri); - LOG.finest(() -> "initResolverContext: initialJson object=" + initialJson + ", type=" + initialJson.getClass().getSimpleName() + ", toString=" + initialJson); - LOG.finest(() -> "initResolverContext: compileOptions object=" + compileOptions + ", remoteFetcher=" + compileOptions.remoteFetcher().getClass().getSimpleName()); - Map emptyRoots = new LinkedHashMap<>(); - Map emptyPointerIndex = new LinkedHashMap<>(); - ResolverContext context = new ResolverContext(emptyRoots, emptyPointerIndex, AnySchema.INSTANCE); - LOG.finest(() -> "initResolverContext: created context object=" + context + ", roots.size=" + context.roots().size() + ", localPointerIndex.size=" + context.localPointerIndex().size()); - return context; - } - /// Core work-stack compilation loop static CompiledRegistry compileWorkStack(JsonValue initialJson, java.net.URI initialUri, ResolverContext context, - Options options, + JsonSchemaOptions jsonSchemaOptions, CompileOptions compileOptions) { LOG.fine(() -> "compileWorkStack: starting work-stack loop with initialUri=" + initialUri); - LOG.finest(() -> "compileWorkStack: initialJson object=" + initialJson + ", type=" + initialJson.getClass().getSimpleName() + ", content=" + initialJson); - LOG.finest(() -> "compileWorkStack: initialUri object=" + initialUri + ", scheme=" + initialUri.getScheme() + ", host=" + initialUri.getHost() + ", path=" + initialUri.getPath()); + LOG.finest(() -> "compileWorkStack: initialJson object=" + initialJson + ", type=" + initialJson.getClass().getSimpleName() + ", content=" + initialJson + + ", initialUri object=" + initialUri + ", scheme=" + initialUri.getScheme() + ", host=" + initialUri.getHost() + ", path=" + initialUri.getPath()); // Work stack (LIFO) for documents to compile Deque workStack = new ArrayDeque<>(); Map built = new NormalizedUriMap(new LinkedHashMap<>()); Set active = new HashSet<>(); - Map parentMap = new HashMap<>(); - - LOG.finest(() -> "compileWorkStack: initialized workStack=" + workStack + ", built=" + built + ", active=" + active); // Push initial document workStack.push(initialUri); - LOG.finer(() -> "compileWorkStack: pushed initial URI to work stack: " + initialUri); LOG.finest(() -> "compileWorkStack: workStack after push=" + workStack + ", contents=" + workStack.stream().map(Object::toString).collect(java.util.stream.Collectors.joining(", ", "[", "]"))); int iterationCount = 0; @@ -438,21 +365,9 @@ static CompiledRegistry compileWorkStack(JsonValue initialJson, final int workStackSize = workStack.size(); final int builtSize = built.size(); final int activeSize = active.size(); - StructuredLog.fine(LOG, "compileWorkStack.iteration", - "iter", finalIterationCount, - "workStack", workStackSize, - "built", builtSize, - "active", activeSize - ); - StructuredLog.finestSampled(LOG, "compileWorkStack.state", 8, - "workStack", workStack.stream().map(Object::toString).collect(java.util.stream.Collectors.joining(",","[","]")), - "builtKeys", built.keySet(), - "activeSet", active - ); java.net.URI currentUri = workStack.pop(); - LOG.finer(() -> "compileWorkStack: popped URI from work stack: " + currentUri); - LOG.finest(() -> "compileWorkStack: workStack after pop=" + workStack + ", contents=" + workStack.stream().map(Object::toString).collect(java.util.stream.Collectors.joining(", ", "[", "]"))); + LOG.finer(() -> "compileWorkStack.iteration iter=" + finalIterationCount + " workStack=" + workStackSize + " built=" + builtSize + " active=" + activeSize); // Check for cycles detectAndThrowCycle(active, currentUri, "compile-time remote ref cycle"); @@ -460,32 +375,43 @@ static CompiledRegistry compileWorkStack(JsonValue initialJson, // Skip if already compiled if (built.containsKey(currentUri)) { LOG.finer(() -> "compileWorkStack: URI already compiled, skipping: " + currentUri); - LOG.finest(() -> "compileWorkStack: built map already contains key=" + currentUri); continue; } - final java.net.URI finalCurrentUri = currentUri; - final Map finalBuilt = built; - final Deque finalWorkStack = workStack; - active.add(currentUri); + LOG.finest(() -> "compileWorkStack: added URI to active set, active now=" + active); try { // Fetch document if needed JsonValue documentJson = fetchIfNeeded(currentUri, initialUri, initialJson, context, compileOptions); - LOG.finer(() -> "compileWorkStack: fetched document for URI: " + currentUri + ", json type: " + documentJson.getClass().getSimpleName()); LOG.finest(() -> "compileWorkStack: fetched documentJson object=" + documentJson + ", type=" + documentJson.getClass().getSimpleName() + ", content=" + documentJson); - // Build root schema for this document - JsonSchema rootSchema = buildRoot(documentJson, currentUri, context, (refToken) -> { - LOG.finest(() -> "compileWorkStack: discovered ref token object=" + refToken + ", class=" + refToken.getClass().getSimpleName()); - if (refToken instanceof RefToken.RemoteRef remoteRef) { - LOG.finest(() -> "compileWorkStack: processing RemoteRef object=" + remoteRef + ", base=" + remoteRef.baseUri() + ", target=" + remoteRef.targetUri()); - java.net.URI targetDocUri = normalizeUri(finalCurrentUri, remoteRef.targetUri().toString()); - boolean scheduled = scheduleRemoteIfUnseen(finalWorkStack, finalBuilt, parentMap, finalCurrentUri, targetDocUri); - LOG.finer(() -> "compileWorkStack: remote ref scheduled=" + scheduled + ", target=" + targetDocUri); - } - }, built, options, compileOptions); + // Use the new MVF compileBundle method that properly handles remote refs + CompilationBundle bundle = SchemaCompiler.compileBundle( + documentJson, + jsonSchemaOptions, + compileOptions + ); + + // Get the compiled schema from the bundle + JsonSchema rootSchema = bundle.entry().schema(); + LOG.finest(() -> "buildRoot: compiled schema object=" + rootSchema + ", class=" + rootSchema.getClass().getSimpleName()); + + // Register all compiled roots from the bundle into the global built map + LOG.finest(() -> "buildRoot: registering " + bundle.all().size() + " compiled roots from bundle into global registry"); + for (CompiledRoot compiledRoot : bundle.all()) { + URI rootUri = compiledRoot.docUri(); + LOG.finest(() -> "buildRoot: registering compiled root for URI: " + rootUri); + built.put(rootUri, compiledRoot); + LOG.fine(() -> "buildRoot: registered compiled root for URI: " + rootUri); + } + + LOG.fine(() -> "buildRoot: built registry now has " + built.size() + " roots: " + built.keySet()); + + // Process any discovered refs from the compilation + // The compileBundle method should have already processed remote refs through the work stack + LOG.finer(() -> "buildRoot: MVF compilation completed, work stack processed remote refs"); + LOG.finer(() -> "buildRoot: completed for docUri=" + currentUri + ", schema type=" + rootSchema.getClass().getSimpleName()); LOG.finest(() -> "compileWorkStack: built rootSchema object=" + rootSchema + ", class=" + rootSchema.getClass().getSimpleName()); } finally { active.remove(currentUri); @@ -495,7 +421,7 @@ static CompiledRegistry compileWorkStack(JsonValue initialJson, // Freeze roots into immutable registry (preserve entry root as initialUri) CompiledRegistry registry = freezeRoots(built, initialUri); - StructuredLog.fine(LOG, "compileWorkStack.done", "roots", registry.roots().size()); + LOG.fine(() -> "compileWorkStack.done roots=" + registry.roots().size()); LOG.finest(() -> "compileWorkStack: final registry object=" + registry + ", entry=" + registry.entry() + ", roots.size=" + registry.roots().size()); return registry; } @@ -507,125 +433,45 @@ static JsonValue fetchIfNeeded(java.net.URI docUri, ResolverContext context, CompileOptions compileOptions) { LOG.fine(() -> "fetchIfNeeded: docUri=" + docUri + ", initialUri=" + initialUri); - LOG.finest(() -> "fetchIfNeeded: docUri object=" + docUri + ", scheme=" + docUri.getScheme() + ", host=" + docUri.getHost() + ", path=" + docUri.getPath()); - LOG.finest(() -> "fetchIfNeeded: initialUri object=" + initialUri + ", scheme=" + initialUri.getScheme() + ", host=" + initialUri.getHost() + ", path=" + initialUri.getPath()); - LOG.finest(() -> "fetchIfNeeded: initialJson object=" + initialJson + ", type=" + initialJson.getClass().getSimpleName() + ", content=" + initialJson); - LOG.finest(() -> "fetchIfNeeded: context object=" + context + ", roots.size=" + context.roots().size() + ", localPointerIndex.size=" + context.localPointerIndex().size()); + LOG.finest(() -> "fetchIfNeeded: docUri object=" + docUri + ", scheme=" + docUri.getScheme() + ", host=" + docUri.getHost() + ", path=" + docUri.getPath() + + ", initialUri object=" + initialUri + ", scheme=" + initialUri.getScheme() + ", host=" + initialUri.getHost() + ", path=" + initialUri.getPath() + + ", initialJson object=" + initialJson + ", type=" + initialJson.getClass().getSimpleName() + ", content=" + initialJson + + ", context object=" + context + ", roots.size=" + context.roots().size() + ", localPointerIndex.size=" + context.localPointerIndex().size()); if (docUri.equals(initialUri)) { LOG.finer(() -> "fetchIfNeeded: using initial JSON for primary document"); - LOG.finest(() -> "fetchIfNeeded: returning initialJson object=" + initialJson); return initialJson; } // MVF: Fetch remote document using RemoteFetcher from compile options LOG.finer(() -> "fetchIfNeeded: fetching remote document: " + docUri); - try { - // Get the base URI without fragment for document fetching - String fragment = docUri.getFragment(); - java.net.URI docUriWithoutFragment = fragment != null ? - java.net.URI.create(docUri.toString().substring(0, docUri.toString().indexOf('#'))) : - docUri; - - LOG.finest(() -> "fetchIfNeeded: document URI without fragment: " + docUriWithoutFragment); - - // Enforce allowed schemes - String scheme = docUriWithoutFragment.getScheme(); - if (scheme == null || !compileOptions.fetchPolicy().allowedSchemes().contains(scheme)) { - throw new RemoteResolutionException( - docUriWithoutFragment, - RemoteResolutionException.Reason.POLICY_DENIED, - "Scheme not allowed by policy: " + scheme - ); - } - - // Prefer a local file mapping for tests when using file:// URIs - java.net.URI fetchUri = docUriWithoutFragment; - if ("file".equalsIgnoreCase(scheme)) { - String base = System.getProperty("json.schema.test.resources", "src/test/resources"); - String path = fetchUri.getPath(); - if (path != null && path.startsWith("/")) path = path.substring(1); - java.nio.file.Path abs = java.nio.file.Paths.get(base, path).toAbsolutePath(); - java.net.URI alt = abs.toUri(); - fetchUri = alt; - LOG.fine(() -> "fetchIfNeeded: Using file mapping for fetch: " + alt + " (original=" + docUriWithoutFragment + ")"); - } - - // Fetch via provided RemoteFetcher to ensure consistent policy/normalization - RemoteFetcher.FetchResult fetchResult; - try { - fetchResult = compileOptions.remoteFetcher().fetch(fetchUri, compileOptions.fetchPolicy()); - } catch (RemoteResolutionException e1) { - // On mapping miss, retry original URI once - if (!fetchUri.equals(docUriWithoutFragment)) { - fetchResult = compileOptions.remoteFetcher().fetch(docUriWithoutFragment, compileOptions.fetchPolicy()); - } else { - throw e1; - } - } - JsonValue fetchedDocument = fetchResult.document(); - - LOG.fine(() -> "fetchIfNeeded: successfully fetched remote document: " + docUriWithoutFragment + ", document type: " + fetchedDocument.getClass().getSimpleName()); - LOG.finest(() -> "fetchIfNeeded: returning fetched document object=" + fetchedDocument + ", type=" + fetchedDocument.getClass().getSimpleName() + ", content=" + fetchedDocument); - return fetchedDocument; - - } catch (Exception e) { - // Network failures are logged by the fetcher; suppress here to avoid duplication - throw new RemoteResolutionException(docUri, RemoteResolutionException.Reason.NETWORK_ERROR, - "Failed to fetch remote document: " + docUri, e); - } - } - - - - /// Build root schema for a document - static JsonSchema buildRoot(JsonValue documentJson, - java.net.URI docUri, - ResolverContext context, - java.util.function.Consumer onRefDiscovered, - Map built, - Options options, - CompileOptions compileOptions) { - LOG.fine(() -> "buildRoot: entry for docUri=" + docUri); - LOG.finer(() -> "buildRoot: document type=" + documentJson.getClass().getSimpleName()); - LOG.finest(() -> "buildRoot: documentJson object=" + documentJson + ", type=" + documentJson.getClass().getSimpleName() + ", content=" + documentJson); - LOG.finest(() -> "buildRoot: docUri object=" + docUri + ", scheme=" + docUri.getScheme() + ", host=" + docUri.getHost() + ", path=" + docUri.getPath()); - LOG.finest(() -> "buildRoot: context object=" + context + ", roots.size=" + context.roots().size() + ", localPointerIndex.size=" + context.localPointerIndex().size()); - LOG.finest(() -> "buildRoot: onRefDiscovered consumer=" + onRefDiscovered); - - // MVF: Use SchemaCompiler.compileBundle to properly integrate with work-stack architecture - // This ensures remote refs are discovered and scheduled properly - LOG.finer(() -> "buildRoot: using MVF compileBundle for proper work-stack integration"); - - // Use the new MVF compileBundle method that properly handles remote refs - CompilationBundle bundle = SchemaCompiler.compileBundle( - documentJson, - options, - compileOptions - ); - - // Get the compiled schema from the bundle - JsonSchema schema = bundle.entry().schema(); - LOG.finest(() -> "buildRoot: compiled schema object=" + schema + ", class=" + schema.getClass().getSimpleName()); - - // Register all compiled roots from the bundle into the global built map - LOG.finest(() -> "buildRoot: registering " + bundle.all().size() + " compiled roots from bundle into global registry"); - for (CompiledRoot compiledRoot : bundle.all()) { - java.net.URI rootUri = compiledRoot.docUri(); - LOG.finest(() -> "buildRoot: registering compiled root for URI: " + rootUri); - built.put(rootUri, compiledRoot); - LOG.fine(() -> "buildRoot: registered compiled root for URI: " + rootUri); + // Get the base URI without fragment for document fetching + String fragment = docUri.getFragment(); + java.net.URI docUriWithoutFragment = fragment != null ? + java.net.URI.create(docUri.toString().substring(0, docUri.toString().indexOf('#'))) : + docUri; + + LOG.finest(() -> "fetchIfNeeded: document URI without fragment: " + docUriWithoutFragment); + + // Enforce allowed schemes + String scheme = docUriWithoutFragment.getScheme(); + if (scheme == null || !compileOptions.fetchPolicy().allowedSchemes().contains(scheme)) { + throw new RemoteResolutionException( + docUriWithoutFragment, + RemoteResolutionException.Reason.POLICY_DENIED, + "Scheme not allowed by policy: " + scheme + ); } - LOG.fine(() -> "buildRoot: built registry now has " + built.size() + " roots: " + built.keySet()); + RemoteFetcher.FetchResult fetchResult = + compileOptions.remoteFetcher().fetch(docUriWithoutFragment, compileOptions.fetchPolicy()); + JsonValue fetchedDocument = fetchResult.document(); - // Process any discovered refs from the compilation - // The compileBundle method should have already processed remote refs through the work stack - LOG.finer(() -> "buildRoot: MVF compilation completed, work stack processed remote refs"); - LOG.finer(() -> "buildRoot: completed for docUri=" + docUri + ", schema type=" + schema.getClass().getSimpleName()); - return schema; + LOG.finer(() -> "fetchIfNeeded: successfully fetched remote document: " + docUriWithoutFragment + ", document type: " + fetchedDocument.getClass().getSimpleName()); + return fetchedDocument; } + /// Tag $ref token as LOCAL or REMOTE sealed interface RefToken permits RefToken.LocalRef, RefToken.RemoteRef { @@ -650,70 +496,9 @@ public String pointer() { } } - /// Schedule remote document for compilation if not seen before - static boolean scheduleRemoteIfUnseen(Deque workStack, - Map built, - Map parentMap, - java.net.URI currentDocUri, - java.net.URI targetDocUri) { - LOG.finer(() -> "scheduleRemoteIfUnseen: target=" + targetDocUri + ", workStack.size=" + workStack.size() + ", built.size=" + built.size()); - LOG.finest(() -> "scheduleRemoteIfUnseen: targetDocUri object=" + targetDocUri + ", scheme=" + targetDocUri.getScheme() + ", host=" + targetDocUri.getHost() + ", path=" + targetDocUri.getPath()); - LOG.finest(() -> "scheduleRemoteIfUnseen: workStack object=" + workStack + ", contents=" + workStack.stream().map(Object::toString).collect(java.util.stream.Collectors.joining(", ", "[", "]"))); - LOG.finest(() -> "scheduleRemoteIfUnseen: built map object=" + built + ", keys=" + built.keySet() + ", size=" + built.size()); - - // Detect remote cycles by walking parent chain - if (formsRemoteCycle(parentMap, currentDocUri, targetDocUri)) { - String cycleMessage = "ERROR: CYCLE: remote $ref cycle detected current=" + currentDocUri + ", target=" + targetDocUri; - LOG.severe(() -> cycleMessage); - throw new IllegalStateException(cycleMessage); - } - - // Check if already built or already in work stack - boolean alreadyBuilt = built.containsKey(targetDocUri); - boolean inWorkStack = workStack.contains(targetDocUri); - LOG.finest(() -> "scheduleRemoteIfUnseen: alreadyBuilt=" + alreadyBuilt + ", inWorkStack=" + inWorkStack); - - if (alreadyBuilt || inWorkStack) { - LOG.finer(() -> "scheduleRemoteIfUnseen: already seen, skipping"); - LOG.finest(() -> "scheduleRemoteIfUnseen: skipping targetDocUri=" + targetDocUri); - return false; - } - - // Track parent chain for cycle detection before scheduling child - parentMap.putIfAbsent(targetDocUri, currentDocUri); - - // Add to work stack - workStack.push(targetDocUri); - LOG.finer(() -> "scheduleRemoteIfUnseen: scheduled remote document: " + targetDocUri); - LOG.finest(() -> "scheduleRemoteIfUnseen: workStack after push=" + workStack + ", contents=" + workStack.stream().map(Object::toString).collect(java.util.stream.Collectors.joining(", ", "[", "]"))); - return true; - } - - private static boolean formsRemoteCycle(Map parentMap, - java.net.URI currentDocUri, - java.net.URI targetDocUri) { - if (currentDocUri.equals(targetDocUri)) { - return true; - } - - java.net.URI cursor = currentDocUri; - while (cursor != null) { - java.net.URI parent = parentMap.get(cursor); - if (parent == null) { - break; - } - if (parent.equals(targetDocUri)) { - return true; - } - cursor = parent; - } - return false; - } - /// Detect and throw on compile-time cycles static void detectAndThrowCycle(Set active, java.net.URI docUri, String pathTrail) { - LOG.finest(() -> "detectAndThrowCycle: active set=" + active + ", docUri=" + docUri + ", pathTrail='" + pathTrail + "'"); - LOG.finest(() -> "detectAndThrowCycle: docUri object=" + docUri + ", scheme=" + docUri.getScheme() + ", host=" + docUri.getHost() + ", path=" + docUri.getPath()); + LOG.finest(() -> "detectAndThrowCycle: active set=" + active + ", docUri object=" + docUri + ", scheme=" + docUri.getScheme() + ", host=" + docUri.getHost() + ", path=" + docUri.getPath() + ", pathTrail='" + pathTrail + "'"); if (active.contains(docUri)) { String cycleMessage = "ERROR: CYCLE: " + pathTrail + "; doc=" + docUri; LOG.severe(() -> cycleMessage); @@ -724,8 +509,7 @@ static void detectAndThrowCycle(Set active, java.net.URI docUri, S /// Freeze roots into immutable registry static CompiledRegistry freezeRoots(Map built, java.net.URI primaryUri) { - LOG.fine(() -> "freezeRoots: freezing " + built.size() + " compiled roots"); - LOG.finest(() -> "freezeRoots: built map object=" + built + ", keys=" + built.keySet() + ", values=" + built.values() + ", size=" + built.size()); + LOG.finer(() -> "freezeRoots: freezing " + built.size() + " compiled roots, built map object=" + built + ", keys=" + built.keySet() + ", values=" + built.values()); // Find entry root by the provided primary URI CompiledRoot entryRoot = built.get(primaryUri); @@ -742,8 +526,7 @@ static CompiledRegistry freezeRoots(Map built, java. final java.net.URI primaryResolved = entryRoot.docUri(); final java.net.URI entryDocUri = entryRoot.docUri(); final String entrySchemaType = entryRoot.schema().getClass().getSimpleName(); - LOG.finest(() -> "freezeRoots: entryRoot docUri=" + entryDocUri + ", schemaType=" + entrySchemaType); - LOG.finest(() -> "freezeRoots: primaryUri object=" + primaryResolved + ", scheme=" + primaryResolved.getScheme() + ", host=" + primaryResolved.getHost() + ", path=" + primaryResolved.getPath()); + LOG.finest(() -> "freezeRoots: entryRoot docUri=" + entryDocUri + ", schemaType=" + entrySchemaType + ", primaryUri object=" + primaryResolved + ", scheme=" + primaryResolved.getScheme() + ", host=" + primaryResolved.getHost() + ", path=" + primaryResolved.getPath()); LOG.fine(() -> "freezeRoots: primary root URI: " + primaryResolved); @@ -756,8 +539,6 @@ static CompiledRegistry freezeRoots(Map built, java. return registry; } - - /// Validates JSON document against this schema /// /// @param json JSON value to validate @@ -804,1902 +585,109 @@ default ValidationResult validate(JsonValue json) { /// Internal validation method used by stack-based traversal ValidationResult validateAt(String path, JsonValue json, Deque stack); - /// Object schema with properties, required fields, and constraints - record ObjectSchema( - Map properties, - Set required, - JsonSchema additionalProperties, - Integer minProperties, - Integer maxProperties, - Map patternProperties, - JsonSchema propertyNames, - Map> dependentRequired, - Map dependentSchemas - ) implements JsonSchema { + /// Validation result types + record ValidationResult(boolean valid, List errors) { + public static ValidationResult success() { + return new ValidationResult(true, List.of()); + } - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - if (!(json instanceof JsonObject obj)) { - return ValidationResult.failure(List.of( - new ValidationError(path, "Expected object") - )); - } + public static ValidationResult failure(List errors) { + return new ValidationResult(false, errors); + } + } - List errors = new ArrayList<>(); + record ValidationError(String path, String message) { + } - // Check property count constraints - int propCount = obj.members().size(); - if (minProperties != null && propCount < minProperties) { - errors.add(new ValidationError(path, "Too few properties: expected at least " + minProperties)); - } - if (maxProperties != null && propCount > maxProperties) { - errors.add(new ValidationError(path, "Too many properties: expected at most " + maxProperties)); - } + /// Validation frame for stack-based processing + record ValidationFrame(String path, JsonSchema schema, JsonValue json) { + } - // Check required properties - for (String reqProp : required) { - if (!obj.members().containsKey(reqProp)) { - errors.add(new ValidationError(path, "Missing required property: " + reqProp)); - } - } + /// Internal key used to detect and break validation cycles + record ValidationKey(JsonSchema schema, JsonValue json, String path) { - // Handle dependentRequired - if (dependentRequired != null) { - for (var entry : dependentRequired.entrySet()) { - String triggerProp = entry.getKey(); - Set requiredDeps = entry.getValue(); - - // If trigger property is present, check all dependent properties - if (obj.members().containsKey(triggerProp)) { - for (String depProp : requiredDeps) { - if (!obj.members().containsKey(depProp)) { - errors.add(new ValidationError(path, "Property '" + triggerProp + "' requires property '" + depProp + "' (dependentRequired)")); - } - } - } + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; } - } - - // Handle dependentSchemas - if (dependentSchemas != null) { - for (var entry : dependentSchemas.entrySet()) { - String triggerProp = entry.getKey(); - JsonSchema depSchema = entry.getValue(); - - // If trigger property is present, apply the dependent schema - if (obj.members().containsKey(triggerProp)) { - if (depSchema == BooleanSchema.FALSE) { - errors.add(new ValidationError(path, "Property '" + triggerProp + "' forbids object unless its dependent schema is satisfied (dependentSchemas=false)")); - } else if (depSchema != BooleanSchema.TRUE) { - // Apply the dependent schema to the entire object - stack.push(new ValidationFrame(path, depSchema, json)); - } - } + if (!(obj instanceof ValidationKey(JsonSchema schema1, JsonValue json1, String path1))) { + return false; } + return this.schema == schema1 && + this.json == json1 && + Objects.equals(this.path, path1); } - // Validate property names if specified - if (propertyNames != null) { - for (String propName : obj.members().keySet()) { - String namePath = path.isEmpty() ? propName : path + "." + propName; - JsonValue nameValue = Json.parse("\"" + propName + "\""); - ValidationResult nameResult = propertyNames.validateAt(namePath + "(name)", nameValue, stack); - if (!nameResult.valid()) { - errors.add(new ValidationError(namePath, "Property name violates propertyNames")); - } - } + @Override + public int hashCode() { + int result = System.identityHashCode(schema); + result = 31 * result + System.identityHashCode(json); + result = 31 * result + (path != null ? path.hashCode() : 0); + return result; } + } - // Validate each property with correct precedence - for (var entry : obj.members().entrySet()) { - String propName = entry.getKey(); - JsonValue propValue = entry.getValue(); - String propPath = path.isEmpty() ? propName : path + "." + propName; - - // Track if property was handled by properties or patternProperties - boolean handledByProperties = false; - boolean handledByPattern = false; - - // 1. Check if property is in properties (highest precedence) - JsonSchema propSchema = properties.get(propName); - if (propSchema != null) { - stack.push(new ValidationFrame(propPath, propSchema, propValue)); - handledByProperties = true; - } - - // 2. Check all patternProperties that match this property name - if (patternProperties != null) { - for (var patternEntry : patternProperties.entrySet()) { - Pattern pattern = patternEntry.getKey(); - JsonSchema patternSchema = patternEntry.getValue(); - if (pattern.matcher(propName).find()) { // unanchored find semantics - stack.push(new ValidationFrame(propPath, patternSchema, propValue)); - handledByPattern = true; - } - } - } + /// Compiled registry holding multiple schema roots + record CompiledRegistry( + java.util.Map roots, + CompiledRoot entry + ) { + } - // 3. If property wasn't handled by properties or patternProperties, apply additionalProperties - if (!handledByProperties && !handledByPattern) { - if (additionalProperties != null) { - if (additionalProperties == BooleanSchema.FALSE) { - // Handle additionalProperties: false - reject unmatched properties - errors.add(new ValidationError(propPath, "Additional properties not allowed")); - } else if (additionalProperties != BooleanSchema.TRUE) { - // Apply the additionalProperties schema (not true/false boolean schemas) - stack.push(new ValidationFrame(propPath, additionalProperties, propValue)); - } - } - } - } + /// Compilation result for a single document + record CompilationResult(JsonSchema schema, java.util.Map pointerIndex) { + } - return errors.isEmpty() ? ValidationResult.success() : ValidationResult.failure(errors); - } + /// Immutable compiled document + record CompiledRoot(java.net.URI docUri, JsonSchema schema, java.util.Map pointerIndex) { } - /// Array schema with item validation and constraints - record ArraySchema( - JsonSchema items, - Integer minItems, - Integer maxItems, - Boolean uniqueItems, - // NEW: Pack 2 array features - List prefixItems, - JsonSchema contains, - Integer minContains, - Integer maxContains - ) implements JsonSchema { + /// Work item to load/compile a document + record WorkItem(java.net.URI docUri) { + } - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - if (!(json instanceof JsonArray arr)) { - return ValidationResult.failure(List.of( - new ValidationError(path, "Expected array") - )); - } + /// Compilation output bundle + record CompilationBundle( + CompiledRoot entry, // the first/root doc + java.util.List all // entry + any remotes (for now it'll just be [entry]) + ) { + } - List errors = new ArrayList<>(); - int itemCount = arr.values().size(); + /// Resolver context for validation-time $ref resolution + record ResolverContext( + java.util.Map roots, + java.util.Map localPointerIndex, // for *entry* root only (for now) + JsonSchema rootSchema + ) { + /// Resolve a RefToken to the target schema + JsonSchema resolve(RefToken token) { + LOG.finest(() -> "ResolverContext.resolve: " + token); + LOG.fine(() -> "ResolverContext.resolve: roots.size=" + roots.size() + ", localPointerIndex.size=" + localPointerIndex.size()); - // Check item count constraints - if (minItems != null && itemCount < minItems) { - errors.add(new ValidationError(path, "Too few items: expected at least " + minItems)); - } - if (maxItems != null && itemCount > maxItems) { - errors.add(new ValidationError(path, "Too many items: expected at most " + maxItems)); - } + if (token instanceof RefToken.LocalRef(String pointerOrAnchor)) { - // Check uniqueness if required (structural equality) - if (uniqueItems != null && uniqueItems) { - Set seen = new HashSet<>(); - for (JsonValue item : arr.values()) { - String canonicalKey = canonicalize(item); - if (!seen.add(canonicalKey)) { - errors.add(new ValidationError(path, "Array items must be unique")); - break; - } + // Handle root reference + if (pointerOrAnchor.equals(SCHEMA_POINTER_ROOT) || pointerOrAnchor.isEmpty()) { + return rootSchema; } - } - // Validate prefixItems + items (tuple validation) - if (prefixItems != null && !prefixItems.isEmpty()) { - // Validate prefix items - fail if not enough items for all prefix positions - for (int i = 0; i < prefixItems.size(); i++) { - if (i >= itemCount) { - errors.add(new ValidationError(path, "Array has too few items for prefixItems validation")); - break; - } - String itemPath = path + "[" + i + "]"; - // Validate prefix items immediately to capture errors - ValidationResult prefixResult = prefixItems.get(i).validateAt(itemPath, arr.values().get(i), stack); - if (!prefixResult.valid()) { - errors.addAll(prefixResult.errors()); - } - } - // Validate remaining items with items schema if present - if (items != null && items != AnySchema.INSTANCE) { - for (int i = prefixItems.size(); i < itemCount; i++) { - String itemPath = path + "[" + i + "]"; - stack.push(new ValidationFrame(itemPath, items, arr.values().get(i))); - } - } - } else if (items != null && items != AnySchema.INSTANCE) { - // Original items validation (no prefixItems) - int index = 0; - for (JsonValue item : arr.values()) { - String itemPath = path + "[" + index + "]"; - stack.push(new ValidationFrame(itemPath, items, item)); - index++; + JsonSchema target = localPointerIndex.get(pointerOrAnchor); + if (target == null) { + throw new IllegalArgumentException("Unresolved $ref: " + pointerOrAnchor); } + return target; } - // Validate contains / minContains / maxContains - if (contains != null) { - int matchCount = 0; - for (JsonValue item : arr.values()) { - // Create isolated validation to check if item matches contains schema - Deque tempStack = new ArrayDeque<>(); - List tempErrors = new ArrayList<>(); - tempStack.push(new ValidationFrame("", contains, item)); - - while (!tempStack.isEmpty()) { - ValidationFrame frame = tempStack.pop(); - ValidationResult result = frame.schema().validateAt(frame.path(), frame.json(), tempStack); - if (!result.valid()) { - tempErrors.addAll(result.errors()); - } - } - - if (tempErrors.isEmpty()) { - matchCount++; - } - } - - int min = (minContains != null ? minContains : 1); // default min=1 - int max = (maxContains != null ? maxContains : Integer.MAX_VALUE); // default max=∞ + if (token instanceof RefToken.RemoteRef remoteRef) { + LOG.finer(() -> "ResolverContext.resolve: RemoteRef " + remoteRef.targetUri()); - if (matchCount < min) { - errors.add(new ValidationError(path, "Array must contain at least " + min + " matching element(s)")); - } else if (matchCount > max) { - errors.add(new ValidationError(path, "Array must contain at most " + max + " matching element(s)")); - } - } - - return errors.isEmpty() ? ValidationResult.success() : ValidationResult.failure(errors); - } - } - - /// String schema with length, pattern, and enum constraints - record StringSchema( - Integer minLength, - Integer maxLength, - Pattern pattern, - FormatValidator formatValidator, - boolean assertFormats - ) implements JsonSchema { - - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - if (!(json instanceof JsonString str)) { - return ValidationResult.failure(List.of( - new ValidationError(path, "Expected string") - )); - } - - String value = str.value(); - List errors = new ArrayList<>(); - - // Check length constraints - int length = value.length(); - if (minLength != null && length < minLength) { - errors.add(new ValidationError(path, "String too short: expected at least " + minLength + " characters")); - } - if (maxLength != null && length > maxLength) { - errors.add(new ValidationError(path, "String too long: expected at most " + maxLength + " characters")); - } - - // Check pattern (unanchored matching - uses find() instead of matches()) - if (pattern != null && !pattern.matcher(value).find()) { - errors.add(new ValidationError(path, "Pattern mismatch")); - } - - // Check format validation (only when format assertion is enabled) - if (formatValidator != null && assertFormats) { - if (!formatValidator.test(value)) { - String formatName = formatValidator instanceof Format format ? format.name().toLowerCase().replace("_", "-") : "unknown"; - errors.add(new ValidationError(path, "Invalid format '" + formatName + "'")); - } - } - - return errors.isEmpty() ? ValidationResult.success() : ValidationResult.failure(errors); - } - } - - /// Number schema with range and multiple constraints - record NumberSchema( - BigDecimal minimum, - BigDecimal maximum, - BigDecimal multipleOf, - Boolean exclusiveMinimum, - Boolean exclusiveMaximum - ) implements JsonSchema { - - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - LOG.finest(() -> "NumberSchema.validateAt: " + json + " minimum=" + minimum + " maximum=" + maximum); - if (!(json instanceof JsonNumber num)) { - return ValidationResult.failure(List.of( - new ValidationError(path, "Expected number") - )); - } - - BigDecimal value = num.toNumber() instanceof BigDecimal bd ? bd : BigDecimal.valueOf(num.toNumber().doubleValue()); - List errors = new ArrayList<>(); - - // Check minimum - if (minimum != null) { - int comparison = value.compareTo(minimum); - LOG.finest(() -> "NumberSchema.validateAt: value=" + value + " minimum=" + minimum + " comparison=" + comparison); - if (exclusiveMinimum != null && exclusiveMinimum && comparison <= 0) { - errors.add(new ValidationError(path, "Below minimum")); - } else if (comparison < 0) { - errors.add(new ValidationError(path, "Below minimum")); - } - } - - // Check maximum - if (maximum != null) { - int comparison = value.compareTo(maximum); - if (exclusiveMaximum != null && exclusiveMaximum && comparison >= 0) { - errors.add(new ValidationError(path, "Above maximum")); - } else if (comparison > 0) { - errors.add(new ValidationError(path, "Above maximum")); - } - } - - // Check multipleOf - if (multipleOf != null) { - BigDecimal remainder = value.remainder(multipleOf); - if (remainder.compareTo(BigDecimal.ZERO) != 0) { - errors.add(new ValidationError(path, "Not multiple of " + multipleOf)); - } - } - - return errors.isEmpty() ? ValidationResult.success() : ValidationResult.failure(errors); - } - } - - /// Boolean schema - validates boolean values - record BooleanSchema() implements JsonSchema { - /// Singleton instances for boolean sub-schema handling - static final BooleanSchema TRUE = new BooleanSchema(); - static final BooleanSchema FALSE = new BooleanSchema(); - - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - // For boolean subschemas, FALSE always fails, TRUE always passes - if (this == FALSE) { - return ValidationResult.failure(List.of( - new ValidationError(path, "Schema should not match") - )); - } - if (this == TRUE) { - return ValidationResult.success(); - } - // Regular boolean validation for normal boolean schemas - if (!(json instanceof JsonBoolean)) { - return ValidationResult.failure(List.of( - new ValidationError(path, "Expected boolean") - )); - } - return ValidationResult.success(); - } - } - - /// Null schema - always valid for null values - record NullSchema() implements JsonSchema { - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - if (!(json instanceof JsonNull)) { - return ValidationResult.failure(List.of( - new ValidationError(path, "Expected null") - )); - } - return ValidationResult.success(); - } - } - - /// Any schema - accepts all values - record AnySchema() implements JsonSchema { - static final AnySchema INSTANCE = new AnySchema(); - - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - return ValidationResult.success(); - } - } - - /// Reference schema for JSON Schema $ref - record RefSchema(RefToken refToken, ResolverContext resolverContext) implements JsonSchema { - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - LOG.finest(() -> "RefSchema.validateAt: " + refToken + " at path: " + path + " with json=" + json); - LOG.fine(() -> "RefSchema.validateAt: Using resolver context with roots.size=" + resolverContext.roots().size() + - " localPointerIndex.size=" + resolverContext.localPointerIndex().size()); - - // Add detailed logging for remote ref resolution - if (refToken instanceof RefToken.RemoteRef(URI baseUri, URI targetUri)) { - LOG.finest(() -> "RefSchema.validateAt: Attempting to resolve RemoteRef: baseUri=" + baseUri + ", targetUri=" + targetUri); - LOG.finest(() -> "RefSchema.validateAt: Available roots in context: " + resolverContext.roots().keySet()); - } - - JsonSchema target = resolverContext.resolve(refToken); - LOG.finest(() -> "RefSchema.validateAt: Resolved target=" + target); - if (target == null) { - return ValidationResult.failure(List.of(new ValidationError(path, "Unresolvable $ref: " + refToken))); - } - // Stay on the SAME traversal stack (uniform non-recursive execution). - stack.push(new ValidationFrame(path, target, json)); - return ValidationResult.success(); - } - - @Override - public String toString() { - return "RefSchema[" + refToken + "]"; - } - } - - /// AllOf composition - must satisfy all schemas - record AllOfSchema(List schemas) implements JsonSchema { - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - // Push all subschemas onto the stack for validation - for (JsonSchema schema : schemas) { - stack.push(new ValidationFrame(path, schema, json)); - } - return ValidationResult.success(); // Actual results emerge from stack processing - } - } - - /// AnyOf composition - must satisfy at least one schema - record AnyOfSchema(List schemas) implements JsonSchema { - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - List collected = new ArrayList<>(); - boolean anyValid = false; - - for (JsonSchema schema : schemas) { - // Create a separate validation stack for this branch - Deque branchStack = new ArrayDeque<>(); - List branchErrors = new ArrayList<>(); - - LOG.finest(() -> "BRANCH START: " + schema.getClass().getSimpleName()); - branchStack.push(new ValidationFrame(path, schema, json)); - - while (!branchStack.isEmpty()) { - ValidationFrame frame = branchStack.pop(); - ValidationResult result = frame.schema().validateAt(frame.path(), frame.json(), branchStack); - if (!result.valid()) { - branchErrors.addAll(result.errors()); - } - } - - if (branchErrors.isEmpty()) { - anyValid = true; - break; - } - collected.addAll(branchErrors); - LOG.finest(() -> "BRANCH END: " + branchErrors.size() + " errors"); - } - - return anyValid ? ValidationResult.success() : ValidationResult.failure(collected); - } - } - - /// OneOf composition - must satisfy exactly one schema - record OneOfSchema(List schemas) implements JsonSchema { - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - int validCount = 0; - List minimalErrors = null; - - for (JsonSchema schema : schemas) { - // Create a separate validation stack for this branch - Deque branchStack = new ArrayDeque<>(); - List branchErrors = new ArrayList<>(); - - LOG.finest(() -> "ONEOF BRANCH START: " + schema.getClass().getSimpleName()); - branchStack.push(new ValidationFrame(path, schema, json)); - - while (!branchStack.isEmpty()) { - ValidationFrame frame = branchStack.pop(); - ValidationResult result = frame.schema().validateAt(frame.path(), frame.json(), branchStack); - if (!result.valid()) { - branchErrors.addAll(result.errors()); - } - } - - if (branchErrors.isEmpty()) { - validCount++; - } else { - // Track minimal error set for zero-valid case - // Prefer errors that don't start with "Expected" (type mismatches) if possible - // In case of ties, prefer later branches (they tend to be more specific) - if (minimalErrors == null || - (branchErrors.size() < minimalErrors.size()) || - (branchErrors.size() == minimalErrors.size() && - hasBetterErrorType(branchErrors, minimalErrors))) { - minimalErrors = branchErrors; - } - } - LOG.finest(() -> "ONEOF BRANCH END: " + branchErrors.size() + " errors, valid=" + branchErrors.isEmpty()); - } - - // Exactly one must be valid - if (validCount == 1) { - return ValidationResult.success(); - } else if (validCount == 0) { - // Zero valid - return minimal error set - return ValidationResult.failure(minimalErrors != null ? minimalErrors : List.of()); - } else { - // Multiple valid - single error - return ValidationResult.failure(List.of( - new ValidationError(path, "oneOf: multiple schemas matched (" + validCount + ")") - )); - } - } - - private boolean hasBetterErrorType(List newErrors, List currentErrors) { - // Prefer errors that don't start with "Expected" (type mismatches) - boolean newHasTypeMismatch = newErrors.stream().anyMatch(e -> e.message().startsWith("Expected")); - boolean currentHasTypeMismatch = currentErrors.stream().anyMatch(e -> e.message().startsWith("Expected")); - - // If new has type mismatch and current doesn't, current is better (keep current) - return !newHasTypeMismatch || currentHasTypeMismatch; - - // If current has type mismatch and new doesn't, new is better (replace current) - - // If both have type mismatches or both don't, prefer later branches - // This is a simple heuristic - } - } - - /// If/Then/Else conditional schema - record ConditionalSchema(JsonSchema ifSchema, JsonSchema thenSchema, JsonSchema elseSchema) implements JsonSchema { - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - // Step 1 - evaluate IF condition (still needs direct validation) - ValidationResult ifResult = ifSchema.validate(json); - - // Step 2 - choose branch - JsonSchema branch = ifResult.valid() ? thenSchema : elseSchema; - - LOG.finer(() -> String.format( - "Conditional path=%s ifValid=%b branch=%s", - path, ifResult.valid(), - branch == null ? "none" : (ifResult.valid() ? "then" : "else"))); - - // Step 3 - if there's a branch, push it onto the stack for later evaluation - if (branch == null) { - return ValidationResult.success(); // no branch → accept - } - - // NEW: push branch onto SAME stack instead of direct call - stack.push(new ValidationFrame(path, branch, json)); - return ValidationResult.success(); // real result emerges later - } - } - - /// Validation result types - record ValidationResult(boolean valid, List errors) { - public static ValidationResult success() { - return new ValidationResult(true, List.of()); - } - - public static ValidationResult failure(List errors) { - return new ValidationResult(false, errors); - } - } - - record ValidationError(String path, String message) { - } - - /// Validation frame for stack-based processing - record ValidationFrame(String path, JsonSchema schema, JsonValue json) { - } - - /// Internal key used to detect and break validation cycles - final class ValidationKey { - private final JsonSchema schema; - private final JsonValue json; - private final String path; - - ValidationKey(JsonSchema schema, JsonValue json, String path) { - this.schema = schema; - this.json = json; - this.path = path; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (!(obj instanceof ValidationKey other)) { - return false; - } - return this.schema == other.schema && - this.json == other.json && - Objects.equals(this.path, other.path); - } - - @Override - public int hashCode() { - int result = System.identityHashCode(schema); - result = 31 * result + System.identityHashCode(json); - result = 31 * result + (path != null ? path.hashCode() : 0); - return result; - } - } - - /// Canonicalization helper for structural equality in uniqueItems - private static String canonicalize(JsonValue v) { - switch (v) { - case JsonObject o -> { - var keys = new ArrayList<>(o.members().keySet()); - Collections.sort(keys); - var sb = new StringBuilder("{"); - for (int i = 0; i < keys.size(); i++) { - String k = keys.get(i); - if (i > 0) sb.append(','); - sb.append('"').append(escapeJsonString(k)).append("\":").append(canonicalize(o.members().get(k))); - } - return sb.append('}').toString(); - } - case JsonArray a -> { - var sb = new StringBuilder("["); - for (int i = 0; i < a.values().size(); i++) { - if (i > 0) sb.append(','); - sb.append(canonicalize(a.values().get(i))); - } - return sb.append(']').toString(); - } - case JsonString s -> { - return "\"" + escapeJsonString(s.value()) + "\""; - } - case null, default -> { - // numbers/booleans/null: rely on stable toString from the Json* impls - assert v != null; - return v.toString(); - } - } - } - - private static String escapeJsonString(String s) { - if (s == null) return "null"; - StringBuilder result = new StringBuilder(); - for (int i = 0; i < s.length(); i++) { - char ch = s.charAt(i); - switch (ch) { - case '"': - result.append("\\\""); - break; - case '\\': - result.append("\\\\"); - break; - case '\b': - result.append("\\b"); - break; - case '\f': - result.append("\\f"); - break; - case '\n': - result.append("\\n"); - break; - case '\r': - result.append("\\r"); - break; - case '\t': - result.append("\\t"); - break; - default: - if (ch < 0x20 || ch > 0x7e) { - result.append("\\u").append(String.format("%04x", (int) ch)); - } else { - result.append(ch); - } - } - } - return result.toString(); - } - - /// Internal schema compiler - final class SchemaCompiler { - /// Per-compilation session state (no static mutable fields). - private static final class Session { - final Map definitions = new LinkedHashMap<>(); - final Map compiledByPointer = new LinkedHashMap<>(); - final Map rawByPointer = new LinkedHashMap<>(); - final Map parentMap = new LinkedHashMap<>(); - JsonSchema currentRootSchema; - Options currentOptions; - long totalFetchedBytes; - int fetchedDocs; - } - /// Strip any fragment from a URI, returning the base document URI. - private static java.net.URI stripFragment(java.net.URI uri) { - String s = uri.toString(); - int i = s.indexOf('#'); - java.net.URI base = i >= 0 ? java.net.URI.create(s.substring(0, i)) : uri; - return base.normalize(); - } - // removed static mutable state; state now lives in Session - - private static void trace(String stage, JsonValue fragment) { - if (LOG.isLoggable(Level.FINER)) { - LOG.finer(() -> - String.format("[%s] %s", stage, fragment.toString())); - } - } - - /// Per-compile carrier for resolver-related state. - private static final class CompileContext { - final Session session; - final Map sharedRoots; - final ResolverContext resolverContext; - final Map localPointerIndex; - final Deque resolutionStack; - final Deque frames = new ArrayDeque<>(); - - CompileContext(Session session, - Map sharedRoots, - ResolverContext resolverContext, - Map localPointerIndex, - Deque resolutionStack) { - this.session = session; - this.sharedRoots = sharedRoots; - this.resolverContext = resolverContext; - this.localPointerIndex = localPointerIndex; - this.resolutionStack = resolutionStack; - } - } - - /// Immutable context frame capturing current document/base/pointer/anchors. - private static final class ContextFrame { - final java.net.URI docUri; - final java.net.URI baseUri; - final String pointer; - final Map anchors; - ContextFrame(java.net.URI docUri, java.net.URI baseUri, String pointer, Map anchors) { - this.docUri = docUri; - this.baseUri = baseUri; - this.pointer = pointer; - this.anchors = anchors == null ? Map.of() : Map.copyOf(anchors); - } - ContextFrame childProperty(String name) { - String escaped = name.replace("~", "~0").replace("/", "~1"); - String nextPtr = pointer.equals("") || pointer.equals(SCHEMA_POINTER_ROOT) ? SCHEMA_POINTER_ROOT + "properties/" + escaped : pointer + "/properties/" + escaped; - return new ContextFrame(docUri, baseUri, nextPtr, anchors); - } - } - - /// JSON Pointer utility for RFC-6901 fragment navigation - static Optional navigatePointer(JsonValue root, String pointer) { - StructuredLog.fine(LOG, "pointer.navigate", "pointer", pointer); - - if (pointer.isEmpty() || pointer.equals(SCHEMA_POINTER_ROOT)) { - return Optional.of(root); - } - - // Remove leading # if present - String path = pointer.startsWith(SCHEMA_POINTER_ROOT) ? pointer.substring(1) : pointer; - if (path.isEmpty()) { - return Optional.of(root); - } - - // Must start with / - if (!path.startsWith("/")) { - return Optional.empty(); - } - - JsonValue current = root; - String[] tokens = path.substring(1).split("/"); - - // Performance warning for deeply nested pointers - if (tokens.length > 50) { - final int tokenCount = tokens.length; - LOG.warning(() -> "PERFORMANCE WARNING: Navigating deeply nested JSON pointer with " + tokenCount + - " segments - possible performance impact"); - } - - for (int i = 0; i < tokens.length; i++) { - if (i > 0 && i % 25 == 0) { - final int segment = i; - final int total = tokens.length; - LOG.warning(() -> "PERFORMANCE WARNING: JSON pointer navigation at segment " + segment + " of " + total); - } - - String token = tokens[i]; - // Unescape ~1 -> / and ~0 -> ~ - String unescaped = token.replace("~1", "/").replace("~0", "~"); - final var currentFinal = current; - final var unescapedFinal = unescaped; - - LOG.finer(() -> "Token: '" + token + "' unescaped: '" + unescapedFinal + "' current: " + currentFinal); - - if (current instanceof JsonObject obj) { - current = obj.members().get(unescaped); - if (current == null) { - LOG.finer(() -> "Property not found: " + unescapedFinal); - return Optional.empty(); - } - } else if (current instanceof JsonArray arr) { - try { - int index = Integer.parseInt(unescaped); - if (index < 0 || index >= arr.values().size()) { - return Optional.empty(); - } - current = arr.values().get(index); - } catch (NumberFormatException e) { - return Optional.empty(); - } - } else { - return Optional.empty(); - } - } - - StructuredLog.fine(LOG, "pointer.found", "pointer", pointer); - return Optional.of(current); - } - - /// Classify a $ref string as local or remote - static RefToken classifyRef(String ref, java.net.URI baseUri) { - StructuredLog.fine(LOG, "ref.classify", "ref", ref, "base", baseUri); - - if (ref == null || ref.isEmpty()) { - throw new IllegalArgumentException("InvalidPointer: empty $ref"); - } - - // Check if it's a URI with scheme (remote) or just fragment/local pointer - try { - java.net.URI refUri = java.net.URI.create(ref); - - // If it has a scheme or authority, it's remote - if (refUri.getScheme() != null || refUri.getAuthority() != null) { - java.net.URI resolvedUri = baseUri.resolve(refUri); - StructuredLog.finer(LOG, "ref.classified", "kind", "remote", "uri", resolvedUri); - return new RefToken.RemoteRef(baseUri, resolvedUri); - } - - // If it's just a fragment or starts with #, it's local - if (ref.startsWith(SCHEMA_POINTER_ROOT) || !ref.contains("://")) { - StructuredLog.finer(LOG, "ref.classified", "kind", "local", "ref", ref); - return new RefToken.LocalRef(ref); - } - - // Default to local for safety during this refactor - StructuredLog.finer(LOG, "ref.defaultLocal", "ref", ref); - return new RefToken.LocalRef(ref); - } catch (IllegalArgumentException e) { - // Invalid URI syntax - treat as local pointer with error handling - if (ref.startsWith(SCHEMA_POINTER_ROOT) || ref.startsWith("/")) { - LOG.finer(() -> "Invalid URI but treating as local ref: " + ref); - return new RefToken.LocalRef(ref); - } - throw new IllegalArgumentException("InvalidPointer: " + ref); - } - } - - /// Index schema fragments by JSON Pointer for efficient lookup - static void indexSchemaByPointer(Session session, String pointer, JsonValue value) { - session.rawByPointer.put(pointer, value); - - if (value instanceof JsonObject obj) { - for (var entry : obj.members().entrySet()) { - String key = entry.getKey(); - // Escape special characters in key - String escapedKey = key.replace("~", "~0").replace("/", "~1"); - indexSchemaByPointer(session, pointer + "/" + escapedKey, entry.getValue()); - } - } else if (value instanceof JsonArray arr) { - for (int i = 0; i < arr.values().size(); i++) { - indexSchemaByPointer(session, pointer + "/" + i, arr.values().get(i)); - } - } - } - - /// New stack-driven compilation method that creates CompilationBundle - static CompilationBundle compileBundle(JsonValue schemaJson, Options options, CompileOptions compileOptions) { - LOG.fine(() -> "compileBundle: Starting with remote compilation enabled"); - LOG.finest(() -> "compileBundle: Starting with schema: " + schemaJson); - - Session session = new Session(); - - // Work stack for documents to compile - Deque workStack = new ArrayDeque<>(); - Set seenUris = new HashSet<>(); - Map compiled = new NormalizedUriMap(new LinkedHashMap<>()); - - // Start with synthetic URI for in-memory root - java.net.URI entryUri = java.net.URI.create("urn:inmemory:root"); - LOG.finest(() -> "compileBundle: Entry URI: " + entryUri); - workStack.push(new WorkItem(entryUri)); - seenUris.add(entryUri); - - LOG.fine(() -> "compileBundle: Initialized work stack with entry URI: " + entryUri + ", workStack size: " + workStack.size()); - - // Process work stack - int processedCount = 0; - final int WORK_WARNING_THRESHOLD = 16; // Warn after processing 16 documents - - while (!workStack.isEmpty()) { - processedCount++; - final int finalProcessedCount = processedCount; - if (processedCount % WORK_WARNING_THRESHOLD == 0) { - LOG.warning(() -> "PERFORMANCE WARNING: compileBundle processing document " + finalProcessedCount + - " - large document chains may impact performance"); - } - - WorkItem workItem = workStack.pop(); - java.net.URI currentUri = workItem.docUri(); - final int currentProcessedCount = processedCount; - LOG.finer(() -> "compileBundle: Processing URI: " + currentUri + " (processed count: " + currentProcessedCount + ")"); - - // Skip if already compiled - if (compiled.containsKey(currentUri)) { - LOG.finer(() -> "compileBundle: Already compiled, skipping: " + currentUri); - continue; - } - - // Handle remote URIs - JsonValue documentToCompile; - if (currentUri.equals(entryUri)) { - // Entry document - use provided schema - documentToCompile = schemaJson; - LOG.finer(() -> "compileBundle: Using entry document for URI: " + currentUri); - } else { - // Remote document - fetch it - LOG.finer(() -> "compileBundle: Fetching remote URI: " + currentUri); - - // Remove fragment from URI to get document URI - String fragment = currentUri.getFragment(); - java.net.URI docUri = fragment != null ? - java.net.URI.create(currentUri.toString().substring(0, currentUri.toString().indexOf('#'))) : - currentUri; - - LOG.finest(() -> "compileBundle: Document URI after fragment removal: " + docUri); - - // Enforce allowed schemes before invoking fetcher - String scheme = docUri.getScheme(); - LOG.fine(() -> "compileBundle: evaluating fetch for docUri=" + docUri + ", scheme=" + scheme + ", allowedSchemes=" + compileOptions.fetchPolicy().allowedSchemes()); - if (scheme == null || !compileOptions.fetchPolicy().allowedSchemes().contains(scheme)) { - throw new RemoteResolutionException( - docUri, - RemoteResolutionException.Reason.POLICY_DENIED, - "Scheme not allowed by policy: " + scheme - ); - } - - try { - java.net.URI first = docUri; - if ("file".equalsIgnoreCase(scheme)) { - String base = System.getProperty("json.schema.test.resources", "src/test/resources"); - String path = docUri.getPath(); - if (path.startsWith("/")) path = path.substring(1); - java.nio.file.Path abs = java.nio.file.Paths.get(base, path).toAbsolutePath(); - java.net.URI alt = abs.toUri(); - first = alt; - LOG.fine(() -> "compileBundle: Using file mapping for fetch: " + alt + " (original=" + docUri + ")"); - } - - // Enforce global document count before fetching - if (session.fetchedDocs + 1 > compileOptions.fetchPolicy().maxDocuments()) { - throw new RemoteResolutionException( - docUri, - RemoteResolutionException.Reason.POLICY_DENIED, - "Maximum document count exceeded for " + docUri - ); - } - - RemoteFetcher.FetchResult fetchResult; - try { - fetchResult = compileOptions.remoteFetcher().fetch(first, compileOptions.fetchPolicy()); - } catch (RemoteResolutionException e1) { - if (!first.equals(docUri)) { - fetchResult = compileOptions.remoteFetcher().fetch(docUri, compileOptions.fetchPolicy()); - } else { - throw e1; - } - } - - if (fetchResult.byteSize() > compileOptions.fetchPolicy().maxDocumentBytes()) { - throw new RemoteResolutionException( - docUri, - RemoteResolutionException.Reason.PAYLOAD_TOO_LARGE, - "Remote document exceeds max allowed bytes at " + docUri + ": " + fetchResult.byteSize() - ); - } - if (fetchResult.elapsed().isPresent() && fetchResult.elapsed().get().compareTo(compileOptions.fetchPolicy().timeout()) > 0) { - throw new RemoteResolutionException( - docUri, - RemoteResolutionException.Reason.TIMEOUT, - "Remote fetch exceeded timeout at " + docUri + ": " + fetchResult.elapsed().get() - ); - } - - // Update global counters and enforce total bytes across the compilation - session.fetchedDocs++; - session.totalFetchedBytes += fetchResult.byteSize(); - if (session.totalFetchedBytes > compileOptions.fetchPolicy().maxTotalBytes()) { - throw new RemoteResolutionException( - docUri, - RemoteResolutionException.Reason.POLICY_DENIED, - "Total fetched bytes exceeded policy across documents at " + docUri + ": " + session.totalFetchedBytes - ); - } - - documentToCompile = fetchResult.document(); - final String normType = documentToCompile.getClass().getSimpleName(); - final java.net.URI normUri = first; - LOG.fine(() -> "compileBundle: Successfully fetched document (normalized): " + normUri + ", document type: " + normType); - } catch (RemoteResolutionException e) { - // Network outcomes are logged by the fetcher; rethrow to surface to caller - throw e; - } - } - - // Compile the schema - LOG.finest(() -> "compileBundle: Compiling document for URI: " + currentUri); - CompilationResult result = compileSingleDocument(session, documentToCompile, options, compileOptions, currentUri, workStack, seenUris, compiled); - LOG.finest(() -> "compileBundle: Document compilation completed for URI: " + currentUri + ", schema type: " + result.schema().getClass().getSimpleName()); - - // Create compiled root and add to map - CompiledRoot compiledRoot = new CompiledRoot(currentUri, result.schema(), result.pointerIndex()); - compiled.put(currentUri, compiledRoot); - LOG.fine(() -> "compileBundle: Added compiled root for URI: " + currentUri + - " with " + result.pointerIndex().size() + " pointer index entries"); - } - - // Create compilation bundle - CompiledRoot entryRoot = compiled.get(entryUri); - if (entryRoot == null) { - LOG.severe(() -> "ERROR: SCHEMA: entry root null doc=" + entryUri); - } - assert entryRoot != null : "Entry root must exist"; - List allRoots = List.copyOf(compiled.values()); - - LOG.fine(() -> "compileBundle: Creating compilation bundle with " + allRoots.size() + " total compiled roots"); - - // Create a map of compiled roots for resolver context - Map rootsMap = new LinkedHashMap<>(); - LOG.finest(() -> "compileBundle: Creating rootsMap from " + allRoots.size() + " compiled roots"); - for (CompiledRoot root : allRoots) { - LOG.finest(() -> "compileBundle: Adding root to map: " + root.docUri()); - // Add both with and without fragment for lookup flexibility - rootsMap.put(root.docUri(), root); - // Also add the base URI without fragment if it has one - if (root.docUri().getFragment() != null) { - java.net.URI baseUri = java.net.URI.create(root.docUri().toString().substring(0, root.docUri().toString().indexOf('#'))); - rootsMap.put(baseUri, root); - LOG.finest(() -> "compileBundle: Also adding base URI: " + baseUri); - } - } - LOG.finest(() -> "compileBundle: Final rootsMap keys: " + rootsMap.keySet()); - - // Create compilation bundle with compiled roots - List updatedRoots = List.copyOf(compiled.values()); - CompiledRoot updatedEntryRoot = compiled.get(entryUri); - - LOG.fine(() -> "compileBundle: Successfully created compilation bundle with " + updatedRoots.size() + - " total documents compiled, entry root type: " + updatedEntryRoot.schema().getClass().getSimpleName()); - LOG.finest(() -> "compileBundle: Completed with entry root: " + updatedEntryRoot); - return new CompilationBundle(updatedEntryRoot, updatedRoots); - } - - /// Compile a single document using new architecture - static CompilationResult compileSingleDocument(Session session, JsonValue schemaJson, Options options, CompileOptions compileOptions, - java.net.URI docUri, Deque workStack, Set seenUris, - Map sharedRoots) { - LOG.fine(() -> "compileSingleDocument: Starting compilation for docUri: " + docUri + ", schema type: " + schemaJson.getClass().getSimpleName()); - - // Initialize session state - session.definitions.clear(); - session.compiledByPointer.clear(); - session.rawByPointer.clear(); - session.currentRootSchema = null; - session.currentOptions = options; - - LOG.finest(() -> "compileSingleDocument: Reset global state, definitions cleared, pointer indexes cleared"); - - // Handle format assertion controls - boolean assertFormats = options.assertFormats(); - - // Check system property first (read once during compile) - String systemProp = System.getProperty("jsonschema.format.assertion"); - if (systemProp != null) { - assertFormats = Boolean.parseBoolean(systemProp); - final boolean finalAssertFormats = assertFormats; - LOG.finest(() -> "compileSingleDocument: Format assertion overridden by system property: " + finalAssertFormats); - } - - // Check root schema flag (highest precedence) - if (schemaJson instanceof JsonObject obj) { - JsonValue formatAssertionValue = obj.members().get("formatAssertion"); - if (formatAssertionValue instanceof JsonBoolean formatAssertionBool) { - assertFormats = formatAssertionBool.value(); - final boolean finalAssertFormats = assertFormats; - LOG.finest(() -> "compileSingleDocument: Format assertion overridden by root schema flag: " + finalAssertFormats); - } - } - - // Update options with final assertion setting - session.currentOptions = new Options(assertFormats); - final boolean finalAssertFormats = assertFormats; - LOG.finest(() -> "compileSingleDocument: Final format assertion setting: " + finalAssertFormats); - - // Index the raw schema by JSON Pointer - LOG.finest(() -> "compileSingleDocument: Indexing schema by pointer"); - indexSchemaByPointer(session, "", schemaJson); - - // Build local pointer index for this document - Map localPointerIndex = new LinkedHashMap<>(); - - trace("compile-start", schemaJson); - LOG.finer(() -> "compileSingleDocument: Calling compileInternalWithContext for docUri: " + docUri); - CompileContext ctx = new CompileContext( - session, - sharedRoots, - new ResolverContext(sharedRoots, localPointerIndex, AnySchema.INSTANCE), - localPointerIndex, - new ArrayDeque<>() - ); - // Initialize frame stack with entry doc and root pointer - ctx.frames.push(new ContextFrame(docUri, docUri, SCHEMA_POINTER_ROOT, Map.of())); - JsonSchema schema = compileWithContext(ctx, schemaJson, docUri, workStack, seenUris); - LOG.finer(() -> "compileSingleDocument: compileInternalWithContext completed, schema type: " + schema.getClass().getSimpleName()); - - session.currentRootSchema = schema; // Store the root schema for self-references - LOG.fine(() -> "compileSingleDocument: Completed compilation for docUri: " + docUri + - ", schema type: " + schema.getClass().getSimpleName() + ", local pointer index size: " + localPointerIndex.size()); - return new CompilationResult(schema, Map.copyOf(localPointerIndex)); - } - - private static JsonSchema compileInternalWithContext(Session session, JsonValue schemaJson, java.net.URI docUri, - Deque workStack, Set seenUris, - Map sharedRoots, - Map localPointerIndex) { - return compileInternalWithContext(session, schemaJson, docUri, workStack, seenUris, - new ResolverContext(sharedRoots, localPointerIndex, AnySchema.INSTANCE), localPointerIndex, new ArrayDeque<>(), sharedRoots, SCHEMA_POINTER_ROOT); - } - - private static JsonSchema compileWithContext(CompileContext ctx, - JsonValue schemaJson, - java.net.URI docUri, - Deque workStack, - Set seenUris) { - String basePointer = ctx.frames.isEmpty() ? SCHEMA_POINTER_ROOT : ctx.frames.peek().pointer; - return compileInternalWithContext( - ctx.session, - schemaJson, - docUri, - workStack, - seenUris, - ctx.resolverContext, - ctx.localPointerIndex, - ctx.resolutionStack, - ctx.sharedRoots, - basePointer - ); - } - - private static JsonSchema compileInternalWithContext(Session session, JsonValue schemaJson, java.net.URI docUri, - Deque workStack, Set seenUris, - ResolverContext resolverContext, - Map localPointerIndex, - Deque resolutionStack, - Map sharedRoots, - String basePointer) { - LOG.fine(() -> "compileInternalWithContext: Starting with schema: " + schemaJson + ", docUri: " + docUri); - - // Check for $ref at this level first - if (schemaJson instanceof JsonObject obj) { - JsonValue refValue = obj.members().get("$ref"); - if (refValue instanceof JsonString refStr) { - LOG.fine(() -> "compileInternalWithContext: Found $ref: " + refStr.value()); - RefToken refToken = classifyRef(refStr.value(), docUri); - - // Handle remote refs by adding to work stack - if (refToken instanceof RefToken.RemoteRef remoteRef) { - LOG.finer(() -> "Remote ref detected: " + remoteRef.targetUri()); - java.net.URI targetDocUri = stripFragment(remoteRef.targetUri()); - LOG.fine(() -> "Remote ref scheduling from docUri=" + docUri + " to target=" + targetDocUri); - LOG.finest(() -> "Remote ref parentMap before cycle check: " + session.parentMap); - if (formsRemoteCycle(session.parentMap, docUri, targetDocUri)) { - String cycleMessage = "ERROR: CYCLE: remote $ref cycle detected current=" + docUri + ", target=" + targetDocUri; - LOG.severe(() -> cycleMessage); - throw new IllegalStateException(cycleMessage); - } - boolean alreadySeen = seenUris.contains(targetDocUri); - LOG.finest(() -> "Remote ref alreadySeen=" + alreadySeen + " for target=" + targetDocUri); - if (!alreadySeen) { - workStack.push(new WorkItem(targetDocUri)); - seenUris.add(targetDocUri); - session.parentMap.putIfAbsent(targetDocUri, docUri); - LOG.finer(() -> "Added to work stack: " + targetDocUri); - } else { - session.parentMap.putIfAbsent(targetDocUri, docUri); - LOG.finer(() -> "Remote ref already scheduled or compiled: " + targetDocUri); - } - LOG.finest(() -> "Remote ref parentMap after scheduling: " + session.parentMap); - LOG.finest(() -> "compileInternalWithContext: Creating RefSchema for remote ref " + remoteRef.targetUri()); - - LOG.fine(() -> "Creating RefSchema for remote ref " + remoteRef.targetUri() + - " with localPointerEntries=" + localPointerIndex.size()); - - var refSchema = new RefSchema(refToken, new ResolverContext(sharedRoots, localPointerIndex, AnySchema.INSTANCE)); - LOG.finest(() -> "compileInternalWithContext: Created RefSchema " + refSchema); - return refSchema; - } - - // Handle local refs - check if they exist first and detect cycles - LOG.finer(() -> "Local ref detected, creating RefSchema: " + refToken.pointer()); - - String pointer = refToken.pointer(); - - // For compilation-time validation, check if the reference exists - if (!pointer.equals(SCHEMA_POINTER_ROOT) && !pointer.isEmpty() && !localPointerIndex.containsKey(pointer)) { - // Check if it might be resolvable via JSON Pointer navigation - Optional target = navigatePointer(session.rawByPointer.get(""), pointer); - if (target.isEmpty() && basePointer != null && !basePointer.isEmpty() && pointer.startsWith(SCHEMA_POINTER_PREFIX)) { - String combined = basePointer + pointer.substring(1); - target = navigatePointer(session.rawByPointer.get(""), combined); - } - if (target.isEmpty() && !pointer.startsWith(SCHEMA_DEFS_POINTER)) { - throw new IllegalArgumentException("Unresolved $ref: " + pointer); - } - } - - // Check for cycles and resolve immediately for $defs references - if (pointer.startsWith(SCHEMA_DEFS_POINTER)) { - // This is a definition reference - check for cycles and resolve immediately - if (resolutionStack.contains(pointer)) { - throw new IllegalArgumentException("CYCLE: Cyclic $ref: " + String.join(" -> ", resolutionStack) + " -> " + pointer); - } - - // Try to get from local pointer index first (for already compiled definitions) - JsonSchema cached = localPointerIndex.get(pointer); - if (cached != null) { - return cached; - } - - // Otherwise, resolve via JSON Pointer and compile - Optional target = navigatePointer(session.rawByPointer.get(""), pointer); - if (target.isEmpty() && pointer.startsWith(SCHEMA_DEFS_POINTER)) { - // Heuristic fallback: locate the same named definition under any nested $defs - String defName = pointer.substring(SCHEMA_DEFS_POINTER.length()); - JsonValue rootRaw = session.rawByPointer.get(""); - // Perform a shallow search over indexed pointers for a matching suffix - for (var entry2 : session.rawByPointer.entrySet()) { - String k = entry2.getKey(); - if (k.endsWith(SCHEMA_DEFS_SEGMENT + defName)) { - target = Optional.ofNullable(entry2.getValue()); - break; - } - } - } - if (target.isEmpty() && basePointer != null && !basePointer.isEmpty() && pointer.startsWith(SCHEMA_POINTER_PREFIX)) { - String combined = basePointer + pointer.substring(1); - target = navigatePointer(session.rawByPointer.get(""), combined); - } - if (target.isPresent()) { - // Check if the target itself contains a $ref that would create a cycle - JsonValue targetValue = target.get(); - if (targetValue instanceof JsonObject targetObj) { - JsonValue targetRef = targetObj.members().get("$ref"); - if (targetRef instanceof JsonString targetRefStr) { - String targetRefPointer = targetRefStr.value(); - if (resolutionStack.contains(targetRefPointer)) { - throw new IllegalArgumentException("CYCLE: Cyclic $ref: " + String.join(" -> ", resolutionStack) + " -> " + pointer + " -> " + targetRefPointer); - } - } - } - - // Push to resolution stack for cycle detection before compiling - resolutionStack.push(pointer); - try { - JsonSchema compiled = compileInternalWithContext(session, targetValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer); - localPointerIndex.put(pointer, compiled); - return compiled; - } finally { - resolutionStack.pop(); - } - } else { - throw new IllegalArgumentException("Unresolved $ref: " + pointer); - } - } - - // Handle root reference (#) specially - use RootRef instead of RefSchema - if (pointer.equals(SCHEMA_POINTER_ROOT) || pointer.isEmpty()) { - // For root reference, create RootRef that will resolve through ResolverContext - // The ResolverContext will be updated later with the proper root schema - return new RootRef(() -> { - // Prefer the session root once available, otherwise use resolver context placeholder. - if (session.currentRootSchema != null) { - return session.currentRootSchema; - } - if (resolverContext != null) { - return resolverContext.rootSchema(); - } - return AnySchema.INSTANCE; - }); - } - - // Create temporary resolver context with current document's pointer index - Map tempRoots = sharedRoots; - - LOG.fine(() -> "Creating temporary RefSchema for local ref " + refToken.pointer() + - " with " + localPointerIndex.size() + " local pointer entries"); - - // For other references, use RefSchema with deferred resolution - // Use a temporary resolver context that will be updated later - return new RefSchema(refToken, new ResolverContext(tempRoots, localPointerIndex, AnySchema.INSTANCE)); - } - } - - if (schemaJson instanceof JsonBoolean bool) { - return bool.value() ? AnySchema.INSTANCE : new NotSchema(AnySchema.INSTANCE); - } - - if (!(schemaJson instanceof JsonObject obj)) { - throw new IllegalArgumentException("Schema must be an object or boolean"); - } - - // Process definitions first and build pointer index - JsonValue defsValue = obj.members().get("$defs"); - if (defsValue instanceof JsonObject defsObj) { - trace("compile-defs", defsValue); - for (var entry : defsObj.members().entrySet()) { - String pointer = (basePointer == null || basePointer.isEmpty()) ? SCHEMA_DEFS_POINTER + entry.getKey() : basePointer + "/$defs/" + entry.getKey(); - JsonSchema compiled = compileInternalWithContext(session, entry.getValue(), docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, pointer); - session.definitions.put(pointer, compiled); - session.compiledByPointer.put(pointer, compiled); - localPointerIndex.put(pointer, compiled); - - // Also index by $anchor if present - if (entry.getValue() instanceof JsonObject defObj) { - JsonValue anchorValue = defObj.members().get("$anchor"); - if (anchorValue instanceof JsonString anchorStr) { - String anchorPointer = SCHEMA_POINTER_ROOT + anchorStr.value(); - localPointerIndex.put(anchorPointer, compiled); - LOG.finest(() -> "Indexed $anchor '" + anchorStr.value() + "' as " + anchorPointer); - } - } - } - } - - // Handle composition keywords - JsonValue allOfValue = obj.members().get("allOf"); - if (allOfValue instanceof JsonArray allOfArr) { - trace("compile-allof", allOfValue); - List schemas = new ArrayList<>(); - for (JsonValue item : allOfArr.values()) { - schemas.add(compileInternalWithContext(session, item, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer)); - } - return new AllOfSchema(schemas); - } - - JsonValue anyOfValue = obj.members().get("anyOf"); - if (anyOfValue instanceof JsonArray anyOfArr) { - trace("compile-anyof", anyOfValue); - List schemas = new ArrayList<>(); - for (JsonValue item : anyOfArr.values()) { - schemas.add(compileInternalWithContext(session, item, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer)); - } - return new AnyOfSchema(schemas); - } - - JsonValue oneOfValue = obj.members().get("oneOf"); - if (oneOfValue instanceof JsonArray oneOfArr) { - trace("compile-oneof", oneOfValue); - List schemas = new ArrayList<>(); - for (JsonValue item : oneOfArr.values()) { - schemas.add(compileInternalWithContext(session, item, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer)); - } - return new OneOfSchema(schemas); - } - - // Handle if/then/else - JsonValue ifValue = obj.members().get("if"); - if (ifValue != null) { - trace("compile-conditional", obj); - JsonSchema ifSchema = compileInternalWithContext(session, ifValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer); - JsonSchema thenSchema = null; - JsonSchema elseSchema = null; - - JsonValue thenValue = obj.members().get("then"); - if (thenValue != null) { - thenSchema = compileInternalWithContext(session, thenValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer); - } - - JsonValue elseValue = obj.members().get("else"); - if (elseValue != null) { - elseSchema = compileInternalWithContext(session, elseValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer); - } - - return new ConditionalSchema(ifSchema, thenSchema, elseSchema); - } - - // Handle const - JsonValue constValue = obj.members().get("const"); - if (constValue != null) { - return new ConstSchema(constValue); - } - - // Handle not - JsonValue notValue = obj.members().get("not"); - if (notValue != null) { - JsonSchema inner = compileInternalWithContext(session, notValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - return new NotSchema(inner); - } - - // Detect keyword-based schema types for use in enum handling and fallback - boolean hasObjectKeywords = obj.members().containsKey("properties") - || obj.members().containsKey("required") - || obj.members().containsKey("additionalProperties") - || obj.members().containsKey("minProperties") - || obj.members().containsKey("maxProperties") - || obj.members().containsKey("patternProperties") - || obj.members().containsKey("propertyNames") - || obj.members().containsKey("dependentRequired") - || obj.members().containsKey("dependentSchemas"); - - boolean hasArrayKeywords = obj.members().containsKey("items") - || obj.members().containsKey("minItems") - || obj.members().containsKey("maxItems") - || obj.members().containsKey("uniqueItems") - || obj.members().containsKey("prefixItems") - || obj.members().containsKey("contains") - || obj.members().containsKey("minContains") - || obj.members().containsKey("maxContains"); - - boolean hasStringKeywords = obj.members().containsKey("pattern") - || obj.members().containsKey("minLength") - || obj.members().containsKey("maxLength") - || obj.members().containsKey("format"); - - // Handle enum early (before type-specific compilation) - JsonValue enumValue = obj.members().get("enum"); - if (enumValue instanceof JsonArray enumArray) { - // Build base schema from type or heuristics - JsonSchema baseSchema; - - // If type is specified, use it; otherwise infer from keywords - JsonValue typeValue = obj.members().get("type"); - if (typeValue instanceof JsonString typeStr) { - baseSchema = switch (typeStr.value()) { - case "object" -> - compileObjectSchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - case "array" -> - compileArraySchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - case "string" -> compileStringSchemaWithContext(session, obj); - case "number", "integer" -> compileNumberSchemaWithContext(obj); - case "boolean" -> new BooleanSchema(); - case "null" -> new NullSchema(); - default -> AnySchema.INSTANCE; - }; - } else if (hasObjectKeywords) { - baseSchema = compileObjectSchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - } else if (hasArrayKeywords) { - baseSchema = compileArraySchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - } else if (hasStringKeywords) { - baseSchema = compileStringSchemaWithContext(session, obj); - } else { - baseSchema = AnySchema.INSTANCE; - } - - // Build enum values set - Set allowedValues = new LinkedHashSet<>(enumArray.values()); - - return new EnumSchema(baseSchema, allowedValues); - } - - // Handle type-based schemas - JsonValue typeValue = obj.members().get("type"); - if (typeValue instanceof JsonString typeStr) { - return switch (typeStr.value()) { - case "object" -> - compileObjectSchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - case "array" -> - compileArraySchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - case "string" -> compileStringSchemaWithContext(session, obj); - case "number" -> compileNumberSchemaWithContext(obj); - case "integer" -> compileNumberSchemaWithContext(obj); // For now, treat integer as number - case "boolean" -> new BooleanSchema(); - case "null" -> new NullSchema(); - default -> AnySchema.INSTANCE; - }; - } else if (typeValue instanceof JsonArray typeArray) { - // Handle type arrays: ["string", "null", ...] - treat as anyOf - List typeSchemas = new ArrayList<>(); - for (JsonValue item : typeArray.values()) { - if (item instanceof JsonString typeStr) { - JsonSchema typeSchema = switch (typeStr.value()) { - case "object" -> - compileObjectSchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - case "array" -> - compileArraySchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - case "string" -> compileStringSchemaWithContext(session, obj); - case "number", "integer" -> compileNumberSchemaWithContext(obj); - case "boolean" -> new BooleanSchema(); - case "null" -> new NullSchema(); - default -> AnySchema.INSTANCE; - }; - typeSchemas.add(typeSchema); - } else { - throw new IllegalArgumentException("Type array must contain only strings"); - } - } - if (typeSchemas.isEmpty()) { - return AnySchema.INSTANCE; - } else if (typeSchemas.size() == 1) { - return typeSchemas.getFirst(); - } else { - return new AnyOfSchema(typeSchemas); - } - } else { - if (hasObjectKeywords) { - return compileObjectSchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - } else if (hasArrayKeywords) { - return compileArraySchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - } else if (hasStringKeywords) { - return compileStringSchemaWithContext(session, obj); - } - } - - return AnySchema.INSTANCE; - } - - // Overload: preserve existing call sites with explicit resolverContext and resolutionStack - private static JsonSchema compileInternalWithContext(Session session, JsonValue schemaJson, java.net.URI docUri, - Deque workStack, Set seenUris, - ResolverContext resolverContext, - Map localPointerIndex, - Deque resolutionStack, - Map sharedRoots) { - return compileInternalWithContext(session, schemaJson, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, SCHEMA_POINTER_ROOT); - } - - /// Object schema compilation with context - private static JsonSchema compileObjectSchemaWithContext(Session session, JsonObject obj, java.net.URI docUri, Deque workStack, Set seenUris, ResolverContext resolverContext, Map localPointerIndex, Deque resolutionStack, Map sharedRoots) { - LOG.finest(() -> "compileObjectSchemaWithContext: Starting with object: " + obj); - Map properties = new LinkedHashMap<>(); - JsonValue propsValue = obj.members().get("properties"); - if (propsValue instanceof JsonObject propsObj) { - LOG.finest(() -> "compileObjectSchemaWithContext: Processing properties: " + propsObj); - for (var entry : propsObj.members().entrySet()) { - LOG.finest(() -> "compileObjectSchemaWithContext: Compiling property '" + entry.getKey() + "': " + entry.getValue()); - // Push a context frame for this property - // (Currently used for diagnostics and future pointer derivations) - // Pop immediately after child compile - JsonSchema propertySchema; - // Best-effort: if we can see a CompileContext via resolverContext, skip; we don't expose it. So just compile. - propertySchema = compileInternalWithContext(session, entry.getValue(), docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - LOG.finest(() -> "compileObjectSchemaWithContext: Property '" + entry.getKey() + "' compiled to: " + propertySchema); - properties.put(entry.getKey(), propertySchema); - - // Add to pointer index - String pointer = SCHEMA_POINTER_ROOT + SCHEMA_PROPERTIES_SEGMENT + entry.getKey(); - localPointerIndex.put(pointer, propertySchema); - } - } - - Set required = new LinkedHashSet<>(); - JsonValue reqValue = obj.members().get("required"); - if (reqValue instanceof JsonArray reqArray) { - for (JsonValue item : reqArray.values()) { - if (item instanceof JsonString str) { - required.add(str.value()); - } - } - } - - JsonSchema additionalProperties = AnySchema.INSTANCE; - JsonValue addPropsValue = obj.members().get("additionalProperties"); - if (addPropsValue instanceof JsonBoolean addPropsBool) { - additionalProperties = addPropsBool.value() ? AnySchema.INSTANCE : BooleanSchema.FALSE; - } else if (addPropsValue instanceof JsonObject addPropsObj) { - additionalProperties = compileInternalWithContext(session, addPropsObj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - } - - // Handle patternProperties - Map patternProperties = null; - JsonValue patternPropsValue = obj.members().get("patternProperties"); - if (patternPropsValue instanceof JsonObject patternPropsObj) { - patternProperties = new LinkedHashMap<>(); - for (var entry : patternPropsObj.members().entrySet()) { - String patternStr = entry.getKey(); - Pattern pattern = Pattern.compile(patternStr); - JsonSchema schema = compileInternalWithContext(session, entry.getValue(), docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - patternProperties.put(pattern, schema); - } - } - - // Handle propertyNames - JsonSchema propertyNames = null; - JsonValue propNamesValue = obj.members().get("propertyNames"); - if (propNamesValue != null) { - propertyNames = compileInternalWithContext(session, propNamesValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - } - - Integer minProperties = getInteger(obj, "minProperties"); - Integer maxProperties = getInteger(obj, "maxProperties"); - - // Handle dependentRequired - Map> dependentRequired = null; - JsonValue depReqValue = obj.members().get("dependentRequired"); - if (depReqValue instanceof JsonObject depReqObj) { - dependentRequired = new LinkedHashMap<>(); - for (var entry : depReqObj.members().entrySet()) { - String triggerProp = entry.getKey(); - JsonValue depsValue = entry.getValue(); - if (depsValue instanceof JsonArray depsArray) { - Set requiredProps = new LinkedHashSet<>(); - for (JsonValue depItem : depsArray.values()) { - if (depItem instanceof JsonString depStr) { - requiredProps.add(depStr.value()); - } else { - throw new IllegalArgumentException("dependentRequired values must be arrays of strings"); - } - } - dependentRequired.put(triggerProp, requiredProps); - } else { - throw new IllegalArgumentException("dependentRequired values must be arrays"); - } - } - } - - // Handle dependentSchemas - Map dependentSchemas = null; - JsonValue depSchValue = obj.members().get("dependentSchemas"); - if (depSchValue instanceof JsonObject depSchObj) { - dependentSchemas = new LinkedHashMap<>(); - for (var entry : depSchObj.members().entrySet()) { - String triggerProp = entry.getKey(); - JsonValue schemaValue = entry.getValue(); - JsonSchema schema; - if (schemaValue instanceof JsonBoolean boolValue) { - schema = boolValue.value() ? AnySchema.INSTANCE : BooleanSchema.FALSE; - } else { - schema = compileInternalWithContext(session, schemaValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - } - dependentSchemas.put(triggerProp, schema); - } - } - - return new ObjectSchema(properties, required, additionalProperties, minProperties, maxProperties, patternProperties, propertyNames, dependentRequired, dependentSchemas); - } - - /// Array schema compilation with context - private static JsonSchema compileArraySchemaWithContext(Session session, JsonObject obj, java.net.URI docUri, Deque workStack, Set seenUris, ResolverContext resolverContext, Map localPointerIndex, Deque resolutionStack, Map sharedRoots) { - JsonSchema items = AnySchema.INSTANCE; - JsonValue itemsValue = obj.members().get("items"); - if (itemsValue != null) { - items = compileInternalWithContext(session, itemsValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - } - - // Parse prefixItems (tuple validation) - List prefixItems = null; - JsonValue prefixItemsVal = obj.members().get("prefixItems"); - if (prefixItemsVal instanceof JsonArray arr) { - prefixItems = new ArrayList<>(arr.values().size()); - for (JsonValue v : arr.values()) { - prefixItems.add(compileInternalWithContext(session, v, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots)); - } - prefixItems = List.copyOf(prefixItems); - } - - // Parse contains schema - JsonSchema contains = null; - JsonValue containsVal = obj.members().get("contains"); - if (containsVal != null) { - contains = compileInternalWithContext(session, containsVal, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); - } - - // Parse minContains / maxContains - Integer minContains = getInteger(obj, "minContains"); - Integer maxContains = getInteger(obj, "maxContains"); - - Integer minItems = getInteger(obj, "minItems"); - Integer maxItems = getInteger(obj, "maxItems"); - Boolean uniqueItems = getBoolean(obj, "uniqueItems"); - - return new ArraySchema(items, minItems, maxItems, uniqueItems, prefixItems, contains, minContains, maxContains); - } - - /// String schema compilation with context - private static JsonSchema compileStringSchemaWithContext(Session session, JsonObject obj) { - Integer minLength = getInteger(obj, "minLength"); - Integer maxLength = getInteger(obj, "maxLength"); - - Pattern pattern = null; - JsonValue patternValue = obj.members().get("pattern"); - if (patternValue instanceof JsonString patternStr) { - pattern = Pattern.compile(patternStr.value()); - } - - // Handle format keyword - FormatValidator formatValidator = null; - boolean assertFormats = session.currentOptions != null && session.currentOptions.assertFormats(); - - if (assertFormats) { - JsonValue formatValue = obj.members().get("format"); - if (formatValue instanceof JsonString formatStr) { - String formatName = formatStr.value(); - formatValidator = Format.byName(formatName); - if (formatValidator == null) { - LOG.fine("Unknown format: " + formatName); - } - } - } - - return new StringSchema(minLength, maxLength, pattern, formatValidator, assertFormats); - } - - /// Number schema compilation with context - private static JsonSchema compileNumberSchemaWithContext(JsonObject obj) { - BigDecimal minimum = getBigDecimal(obj, "minimum"); - BigDecimal maximum = getBigDecimal(obj, "maximum"); - BigDecimal multipleOf = getBigDecimal(obj, "multipleOf"); - Boolean exclusiveMinimum = getBoolean(obj, "exclusiveMinimum"); - Boolean exclusiveMaximum = getBoolean(obj, "exclusiveMaximum"); - - // Handle numeric exclusiveMinimum/exclusiveMaximum (2020-12 spec) - BigDecimal exclusiveMinValue = getBigDecimal(obj, "exclusiveMinimum"); - BigDecimal exclusiveMaxValue = getBigDecimal(obj, "exclusiveMaximum"); - - // Normalize: if numeric exclusives are present, convert to boolean form - if (exclusiveMinValue != null) { - minimum = exclusiveMinValue; - exclusiveMinimum = true; - } - if (exclusiveMaxValue != null) { - maximum = exclusiveMaxValue; - exclusiveMaximum = true; - } - - return new NumberSchema(minimum, maximum, multipleOf, exclusiveMinimum, exclusiveMaximum); - } - - private static Integer getInteger(JsonObject obj, String key) { - JsonValue value = obj.members().get(key); - if (value instanceof JsonNumber num) { - Number n = num.toNumber(); - if (n instanceof Integer i) return i; - if (n instanceof Long l) return l.intValue(); - if (n instanceof BigDecimal bd) return bd.intValue(); - } - return null; - } - - private static Boolean getBoolean(JsonObject obj, String key) { - JsonValue value = obj.members().get(key); - if (value instanceof JsonBoolean bool) { - return bool.value(); - } - return null; - } - - private static BigDecimal getBigDecimal(JsonObject obj, String key) { - JsonValue value = obj.members().get(key); - if (value instanceof JsonNumber num) { - Number n = num.toNumber(); - if (n instanceof BigDecimal) return (BigDecimal) n; - if (n instanceof BigInteger) return new BigDecimal((BigInteger) n); - return BigDecimal.valueOf(n.doubleValue()); - } - return null; - } - } - - /// Const schema - validates that a value equals a constant - record ConstSchema(JsonValue constValue) implements JsonSchema { - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - return json.equals(constValue) ? - ValidationResult.success() : - ValidationResult.failure(List.of(new ValidationError(path, "Value must equal const value"))); - } - } - - /// Enum schema - validates that a value is in a set of allowed values - record EnumSchema(JsonSchema baseSchema, Set allowedValues) implements JsonSchema { - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - // First validate against base schema - ValidationResult baseResult = baseSchema.validateAt(path, json, stack); - if (!baseResult.valid()) { - return baseResult; - } - - // Then check if value is in enum - if (!allowedValues.contains(json)) { - return ValidationResult.failure(List.of(new ValidationError(path, "Not in enum"))); - } - - return ValidationResult.success(); - } - } - - /// Not composition - inverts the validation result of the inner schema - record NotSchema(JsonSchema schema) implements JsonSchema { - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - ValidationResult result = schema.validate(json); - return result.valid() ? - ValidationResult.failure(List.of(new ValidationError(path, "Schema should not match"))) : - ValidationResult.success(); - } - } - - /// Root reference schema that refers back to the root schema - record RootRef(java.util.function.Supplier rootSupplier) implements JsonSchema { - @Override - public ValidationResult validateAt(String path, JsonValue json, Deque stack) { - LOG.finest(() -> "RootRef.validateAt at path: " + path); - JsonSchema root = rootSupplier.get(); - if (root == null) { - // Shouldn't happen once compilation finishes; be conservative and fail closed: - return ValidationResult.failure(List.of(new ValidationError(path, "Root schema not available"))); - } - // Stay within the SAME stack to preserve traversal semantics (matches AllOf/Conditional). - stack.push(new ValidationFrame(path, root, json)); - return ValidationResult.success(); - } - } - - /// Compiled registry holding multiple schema roots - record CompiledRegistry( - java.util.Map roots, - CompiledRoot entry - ) { - } - - /// Classification of a $ref discovered during compilation - - - /// Compilation result for a single document - record CompilationResult(JsonSchema schema, java.util.Map pointerIndex) { - } - - /// Immutable compiled document - record CompiledRoot(java.net.URI docUri, JsonSchema schema, java.util.Map pointerIndex) { - } - - /// Work item to load/compile a document - record WorkItem(java.net.URI docUri) { - } - - /// Compilation output bundle - record CompilationBundle( - CompiledRoot entry, // the first/root doc - java.util.List all // entry + any remotes (for now it'll just be [entry]) - ) { - } - - /// Resolver context for validation-time $ref resolution - record ResolverContext( - java.util.Map roots, - java.util.Map localPointerIndex, // for *entry* root only (for now) - JsonSchema rootSchema - ) { - /// Resolve a RefToken to the target schema - JsonSchema resolve(RefToken token) { - LOG.finest(() -> "ResolverContext.resolve: " + token); - LOG.fine(() -> "ResolverContext.resolve: roots.size=" + roots.size() + ", localPointerIndex.size=" + localPointerIndex.size()); - - if (token instanceof RefToken.LocalRef(String pointerOrAnchor)) { - - // Handle root reference - if (pointerOrAnchor.equals(SCHEMA_POINTER_ROOT) || pointerOrAnchor.isEmpty()) { - return rootSchema; - } - - JsonSchema target = localPointerIndex.get(pointerOrAnchor); - if (target == null) { - throw new IllegalArgumentException("Unresolved $ref: " + pointerOrAnchor); - } - return target; - } - - if (token instanceof RefToken.RemoteRef remoteRef) { - LOG.finer(() -> "ResolverContext.resolve: RemoteRef " + remoteRef.targetUri()); - - // Get the document URI without fragment - java.net.URI targetUri = remoteRef.targetUri(); - String originalFragment = targetUri.getFragment(); - java.net.URI docUri = originalFragment != null ? - java.net.URI.create(targetUri.toString().substring(0, targetUri.toString().indexOf('#'))) : - targetUri; + // Get the document URI without fragment + java.net.URI targetUri = remoteRef.targetUri(); + String originalFragment = targetUri.getFragment(); + java.net.URI docUri = originalFragment != null ? + java.net.URI.create(targetUri.toString().substring(0, targetUri.toString().indexOf('#'))) : + targetUri; // JSON Pointer fragments should start with #, so add it if missing final String fragment; @@ -2754,180 +742,4 @@ JsonSchema resolve(RefToken token) { } } - /// Format validator interface for string format validation - sealed interface FormatValidator { - /// Test if the string value matches the format - /// @param s the string to test - /// @return true if the string matches the format, false otherwise - boolean test(String s); - } - - /// Built-in format validators - enum Format implements FormatValidator { - UUID { - @Override - public boolean test(String s) { - try { - java.util.UUID.fromString(s); - return true; - } catch (IllegalArgumentException e) { - return false; - } - } - }, - - EMAIL { - @Override - public boolean test(String s) { - // Pragmatic RFC-5322-lite regex: reject whitespace, require TLD, no consecutive dots - return s.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") && !s.contains(".."); - } - }, - - IPV4 { - @Override - public boolean test(String s) { - String[] parts = s.split("\\."); - if (parts.length != 4) return false; - - for (String part : parts) { - try { - int num = Integer.parseInt(part); - if (num < 0 || num > 255) return false; - // Check for leading zeros (except for 0 itself) - if (part.length() > 1 && part.startsWith("0")) return false; - } catch (NumberFormatException e) { - return false; - } - } - return true; - } - }, - - IPV6 { - @Override - public boolean test(String s) { - try { - // Use InetAddress to validate, but also check it contains ':' to distinguish from IPv4 - //noinspection ResultOfMethodCallIgnored - java.net.InetAddress.getByName(s); - return s.contains(":"); - } catch (Exception e) { - return false; - } - } - }, - - URI { - @Override - public boolean test(String s) { - try { - java.net.URI uri = new java.net.URI(s); - return uri.isAbsolute() && uri.getScheme() != null; - } catch (Exception e) { - return false; - } - } - }, - - URI_REFERENCE { - @Override - public boolean test(String s) { - try { - new java.net.URI(s); - return true; - } catch (Exception e) { - return false; - } - } - }, - - HOSTNAME { - @Override - public boolean test(String s) { - // Basic hostname validation: labels a-zA-Z0-9-, no leading/trailing -, label 1-63, total ≤255 - if (s.isEmpty() || s.length() > 255) return false; - if (!s.contains(".")) return false; // Must have at least one dot - - String[] labels = s.split("\\."); - for (String label : labels) { - if (label.isEmpty() || label.length() > 63) return false; - if (label.startsWith("-") || label.endsWith("-")) return false; - if (!label.matches("^[a-zA-Z0-9-]+$")) return false; - } - return true; - } - }, - - DATE { - @Override - public boolean test(String s) { - try { - java.time.LocalDate.parse(s); - return true; - } catch (Exception e) { - return false; - } - } - }, - - TIME { - @Override - public boolean test(String s) { - try { - // Try OffsetTime first (with timezone) - java.time.OffsetTime.parse(s); - return true; - } catch (Exception e) { - try { - // Try LocalTime (without timezone) - java.time.LocalTime.parse(s); - return true; - } catch (Exception e2) { - return false; - } - } - } - }, - - DATE_TIME { - @Override - public boolean test(String s) { - try { - // Try OffsetDateTime first (with timezone) - java.time.OffsetDateTime.parse(s); - return true; - } catch (Exception e) { - try { - // Try LocalDateTime (without timezone) - java.time.LocalDateTime.parse(s); - return true; - } catch (Exception e2) { - return false; - } - } - } - }, - - REGEX { - @Override - public boolean test(String s) { - try { - java.util.regex.Pattern.compile(s); - return true; - } catch (Exception e) { - return false; - } - } - }; - - /// Get format validator by name (case-insensitive) - static FormatValidator byName(String name) { - try { - return Format.valueOf(name.toUpperCase().replace("-", "_")); - } catch (IllegalArgumentException e) { - return null; // Unknown format - } - } - } } diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/NotSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/NotSchema.java new file mode 100644 index 0000000..750630b --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/NotSchema.java @@ -0,0 +1,17 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.Deque; +import java.util.List; + +/// Not composition - inverts the validation result of the inner schema +public record NotSchema(JsonSchema schema) implements JsonSchema { + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + ValidationResult result = schema.validate(json); + return result.valid() ? + ValidationResult.failure(List.of(new ValidationError(path, "Schema should not match"))) : + ValidationResult.success(); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/NullSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/NullSchema.java new file mode 100644 index 0000000..d9a07a1 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/NullSchema.java @@ -0,0 +1,20 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonNull; +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.Deque; +import java.util.List; + +/// Null schema - always valid for null values +public record NullSchema() implements JsonSchema { + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + if (!(json instanceof JsonNull)) { + return ValidationResult.failure(List.of( + new ValidationError(path, "Expected null") + )); + } + return ValidationResult.success(); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/NumberSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/NumberSchema.java new file mode 100644 index 0000000..665d38e --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/NumberSchema.java @@ -0,0 +1,63 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonNumber; +import jdk.sandbox.java.util.json.JsonValue; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +/// Number schema with range and multiple constraints +public record NumberSchema( + BigDecimal minimum, + BigDecimal maximum, + BigDecimal multipleOf, + Boolean exclusiveMinimum, + Boolean exclusiveMaximum +) implements JsonSchema { + + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + LOG.finest(() -> "NumberSchema.validateAt: " + json + " minimum=" + minimum + " maximum=" + maximum); + if (!(json instanceof JsonNumber num)) { + return ValidationResult.failure(List.of( + new ValidationError(path, "Expected number") + )); + } + + BigDecimal value = num.toNumber() instanceof BigDecimal bd ? bd : BigDecimal.valueOf(num.toNumber().doubleValue()); + List errors = new ArrayList<>(); + + // Check minimum + if (minimum != null) { + int comparison = value.compareTo(minimum); + LOG.finest(() -> "NumberSchema.validateAt: value=" + value + " minimum=" + minimum + " comparison=" + comparison); + if (exclusiveMinimum != null && exclusiveMinimum && comparison <= 0) { + errors.add(new ValidationError(path, "Below minimum")); + } else if (comparison < 0) { + errors.add(new ValidationError(path, "Below minimum")); + } + } + + // Check maximum + if (maximum != null) { + int comparison = value.compareTo(maximum); + if (exclusiveMaximum != null && exclusiveMaximum && comparison >= 0) { + errors.add(new ValidationError(path, "Above maximum")); + } else if (comparison > 0) { + errors.add(new ValidationError(path, "Above maximum")); + } + } + + // Check multipleOf + if (multipleOf != null) { + BigDecimal remainder = value.remainder(multipleOf); + if (remainder.compareTo(BigDecimal.ZERO) != 0) { + errors.add(new ValidationError(path, "Not multiple of " + multipleOf)); + } + } + + return errors.isEmpty() ? ValidationResult.success() : ValidationResult.failure(errors); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ObjectSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ObjectSchema.java new file mode 100644 index 0000000..779adbc --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/ObjectSchema.java @@ -0,0 +1,141 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.*; +import java.util.regex.Pattern; + +/// Object schema with properties, required fields, and constraints +public record ObjectSchema( + Map properties, + Set required, + JsonSchema additionalProperties, + Integer minProperties, + Integer maxProperties, + Map patternProperties, + JsonSchema propertyNames, + Map> dependentRequired, + Map dependentSchemas +) implements JsonSchema { + + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + if (!(json instanceof JsonObject obj)) { + return ValidationResult.failure(List.of( + new ValidationError(path, "Expected object") + )); + } + + List errors = new ArrayList<>(); + + // Check property count constraints + int propCount = obj.members().size(); + if (minProperties != null && propCount < minProperties) { + errors.add(new ValidationError(path, "Too few properties: expected at least " + minProperties)); + } + if (maxProperties != null && propCount > maxProperties) { + errors.add(new ValidationError(path, "Too many properties: expected at most " + maxProperties)); + } + + // Check required properties + for (String reqProp : required) { + if (!obj.members().containsKey(reqProp)) { + errors.add(new ValidationError(path, "Missing required property: " + reqProp)); + } + } + + // Handle dependentRequired + if (dependentRequired != null) { + for (var entry : dependentRequired.entrySet()) { + String triggerProp = entry.getKey(); + Set requiredDeps = entry.getValue(); + + // If trigger property is present, check all dependent properties + if (obj.members().containsKey(triggerProp)) { + for (String depProp : requiredDeps) { + if (!obj.members().containsKey(depProp)) { + errors.add(new ValidationError(path, "Property '" + triggerProp + "' requires property '" + depProp + "' (dependentRequired)")); + } + } + } + } + } + + // Handle dependentSchemas + if (dependentSchemas != null) { + for (var entry : dependentSchemas.entrySet()) { + String triggerProp = entry.getKey(); + JsonSchema depSchema = entry.getValue(); + + // If trigger property is present, apply the dependent schema + if (obj.members().containsKey(triggerProp)) { + if (depSchema == BooleanSchema.FALSE) { + errors.add(new ValidationError(path, "Property '" + triggerProp + "' forbids object unless its dependent schema is satisfied (dependentSchemas=false)")); + } else if (depSchema != BooleanSchema.TRUE) { + // Apply the dependent schema to the entire object + stack.push(new ValidationFrame(path, depSchema, json)); + } + } + } + } + + // Validate property names if specified + if (propertyNames != null) { + for (String propName : obj.members().keySet()) { + String namePath = path.isEmpty() ? propName : path + "." + propName; + JsonValue nameValue = Json.parse("\"" + propName + "\""); + ValidationResult nameResult = propertyNames.validateAt(namePath + "(name)", nameValue, stack); + if (!nameResult.valid()) { + errors.add(new ValidationError(namePath, "Property name violates propertyNames")); + } + } + } + + // Validate each property with correct precedence + for (var entry : obj.members().entrySet()) { + String propName = entry.getKey(); + JsonValue propValue = entry.getValue(); + String propPath = path.isEmpty() ? propName : path + "." + propName; + + // Track if property was handled by properties or patternProperties + boolean handledByProperties = false; + boolean handledByPattern = false; + + // 1. Check if property is in properties (highest precedence) + JsonSchema propSchema = properties.get(propName); + if (propSchema != null) { + stack.push(new ValidationFrame(propPath, propSchema, propValue)); + handledByProperties = true; + } + + // 2. Check all patternProperties that match this property name + if (patternProperties != null) { + for (var patternEntry : patternProperties.entrySet()) { + Pattern pattern = patternEntry.getKey(); + JsonSchema patternSchema = patternEntry.getValue(); + if (pattern.matcher(propName).find()) { // unanchored find semantics + stack.push(new ValidationFrame(propPath, patternSchema, propValue)); + handledByPattern = true; + } + } + } + + // 3. If property wasn't handled by properties or patternProperties, apply additionalProperties + if (!handledByProperties && !handledByPattern) { + if (additionalProperties != null) { + if (additionalProperties == BooleanSchema.FALSE) { + // Handle additionalProperties: false - reject unmatched properties + errors.add(new ValidationError(propPath, "Additional properties not allowed")); + } else if (additionalProperties != BooleanSchema.TRUE) { + // Apply the additionalProperties schema (not true/false boolean schemas) + stack.push(new ValidationFrame(propPath, additionalProperties, propValue)); + } + } + } + } + + return errors.isEmpty() ? ValidationResult.success() : ValidationResult.failure(errors); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/OneOfSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/OneOfSchema.java new file mode 100644 index 0000000..920f48f --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/OneOfSchema.java @@ -0,0 +1,76 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +/// OneOf composition - must satisfy exactly one schema +public record OneOfSchema(List schemas) implements JsonSchema { + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + int validCount = 0; + List minimalErrors = null; + + for (JsonSchema schema : schemas) { + // Create a separate validation stack for this branch + Deque branchStack = new ArrayDeque<>(); + List branchErrors = new ArrayList<>(); + + LOG.finest(() -> "one of BRANCH START: " + schema.getClass().getSimpleName()); + branchStack.push(new ValidationFrame(path, schema, json)); + + while (!branchStack.isEmpty()) { + ValidationFrame frame = branchStack.pop(); + ValidationResult result = frame.schema().validateAt(frame.path(), frame.json(), branchStack); + if (!result.valid()) { + branchErrors.addAll(result.errors()); + } + } + + if (branchErrors.isEmpty()) { + validCount++; + } else { + // Track minimal error set for zero-valid case + // Prefer errors that don't start with "Expected" (type mismatches) if possible + // In case of ties, prefer later branches (they tend to be more specific) + if (minimalErrors == null || + (branchErrors.size() < minimalErrors.size()) || + (branchErrors.size() == minimalErrors.size() && + hasBetterErrorType(branchErrors, minimalErrors))) { + minimalErrors = branchErrors; + } + } + LOG.finest(() -> "one of BRANCH END: " + branchErrors.size() + " errors, valid=" + branchErrors.isEmpty()); + } + + // Exactly one must be valid + if (validCount == 1) { + return ValidationResult.success(); + } else if (validCount == 0) { + // Zero valid - return minimal error set + return ValidationResult.failure(minimalErrors != null ? minimalErrors : List.of()); + } else { + // Multiple valid - single error + return ValidationResult.failure(List.of( + new ValidationError(path, "oneOf: multiple schemas matched (" + validCount + ")") + )); + } + } + + private boolean hasBetterErrorType(List newErrors, List currentErrors) { + // Prefer errors that don't start with "Expected" (type mismatches) + boolean newHasTypeMismatch = newErrors.stream().anyMatch(e -> e.message().startsWith("Expected")); + boolean currentHasTypeMismatch = currentErrors.stream().anyMatch(e -> e.message().startsWith("Expected")); + + // If new has type mismatch and current doesn't, current is better (keep current) + return !newHasTypeMismatch || currentHasTypeMismatch; + + // If current has type mismatch and new doesn't, new is better (replace current) + + // If both have type mismatches or both don't, prefer later branches + // This is a simple heuristic + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/RefSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/RefSchema.java new file mode 100644 index 0000000..efcec35 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/RefSchema.java @@ -0,0 +1,37 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.net.URI; +import java.util.Deque; +import java.util.List; + +/// Reference schema for JSON Schema $ref +public record RefSchema(RefToken refToken, ResolverContext resolverContext) implements JsonSchema { + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + LOG.finest(() -> "RefSchema.validateAt: " + refToken + " at path: " + path + " with json=" + json); + LOG.fine(() -> "RefSchema.validateAt: Using resolver context with roots.size=" + resolverContext.roots().size() + + " localPointerIndex.size=" + resolverContext.localPointerIndex().size()); + + // Add detailed logging for remote ref resolution + if (refToken instanceof RefToken.RemoteRef(URI baseUri, URI targetUri)) { + LOG.finest(() -> "RefSchema.validateAt: Attempting to resolve RemoteRef: baseUri=" + baseUri + ", targetUri=" + targetUri); + LOG.finest(() -> "RefSchema.validateAt: Available roots in context: " + resolverContext.roots().keySet()); + } + + JsonSchema target = resolverContext.resolve(refToken); + LOG.finest(() -> "RefSchema.validateAt: Resolved target=" + target); + if (target == null) { + return ValidationResult.failure(List.of(new ValidationError(path, "Unresolvable $ref: " + refToken))); + } + // Stay on the SAME traversal stack (uniform non-recursive execution). + stack.push(new ValidationFrame(path, target, json)); + return ValidationResult.success(); + } + + @Override + public String toString() { + return "RefSchema[" + refToken + "]"; + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/RemoteResolutionException.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/RemoteResolutionException.java new file mode 100644 index 0000000..d600c3c --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/RemoteResolutionException.java @@ -0,0 +1,38 @@ +package io.github.simbo1905.json.schema; + +import java.util.Objects; + +/// Exception signalling remote resolution failures with typed reasons +public final class RemoteResolutionException extends RuntimeException { + private final java.net.URI uri; + private final Reason reason; + + RemoteResolutionException(java.net.URI uri, Reason reason, String message) { + super(message); + this.uri = Objects.requireNonNull(uri, "uri"); + this.reason = Objects.requireNonNull(reason, "reason"); + } + + RemoteResolutionException(java.net.URI uri, Reason reason, String message, Throwable cause) { + super(message, cause); + this.uri = Objects.requireNonNull(uri, "uri"); + this.reason = Objects.requireNonNull(reason, "reason"); + } + + public java.net.URI uri() { + return uri; + } + + public Reason reason() { + return reason; + } + + enum Reason { + NETWORK_ERROR, + POLICY_DENIED, + NOT_FOUND, + POINTER_MISSING, + PAYLOAD_TOO_LARGE, + TIMEOUT + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/RootRef.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/RootRef.java new file mode 100644 index 0000000..7a9f12a --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/RootRef.java @@ -0,0 +1,22 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.Deque; +import java.util.List; + +/// Root reference schema that refers back to the root schema +public record RootRef(java.util.function.Supplier rootSupplier) implements JsonSchema { + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + LOG.finest(() -> "RootRef.validateAt at path: " + path); + JsonSchema root = rootSupplier.get(); + if (root == null) { + // Shouldn't happen once compilation finishes; be conservative and fail closed: + return ValidationResult.failure(List.of(new ValidationError(path, "Root schema not available"))); + } + // Stay within the SAME stack to preserve traversal semantics (matches AllOf/Conditional). + stack.push(new ValidationFrame(path, root, json)); + return ValidationResult.success(); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/SchemaCompiler.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/SchemaCompiler.java new file mode 100644 index 0000000..d6d60d3 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/SchemaCompiler.java @@ -0,0 +1,1075 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.*; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.URI; +import java.util.*; +import java.util.logging.Level; +import java.util.regex.Pattern; + +import static io.github.simbo1905.json.schema.JsonSchema.LOG; + +/// Internal schema compiler +public final class SchemaCompiler { + public static boolean formsRemoteCycle(Map parentMap, + URI currentDocUri, + URI targetDocUri) { + if (currentDocUri.equals(targetDocUri)) { + return true; + } + + URI cursor = currentDocUri; + while (true) { + URI parent = parentMap.get(cursor); + if (parent == null) { + break; + } + if (parent.equals(targetDocUri)) { + return true; + } + cursor = parent; + } + return false; + } + + /// Per-compilation session state (no static mutable fields). + private static final class Session { + final Map rawByPointer = new LinkedHashMap<>(); + final Map parentMap = new LinkedHashMap<>(); + JsonSchema currentRootSchema; + JsonSchema.JsonSchemaOptions currentJsonSchemaOptions; + long totalFetchedBytes; + int fetchedDocs; + } + + /// Strip any fragment from a URI, returning the base document URI. + private static java.net.URI stripFragment(java.net.URI uri) { + String s = uri.toString(); + int i = s.indexOf('#'); + java.net.URI base = i >= 0 ? java.net.URI.create(s.substring(0, i)) : uri; + return base.normalize(); + } + // removed static mutable state; state now lives in Session + + private static void trace(String stage, JsonValue fragment) { + if (LOG.isLoggable(Level.FINER)) { + LOG.finer(() -> + String.format("[%s] %s", stage, fragment.toString())); + } + } + + /// Per-compile carrier for resolver-related state. + private static final class CompileContext { + final Session session; + final Map sharedRoots; + final JsonSchema.ResolverContext resolverContext; + final Map localPointerIndex; + final Deque resolutionStack; + final Deque frames = new ArrayDeque<>(); + + CompileContext(Session session, + Map sharedRoots, + JsonSchema.ResolverContext resolverContext, + Map localPointerIndex, + Deque resolutionStack) { + this.session = session; + this.sharedRoots = sharedRoots; + this.resolverContext = resolverContext; + this.localPointerIndex = localPointerIndex; + this.resolutionStack = resolutionStack; + } + } + + /// Immutable context frame capturing current document/base/pointer/anchors. + private record ContextFrame(URI docUri, URI baseUri, String pointer, Map anchors) { + private ContextFrame(URI docUri, URI baseUri, String pointer, Map anchors) { + this.docUri = docUri; + this.baseUri = baseUri; + this.pointer = pointer; + this.anchors = anchors == null ? Map.of() : Map.copyOf(anchors); + } + } + + /// JSON Pointer utility for RFC-6901 fragment navigation + static Optional navigatePointer(JsonValue root, String pointer) { + LOG.fine(() -> "pointer.navigate pointer=" + pointer); + + + if (pointer.isEmpty() || pointer.equals(JsonSchema.SCHEMA_POINTER_ROOT)) { + return Optional.of(root); + } + + // Remove leading # if present + String path = pointer.startsWith(JsonSchema.SCHEMA_POINTER_ROOT) ? pointer.substring(1) : pointer; + if (path.isEmpty()) { + return Optional.of(root); + } + + // Must start with / + if (!path.startsWith("/")) { + return Optional.empty(); + } + + JsonValue current = root; + String[] tokens = path.substring(1).split("/"); + + // Performance warning for deeply nested pointers + if (tokens.length > 50) { + final int tokenCount = tokens.length; + LOG.warning(() -> "PERFORMANCE WARNING: Navigating deeply nested JSON pointer with " + tokenCount + + " segments - possible performance impact"); + } + + for (int i = 0; i < tokens.length; i++) { + if (i > 0 && i % 25 == 0) { + final int segment = i; + final int total = tokens.length; + LOG.warning(() -> "PERFORMANCE WARNING: JSON pointer navigation at segment " + segment + " of " + total); + } + + String token = tokens[i]; + // Unescape ~1 -> / and ~0 -> ~ + String unescaped = token.replace("~1", "/").replace("~0", "~"); + final var currentFinal = current; + final var unescapedFinal = unescaped; + + LOG.finer(() -> "Token: '" + token + "' unescaped: '" + unescapedFinal + "' current: " + currentFinal); + + if (current instanceof JsonObject obj) { + current = obj.members().get(unescaped); + if (current == null) { + LOG.finer(() -> "Property not found: " + unescapedFinal); + return Optional.empty(); + } + } else if (current instanceof JsonArray arr) { + try { + int index = Integer.parseInt(unescaped); + if (index < 0 || index >= arr.values().size()) { + return Optional.empty(); + } + current = arr.values().get(index); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } else { + return Optional.empty(); + } + } + + LOG.fine(() -> "pointer.navigate pointer=" + pointer); + + return Optional.of(current); + } + + /// Classify a $ref string as local or remote + static JsonSchema.RefToken classifyRef(String ref, URI baseUri) { + LOG.fine(() -> "ref.classify ref=" + ref + " base=" + baseUri); + + + if (ref == null || ref.isEmpty()) { + throw new IllegalArgumentException("InvalidPointer: empty $ref"); + } + + // Check if it's a URI with scheme (remote) or just fragment/local pointer + try { + URI refUri = URI.create(ref); + + // If it has a scheme or authority, it's remote + if (refUri.getScheme() != null || refUri.getAuthority() != null) { + URI resolvedUri = baseUri.resolve(refUri); + LOG.finer(() -> "ref.classified kind=remote uri=" + resolvedUri); + + return new JsonSchema.RefToken.RemoteRef(baseUri, resolvedUri); + } + + // If it's just a fragment or starts with #, it's local + if (ref.startsWith(JsonSchema.SCHEMA_POINTER_ROOT) || !ref.contains("://")) { + LOG.finer(() -> "ref is local root " + ref); + return new JsonSchema.RefToken.LocalRef(ref); + } + + // Default to local for safety during this refactor + LOG.finer(() -> "ref is not local root " + ref); + throw new AssertionError("not implemented"); + //return new RefToken.LocalRef(ref); + } catch (IllegalArgumentException e) { + // Invalid URI syntax - treat as local pointer with error handling + if (ref.startsWith(JsonSchema.SCHEMA_POINTER_ROOT) || ref.startsWith("/")) { + LOG.finer(() -> "Invalid URI but treating as local ref: " + ref); + return new JsonSchema.RefToken.LocalRef(ref); + } + throw new IllegalArgumentException("InvalidPointer: " + ref); + } + } + + /// Index schema fragments by JSON Pointer for efficient lookup + static void indexSchemaByPointer(Session session, String pointer, JsonValue value) { + session.rawByPointer.put(pointer, value); + + if (value instanceof JsonObject obj) { + for (var entry : obj.members().entrySet()) { + String key = entry.getKey(); + // Escape special characters in key + String escapedKey = key.replace("~", "~0").replace("/", "~1"); + indexSchemaByPointer(session, pointer + "/" + escapedKey, entry.getValue()); + } + } else if (value instanceof JsonArray arr) { + for (int i = 0; i < arr.values().size(); i++) { + indexSchemaByPointer(session, pointer + "/" + i, arr.values().get(i)); + } + } + } + + /// New stack-driven compilation method that creates CompilationBundle + static JsonSchema.CompilationBundle compileBundle(JsonValue schemaJson, JsonSchema.JsonSchemaOptions jsonSchemaOptions, JsonSchema.CompileOptions compileOptions) { + LOG.fine(() -> "compileBundle: Starting with remote compilation enabled"); + + Session session = new Session(); + + // Work stack for documents to compile + Deque workStack = new ArrayDeque<>(); + Set seenUris = new HashSet<>(); + Map compiled = new JsonSchema.NormalizedUriMap(new LinkedHashMap<>()); + + // Start with synthetic URI for in-memory root + URI entryUri = URI.create("urn:inmemory:root"); + LOG.finest(() -> "compileBundle: Entry URI: " + entryUri); + workStack.push(new JsonSchema.WorkItem(entryUri)); + seenUris.add(entryUri); + + LOG.fine(() -> "compileBundle: Initialized work stack with entry URI: " + entryUri + ", workStack size: " + workStack.size()); + + // Process work stack + int processedCount = 0; + final int WORK_WARNING_THRESHOLD = 16; // Warn after processing 16 documents + + while (!workStack.isEmpty()) { + processedCount++; + final int finalProcessedCount = processedCount; + if (processedCount % WORK_WARNING_THRESHOLD == 0) { + LOG.warning(() -> "PERFORMANCE WARNING: compileBundle processing document " + finalProcessedCount + + " - large document chains may impact performance"); + } + + JsonSchema.WorkItem workItem = workStack.pop(); + URI currentUri = workItem.docUri(); + final int currentProcessedCount = processedCount; + LOG.finer(() -> "compileBundle: Processing URI: " + currentUri + " (processed count: " + currentProcessedCount + ")"); + + // Skip if already compiled + if (compiled.containsKey(currentUri)) { + LOG.finer(() -> "compileBundle: Already compiled, skipping: " + currentUri); + continue; + } + + // Handle remote URIs + JsonValue documentToCompile; + if (currentUri.equals(entryUri)) { + // Entry document - use provided schema + documentToCompile = schemaJson; + LOG.finer(() -> "compileBundle: Using entry document for URI: " + currentUri); + } else { + // Remote document - fetch it + LOG.finer(() -> "compileBundle: Fetching remote URI: " + currentUri); + + // Remove fragment from URI to get document URI + String fragment = currentUri.getFragment(); + URI docUri = fragment != null ? + URI.create(currentUri.toString().substring(0, currentUri.toString().indexOf('#'))) : + currentUri; + + LOG.finest(() -> "compileBundle: Document URI after fragment removal: " + docUri); + + // Enforce allowed schemes before invoking fetcher + String scheme = docUri.getScheme(); + LOG.fine(() -> "compileBundle: evaluating fetch for docUri=" + docUri + ", scheme=" + scheme + ", allowedSchemes=" + compileOptions.fetchPolicy().allowedSchemes()); + if (scheme == null || !compileOptions.fetchPolicy().allowedSchemes().contains(scheme)) { + throw new RemoteResolutionException( + docUri, + RemoteResolutionException.Reason.POLICY_DENIED, + "Scheme not allowed by policy: " + scheme + ); + } + + // Enforce global document count before fetching + if (session.fetchedDocs + 1 > compileOptions.fetchPolicy().maxDocuments()) { + throw new RemoteResolutionException( + docUri, + RemoteResolutionException.Reason.POLICY_DENIED, + "Maximum document count exceeded for " + docUri + ); + } + + JsonSchema.RemoteFetcher.FetchResult fetchResult = + compileOptions.remoteFetcher().fetch(docUri, compileOptions.fetchPolicy()); + + if (fetchResult.byteSize() > compileOptions.fetchPolicy().maxDocumentBytes()) { + throw new RemoteResolutionException( + docUri, + RemoteResolutionException.Reason.PAYLOAD_TOO_LARGE, + "Remote document exceeds max allowed bytes at " + docUri + ": " + fetchResult.byteSize() + ); + } + if (fetchResult.elapsed().isPresent() && fetchResult.elapsed().get().compareTo(compileOptions.fetchPolicy().timeout()) > 0) { + throw new RemoteResolutionException( + docUri, + RemoteResolutionException.Reason.TIMEOUT, + "Remote fetch exceeded timeout at " + docUri + ": " + fetchResult.elapsed().get() + ); + } + + // Update global counters and enforce total bytes across the compilation + session.fetchedDocs++; + session.totalFetchedBytes += fetchResult.byteSize(); + if (session.totalFetchedBytes > compileOptions.fetchPolicy().maxTotalBytes()) { + throw new RemoteResolutionException( + docUri, + RemoteResolutionException.Reason.POLICY_DENIED, + "Total fetched bytes exceeded policy across documents at " + docUri + ": " + session.totalFetchedBytes + ); + } + + documentToCompile = fetchResult.document(); + final String normType = documentToCompile.getClass().getSimpleName(); + final URI normUri = docUri; + LOG.fine(() -> "compileBundle: Successfully fetched document: " + normUri + ", document type: " + normType); + } + + // Compile the schema + JsonSchema.CompilationResult result = compileSingleDocument(session, documentToCompile, jsonSchemaOptions, currentUri, workStack, seenUris, compiled); + + // Create compiled root and add to map + JsonSchema.CompiledRoot compiledRoot = new JsonSchema.CompiledRoot(currentUri, result.schema(), result.pointerIndex()); + compiled.put(currentUri, compiledRoot); + LOG.fine(() -> "compileBundle: Added compiled root for URI: " + currentUri + + " with " + result.pointerIndex().size() + " pointer index entries"); + } + + // Create compilation bundle + JsonSchema.CompiledRoot entryRoot = compiled.get(entryUri); + if (entryRoot == null) { + LOG.severe(() -> "ERROR: SCHEMA: entry root null doc=" + entryUri); + } + assert entryRoot != null : "Entry root must exist"; + List allRoots = List.copyOf(compiled.values()); + + LOG.fine(() -> "compileBundle: Creating compilation bundle with " + allRoots.size() + " total compiled roots"); + + // Create a map of compiled roots for resolver context + Map rootsMap = new LinkedHashMap<>(); + LOG.finest(() -> "compileBundle: Creating rootsMap from " + allRoots.size() + " compiled roots"); + for (JsonSchema.CompiledRoot root : allRoots) { + LOG.finest(() -> "compileBundle: Adding root to map: " + root.docUri()); + // Add both with and without fragment for lookup flexibility + rootsMap.put(root.docUri(), root); + // Also add the base URI without fragment if it has one + if (root.docUri().getFragment() != null) { + URI baseUri = URI.create(root.docUri().toString().substring(0, root.docUri().toString().indexOf('#'))); + rootsMap.put(baseUri, root); + LOG.finest(() -> "compileBundle: Also adding base URI: " + baseUri); + } + } + LOG.finest(() -> "compileBundle: Final rootsMap keys: " + rootsMap.keySet()); + + // Create compilation bundle with compiled roots + List updatedRoots = List.copyOf(compiled.values()); + JsonSchema.CompiledRoot updatedEntryRoot = compiled.get(entryUri); + + LOG.fine(() -> "compileBundle: Successfully created compilation bundle with " + updatedRoots.size() + + " total documents compiled, entry root type: " + updatedEntryRoot.schema().getClass().getSimpleName()); + LOG.finest(() -> "compileBundle: Completed with entry root: " + updatedEntryRoot); + return new JsonSchema.CompilationBundle(updatedEntryRoot, updatedRoots); + } + + /// Compile a single document using new architecture + static JsonSchema.CompilationResult compileSingleDocument(Session session, JsonValue schemaJson, JsonSchema.JsonSchemaOptions jsonSchemaOptions, + URI docUri, Deque workStack, Set seenUris, + Map sharedRoots) { + LOG.fine(() -> "compileSingleDocument: Starting compilation for docUri: " + docUri + ", schema type: " + schemaJson.getClass().getSimpleName()); + + // Initialize session state + session.rawByPointer.clear(); + session.currentRootSchema = null; + session.currentJsonSchemaOptions = jsonSchemaOptions; + + LOG.finest(() -> "compileSingleDocument: Reset global state, definitions cleared, pointer indexes cleared"); + + // Handle format assertion controls + boolean assertFormats = jsonSchemaOptions.assertFormats(); + + // Check system property first (read once during compile) + String systemProp = System.getProperty("jsonschema.format.assertion"); + if (systemProp != null) { + assertFormats = Boolean.parseBoolean(systemProp); + final boolean finalAssertFormats = assertFormats; + LOG.finest(() -> "compileSingleDocument: Format assertion overridden by system property: " + finalAssertFormats); + } + + // Check root schema flag (highest precedence) + if (schemaJson instanceof JsonObject obj) { + JsonValue formatAssertionValue = obj.members().get("formatAssertion"); + if (formatAssertionValue instanceof JsonBoolean formatAssertionBool) { + assertFormats = formatAssertionBool.value(); + final boolean finalAssertFormats = assertFormats; + LOG.finest(() -> "compileSingleDocument: Format assertion overridden by root schema flag: " + finalAssertFormats); + } + } + + // Update jsonSchemaOptions with final assertion setting + session.currentJsonSchemaOptions = new JsonSchema.JsonSchemaOptions(assertFormats); + final boolean finalAssertFormats = assertFormats; + LOG.finest(() -> "compileSingleDocument: Final format assertion setting: " + finalAssertFormats); + + // Index the raw schema by JSON Pointer + LOG.finest(() -> "compileSingleDocument: Indexing schema by pointer"); + indexSchemaByPointer(session, "", schemaJson); + + // Build local pointer index for this document + Map localPointerIndex = new LinkedHashMap<>(); + + trace("compile-start", schemaJson); + LOG.finer(() -> "compileSingleDocument: Calling compileInternalWithContext for docUri: " + docUri); + CompileContext ctx = new CompileContext( + session, + sharedRoots, + new JsonSchema.ResolverContext(sharedRoots, localPointerIndex, AnySchema.INSTANCE), + localPointerIndex, + new ArrayDeque<>() + ); + // Initialize frame stack with entry doc and root pointer + ctx.frames.push(new ContextFrame(docUri, docUri, JsonSchema.SCHEMA_POINTER_ROOT, Map.of())); + JsonSchema schema = compileWithContext(ctx, schemaJson, docUri, workStack, seenUris); + LOG.finer(() -> "compileSingleDocument: compileInternalWithContext completed, schema type: " + schema.getClass().getSimpleName()); + + session.currentRootSchema = schema; // Store the root schema for self-references + LOG.fine(() -> "compileSingleDocument: Completed compilation for docUri: " + docUri + + ", schema type: " + schema.getClass().getSimpleName() + ", local pointer index size: " + localPointerIndex.size()); + return new JsonSchema.CompilationResult(schema, Map.copyOf(localPointerIndex)); + } + + private static JsonSchema compileWithContext(CompileContext ctx, + JsonValue schemaJson, + URI docUri, + Deque workStack, + Set seenUris) { + String basePointer = ctx.frames.isEmpty() ? JsonSchema.SCHEMA_POINTER_ROOT : ctx.frames.peek().pointer; + return compileInternalWithContext( + ctx.session, + schemaJson, + docUri, + workStack, + seenUris, + ctx.resolverContext, + ctx.localPointerIndex, + ctx.resolutionStack, + ctx.sharedRoots, + basePointer + ); + } + + private static JsonSchema compileInternalWithContext(Session session, JsonValue schemaJson, URI docUri, + Deque workStack, Set seenUris, + JsonSchema.ResolverContext resolverContext, + Map localPointerIndex, + Deque resolutionStack, + Map sharedRoots, + String basePointer) { + LOG.fine(() -> "compileInternalWithContext: Starting with schema: " + schemaJson + ", docUri: " + docUri); + + // Check for $ref at this level first + if (schemaJson instanceof JsonObject obj) { + JsonValue refValue = obj.members().get("$ref"); + if (refValue instanceof JsonString refStr) { + LOG.fine(() -> "compileInternalWithContext: Found $ref: " + refStr.value()); + JsonSchema.RefToken refToken = classifyRef(refStr.value(), docUri); + + // Handle remote refs by adding to work stack + RefSchema refSchema = new RefSchema(refToken, new JsonSchema.ResolverContext(sharedRoots, localPointerIndex, AnySchema.INSTANCE)); + if (refToken instanceof JsonSchema.RefToken.RemoteRef remoteRef) { + LOG.finer(() -> "Remote ref detected: " + remoteRef.targetUri()); + URI targetDocUri = stripFragment(remoteRef.targetUri()); + LOG.fine(() -> "Remote ref scheduling from docUri=" + docUri + " to target=" + targetDocUri); + LOG.finest(() -> "Remote ref parentMap before cycle check: " + session.parentMap); + if (formsRemoteCycle(session.parentMap, docUri, targetDocUri)) { + String cycleMessage = "ERROR: CYCLE: remote $ref cycle detected current=" + docUri + ", target=" + targetDocUri; + LOG.severe(() -> cycleMessage); + throw new IllegalStateException(cycleMessage); + } + boolean alreadySeen = seenUris.contains(targetDocUri); + LOG.finest(() -> "Remote ref alreadySeen=" + alreadySeen + " for target=" + targetDocUri); + if (!alreadySeen) { + workStack.push(new JsonSchema.WorkItem(targetDocUri)); + seenUris.add(targetDocUri); + session.parentMap.putIfAbsent(targetDocUri, docUri); + LOG.finer(() -> "Added to work stack: " + targetDocUri); + } else { + session.parentMap.putIfAbsent(targetDocUri, docUri); + LOG.finer(() -> "Remote ref already scheduled or compiled: " + targetDocUri); + } + LOG.finest(() -> "Remote ref parentMap after scheduling: " + session.parentMap); + LOG.finest(() -> "compileInternalWithContext: Creating RefSchema for remote ref " + remoteRef.targetUri()); + + LOG.fine(() -> "Creating RefSchema for remote ref " + remoteRef.targetUri() + + " with localPointerEntries=" + localPointerIndex.size()); + + LOG.finest(() -> "compileInternalWithContext: Created RefSchema " + refSchema); + return refSchema; + } + + // Handle local refs - check if they exist first and detect cycles + LOG.finer(() -> "Local ref detected, creating RefSchema: " + refToken.pointer()); + + String pointer = refToken.pointer(); + + // For compilation-time validation, check if the reference exists + if (!pointer.equals(JsonSchema.SCHEMA_POINTER_ROOT) && !pointer.isEmpty() && !localPointerIndex.containsKey(pointer)) { + // Check if it might be resolvable via JSON Pointer navigation + Optional target = navigatePointer(session.rawByPointer.get(""), pointer); + if (target.isEmpty() && basePointer != null && !basePointer.isEmpty() && pointer.startsWith(JsonSchema.SCHEMA_POINTER_PREFIX)) { + String combined = basePointer + pointer.substring(1); + target = navigatePointer(session.rawByPointer.get(""), combined); + } + if (target.isEmpty() && !pointer.startsWith(JsonSchema.SCHEMA_DEFS_POINTER)) { + throw new IllegalArgumentException("Unresolved $ref: " + pointer); + } + } + + // Check for cycles and resolve immediately for $defs references + if (pointer.startsWith(JsonSchema.SCHEMA_DEFS_POINTER)) { + // This is a definition reference - check for cycles and resolve immediately + if (resolutionStack.contains(pointer)) { + throw new IllegalArgumentException("CYCLE: Cyclic $ref: " + String.join(" -> ", resolutionStack) + " -> " + pointer); + } + + // Try to get from local pointer index first (for already compiled definitions) + JsonSchema cached = localPointerIndex.get(pointer); + if (cached != null) { + return cached; + } + + // Otherwise, resolve via JSON Pointer and compile + Optional target = navigatePointer(session.rawByPointer.get(""), pointer); + if (target.isEmpty() && pointer.startsWith(JsonSchema.SCHEMA_DEFS_POINTER)) { + // Heuristic fallback: locate the same named definition under any nested $defs + String defName = pointer.substring(JsonSchema.SCHEMA_DEFS_POINTER.length()); + // Perform a shallow search over indexed pointers for a matching suffix + for (var entry2 : session.rawByPointer.entrySet()) { + String k = entry2.getKey(); + if (k.endsWith(JsonSchema.SCHEMA_DEFS_SEGMENT + defName)) { + target = Optional.ofNullable(entry2.getValue()); + break; + } + } + } + if (target.isEmpty() && basePointer != null && !basePointer.isEmpty() && pointer.startsWith(JsonSchema.SCHEMA_POINTER_PREFIX)) { + String combined = basePointer + pointer.substring(1); + target = navigatePointer(session.rawByPointer.get(""), combined); + } + if (target.isPresent()) { + // Check if the target itself contains a $ref that would create a cycle + JsonValue targetValue = target.get(); + if (targetValue instanceof JsonObject targetObj) { + JsonValue targetRef = targetObj.members().get("$ref"); + if (targetRef instanceof JsonString targetRefStr) { + String targetRefPointer = targetRefStr.value(); + if (resolutionStack.contains(targetRefPointer)) { + throw new IllegalArgumentException("CYCLE: Cyclic $ref: " + String.join(" -> ", resolutionStack) + " -> " + pointer + " -> " + targetRefPointer); + } + } + } + + // Push to resolution stack for cycle detection before compiling + resolutionStack.push(pointer); + try { + JsonSchema compiled = compileInternalWithContext(session, targetValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer); + localPointerIndex.put(pointer, compiled); + return compiled; + } finally { + resolutionStack.pop(); + } + } else { + throw new IllegalArgumentException("Unresolved $ref: " + pointer); + } + } + + // Handle root reference (#) specially - use RootRef instead of RefSchema + if (pointer.equals(JsonSchema.SCHEMA_POINTER_ROOT) || pointer.isEmpty()) { + // For root reference, create RootRef that will resolve through ResolverContext + // The ResolverContext will be updated later with the proper root schema + return new RootRef(() -> { + // Prefer the session root once available, otherwise use resolver context placeholder. + if (session.currentRootSchema != null) { + return session.currentRootSchema; + } + if (resolverContext != null) { + return resolverContext.rootSchema(); + } + return AnySchema.INSTANCE; + }); + } + + // Create temporary resolver context with current document's pointer index + + LOG.fine(() -> "Creating temporary RefSchema for local ref " + refToken.pointer() + + " with " + localPointerIndex.size() + " local pointer entries"); + + // For other references, use RefSchema with deferred resolution + // Use a temporary resolver context that will be updated later + return refSchema; + } + } + + if (schemaJson instanceof JsonBoolean bool) { + return bool.value() ? AnySchema.INSTANCE : new NotSchema(AnySchema.INSTANCE); + } + + if (!(schemaJson instanceof JsonObject obj)) { + throw new IllegalArgumentException("Schema must be an object or boolean"); + } + + // Process definitions first and build pointer index + JsonValue defsValue = obj.members().get("$defs"); + if (defsValue instanceof JsonObject defsObj) { + trace("compile-defs", defsValue); + for (var entry : defsObj.members().entrySet()) { + String pointer = (basePointer == null || basePointer.isEmpty()) ? JsonSchema.SCHEMA_DEFS_POINTER + entry.getKey() : basePointer + "/$defs/" + entry.getKey(); + JsonSchema compiled = compileInternalWithContext(session, entry.getValue(), docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, pointer); + localPointerIndex.put(pointer, compiled); + + // Also index by $anchor if present + if (entry.getValue() instanceof JsonObject defObj) { + JsonValue anchorValue = defObj.members().get("$anchor"); + if (anchorValue instanceof JsonString anchorStr) { + String anchorPointer = JsonSchema.SCHEMA_POINTER_ROOT + anchorStr.value(); + localPointerIndex.put(anchorPointer, compiled); + LOG.finest(() -> "Indexed $anchor '" + anchorStr.value() + "' as " + anchorPointer); + } + } + } + } + + // Handle composition keywords + JsonValue allOfValue = obj.members().get("allOf"); + if (allOfValue instanceof JsonArray allOfArr) { + trace("compile-allof", allOfValue); + List schemas = new ArrayList<>(); + for (JsonValue item : allOfArr.values()) { + schemas.add(compileInternalWithContext(session, item, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer)); + } + return new AllOfSchema(schemas); + } + + JsonValue anyOfValue = obj.members().get("anyOf"); + if (anyOfValue instanceof JsonArray anyOfArr) { + trace("compile-anyof", anyOfValue); + List schemas = new ArrayList<>(); + for (JsonValue item : anyOfArr.values()) { + schemas.add(compileInternalWithContext(session, item, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer)); + } + return new AnyOfSchema(schemas); + } + + JsonValue oneOfValue = obj.members().get("oneOf"); + if (oneOfValue instanceof JsonArray oneOfArr) { + trace("compile-oneof", oneOfValue); + List schemas = new ArrayList<>(); + for (JsonValue item : oneOfArr.values()) { + schemas.add(compileInternalWithContext(session, item, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer)); + } + return new OneOfSchema(schemas); + } + + // Handle if/then/else + JsonValue ifValue = obj.members().get("if"); + if (ifValue != null) { + trace("compile-conditional", obj); + JsonSchema ifSchema = compileInternalWithContext(session, ifValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer); + JsonSchema thenSchema = null; + JsonSchema elseSchema = null; + + JsonValue thenValue = obj.members().get("then"); + if (thenValue != null) { + thenSchema = compileInternalWithContext(session, thenValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer); + } + + JsonValue elseValue = obj.members().get("else"); + if (elseValue != null) { + elseSchema = compileInternalWithContext(session, elseValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, basePointer); + } + + return new ConditionalSchema(ifSchema, thenSchema, elseSchema); + } + + // Handle const + JsonValue constValue = obj.members().get("const"); + if (constValue != null) { + return new ConstSchema(constValue); + } + + // Handle not + JsonValue notValue = obj.members().get("not"); + if (notValue != null) { + JsonSchema inner = compileInternalWithContext(session, notValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + return new NotSchema(inner); + } + + // Detect keyword-based schema types for use in enum handling and fallback + boolean hasObjectKeywords = obj.members().containsKey("properties") + || obj.members().containsKey("required") + || obj.members().containsKey("additionalProperties") + || obj.members().containsKey("minProperties") + || obj.members().containsKey("maxProperties") + || obj.members().containsKey("patternProperties") + || obj.members().containsKey("propertyNames") + || obj.members().containsKey("dependentRequired") + || obj.members().containsKey("dependentSchemas"); + + boolean hasArrayKeywords = obj.members().containsKey("items") + || obj.members().containsKey("minItems") + || obj.members().containsKey("maxItems") + || obj.members().containsKey("uniqueItems") + || obj.members().containsKey("prefixItems") + || obj.members().containsKey("contains") + || obj.members().containsKey("minContains") + || obj.members().containsKey("maxContains"); + + boolean hasStringKeywords = obj.members().containsKey("pattern") + || obj.members().containsKey("minLength") + || obj.members().containsKey("maxLength") + || obj.members().containsKey("format"); + + // Handle enum early (before type-specific compilation) + JsonValue enumValue = obj.members().get("enum"); + if (enumValue instanceof JsonArray enumArray) { + // Build base schema from type or heuristics + JsonSchema baseSchema; + + // If type is specified, use it; otherwise infer from keywords + JsonValue typeValue = obj.members().get("type"); + if (typeValue instanceof JsonString typeStr) { + baseSchema = switch (typeStr.value()) { + case "object" -> + compileObjectSchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + case "array" -> + compileArraySchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + case "string" -> compileStringSchemaWithContext(session, obj); + case "number", "integer" -> compileNumberSchemaWithContext(obj); + case "boolean" -> new BooleanSchema(); + case "null" -> new NullSchema(); + default -> AnySchema.INSTANCE; + }; + } else if (hasObjectKeywords) { + baseSchema = compileObjectSchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + } else if (hasArrayKeywords) { + baseSchema = compileArraySchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + } else if (hasStringKeywords) { + baseSchema = compileStringSchemaWithContext(session, obj); + } else { + baseSchema = AnySchema.INSTANCE; + } + + // Build enum values set + Set allowedValues = new LinkedHashSet<>(enumArray.values()); + + return new EnumSchema(baseSchema, allowedValues); + } + + // Handle type-based schemas + JsonValue typeValue = obj.members().get("type"); + if (typeValue instanceof JsonString typeStr) { + return switch (typeStr.value()) { + case "object" -> + compileObjectSchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + case "array" -> + compileArraySchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + case "string" -> compileStringSchemaWithContext(session, obj); + case "number" -> compileNumberSchemaWithContext(obj); + case "integer" -> compileNumberSchemaWithContext(obj); // For now, treat integer as number + case "boolean" -> new BooleanSchema(); + case "null" -> new NullSchema(); + default -> AnySchema.INSTANCE; + }; + } else if (typeValue instanceof JsonArray typeArray) { + // Handle type arrays: ["string", "null", ...] - treat as anyOf + List typeSchemas = new ArrayList<>(); + for (JsonValue item : typeArray.values()) { + if (item instanceof JsonString typeStr) { + JsonSchema typeSchema = switch (typeStr.value()) { + case "object" -> + compileObjectSchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + case "array" -> + compileArraySchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + case "string" -> compileStringSchemaWithContext(session, obj); + case "number", "integer" -> compileNumberSchemaWithContext(obj); + case "boolean" -> new BooleanSchema(); + case "null" -> new NullSchema(); + default -> AnySchema.INSTANCE; + }; + typeSchemas.add(typeSchema); + } else { + throw new IllegalArgumentException("Type array must contain only strings"); + } + } + if (typeSchemas.isEmpty()) { + return AnySchema.INSTANCE; + } else if (typeSchemas.size() == 1) { + return typeSchemas.getFirst(); + } else { + return new AnyOfSchema(typeSchemas); + } + } else { + if (hasObjectKeywords) { + return compileObjectSchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + } else if (hasArrayKeywords) { + return compileArraySchemaWithContext(session, obj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + } else if (hasStringKeywords) { + return compileStringSchemaWithContext(session, obj); + } + } + + return AnySchema.INSTANCE; + } + + // Overload: preserve existing call sites with explicit resolverContext and resolutionStack + private static JsonSchema compileInternalWithContext(Session session, JsonValue schemaJson, URI docUri, + Deque workStack, Set seenUris, + JsonSchema.ResolverContext resolverContext, + Map localPointerIndex, + Deque resolutionStack, + Map sharedRoots) { + return compileInternalWithContext(session, schemaJson, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots, JsonSchema.SCHEMA_POINTER_ROOT); + } + + /// Object schema compilation with context + private static JsonSchema compileObjectSchemaWithContext(Session session, JsonObject obj, URI docUri, Deque workStack, Set seenUris, JsonSchema.ResolverContext resolverContext, Map localPointerIndex, Deque resolutionStack, Map sharedRoots) { + LOG.finest(() -> "compileObjectSchemaWithContext: Starting with object: " + obj); + Map properties = new LinkedHashMap<>(); + JsonValue propsValue = obj.members().get("properties"); + if (propsValue instanceof JsonObject propsObj) { + LOG.finest(() -> "compileObjectSchemaWithContext: Processing properties: " + propsObj); + for (var entry : propsObj.members().entrySet()) { + LOG.finest(() -> "compileObjectSchemaWithContext: Compiling property '" + entry.getKey() + "': " + entry.getValue()); + // Push a context frame for this property + // (Currently used for diagnostics and future pointer derivations) + // Pop immediately after child compile + JsonSchema propertySchema; + // Best-effort: if we can see a CompileContext via resolverContext, skip; we don't expose it. So just compile. + propertySchema = compileInternalWithContext(session, entry.getValue(), docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + LOG.finest(() -> "compileObjectSchemaWithContext: Property '" + entry.getKey() + "' compiled to: " + propertySchema); + properties.put(entry.getKey(), propertySchema); + + // Add to pointer index + String pointer = JsonSchema.SCHEMA_POINTER_ROOT + JsonSchema.SCHEMA_PROPERTIES_SEGMENT + entry.getKey(); + localPointerIndex.put(pointer, propertySchema); + } + } + + Set required = new LinkedHashSet<>(); + JsonValue reqValue = obj.members().get("required"); + if (reqValue instanceof JsonArray reqArray) { + for (JsonValue item : reqArray.values()) { + if (item instanceof JsonString str) { + required.add(str.value()); + } + } + } + + JsonSchema additionalProperties = AnySchema.INSTANCE; + JsonValue addPropsValue = obj.members().get("additionalProperties"); + if (addPropsValue instanceof JsonBoolean addPropsBool) { + additionalProperties = addPropsBool.value() ? AnySchema.INSTANCE : BooleanSchema.FALSE; + } else if (addPropsValue instanceof JsonObject addPropsObj) { + additionalProperties = compileInternalWithContext(session, addPropsObj, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + } + + // Handle patternProperties + Map patternProperties = null; + JsonValue patternPropsValue = obj.members().get("patternProperties"); + if (patternPropsValue instanceof JsonObject patternPropsObj) { + patternProperties = new LinkedHashMap<>(); + for (var entry : patternPropsObj.members().entrySet()) { + String patternStr = entry.getKey(); + Pattern pattern = Pattern.compile(patternStr); + JsonSchema schema = compileInternalWithContext(session, entry.getValue(), docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + patternProperties.put(pattern, schema); + } + } + + // Handle propertyNames + JsonSchema propertyNames = null; + JsonValue propNamesValue = obj.members().get("propertyNames"); + if (propNamesValue != null) { + propertyNames = compileInternalWithContext(session, propNamesValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + } + + Integer minProperties = getInteger(obj, "minProperties"); + Integer maxProperties = getInteger(obj, "maxProperties"); + + // Handle dependentRequired + Map> dependentRequired = null; + JsonValue depReqValue = obj.members().get("dependentRequired"); + if (depReqValue instanceof JsonObject depReqObj) { + dependentRequired = new LinkedHashMap<>(); + for (var entry : depReqObj.members().entrySet()) { + String triggerProp = entry.getKey(); + JsonValue depsValue = entry.getValue(); + if (depsValue instanceof JsonArray depsArray) { + Set requiredProps = new LinkedHashSet<>(); + for (JsonValue depItem : depsArray.values()) { + if (depItem instanceof JsonString depStr) { + requiredProps.add(depStr.value()); + } else { + throw new IllegalArgumentException("dependentRequired values must be arrays of strings"); + } + } + dependentRequired.put(triggerProp, requiredProps); + } else { + throw new IllegalArgumentException("dependentRequired values must be arrays"); + } + } + } + + // Handle dependentSchemas + Map dependentSchemas = null; + JsonValue depSchValue = obj.members().get("dependentSchemas"); + if (depSchValue instanceof JsonObject depSchObj) { + dependentSchemas = new LinkedHashMap<>(); + for (var entry : depSchObj.members().entrySet()) { + String triggerProp = entry.getKey(); + JsonValue schemaValue = entry.getValue(); + JsonSchema schema; + if (schemaValue instanceof JsonBoolean boolValue) { + schema = boolValue.value() ? AnySchema.INSTANCE : BooleanSchema.FALSE; + } else { + schema = compileInternalWithContext(session, schemaValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + } + dependentSchemas.put(triggerProp, schema); + } + } + + return new ObjectSchema(properties, required, additionalProperties, minProperties, maxProperties, patternProperties, propertyNames, dependentRequired, dependentSchemas); + } + + /// Array schema compilation with context + private static JsonSchema compileArraySchemaWithContext(Session session, JsonObject obj, URI docUri, Deque workStack, Set seenUris, JsonSchema.ResolverContext resolverContext, Map localPointerIndex, Deque resolutionStack, Map sharedRoots) { + JsonSchema items = AnySchema.INSTANCE; + JsonValue itemsValue = obj.members().get("items"); + if (itemsValue != null) { + items = compileInternalWithContext(session, itemsValue, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + } + + // Parse prefixItems (tuple validation) + List prefixItems = null; + JsonValue prefixItemsVal = obj.members().get("prefixItems"); + if (prefixItemsVal instanceof JsonArray arr) { + prefixItems = new ArrayList<>(arr.values().size()); + for (JsonValue v : arr.values()) { + prefixItems.add(compileInternalWithContext(session, v, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots)); + } + prefixItems = List.copyOf(prefixItems); + } + + // Parse contains schema + JsonSchema contains = null; + JsonValue containsVal = obj.members().get("contains"); + if (containsVal != null) { + contains = compileInternalWithContext(session, containsVal, docUri, workStack, seenUris, resolverContext, localPointerIndex, resolutionStack, sharedRoots); + } + + // Parse minContains / maxContains + Integer minContains = getInteger(obj, "minContains"); + Integer maxContains = getInteger(obj, "maxContains"); + + Integer minItems = getInteger(obj, "minItems"); + Integer maxItems = getInteger(obj, "maxItems"); + Boolean uniqueItems = getBoolean(obj, "uniqueItems"); + + return new ArraySchema(items, minItems, maxItems, uniqueItems, prefixItems, contains, minContains, maxContains); + } + + /// String schema compilation with context + private static JsonSchema compileStringSchemaWithContext(Session session, JsonObject obj) { + Integer minLength = getInteger(obj, "minLength"); + Integer maxLength = getInteger(obj, "maxLength"); + + Pattern pattern = null; + JsonValue patternValue = obj.members().get("pattern"); + if (patternValue instanceof JsonString patternStr) { + pattern = Pattern.compile(patternStr.value()); + } + + // Handle format keyword + FormatValidator formatValidator = null; + boolean assertFormats = session.currentJsonSchemaOptions != null && session.currentJsonSchemaOptions.assertFormats(); + + if (assertFormats) { + JsonValue formatValue = obj.members().get("format"); + if (formatValue instanceof JsonString formatStr) { + String formatName = formatStr.value(); + formatValidator = Format.byName(formatName); + if (formatValidator == null) { + LOG.fine("Unknown format: " + formatName); + } + } + } + + return new StringSchema(minLength, maxLength, pattern, formatValidator, assertFormats); + } + + /// Number schema compilation with context + private static JsonSchema compileNumberSchemaWithContext(JsonObject obj) { + BigDecimal minimum = getBigDecimal(obj, "minimum"); + BigDecimal maximum = getBigDecimal(obj, "maximum"); + BigDecimal multipleOf = getBigDecimal(obj, "multipleOf"); + Boolean exclusiveMinimum = getBoolean(obj, "exclusiveMinimum"); + Boolean exclusiveMaximum = getBoolean(obj, "exclusiveMaximum"); + + // Handle numeric exclusiveMinimum/exclusiveMaximum (2020-12 spec) + BigDecimal exclusiveMinValue = getBigDecimal(obj, "exclusiveMinimum"); + BigDecimal exclusiveMaxValue = getBigDecimal(obj, "exclusiveMaximum"); + + // Normalize: if numeric exclusives are present, convert to boolean form + if (exclusiveMinValue != null) { + minimum = exclusiveMinValue; + exclusiveMinimum = true; + } + if (exclusiveMaxValue != null) { + maximum = exclusiveMaxValue; + exclusiveMaximum = true; + } + + return new NumberSchema(minimum, maximum, multipleOf, exclusiveMinimum, exclusiveMaximum); + } + + private static Integer getInteger(JsonObject obj, String key) { + JsonValue value = obj.members().get(key); + if (value instanceof JsonNumber num) { + Number n = num.toNumber(); + if (n instanceof Integer i) return i; + if (n instanceof Long l) return l.intValue(); + if (n instanceof BigDecimal bd) return bd.intValue(); + } + return null; + } + + private static Boolean getBoolean(JsonObject obj, String key) { + JsonValue value = obj.members().get(key); + if (value instanceof JsonBoolean bool) { + return bool.value(); + } + return null; + } + + private static BigDecimal getBigDecimal(JsonObject obj, String key) { + JsonValue value = obj.members().get(key); + if (value instanceof JsonNumber num) { + Number n = num.toNumber(); + if (n instanceof BigDecimal) return (BigDecimal) n; + if (n instanceof BigInteger) return new BigDecimal((BigInteger) n); + return BigDecimal.valueOf(n.doubleValue()); + } + return null; + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/SchemaLogging.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/SchemaLogging.java deleted file mode 100644 index 08a462f..0000000 --- a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/SchemaLogging.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.simbo1905.json.schema; - -import java.util.logging.Logger; - -/// Centralized logger for the JSON Schema subsystem. -/// All classes must use this logger via: -/// import static io.github.simbo1905.json.schema.SchemaLogging.LOG; -final class SchemaLogging { - public static final Logger LOG = Logger.getLogger("io.github.simbo1905.json.schema"); - private SchemaLogging() {} -} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/StringSchema.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/StringSchema.java new file mode 100644 index 0000000..08a80b3 --- /dev/null +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/StringSchema.java @@ -0,0 +1,55 @@ +package io.github.simbo1905.json.schema; + +import jdk.sandbox.java.util.json.JsonString; +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.regex.Pattern; + +/// String schema with length, pattern, and enum constraints +public record StringSchema( + Integer minLength, + Integer maxLength, + Pattern pattern, + FormatValidator formatValidator, + boolean assertFormats +) implements JsonSchema { + + @Override + public ValidationResult validateAt(String path, JsonValue json, Deque stack) { + if (!(json instanceof JsonString str)) { + return ValidationResult.failure(List.of( + new ValidationError(path, "Expected string") + )); + } + + String value = str.value(); + List errors = new ArrayList<>(); + + // Check length constraints + int length = value.length(); + if (minLength != null && length < minLength) { + errors.add(new ValidationError(path, "String too short: expected at least " + minLength + " characters")); + } + if (maxLength != null && length > maxLength) { + errors.add(new ValidationError(path, "String too long: expected at most " + maxLength + " characters")); + } + + // Check pattern (unanchored matching - uses find() instead of matches()) + if (pattern != null && !pattern.matcher(value).find()) { + errors.add(new ValidationError(path, "Pattern mismatch")); + } + + // Check format validation (only when format assertion is enabled) + if (formatValidator != null && assertFormats) { + if (!formatValidator.test(value)) { + String formatName = formatValidator instanceof Format format ? format.name().toLowerCase().replace("_", "-") : "unknown"; + errors.add(new ValidationError(path, "Invalid format '" + formatName + "'")); + } + } + + return errors.isEmpty() ? ValidationResult.success() : ValidationResult.failure(errors); + } +} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/StructuredLog.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/StructuredLog.java deleted file mode 100644 index cea7b0c..0000000 --- a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/StructuredLog.java +++ /dev/null @@ -1,93 +0,0 @@ -package io.github.simbo1905.json.schema; - -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Supplier; -import java.util.logging.Level; -import java.util.logging.Logger; - -/// Package-private helper for structured JUL logging with simple sampling. -/// Produces concise key=value pairs prefixed by event=NAME. -final class StructuredLog { - private static final Map COUNTERS = new ConcurrentHashMap<>(); - - static void fine(Logger log, String event, Object... kv) { - if (log.isLoggable(Level.FINE)) log.fine(() -> ev(event, kv)); - } - - static void finer(Logger log, String event, Object... kv) { - if (log.isLoggable(Level.FINER)) log.finer(() -> ev(event, kv)); - } - - static void finest(Logger log, String event, Object... kv) { - if (log.isLoggable(Level.FINEST)) log.finest(() -> ev(event, kv)); - } - - static void finest(Logger log, String event, Supplier> supplier) { - if (!log.isLoggable(Level.FINEST)) return; - Map m = supplier.get(); - Object[] kv = new Object[m.size() * 2]; - int i = 0; - for (var e : m.entrySet()) { - kv[i++] = e.getKey(); - kv[i++] = e.getValue(); - } - log.finest(() -> ev(event, kv)); - } - - /// Log at FINEST but only every Nth occurrence per event key. - static void finestSampled(Logger log, String event, int everyN, Object... kv) { - if (!log.isLoggable(Level.FINEST)) return; - if (everyN <= 1) { - log.finest(() -> ev(event, kv)); - return; - } - long n = COUNTERS.computeIfAbsent(event, k -> new AtomicLong()).incrementAndGet(); - if (n % everyN == 0L) { - log.finest(() -> ev(event, kv("sample", n, kv))); - } - } - - private static Object[] kv(String k, Object v, Object... rest) { - Object[] out = new Object[2 + rest.length]; - out[0] = k; out[1] = v; - System.arraycopy(rest, 0, out, 2, rest.length); - return out; - } - - static String ev(String event, Object... kv) { - StringBuilder sb = new StringBuilder(64); - sb.append("event=").append(sanitize(event)); - for (int i = 0; i + 1 < kv.length; i += 2) { - Object key = kv[i]; - Object val = kv[i + 1]; - if (key == null) continue; - String k = key.toString(); - String v = val == null ? "null" : sanitize(val.toString()); - sb.append(' ').append(k).append('='); - // quote if contains whitespace - if (needsQuotes(v)) sb.append('"').append(v).append('"'); else sb.append(v); - } - return sb.toString(); - } - - private static boolean needsQuotes(String s) { - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - if (Character.isWhitespace(c)) return true; - if (c == '"') return true; - } - return false; - } - - private static String sanitize(String s) { - if (s == null) return "null"; - // Trim overly long payloads to keep logs readable - final int MAX = 256; - String trimmed = s.length() > MAX ? s.substring(0, MAX) + "…" : s; - // Collapse newlines and tabs - return trimmed.replace('\n', ' ').replace('\r', ' ').replace('\t', ' '); - } -} diff --git a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/VirtualThreadHttpFetcher.java b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/VirtualThreadHttpFetcher.java index 836cf00..f74429c 100644 --- a/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/VirtualThreadHttpFetcher.java +++ b/json-java21-schema/src/main/java/io/github/simbo1905/json/schema/VirtualThreadHttpFetcher.java @@ -21,7 +21,8 @@ import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; -import static io.github.simbo1905.json.schema.SchemaLogging.LOG; + +import static io.github.simbo1905.json.schema.JsonSchema.LOG; /// `RemoteFetcher` implementation that performs blocking HTTP requests /// on Java 21 virtual threads. Reuses responses via an in-memory cache @@ -32,22 +33,32 @@ final class VirtualThreadHttpFetcher implements JsonSchema.RemoteFetcher { private final ConcurrentMap cache = new ConcurrentHashMap<>(); private final AtomicInteger documentCount = new AtomicInteger(); private final AtomicLong totalBytes = new AtomicLong(); + private final String scheme; - VirtualThreadHttpFetcher() { - this(HttpClient.newBuilder().build()); - // Centralized network logging banner - LOG.config(() -> "http.fetcher init redirectPolicy=default timeout=" + 0 + "ms"); + VirtualThreadHttpFetcher(String scheme) { + this(scheme, HttpClient.newBuilder().build()); + LOG.config(() -> "http.fetcher init scheme=" + this.scheme); } - VirtualThreadHttpFetcher(HttpClient client) { + VirtualThreadHttpFetcher(String scheme, HttpClient client) { + this.scheme = Objects.requireNonNull(scheme, "scheme").toLowerCase(Locale.ROOT); this.client = client; } @Override - public FetchResult fetch(URI uri, JsonSchema.FetchPolicy policy) { + public String scheme() { + return scheme; + } + + @Override + public FetchResult fetch(URI uri, FetchPolicy policy) { Objects.requireNonNull(uri, "uri"); Objects.requireNonNull(policy, "policy"); - ensureSchemeAllowed(uri, policy.allowedSchemes()); + String uriScheme = ensureSchemeAllowed(uri, policy.allowedSchemes()); + if (!scheme.equals(uriScheme)) { + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.POLICY_DENIED, + "Fetcher configured for scheme " + scheme + " but received " + uriScheme); + } FetchResult cached = cache.get(uri); if (cached != null) { @@ -60,25 +71,25 @@ public FetchResult fetch(URI uri, JsonSchema.FetchPolicy policy) { return previous != null ? previous : fetched; } - private FetchResult fetchOnVirtualThread(URI uri, JsonSchema.FetchPolicy policy) { + private FetchResult fetchOnVirtualThread(URI uri, FetchPolicy policy) { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { Future future = executor.submit(() -> performFetch(uri, policy)); return future.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); LOG.severe(() -> "ERROR: FETCH: " + uri + " - interrupted TIMEOUT"); - throw new JsonSchema.RemoteResolutionException(uri, JsonSchema.RemoteResolutionException.Reason.TIMEOUT, "Interrupted while fetching " + uri, e); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.TIMEOUT, "Interrupted while fetching " + uri, e); } catch (java.util.concurrent.ExecutionException e) { Throwable cause = e.getCause(); - if (cause instanceof JsonSchema.RemoteResolutionException ex) { + if (cause instanceof RemoteResolutionException ex) { throw ex; } LOG.severe(() -> "ERROR: FETCH: " + uri + " - exec NETWORK_ERROR"); - throw new JsonSchema.RemoteResolutionException(uri, JsonSchema.RemoteResolutionException.Reason.NETWORK_ERROR, "Failed fetching " + uri, cause); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.NETWORK_ERROR, "Failed fetching " + uri, cause); } } - private FetchResult performFetch(URI uri, JsonSchema.FetchPolicy policy) { + private FetchResult performFetch(URI uri, FetchPolicy policy) { enforceDocumentLimits(uri, policy); LOG.finer(() -> "http.fetch start method=GET uri=" + uri); @@ -94,7 +105,7 @@ private FetchResult performFetch(URI uri, JsonSchema.FetchPolicy policy) { int status = response.statusCode(); if (status / 100 != 2) { LOG.severe(() -> "ERROR: FETCH: " + uri + " - " + status + " NOT_FOUND"); - throw new JsonSchema.RemoteResolutionException(uri, JsonSchema.RemoteResolutionException.Reason.NOT_FOUND, "HTTP " + status + " fetching " + uri); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.NOT_FOUND, "HTTP " + status + " fetching " + uri); } // Stream with hard cap to enforce maxDocumentBytes during read @@ -110,7 +121,7 @@ private FetchResult performFetch(URI uri, JsonSchema.FetchPolicy policy) { readTotal += n; if (readTotal > cap) { LOG.severe(() -> "ERROR: FETCH: " + uri + " - 413 PAYLOAD_TOO_LARGE"); - throw new JsonSchema.RemoteResolutionException(uri, JsonSchema.RemoteResolutionException.Reason.PAYLOAD_TOO_LARGE, "Payload too large for " + uri); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.PAYLOAD_TOO_LARGE, "Payload too large for " + uri); } out.write(buf, 0, n); } @@ -120,7 +131,7 @@ private FetchResult performFetch(URI uri, JsonSchema.FetchPolicy policy) { long total = totalBytes.addAndGet(bytes.length); if (total > policy.maxTotalBytes()) { LOG.severe(() -> "ERROR: FETCH: " + uri + " - policy TOTAL_BYTES_EXCEEDED"); - throw new JsonSchema.RemoteResolutionException(uri, JsonSchema.RemoteResolutionException.Reason.POLICY_DENIED, "Total fetched bytes exceeded policy for " + uri); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.POLICY_DENIED, "Total fetched bytes exceeded policy for " + uri); } String body = new String(bytes, StandardCharsets.UTF_8); @@ -130,28 +141,29 @@ private FetchResult performFetch(URI uri, JsonSchema.FetchPolicy policy) { return new FetchResult(json, bytes.length, Optional.of(elapsed)); } catch (HttpTimeoutException e) { LOG.severe(() -> "ERROR: FETCH: " + uri + " - timeout TIMEOUT"); - throw new JsonSchema.RemoteResolutionException(uri, JsonSchema.RemoteResolutionException.Reason.TIMEOUT, "Fetch timeout for " + uri, e); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.TIMEOUT, "Fetch timeout for " + uri, e); } catch (IOException e) { LOG.severe(() -> "ERROR: FETCH: " + uri + " - io NETWORK_ERROR"); - throw new JsonSchema.RemoteResolutionException(uri, JsonSchema.RemoteResolutionException.Reason.NETWORK_ERROR, "I/O error fetching " + uri, e); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.NETWORK_ERROR, "I/O error fetching " + uri, e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); LOG.severe(() -> "ERROR: FETCH: " + uri + " - interrupted TIMEOUT"); - throw new JsonSchema.RemoteResolutionException(uri, JsonSchema.RemoteResolutionException.Reason.TIMEOUT, "Interrupted fetching " + uri, e); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.TIMEOUT, "Interrupted fetching " + uri, e); } } - private void ensureSchemeAllowed(URI uri, Set allowedSchemes) { - String scheme = uri.getScheme(); - if (scheme == null || !allowedSchemes.contains(scheme.toLowerCase(Locale.ROOT))) { - throw new JsonSchema.RemoteResolutionException(uri, JsonSchema.RemoteResolutionException.Reason.POLICY_DENIED, "Disallowed scheme: " + scheme); + private String ensureSchemeAllowed(URI uri, Set allowedSchemes) { + String uriScheme = uri.getScheme(); + if (uriScheme == null || !allowedSchemes.contains(uriScheme.toLowerCase(Locale.ROOT))) { + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.POLICY_DENIED, "Disallowed scheme: " + uriScheme); } + return uriScheme.toLowerCase(Locale.ROOT); } - private void enforceDocumentLimits(URI uri, JsonSchema.FetchPolicy policy) { + private void enforceDocumentLimits(URI uri, FetchPolicy policy) { int docs = documentCount.incrementAndGet(); if (docs > policy.maxDocuments()) { - throw new JsonSchema.RemoteResolutionException(uri, JsonSchema.RemoteResolutionException.Reason.POLICY_DENIED, "Maximum document count exceeded for " + uri); + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.POLICY_DENIED, "Maximum document count exceeded for " + uri); } } @@ -162,7 +174,7 @@ JsonValue fetchSchemaJson(java.net.URI docUri) { try { long start = System.nanoTime(); - JsonSchema.FetchPolicy policy = JsonSchema.FetchPolicy.defaults(); + FetchPolicy policy = FetchPolicy.defaults(); LOG.finest(() -> "fetchSchemaJson: policy object=" + policy + ", allowedSchemes=" + policy.allowedSchemes() + ", maxDocumentBytes=" + policy.maxDocumentBytes() + ", timeout=" + policy.timeout()); JsonSchema.RemoteFetcher.FetchResult result = fetch(docUri, policy); @@ -173,12 +185,12 @@ JsonValue fetchSchemaJson(java.net.URI docUri) { LOG.finest(() -> "fetchSchemaJson: returning document object=" + result.document() + ", type=" + result.document().getClass().getSimpleName() + ", content=" + result.document().toString()); return result.document(); - } catch (JsonSchema.RemoteResolutionException e) { + } catch (RemoteResolutionException e) { // Already logged by the fetch path; rethrow throw e; } catch (Exception e) { LOG.severe(() -> "ERROR: FETCH: " + docUri + " - unexpected NETWORK_ERROR"); - throw new JsonSchema.RemoteResolutionException(docUri, JsonSchema.RemoteResolutionException.Reason.NETWORK_ERROR, "Failed to fetch schema", e); + throw new RemoteResolutionException(docUri, RemoteResolutionException.Reason.NETWORK_ERROR, "Failed to fetch schema", e); } } } diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaCheckIT.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaCheckIT.java index 9cfb13c..005c304 100644 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaCheckIT.java +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaCheckIT.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assumptions; -import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.nio.file.Files; @@ -17,7 +16,6 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.LongAdder; import java.util.stream.Stream; import java.util.stream.StreamSupport; @@ -108,93 +106,97 @@ Stream testsFromFile(Path file) { } METRICS.testsDiscovered.add(testCount); perFile(file).tests.add(testCount); - - return StreamSupport.stream(root.spliterator(), false) - .flatMap(group -> { - final var groupDesc = group.get("description").asText(); - try { - /// Attempt to compile the schema for this group; if unsupported features - /// (e.g., unresolved anchors) are present, skip this group gracefully. - final var schema = JsonSchema.compile( - Json.parse(group.get("schema").toString())); - - return StreamSupport.stream(group.get("tests").spliterator(), false) - .map(test -> DynamicTest.dynamicTest( - groupDesc + " – " + test.get("description").asText(), - () -> { - final var expected = test.get("valid").asBoolean(); - final boolean actual; - try { - actual = schema.validate( - Json.parse(test.get("data").toString())).valid(); - - /// Count validation attempt - METRICS.run.increment(); - perFile(file).run.increment(); - } catch (Exception e) { - final var reason = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); - System.err.println("[JsonSchemaCheckIT] Skipping test due to exception: " - + groupDesc + " — " + reason + " (" + file.getFileName() + ")"); - - /// Count exception as skipped mismatch in strict metrics - METRICS.skippedMismatch.increment(); - perFile(file).skipMismatch.increment(); - - if (isStrict()) throw e; - Assumptions.assumeTrue(false, "Skipped: " + reason); - return; /// not reached when strict - } - if (isStrict()) { - try { - assertEquals(expected, actual); - /// Count pass in strict mode - METRICS.passed.increment(); - perFile(file).pass.increment(); - } catch (AssertionError e) { - /// Count failure in strict mode - METRICS.failed.increment(); - perFile(file).fail.increment(); - throw e; - } - } else if (expected != actual) { - System.err.println("[JsonSchemaCheckIT] Mismatch (ignored): " - + groupDesc + " — expected=" + expected + ", actual=" + actual - + " (" + file.getFileName() + ")"); - - /// Count lenient mismatch skip - METRICS.skippedMismatch.increment(); - perFile(file).skipMismatch.increment(); - - Assumptions.assumeTrue(false, "Mismatch ignored"); - } else { - /// Count pass in lenient mode - METRICS.passed.increment(); - perFile(file).pass.increment(); - } - })); - } catch (Exception ex) { - /// Unsupported schema for this group; emit a single skipped test for visibility - final var reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage(); - System.err.println("[JsonSchemaCheckIT] Skipping group due to unsupported schema: " - + groupDesc + " — " + reason + " (" + file.getFileName() + ")"); - - /// Count unsupported group skip - METRICS.skippedUnsupported.increment(); - perFile(file).skipUnsupported.increment(); - - return Stream.of(DynamicTest.dynamicTest( - groupDesc + " – SKIPPED: " + reason, - () -> { if (isStrict()) throw ex; Assumptions.assumeTrue(false, "Unsupported schema: " + reason); } - )); - } - }); + return dynamicTestStream(file, root); } catch (Exception ex) { throw new RuntimeException("Failed to process " + file, ex); } } - static StrictMetrics.FileCounters perFile(Path file) { + static Stream dynamicTestStream(Path file, JsonNode root) { + return StreamSupport.stream(root.spliterator(), false) + .flatMap(group -> { + final var groupDesc = group.get("description").asText(); + try { + /// Attempt to compile the schema for this group; if unsupported features + /// (e.g., unresolved anchors) are present, skip this group gracefully. + final var schema = JsonSchema.compile( + Json.parse(group.get("schema").toString())); + + return StreamSupport.stream(group.get("tests").spliterator(), false) + .map(test -> DynamicTest.dynamicTest( + groupDesc + " – " + test.get("description").asText(), + () -> { + final var expected = test.get("valid").asBoolean(); + final boolean actual; + try { + actual = schema.validate( + Json.parse(test.get("data").toString())).valid(); + + /// Count validation attempt + METRICS.run.increment(); + perFile(file).run.increment(); + } catch (Exception e) { + final var reason = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + System.err.println("[JsonSchemaCheckIT] Skipping test due to exception: " + + groupDesc + " — " + reason + " (" + file.getFileName() + ")"); + + /// Count exception as skipped mismatch in strict metrics + METRICS.skippedMismatch.increment(); + perFile(file).skipMismatch.increment(); + + if (isStrict()) throw e; + Assumptions.assumeTrue(false, "Skipped: " + reason); + return; /// not reached when strict + } + + if (isStrict()) { + try { + assertEquals(expected, actual); + /// Count pass in strict mode + METRICS.passed.increment(); + perFile(file).pass.increment(); + } catch (AssertionError e) { + /// Count failure in strict mode + METRICS.failed.increment(); + perFile(file).fail.increment(); + throw e; + } + } else if (expected != actual) { + System.err.println("[JsonSchemaCheckIT] Mismatch (ignored): " + + groupDesc + " — expected=" + expected + ", actual=" + actual + + " (" + file.getFileName() + ")"); + + /// Count lenient mismatch skip + METRICS.skippedMismatch.increment(); + perFile(file).skipMismatch.increment(); + + Assumptions.assumeTrue(false, "Mismatch ignored"); + } else { + /// Count pass in lenient mode + METRICS.passed.increment(); + perFile(file).pass.increment(); + } + })); + } catch (Exception ex) { + /// Unsupported schema for this group; emit a single skipped test for visibility + final var reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage(); + System.err.println("[JsonSchemaCheckIT] Skipping group due to unsupported schema: " + + groupDesc + " — " + reason + " (" + file.getFileName() + ")"); + + /// Count unsupported group skip + METRICS.skippedUnsupported.increment(); + perFile(file).skipUnsupported.increment(); + + return Stream.of(DynamicTest.dynamicTest( + groupDesc + " – SKIPPED: " + reason, + () -> { if (isStrict()) throw ex; Assumptions.assumeTrue(false, "Unsupported schema: " + reason); } + )); + } + }); + } + + static StrictMetrics.FileCounters perFile(Path file) { return METRICS.perFile.computeIfAbsent(file.getFileName().toString(), k -> new StrictMetrics.FileCounters()); } diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaDraft4Test.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaDraft4Test.java new file mode 100644 index 0000000..3943cdb --- /dev/null +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaDraft4Test.java @@ -0,0 +1,104 @@ +package io.github.simbo1905.json.schema; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jdk.sandbox.java.util.json.Json; +import org.junit.jupiter.api.*; + +import java.nio.file.Path; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static io.github.simbo1905.json.schema.JsonSchema.LOG; + + +public class JsonSchemaDraft4Test extends JsonSchemaTestBase { + private static final ObjectMapper MAPPER = new ObjectMapper(); + final String idTest = """ + [ + { + "description": "id inside an enum is not a real identifier", + "comment": "the implementation must not be confused by an id buried in the enum", + "schema": { + "definitions": { + "id_in_enum": { + "enum": [ + { + "id": "https://localhost:1234/my_identifier.json", + "type": "null" + } + ] + }, + "real_id_in_schema": { + "id": "https://localhost:1234/my_identifier.json", + "type": "string" + }, + "zzz_id_in_const": { + "const": { + "id": "https://localhost:1234/my_identifier.json", + "type": "null" + } + } + }, + "anyOf": [ + { "$ref": "#/definitions/id_in_enum" }, + { "$ref": "https://localhost:1234/my_identifier.json" } + ] + }, + "tests": [ + { + "description": "exact match to enum, and type matches", + "data": { + "id": "https://localhost:1234/my_identifier.json", + "type": "null" + }, + "valid": true + }, + { + "description": "match $ref to id", + "data": "a string to match #/definitions/id_in_enum", + "valid": true + }, + { + "description": "no match on enum or $ref to id", + "data": 1, + "valid": false + } + ] + } + + ] + """; + + @TestFactory + public Stream testId() throws JsonProcessingException { + final var root = MAPPER.readTree(idTest); + return StreamSupport.stream(root.spliterator(), false).flatMap(group -> { + final var groupDesc = group.get("description").asText(); + try { + final var schema = JsonSchema.compile(Json.parse(group.get("schema").toString())); + + return StreamSupport.stream(group.get("tests").spliterator(), false).map(test -> DynamicTest.dynamicTest(groupDesc + " – " + test.get("description").asText(), () -> { + final var expected = test.get("valid").asBoolean(); + final boolean actual = schema.validate(Json.parse(test.get("data").toString())).valid(); + try { + Assertions.assertEquals(expected, actual); + } catch (AssertionError e) { + LOG.fine(() -> "Assertion failed: " + groupDesc + " — expected=" + expected + ", actual=" + actual + " (JsonSchemaDraft4Test.java)"); + throw e; + } + + })); + } catch (Exception ex) { + /// Unsupported schema for this group; emit a single skipped test for visibility + final var reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage(); + LOG.fine(()->"Skipping group due to unsupported schema: " + groupDesc + " — " + reason + " (JsonSchemaDraft4Test.java)"); + + return Stream.of(DynamicTest.dynamicTest(groupDesc + " – SKIPPED: " + reason, () -> { + if (JsonSchemaCheckIT.isStrict()) throw ex; + Assumptions.assumeTrue(false, "Unsupported schema: " + reason); + })); + } + }); + } +} diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaFormatTest.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaFormatTest.java index f99bcb5..26d3082 100644 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaFormatTest.java +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaFormatTest.java @@ -74,7 +74,7 @@ void testUuidFormat() { assertThat(schemaAnnotation.validate(Json.parse("\"not-a-uuid\"")).valid()).isTrue(); // With format assertion enabled - only valid UUIDs should pass - JsonSchema schemaAssertion = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schemaAssertion = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); assertThat(schemaAssertion.validate(Json.parse("\"123e4567-e89b-12d3-a456-426614174000\"")).valid()).isTrue(); assertThat(schemaAssertion.validate(Json.parse("\"123e4567e89b12d3a456426614174000\"")).valid()).isFalse(); assertThat(schemaAssertion.validate(Json.parse("\"not-a-uuid\"")).valid()).isFalse(); @@ -90,7 +90,7 @@ void testEmailFormat() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid emails assertThat(schema.validate(Json.parse("\"a@b.co\"")).valid()).isTrue(); @@ -112,7 +112,7 @@ void testIpv4Format() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid IPv4 assertThat(schema.validate(Json.parse("\"192.168.0.1\"")).valid()).isTrue(); @@ -132,7 +132,7 @@ void testIpv6Format() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid IPv6 assertThat(schema.validate(Json.parse("\"2001:0db8::1\"")).valid()).isTrue(); @@ -152,7 +152,7 @@ void testUriFormat() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid URI assertThat(schema.validate(Json.parse("\"https://example.com/x?y#z\"")).valid()).isTrue(); @@ -171,7 +171,7 @@ void testUriReferenceFormat() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid URI references assertThat(schema.validate(Json.parse("\"../rel/path?x=1\"")).valid()).isTrue(); @@ -191,7 +191,7 @@ void testHostnameFormat() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid hostnames assertThat(schema.validate(Json.parse("\"example.com\"")).valid()).isTrue(); @@ -212,7 +212,7 @@ void testDateFormat() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid date assertThat(schema.validate(Json.parse("\"2025-09-16\"")).valid()).isTrue(); @@ -231,7 +231,7 @@ void testTimeFormat() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid times assertThat(schema.validate(Json.parse("\"23:59:59\"")).valid()).isTrue(); @@ -252,7 +252,7 @@ void testDateTimeFormat() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid date-times assertThat(schema.validate(Json.parse("\"2025-09-16T12:34:56Z\"")).valid()).isTrue(); @@ -273,7 +273,7 @@ void testRegexFormat() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid regex assertThat(schema.validate(Json.parse("\"[A-Z]{2,3}\"")).valid()).isTrue(); @@ -298,7 +298,7 @@ void testUnknownFormat() { assertThat(schemaAnnotation.validate(Json.parse("\"\"")).valid()).isTrue(); // With format assertion enabled - unknown format should be no-op (no errors) - JsonSchema schemaAssertion = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schemaAssertion = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); assertThat(schemaAssertion.validate(Json.parse("\"x\"")).valid()).isTrue(); assertThat(schemaAssertion.validate(Json.parse("\"\"")).valid()).isTrue(); } @@ -380,7 +380,7 @@ void testFormatWithOtherConstraints() { } """; - JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.Options(true)); + JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson), new JsonSchema.JsonSchemaOptions(true)); // Valid: meets all constraints assertThat(schema.validate(Json.parse("\"test@example.com\"")).valid()).isTrue(); diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRefLocalTest.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRefLocalTest.java index fbaf072..a7e1e9b 100644 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRefLocalTest.java +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRefLocalTest.java @@ -64,11 +64,6 @@ void testDefsByName() { @Test void testNestedPointer() { - /// Schema with nested pointer #/properties/... - io.github.simbo1905.json.schema.SchemaLogging.LOG.fine("testNestedPointer: Starting detailed logging"); - io.github.simbo1905.json.schema.SchemaLogging.LOG.finer("testNestedPointer: About to parse schema JSON"); - io.github.simbo1905.json.schema.SchemaLogging.LOG.info("TEST: JsonSchemaRefLocalTest#testNestedPointer"); - var schemaJson = Json.parse(""" { "type":"object", @@ -83,23 +78,23 @@ void testNestedPointer() { } } """); - io.github.simbo1905.json.schema.SchemaLogging.LOG.finer("testNestedPointer: Schema JSON parsed successfully"); - io.github.simbo1905.json.schema.SchemaLogging.LOG.fine("testNestedPointer: Schema JSON parsed: " + schemaJson); - io.github.simbo1905.json.schema.SchemaLogging.LOG.finer("testNestedPointer: About to compile schema"); + JsonSchema.LOG.finer("testNestedPointer: Schema JSON parsed successfully"); + JsonSchema.LOG.fine("testNestedPointer: Schema JSON parsed: " + schemaJson); + JsonSchema.LOG.finer("testNestedPointer: About to compile schema"); var schema = JsonSchema.compile(schemaJson); - io.github.simbo1905.json.schema.SchemaLogging.LOG.finer("testNestedPointer: Schema compiled successfully"); - io.github.simbo1905.json.schema.SchemaLogging.LOG.fine("testNestedPointer: Compiled schema: " + schema); + JsonSchema.LOG.finer("testNestedPointer: Schema compiled successfully"); + JsonSchema.LOG.fine("testNestedPointer: Compiled schema: " + schema); // { "refUser": { "id":"aa" } } valid - io.github.simbo1905.json.schema.SchemaLogging.LOG.fine("testNestedPointer: Validating first case - should pass"); + JsonSchema.LOG.fine("testNestedPointer: Validating first case - should pass"); var result1 = schema.validate(Json.parse("{ \"refUser\": { \"id\":\"aa\" } }")); - io.github.simbo1905.json.schema.SchemaLogging.LOG.finest("testNestedPointer: First validation result: " + result1); + JsonSchema.LOG.finest("testNestedPointer: First validation result: " + result1); assertThat(result1.valid()).isTrue(); // { "refUser": { "id":"a" } } invalid (minLength) - io.github.simbo1905.json.schema.SchemaLogging.LOG.fine("testNestedPointer: Validating second case - should fail"); + JsonSchema.LOG.fine("testNestedPointer: Validating second case - should fail"); var result2 = schema.validate(Json.parse("{ \"refUser\": { \"id\":\"a\" } }")); - io.github.simbo1905.json.schema.SchemaLogging.LOG.finest("testNestedPointer: Second validation result: " + result2); + JsonSchema.LOG.finest("testNestedPointer: Second validation result: " + result2); assertThat(result2.valid()).isFalse(); assertThat(result2.errors()).hasSize(1); assertThat(result2.errors().get(0).message()).contains("String too short"); diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRemoteRefTest.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRemoteRefTest.java index 5955145..cd8262f 100644 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRemoteRefTest.java +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRemoteRefTest.java @@ -6,409 +6,414 @@ import org.junit.jupiter.api.Test; import java.net.URI; +import java.nio.file.Path; import java.time.Duration; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.concurrent.atomic.AtomicInteger; -import static io.github.simbo1905.json.schema.SchemaLogging.LOG; +import static io.github.simbo1905.json.schema.JsonSchema.LOG; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; final class JsonSchemaRemoteRefTest extends JsonSchemaTestBase { - @Test - void resolves_http_ref_to_pointer_inside_remote_doc() { - LOG.info(() -> "START resolves_http_ref_to_pointer_inside_remote_doc"); - final var remoteUri = TestResourceUtils.getTestResourceUri("JsonSchemaRemoteRefTest/a.json"); - final var remoteDoc = Json.parse(""" - { - "$id": "file:///JsonSchemaRemoteRefTest/a.json", - "$defs": { - "X": { - "type": "integer", - "minimum": 2 - } - } + @Test + void resolves_http_ref_to_pointer_inside_remote_doc() { + LOG.info(() -> "START resolves_http_ref_to_pointer_inside_remote_doc"); + final var remoteUri = URI.create("file:///JsonSchemaRemoteRefTest/a.json"); + final var remoteDoc = Json.parse(""" + { + "$id": "file:///JsonSchemaRemoteRefTest/a.json", + "$defs": { + "X": { + "type": "integer", + "minimum": 2 } - """); - logRemote("remoteDoc=", remoteDoc); - final var fetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); - final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); - - LOG.finer(() -> "Compiling schema for file remote ref"); - final var schema = JsonSchema.compile( - Json.parse(""" - {"$ref":"file:///JsonSchemaRemoteRefTest/a.json#/$defs/X"} - """), - JsonSchema.Options.DEFAULT, - options - ); - - final var pass = schema.validate(Json.parse("3")); - logResult("validate-3", pass); - assertThat(pass.valid()).isTrue(); - final var fail = schema.validate(Json.parse("1")); - logResult("validate-1", fail); - assertThat(fail.valid()).isFalse(); + } + } + """); + logRemote("remoteDoc=", remoteDoc); + final var fetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); + final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); + + LOG.finer(() -> "Compiling schema for file remote ref"); + final var schema = JsonSchema.compile(URI.create("urn:inmemory:root"), Json.parse(""" + {"$ref":"file:///JsonSchemaRemoteRefTest/a.json#/$defs/X"} + """), JsonSchema.JsonSchemaOptions.DEFAULT, options); + + final var pass = schema.validate(Json.parse("3")); + logResult("validate-3", pass); + assertThat(pass.valid()).isTrue(); + final var fail = schema.validate(Json.parse("1")); + logResult("validate-1", fail); + assertThat(fail.valid()).isFalse(); + } + + static void logRemote(String label, JsonValue json) { + LOG.finest(() -> label + json); + } + + static void logResult(String label, JsonSchema.ValidationResult result) { + LOG.fine(() -> label + " valid=" + result.valid()); + if (!result.valid()) { + LOG.finest(() -> label + " errors=" + result.errors()); } - - @Test - void resolves_relative_ref_against_remote_id_chain() { - LOG.info(() -> "START resolves_relative_ref_against_remote_id_chain"); - final var remoteUri = TestResourceUtils.getTestResourceUri("JsonSchemaRemoteRefTest/base/root.json"); - final var remoteDoc = Json.parse(""" - { - "$id": "%s", + } + + @Test + void resolves_relative_ref_against_remote_id_chain() { + LOG.info(() -> "START resolves_relative_ref_against_remote_id_chain"); + final var remoteUri = URI.create("file:///JsonSchemaRemoteRefTest/base/root.json"); + final var remoteDoc = Json.parse(""" + { + "$id": "%s", + "$defs": { + "Module": { + "$id": "dir/schema.json", "$defs": { - "Module": { - "$id": "dir/schema.json", - "$defs": { - "Name": { - "type": "string", - "minLength": 2 - } - }, - "$ref": "#/$defs/Name" + "Name": { + "type": "string", + "minLength": 2 } - } + }, + "$ref": "#/$defs/Name" } - """.formatted(remoteUri)); - logRemote("remoteDoc=", remoteDoc); - final var fetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); - final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); - - LOG.finer(() -> "Compiling schema for relative remote $id chain"); - final var schema = JsonSchema.compile( - Json.parse(""" - {"$ref":"%s#/$defs/Module"} - """.formatted(remoteUri)), - JsonSchema.Options.DEFAULT, - options - ); - - final var ok = schema.validate(Json.parse("\"Al\"")); - logResult("validate-Al", ok); - assertThat(ok.valid()).isTrue(); - final var bad = schema.validate(Json.parse("\"A\"")); - logResult("validate-A", bad); - assertThat(bad.valid()).isFalse(); - } - - @Test - void resolves_named_anchor_in_remote_doc() { - LOG.info(() -> "START resolves_named_anchor_in_remote_doc"); - final var remoteUri = TestResourceUtils.getTestResourceUri("JsonSchemaRemoteRefTest/anchors.json"); - final var remoteDoc = Json.parse(""" - { - "$id": "%s", - "$anchor": "root", - "$defs": { - "A": { - "$anchor": "top", - "type": "string" - } - } + } + } + """.formatted(remoteUri)); + logRemote("remoteDoc=", remoteDoc); + final var fetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); + final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); + + LOG.finer(() -> "Compiling schema for relative remote $id chain"); + final var schema = JsonSchema.compile(URI.create("urn:inmemory:root"), Json.parse(""" + {"$ref":"%s#/$defs/Module"} + """.formatted(remoteUri)), JsonSchema.JsonSchemaOptions.DEFAULT, options); + + final var ok = schema.validate(Json.parse("\"Al\"")); + logResult("validate-Al", ok); + assertThat(ok.valid()).isTrue(); + final var bad = schema.validate(Json.parse("\"A\"")); + logResult("validate-A", bad); + assertThat(bad.valid()).isFalse(); + } + + @Test + void resolves_named_anchor_in_remote_doc() { + LOG.info(() -> "START resolves_named_anchor_in_remote_doc"); + final var remoteUri = URI.create("file:///JsonSchemaRemoteRefTest/anchors.json"); + final var remoteDoc = Json.parse(""" + { + "$id": "%s", + "$anchor": "root", + "$defs": { + "A": { + "$anchor": "top", + "type": "string" } - """.formatted(remoteUri)); - logRemote("remoteDoc=", remoteDoc); - final var fetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); - final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); - - LOG.finer(() -> "Compiling schema for remote anchor"); - final var schema = JsonSchema.compile( - Json.parse(""" - {"$ref":"%s#top"} - """.formatted(remoteUri)), - JsonSchema.Options.DEFAULT, - options - ); - - final var pass = schema.validate(Json.parse("\"x\"")); - logResult("validate-x", pass); - assertThat(pass.valid()).isTrue(); - final var fail = schema.validate(Json.parse("1")); - logResult("validate-1", fail); - assertThat(fail.valid()).isFalse(); - } + } + } + """.formatted(remoteUri)); + logRemote("remoteDoc=", remoteDoc); + final var fetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); + final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); + + LOG.finer(() -> "Compiling schema for remote anchor"); + final var schema = JsonSchema.compile(URI.create("urn:inmemory:root"), Json.parse(""" + {"$ref":"%s#top"} + """.formatted(remoteUri)), JsonSchema.JsonSchemaOptions.DEFAULT, options); + + final var pass = schema.validate(Json.parse("\"x\"")); + logResult("validate-x", pass); + assertThat(pass.valid()).isTrue(); + final var fail = schema.validate(Json.parse("1")); + logResult("validate-1", fail); + assertThat(fail.valid()).isFalse(); + } + + @Test + void error_unresolvable_remote_pointer() { + LOG.info(() -> "START error_unresolvable_remote_pointer"); + final var remoteUri = URI.create("file:///JsonSchemaRemoteRefTest/a.json"); + final var remoteDoc = Json.parse(""" + { + "$id": "file:///JsonSchemaRemoteRefTest/a.json", + "$defs": { + "Present": {"type":"integer"} + } + } + """); + logRemote("remoteDoc=", remoteDoc); + final var fetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); + final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); + + LOG.finer(() -> "Attempting compile expecting pointer failure"); + final ThrowableAssert.ThrowingCallable compile = () -> JsonSchema.compile(URI.create("urn:inmemory:root"), toJson(""" + {"$ref":"file:///JsonSchemaRemoteRefTest/a.json#/$defs/Missing"} + """), JsonSchema.JsonSchemaOptions.DEFAULT, options); + + LOG.finer(() -> "Asserting RemoteResolutionException for missing pointer"); + assertThatThrownBy(compile).isInstanceOf(RemoteResolutionException.class) + .hasFieldOrPropertyWithValue("reason", RemoteResolutionException.Reason.POINTER_MISSING) + .hasMessageContaining("file:///JsonSchemaRemoteRefTest/a.json#/$defs/Missing"); + } + + static JsonValue toJson(String json) { + return Json.parse(json); + } + + final FetchPolicy policy = FetchPolicy.defaults().withAllowedSchemes(Set.of("http", "https","file")); + @Test + void denies_disallowed_scheme() { + LOG.info(() -> "START denies_disallowed_scheme"); + final var jailRoot = Path.of(System.getProperty("user.dir"), "json-java21-schema", "src", "test", "resources").toAbsolutePath().normalize(); + final var options = JsonSchema.CompileOptions.remoteDefaults(new FileFetcher(jailRoot)).withFetchPolicy(policy); + + LOG.finer(() -> "Compiling schema expecting disallowed scheme"); + + final var passwordFile = toJson(""" + {"$ref":"file:///etc/passwd#/"} + """); + + final ThrowableAssert.ThrowingCallable compile = () -> + JsonSchema.compile(URI.create("urn:inmemory:root"), passwordFile, JsonSchema.JsonSchemaOptions.DEFAULT, options); + + LOG.finer(() -> "Asserting RemoteResolutionException for scheme policy"); + assertThatThrownBy(compile) + .isInstanceOf(RemoteResolutionException.class) + .hasFieldOrPropertyWithValue("reason", RemoteResolutionException.Reason.POLICY_DENIED) + .hasMessageContaining("Outside jail") + .hasMessageContaining("/etc/passwd"); + } + + @Test + void enforces_timeout_and_size_limits() { + LOG.info(() -> "START enforces_timeout_and_size_limits"); + final var remoteUri = URI.create("file:///JsonSchemaRemoteRefTest/cache.json"); + final var remoteDoc = toJson(""" + {"type":"integer"} + """); + logRemote("remoteDoc=", remoteDoc); + + final var policy = FetchPolicy.defaults().withMaxDocumentBytes().withTimeout(Duration.ofMillis(5)); + + final var oversizedFetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc, 2048, Duration.ofMillis(1)))); + final var timeoutFetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc, 1, Duration.ofMillis(50)))); + + final var oversizedOptions = JsonSchema.CompileOptions.remoteDefaults(oversizedFetcher).withFetchPolicy(policy); + final var timeoutOptions = JsonSchema.CompileOptions.remoteDefaults(timeoutFetcher).withFetchPolicy(policy); + + LOG.finer(() -> "Asserting payload too large"); + final ThrowableAssert.ThrowingCallable oversizedCompile = () -> JsonSchema.compile(URI.create("urn:inmemory:root"), toJson(""" + {"$ref":"file:///JsonSchemaRemoteRefTest/cache.json"} + """), JsonSchema.JsonSchemaOptions.DEFAULT, oversizedOptions); + + assertThatThrownBy(oversizedCompile) + .isInstanceOf(RemoteResolutionException.class) + .hasFieldOrPropertyWithValue("reason", RemoteResolutionException.Reason.PAYLOAD_TOO_LARGE) + .hasMessageContaining("file:///JsonSchemaRemoteRefTest/cache.json"); + + LOG.finer(() -> "Asserting timeout policy violation"); + final ThrowableAssert.ThrowingCallable timeoutCompile = () -> JsonSchema.compile(URI.create("urn:inmemory:root"), toJson(""" + {"$ref":"file:///JsonSchemaRemoteRefTest/cache.json"} + """), JsonSchema.JsonSchemaOptions.DEFAULT, timeoutOptions); + + assertThatThrownBy(timeoutCompile).isInstanceOf(RemoteResolutionException.class).hasFieldOrPropertyWithValue("reason", RemoteResolutionException.Reason.TIMEOUT).hasMessageContaining("file:///JsonSchemaRemoteRefTest/cache.json"); + } + + @Test + void caches_remote_doc_and_reuses_compiled_node() { + LOG.info(() -> "START caches_remote_doc_and_reuses_compiled_node"); + final var remoteUri = URI.create("file:///JsonSchemaRemoteRefTest/cache.json"); + final var remoteDoc = toJson(""" + { + "$id": "file:///JsonSchemaRemoteRefTest/cache.json", + "type": "integer" + } + """); + logRemote("remoteDoc=", remoteDoc); - @Test - void error_unresolvable_remote_pointer() { - LOG.info(() -> "START error_unresolvable_remote_pointer"); - final var remoteUri = TestResourceUtils.getTestResourceUri("JsonSchemaRemoteRefTest/a.json"); - final var remoteDoc = Json.parse(""" - { - "$id": "file:///JsonSchemaRemoteRefTest/a.json", - "$defs": { - "Present": {"type":"integer"} - } - } - """); - logRemote("remoteDoc=", remoteDoc); - final var fetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); - final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); - - LOG.finer(() -> "Attempting compile expecting pointer failure"); - final ThrowableAssert.ThrowingCallable compile = () -> JsonSchema.compile( - toJson(""" - {"$ref":"file:///JsonSchemaRemoteRefTest/a.json#/$defs/Missing"} - """), - JsonSchema.Options.DEFAULT, - options - ); - - LOG.finer(() -> "Asserting RemoteResolutionException for missing pointer"); - assertThatThrownBy(compile) - .isInstanceOf(JsonSchema.RemoteResolutionException.class) - .hasFieldOrPropertyWithValue("reason", JsonSchema.RemoteResolutionException.Reason.POINTER_MISSING) - .hasMessageContaining("file:///JsonSchemaRemoteRefTest/a.json#/$defs/Missing"); - } + final var fetcher = new CountingFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); + final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); - @Test - void denies_disallowed_scheme() { - LOG.info(() -> "START denies_disallowed_scheme"); - final var fetcher = new MapRemoteFetcher(Map.of()); - final var policy = JsonSchema.FetchPolicy.defaults().withAllowedSchemes(Set.of("http", "https")); - final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher).withFetchPolicy(policy); - - LOG.finer(() -> "Compiling schema expecting disallowed scheme"); - final ThrowableAssert.ThrowingCallable compile = () -> JsonSchema.compile( - toJson(""" - {"$ref":"file:///etc/passwd#/"} - """), - JsonSchema.Options.DEFAULT, - options - ); - - LOG.finer(() -> "Asserting RemoteResolutionException for scheme policy"); - assertThatThrownBy(compile) - .isInstanceOf(JsonSchema.RemoteResolutionException.class) - .hasFieldOrPropertyWithValue("reason", JsonSchema.RemoteResolutionException.Reason.POLICY_DENIED) - .hasMessageContaining("file:///etc/passwd"); + LOG.finer(() -> "Compiling schema twice with same remote ref"); + final var schema = JsonSchema.compile(URI.create("urn:inmemory:root"), toJson(""" + { + "allOf": [ + {"$ref":"file:///JsonSchemaRemoteRefTest/cache.json"}, + {"$ref":"file:///JsonSchemaRemoteRefTest/cache.json"} + ] + } + """), JsonSchema.JsonSchemaOptions.DEFAULT, options); + + assertThat(fetcher.calls()).isEqualTo(1); + final var first = schema.validate(toJson("5")); + logResult("validate-5-first", first); + assertThat(first.valid()).isTrue(); + final var second = schema.validate(toJson("5")); + logResult("validate-5-second", second); + assertThat(second.valid()).isTrue(); + assertThat(fetcher.calls()).isEqualTo(1); + } + + @Test + void detects_cross_document_cycle() { + LOG.info(() -> "START detects_cross_document_cycle"); + final var uriA = URI.create("file:///JsonSchemaRemoteRefTest/a.json"); + final var uriB = URI.create("file:///JsonSchemaRemoteRefTest/b.json"); + final var docA = toJson(""" + {"$id":"file:///JsonSchemaRemoteRefTest/a.json","$ref":"file:///JsonSchemaRemoteRefTest/b.json"} + """); + final var docB = toJson(""" + {"$id":"file:///JsonSchemaRemoteRefTest/b.json","$ref":"file:///JsonSchemaRemoteRefTest/a.json"} + """); + logRemote("docA=", docA); + logRemote("docB=", docB); + + final var fetcher = new MapRemoteFetcher(Map.of(uriA, RemoteDocument.json(docA), uriB, RemoteDocument.json(docB))); + final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); + + LOG.finer(() -> "Compiling schema expecting cycle detection"); + try (CapturedLogs logs = captureLogs()) { + assertThatThrownBy(() -> JsonSchema.compile(URI.create("urn:inmemory:root"), toJson(""" + {"$ref":"file:///JsonSchemaRemoteRefTest/a.json"} + """), JsonSchema.JsonSchemaOptions.DEFAULT, options)).isInstanceOf(IllegalStateException.class).hasMessageContaining("ERROR: CYCLE: remote $ref cycle"); + assertThat(logs.lines().stream().anyMatch(line -> line.startsWith("ERROR: CYCLE:"))).isTrue(); } + } + + static CapturedLogs captureLogs() { + return new CapturedLogs(java.util.logging.Level.SEVERE); + } + + @Test + void resolves_anchor_defined_in_nested_remote_scope() { + LOG.info(() -> "START resolves_anchor_defined_in_nested_remote_scope"); + final var remoteUri = URI.create("file:///JsonSchemaRemoteRefTest/nest.json"); + final var remoteDoc = toJson(""" + { + "$id": "file:///JsonSchemaRemoteRefTest/nest.json", + "$defs": { + "Inner": { + "$anchor": "inner", + "type": "number", + "minimum": 0 + } + } + } + """); + logRemote("remoteDoc=", remoteDoc); + + final var fetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); + final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); + + LOG.finer(() -> "Compiling schema for nested anchor"); + final var schema = JsonSchema.compile(URI.create("urn:inmemory:root"), toJson(""" + {"$ref":"file:///JsonSchemaRemoteRefTest/nest.json#inner"} + """), JsonSchema.JsonSchemaOptions.DEFAULT, options); + + final var positive = schema.validate(toJson("1")); + logResult("validate-1", positive); + assertThat(positive.valid()).isTrue(); + final var negative = schema.validate(toJson("-1")); + logResult("validate-minus1", negative); + assertThat(negative.valid()).isFalse(); + } + + static final class CapturedLogs implements AutoCloseable { + private final java.util.logging.Handler handler; + private final List lines = new ArrayList<>(); + private final java.util.logging.Level original; + + CapturedLogs(java.util.logging.Level level) { + original = LOG.getLevel(); + LOG.setLevel(level); + handler = new java.util.logging.Handler() { + @Override + public void publish(java.util.logging.LogRecord record) { + if (record.getLevel().intValue() >= level.intValue()) { + lines.add(record.getMessage()); + } + } - @Test - void enforces_timeout_and_size_limits() { - LOG.info(() -> "START enforces_timeout_and_size_limits"); - final var remoteUri = TestResourceUtils.getTestResourceUri("JsonSchemaRemoteRefTest/cache.json"); - final var remoteDoc = toJson(""" - {"type":"integer"} - """); - logRemote("remoteDoc=", remoteDoc); - - final var policy = JsonSchema.FetchPolicy.defaults() - .withMaxDocumentBytes() - .withTimeout(Duration.ofMillis(5)); - - final var oversizedFetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc, 2048, Optional.of(Duration.ofMillis(1))))); - final var timeoutFetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc, 1, Optional.of(Duration.ofMillis(50))))); - - final var oversizedOptions = JsonSchema.CompileOptions.remoteDefaults(oversizedFetcher).withFetchPolicy(policy); - final var timeoutOptions = JsonSchema.CompileOptions.remoteDefaults(timeoutFetcher).withFetchPolicy(policy); + @Override + public void flush() { + } - LOG.finer(() -> "Asserting payload too large"); - final ThrowableAssert.ThrowingCallable oversizedCompile = () -> JsonSchema.compile( - toJson(""" - {"$ref":"file:///JsonSchemaRemoteRefTest/cache.json"} - """), - JsonSchema.Options.DEFAULT, - oversizedOptions - ); - - assertThatThrownBy(oversizedCompile) - .isInstanceOf(JsonSchema.RemoteResolutionException.class) - .hasFieldOrPropertyWithValue("reason", JsonSchema.RemoteResolutionException.Reason.PAYLOAD_TOO_LARGE) - .hasMessageContaining("file:///JsonSchemaRemoteRefTest/cache.json"); - - LOG.finer(() -> "Asserting timeout policy violation"); - final ThrowableAssert.ThrowingCallable timeoutCompile = () -> JsonSchema.compile( - toJson(""" - {"$ref":"file:///JsonSchemaRemoteRefTest/cache.json"} - """), - JsonSchema.Options.DEFAULT, - timeoutOptions - ); - - assertThatThrownBy(timeoutCompile) - .isInstanceOf(JsonSchema.RemoteResolutionException.class) - .hasFieldOrPropertyWithValue("reason", JsonSchema.RemoteResolutionException.Reason.TIMEOUT) - .hasMessageContaining("file:///JsonSchemaRemoteRefTest/cache.json"); + @Override + public void close() throws SecurityException { + } + }; + LOG.addHandler(handler); } - @Test - void caches_remote_doc_and_reuses_compiled_node() { - LOG.info(() -> "START caches_remote_doc_and_reuses_compiled_node"); - final var remoteUri = TestResourceUtils.getTestResourceUri("JsonSchemaRemoteRefTest/cache.json"); - final var remoteDoc = toJson(""" - { - "$id": "file:///JsonSchemaRemoteRefTest/cache.json", - "type": "integer" - } - """); - logRemote("remoteDoc=", remoteDoc); - - final var fetcher = new CountingFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); - final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); - - LOG.finer(() -> "Compiling schema twice with same remote ref"); - final var schema = JsonSchema.compile( - toJson(""" - { - "allOf": [ - {"$ref":"file:///JsonSchemaRemoteRefTest/cache.json"}, - {"$ref":"file:///JsonSchemaRemoteRefTest/cache.json"} - ] - } - """), - JsonSchema.Options.DEFAULT, - options - ); - - assertThat(fetcher.calls()).isEqualTo(1); - final var first = schema.validate(toJson("5")); - logResult("validate-5-first", first); - assertThat(first.valid()).isTrue(); - final var second = schema.validate(toJson("5")); - logResult("validate-5-second", second); - assertThat(second.valid()).isTrue(); - assertThat(fetcher.calls()).isEqualTo(1); + List lines() { + return List.copyOf(lines); } - @Test - void detects_cross_document_cycle() { - LOG.info(() -> "START detects_cross_document_cycle"); - final var uriA = TestResourceUtils.getTestResourceUri("JsonSchemaRemoteRefTest/a.json"); - final var uriB = TestResourceUtils.getTestResourceUri("JsonSchemaRemoteRefTest/b.json"); - final var docA = toJson(""" - {"$id":"file:///JsonSchemaRemoteRefTest/a.json","$ref":"file:///JsonSchemaRemoteRefTest/b.json"} - """); - final var docB = toJson(""" - {"$id":"file:///JsonSchemaRemoteRefTest/b.json","$ref":"file:///JsonSchemaRemoteRefTest/a.json"} - """); - logRemote("docA=", docA); - logRemote("docB=", docB); - - final var fetcher = new MapRemoteFetcher(Map.of( - uriA, RemoteDocument.json(docA), - uriB, RemoteDocument.json(docB) - )); - final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); - - LOG.finer(() -> "Compiling schema expecting cycle detection"); - try (CapturedLogs logs = captureLogs(java.util.logging.Level.SEVERE)) { - assertThatThrownBy(() -> JsonSchema.compile( - toJson(""" - {"$ref":"file:///JsonSchemaRemoteRefTest/a.json"} - """), - JsonSchema.Options.DEFAULT, - options - )).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("ERROR: CYCLE: remote $ref cycle"); - assertThat(logs.lines().stream().anyMatch(line -> line.startsWith("ERROR: CYCLE:"))).isTrue(); - } + @Override + public void close() { + LOG.removeHandler(handler); + LOG.setLevel(original); } + } - @Test - void resolves_anchor_defined_in_nested_remote_scope() { - LOG.info(() -> "START resolves_anchor_defined_in_nested_remote_scope"); - final var remoteUri = TestResourceUtils.getTestResourceUri("JsonSchemaRemoteRefTest/nest.json"); - final var remoteDoc = toJson(""" - { - "$id": "file:///JsonSchemaRemoteRefTest/nest.json", - "$defs": { - "Inner": { - "$anchor": "inner", - "type": "number", - "minimum": 0 - } - } - } - """); - logRemote("remoteDoc=", remoteDoc); - - final var fetcher = new MapRemoteFetcher(Map.of(remoteUri, RemoteDocument.json(remoteDoc))); - final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher); - - LOG.finer(() -> "Compiling schema for nested anchor"); - final var schema = JsonSchema.compile( - toJson(""" - {"$ref":"file:///JsonSchemaRemoteRefTest/nest.json#inner"} - """), - JsonSchema.Options.DEFAULT, - options - ); - - final var positive = schema.validate(toJson("1")); - logResult("validate-1", positive); - assertThat(positive.valid()).isTrue(); - final var negative = schema.validate(toJson("-1")); - logResult("validate-minus1", negative); - assertThat(negative.valid()).isFalse(); + record RemoteDocument(JsonValue document, long byteSize, Optional elapsed) { + static RemoteDocument json(JsonValue document) { + return new RemoteDocument(document, document.toString().getBytes().length, Optional.empty()); } - private static JsonValue toJson(String json) { - return Json.parse(json); + static RemoteDocument json(JsonValue document, long byteSize, Duration elapsed) { + return new RemoteDocument(document, byteSize, Optional.ofNullable(elapsed)); } + } - private record RemoteDocument(JsonValue document, long byteSize, Optional elapsed) { - static RemoteDocument json(JsonValue document) { - return new RemoteDocument(document, document.toString().getBytes().length, Optional.empty()); - } - - static RemoteDocument json(JsonValue document, long byteSize, Optional elapsed) { - return new RemoteDocument(document, byteSize, elapsed); - } + record MapRemoteFetcher(String scheme, Map documents) implements JsonSchema.RemoteFetcher { + MapRemoteFetcher(Map documents) { + this("file", documents); } - private static final class MapRemoteFetcher implements JsonSchema.RemoteFetcher { - private final Map documents; + MapRemoteFetcher(String scheme, Map documents) { + this.scheme = Objects.requireNonNull(scheme, "scheme"); + this.documents = Map.copyOf(Objects.requireNonNull(documents, "documents")); + } - private MapRemoteFetcher(Map documents) { - this.documents = Map.copyOf(documents); - } + @Override + public String scheme() { + return scheme; + } - @Override - public FetchResult fetch(URI uri, JsonSchema.FetchPolicy policy) { - final var doc = documents.get(uri); - if (doc == null) { - throw new JsonSchema.RemoteResolutionException( - uri, - JsonSchema.RemoteResolutionException.Reason.NOT_FOUND, - "No remote document registered for " + uri - ); - } - return new FetchResult(doc.document(), doc.byteSize(), doc.elapsed()); - } + @Override + public FetchResult fetch(URI uri, FetchPolicy policy) { + final var doc = documents.get(uri); + if (doc == null) { + throw new RemoteResolutionException(uri, RemoteResolutionException.Reason.NOT_FOUND, "No remote document registered for " + uri); + } + return new FetchResult(doc.document(), doc.byteSize(), doc.elapsed()); } + } - private static final class CountingFetcher implements JsonSchema.RemoteFetcher { - private final MapRemoteFetcher delegate; - private final AtomicInteger calls = new AtomicInteger(); + static final class CountingFetcher implements JsonSchema.RemoteFetcher { + private final MapRemoteFetcher delegate; + private final AtomicInteger calls = new AtomicInteger(); - private CountingFetcher(Map documents) { - this.delegate = new MapRemoteFetcher(documents); - } + private CountingFetcher(Map documents) { + this.delegate = new MapRemoteFetcher(documents); + } - int calls() { - return calls.get(); - } - - @Override - public FetchResult fetch(URI uri, JsonSchema.FetchPolicy policy) { - calls.incrementAndGet(); - return delegate.fetch(uri, policy); - } + int calls() { + return calls.get(); } - private static void logRemote(String label, JsonValue json) { - LOG.finest(() -> label + json); + @Override + public String scheme() { + return delegate.scheme(); } - private static void logResult(String label, JsonSchema.ValidationResult result) { - LOG.fine(() -> label + " valid=" + result.valid()); - if (!result.valid()) { - LOG.finest(() -> label + " errors=" + result.errors()); - } + @Override + public FetchResult fetch(URI uri, FetchPolicy policy) { + calls.incrementAndGet(); + return delegate.fetch(uri, policy); } + } } diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRemoteServerRefTest.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRemoteServerRefTest.java index 8325551..526a721 100644 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRemoteServerRefTest.java +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaRemoteServerRefTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import java.net.URI; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -11,31 +12,36 @@ class JsonSchemaRemoteServerRefTest extends JsonSchemaTestBase { - @RegisterExtension - static final RemoteSchemaServerRule SERVER = new RemoteSchemaServerRule(); + @RegisterExtension + static final RemoteSchemaServerRule SERVER = new RemoteSchemaServerRule(); - @Test + @Test void resolves_pointer_inside_remote_doc_via_http() { - var policy = JsonSchema.FetchPolicy.defaults().withAllowedSchemes(Set.of("http","https")); - var options = JsonSchema.CompileOptions.remoteDefaults(new VirtualThreadHttpFetcher()).withFetchPolicy(policy); + var policy = FetchPolicy.defaults().withAllowedSchemes(Set.of(FetchPolicy.HTTP, FetchPolicy.HTTPS)); + var fetcher = new JsonSchema.CompileOptions.DelegatingRemoteFetcher( + new VirtualThreadHttpFetcher(FetchPolicy.HTTP), + new VirtualThreadHttpFetcher(FetchPolicy.HTTPS)); + var options = JsonSchema.CompileOptions.remoteDefaults(fetcher).withFetchPolicy(policy); var schema = Json.parse("{\"$ref\":\"" + SERVER.url("/a.json") + "#/$defs/X\"}"); - var compiled = JsonSchema.compile(schema, JsonSchema.Options.DEFAULT, options); + var compiled = JsonSchema.compile(URI.create("urn:inmemory:root"), schema, JsonSchema.JsonSchemaOptions.DEFAULT, options); assertThat(compiled.validate(Json.parse("1")).valid()).isTrue(); assertThat(compiled.validate(Json.parse("0")).valid()).isFalse(); } @Test void remote_cycle_detected_and_throws() { - var policy = JsonSchema.FetchPolicy.defaults().withAllowedSchemes(Set.of("http","https")); - var options = JsonSchema.CompileOptions.remoteDefaults(new VirtualThreadHttpFetcher()).withFetchPolicy(policy); + var policy = FetchPolicy.defaults().withAllowedSchemes(Set.of(FetchPolicy.HTTP, FetchPolicy.HTTPS)); + var fetcher = new JsonSchema.CompileOptions.DelegatingRemoteFetcher( + new VirtualThreadHttpFetcher(FetchPolicy.HTTP), + new VirtualThreadHttpFetcher(FetchPolicy.HTTPS)); + var options = JsonSchema.CompileOptions.remoteDefaults(fetcher).withFetchPolicy(policy); // Cycles should be detected and throw an exception regardless of scheme assertThatThrownBy(() -> JsonSchema.compile( - Json.parse("{\"$ref\":\"" + SERVER.url("/cycle1.json") + "#\"}"), - JsonSchema.Options.DEFAULT, + URI.create("urn:inmemory:root"), Json.parse("{\"$ref\":\"" + SERVER.url("/cycle1.json") + "#\"}"), + JsonSchema.JsonSchemaOptions.DEFAULT, options )).isInstanceOf(IllegalStateException.class) .hasMessageContaining("ERROR: CYCLE: remote $ref cycle"); } } - diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaTest.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaTest.java index a3de1b0..a16d12a 100644 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaTest.java +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaTest.java @@ -8,7 +8,7 @@ class JsonSchemaTest extends JsonSchemaTestBase { @Test void testStringTypeValidation() { - io.github.simbo1905.json.schema.SchemaLogging.LOG.info("TEST: JsonSchemaTest#testStringTypeValidation"); String schemaJson = """ + JsonSchema.LOG.info("TEST: JsonSchemaTest#testStringTypeValidation"); String schemaJson = """ { "type": "string" } @@ -448,7 +448,7 @@ void testComplexRecursiveSchema() { "required": ["id", "name"] } """; - io.github.simbo1905.json.schema.SchemaLogging.LOG.info("TEST: JsonSchemaTest#testComplexRecursiveSchema"); + JsonSchema.LOG.info("TEST: JsonSchemaTest#testComplexRecursiveSchema"); JsonSchema schema = JsonSchema.compile(Json.parse(schemaJson)); @@ -564,7 +564,7 @@ void linkedListRecursion() { {"value":1,"next":{"value":2,"next":{"value":3}}} """)).valid()).isTrue(); // ✓ valid - io.github.simbo1905.json.schema.SchemaLogging.LOG.info("TEST: JsonSchemaTest#linkedListRecursion"); + JsonSchema.LOG.info("TEST: JsonSchemaTest#linkedListRecursion"); assertThat(s.validate(Json.parse(""" {"value":1,"next":{"next":{"value":3}}} """)).valid()).isFalse(); // ✗ missing value @@ -572,7 +572,7 @@ void linkedListRecursion() { @Test void binaryTreeRecursion() { - io.github.simbo1905.json.schema.SchemaLogging.LOG.info("TEST: JsonSchemaTest#binaryTreeRecursion"); String schema = """ + JsonSchema.LOG.info("TEST: JsonSchemaTest#binaryTreeRecursion"); String schema = """ { "type":"object", "properties":{ diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaTestBase.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaTestBase.java index 7bda607..579540a 100644 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaTestBase.java +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/JsonSchemaTestBase.java @@ -1,21 +1,9 @@ package io.github.simbo1905.json.schema; -import jdk.sandbox.java.util.json.Json; -import jdk.sandbox.java.util.json.JsonValue; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInfo; -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -import static io.github.simbo1905.json.schema.SchemaLogging.LOG; +import static io.github.simbo1905.json.schema.JsonSchema.LOG; /// Base class for all schema tests. /// - Emits an INFO banner per test. @@ -29,70 +17,4 @@ void announce(TestInfo testInfo) { .orElseGet(testInfo::getDisplayName); LOG.info(() -> "TEST: " + cls + "#" + name); } - - protected final JsonValue readJson(String resourcePath) { - return Json.parse(readText(resourcePath)); - } - - protected final String readText(String resourcePath) { - try { - Path p = Path.of(Objects.requireNonNull( - getClass().getClassLoader().getResource(resourcePath), resourcePath - ).toURI()); - return Files.readString(p, StandardCharsets.UTF_8); - } catch (URISyntaxException | IOException e) { - throw new RuntimeException("Failed to read resource: " + resourcePath, e); - } - } - - protected final URI uriOf(String relativeResourcePath) { - return TestResourceUtils.getTestResourceUri(relativeResourcePath); - } - - protected final JsonSchema.ValidationResult validate(JsonSchema schema, JsonValue instance) { - return schema.validate(instance); - } - - protected final void assertValid(JsonSchema schema, String instanceJson) { - final var res = schema.validate(Json.parse(instanceJson)); - org.assertj.core.api.Assertions.assertThat(res.valid()).isTrue(); - } - - protected final void assertInvalid(JsonSchema schema, String instanceJson) { - final var res = schema.validate(Json.parse(instanceJson)); - org.assertj.core.api.Assertions.assertThat(res.valid()).isFalse(); - } - - protected static CapturedLogs captureLogs(java.util.logging.Level level) { - return new CapturedLogs(level); - } - - static final class CapturedLogs implements AutoCloseable { - private final java.util.logging.Handler handler; - private final List lines = new ArrayList<>(); - private final java.util.logging.Level original; - - CapturedLogs(java.util.logging.Level level) { - original = LOG.getLevel(); - LOG.setLevel(level); - handler = new java.util.logging.Handler() { - @Override public void publish(java.util.logging.LogRecord record) { - if (record.getLevel().intValue() >= level.intValue()) { - lines.add(record.getMessage()); - } - } - @Override public void flush() { } - @Override public void close() throws SecurityException { } - }; - LOG.addHandler(handler); - } - - List lines() { return List.copyOf(lines); } - - @Override - public void close() { - LOG.removeHandler(handler); - LOG.setLevel(original); - } - } } diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCCompileOnlyTest.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCCompileOnlyTest.java deleted file mode 100644 index 6cb7e34..0000000 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCCompileOnlyTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package io.github.simbo1905.json.schema; - -import jdk.sandbox.java.util.json.Json; -import jdk.sandbox.java.util.json.JsonValue; -import org.junit.jupiter.api.Test; - -import java.net.URI; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/// Compile-only posture: deny all remote fetches to reveal which fragments -/// compile locally. This is a unit-level gate prior to the full OpenRPC IT. -class OpenRPCCompileOnlyTest extends JsonSchemaLoggingConfig { - - @Test - void compile_local_fragment_succeeds_with_remote_denied() { - final var fragment = "{" + - "\"$defs\":{\"X\":{\"type\":\"integer\"}}," + - "\"$ref\":\"#/$defs/X\"" + - "}"; - - final var fetcher = new MapRemoteFetcher(Map.of()); - final var policy = JsonSchema.FetchPolicy.defaults().withAllowedSchemes(Set.of("file")); - final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher).withFetchPolicy(policy); - - final var schema = JsonSchema.compile(Json.parse(fragment), JsonSchema.Options.DEFAULT, options); - assertThat(schema.validate(Json.parse("1")).valid()).isTrue(); - assertThat(schema.validate(Json.parse("\"x\""))).extracting("valid").isEqualTo(false); - } - - @Test - void compile_remote_ref_is_denied_by_policy() { - final var fragment = "{" + - "\"$ref\":\"http://example.com/openrpc.json#/$defs/X\"" + - "}"; - - final var fetcher = new MapRemoteFetcher(Map.of()); - final var policy = JsonSchema.FetchPolicy.defaults().withAllowedSchemes(Set.of("file")); - final var options = JsonSchema.CompileOptions.remoteDefaults(fetcher).withFetchPolicy(policy); - - assertThatThrownBy(() -> JsonSchema.compile(Json.parse(fragment), JsonSchema.Options.DEFAULT, options)) - .isInstanceOf(JsonSchema.RemoteResolutionException.class) - .hasFieldOrPropertyWithValue("reason", JsonSchema.RemoteResolutionException.Reason.POLICY_DENIED) - .hasMessageContaining("http://example.com/openrpc.json"); - } - - private static final class MapRemoteFetcher implements JsonSchema.RemoteFetcher { - private final Map documents; - private MapRemoteFetcher(Map documents) { this.documents = Map.copyOf(documents); } - @Override public FetchResult fetch(URI uri, JsonSchema.FetchPolicy policy) { - throw new JsonSchema.RemoteResolutionException(uri, - JsonSchema.RemoteResolutionException.Reason.NOT_FOUND, - "No remote document registered for " + uri); - } - } -} diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCFragmentsUnitTest.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCFragmentsUnitTest.java index c6417d8..64bbc23 100644 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCFragmentsUnitTest.java +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCFragmentsUnitTest.java @@ -3,7 +3,7 @@ import jdk.sandbox.java.util.json.Json; import org.junit.jupiter.api.Test; -import static io.github.simbo1905.json.schema.SchemaLogging.LOG; +import static io.github.simbo1905.json.schema.JsonSchema.LOG; import static org.assertj.core.api.Assertions.assertThat; /// Unit tests that exercise OpenRPC-like schema fragments using only diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCSchemaValidationIT.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCSchemaValidationIT.java index 69dd487..d0ceba8 100644 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCSchemaValidationIT.java +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/OpenRPCSchemaValidationIT.java @@ -1,6 +1,5 @@ package io.github.simbo1905.json.schema; -import jdk.sandbox.java.util.json.Json; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; @@ -15,8 +14,8 @@ import java.util.Objects; import java.util.stream.Stream; +import static io.github.simbo1905.json.schema.JsonSchema.LOG; import static org.assertj.core.api.Assertions.assertThat; -import static io.github.simbo1905.json.schema.SchemaLogging.LOG; /// Integration tests: validate OpenRPC documents using a minimal embedded meta-schema. /// Resources: diff --git a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/TestResourceUtils.java b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/TestResourceUtils.java index 3d85f9f..1094ee0 100644 --- a/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/TestResourceUtils.java +++ b/json-java21-schema/src/test/java/io/github/simbo1905/json/schema/TestResourceUtils.java @@ -3,7 +3,8 @@ import java.net.URI; import java.nio.file.Path; import java.nio.file.Paths; -import static io.github.simbo1905.json.schema.SchemaLogging.LOG; + +import static io.github.simbo1905.json.schema.JsonSchema.LOG; /// Test utility for handling file:// URLs in remote reference tests /// Provides consistent path resolution and configuration for test resources @@ -23,10 +24,7 @@ public final class TestResourceUtils { static { // Log configuration at CONFIG level for debugging - LOG.config(() -> "Test Resource Configuration:"); - LOG.config(() -> " TEST_RESOURCE_BASE: " + TEST_RESOURCE_BASE); - LOG.config(() -> " TEST_WORKING_DIR: " + TEST_WORKING_DIR); - LOG.config(() -> " Absolute resource base: " + Paths.get(TEST_RESOURCE_BASE).toAbsolutePath()); + LOG.config(() -> "Test Resource Configuration:\n TEST_RESOURCE_BASE: " + TEST_RESOURCE_BASE + "\n TEST_WORKING_DIR: " + TEST_WORKING_DIR + "\n Absolute resource base: " + Paths.get(TEST_RESOURCE_BASE).toAbsolutePath()); } /// Get a file:// URI for a test resource file diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..c025ed1 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,48 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# + +################################################################################# +# WARNING: Do not store sensitive information in this file, # +# as its contents will be included in the Qodana report. # +################################################################################# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +projectJDK: "21" #(Applied in CI/CD pipeline) + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50 + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-jvm-community:2025.2