|
| 1 | +package io.github.simbo1905.json.schema; |
| 2 | + |
| 3 | +import jdk.sandbox.java.util.json.JsonArray; |
| 4 | +import jdk.sandbox.java.util.json.JsonBoolean; |
| 5 | +import jdk.sandbox.java.util.json.JsonNull; |
| 6 | +import jdk.sandbox.java.util.json.JsonNumber; |
| 7 | +import jdk.sandbox.java.util.json.JsonObject; |
| 8 | +import jdk.sandbox.java.util.json.JsonString; |
| 9 | +import jdk.sandbox.java.util.json.JsonValue; |
| 10 | +import net.jqwik.api.Arbitraries; |
| 11 | +import net.jqwik.api.Arbitrary; |
| 12 | +import net.jqwik.api.Combinators; |
| 13 | +import net.jqwik.api.ForAll; |
| 14 | +import net.jqwik.api.GenerationMode; |
| 15 | +import net.jqwik.api.Property; |
| 16 | +import net.jqwik.api.UseArbitraryProviders; |
| 17 | +import net.jqwik.api.providers.ArbitraryProvider; |
| 18 | +import net.jqwik.api.providers.SubtypeProvider; |
| 19 | +import net.jqwik.api.providers.TypeUsage; |
| 20 | + |
| 21 | +import java.math.BigDecimal; |
| 22 | +import java.util.LinkedHashMap; |
| 23 | +import java.util.List; |
| 24 | +import java.util.Map; |
| 25 | +import java.util.Objects; |
| 26 | +import java.util.Set; |
| 27 | +import java.util.logging.Logger; |
| 28 | +import java.util.stream.Collectors; |
| 29 | + |
| 30 | +import static org.assertj.core.api.Assertions.assertThat; |
| 31 | + |
| 32 | +@UseArbitraryProviders(ITJsonSchemaExhaustiveTest.SchemaArbitraryProvider.class) |
| 33 | +class ITJsonSchemaExhaustiveTest extends JsonSchemaLoggingConfig { |
| 34 | + |
| 35 | + private static final Logger LOGGER = Logger.getLogger(io.github.simbo1905.json.schema.JsonSchema.class.getName()); |
| 36 | + private static final int MAX_DEPTH = 3; |
| 37 | + private static final List<String> PROPERTY_NAMES = List.of("alpha", "beta", "gamma", "delta"); |
| 38 | + private static final List<List<String>> PROPERTY_PAIRS = List.of( |
| 39 | + List.of("alpha", "beta"), |
| 40 | + List.of("alpha", "gamma"), |
| 41 | + List.of("alpha", "delta"), |
| 42 | + List.of("beta", "gamma"), |
| 43 | + List.of("beta", "delta"), |
| 44 | + List.of("gamma", "delta") |
| 45 | + ); |
| 46 | + |
| 47 | + @Property(generation = GenerationMode.EXHAUSTIVE) |
| 48 | + void exhaustiveRoundTrip(@ForAll ITJsonSchemaExhaustiveTest.JsonSchema schema) { |
| 49 | + LOGGER.info(() -> "Executing exhaustiveRoundTrip property test"); |
| 50 | + |
| 51 | + final var schemaDescription = describeSchema(schema); |
| 52 | + LOGGER.fine(() -> "Schema descriptor: " + schemaDescription); |
| 53 | + |
| 54 | + final var schemaJson = schemaToJsonObject(schema); |
| 55 | + LOGGER.finer(() -> "Schema JSON: " + schemaJson); |
| 56 | + |
| 57 | + final var compiled = io.github.simbo1905.json.schema.JsonSchema.compile(schemaJson); |
| 58 | + |
| 59 | + final var compliantDocument = buildCompliantDocument(schema); |
| 60 | + LOGGER.finer(() -> "Compliant document: " + compliantDocument); |
| 61 | + |
| 62 | + final var validation = compiled.validate(compliantDocument); |
| 63 | + assertThat(validation.valid()) |
| 64 | + .as("Compliant document should validate for schema %s", schemaDescription) |
| 65 | + .isTrue(); |
| 66 | + assertThat(validation.errors()) |
| 67 | + .as("No validation errors expected for compliant document") |
| 68 | + .isEmpty(); |
| 69 | + |
| 70 | + final var failingDocuments = failingDocuments(schema, compliantDocument); |
| 71 | + assertThat(failingDocuments) |
| 72 | + .as("Negative cases should be generated for schema %s", schemaDescription) |
| 73 | + .isNotEmpty(); |
| 74 | + |
| 75 | + final var failingDocumentStrings = failingDocuments.stream() |
| 76 | + .map(Object::toString) |
| 77 | + .toList(); |
| 78 | + LOGGER.finest(() -> "Failing documents: " + failingDocumentStrings); |
| 79 | + |
| 80 | + failingDocuments.forEach(failing -> { |
| 81 | + final var failingResult = compiled.validate(failing); |
| 82 | + assertThat(failingResult.valid()) |
| 83 | + .as("Expected validation failure for %s against schema %s", failing, schemaDescription) |
| 84 | + .isFalse(); |
| 85 | + assertThat(failingResult.errors()) |
| 86 | + .as("Expected validation errors for %s against schema %s", failing, schemaDescription) |
| 87 | + .isNotEmpty(); |
| 88 | + }); |
| 89 | + } |
| 90 | + |
| 91 | + private static JsonValue buildCompliantDocument(JsonSchema schema) { |
| 92 | + return switch (schema) { |
| 93 | + case ObjectSchema(var properties) -> JsonObject.of(properties.stream() |
| 94 | + .collect(Collectors.toMap( |
| 95 | + JsonSchema.Property::name, |
| 96 | + property -> buildCompliantDocument(property.schema()), |
| 97 | + (left, right) -> left, |
| 98 | + LinkedHashMap::new |
| 99 | + ))); |
| 100 | + case ArraySchema(var items) -> JsonArray.of(items.stream() |
| 101 | + .map(ITJsonSchemaExhaustiveTest::buildCompliantDocument) |
| 102 | + .toList()); |
| 103 | + case StringSchema ignored -> JsonString.of("valid"); |
| 104 | + case NumberSchema ignored -> JsonNumber.of(BigDecimal.ONE); |
| 105 | + case BooleanSchema ignored -> JsonBoolean.of(true); |
| 106 | + case NullSchema ignored -> JsonNull.of(); |
| 107 | + }; |
| 108 | + } |
| 109 | + |
| 110 | + private static List<JsonValue> failingDocuments(JsonSchema schema, JsonValue compliant) { |
| 111 | + return switch (schema) { |
| 112 | + case ObjectSchema(var properties) -> properties.isEmpty() |
| 113 | + ? List.<JsonValue>of(JsonNull.of()) |
| 114 | + : properties.stream() |
| 115 | + .map(JsonSchema.Property::name) |
| 116 | + .map(name -> removeProperty((JsonObject) compliant, name)) |
| 117 | + .map(json -> (JsonValue) json) |
| 118 | + .toList(); |
| 119 | + case ArraySchema(var items) -> { |
| 120 | + final var values = ((JsonArray) compliant).values(); |
| 121 | + if (values.isEmpty()) { |
| 122 | + yield List.<JsonValue>of(JsonNull.of()); |
| 123 | + } |
| 124 | + final var truncated = JsonArray.of(values.stream().limit(values.size() - 1L).toList()); |
| 125 | + yield List.<JsonValue>of(truncated); |
| 126 | + } |
| 127 | + case StringSchema ignored -> List.<JsonValue>of(JsonNumber.of(BigDecimal.TWO)); |
| 128 | + case NumberSchema ignored -> List.<JsonValue>of(JsonString.of("not-a-number")); |
| 129 | + case BooleanSchema ignored -> List.<JsonValue>of(JsonNull.of()); |
| 130 | + case NullSchema ignored -> List.<JsonValue>of(JsonBoolean.of(true)); |
| 131 | + }; |
| 132 | + } |
| 133 | + |
| 134 | + private static JsonObject removeProperty(JsonObject original, String missingProperty) { |
| 135 | + final var filtered = original.members().entrySet().stream() |
| 136 | + .filter(entry -> !Objects.equals(entry.getKey(), missingProperty)) |
| 137 | + .collect(Collectors.toMap( |
| 138 | + Map.Entry::getKey, |
| 139 | + Map.Entry::getValue, |
| 140 | + (left, right) -> left, |
| 141 | + LinkedHashMap::new |
| 142 | + )); |
| 143 | + return JsonObject.of(filtered); |
| 144 | + } |
| 145 | + |
| 146 | + private static JsonObject schemaToJsonObject(JsonSchema schema) { |
| 147 | + return switch (schema) { |
| 148 | + case ObjectSchema(var properties) -> { |
| 149 | + final var schemaMap = new LinkedHashMap<String, JsonValue>(); |
| 150 | + schemaMap.put("type", JsonString.of("object")); |
| 151 | + final var propertyMap = properties.isEmpty() |
| 152 | + ? JsonObject.of(Map.<String, JsonValue>of()) |
| 153 | + : JsonObject.of(properties.stream() |
| 154 | + .collect(Collectors.toMap( |
| 155 | + JsonSchema.Property::name, |
| 156 | + property -> (JsonValue) schemaToJsonObject(property.schema()), |
| 157 | + (left, right) -> left, |
| 158 | + LinkedHashMap::new |
| 159 | + ))); |
| 160 | + schemaMap.put("properties", propertyMap); |
| 161 | + final var requiredValues = properties.stream() |
| 162 | + .map(JsonSchema.Property::name) |
| 163 | + .map(JsonString::of) |
| 164 | + .map(value -> (JsonValue) value) |
| 165 | + .toList(); |
| 166 | + schemaMap.put("required", JsonArray.of(requiredValues)); |
| 167 | + schemaMap.put("additionalProperties", JsonBoolean.of(false)); |
| 168 | + yield JsonObject.of(schemaMap); |
| 169 | + } |
| 170 | + case ArraySchema(var items) -> { |
| 171 | + final var schemaMap = new LinkedHashMap<String, JsonValue>(); |
| 172 | + schemaMap.put("type", JsonString.of("array")); |
| 173 | + final var prefixItems = items.stream() |
| 174 | + .map(ITJsonSchemaExhaustiveTest::schemaToJsonObject) |
| 175 | + .map(value -> (JsonValue) value) |
| 176 | + .toList(); |
| 177 | + schemaMap.put("prefixItems", JsonArray.of(prefixItems)); |
| 178 | + schemaMap.put("items", JsonBoolean.of(false)); |
| 179 | + schemaMap.put("minItems", JsonNumber.of((long) items.size())); |
| 180 | + schemaMap.put("maxItems", JsonNumber.of((long) items.size())); |
| 181 | + yield JsonObject.of(schemaMap); |
| 182 | + } |
| 183 | + case StringSchema ignored -> primitiveSchema("string"); |
| 184 | + case NumberSchema ignored -> primitiveSchema("number"); |
| 185 | + case BooleanSchema ignored -> primitiveSchema("boolean"); |
| 186 | + case NullSchema ignored -> primitiveSchema("null"); |
| 187 | + }; |
| 188 | + } |
| 189 | + |
| 190 | + private static JsonObject primitiveSchema(String type) { |
| 191 | + final var schemaMap = new LinkedHashMap<String, JsonValue>(); |
| 192 | + schemaMap.put("type", JsonString.of(type)); |
| 193 | + return JsonObject.of(schemaMap); |
| 194 | + } |
| 195 | + |
| 196 | + private static String describeSchema(JsonSchema schema) { |
| 197 | + return switch (schema) { |
| 198 | + case ObjectSchema(var properties) -> properties.stream() |
| 199 | + .map(property -> property.name() + ":" + describeSchema(property.schema())) |
| 200 | + .collect(Collectors.joining(",", "object{", "}")); |
| 201 | + case ArraySchema(var items) -> items.stream() |
| 202 | + .map(ITJsonSchemaExhaustiveTest::describeSchema) |
| 203 | + .collect(Collectors.joining(",", "array[", "]")); |
| 204 | + case StringSchema ignored -> "string"; |
| 205 | + case NumberSchema ignored -> "number"; |
| 206 | + case BooleanSchema ignored -> "boolean"; |
| 207 | + case NullSchema ignored -> "null"; |
| 208 | + }; |
| 209 | + } |
| 210 | + |
| 211 | + static final class SchemaArbitraryProvider implements ArbitraryProvider { |
| 212 | + @Override |
| 213 | + public boolean canProvideFor(TypeUsage targetType) { |
| 214 | + return targetType.isOfType(ITJsonSchemaExhaustiveTest.JsonSchema.class); |
| 215 | + } |
| 216 | + |
| 217 | + @Override |
| 218 | + public Set<Arbitrary<?>> provideFor(TypeUsage targetType, SubtypeProvider subtypeProvider) { |
| 219 | + return Set.of(schemaArbitrary(MAX_DEPTH)); |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + private static Arbitrary<JsonSchema> schemaArbitrary(int depth) { |
| 224 | + final var primitives = Arbitraries.of( |
| 225 | + new StringSchema(), |
| 226 | + new NumberSchema(), |
| 227 | + new BooleanSchema(), |
| 228 | + new NullSchema() |
| 229 | + ); |
| 230 | + if (depth == 0) { |
| 231 | + return primitives; |
| 232 | + } |
| 233 | + return Arbitraries.oneOf( |
| 234 | + primitives, |
| 235 | + objectSchemaArbitrary(depth), |
| 236 | + arraySchemaArbitrary(depth) |
| 237 | + ); |
| 238 | + } |
| 239 | + |
| 240 | + private static Arbitrary<JsonSchema> objectSchemaArbitrary(int depth) { |
| 241 | + if (depth == 1) { |
| 242 | + return Arbitraries.of(new ObjectSchema(List.of())); |
| 243 | + } |
| 244 | + final var childDepth = depth - 1; |
| 245 | + final var empty = Arbitraries.of(new ObjectSchema(List.of())); |
| 246 | + final var single = Combinators.combine( |
| 247 | + Arbitraries.of(PROPERTY_NAMES), |
| 248 | + schemaArbitrary(childDepth) |
| 249 | + ).as((name, child) -> new ObjectSchema(List.of(new Property(name, child)))); |
| 250 | + final var pair = Combinators.combine( |
| 251 | + Arbitraries.of(PROPERTY_PAIRS), |
| 252 | + schemaArbitrary(childDepth), |
| 253 | + schemaArbitrary(childDepth) |
| 254 | + ).as((names, first, second) -> new ObjectSchema(List.of( |
| 255 | + new Property(names.getFirst(), first), |
| 256 | + new Property(names.getLast(), second) |
| 257 | + ))); |
| 258 | + return Arbitraries.oneOf(empty, single, pair); |
| 259 | + } |
| 260 | + |
| 261 | + private static Arbitrary<JsonSchema> arraySchemaArbitrary(int depth) { |
| 262 | + if (depth == 1) { |
| 263 | + return Arbitraries.of(new ArraySchema(List.of())); |
| 264 | + } |
| 265 | + final var childDepth = depth - 1; |
| 266 | + final var empty = Arbitraries.of(new ArraySchema(List.of())); |
| 267 | + final var single = schemaArbitrary(childDepth) |
| 268 | + .map(child -> new ArraySchema(List.of(child))); |
| 269 | + final var pair = Combinators.combine( |
| 270 | + schemaArbitrary(childDepth), |
| 271 | + schemaArbitrary(childDepth) |
| 272 | + ).as((first, second) -> new ArraySchema(List.of(first, second))); |
| 273 | + return Arbitraries.oneOf(empty, single, pair); |
| 274 | + } |
| 275 | + |
| 276 | + sealed interface JsonSchema permits ObjectSchema, ArraySchema, StringSchema, NumberSchema, BooleanSchema, NullSchema { |
| 277 | + record ObjectSchema(List<Property> properties) implements JsonSchema { |
| 278 | + ObjectSchema { |
| 279 | + properties = List.copyOf(properties); |
| 280 | + } |
| 281 | + } |
| 282 | + |
| 283 | + record Property(String name, JsonSchema schema) { |
| 284 | + Property { |
| 285 | + name = Objects.requireNonNull(name); |
| 286 | + schema = Objects.requireNonNull(schema); |
| 287 | + } |
| 288 | + } |
| 289 | + |
| 290 | + record ArraySchema(List<JsonSchema> items) implements JsonSchema { |
| 291 | + ArraySchema { |
| 292 | + items = List.copyOf(items); |
| 293 | + } |
| 294 | + } |
| 295 | + |
| 296 | + record StringSchema() implements JsonSchema {} |
| 297 | + |
| 298 | + record NumberSchema() implements JsonSchema {} |
| 299 | + |
| 300 | + record BooleanSchema() implements JsonSchema {} |
| 301 | + |
| 302 | + record NullSchema() implements JsonSchema {} |
| 303 | + } |
| 304 | +} |
0 commit comments