File Coverage

lib/JSON/Structure/SchemaValidator.pm
Criterion Covered Total %
statement 305 565 53.9
branch 170 408 41.6
condition 61 231 26.4
subroutine 37 48 77.0
pod 1 2 50.0
total 574 1254 45.7


line stmt bran cond sub pod time code
1             package JSON::Structure::SchemaValidator;
2              
3 17     17   399520 use strict;
  17         41  
  17         738  
4 17     17   89 use warnings;
  17         42  
  17         919  
5 17     17   222 use v5.20;
  17         66  
6              
7             our $VERSION = '0.6.0';
8              
9 17     17   133 use JSON::MaybeXS;
  17         35  
  17         1593  
10 17     17   119 use Scalar::Util qw(blessed);
  17         60  
  17         1637  
11 17     17   1888 use JSON::Structure::Types;
  17         2139  
  17         928  
12 17     17   3873 use JSON::Structure::ErrorCodes qw(:all);
  17         39  
  17         11457  
13 17     17   2113 use JSON::Structure::JsonSourceLocator;
  17         37  
  17         213896  
14              
15             =head1 NAME
16              
17             JSON::Structure::SchemaValidator - Validate JSON Structure schema documents
18              
19             =head1 SYNOPSIS
20              
21             use JSON::Structure::SchemaValidator;
22             use JSON::PP;
23            
24             my $validator = JSON::Structure::SchemaValidator->new(
25             extended => 1, # Enable extended validation
26             allow_import => 1, # Enable $import/$importdefs
27             );
28            
29             my $schema = decode_json($schema_json);
30             my $result = $validator->validate($schema, $schema_json);
31            
32             if ($result->is_valid) {
33             say "Schema is valid!";
34             } else {
35             for my $error (@{$result->errors}) {
36             say $error->to_string;
37             }
38             }
39              
40             =head1 DESCRIPTION
41              
42             Validates JSON Structure Core documents for conformance with the specification.
43             Provides error messages annotated with line and column numbers.
44              
45             =cut
46              
47             # Regular expressions
48             my $ABSOLUTE_URI_REGEX = qr/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//;
49             my $IDENTIFIER_REGEX = qr/^[A-Za-z_][A-Za-z0-9_]*$/;
50             my $IDENTIFIER_DOLLAR_REGEX = qr/^[A-Za-z_\$][A-Za-z0-9_\$]*$/;
51             my $MAP_KEY_REGEX = qr/^[A-Za-z0-9._-]+$/;
52              
53             # Type definitions
54             my %PRIMITIVE_TYPES =
55             map { $_ => 1 } @{ JSON::Structure::Types::PRIMITIVE_TYPES() };
56             my %COMPOUND_TYPES =
57             map { $_ => 1 } @{ JSON::Structure::Types::COMPOUND_TYPES() };
58             my %NUMERIC_TYPES =
59             map { $_ => 1 } @{ JSON::Structure::Types::NUMERIC_TYPES() };
60              
61             # Reserved keywords
62             my %RESERVED_KEYWORDS = map { $_ => 1 } qw(
63             definitions $extends $id $ref $root $schema $uses
64             $offers abstract additionalProperties const default
65             description enum examples format items maxLength
66             name precision properties required scale type
67             values choices selector tuple
68             );
69              
70             # Extended keywords for conditional composition
71             my %COMPOSITION_KEYWORDS =
72             map { $_ => 1 } qw(allOf anyOf oneOf not if then else);
73              
74             # Extended keywords for validation - combined for warning generation
75             my %VALIDATION_EXTENSION_KEYWORDS = map { $_ => 1 } qw(
76             pattern format minLength maxLength
77             minimum maximum exclusiveMinimum exclusiveMaximum multipleOf
78             minItems maxItems uniqueItems contains minContains maxContains
79             minProperties maxProperties dependentRequired patternProperties
80             propertyNames default contentEncoding contentMediaType
81             minEntries maxEntries patternKeys keyNames has
82             );
83              
84             # Extended keywords for validation - categorized for constraint validation
85             my %NUMERIC_VALIDATION_KEYWORDS = map { $_ => 1 } qw(
86             minimum maximum exclusiveMinimum exclusiveMaximum multipleOf
87             );
88             my %STRING_VALIDATION_KEYWORDS = map { $_ => 1 } qw(
89             minLength maxLength pattern format contentEncoding contentMediaType
90             );
91             my %ARRAY_VALIDATION_KEYWORDS = map { $_ => 1 } qw(
92             minItems maxItems uniqueItems contains minContains maxContains
93             );
94             my %OBJECT_VALIDATION_KEYWORDS = map { $_ => 1 } qw(
95             minProperties maxProperties minEntries maxEntries
96             dependentRequired patternProperties patternKeys
97             propertyNames keyNames has default
98             );
99              
100             # Combined validation keywords for warning detection
101             my %ALL_VALIDATION_KEYWORDS = (
102             %NUMERIC_VALIDATION_KEYWORDS, %STRING_VALIDATION_KEYWORDS,
103             %ARRAY_VALIDATION_KEYWORDS, %OBJECT_VALIDATION_KEYWORDS,
104             );
105              
106             # Valid format values
107             my %VALID_FORMATS = map { $_ => 1 } qw(
108             ipv4 ipv6 email idn-email hostname idn-hostname
109             iri iri-reference uri-template relative-json-pointer regex
110             );
111              
112             # Known extensions
113             my %KNOWN_EXTENSIONS = map { $_ => 1 } qw(
114             JSONStructureImport JSONStructureAlternateNames JSONStructureUnits
115             JSONStructureConditionalComposition JSONStructureValidation
116             );
117              
118             sub new {
119 27     27 0 332923 my ( $class, %args ) = @_;
120              
121             my $self = bless {
122             allow_dollar => $args{allow_dollar} // 0,
123             allow_import => $args{allow_import} // 0,
124             import_map => $args{import_map} // {},
125             extended => $args{extended} // 0,
126             external_schemas => {},
127             warn_on_unused_extensions => $args{warn_on_unused_extension_keywords}
128             // 1,
129 27   50     948 max_validation_depth => $args{max_validation_depth} // 64,
      50        
      50        
      100        
      50        
      50        
130             enabled_extensions => {},
131             errors => [],
132             warnings => [],
133             doc => undef,
134             source_text => undef,
135             source_locator => undef,
136             seen_extends => {},
137             seen_refs => {},
138             current_depth => 0,
139             }, $class;
140              
141             # Build lookup for external schemas by $id
142 27 50       149 if ( $args{external_schemas} ) {
143 0         0 for my $schema ( @{ $args{external_schemas} } ) {
  0         0  
144 0 0 0     0 if ( ref($schema) eq 'HASH' && exists $schema->{'$id'} ) {
145 0         0 $self->{external_schemas}{ $schema->{'$id'} } = $schema;
146             }
147             }
148             }
149              
150 27         97 return $self;
151             }
152              
153             =head2 validate($doc, $source_text)
154              
155             Validates a JSON Structure schema document.
156              
157             Returns a ValidationResult object with errors and warnings.
158              
159             =cut
160              
161             sub validate {
162 66     66 1 530 my ( $self, $doc, $source_text ) = @_;
163              
164             # Reset state
165 66         277 $self->{errors} = [];
166 66         166 $self->{warnings} = [];
167 66         171 $self->{doc} = $doc;
168 66         170 $self->{source_text} = $source_text;
169 66         149 $self->{seen_extends} = {};
170 66         141 $self->{seen_refs} = {};
171 66         142 $self->{enabled_extensions} = {};
172 66         137 $self->{current_depth} = 0;
173              
174             # Initialize source locator
175 66 100       213 if ( defined $source_text ) {
176             $self->{source_locator} =
177 1         16 JSON::Structure::JsonSourceLocator->new($source_text);
178             }
179             else {
180 65         137 $self->{source_locator} = undef;
181             }
182              
183             # Check for null/undefined
184 66 50       194 if ( !defined $doc ) {
185 0         0 $self->_add_error( SCHEMA_NULL, 'Schema cannot be null', '#' );
186 0         0 return $self->_make_result();
187             }
188              
189             # Check document is an object
190 66 50       258 if ( ref($doc) ne 'HASH' ) {
191 0         0 $self->_add_error( SCHEMA_INVALID_TYPE,
192             'Root of the document must be a JSON object', '#' );
193 0         0 return $self->_make_result();
194             }
195              
196             # Process $import and $importdefs
197 66 50       187 $self->_process_imports( $doc, '#' ) if $self->{allow_import};
198              
199             # Check enabled extensions
200 66 100       197 $self->_check_enabled_extensions($doc) if $self->{extended};
201              
202             # Validate required top-level keywords
203 66         296 $self->_check_required_top_level_keywords( $doc, '#' );
204              
205             # Validate $schema
206 66 100       175 if ( exists $doc->{'$schema'} ) {
207 56         213 $self->_check_is_absolute_uri( $doc->{'$schema'}, '$schema',
208             '#/$schema' );
209             }
210              
211             # Validate $id
212 66 100       323 if ( exists $doc->{'$id'} ) {
213 64         164 $self->_check_is_absolute_uri( $doc->{'$id'}, '$id', '#/$id' );
214             }
215              
216             # Validate $uses
217 66 100       191 if ( exists $doc->{'$uses'} ) {
218 3         11 $self->_check_uses( $doc->{'$uses'}, '#/$uses' );
219             }
220              
221             # Check for conflicting type and $root
222 66 50 66     300 if ( exists $doc->{type} && exists $doc->{'$root'} ) {
223 0         0 $self->_add_error(
224             SCHEMA_ROOT_CONFLICT,
225             "Document cannot have both 'type' at root and '\$root' at the same time",
226             '#'
227             );
228             }
229              
230             # Validate type if present
231 66 100       170 if ( exists $doc->{type} ) {
232 62         245 $self->_validate_schema( $doc, 1, '#', undef );
233             }
234              
235             # Validate $root if present
236 66 50       197 if ( exists $doc->{'$root'} ) {
237 0         0 $self->_check_json_pointer( $doc->{'$root'}, $self->{doc}, '#/$root' );
238             }
239              
240             # Validate definitions
241 66 100       180 if ( exists $doc->{definitions} ) {
242 1 50       6 if ( ref( $doc->{definitions} ) ne 'HASH' ) {
243 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
244             'definitions must be an object',
245             '#/definitions' );
246             }
247             else {
248 1         10 $self->_validate_namespace( $doc->{definitions}, '#/definitions' );
249             }
250             }
251              
252             # Validate $offers
253 66 50       199 if ( exists $doc->{'$offers'} ) {
254 0         0 $self->_check_offers( $doc->{'$offers'}, '#/$offers' );
255             }
256              
257             # Check composition keywords at root if no type
258 66 50 66     231 if ( $self->{extended} && !exists $doc->{type} ) {
259 0         0 $self->_check_composition_keywords( $doc, '#' );
260             }
261              
262             # Ensure document has type, $root, or composition keywords
263 66         153 my $has_type = exists $doc->{type};
264 66         122 my $has_root = exists $doc->{'$root'};
265             my $has_composition =
266 66   66     200 $self->{extended} && $self->_has_composition_keywords($doc);
267              
268 66 50 66     217 if ( !$has_type && !$has_root && !$has_composition ) {
      33        
269 4         14 $self->_add_error(
270             SCHEMA_ROOT_MISSING_TYPE,
271             "Document must have 'type', '\$root', or composition keywords at root",
272             '#'
273             );
274             }
275              
276             # Check for validation extension keywords without $uses (warnings)
277 66 100       198 if ( $self->{extended} ) {
278 3         23 $self->_check_validation_keyword_warnings( $doc, '#' );
279             }
280              
281 66         188 return $self->_make_result();
282             }
283              
284             sub _make_result {
285 66     66   146 my ($self) = @_;
286              
287             return JSON::Structure::Types::ValidationResult->new(
288 66         508 is_valid => scalar( @{ $self->{errors} } ) == 0,
289             errors => $self->{errors},
290             warnings => $self->{warnings},
291 66         126 );
292             }
293              
294             sub _add_error {
295 31     31   103 my ( $self, $code, $message, $path, $schema_path ) = @_;
296              
297             my $location =
298             $self->{source_locator}
299 31 100       251 ? $self->{source_locator}->get_location($path)
300             : JSON::Structure::Types::JsonLocation->unknown();
301              
302 31         297 push @{ $self->{errors} },
  31         158  
303             JSON::Structure::Types::ValidationError->new(
304             code => $code,
305             message => $message,
306             path => $path,
307             severity => JSON::Structure::Types::ValidationSeverity::ERROR,
308             location => $location,
309             schema_path => $schema_path,
310             );
311             }
312              
313             sub _add_warning {
314 0     0   0 my ( $self, $code, $message, $path ) = @_;
315              
316             my $location =
317             $self->{source_locator}
318 0 0       0 ? $self->{source_locator}->get_location($path)
319             : JSON::Structure::Types::JsonLocation->unknown();
320              
321 0         0 push @{ $self->{warnings} },
  0         0  
322             JSON::Structure::Types::ValidationError->new(
323             code => $code,
324             message => $message,
325             path => $path,
326             severity => JSON::Structure::Types::ValidationSeverity::WARNING,
327             location => $location,
328             );
329             }
330              
331             sub _check_validation_keyword_warnings {
332 3     3   8 my ( $self, $schema, $path ) = @_;
333              
334 3 50       10 return unless ref($schema) eq 'HASH';
335              
336             # Check if $uses is present at this level
337 3         7 my $has_uses = exists $schema->{'$uses'};
338              
339             # Check for validation keywords without $uses
340 3         11 for my $key ( keys %$schema ) {
341 19 50 66     61 if ( exists $ALL_VALIDATION_KEYWORDS{$key} && !$has_uses ) {
342 0         0 $self->_add_warning(
343             SCHEMA_EXTENSION_KEYWORD_NOT_ENABLED,
344             "Validation keyword '$key' found but no '\$uses' declaration in scope. Add '\$uses' with an appropriate validation extension to enable this keyword.",
345             "$path/$key"
346             );
347             }
348             }
349              
350             # Recurse into nested schemas
351 3 50       10 if ( exists $schema->{type} ) {
352 3         7 my $type = $schema->{type};
353              
354 3 50 33     26 if ( $type eq 'array' && exists $schema->{items} ) {
    50 33        
    50 33        
    50 0        
355             $self->_check_validation_keyword_warnings( $schema->{items},
356 0         0 "$path/items" );
357             }
358             elsif ( $type eq 'map' && exists $schema->{values} ) {
359             $self->_check_validation_keyword_warnings( $schema->{values},
360 0         0 "$path/values" );
361             }
362             elsif ( $type eq 'object' ) {
363 0 0 0     0 if ( exists $schema->{properties}
364             && ref( $schema->{properties} ) eq 'HASH' )
365             {
366 0         0 for my $prop ( keys %{ $schema->{properties} } ) {
  0         0  
367             $self->_check_validation_keyword_warnings(
368 0         0 $schema->{properties}{$prop},
369             "$path/properties/$prop"
370             );
371             }
372             }
373 0 0 0     0 if ( exists $schema->{optionalProperties}
374             && ref( $schema->{optionalProperties} ) eq 'HASH' )
375             {
376 0         0 for my $prop ( keys %{ $schema->{optionalProperties} } ) {
  0         0  
377             $self->_check_validation_keyword_warnings(
378 0         0 $schema->{optionalProperties}{$prop},
379             "$path/optionalProperties/$prop"
380             );
381             }
382             }
383             }
384             elsif ( $type eq 'tuple'
385             && exists $schema->{items}
386             && ref( $schema->{items} ) eq 'ARRAY' )
387             {
388 0         0 for my $i ( 0 .. $#{ $schema->{items} } ) {
  0         0  
389 0         0 $self->_check_validation_keyword_warnings( $schema->{items}[$i],
390             "$path/items/$i" );
391             }
392             }
393             }
394              
395             # Check composition keywords
396 3         7 for my $comp_key (qw(allOf anyOf oneOf)) {
397 9 50 33     27 if ( exists $schema->{$comp_key}
398             && ref( $schema->{$comp_key} ) eq 'ARRAY' )
399             {
400 0         0 for my $i ( 0 .. $#{ $schema->{$comp_key} } ) {
  0         0  
401             $self->_check_validation_keyword_warnings(
402 0         0 $schema->{$comp_key}[$i],
403             "$path/$comp_key/$i" );
404             }
405             }
406             }
407              
408             # Check if keyword
409 3 50       8 if ( exists $schema->{if} ) {
410 0         0 $self->_check_validation_keyword_warnings( $schema->{if}, "$path/if" );
411             $self->_check_validation_keyword_warnings( $schema->{then},
412             "$path/then" )
413 0 0       0 if exists $schema->{then};
414             $self->_check_validation_keyword_warnings( $schema->{else},
415             "$path/else" )
416 0 0       0 if exists $schema->{else};
417             }
418              
419             # Check definitions
420 3 50 33     12 if ( exists $schema->{definitions}
421             && ref( $schema->{definitions} ) eq 'HASH' )
422             {
423             $self->_check_definitions_warnings( $schema->{definitions},
424 0         0 "$path/definitions" );
425             }
426             }
427              
428             sub _check_definitions_warnings {
429 0     0   0 my ( $self, $defs, $path ) = @_;
430              
431 0 0       0 return unless ref($defs) eq 'HASH';
432              
433 0         0 for my $key ( keys %$defs ) {
434 0         0 my $val = $defs->{$key};
435 0 0       0 if ( ref($val) eq 'HASH' ) {
436 0 0       0 if ( exists $val->{type} ) {
437              
438             # This is a schema
439 0         0 $self->_check_validation_keyword_warnings( $val, "$path/$key" );
440             }
441             else {
442             # This might be a namespace
443 0         0 $self->_check_definitions_warnings( $val, "$path/$key" );
444             }
445             }
446             }
447             }
448              
449             sub _check_required_top_level_keywords {
450 66     66   166 my ( $self, $obj, $location ) = @_;
451              
452             # $id is required at root
453 66 100       176 if ( !exists $obj->{'$id'} ) {
454 2         11 $self->_add_error( SCHEMA_ROOT_MISSING_ID,
455             "Missing required '\$id' keyword at root", $location );
456             }
457              
458             # Root schema with 'type' must have 'name'
459 66 100 100     371 if ( exists $obj->{type} && !exists $obj->{name} ) {
460 2         7 $self->_add_error( SCHEMA_ROOT_MISSING_NAME,
461             "Root schema with 'type' must have a 'name' property", $location );
462             }
463             }
464              
465             sub _check_is_absolute_uri {
466 120     120   334 my ( $self, $value, $keyword, $location ) = @_;
467              
468 120 50 33     720 if ( !defined $value || ref($value) ) {
469 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
470             "'$keyword' must be a string", $location );
471 0         0 return;
472             }
473              
474 120 100       1177 if ( $value !~ $ABSOLUTE_URI_REGEX ) {
475 9         42 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
476             "'$keyword' must be an absolute URI", $location );
477             }
478             }
479              
480             sub _check_enabled_extensions {
481 3     3   7 my ( $self, $doc ) = @_;
482              
483 3   50     11 my $schema_uri = $doc->{'$schema'} // '';
484 3   50     9 my $uses = $doc->{'$uses'} // [];
485              
486             # Check if using extended or validation meta-schema
487 3 50       20 if ( $schema_uri =~ /extended|validation/ ) {
488 0 0       0 if ( $schema_uri =~ /validation/ ) {
489             $self->{enabled_extensions}{JSONStructureConditionalComposition} =
490 0         0 1;
491 0         0 $self->{enabled_extensions}{JSONStructureValidation} = 1;
492             }
493             }
494              
495             # Check $uses array
496 3 50       10 if ( ref($uses) eq 'ARRAY' ) {
497 3         9 for my $ext (@$uses) {
498 3 50       10 if ( exists $KNOWN_EXTENSIONS{$ext} ) {
499 3         12 $self->{enabled_extensions}{$ext} = 1;
500             }
501             }
502             }
503             }
504              
505             sub _check_uses {
506 3     3   7 my ( $self, $uses, $path ) = @_;
507              
508 3 50       13 if ( ref($uses) ne 'ARRAY' ) {
509 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
510             '$uses must be an array', $path );
511 0         0 return;
512             }
513              
514 3         37 for my $i ( 0 .. $#$uses ) {
515 3         9 my $ext = $uses->[$i];
516 3 50 33     34 if ( !defined $ext || ref($ext) ) {
    50 33        
517 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
518             "\$uses[$i] must be a string", "$path\[$i]" );
519             }
520             elsif ( $self->{extended} && !exists $KNOWN_EXTENSIONS{$ext} ) {
521 0         0 $self->_add_error(
522             SCHEMA_USES_UNKNOWN_EXTENSION,
523             "Unknown extension '$ext' in \$uses",
524             "$path\[$i]"
525             );
526             }
527             }
528             }
529              
530             sub _check_offers {
531 0     0   0 my ( $self, $offers, $path ) = @_;
532              
533 0 0       0 if ( ref($offers) ne 'ARRAY' ) {
534 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
535             '$offers must be an array', $path );
536 0         0 return;
537             }
538              
539 0         0 for my $i ( 0 .. $#$offers ) {
540 0         0 my $ext = $offers->[$i];
541 0 0 0     0 if ( !defined $ext || ref($ext) ) {
542 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
543             "\$offers[$i] must be a string", "$path\[$i]" );
544             }
545             }
546             }
547              
548             sub _has_composition_keywords {
549 3     3   9 my ( $self, $obj ) = @_;
550              
551 3 50       10 return 0 unless ref($obj) eq 'HASH';
552              
553 3         14 for my $key ( keys %COMPOSITION_KEYWORDS ) {
554 21 50       48 return 1 if exists $obj->{$key};
555             }
556              
557 3         13 return 0;
558             }
559              
560             sub _check_composition_keywords {
561 3     3   9 my ( $self, $obj, $path ) = @_;
562              
563 3         7 for my $keyword (qw(allOf anyOf oneOf)) {
564 9 50       24 if ( exists $obj->{$keyword} ) {
565 0         0 $self->_validate_composition_array( $obj->{$keyword}, $keyword,
566             "$path/$keyword" );
567             }
568             }
569              
570 3 50       10 if ( exists $obj->{not} ) {
571 0 0       0 if ( ref( $obj->{not} ) ne 'HASH' ) {
572 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
573             "'not' must be a schema object", "$path/not" );
574             }
575             else {
576 0         0 $self->_validate_schema( $obj->{not}, 0, "$path/not", undef );
577             }
578             }
579              
580             # if/then/else
581 3 50       9 if ( exists $obj->{if} ) {
582 0 0       0 if ( ref( $obj->{if} ) ne 'HASH' ) {
583 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
584             "'if' must be a schema object", "$path/if" );
585             }
586             else {
587 0         0 $self->_validate_schema( $obj->{if}, 0, "$path/if", undef );
588             }
589             }
590              
591 3 50       9 if ( exists $obj->{then} ) {
592 0 0       0 if ( ref( $obj->{then} ) ne 'HASH' ) {
593 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
594             "'then' must be a schema object", "$path/then" );
595             }
596             else {
597 0         0 $self->_validate_schema( $obj->{then}, 0, "$path/then", undef );
598             }
599             }
600              
601 3 50       10 if ( exists $obj->{else} ) {
602 0 0       0 if ( ref( $obj->{else} ) ne 'HASH' ) {
603 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
604             "'else' must be a schema object", "$path/else" );
605             }
606             else {
607 0         0 $self->_validate_schema( $obj->{else}, 0, "$path/else", undef );
608             }
609             }
610             }
611              
612             sub _validate_composition_array {
613 0     0   0 my ( $self, $arr, $keyword, $path ) = @_;
614              
615 0 0       0 if ( ref($arr) ne 'ARRAY' ) {
616 0         0 $self->_add_error( SCHEMA_COMPOSITION_NOT_ARRAY,
617             "$keyword must be an array", $path );
618 0         0 return;
619             }
620              
621 0 0       0 if ( @$arr == 0 ) {
622 0         0 $self->_add_error( SCHEMA_COMPOSITION_EMPTY,
623             "$keyword array cannot be empty", $path );
624 0         0 return;
625             }
626              
627 0         0 for my $i ( 0 .. $#$arr ) {
628 0         0 my $schema = $arr->[$i];
629 0 0       0 if ( ref($schema) ne 'HASH' ) {
630 0         0 $self->_add_error(
631             SCHEMA_KEYWORD_INVALID_TYPE,
632             "$keyword\[$i] must be a schema object",
633             "$path\[$i]"
634             );
635             }
636             else {
637 0         0 $self->_validate_schema( $schema, 0, "$path\[$i]", undef );
638             }
639             }
640             }
641              
642             sub _check_json_pointer {
643 0     0   0 my ( $self, $pointer, $doc, $path ) = @_;
644              
645 0 0 0     0 if ( !defined $pointer || ref($pointer) ) {
646 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
647             '$root must be a string', $path );
648 0         0 return;
649             }
650              
651             # Validate the pointer resolves
652 0         0 my $target = $self->_resolve_json_pointer( $pointer, $doc );
653 0 0       0 if ( !defined $target ) {
654 0         0 $self->_add_error( SCHEMA_REF_NOT_FOUND,
655             "\$root target does not exist: $pointer", $path );
656             }
657             }
658              
659             sub _resolve_json_pointer {
660 2     2   7 my ( $self, $pointer, $doc ) = @_;
661              
662             # Handle # prefix
663 2         10 $pointer =~ s/^#//;
664              
665 2 50 33     12 return $doc if $pointer eq '' || $pointer eq '/';
666              
667 2         9 my @segments = split m{/}, $pointer;
668 2 50 33     11 shift @segments if @segments && $segments[0] eq '';
669              
670 2         6 my $current = $doc;
671              
672 2         6 for my $segment (@segments) {
673              
674             # Unescape JSON Pointer tokens
675 3         7 $segment =~ s/~1/\//g;
676 3         7 $segment =~ s/~0/~/g;
677              
678 3 50       9 if ( ref($current) eq 'HASH' ) {
    0          
679 3 100       12 return undef unless exists $current->{$segment};
680 2         6 $current = $current->{$segment};
681             }
682             elsif ( ref($current) eq 'ARRAY' ) {
683 0 0       0 return undef unless $segment =~ /^\d+$/;
684 0         0 my $idx = int($segment);
685 0 0       0 return undef if $idx >= @$current;
686 0         0 $current = $current->[$idx];
687             }
688             else {
689 0         0 return undef;
690             }
691             }
692              
693 1         4 return $current;
694             }
695              
696             sub _validate_namespace {
697 2     2   7 my ( $self, $definitions, $path ) = @_;
698              
699 2         6 for my $name ( keys %$definitions ) {
700 2         5 my $def = $definitions->{$name};
701 2         5 my $def_path = "$path/$name";
702              
703             # Validate identifier
704             my $id_regex =
705 2 50       7 $self->{allow_dollar} ? $IDENTIFIER_DOLLAR_REGEX : $IDENTIFIER_REGEX;
706 2 50       63 if ( $name !~ $id_regex ) {
707 0         0 $self->_add_error( SCHEMA_NAME_INVALID,
708             "Definition name '$name' must be a valid identifier",
709             $def_path );
710             }
711              
712 2 50       9 if ( ref($def) ne 'HASH' ) {
713 0         0 $self->_add_error( SCHEMA_INVALID_TYPE,
714             'Definition must be an object', $def_path );
715 0         0 next;
716             }
717              
718             # Check for nested definitions (namespace)
719 2 0 33     7 if ( !exists $def->{type}
      0        
720             && !exists $def->{'$ref'}
721             && !$self->_has_composition_keywords($def) )
722             {
723             # Could be a namespace with nested definitions
724 0         0 my $has_nested = 0;
725 0         0 for my $key ( keys %$def ) {
726 0 0 0     0 if (
      0        
727             ref( $def->{$key} ) eq 'HASH'
728             && ( exists $def->{$key}{type}
729             || exists $def->{$key}{'$ref'} )
730             )
731             {
732 0         0 $has_nested = 1;
733 0         0 last;
734             }
735             }
736              
737 0 0       0 if ($has_nested) {
738 0         0 $self->_validate_namespace( $def, $def_path );
739 0         0 next;
740             }
741              
742             # Not a namespace - must have type or $ref
743 0         0 $self->_add_error( SCHEMA_MISSING_TYPE,
744             "Definition must have 'type' or '\$ref'", $def_path );
745 0         0 next;
746             }
747              
748 2         6 $self->_validate_schema( $def, 0, $def_path, $name );
749             }
750             }
751              
752             sub _validate_schema {
753 98     98   273 my ( $self, $schema, $is_root, $path, $name_in_namespace ) = @_;
754              
755             # Check depth
756 98         202 $self->{current_depth}++;
757 98 50       376 if ( $self->{current_depth} > $self->{max_validation_depth} ) {
758 0         0 $self->_add_error(
759             SCHEMA_MAX_DEPTH_EXCEEDED,
760             "Maximum validation depth ($self->{max_validation_depth}) exceeded",
761             $path
762             );
763 0         0 $self->{current_depth}--;
764 0         0 return;
765             }
766              
767             # Handle boolean schemas
768 98 50       252 if ( !ref($schema) ) {
769 0 0 0     0 if ( $schema eq '1'
      0        
      0        
      0        
770             || $schema eq '0'
771             || _is_json_bool($schema)
772             || $schema eq 'true'
773             || $schema eq 'false' )
774             {
775 0         0 $self->{current_depth}--;
776 0         0 return; # Boolean schema is valid
777             }
778             }
779              
780 98 50       280 if ( ref($schema) ne 'HASH' ) {
781 0         0 $self->_add_error( SCHEMA_INVALID_TYPE,
782             'Schema must be a boolean or object', $path );
783 0         0 $self->{current_depth}--;
784 0         0 return;
785             }
786              
787             # Validate name if present
788 98 100       245 if ( exists $schema->{name} ) {
789             my $id_regex =
790 60 50       164 $self->{allow_dollar} ? $IDENTIFIER_DOLLAR_REGEX : $IDENTIFIER_REGEX;
791 60 50 33     689 if ( !defined $schema->{name}
      33        
792             || ref( $schema->{name} )
793             || $schema->{name} !~ $id_regex )
794             {
795 0         0 $self->_add_error( SCHEMA_NAME_INVALID,
796             "'name' must be a valid identifier", "$path/name" );
797             }
798             }
799              
800             # Validate type
801 98         283 my $type = $schema->{type};
802              
803 98 50       227 if ( !defined $type ) {
804              
805             # Check for composition keywords in extended mode
806 0 0 0     0 if ( $self->{extended} && $self->_has_composition_keywords($schema) ) {
807 0         0 $self->_check_composition_keywords( $schema, $path );
808 0         0 $self->{current_depth}--;
809 0         0 return;
810             }
811              
812             # Check for $ref in type object form - this is allowed
813 0 0       0 if ( exists $schema->{'$ref'} ) {
814 0         0 $self->_add_error( SCHEMA_REF_NOT_IN_TYPE,
815             "\$ref is only permitted inside the 'type' attribute", $path );
816             }
817              
818 0 0       0 if ( !$is_root ) {
819              
820             # Non-root schemas without type are OK if they have other defining keywords
821 0         0 my $has_defining = 0;
822 0         0 for my $kw (qw(properties items values choices const enum)) {
823 0 0       0 if ( exists $schema->{$kw} ) {
824 0         0 $has_defining = 1;
825 0         0 last;
826             }
827             }
828              
829 0 0 0     0 if ( !$has_defining && !exists $schema->{'$ref'} ) {
830              
831             # Could be just metadata - that's OK in some contexts
832             }
833             }
834             }
835             else {
836 98         265 $self->_validate_type( $type, $schema, $path );
837             }
838              
839             # Validate properties
840 98 100       255 if ( exists $schema->{properties} ) {
841 20         81 $self->_validate_properties( $schema->{properties}, $path );
842             }
843              
844             # Validate required
845 98 100       295 if ( exists $schema->{required} ) {
846             $self->_validate_required( $schema->{required}, $schema->{properties},
847 9         46 $path );
848             }
849              
850             # Validate items
851 98 100       243 if ( exists $schema->{items} ) {
852 2         13 $self->_validate_items( $schema->{items}, "$path/items" );
853             }
854              
855             # Validate values (for map type)
856 98 100       273 if ( exists $schema->{values} ) {
857 1         8 $self->_validate_values( $schema->{values}, "$path/values" );
858             }
859              
860             # Validate choices (for choice type)
861 98 100       317 if ( exists $schema->{choices} ) {
862             $self->_validate_choices( $schema->{choices}, $schema->{selector},
863 1         6 $path );
864             }
865              
866             # Validate tuple
867 98 100       279 if ( exists $schema->{tuple} ) {
868             $self->_validate_tuple( $schema->{tuple}, $schema->{properties},
869 1         6 $path );
870             }
871              
872             # Validate enum
873 98 100       246 if ( exists $schema->{enum} ) {
874 3         15 $self->_validate_enum( $schema->{enum}, "$path/enum" );
875             }
876              
877             # Validate const
878 98 50       243 if ( exists $schema->{const} ) {
879              
880             # const can be any value, no specific validation needed
881             }
882              
883             # Validate additionalProperties
884 98 50       234 if ( exists $schema->{additionalProperties} ) {
885             $self->_validate_additional_properties( $schema->{additionalProperties},
886 0         0 "$path/additionalProperties" );
887             }
888              
889             # Validate $extends
890 98 50       284 if ( exists $schema->{'$extends'} ) {
891 0         0 $self->_validate_extends( $schema->{'$extends'}, $path );
892             }
893              
894             # Validate definitions within schema
895 98 100       237 if ( exists $schema->{definitions} ) {
896 1 50       5 if ( ref( $schema->{definitions} ) ne 'HASH' ) {
897 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
898             'definitions must be an object',
899             "$path/definitions" );
900             }
901             else {
902             $self->_validate_namespace( $schema->{definitions},
903 1         7 "$path/definitions" );
904             }
905             }
906              
907             # Validate extended keywords if enabled
908 98 100       295 if ( $self->{extended} ) {
909 3         13 $self->_validate_extended_keywords( $schema, $type, $path );
910              
911             # Also check composition keywords within schemas with type
912 3         11 $self->_check_composition_keywords( $schema, $path );
913             }
914              
915             # Validate altnames
916 98 50       246 if ( exists $schema->{altnames} ) {
917 0         0 $self->_validate_altnames( $schema->{altnames}, "$path/altnames" );
918             }
919              
920 98         281 $self->{current_depth}--;
921             }
922              
923             sub _validate_type {
924 98     98   238 my ( $self, $type, $schema, $path ) = @_;
925              
926 98         285 my $type_path = "$path/type";
927              
928             # Type can be a string, array of strings, or object with $ref
929 98 50       274 if ( !defined $type ) {
930 0         0 return; # No type is OK in some contexts
931             }
932              
933 98 100       269 if ( !ref($type) ) {
    50          
    50          
934              
935             # Simple string type
936 96         323 $self->_validate_type_name( $type, $type_path );
937 96         288 $self->_validate_type_constraints( $type, $schema, $path );
938             }
939             elsif ( ref($type) eq 'ARRAY' ) {
940              
941             # Array of types (union)
942 0 0       0 if ( @$type == 0 ) {
943 0         0 $self->_add_error( SCHEMA_TYPE_ARRAY_EMPTY,
944             'type array cannot be empty', $type_path );
945 0         0 return;
946             }
947              
948 0         0 for my $i ( 0 .. $#$type ) {
949 0         0 my $t = $type->[$i];
950 0 0 0     0 if ( !defined $t || ref($t) ) {
951 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
952             "type[$i] must be a string",
953             "$type_path\[$i]" );
954             }
955             else {
956 0         0 $self->_validate_type_name( $t, "$type_path\[$i]" );
957             }
958             }
959             }
960             elsif ( ref($type) eq 'HASH' ) {
961              
962             # Object type with $ref
963 2 50       8 if ( !exists $type->{'$ref'} ) {
964 0         0 $self->_add_error( SCHEMA_TYPE_OBJECT_MISSING_REF,
965             'type object must contain $ref', $type_path );
966             }
967             else {
968 2         10 $self->_validate_ref( $type->{'$ref'}, "$type_path/\$ref" );
969             }
970             }
971             else {
972 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
973             'type must be a string, array, or object with $ref', $type_path );
974             }
975             }
976              
977             sub _validate_type_name {
978 96     96   249 my ( $self, $type_name, $path ) = @_;
979              
980 96 100       384 return if exists $PRIMITIVE_TYPES{$type_name};
981 32 100       148 return if exists $COMPOUND_TYPES{$type_name};
982              
983 4         27 $self->_add_error( SCHEMA_TYPE_INVALID, "Invalid type: '$type_name'",
984             $path );
985             }
986              
987             sub _validate_type_constraints {
988 96     96   274 my ( $self, $type, $schema, $path ) = @_;
989              
990             # Validate compound type requirements
991 96 100 66     719 if ( $type eq 'array' || $type eq 'set' ) {
    100          
    100          
    100          
992 3 100 66     24 if ( !exists $schema->{items} && !exists $schema->{contains} ) {
993 1         7 $self->_add_error( SCHEMA_ARRAY_MISSING_ITEMS,
994             "$type type requires 'items' or 'contains' schema", $path );
995             }
996             }
997             elsif ( $type eq 'map' ) {
998 2 100       9 if ( !exists $schema->{values} ) {
999 1         3 $self->_add_error( SCHEMA_MAP_MISSING_VALUES,
1000             "map type requires 'values' schema", $path );
1001             }
1002             }
1003             elsif ( $type eq 'tuple' ) {
1004 2 100 66     15 if ( !exists $schema->{properties} || !exists $schema->{tuple} ) {
1005 1         5 $self->_add_error( SCHEMA_TUPLE_MISSING_DEFINITION,
1006             "tuple type requires 'properties' and 'tuple' keywords",
1007             $path );
1008             }
1009             }
1010             elsif ( $type eq 'choice' ) {
1011 2 100       8 if ( !exists $schema->{choices} ) {
1012 1         4 $self->_add_error( SCHEMA_CHOICE_MISSING_CHOICES,
1013             "choice type requires 'choices' keyword", $path );
1014             }
1015             }
1016             }
1017              
1018             sub _validate_ref {
1019 2     2   6 my ( $self, $ref, $path ) = @_;
1020              
1021 2 50 33     11 if ( !defined $ref || ref($ref) ) {
1022 0         0 $self->_add_error( SCHEMA_REF_INVALID, '$ref must be a string', $path );
1023 0         0 return;
1024             }
1025              
1026             # Check for circular references
1027 2 50       8 if ( exists $self->{seen_refs}{$ref} ) {
1028 0         0 $self->_add_error( SCHEMA_CIRCULAR_REF,
1029             "Circular reference detected: $ref", $path );
1030 0         0 return;
1031             }
1032              
1033             # Mark this ref as being processed
1034 2         7 $self->{seen_refs}{$ref} = 1;
1035              
1036             # Check if reference resolves
1037 2         9 my $target = $self->_resolve_json_pointer( $ref, $self->{doc} );
1038 2 100       5 if ( !defined $target ) {
1039              
1040             # Check external schemas
1041 1         4 my $found = 0;
1042 1         3 for my $id ( keys %{ $self->{external_schemas} } ) {
  1         5  
1043 0         0 my $ext = $self->{external_schemas}{$id};
1044 0 0       0 if ( $ref =~ /^$id/ ) {
1045 0         0 $found = 1;
1046 0         0 last;
1047             }
1048             }
1049              
1050 1 50       5 if ( !$found ) {
1051 1         6 $self->_add_error( SCHEMA_REF_NOT_FOUND,
1052             "\$ref target does not exist: $ref", $path );
1053             }
1054             }
1055             else {
1056             # Validate the target schema, checking for circular refs
1057 1 50 33     28 if ( ref($target) eq 'HASH' && exists $target->{type} ) {
1058 1         5 $self->_check_type_for_circular_ref( $target, $ref, $path );
1059             }
1060             }
1061              
1062             # Clear the seen ref after processing
1063 2         8 delete $self->{seen_refs}{$ref};
1064             }
1065              
1066             sub _check_type_for_circular_ref {
1067 1     1   4 my ( $self, $schema, $original_ref, $path ) = @_;
1068              
1069 1 50       5 return unless ref($schema) eq 'HASH';
1070              
1071 1         2 my $type = $schema->{type};
1072 1 50       3 return unless defined $type;
1073              
1074 1 0 33     6 if ( ref($type) eq 'HASH' && exists $type->{'$ref'} ) {
1075 0         0 my $nested_ref = $type->{'$ref'};
1076 0 0       0 if ( $nested_ref eq $original_ref ) {
    0          
1077 0         0 $self->_add_error(
1078             SCHEMA_CIRCULAR_REF,
1079             "Direct circular reference detected: type references $nested_ref which references itself",
1080             $path
1081             );
1082             }
1083             elsif ( exists $self->{seen_refs}{$nested_ref} ) {
1084 0         0 $self->_add_error( SCHEMA_CIRCULAR_REF,
1085             "Circular reference chain detected involving: $nested_ref",
1086             $path );
1087             }
1088             }
1089             }
1090              
1091             sub _validate_properties {
1092 20     20   173 my ( $self, $properties, $path ) = @_;
1093              
1094 20 50       89 if ( ref($properties) ne 'HASH' ) {
1095 0         0 $self->_add_error( SCHEMA_PROPERTIES_NOT_OBJECT,
1096             'properties must be an object',
1097             "$path/properties" );
1098 0         0 return;
1099             }
1100              
1101 20         93 for my $prop_name ( keys %$properties ) {
1102 29         65 my $prop_schema = $properties->{$prop_name};
1103 29         62 my $prop_path = "$path/properties/$prop_name";
1104              
1105 29 50       114 if ( ref($prop_schema) eq 'HASH' ) {
    0          
1106 29         174 $self->_validate_schema( $prop_schema, 0, $prop_path, undef );
1107             }
1108             elsif ( !ref($prop_schema) ) {
1109              
1110             # Boolean schema is valid
1111             }
1112             else {
1113 0         0 $self->_add_error( SCHEMA_INVALID_TYPE,
1114             'Property schema must be a boolean or object', $prop_path );
1115             }
1116             }
1117             }
1118              
1119             sub _validate_required {
1120 9     9   29 my ( $self, $required, $properties, $path ) = @_;
1121              
1122 9 50       41 if ( ref($required) ne 'ARRAY' ) {
1123 0         0 $self->_add_error( SCHEMA_REQUIRED_NOT_ARRAY,
1124             'required must be an array',
1125             "$path/required" );
1126 0         0 return;
1127             }
1128              
1129 9         42 for my $i ( 0 .. $#$required ) {
1130 10         27 my $prop = $required->[$i];
1131              
1132 10 50 33     58 if ( !defined $prop || ref($prop) ) {
1133 0         0 $self->_add_error(
1134             SCHEMA_REQUIRED_ITEM_NOT_STRING,
1135             'required array items must be strings',
1136             "$path/required[$i]"
1137             );
1138 0         0 next;
1139             }
1140              
1141             # Check if property exists in properties
1142 10 50 33     69 if ( defined $properties && ref($properties) eq 'HASH' ) {
1143 10 100       45 if ( !exists $properties->{$prop} ) {
1144 1         7 $self->_add_error(
1145             SCHEMA_REQUIRED_PROPERTY_NOT_DEFINED,
1146             "Required property '$prop' is not defined in properties",
1147             "$path/required[$i]"
1148             );
1149             }
1150             }
1151             }
1152             }
1153              
1154             sub _validate_items {
1155 2     2   8 my ( $self, $items, $path ) = @_;
1156              
1157 2 50       10 if ( ref($items) eq 'HASH' ) {
    0          
1158 2         45 $self->_validate_schema( $items, 0, $path, undef );
1159             }
1160             elsif ( !ref($items) ) {
1161              
1162             # Boolean schema is valid
1163             }
1164             else {
1165 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
1166             'items must be a boolean or schema object', $path );
1167             }
1168             }
1169              
1170             sub _validate_values {
1171 1     1   33 my ( $self, $values, $path ) = @_;
1172              
1173 1 50       7 if ( ref($values) eq 'HASH' ) {
    0          
1174 1         5 $self->_validate_schema( $values, 0, $path, undef );
1175             }
1176             elsif ( !ref($values) ) {
1177              
1178             # Boolean schema is valid
1179             }
1180             else {
1181 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
1182             'values must be a boolean or schema object', $path );
1183             }
1184             }
1185              
1186             sub _validate_choices {
1187 1     1   4 my ( $self, $choices, $selector, $path ) = @_;
1188              
1189 1 50       15 if ( ref($choices) ne 'HASH' ) {
1190 0         0 $self->_add_error( SCHEMA_CHOICES_NOT_OBJECT,
1191             'choices must be an object',
1192             "$path/choices" );
1193 0         0 return;
1194             }
1195              
1196 1 50       5 if ( keys %$choices == 0 ) {
1197 0         0 $self->_add_error( SCHEMA_KEYWORD_EMPTY, 'choices cannot be empty',
1198             "$path/choices" );
1199 0         0 return;
1200             }
1201              
1202 1         5 for my $choice_name ( keys %$choices ) {
1203 2         5 my $choice_schema = $choices->{$choice_name};
1204 2 50       9 if ( ref($choice_schema) eq 'HASH' ) {
    0          
1205 2         9 $self->_validate_schema( $choice_schema, 0,
1206             "$path/choices/$choice_name", undef );
1207             }
1208             elsif ( !ref($choice_schema) ) {
1209              
1210             # Boolean schema is valid
1211             }
1212             else {
1213 0         0 $self->_add_error(
1214             SCHEMA_INVALID_TYPE,
1215             'Choice schema must be a boolean or object',
1216             "$path/choices/$choice_name"
1217             );
1218             }
1219             }
1220             }
1221              
1222             sub _validate_tuple {
1223 1     1   4 my ( $self, $tuple, $properties, $path ) = @_;
1224              
1225 1 50       6 if ( ref($tuple) ne 'ARRAY' ) {
1226 0         0 $self->_add_error(
1227             SCHEMA_TUPLE_ORDER_NOT_ARRAY,
1228             "'tuple' keyword must be an array of property names",
1229             "$path/tuple"
1230             );
1231 0         0 return;
1232             }
1233              
1234             # Validate each tuple entry exists in properties
1235 1 50 33     9 if ( defined $properties && ref($properties) eq 'HASH' ) {
1236 1         5 for my $i ( 0 .. $#$tuple ) {
1237 2         5 my $prop = $tuple->[$i];
1238 2 50 33     11 if ( !defined $prop || ref($prop) ) {
1239 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
1240             "tuple[$i] must be a string",
1241             "$path/tuple[$i]" );
1242 0         0 next;
1243             }
1244              
1245 2 50       10 if ( !exists $properties->{$prop} ) {
1246 0         0 $self->_add_error(
1247             SCHEMA_REQUIRED_PROPERTY_NOT_DEFINED,
1248             "Tuple property '$prop' is not defined in properties",
1249             "$path/tuple[$i]"
1250             );
1251             }
1252             }
1253             }
1254             }
1255              
1256             sub _validate_enum {
1257 3     3   9 my ( $self, $enum, $path ) = @_;
1258              
1259 3 50       10 if ( ref($enum) ne 'ARRAY' ) {
1260 0         0 $self->_add_error( SCHEMA_ENUM_NOT_ARRAY, 'enum must be an array',
1261             $path );
1262 0         0 return;
1263             }
1264              
1265 3 100       12 if ( @$enum == 0 ) {
1266 1         5 $self->_add_error( SCHEMA_ENUM_EMPTY, 'enum array cannot be empty',
1267             $path );
1268 1         3 return;
1269             }
1270              
1271             # Check for duplicates
1272 2         4 my %seen;
1273 2         9 for my $i ( 0 .. $#$enum ) {
1274 6         13 my $value = $enum->[$i];
1275 6         14 my $key = _value_to_key($value);
1276              
1277 6 100       17 if ( exists $seen{$key} ) {
1278 1         6 $self->_add_error( SCHEMA_ENUM_DUPLICATES,
1279             'enum array contains duplicate values', $path );
1280 1         4 last;
1281             }
1282 5         15 $seen{$key} = 1;
1283             }
1284             }
1285              
1286             sub _value_to_key {
1287 6     6   11 my ($value) = @_;
1288              
1289             # Create a string key for comparison
1290 6 50       20 if ( !defined $value ) {
    50          
    0          
    0          
1291 0         0 return 'null';
1292             }
1293             elsif ( !ref($value) ) {
1294 6         16 return "s:$value";
1295             }
1296             elsif ( ref($value) eq 'ARRAY' ) {
1297 0         0 return 'a:' . join( ',', map { _value_to_key($_) } @$value );
  0         0  
1298             }
1299             elsif ( ref($value) eq 'HASH' ) {
1300             return 'o:'
1301             . join( ',',
1302 0         0 map { "$_:" . _value_to_key( $value->{$_} ) } sort keys %$value );
  0         0  
1303             }
1304             else {
1305 0         0 return "?:$value";
1306             }
1307             }
1308              
1309             sub _validate_additional_properties {
1310 0     0   0 my ( $self, $additional, $path ) = @_;
1311              
1312 0 0       0 if ( ref($additional) eq 'HASH' ) {
    0          
1313 0         0 $self->_validate_schema( $additional, 0, $path, undef );
1314             }
1315             elsif ( !ref($additional) ) {
1316              
1317             # Boolean is valid
1318 0 0 0     0 if ( $additional !~ /^[01]$/
      0        
      0        
1319             && !_is_json_bool($additional)
1320             && $additional ne 'true'
1321             && $additional ne 'false' )
1322             {
1323             # Plain strings that aren't booleans aren't valid
1324 0         0 $self->_add_error( SCHEMA_ADDITIONAL_PROPERTIES_INVALID,
1325             'additionalProperties must be a boolean or schema', $path );
1326             }
1327             }
1328             else {
1329 0         0 $self->_add_error( SCHEMA_ADDITIONAL_PROPERTIES_INVALID,
1330             'additionalProperties must be a boolean or schema', $path );
1331             }
1332             }
1333              
1334             sub _validate_extends {
1335 0     0   0 my ( $self, $extends, $path ) = @_;
1336              
1337 0 0 0     0 if ( !defined $extends || ref($extends) ) {
1338 0         0 $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
1339             '$extends must be a string',
1340             "$path/\$extends" );
1341 0         0 return;
1342             }
1343              
1344             # Check for circular extends
1345 0 0       0 if ( exists $self->{seen_extends}{$extends} ) {
1346 0         0 $self->_add_error(
1347             SCHEMA_EXTENDS_CIRCULAR,
1348             "Circular \$extends reference detected: $extends",
1349             "$path/\$extends"
1350             );
1351 0         0 return;
1352             }
1353              
1354 0         0 $self->{seen_extends}{$extends} = 1;
1355              
1356             # Check if reference resolves
1357 0         0 my $target = $self->_resolve_json_pointer( $extends, $self->{doc} );
1358 0 0       0 if ( !defined $target ) {
1359 0         0 $self->_add_error(
1360             SCHEMA_EXTENDS_NOT_FOUND,
1361             "\$extends reference not found: $extends",
1362             "$path/\$extends"
1363             );
1364             }
1365             }
1366              
1367             sub _check_constraint_type_mismatch {
1368 3     3   8 my ( $self, $schema, $type, $path ) = @_;
1369              
1370             # Numeric constraints can only be on numeric types
1371 3         18 my @numeric_types =
1372             qw(int8 int16 int32 int64 uint8 uint16 uint32 uint64 float16 float32 float64 decimal integer double number float);
1373 3         8 my $is_numeric = grep { $_ eq $type } @numeric_types;
  48         93  
1374              
1375 3         7 for my $keyword (
1376             qw(minimum maximum exclusiveMinimum exclusiveMaximum multipleOf))
1377             {
1378 15 50 66     51 if ( exists $schema->{$keyword} && !$is_numeric ) {
1379 0         0 $self->_add_error(
1380             SCHEMA_CONSTRAINT_TYPE_MISMATCH,
1381             "Constraint '$keyword' is only valid for numeric types, not '$type'",
1382             "$path/$keyword"
1383             );
1384             }
1385             }
1386              
1387             # String constraints can only be on string types
1388 3         12 my @string_types =
1389             qw(string date time datetime duration uri base64 binary uuid jsonpointer name);
1390 3         9 my $is_string = grep { $_ eq $type } @string_types;
  33         62  
1391              
1392 3         7 for my $keyword (
1393             qw(minLength maxLength pattern format contentEncoding contentMediaType))
1394             {
1395 18 50 66     63 if ( exists $schema->{$keyword} && !$is_string ) {
1396 0         0 $self->_add_error(
1397             SCHEMA_CONSTRAINT_TYPE_MISMATCH,
1398             "Constraint '$keyword' is only valid for string types, not '$type'",
1399             "$path/$keyword"
1400             );
1401             }
1402             }
1403             }
1404              
1405             sub _validate_extended_keywords {
1406 3     3   9 my ( $self, $schema, $type, $path ) = @_;
1407              
1408 3   50     9 $type //= '';
1409              
1410             # Check constraint-type mismatches
1411 3         13 $self->_check_constraint_type_mismatch( $schema, $type, $path );
1412              
1413             # Check numeric constraints
1414 3         11 for my $keyword ( keys %NUMERIC_VALIDATION_KEYWORDS ) {
1415 15 100       37 if ( exists $schema->{$keyword} ) {
1416 2         5 my $value = $schema->{$keyword};
1417 2 50 33     23 if ( !defined $value
      33        
1418             || ref($value)
1419             || $value !~ /^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$/ )
1420             {
1421 0         0 $self->_add_error(
1422             SCHEMA_NUMBER_CONSTRAINT_INVALID,
1423             "$keyword must be a number",
1424             "$path/$keyword"
1425             );
1426             }
1427             }
1428             }
1429              
1430             # Check min/max relationships
1431 3 50 66     14 if ( exists $schema->{minimum} && exists $schema->{maximum} ) {
1432 1 50       4 if ( $schema->{minimum} > $schema->{maximum} ) {
1433 1         5 $self->_add_error( SCHEMA_MIN_GREATER_THAN_MAX,
1434             "'minimum' cannot be greater than 'maximum'", $path );
1435             }
1436             }
1437              
1438 3 0 33     10 if ( exists $schema->{minLength} && exists $schema->{maxLength} ) {
1439 0 0       0 if ( $schema->{minLength} > $schema->{maxLength} ) {
1440 0         0 $self->_add_error( SCHEMA_MIN_GREATER_THAN_MAX,
1441             "'minLength' cannot be greater than 'maxLength'", $path );
1442             }
1443             }
1444              
1445 3 0 33     9 if ( exists $schema->{minItems} && exists $schema->{maxItems} ) {
1446 0 0       0 if ( $schema->{minItems} > $schema->{maxItems} ) {
1447 0         0 $self->_add_error( SCHEMA_MIN_GREATER_THAN_MAX,
1448             "'minItems' cannot be greater than 'maxItems'", $path );
1449             }
1450             }
1451              
1452             # Check for negative values where not allowed
1453 3 50       10 if ( exists $schema->{minItems} ) {
1454 0         0 my $value = $schema->{minItems};
1455 0 0 0     0 if ( defined $value
      0        
      0        
1456             && !ref($value)
1457             && $value =~ /^-?\d+$/
1458             && $value < 0 )
1459             {
1460 0         0 $self->_add_error( SCHEMA_MIN_ITEMS_NEGATIVE,
1461             'minItems cannot be negative',
1462             "$path/minItems" );
1463             }
1464             }
1465              
1466 3 50       9 if ( exists $schema->{minLength} ) {
1467 0         0 my $value = $schema->{minLength};
1468 0 0 0     0 if ( defined $value
      0        
      0        
1469             && !ref($value)
1470             && $value =~ /^-?\d+$/
1471             && $value < 0 )
1472             {
1473 0         0 $self->_add_error( SCHEMA_MIN_LENGTH_NEGATIVE,
1474             'minLength cannot be negative',
1475             "$path/minLength" );
1476             }
1477             }
1478              
1479             # Check multipleOf must be positive
1480 3 50       8 if ( exists $schema->{multipleOf} ) {
1481 0         0 my $value = $schema->{multipleOf};
1482 0 0 0     0 if ( defined $value
      0        
1483             && !ref($value)
1484             && $value =~ /^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$/ )
1485             {
1486 0 0       0 if ( $value <= 0 ) {
1487 0         0 $self->_add_error(
1488             SCHEMA_MULTIPLE_OF_NOT_POSITIVE,
1489             'multipleOf must be greater than 0',
1490             "$path/multipleOf"
1491             );
1492             }
1493             }
1494             }
1495              
1496             # Check pattern
1497 3 100       39 if ( exists $schema->{pattern} ) {
1498 2         6 my $pattern = $schema->{pattern};
1499 2 50 33     11 if ( !defined $pattern || ref($pattern) ) {
1500 0         0 $self->_add_error( SCHEMA_PATTERN_NOT_STRING,
1501             'pattern must be a string',
1502             "$path/pattern" );
1503             }
1504             else {
1505 2         5 my $pattern_ok = eval { qr/$pattern/; 1 };
  2         92  
  1         4  
1506 2 100       11 if ( !$pattern_ok ) {
1507 1         7 $self->_add_error( SCHEMA_PATTERN_INVALID,
1508             "pattern is not a valid regular expression: '$pattern'",
1509             "$path/pattern" );
1510             }
1511             }
1512             }
1513              
1514             # Check uniqueItems
1515 3 50       12 if ( exists $schema->{uniqueItems} ) {
1516 0           my $value = $schema->{uniqueItems};
1517 0 0         if ( !_is_boolean($value) ) {
1518 0           $self->_add_error(
1519             SCHEMA_UNIQUE_ITEMS_NOT_BOOLEAN,
1520             'uniqueItems must be a boolean',
1521             "$path/uniqueItems"
1522             );
1523             }
1524             }
1525             }
1526              
1527             # List of known JSON boolean classes from various JSON implementations
1528             my @JSON_BOOL_CLASSES = qw(
1529             JSON::PP::Boolean
1530             JSON::XS::Boolean
1531             Cpanel::JSON::XS::Boolean
1532             JSON::Tiny::_Bool
1533             Mojo::JSON::_Bool
1534             Types::Serialiser::Boolean
1535             );
1536              
1537             # Helper to check if a value is a JSON boolean from any JSON parser
1538             sub _is_json_bool {
1539 0     0     my ($value) = @_;
1540 0 0 0       return 0 unless defined $value && blessed($value);
1541 0           for my $class (@JSON_BOOL_CLASSES) {
1542 0 0         return 1 if $value->isa($class);
1543             }
1544 0 0         return 1 if JSON::MaybeXS::is_bool($value);
1545 0           return 0;
1546             }
1547              
1548             sub _is_boolean {
1549 0     0     my ($value) = @_;
1550              
1551             # JSON booleans are blessed references
1552 0 0         return 1 if _is_json_bool($value);
1553 0 0         return 0 if ref($value);
1554 0 0         return 1 if $value =~ /^[01]$/;
1555 0 0 0       return 1 if $value eq 'true' || $value eq 'false';
1556 0           return 0;
1557             }
1558              
1559             sub _validate_altnames {
1560 0     0     my ( $self, $altnames, $path ) = @_;
1561              
1562 0 0         if ( ref($altnames) ne 'HASH' ) {
1563 0           $self->_add_error( SCHEMA_ALTNAMES_NOT_OBJECT,
1564             'altnames must be an object', $path );
1565 0           return;
1566             }
1567              
1568 0           for my $key ( keys %$altnames ) {
1569 0           my $value = $altnames->{$key};
1570 0 0 0       if ( !defined $value || ref($value) ) {
1571 0           $self->_add_error(
1572             SCHEMA_ALTNAMES_VALUE_NOT_STRING,
1573             'altnames values must be strings',
1574             "$path/$key"
1575             );
1576             }
1577             }
1578             }
1579              
1580             sub _process_imports {
1581 0     0     my ( $self, $obj, $path ) = @_;
1582              
1583             # Process $import and $importdefs recursively
1584 0 0         return unless ref($obj) eq 'HASH';
1585              
1586 0           for my $key ( keys %$obj ) {
1587 0 0 0       if ( $key eq '$import' || $key eq '$importdefs' ) {
    0          
    0          
1588              
1589             # Handle import (implementation would fetch external schemas)
1590             # For now, just validate the URI is a string
1591 0           my $uri = $obj->{$key};
1592 0 0 0       if ( !defined $uri || ref($uri) ) {
1593 0           $self->_add_error( SCHEMA_KEYWORD_INVALID_TYPE,
1594             "$key must be a string", "$path/$key" );
1595             }
1596             }
1597             elsif ( ref( $obj->{$key} ) eq 'HASH' ) {
1598 0           $self->_process_imports( $obj->{$key}, "$path/$key" );
1599             }
1600             elsif ( ref( $obj->{$key} ) eq 'ARRAY' ) {
1601 0           for my $i ( 0 .. $#{ $obj->{$key} } ) {
  0            
1602 0 0         if ( ref( $obj->{$key}[$i] ) eq 'HASH' ) {
1603 0           $self->_process_imports( $obj->{$key}[$i],
1604             "$path/$key\[$i]" );
1605             }
1606             }
1607             }
1608             }
1609             }
1610              
1611             1;
1612              
1613             __END__