Skip to content

Commit 0141ee6

Browse files
committed
all tests passing
1 parent d15c09d commit 0141ee6

File tree

3 files changed

+516
-29
lines changed

3 files changed

+516
-29
lines changed

json-java21-schema/src/main/java/io/github/simbo1905/json/schema/JsonSchema.java

Lines changed: 210 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
import java.math.BigInteger;
3232
import java.util.*;
3333
import java.util.regex.Pattern;
34+
import java.util.logging.Level;
35+
import java.util.logging.Logger;
3436

3537
/// Single public sealed interface for JSON Schema validation.
3638
///
@@ -51,11 +53,28 @@
5153
/// }
5254
/// }
5355
/// ```
54-
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.NotSchema {
56+
public sealed interface JsonSchema
57+
permits JsonSchema.Nothing,
58+
JsonSchema.ObjectSchema,
59+
JsonSchema.ArraySchema,
60+
JsonSchema.StringSchema,
61+
JsonSchema.NumberSchema,
62+
JsonSchema.BooleanSchema,
63+
JsonSchema.NullSchema,
64+
JsonSchema.AnySchema,
65+
JsonSchema.RefSchema,
66+
JsonSchema.AllOfSchema,
67+
JsonSchema.AnyOfSchema,
68+
JsonSchema.ConditionalSchema,
69+
JsonSchema.ConstSchema,
70+
JsonSchema.NotSchema,
71+
JsonSchema.RootRef {
72+
73+
Logger LOG = Logger.getLogger(JsonSchema.class.getName());
5574

5675
/// Prevents external implementations, ensuring all schema types are inner records
5776
enum Nothing implements JsonSchema {
58-
INSTANCE;
77+
; // Empty enum - just used as a sealed interface permit
5978

6079
@Override
6180
public ValidationResult validateAt(String path, JsonValue json, Deque<ValidationFrame> stack) {
@@ -85,6 +104,8 @@ default ValidationResult validate(JsonValue json) {
85104

86105
while (!stack.isEmpty()) {
87106
ValidationFrame frame = stack.pop();
107+
LOG.finest(() -> "POP " + frame.path() +
108+
" schema=" + frame.schema().getClass().getSimpleName());
88109
ValidationResult result = frame.schema.validateAt(frame.path, frame.json, stack);
89110
if (!result.valid()) {
90111
errors.addAll(result.errors());
@@ -345,38 +366,72 @@ public ValidationResult validateAt(String path, JsonValue json, Deque<Validation
345366
record AllOfSchema(List<JsonSchema> schemas) implements JsonSchema {
346367
@Override
347368
public ValidationResult validateAt(String path, JsonValue json, Deque<ValidationFrame> stack) {
348-
List<ValidationError> errors = new ArrayList<>();
369+
// Push all subschemas onto the stack for validation
349370
for (JsonSchema schema : schemas) {
350371
stack.push(new ValidationFrame(path, schema, json));
351372
}
352-
return ValidationResult.success(); // Errors collected by caller
373+
return ValidationResult.success(); // Actual results emerge from stack processing
353374
}
354375
}
355376

356377
/// AnyOf composition - must satisfy at least one schema
357378
record AnyOfSchema(List<JsonSchema> schemas) implements JsonSchema {
358379
@Override
359380
public ValidationResult validateAt(String path, JsonValue json, Deque<ValidationFrame> stack) {
360-
throw new UnsupportedOperationException("AnyOf composition not implemented");
361-
}
362-
}
381+
List<ValidationError> collected = new ArrayList<>();
382+
boolean anyValid = false;
363383

364-
/// OneOf composition - must satisfy exactly one schema
365-
record OneOfSchema(List<JsonSchema> schemas) implements JsonSchema {
366-
@Override
367-
public ValidationResult validateAt(String path, JsonValue json, Deque<ValidationFrame> stack) {
368-
throw new UnsupportedOperationException("OneOf composition not implemented");
384+
for (JsonSchema schema : schemas) {
385+
// Create a separate validation stack for this branch
386+
Deque<ValidationFrame> branchStack = new ArrayDeque<>();
387+
List<ValidationError> branchErrors = new ArrayList<>();
388+
389+
LOG.finest(() -> "BRANCH START: " + schema.getClass().getSimpleName());
390+
branchStack.push(new ValidationFrame(path, schema, json));
391+
392+
while (!branchStack.isEmpty()) {
393+
ValidationFrame frame = branchStack.pop();
394+
ValidationResult result = frame.schema().validateAt(frame.path(), frame.json(), branchStack);
395+
if (!result.valid()) {
396+
branchErrors.addAll(result.errors());
397+
}
398+
}
399+
400+
if (branchErrors.isEmpty()) {
401+
anyValid = true;
402+
break;
403+
}
404+
collected.addAll(branchErrors);
405+
LOG.finest(() -> "BRANCH END: " + branchErrors.size() + " errors");
406+
}
407+
408+
return anyValid ? ValidationResult.success() : ValidationResult.failure(collected);
369409
}
370410
}
371411

372-
/// Not composition - must not satisfy the schema
373-
record NotSchema(JsonSchema schema) implements JsonSchema {
412+
/// If/Then/Else conditional schema
413+
record ConditionalSchema(JsonSchema ifSchema, JsonSchema thenSchema, JsonSchema elseSchema) implements JsonSchema {
374414
@Override
375415
public ValidationResult validateAt(String path, JsonValue json, Deque<ValidationFrame> stack) {
376-
ValidationResult result = schema.validate(json);
377-
return result.valid() ?
378-
ValidationResult.failure(List.of(new ValidationError(path, "Schema should not match"))) :
379-
ValidationResult.success();
416+
// Step 1 - evaluate IF condition (still needs direct validation)
417+
ValidationResult ifResult = ifSchema.validate(json);
418+
419+
// Step 2 - choose branch
420+
JsonSchema branch = ifResult.valid() ? thenSchema : elseSchema;
421+
422+
LOG.finer(() -> String.format(
423+
"Conditional path=%s ifValid=%b branch=%s",
424+
path, ifResult.valid(),
425+
branch == null ? "none" : (ifResult.valid() ? "then" : "else")));
426+
427+
// Step 3 - if there's a branch, push it onto the stack for later evaluation
428+
if (branch == null) {
429+
return ValidationResult.success(); // no branch → accept
430+
}
431+
432+
// NEW: push branch onto SAME stack instead of direct call
433+
stack.push(new ValidationFrame(path, branch, json));
434+
return ValidationResult.success(); // real result emerges later
380435
}
381436
}
382437

@@ -399,8 +454,25 @@ record ValidationFrame(String path, JsonSchema schema, JsonValue json) {}
399454
/// Internal schema compiler
400455
final class SchemaCompiler {
401456
private static final Map<String, JsonSchema> definitions = new HashMap<>();
457+
private static JsonSchema currentRootSchema;
458+
459+
private static void trace(String stage, JsonValue fragment) {
460+
if (LOG.isLoggable(Level.FINER)) {
461+
LOG.finer(() ->
462+
String.format("[%s] %s", stage, fragment.toString()));
463+
}
464+
}
402465

403466
static JsonSchema compile(JsonValue schemaJson) {
467+
definitions.clear(); // Clear any previous definitions
468+
currentRootSchema = null;
469+
trace("compile-start", schemaJson);
470+
JsonSchema schema = compileInternal(schemaJson);
471+
currentRootSchema = schema; // Store the root schema for self-references
472+
return schema;
473+
}
474+
475+
private static JsonSchema compileInternal(JsonValue schemaJson) {
404476
if (schemaJson instanceof JsonBoolean bool) {
405477
return bool.value() ? AnySchema.INSTANCE : new NotSchema(AnySchema.INSTANCE);
406478
}
@@ -412,15 +484,21 @@ static JsonSchema compile(JsonValue schemaJson) {
412484
// Process definitions first
413485
JsonValue defsValue = obj.members().get("$defs");
414486
if (defsValue instanceof JsonObject defsObj) {
487+
trace("compile-defs", defsValue);
415488
for (var entry : defsObj.members().entrySet()) {
416-
definitions.put("#/$defs/" + entry.getKey(), compile(entry.getValue()));
489+
definitions.put("#/$defs/" + entry.getKey(), compileInternal(entry.getValue()));
417490
}
418491
}
419492

420493
// Handle $ref first
421494
JsonValue refValue = obj.members().get("$ref");
422495
if (refValue instanceof JsonString refStr) {
423496
String ref = refStr.value();
497+
trace("compile-ref", refValue);
498+
if (ref.equals("#")) {
499+
// Lazily resolve to whatever the root schema becomes after compilation
500+
return new RootRef(() -> currentRootSchema);
501+
}
424502
JsonSchema resolved = definitions.get(ref);
425503
if (resolved == null) {
426504
throw new IllegalArgumentException("Unresolved $ref: " + ref);
@@ -431,13 +509,77 @@ static JsonSchema compile(JsonValue schemaJson) {
431509
// Handle composition keywords
432510
JsonValue allOfValue = obj.members().get("allOf");
433511
if (allOfValue instanceof JsonArray allOfArr) {
512+
trace("compile-allof", allOfValue);
434513
List<JsonSchema> schemas = new ArrayList<>();
435514
for (JsonValue item : allOfArr.values()) {
436-
schemas.add(compile(item));
515+
schemas.add(compileInternal(item));
437516
}
438517
return new AllOfSchema(schemas);
439518
}
440519

520+
JsonValue anyOfValue = obj.members().get("anyOf");
521+
if (anyOfValue instanceof JsonArray anyOfArr) {
522+
trace("compile-anyof", anyOfValue);
523+
List<JsonSchema> schemas = new ArrayList<>();
524+
for (JsonValue item : anyOfArr.values()) {
525+
schemas.add(compileInternal(item));
526+
}
527+
return new AnyOfSchema(schemas);
528+
}
529+
530+
// Handle if/then/else
531+
JsonValue ifValue = obj.members().get("if");
532+
if (ifValue != null) {
533+
trace("compile-conditional", obj);
534+
JsonSchema ifSchema = compileInternal(ifValue);
535+
JsonSchema thenSchema = null;
536+
JsonSchema elseSchema = null;
537+
538+
JsonValue thenValue = obj.members().get("then");
539+
if (thenValue != null) {
540+
thenSchema = compileInternal(thenValue);
541+
}
542+
543+
JsonValue elseValue = obj.members().get("else");
544+
if (elseValue != null) {
545+
elseSchema = compileInternal(elseValue);
546+
}
547+
548+
return new ConditionalSchema(ifSchema, thenSchema, elseSchema);
549+
}
550+
551+
// Handle const
552+
JsonValue constValue = obj.members().get("const");
553+
if (constValue != null) {
554+
return new ConstSchema(constValue);
555+
}
556+
557+
// Handle not
558+
JsonValue notValue = obj.members().get("not");
559+
if (notValue != null) {
560+
JsonSchema inner = compileInternal(notValue);
561+
return new NotSchema(inner);
562+
}
563+
564+
// If object-like keywords are present without explicit type, treat as object schema
565+
boolean hasObjectKeywords = obj.members().containsKey("properties")
566+
|| obj.members().containsKey("required")
567+
|| obj.members().containsKey("additionalProperties")
568+
|| obj.members().containsKey("minProperties")
569+
|| obj.members().containsKey("maxProperties");
570+
571+
// If array-like keywords are present without explicit type, treat as array schema
572+
boolean hasArrayKeywords = obj.members().containsKey("items")
573+
|| obj.members().containsKey("minItems")
574+
|| obj.members().containsKey("maxItems")
575+
|| obj.members().containsKey("uniqueItems");
576+
577+
// If string-like keywords are present without explicit type, treat as string schema
578+
boolean hasStringKeywords = obj.members().containsKey("pattern")
579+
|| obj.members().containsKey("minLength")
580+
|| obj.members().containsKey("maxLength")
581+
|| obj.members().containsKey("enum");
582+
441583
// Handle type-based schemas
442584
JsonValue typeValue = obj.members().get("type");
443585
if (typeValue instanceof JsonString typeStr) {
@@ -451,9 +593,16 @@ static JsonSchema compile(JsonValue schemaJson) {
451593
case "null" -> new NullSchema();
452594
default -> AnySchema.INSTANCE;
453595
};
596+
} else {
597+
if (hasObjectKeywords) {
598+
return compileObjectSchema(obj);
599+
} else if (hasArrayKeywords) {
600+
return compileArraySchema(obj);
601+
} else if (hasStringKeywords) {
602+
return compileStringSchema(obj);
603+
}
454604
}
455605

456-
457606
return AnySchema.INSTANCE;
458607
}
459608

@@ -462,7 +611,7 @@ private static JsonSchema compileObjectSchema(JsonObject obj) {
462611
JsonValue propsValue = obj.members().get("properties");
463612
if (propsValue instanceof JsonObject propsObj) {
464613
for (var entry : propsObj.members().entrySet()) {
465-
properties.put(entry.getKey(), compile(entry.getValue()));
614+
properties.put(entry.getKey(), compileInternal(entry.getValue()));
466615
}
467616
}
468617

@@ -481,7 +630,7 @@ private static JsonSchema compileObjectSchema(JsonObject obj) {
481630
if (addPropsValue instanceof JsonBoolean addPropsBool) {
482631
additionalProperties = addPropsBool.value() ? AnySchema.INSTANCE : new NotSchema(AnySchema.INSTANCE);
483632
} else if (addPropsValue instanceof JsonObject addPropsObj) {
484-
additionalProperties = compile(addPropsObj);
633+
additionalProperties = compileInternal(addPropsObj);
485634
}
486635

487636
Integer minProperties = getInteger(obj, "minProperties");
@@ -494,7 +643,7 @@ private static JsonSchema compileArraySchema(JsonObject obj) {
494643
JsonSchema items = AnySchema.INSTANCE;
495644
JsonValue itemsValue = obj.members().get("items");
496645
if (itemsValue != null) {
497-
items = compile(itemsValue);
646+
items = compileInternal(itemsValue);
498647
}
499648

500649
Integer minItems = getInteger(obj, "minItems");
@@ -542,9 +691,9 @@ private static Integer getInteger(JsonObject obj, String key) {
542691
JsonValue value = obj.members().get(key);
543692
if (value instanceof JsonNumber num) {
544693
Number n = num.toNumber();
545-
if (n instanceof Integer) return (Integer) n;
546-
if (n instanceof Long) return ((Long) n).intValue();
547-
if (n instanceof BigDecimal) return ((BigDecimal) n).intValue();
694+
if (n instanceof Integer i) return i;
695+
if (n instanceof Long l) return l.intValue();
696+
if (n instanceof BigDecimal bd) return bd.intValue();
548697
}
549698
return null;
550699
}
@@ -568,4 +717,38 @@ private static BigDecimal getBigDecimal(JsonObject obj, String key) {
568717
return null;
569718
}
570719
}
720+
721+
/// Const schema - validates that a value equals a constant
722+
record ConstSchema(JsonValue constValue) implements JsonSchema {
723+
@Override
724+
public ValidationResult validateAt(String path, JsonValue json, Deque<ValidationFrame> stack) {
725+
return json.equals(constValue) ?
726+
ValidationResult.success() :
727+
ValidationResult.failure(List.of(new ValidationError(path, "Value must equal const value")));
728+
}
729+
}
730+
731+
/// Not composition - inverts the validation result of the inner schema
732+
record NotSchema(JsonSchema schema) implements JsonSchema {
733+
@Override
734+
public ValidationResult validateAt(String path, JsonValue json, Deque<ValidationFrame> stack) {
735+
ValidationResult result = schema.validate(json);
736+
return result.valid() ?
737+
ValidationResult.failure(List.of(new ValidationError(path, "Schema should not match"))) :
738+
ValidationResult.success();
739+
}
740+
}
741+
742+
/// Root reference schema that refers back to the root schema
743+
record RootRef(java.util.function.Supplier<JsonSchema> rootSupplier) implements JsonSchema {
744+
@Override
745+
public ValidationResult validateAt(String path, JsonValue json, Deque<ValidationFrame> stack) {
746+
JsonSchema root = rootSupplier.get();
747+
if (root == null) {
748+
// No root yet (should not happen during validation), accept for now
749+
return ValidationResult.success();
750+
}
751+
return root.validate(json); // Direct validation against root schema
752+
}
753+
}
571754
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package io.github.simbo1905.json.schema;
2+
3+
import org.junit.jupiter.api.BeforeAll;
4+
import java.util.logging.*;
5+
6+
public class JsonSchemaLoggingConfig {
7+
@BeforeAll
8+
static void enableJulDebug() {
9+
Logger root = Logger.getLogger("");
10+
root.setLevel(Level.FINEST); // show FINEST level messages
11+
for (Handler h : root.getHandlers()) {
12+
h.setLevel(Level.FINEST);
13+
}
14+
}
15+
}

0 commit comments

Comments
 (0)