diff --git a/json-java21-jtd/ARCHITECTURE.md b/json-java21-jtd/ARCHITECTURE.md index 7b99718..20ef5e1 100644 --- a/json-java21-jtd/ARCHITECTURE.md +++ b/json-java21-jtd/ARCHITECTURE.md @@ -46,53 +46,54 @@ flowchart TD ## Core API Design -Following modern Java patterns, we use a single public sealed interface with package-private record implementations: +Following modern Java patterns, we use a package-private sealed interface with record implementations and a public facade class: ```java -package io.github.simbo1905.json.jtd; +package json.java21.jtd; import jdk.sandbox.java.util.json.*; -public sealed interface JTDSchema - permits JTDSchema.EmptySchema, - JTDSchema.RefSchema, - JTDSchema.TypeSchema, - JTDSchema.EnumSchema, - JTDSchema.ElementsSchema, - JTDSchema.PropertiesSchema, - JTDSchema.ValuesSchema, - JTDSchema.DiscriminatorSchema { - - /// Compile JTD schema from JSON - static JTDSchema compile(JsonValue schemaJson) { - // Parse and build immutable schema hierarchy - } - - /// Validate JSON document against schema - default ValidationResult validate(JsonValue json) { - // Stack-based validation - } - +/// Package-private sealed interface for schema types +sealed interface JtdSchema + permits JtdSchema.EmptySchema, + JtdSchema.RefSchema, + JtdSchema.TypeSchema, + JtdSchema.EnumSchema, + JtdSchema.ElementsSchema, + JtdSchema.PropertiesSchema, + JtdSchema.ValuesSchema, + JtdSchema.DiscriminatorSchema, + JtdSchema.NullableSchema { + /// Schema type records (package-private) - record EmptySchema() implements JTDSchema {} - record RefSchema(String ref) implements JTDSchema {} - record TypeSchema(PrimitiveType type) implements JTDSchema {} - record EnumSchema(Set values) implements JTDSchema {} - record ElementsSchema(JTDSchema elements) implements JTDSchema {} + record EmptySchema() implements JtdSchema {} + record RefSchema(String ref, Map definitions) implements JtdSchema {} + record TypeSchema(PrimitiveType type) implements JtdSchema {} + record EnumSchema(Set values) implements JtdSchema {} + record ElementsSchema(JtdSchema elements) implements JtdSchema {} record PropertiesSchema( - Map properties, - Map optionalProperties, + Map properties, + Map optionalProperties, boolean additionalProperties - ) implements JTDSchema {} - record ValuesSchema(JTDSchema values) implements JTDSchema {} + ) implements JtdSchema {} + record ValuesSchema(JtdSchema values) implements JtdSchema {} record DiscriminatorSchema( String discriminator, - Map mapping - ) implements JTDSchema {} + Map mapping + ) implements JtdSchema {} + record NullableSchema(JtdSchema nullable) implements JtdSchema {} +} + +/// Public facade class for JTD operations +public class Jtd { + /// Compile and validate JSON against JTD schema + public Result validate(JsonValue schema, JsonValue instance) { + JtdSchema jtdSchema = compileSchema(schema); + return validateWithStack(jtdSchema, instance); + } /// Validation result - record ValidationResult(boolean valid, List errors) {} - record ValidationError(String instancePath, String schemaPath, String message) {} + public record Result(boolean isValid, List errors) {} } ``` @@ -232,7 +233,10 @@ record CompiledSchema( ```java import jdk.sandbox.java.util.json.*; -import io.github.simbo1905.json.jtd.JTDSchema; +import json.java21.jtd.Jtd; + +// Create JTD validator +Jtd jtd = new Jtd(); // Compile JTD schema String schemaJson = """ @@ -248,18 +252,16 @@ String schemaJson = """ } """; -JTDSchema schema = JTDSchema.compile(Json.parse(schemaJson)); - // Validate JSON String json = """ {"id": "123", "name": "Alice", "age": 30, "email": "alice@example.com"} """; -JTDSchema.ValidationResult result = schema.validate(Json.parse(json)); +Jtd.Result result = jtd.validate(Json.parse(schemaJson), Json.parse(json)); -if (!result.valid()) { +if (!result.isValid()) { for (var error : result.errors()) { - System.out.println(error.instancePath() + ": " + error.message()); + System.out.println(error); } } ``` diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdExhaustiveTest.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdExhaustiveTest.java deleted file mode 100644 index c2f2b9d..0000000 --- a/json-java21-jtd/src/test/java/json/java21/jtd/JtdExhaustiveTest.java +++ /dev/null @@ -1,574 +0,0 @@ -package json.java21.jtd; - -import jdk.sandbox.java.util.json.*; -import net.jqwik.api.*; -import org.junit.jupiter.api.Assertions; - -import java.math.BigDecimal; -import java.util.*; -import java.util.stream.Collectors; - -import static org.assertj.core.api.Assertions.assertThat; - -/// Exhaustive property-based testing for JTD validator -/// Generates comprehensive schema/document permutations to validate RFC 8927 compliance -class JtdExhaustiveTest extends JtdTestBase { - - private static final int MAX_DEPTH = 3; - private static final List PROPERTY_NAMES = List.of("alpha", "beta", "gamma", "delta", "epsilon"); - private static final List> PROPERTY_PAIRS = List.of( - List.of("alpha", "beta"), - List.of("alpha", "gamma"), - List.of("beta", "delta"), - List.of("gamma", "epsilon") - ); - private static final List DISCRIMINATOR_VALUES = List.of("type1", "type2", "type3"); - private static final List ENUM_VALUES = List.of("red", "green", "blue", "yellow"); - private static final Random RANDOM = new Random(); - - @Provide - Arbitrary jtdSchemas() { - return jtdSchemaArbitrary(MAX_DEPTH); - } - - @Property(tries = 100) - void exhaustiveJtdValidation(@ForAll("jtdSchemas") JtdExhaustiveTest.JtdTestSchema schema) { - LOG.info(() -> "Executing exhaustiveJtdValidation property test"); - - final var schemaDescription = describeJtdSchema(schema); - - // Skip problematic schema combinations that create validation issues - if (schemaDescription.contains("elements[discriminator[") && schemaDescription.contains("type=")) { - LOG.fine(() -> "Skipping problematic schema combination: " + schemaDescription); - return; // Skip this test case - } - - LOG.fine(() -> "JTD schema descriptor: " + schemaDescription); - - final var schemaJson = jtdSchemaToJsonObject(schema); - LOG.fine(() -> "JTD schema JSON: " + schemaJson); - - final var validator = new Jtd(); - - final var compliantDocument = buildCompliantJtdDocument(schema); - LOG.fine(() -> "Compliant JTD document: " + compliantDocument); - - final var validationResult = validator.validate(schemaJson, compliantDocument); - - if (!validationResult.isValid()) { - LOG.severe(() -> String.format("ERROR: Compliant document failed validation!%nSchema: %s%nDocument: %s%nErrors: %s", - schemaJson, compliantDocument, validationResult.errors())); - } - - assertThat(validationResult.isValid()) - .as("Compliant JTD document should validate for schema %s", schemaDescription) - .isTrue(); - assertThat(validationResult.errors()) - .as("No validation errors expected for compliant JTD document") - .isEmpty(); - - final var failingDocuments = createFailingJtdDocuments(schema, compliantDocument); - - // RFC 8927: Empty schema {} and PropertiesSchema with no properties accept everything - // Nullable schema accepts null, so may have limited failing cases - if (!(schema instanceof EmptySchema) && !(schema instanceof NullableSchema) && !isEmptyPropertiesSchema(schema)) { - assertThat(failingDocuments) - .as("Negative cases should be generated for JTD schema %s", schemaDescription) - .isNotEmpty(); - } - - final var failingDocumentStrings = failingDocuments.stream() - .map(Object::toString) - .toList(); - LOG.finest(() -> "Failing JTD documents: " + failingDocumentStrings); - - failingDocuments.forEach(failing -> { - LOG.finest(() -> String.format("Testing failing document: %s against schema: %s", failing, schemaJson)); - final var failingResult = validator.validate(schemaJson, failing); - - if (failingResult.isValid()) { - LOG.severe(() -> String.format("UNEXPECTED: Failing document passed validation!%nSchema: %s%nDocument: %s%nExpected: FAILURE, Got: SUCCESS", - schemaJson, failing)); - } - - assertThat(failingResult.isValid()) - .as("Expected JTD validation failure for %s against schema %s", failing, schemaDescription) - .isFalse(); - assertThat(failingResult.errors()) - .as("Expected JTD validation errors for %s against schema %s", failing, schemaDescription) - .isNotEmpty(); - }); - } - - private static JsonValue buildCompliantJtdDocument(JtdTestSchema schema) { - return switch (schema) { - case EmptySchema() -> generateAnyJsonValue(); // RFC 8927: {} accepts anything - case RefSchema(var ignored) -> JsonString.of("ref-compliant-value"); - case TypeSchema(var type) -> buildCompliantTypeValue(type); - case EnumSchema(var values) -> JsonString.of(values.getFirst()); - case ElementsSchema(var elementSchema) -> JsonArray.of(List.of( - buildCompliantJtdDocument(elementSchema), - buildCompliantJtdDocument(elementSchema) - )); - case PropertiesSchema(var required, var optional, var ignored1) -> { - final var members = new LinkedHashMap(); - required.forEach((key, valueSchema) -> - members.put(key, buildCompliantJtdDocument(valueSchema)) - ); - optional.forEach((key, valueSchema) -> - members.put(key, buildCompliantJtdDocument(valueSchema)) - ); - yield JsonObject.of(members); - } - case ValuesSchema(var valueSchema) -> JsonObject.of(Map.of( - "key1", buildCompliantJtdDocument(valueSchema), - "key2", buildCompliantJtdDocument(valueSchema) - )); - case DiscriminatorSchema(var discriminator, var mapping) -> { - final var firstEntry = mapping.entrySet().iterator().next(); - final var discriminatorValue = firstEntry.getKey(); - final var variantSchema = firstEntry.getValue(); - - // Discriminator schemas always generate objects with the discriminator field - final var members = new LinkedHashMap(); - members.put(discriminator, JsonString.of(discriminatorValue)); - - // Add properties based on the variant schema type - if (variantSchema instanceof PropertiesSchema props) { - // Don't re-add the discriminator field when processing properties - props.properties().forEach((key, valueSchema) -> { - if (!key.equals(discriminator)) { // Skip discriminator field to avoid overwriting - members.put(key, buildCompliantJtdDocument(valueSchema)); - } - }); - props.optionalProperties().forEach((key, valueSchema) -> { - if (!key.equals(discriminator)) { // Skip discriminator field to avoid overwriting - members.put(key, buildCompliantJtdDocument(valueSchema)); - } - }); - } - // For TypeSchema variants, the object with just the discriminator field should be valid - // For EnumSchema variants, same logic applies - yield JsonObject.of(members); - } - case NullableSchema(var ignored) -> JsonNull.of(); - }; - } - - private static boolean isEmptyPropertiesSchema(JtdTestSchema schema) { - return schema instanceof PropertiesSchema props && - props.properties().isEmpty() && - props.optionalProperties().isEmpty(); - } - - private static JsonValue generateAnyJsonValue() { - // Generate a random JSON value of any type for RFC 8927 empty schema - return switch (RANDOM.nextInt(7)) { - case 0 -> JsonNull.of(); - case 1 -> JsonBoolean.of(RANDOM.nextBoolean()); - case 2 -> JsonNumber.of(RANDOM.nextInt(100)); - case 3 -> JsonNumber.of(RANDOM.nextDouble()); - case 4 -> JsonString.of("random-string-" + RANDOM.nextInt(1000)); - case 5 -> JsonArray.of(List.of(generateAnyJsonValue(), generateAnyJsonValue())); - case 6 -> JsonObject.of(Map.of( - "key" + RANDOM.nextInt(10), generateAnyJsonValue(), - "prop" + RANDOM.nextInt(10), generateAnyJsonValue() - )); - default -> JsonString.of("fallback"); - }; - } - - private static JsonValue buildCompliantTypeValue(String type) { - return switch (type) { - case "boolean" -> JsonBoolean.of(true); - case "string" -> JsonString.of("compliant-string"); - case "timestamp" -> JsonString.of("2023-12-25T10:30:00Z"); - case "int8" -> JsonNumber.of(42); - case "uint8" -> JsonNumber.of(200); - case "int16" -> JsonNumber.of(30000); - case "uint16" -> JsonNumber.of(50000); - case "int32" -> JsonNumber.of(1000000); - case "uint32" -> JsonNumber.of(3000000000L); - case "float32", "float64" -> JsonNumber.of(new BigDecimal("3.14159")); - default -> JsonString.of("unknown-type-value"); - }; - } - - private static List createFailingJtdDocuments(JtdTestSchema schema, JsonValue compliant) { - return switch (schema) { - case EmptySchema ignored -> List.of(); // RFC 8927: {} accepts everything - no failing documents - case RefSchema ignored -> List.of(JsonNull.of()); // Ref should fail on null - case TypeSchema(var type) -> createFailingTypeValues(type); - case EnumSchema(var ignored) -> List.of(JsonString.of("invalid-enum-value")); - case ElementsSchema(var elementSchema) -> { - if (compliant instanceof JsonArray arr && !arr.values().isEmpty()) { - final var invalidElement = createFailingJtdDocuments(elementSchema, arr.values().getFirst()); - if (!invalidElement.isEmpty()) { - final var mixedArray = JsonArray.of(List.of( - arr.values().getFirst(), - invalidElement.getFirst() - )); - yield List.of(mixedArray, JsonNull.of()); - } - } - yield List.of(JsonNull.of()); - } - case PropertiesSchema(var required, var optional, var additional) -> { - // RFC 8927: PropertiesSchema with no properties behaves like empty schema - if (required.isEmpty() && optional.isEmpty()) { - // No properties defined - this is equivalent to empty schema, accepts everything - yield List.of(); - } - - final var failures = new ArrayList(); - if (!required.isEmpty()) { - final var firstKey = required.keySet().iterator().next(); - failures.add(removeProperty((JsonObject) compliant, firstKey)); - } - if (!additional) { - failures.add(addExtraProperty((JsonObject) compliant, "extraProperty")); - } - failures.add(JsonNull.of()); - yield failures; - } - case ValuesSchema ignored -> List.of(JsonNull.of(), JsonString.of("not-an-object")); - case DiscriminatorSchema(var ignored, var ignored1) -> { - final var failures = new ArrayList(); - failures.add(replaceDiscriminatorValue((JsonObject) compliant, "invalid-discriminator")); - failures.add(JsonNull.of()); - yield failures; - } - case NullableSchema ignored -> List.of(); // Nullable accepts null - }; - } - - private static List createFailingTypeValues(String type) { - return switch (type) { - case "boolean" -> List.of(JsonString.of("not-boolean"), JsonNumber.of(1)); - case "string", "timestamp" -> List.of(JsonNumber.of(123), JsonBoolean.of(false)); - case "int8", "uint8", "int16", "int32", "uint32", "uint16" -> List.of(JsonString.of("not-integer"), JsonNumber.of(new BigDecimal("3.14"))); - case "float32", "float64" -> List.of(JsonString.of("not-float"), JsonBoolean.of(true)); - default -> List.of(JsonNull.of()); - }; - } - - private static JsonObject removeProperty(JsonObject original, String missingProperty) { - final var filtered = original.members().entrySet().stream() - .filter(entry -> !Objects.equals(entry.getKey(), missingProperty)) - .collect(Collectors.toMap( - Map.Entry::getKey, - Map.Entry::getValue, - (left, right) -> left, - LinkedHashMap::new - )); - return JsonObject.of(filtered); - } - - @SuppressWarnings("SameParameterValue") - private static JsonObject addExtraProperty(JsonObject original, String extraProperty) { - final var extended = new LinkedHashMap<>(original.members()); - extended.put(extraProperty, JsonString.of("extra-value")); - return JsonObject.of(extended); - } - - @SuppressWarnings("SameParameterValue") - private static JsonValue replaceDiscriminatorValue(JsonObject original, String newValue) { - final var modified = new LinkedHashMap<>(original.members()); - // Find and replace discriminator field - for (var entry : modified.entrySet()) { - if (entry.getValue() instanceof JsonString) { - modified.put(entry.getKey(), JsonString.of(newValue)); - break; - } - } - return JsonObject.of(modified); - } - - private static JsonObject jtdSchemaToJsonObject(JtdTestSchema schema) { - return switch (schema) { - case EmptySchema() -> JsonObject.of(Map.of()); - case RefSchema(var ref) -> JsonObject.of(Map.of("ref", JsonString.of(ref))); - case TypeSchema(var type) -> JsonObject.of(Map.of("type", JsonString.of(type))); - case EnumSchema(var values) -> JsonObject.of(Map.of( - "enum", JsonArray.of(values.stream().map(JsonString::of).toList()) - )); - case ElementsSchema(var elementSchema) -> JsonObject.of(Map.of( - "elements", jtdSchemaToJsonObject(elementSchema) - )); - case PropertiesSchema(var required, var optional, var additional) -> { - final var schemaMap = new LinkedHashMap(); - if (!required.isEmpty()) { - schemaMap.put("properties", JsonObject.of( - required.entrySet().stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - entry -> jtdSchemaToJsonObject(entry.getValue()) - )) - )); - } - if (!optional.isEmpty()) { - schemaMap.put("optionalProperties", JsonObject.of( - optional.entrySet().stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - entry -> jtdSchemaToJsonObject(entry.getValue()) - )) - )); - } - if (additional) { - schemaMap.put("additionalProperties", JsonBoolean.of(true)); - } - yield JsonObject.of(schemaMap); - } - case ValuesSchema(var valueSchema) -> JsonObject.of(Map.of( - "values", jtdSchemaToJsonObject(valueSchema) - )); - case DiscriminatorSchema(var discriminator, var mapping) -> { - final var schemaMap = new LinkedHashMap(); - schemaMap.put("discriminator", JsonString.of(discriminator)); - schemaMap.put("mapping", JsonObject.of( - mapping.entrySet().stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - entry -> jtdSchemaToJsonObject(entry.getValue()) - )) - )); - yield JsonObject.of(schemaMap); - } - case NullableSchema(var inner) -> { - final var innerSchema = jtdSchemaToJsonObject(inner); - final var nullableMap = new LinkedHashMap<>(innerSchema.members()); - nullableMap.put("nullable", JsonBoolean.of(true)); - yield JsonObject.of(nullableMap); - } - }; - } - - private static String describeJtdSchema(JtdTestSchema schema) { - return switch (schema) { - case EmptySchema() -> "empty"; - case RefSchema(var ref) -> "ref:" + ref; - case TypeSchema(var type) -> "type:" + type; - case EnumSchema(var values) -> "enum[" + String.join(",", values) + "]"; - case ElementsSchema(var elementSchema) -> "elements[" + describeJtdSchema(elementSchema) + "]"; - case PropertiesSchema(var required, var optional, var additional) -> { - final var parts = new ArrayList(); - if (!required.isEmpty()) { - parts.add("required{" + String.join(",", required.keySet()) + "}"); - } - if (!optional.isEmpty()) { - parts.add("optional{" + String.join(",", optional.keySet()) + "}"); - } - if (additional) { - parts.add("additional"); - } - yield "properties[" + String.join(",", parts) + "]"; - } - case ValuesSchema(var valueSchema) -> "values[" + describeJtdSchema(valueSchema) + "]"; - case DiscriminatorSchema(var discriminator, var mapping) -> - "discriminator[" + discriminator + "→{" + String.join(",", mapping.keySet()) + "}]"; - case NullableSchema(var inner) -> "nullable[" + describeJtdSchema(inner) + "]"; - }; - } - - @SuppressWarnings("unchecked") - private static Arbitrary jtdSchemaArbitrary(int depth) { - final var primitives = Arbitraries.of( - new EmptySchema(), - new TypeSchema("boolean"), - new TypeSchema("string"), - new TypeSchema("int32"), - new TypeSchema("float64"), - new TypeSchema("timestamp") - ); - - if (depth == 0) { - return (Arbitrary) (Arbitrary) primitives; - } - - //noinspection RedundantCast - return (Arbitrary) (Arbitrary) Arbitraries.oneOf( - primitives, - enumSchemaArbitrary(), - elementsSchemaArbitrary(depth), - propertiesSchemaArbitrary(depth), - valuesSchemaArbitrary(depth), - discriminatorSchemaArbitrary(), - nullableSchemaArbitrary(depth) - ); - } - - private static Arbitrary enumSchemaArbitrary() { - return Arbitraries.of(ENUM_VALUES) - .list().ofMinSize(1).ofMaxSize(4) - .map(values -> new EnumSchema(new ArrayList<>(values))); - } - - private static Arbitrary elementsSchemaArbitrary(int depth) { - // Avoid generating ElementsSchema with DiscriminatorSchema that maps to simple types - // This creates validation issues as discriminator objects won't match simple type schemas - return jtdSchemaArbitrary(depth - 1) - .filter(schema -> { - // Filter out problematic combinations - if (schema instanceof DiscriminatorSchema disc) { - // Avoid discriminator mapping to simple types when used in elements - var firstVariant = disc.mapping().values().iterator().next(); - return !(firstVariant instanceof TypeSchema) && !(firstVariant instanceof EnumSchema); - } - return true; - }) - .map(ElementsSchema::new); - } - - private static Arbitrary propertiesSchemaArbitrary(int depth) { - final var childDepth = depth - 1; - - final var empty = Arbitraries.of(new PropertiesSchema(Map.of(), Map.of(), false)); - - final var singleRequired = Combinators.combine( - Arbitraries.of(PROPERTY_NAMES), - jtdSchemaArbitrary(childDepth) - ).as((name, schema) -> { - Assertions.assertNotNull(name); - Assertions.assertNotNull(schema); - return new PropertiesSchema( - Map.of(name, schema), - Map.of(), - false - ); - }); - - final var mixed = Combinators.combine( - Arbitraries.of(PROPERTY_PAIRS), - jtdSchemaArbitrary(childDepth), - jtdSchemaArbitrary(childDepth) - ).as((names, requiredSchema, optionalSchema) -> { - Assertions.assertNotNull(names); - Assertions.assertNotNull(requiredSchema); - Assertions.assertNotNull(optionalSchema); - return new PropertiesSchema( - Map.of(names.getFirst(), requiredSchema), - Map.of(names.getLast(), optionalSchema), - false - ); - }); - - final var withAdditional = mixed.map(props -> - { - Assertions.assertNotNull(props); - return new PropertiesSchema(props.properties(), props.optionalProperties(), true); - } - ); - - return Arbitraries.oneOf(empty, singleRequired, mixed, withAdditional); - } - - private static Arbitrary valuesSchemaArbitrary(int depth) { - return jtdSchemaArbitrary(depth - 1) - .map(ValuesSchema::new); - } - /// Creates simple PropertiesSchema instances for discriminator mappings without recursion - /// This prevents stack overflow while ensuring RFC 8927 compliance - private static Arbitrary simplePropertiesSchemaArbitrary() { - // Create primitive schemas that don't recurse - final var primitiveSchemas = Arbitraries.of( - new EmptySchema(), - new TypeSchema("boolean"), - new TypeSchema("string"), - new TypeSchema("int32"), - new EnumSchema(List.of("red", "green", "blue")) - ); - - return Arbitraries.oneOf( - // Empty properties schema - Arbitraries.of(new PropertiesSchema(Map.of(), Map.of(), false)), - - // Single required property with primitive schema - Combinators.combine( - Arbitraries.of(PROPERTY_NAMES), - primitiveSchemas - ).as((name, schema) -> { - Assertions.assertNotNull(name); - Assertions.assertNotNull(schema); - return new PropertiesSchema( - Map.of(name, schema), - Map.of(), - false - ); - }), - - // Single optional property with primitive schema - Combinators.combine( - Arbitraries.of(PROPERTY_NAMES), - primitiveSchemas - ).as((name, schema) -> { - Assertions.assertNotNull(name); - Assertions.assertNotNull(schema); - return new PropertiesSchema( - Map.of(), - Map.of(name, schema), - false - ); - }), - - // Required + optional property with primitive schemas - Combinators.combine( - Arbitraries.of(PROPERTY_PAIRS), - primitiveSchemas, - primitiveSchemas - ).as((names, requiredSchema, optionalSchema) -> { - Assertions.assertNotNull(names); - Assertions.assertNotNull(requiredSchema); - Assertions.assertNotNull(optionalSchema); - return new PropertiesSchema( - Map.of(names.getFirst(), requiredSchema), - Map.of(names.get(1), optionalSchema), - false - ); - }) - ); - } - private static Arbitrary discriminatorSchemaArbitrary() { - - return Combinators.combine( - Arbitraries.of(PROPERTY_NAMES), - Arbitraries.of(DISCRIMINATOR_VALUES), - Arbitraries.of(DISCRIMINATOR_VALUES), - simplePropertiesSchemaArbitrary(), - simplePropertiesSchemaArbitrary() - ).as((discriminatorKey, value1, value2, schema1, schema2) -> { - final var mapping = new LinkedHashMap(); - mapping.put(value1, schema1); - Assertions.assertNotNull(value1); - if (!value1.equals(value2)) { - mapping.put(value2, schema2); - } - return new DiscriminatorSchema(discriminatorKey, mapping); - }); - } - - private static Arbitrary nullableSchemaArbitrary(int depth) { - return jtdSchemaArbitrary(depth - 1) - .map(NullableSchema::new); - } - - /// Sealed interface for JTD test schemas - sealed interface JtdTestSchema permits - EmptySchema, RefSchema, TypeSchema, EnumSchema, - ElementsSchema, PropertiesSchema, ValuesSchema, - DiscriminatorSchema, NullableSchema {} - - record EmptySchema() implements JtdTestSchema {} - record RefSchema(String ref) implements JtdTestSchema {} - record TypeSchema(String type) implements JtdTestSchema {} - record EnumSchema(List values) implements JtdTestSchema {} - record ElementsSchema(JtdTestSchema elements) implements JtdTestSchema {} - record PropertiesSchema( - Map properties, - Map optionalProperties, - boolean additionalProperties - ) implements JtdTestSchema {} - record ValuesSchema(JtdTestSchema values) implements JtdTestSchema {} - record DiscriminatorSchema(String discriminator, Map mapping) implements JtdTestSchema {} - record NullableSchema(JtdTestSchema schema) implements JtdTestSchema {} -} diff --git a/json-java21-jtd/src/test/java/json/java21/jtd/JtdPropertyTest.java b/json-java21-jtd/src/test/java/json/java21/jtd/JtdPropertyTest.java new file mode 100644 index 0000000..36f694e --- /dev/null +++ b/json-java21-jtd/src/test/java/json/java21/jtd/JtdPropertyTest.java @@ -0,0 +1,452 @@ +package json.java21.jtd; + +import jdk.sandbox.java.util.json.*; +import net.jqwik.api.*; +import org.junit.jupiter.api.Assertions; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Property-based testing for JTD validator +/// Generates comprehensive schema/document permutations to validate RFC 8927 compliance +class JtdPropertyTest extends JtdTestBase { + + private static final int MAX_DEPTH = 3; + private static final List PROPERTY_NAMES = List.of("alpha", "beta", "gamma", "delta", "epsilon"); + private static final List> PROPERTY_PAIRS = List.of(List.of("alpha", "beta"), List.of("alpha", "gamma"), List.of("beta", "delta"), List.of("gamma", "epsilon")); + private static final List DISCRIMINATOR_VALUES = List.of("type1", "type2", "type3"); + private static final List ENUM_VALUES = List.of("red", "green", "blue", "yellow"); + private static final Random RANDOM = new Random(); + + private static JsonValue buildCompliantJtdDocument(JtdTestSchema schema) { + return switch (schema) { + case EmptySchema() -> generateAnyJsonValue(); // RFC 8927: {} accepts anything + case RefSchema(var ignored) -> JsonString.of("ref-compliant-value"); + case TypeSchema(var type) -> buildCompliantTypeValue(type); + case EnumSchema(var values) -> JsonString.of(values.getFirst()); + case ElementsSchema(var elementSchema) -> + JsonArray.of(List.of(buildCompliantJtdDocument(elementSchema), buildCompliantJtdDocument(elementSchema))); + case PropertiesSchema(var required, var optional, var ignored1) -> { + final var members = new LinkedHashMap(); + required.forEach((key, valueSchema) -> members.put(key, buildCompliantJtdDocument(valueSchema))); + optional.forEach((key, valueSchema) -> members.put(key, buildCompliantJtdDocument(valueSchema))); + yield JsonObject.of(members); + } + case ValuesSchema(var valueSchema) -> + JsonObject.of(Map.of("key1", buildCompliantJtdDocument(valueSchema), "key2", buildCompliantJtdDocument(valueSchema))); + case DiscriminatorSchema(var discriminator, var mapping) -> { + final var firstEntry = mapping.entrySet().iterator().next(); + final var discriminatorValue = firstEntry.getKey(); + final var variantSchema = firstEntry.getValue(); + + // Discriminator schemas always generate objects with the discriminator field + final var members = new LinkedHashMap(); + members.put(discriminator, JsonString.of(discriminatorValue)); + + // Add properties based on the variant schema type + if (variantSchema instanceof PropertiesSchema props) { + // Don't re-add the discriminator field when processing properties + props.properties().forEach((key, valueSchema) -> { + if (!key.equals(discriminator)) { // Skip discriminator field to avoid overwriting + members.put(key, buildCompliantJtdDocument(valueSchema)); + } + }); + props.optionalProperties().forEach((key, valueSchema) -> { + if (!key.equals(discriminator)) { // Skip discriminator field to avoid overwriting + members.put(key, buildCompliantJtdDocument(valueSchema)); + } + }); + } + // For TypeSchema variants, the object with just the discriminator field should be valid + // For EnumSchema variants, same logic applies + yield JsonObject.of(members); + } + case NullableSchema(var ignored) -> JsonNull.of(); + }; + } + + private static boolean isEmptyPropertiesSchema(JtdTestSchema schema) { + return schema instanceof PropertiesSchema props && props.properties().isEmpty() && props.optionalProperties().isEmpty(); + } + + private static JsonValue generateAnyJsonValue() { + // Generate a random JSON value of any type for RFC 8927 empty schema + return switch (RANDOM.nextInt(7)) { + case 0 -> JsonNull.of(); + case 1 -> JsonBoolean.of(RANDOM.nextBoolean()); + case 2 -> JsonNumber.of(RANDOM.nextInt(100)); + case 3 -> JsonNumber.of(RANDOM.nextDouble()); + case 4 -> JsonString.of("random-string-" + RANDOM.nextInt(1000)); + case 5 -> JsonArray.of(List.of(generateAnyJsonValue(), generateAnyJsonValue())); + case 6 -> + JsonObject.of(Map.of("key" + RANDOM.nextInt(10), generateAnyJsonValue(), "prop" + RANDOM.nextInt(10), generateAnyJsonValue())); + default -> JsonString.of("fallback"); + }; + } + + private static JsonValue buildCompliantTypeValue(String type) { + return switch (type) { + case "boolean" -> JsonBoolean.of(true); + case "string" -> JsonString.of("compliant-string"); + case "timestamp" -> JsonString.of("2023-12-25T10:30:00Z"); + case "int8" -> JsonNumber.of(42); + case "uint8" -> JsonNumber.of(200); + case "int16" -> JsonNumber.of(30000); + case "uint16" -> JsonNumber.of(50000); + case "int32" -> JsonNumber.of(1000000); + case "uint32" -> JsonNumber.of(3000000000L); + case "float32", "float64" -> JsonNumber.of(new BigDecimal("3.14159")); + default -> JsonString.of("unknown-type-value"); + }; + } + + private static List createFailingJtdDocuments(JtdTestSchema schema, JsonValue compliant) { + return switch (schema) { + case EmptySchema ignored -> List.of(); // RFC 8927: {} accepts everything - no failing documents + case RefSchema ignored -> List.of(JsonNull.of()); // Ref should fail on null + case TypeSchema(var type) -> createFailingTypeValues(type); + case EnumSchema(var ignored) -> List.of(JsonString.of("invalid-enum-value")); + case ElementsSchema(var elementSchema) -> { + if (compliant instanceof JsonArray arr && !arr.values().isEmpty()) { + final var invalidElement = createFailingJtdDocuments(elementSchema, arr.values().getFirst()); + if (!invalidElement.isEmpty()) { + final var mixedArray = JsonArray.of(List.of(arr.values().getFirst(), invalidElement.getFirst())); + yield List.of(mixedArray, JsonNull.of()); + } + } + yield List.of(JsonNull.of()); + } + case PropertiesSchema(var required, var optional, var additional) -> { + // RFC 8927: PropertiesSchema with no properties behaves like empty schema + if (required.isEmpty() && optional.isEmpty()) { + // No properties defined - this is equivalent to empty schema, accepts everything + yield List.of(); + } + + final var failures = new ArrayList(); + if (!required.isEmpty()) { + final var firstKey = required.keySet().iterator().next(); + failures.add(removeProperty((JsonObject) compliant, firstKey)); + } + if (!additional) { + failures.add(addExtraProperty((JsonObject) compliant, "extraProperty")); + } + failures.add(JsonNull.of()); + yield failures; + } + case ValuesSchema ignored -> List.of(JsonNull.of(), JsonString.of("not-an-object")); + case DiscriminatorSchema(var ignored, var ignored1) -> { + final var failures = new ArrayList(); + failures.add(replaceDiscriminatorValue((JsonObject) compliant, "invalid-discriminator")); + failures.add(JsonNull.of()); + yield failures; + } + case NullableSchema ignored -> List.of(); // Nullable accepts null + }; + } + + private static List createFailingTypeValues(String type) { + return switch (type) { + case "boolean" -> List.of(JsonString.of("not-boolean"), JsonNumber.of(1)); + case "string", "timestamp" -> List.of(JsonNumber.of(123), JsonBoolean.of(false)); + case "int8", "uint8", "int16", "int32", "uint32", "uint16" -> + List.of(JsonString.of("not-integer"), JsonNumber.of(new BigDecimal("3.14"))); + case "float32", "float64" -> List.of(JsonString.of("not-float"), JsonBoolean.of(true)); + default -> List.of(JsonNull.of()); + }; + } + + private static JsonObject removeProperty(JsonObject original, String missingProperty) { + final var filtered = original.members().entrySet().stream().filter(entry -> !Objects.equals(entry.getKey(), missingProperty)).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (left, right) -> left, LinkedHashMap::new)); + return JsonObject.of(filtered); + } + + @SuppressWarnings("SameParameterValue") + private static JsonObject addExtraProperty(JsonObject original, String extraProperty) { + final var extended = new LinkedHashMap<>(original.members()); + extended.put(extraProperty, JsonString.of("extra-value")); + return JsonObject.of(extended); + } + + @SuppressWarnings("SameParameterValue") + private static JsonValue replaceDiscriminatorValue(JsonObject original, String newValue) { + final var modified = new LinkedHashMap<>(original.members()); + // Find and replace discriminator field + for (var entry : modified.entrySet()) { + if (entry.getValue() instanceof JsonString) { + modified.put(entry.getKey(), JsonString.of(newValue)); + break; + } + } + return JsonObject.of(modified); + } + + private static JsonObject jtdSchemaToJsonObject(JtdTestSchema schema) { + return switch (schema) { + case EmptySchema() -> JsonObject.of(Map.of()); + case RefSchema(var ref) -> JsonObject.of(Map.of("ref", JsonString.of(ref))); + case TypeSchema(var type) -> JsonObject.of(Map.of("type", JsonString.of(type))); + case EnumSchema(var values) -> + JsonObject.of(Map.of("enum", JsonArray.of(values.stream().map(JsonString::of).toList()))); + case ElementsSchema(var elementSchema) -> JsonObject.of(Map.of("elements", jtdSchemaToJsonObject(elementSchema))); + case PropertiesSchema(var required, var optional, var additional) -> { + final var schemaMap = new LinkedHashMap(); + if (!required.isEmpty()) { + schemaMap.put("properties", JsonObject.of(required.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> jtdSchemaToJsonObject(entry.getValue()))))); + } + if (!optional.isEmpty()) { + schemaMap.put("optionalProperties", JsonObject.of(optional.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> jtdSchemaToJsonObject(entry.getValue()))))); + } + if (additional) { + schemaMap.put("additionalProperties", JsonBoolean.of(true)); + } + yield JsonObject.of(schemaMap); + } + case ValuesSchema(var valueSchema) -> JsonObject.of(Map.of("values", jtdSchemaToJsonObject(valueSchema))); + case DiscriminatorSchema(var discriminator, var mapping) -> { + final var schemaMap = new LinkedHashMap(); + schemaMap.put("discriminator", JsonString.of(discriminator)); + schemaMap.put("mapping", JsonObject.of(mapping.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> jtdSchemaToJsonObject(entry.getValue()))))); + yield JsonObject.of(schemaMap); + } + case NullableSchema(var inner) -> { + final var innerSchema = jtdSchemaToJsonObject(inner); + final var nullableMap = new LinkedHashMap<>(innerSchema.members()); + nullableMap.put("nullable", JsonBoolean.of(true)); + yield JsonObject.of(nullableMap); + } + }; + } + + private static String describeJtdSchema(JtdTestSchema schema) { + return switch (schema) { + case EmptySchema() -> "empty"; + case RefSchema(var ref) -> "ref:" + ref; + case TypeSchema(var type) -> "type:" + type; + case EnumSchema(var values) -> "enum[" + String.join(",", values) + "]"; + case ElementsSchema(var elementSchema) -> "elements[" + describeJtdSchema(elementSchema) + "]"; + case PropertiesSchema(var required, var optional, var additional) -> { + final var parts = new ArrayList(); + if (!required.isEmpty()) { + parts.add("required{" + String.join(",", required.keySet()) + "}"); + } + if (!optional.isEmpty()) { + parts.add("optional{" + String.join(",", optional.keySet()) + "}"); + } + if (additional) { + parts.add("additional"); + } + yield "properties[" + String.join(",", parts) + "]"; + } + case ValuesSchema(var valueSchema) -> "values[" + describeJtdSchema(valueSchema) + "]"; + case DiscriminatorSchema(var discriminator, var mapping) -> + "discriminator[" + discriminator + "→{" + String.join(",", mapping.keySet()) + "}]"; + case NullableSchema(var inner) -> "nullable[" + describeJtdSchema(inner) + "]"; + }; + } + + @SuppressWarnings("unchecked") + private static Arbitrary jtdSchemaArbitrary(int depth) { + final var primitives = Arbitraries.of(new EmptySchema(), new TypeSchema("boolean"), new TypeSchema("string"), new TypeSchema("int32"), new TypeSchema("float64"), new TypeSchema("timestamp")); + + if (depth == 0) { + return (Arbitrary) (Arbitrary) primitives; + } + + //noinspection RedundantCast + return (Arbitrary) (Arbitrary) Arbitraries.oneOf(primitives, enumSchemaArbitrary(), elementsSchemaArbitrary(depth), propertiesSchemaArbitrary(depth), valuesSchemaArbitrary(depth), discriminatorSchemaArbitrary(), nullableSchemaArbitrary(depth)); + } + + private static Arbitrary enumSchemaArbitrary() { + return Arbitraries.of(ENUM_VALUES).list().ofMinSize(1).ofMaxSize(4).map(values -> new EnumSchema(new ArrayList<>(values))); + } + + private static Arbitrary elementsSchemaArbitrary(int depth) { + // Avoid generating ElementsSchema with DiscriminatorSchema that maps to simple types + // This creates validation issues as discriminator objects won't match simple type schemas + return jtdSchemaArbitrary(depth - 1).filter(schema -> { + // Filter out problematic combinations + if (schema instanceof DiscriminatorSchema disc) { + // Avoid discriminator mapping to simple types when used in elements + var firstVariant = disc.mapping().values().iterator().next(); + return !(firstVariant instanceof TypeSchema) && !(firstVariant instanceof EnumSchema); + } + return true; + }).map(ElementsSchema::new); + } + + private static Arbitrary propertiesSchemaArbitrary(int depth) { + final var childDepth = depth - 1; + + final var empty = Arbitraries.of(new PropertiesSchema(Map.of(), Map.of(), false)); + + final var singleRequired = Combinators.combine(Arbitraries.of(PROPERTY_NAMES), jtdSchemaArbitrary(childDepth)).as((name, schema) -> { + Assertions.assertNotNull(name); + Assertions.assertNotNull(schema); + return new PropertiesSchema(Map.of(name, schema), Map.of(), false); + }); + + final var mixed = Combinators.combine(Arbitraries.of(PROPERTY_PAIRS), jtdSchemaArbitrary(childDepth), jtdSchemaArbitrary(childDepth)).as((names, requiredSchema, optionalSchema) -> { + Assertions.assertNotNull(names); + Assertions.assertNotNull(requiredSchema); + Assertions.assertNotNull(optionalSchema); + return new PropertiesSchema(Map.of(names.getFirst(), requiredSchema), Map.of(names.getLast(), optionalSchema), false); + }); + + final var withAdditional = mixed.map(props -> { + Assertions.assertNotNull(props); + return new PropertiesSchema(props.properties(), props.optionalProperties(), true); + }); + + return Arbitraries.oneOf(empty, singleRequired, mixed, withAdditional); + } + + private static Arbitrary valuesSchemaArbitrary(int depth) { + return jtdSchemaArbitrary(depth - 1).map(ValuesSchema::new); + } + + /// Creates simple PropertiesSchema instances for discriminator mappings without recursion + /// This prevents stack overflow while ensuring RFC 8927 compliance + private static Arbitrary simplePropertiesSchemaArbitrary() { + // Create primitive schemas that don't recurse + final var primitiveSchemas = Arbitraries.of(new EmptySchema(), new TypeSchema("boolean"), new TypeSchema("string"), new TypeSchema("int32"), new EnumSchema(List.of("red", "green", "blue"))); + + return Arbitraries.oneOf( + // Empty properties schema + Arbitraries.of(new PropertiesSchema(Map.of(), Map.of(), false)), + + // Single required property with primitive schema + Combinators.combine(Arbitraries.of(PROPERTY_NAMES), primitiveSchemas).as((name, schema) -> { + Assertions.assertNotNull(name); + Assertions.assertNotNull(schema); + return new PropertiesSchema(Map.of(name, schema), Map.of(), false); + }), + + // Single optional property with primitive schema + Combinators.combine(Arbitraries.of(PROPERTY_NAMES), primitiveSchemas).as((name, schema) -> { + Assertions.assertNotNull(name); + Assertions.assertNotNull(schema); + return new PropertiesSchema(Map.of(), Map.of(name, schema), false); + }), + + // Required + optional property with primitive schemas + Combinators.combine(Arbitraries.of(PROPERTY_PAIRS), primitiveSchemas, primitiveSchemas).as((names, requiredSchema, optionalSchema) -> { + Assertions.assertNotNull(names); + Assertions.assertNotNull(requiredSchema); + Assertions.assertNotNull(optionalSchema); + return new PropertiesSchema(Map.of(names.getFirst(), requiredSchema), Map.of(names.get(1), optionalSchema), false); + })); + } + + private static Arbitrary discriminatorSchemaArbitrary() { + + return Combinators.combine(Arbitraries.of(PROPERTY_NAMES), Arbitraries.of(DISCRIMINATOR_VALUES), Arbitraries.of(DISCRIMINATOR_VALUES), simplePropertiesSchemaArbitrary(), simplePropertiesSchemaArbitrary()).as((discriminatorKey, value1, value2, schema1, schema2) -> { + final var mapping = new LinkedHashMap(); + mapping.put(value1, schema1); + Assertions.assertNotNull(value1); + if (!value1.equals(value2)) { + mapping.put(value2, schema2); + } + return new DiscriminatorSchema(discriminatorKey, mapping); + }); + } + + private static Arbitrary nullableSchemaArbitrary(int depth) { + return jtdSchemaArbitrary(depth - 1).map(NullableSchema::new); + } + + @Provide + Arbitrary jtdSchemas() { + return jtdSchemaArbitrary(MAX_DEPTH); + } + + @Property(generation = GenerationMode.AUTO) + void exhaustiveJtdValidation(@ForAll("jtdSchemas") JtdPropertyTest.JtdTestSchema schema) { + LOG.finer(() -> "Executing exhaustiveJtdValidation property test"); + + final var schemaDescription = describeJtdSchema(schema); + + // Skip problematic schema combinations that create validation issues + if (schemaDescription.contains("elements[discriminator[") && schemaDescription.contains("type=")) { + LOG.fine(() -> "Skipping problematic schema combination: " + schemaDescription); + return; // Skip this test case + } + + LOG.fine(() -> "JTD schema descriptor: " + schemaDescription); + + final var schemaJson = jtdSchemaToJsonObject(schema); + LOG.fine(() -> "JTD schema JSON: " + schemaJson); + + final var validator = new Jtd(); + + final var compliantDocument = buildCompliantJtdDocument(schema); + LOG.fine(() -> "Compliant JTD document: " + compliantDocument); + + final var validationResult = validator.validate(schemaJson, compliantDocument); + + if (!validationResult.isValid()) { + LOG.severe(() -> String.format("ERROR: Compliant document failed validation!%nSchema: %s%nDocument: %s%nErrors: %s", schemaJson, compliantDocument, validationResult.errors())); + } + + assertThat(validationResult.isValid()).as("Compliant JTD document should validate for schema %s", schemaDescription).isTrue(); + assertThat(validationResult.errors()).as("No validation errors expected for compliant JTD document").isEmpty(); + + final var failingDocuments = createFailingJtdDocuments(schema, compliantDocument); + + // RFC 8927: Empty schema {} and PropertiesSchema with no properties accept everything + // Nullable schema accepts null, so may have limited failing cases + if (!(schema instanceof EmptySchema) && !(schema instanceof NullableSchema) && !isEmptyPropertiesSchema(schema)) { + assertThat(failingDocuments).as("Negative cases should be generated for JTD schema %s", schemaDescription).isNotEmpty(); + } + + final var failingDocumentStrings = failingDocuments.stream().map(Object::toString).toList(); + LOG.finest(() -> "Failing JTD documents: " + failingDocumentStrings); + + failingDocuments.forEach(failing -> { + LOG.finest(() -> String.format("Testing failing document: %s against schema: %s", failing, schemaJson)); + final var failingResult = validator.validate(schemaJson, failing); + + if (failingResult.isValid()) { + LOG.severe(() -> String.format("UNEXPECTED: Failing document passed validation!%nSchema: %s%nDocument: %s%nExpected: FAILURE, Got: SUCCESS", schemaJson, failing)); + } + + assertThat(failingResult.isValid()).as("Expected JTD validation failure for %s against schema %s", failing, schemaDescription).isFalse(); + assertThat(failingResult.errors()).as("Expected JTD validation errors for %s against schema %s", failing, schemaDescription).isNotEmpty(); + }); + } + + /// Sealed interface for JTD test schemas + sealed interface JtdTestSchema permits EmptySchema, RefSchema, TypeSchema, EnumSchema, ElementsSchema, PropertiesSchema, ValuesSchema, DiscriminatorSchema, NullableSchema { + } + + record EmptySchema() implements JtdTestSchema { + } + + record RefSchema(String ref) implements JtdTestSchema { + } + + record TypeSchema(String type) implements JtdTestSchema { + } + + record EnumSchema(List values) implements JtdTestSchema { + } + + record ElementsSchema(JtdTestSchema elements) implements JtdTestSchema { + } + + record PropertiesSchema(Map properties, Map optionalProperties, + boolean additionalProperties) implements JtdTestSchema { + } + + record ValuesSchema(JtdTestSchema values) implements JtdTestSchema { + } + + record DiscriminatorSchema(String discriminator, Map mapping) implements JtdTestSchema { + } + + record NullableSchema(JtdTestSchema schema) implements JtdTestSchema { + } +}