3131import java .math .BigInteger ;
3232import java .util .*;
3333import 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///
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}
0 commit comments