File Coverage

lib/JSON/Structure/InstanceValidator.pm
Criterion Covered Total %
statement 470 620 75.8
branch 305 472 64.6
condition 116 244 47.5
subroutine 46 49 93.8
pod 1 2 50.0
total 938 1387 67.6


line stmt bran cond sub pod time code
1             package JSON::Structure::InstanceValidator;
2              
3 17     17   358877 use strict;
  17         41  
  17         736  
4 17     17   128 use warnings;
  17         32  
  17         1128  
5 17     17   232 use v5.20;
  17         77  
6              
7             our $VERSION = '0.6.0';
8              
9 17     17   111 use JSON::MaybeXS;
  17         68  
  17         1369  
10 17     17   171 use B;
  17         32  
  17         517  
11 17     17   10086 use MIME::Base64 ();
  17         15880  
  17         784  
12 17     17   138 use Scalar::Util qw(looks_like_number blessed);
  17         37  
  17         1355  
13 17     17   9554 use Time::Local;
  17         45212  
  17         1354  
14              
15 17     17   1383 use JSON::Structure::Types;
  17         37  
  17         883  
16 17     17   1268 use JSON::Structure::ErrorCodes qw(:all);
  17         46  
  17         11429  
17 17     17   1496 use JSON::Structure::JsonSourceLocator;
  17         38  
  17         187238  
18              
19             # List of known JSON boolean classes from various JSON implementations
20             my @JSON_BOOL_CLASSES = qw(
21             JSON::PP::Boolean
22             JSON::XS::Boolean
23             Cpanel::JSON::XS::Boolean
24             JSON::Tiny::_Bool
25             Mojo::JSON::_Bool
26             Types::Serialiser::Boolean
27             );
28              
29             # Helper to check if a value is a JSON boolean from any JSON parser.
30             # Uses blessed() and isa() to support multiple JSON implementations:
31             # JSON::PP, JSON::XS, Cpanel::JSON::XS, etc.
32             sub _is_json_bool {
33 1085     1085   2039 my ($value) = @_;
34 1085 100 66     5162 return 0 unless defined $value && blessed($value);
35 29         67 for my $class (@JSON_BOOL_CLASSES) {
36 29 50       335 return 1 if $value->isa($class);
37             }
38              
39             # Also check for is_bool if available (JSON::MaybeXS compatibility)
40 0 0       0 return 1 if JSON::MaybeXS::is_bool($value);
41 0         0 return 0;
42             }
43              
44             # Helper to check if a scalar was a number in the original JSON.
45             # Uses B module to inspect internal flags set by JSON parsers during parsing.
46             # Note: This approach relies on Perl's internal SV flags which are set when
47             # JSON parsers parse numeric literals. The flags IOK (integer OK) and NOK
48             # (numeric OK) indicate the value originated as a JSON number.
49             # Limitation: May behave differently with dualvars or tied scalars.
50             sub _is_numeric {
51 247     247   512 my ($value) = @_;
52 247 50 33     835 return 0 unless defined $value && !ref($value);
53              
54             # Exclude booleans first
55 247 50       464 return 0 if _is_json_bool($value);
56 247         1078 my $b_obj = B::svref_2object( \$value );
57 247         751 my $flags = $b_obj->FLAGS;
58              
59             # Check if the value has numeric flags set (IOK/NOK)
60 247 100       1265 return ( $flags & ( B::SVf_IOK | B::SVf_NOK ) ) ? 1 : 0;
61             }
62              
63             # Helper to check if a value is a pure string (no numeric flags).
64             # A "pure string" is a non-reference scalar that:
65             # 1. Is not a JSON boolean
66             # 2. Has POK (string OK) flag set
67             # 3. Does NOT have IOK/NOK (numeric) flags set
68             # This distinguishes JSON string values like "123" from numeric 123.
69             # Note: Numeric-looking strings (e.g., "42") are treated as strings per
70             # JSON Structure semantics - the JSON encoding determines the type.
71             sub _is_pure_string {
72 97     97   331 my ($value) = @_;
73 97 50 33     347 return 0 unless defined $value && !ref($value);
74              
75             # If it's a boolean, it's not a pure string
76 97 50       190 return 0 if _is_json_bool($value);
77 97         489 my $b_obj = B::svref_2object( \$value );
78 97         368 my $flags = $b_obj->FLAGS;
79              
80             # Check if POK (string) is set but not IOK/NOK (numeric)
81 97   66     691 return ( $flags & B::SVf_POK ) && !( $flags & ( B::SVf_IOK | B::SVf_NOK ) );
82             }
83              
84             =head1 NAME
85              
86             JSON::Structure::InstanceValidator - Validate JSON instances against JSON Structure schemas
87              
88             =head1 SYNOPSIS
89              
90             use JSON::Structure::InstanceValidator;
91             use JSON::PP;
92            
93             my $schema = decode_json($schema_json);
94             my $validator = JSON::Structure::InstanceValidator->new(schema => $schema);
95            
96             my $instance = decode_json($instance_json);
97             my $result = $validator->validate($instance, $instance_json);
98            
99             if ($result->is_valid) {
100             say "Instance is valid!";
101             } else {
102             for my $error (@{$result->errors}) {
103             say $error->to_string;
104             }
105             }
106              
107             =head1 DESCRIPTION
108              
109             Validates JSON data instances against JSON Structure schemas.
110              
111             =cut
112              
113             # Integer type ranges
114             my %INT_RANGES = (
115             int8 => { min => -128, max => 127 },
116             uint8 => { min => 0, max => 255 },
117             int16 => { min => -32768, max => 32767 },
118             uint16 => { min => 0, max => 65535 },
119             int32 => { min => -2147483648, max => 2147483647 },
120             uint32 => { min => 0, max => 4294967295 },
121             int64 => { min => -9223372036854775808, max => 9223372036854775807 },
122             uint64 => { min => 0, max => 18446744073709551615 },
123             integer => { min => -2147483648, max => 2147483647 }, # Alias for int32
124             );
125              
126             # Regex patterns for format validation
127             my $DATE_REGEX = qr/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
128             my $TIME_REGEX =
129             qr/^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?$/i;
130             my $DATETIME_REGEX =
131             qr/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?$/i;
132             my $DURATION_REGEX =
133             qr/^P(?:(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/;
134             my $UUID_REGEX =
135             qr/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
136             my $URI_REGEX =
137             qr/^[a-zA-Z][a-zA-Z0-9+\-.]*:/; # Any valid scheme (not just :// based)
138             my $JSONPOINTER_REGEX = qr/^(?:\/(?:[^~\/]|~[01])*)*$/;
139             my $EMAIL_REGEX = qr/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
140             my $IPV4_REGEX =
141             qr/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
142             my $HOSTNAME_REGEX =
143             qr/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
144              
145             sub new {
146 115     115 0 910647 my ( $class, %args ) = @_;
147              
148             my $self = bless {
149             schema => $args{schema},
150             schema_text => $args{schema_text},
151             extended => $args{extended} // 0,
152             allow_import => $args{allow_import} // 0,
153 115   100     1983 max_validation_depth => $args{max_validation_depth} // 64,
      50        
      50        
154             errors => [],
155             warnings => [],
156             source_locator => undef,
157             current_depth => 0,
158             }, $class;
159              
160 115         444 return $self;
161             }
162              
163             =head2 validate($instance, $source_text)
164              
165             Validates a JSON instance against the schema.
166              
167             Returns a ValidationResult object with errors and warnings.
168              
169             =cut
170              
171             sub validate {
172 481     481 1 2023 my ( $self, $instance, $source_text ) = @_;
173              
174             # Reset state
175 481         1570 $self->{errors} = [];
176 481         2433 $self->{warnings} = [];
177 481         1027 $self->{current_depth} = 0;
178              
179             # Initialize source locator
180 481 100       1236 if ( defined $source_text ) {
181             $self->{source_locator} =
182 1         18 JSON::Structure::JsonSourceLocator->new($source_text);
183             }
184             else {
185 480         986 $self->{source_locator} = undef;
186             }
187              
188 481         882 my $schema = $self->{schema};
189              
190             # Handle null schema
191 481 50       1426 if ( !defined $schema ) {
192 0         0 $self->_add_error( INSTANCE_SCHEMA_FALSE, 'Schema is null', '#' );
193 0         0 return $self->_make_result();
194             }
195              
196             # Find the root schema to validate against
197 481         795 my $root_schema = $schema;
198              
199             # Check for $root reference
200 481 100 100     2568 if ( ref($schema) eq 'HASH' && exists $schema->{'$root'} ) {
201 3         12 my $resolved = $self->_resolve_ref( $schema->{'$root'}, $schema );
202 3 50       11 if ( !defined $resolved ) {
203 0         0 $self->_add_error( INSTANCE_ROOT_UNRESOLVED,
204             "Unable to resolve \$root reference: $schema->{'$root'}", '#' );
205 0         0 return $self->_make_result();
206             }
207 3         8 $root_schema = $resolved;
208             }
209              
210             # Validate the instance
211 481         1770 $self->_validate_value( $instance, $root_schema, '#', '#' );
212              
213 481         1277 return $self->_make_result();
214             }
215              
216             sub _make_result {
217 481     481   842 my ($self) = @_;
218              
219             return JSON::Structure::Types::ValidationResult->new(
220 481         2578 is_valid => scalar( @{ $self->{errors} } ) == 0,
221             errors => $self->{errors},
222             warnings => $self->{warnings},
223 481         883 );
224             }
225              
226             sub _add_error {
227 226     226   663 my ( $self, $code, $message, $path, $schema_path ) = @_;
228              
229             my $location =
230             $self->{source_locator}
231 226 100       1299 ? $self->{source_locator}->get_location($path)
232             : JSON::Structure::Types::JsonLocation->unknown();
233              
234 226         411 push @{ $self->{errors} },
  226         1067  
235             JSON::Structure::Types::ValidationError->new(
236             code => $code,
237             message => $message,
238             path => $path,
239             severity => JSON::Structure::Types::ValidationSeverity::ERROR,
240             location => $location,
241             schema_path => $schema_path,
242             );
243             }
244              
245             sub _validate_value {
246 688     688   1713 my ( $self, $value, $schema, $path, $schema_path ) = @_;
247              
248             # Check depth
249 688         1180 $self->{current_depth}++;
250 688 50       1710 if ( $self->{current_depth} > $self->{max_validation_depth} ) {
251 0         0 $self->_add_error(
252             INSTANCE_MAX_DEPTH_EXCEEDED,
253             "Maximum validation depth ($self->{max_validation_depth}) exceeded",
254             $path,
255             $schema_path
256             );
257 0         0 $self->{current_depth}--;
258 0         0 return;
259             }
260              
261             # Handle boolean schemas (both raw scalars and JSON boolean objects)
262 688 100 66     2220 if ( !ref($schema) || _is_json_bool($schema) ) {
263 10 100       31 if ( _is_false($schema) ) {
264 5         57 $self->_add_error( INSTANCE_SCHEMA_FALSE,
265             "Schema 'false' rejects all values",
266             $path, $schema_path );
267             }
268              
269             # true schema accepts everything
270 10         25 $self->{current_depth}--;
271 10         27 return;
272             }
273              
274 678 50       1688 if ( ref($schema) ne 'HASH' ) {
275 0         0 $self->{current_depth}--;
276 0         0 return;
277             }
278              
279             # Handle type with $ref
280 678 50 100     2975 if ( exists $schema->{type}
      66        
281             && ref( $schema->{type} ) eq 'HASH'
282             && exists $schema->{type}{'$ref'} )
283             {
284             my $resolved =
285 5         24 $self->_resolve_ref( $schema->{type}{'$ref'}, $self->{schema} );
286 5 50       15 if ( !defined $resolved ) {
287 0         0 $self->_add_error( INSTANCE_REF_UNRESOLVED,
288             "Unable to resolve reference: $schema->{type}{'$ref'}",
289             $path, $schema_path );
290 0         0 $self->{current_depth}--;
291 0         0 return;
292             }
293              
294             # Merge the resolved schema with any additional constraints
295 5         21 my $merged = {%$resolved};
296 5         18 for my $key ( keys %$schema ) {
297 20 100       46 next if $key eq 'type';
298 15         34 $merged->{$key} = $schema->{$key};
299             }
300 5         24 $self->_validate_value( $value, $merged, $path, $schema_path );
301 5         9 $self->{current_depth}--;
302 5         25 return;
303             }
304              
305             # Validate const
306 673 100       1552 if ( exists $schema->{const} ) {
307 7 100       35 if ( !$self->_values_equal( $value, $schema->{const} ) ) {
308 3         14 $self->_add_error( INSTANCE_CONST_MISMATCH,
309             'Value must equal const value',
310             $path, $schema_path );
311             }
312 7         17 $self->{current_depth}--;
313 7         19 return;
314             }
315              
316             # Validate enum
317 666 100       1519 if ( exists $schema->{enum} ) {
318 11         18 my $found = 0;
319 11         17 for my $enum_val ( @{ $schema->{enum} } ) {
  11         31  
320 24 100       70 if ( $self->_values_equal( $value, $enum_val ) ) {
321 7         33 $found = 1;
322 7         13 last;
323             }
324             }
325 11 100       29 if ( !$found ) {
326 4         17 $self->_add_error( INSTANCE_ENUM_MISMATCH,
327             'Value must be one of the enum values',
328             $path, $schema_path );
329             }
330             }
331              
332             # Validate type
333 666         1208 my $type = $schema->{type};
334              
335 666 100       1400 if ( defined $type ) {
336 643 50       1653 if ( ref($type) eq 'ARRAY' ) {
    50          
337              
338             # Union type - value must match at least one
339 0         0 my $matched = 0;
340 0         0 for my $t (@$type) {
341 0 0       0 if ( $self->_check_type( $value, $t ) ) {
342 0         0 $matched = 1;
343 0         0 last;
344             }
345             }
346 0 0       0 if ( !$matched ) {
347 0         0 $self->_add_error( INSTANCE_TYPE_MISMATCH,
348             "Value must be one of: " . join( ', ', @$type ),
349             $path, $schema_path );
350             }
351             }
352             elsif ( !ref($type) ) {
353 643         1567 $self->_validate_type( $value, $type, $schema, $path,
354             $schema_path );
355             }
356             }
357              
358             # Validate composition keywords in extended mode
359 666 100       1471 if ( $self->{extended} ) {
360 392         1090 $self->_validate_composition( $value, $schema, $path, $schema_path );
361             }
362              
363 666         1631 $self->{current_depth}--;
364             }
365              
366             sub _validate_type {
367 643     643   1642 my ( $self, $value, $type, $schema, $path, $schema_path ) = @_;
368              
369             # Validate based on type
370 643 100 100     7581 if ( $type eq 'null' ) {
    100 100        
    100 66        
    100 100        
    100 66        
    50 33        
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    50          
371 7         46 $self->_validate_null( $value, $path, $schema_path );
372             }
373             elsif ( $type eq 'boolean' ) {
374 12         34 $self->_validate_boolean( $value, $path, $schema_path );
375             }
376             elsif ( $type eq 'string' ) {
377 104         331 $self->_validate_string( $value, $schema, $path, $schema_path );
378             }
379             elsif ($type eq 'number'
380             || $type eq 'float'
381             || $type eq 'double'
382             || $type eq 'float8'
383             || $type eq 'decimal' )
384             {
385 91         287 $self->_validate_number( $value, $type, $schema, $path, $schema_path );
386             }
387             elsif ( $type eq 'integer' || exists $INT_RANGES{$type} ) {
388 178         585 $self->_validate_integer( $value, $type, $schema, $path, $schema_path );
389             }
390             elsif ( $type eq 'int128' || $type eq 'uint128' ) {
391 0         0 $self->_validate_big_integer( $value, $type, $schema, $path,
392             $schema_path );
393             }
394             elsif ( $type eq 'object' ) {
395 66         296 $self->_validate_object( $value, $schema, $path, $schema_path );
396             }
397             elsif ( $type eq 'array' ) {
398 17         66 $self->_validate_array( $value, $schema, $path, $schema_path );
399             }
400             elsif ( $type eq 'set' ) {
401 11         40 $self->_validate_set( $value, $schema, $path, $schema_path );
402             }
403             elsif ( $type eq 'map' ) {
404 15         55 $self->_validate_map( $value, $schema, $path, $schema_path );
405             }
406             elsif ( $type eq 'tuple' ) {
407 7         33 $self->_validate_tuple( $value, $schema, $path, $schema_path );
408             }
409             elsif ( $type eq 'choice' ) {
410 11         40 $self->_validate_choice( $value, $schema, $path, $schema_path );
411             }
412             elsif ( $type eq 'any' ) {
413              
414             # Any type accepts all values
415             }
416             elsif ( $type eq 'date' ) {
417 15         57 $self->_validate_date( $value, $path, $schema_path );
418             }
419             elsif ( $type eq 'time' ) {
420 18         62 $self->_validate_time( $value, $path, $schema_path );
421             }
422             elsif ( $type eq 'datetime' ) {
423 12         44 $self->_validate_datetime( $value, $path, $schema_path );
424             }
425             elsif ( $type eq 'duration' ) {
426 18         65 $self->_validate_duration( $value, $path, $schema_path );
427             }
428             elsif ( $type eq 'uuid' ) {
429 15         58 $self->_validate_uuid( $value, $path, $schema_path );
430             }
431             elsif ( $type eq 'uri' ) {
432 15         54 $self->_validate_uri( $value, $path, $schema_path );
433             }
434             elsif ( $type eq 'binary' ) {
435 11         40 $self->_validate_binary( $value, $path, $schema_path );
436             }
437             elsif ( $type eq 'jsonpointer' ) {
438 14         75 $self->_validate_jsonpointer( $value, $path, $schema_path );
439             }
440             else {
441 0         0 $self->_add_error( INSTANCE_TYPE_UNKNOWN, "Unknown type: $type",
442             $path, $schema_path );
443             }
444             }
445              
446             sub _check_type {
447 0     0   0 my ( $self, $value, $type ) = @_;
448              
449 0 0 0     0 if ( $type eq 'null' ) {
    0 0        
    0 0        
    0 0        
    0 0        
    0 0        
    0 0        
    0 0        
      0        
450 0   0     0 return !defined $value || ( ref($value) eq '' && $value eq 'null' );
451             }
452             elsif ( $type eq 'boolean' ) {
453 0   0     0 return _is_json_bool($value)
454             || ( defined $value
455             && !ref($value)
456             && ( $value eq 'true' || $value eq 'false' || $value =~ /^[01]$/ )
457             );
458             }
459             elsif ( $type eq 'string' ) {
460 0   0     0 return defined $value && !ref($value);
461             }
462             elsif ($type eq 'number'
463             || $type eq 'float'
464             || $type eq 'double'
465             || $type eq 'float8'
466             || $type eq 'decimal' )
467             {
468 0   0     0 return defined $value && !ref($value) && looks_like_number($value);
469             }
470             elsif ( $type eq 'integer' || exists $INT_RANGES{$type} ) {
471             return
472 0   0     0 defined $value
473             && !ref($value)
474             && looks_like_number($value)
475             && $value == int($value);
476             }
477             elsif ( $type eq 'object' || $type eq 'map' || $type eq 'choice' ) {
478 0         0 return ref($value) eq 'HASH';
479             }
480             elsif ( $type eq 'array' || $type eq 'set' || $type eq 'tuple' ) {
481 0         0 return ref($value) eq 'ARRAY';
482             }
483             elsif ( $type eq 'any' ) {
484 0         0 return 1;
485             }
486             else {
487 0         0 return 0;
488             }
489             }
490              
491             sub _validate_null {
492 7     7   21 my ( $self, $value, $path, $schema_path ) = @_;
493              
494 7 50 66     48 unless ( !defined $value || ( ref($value) eq '' && $value eq 'null' ) ) {
      66        
495 5         19 $self->_add_error( INSTANCE_NULL_EXPECTED, 'Value must be null',
496             $path, $schema_path );
497             }
498             }
499              
500             sub _validate_boolean {
501 12     12   31 my ( $self, $value, $path, $schema_path ) = @_;
502              
503             # Accept JSON booleans from any JSON parser (JSON::PP, JSON::XS, etc.)
504 12 100       26 unless ( _is_json_bool($value) ) {
505 5         21 $self->_add_error( INSTANCE_BOOLEAN_EXPECTED, 'Value must be a boolean',
506             $path, $schema_path );
507             }
508             }
509              
510             sub _validate_string {
511 104     104   256 my ( $self, $value, $schema, $path, $schema_path ) = @_;
512              
513             # Must be defined, non-reference, and a pure string (not a number)
514 104 100 66     568 unless ( defined $value && !ref($value) && _is_pure_string($value) ) {
      100        
515 18         73 $self->_add_error( INSTANCE_STRING_EXPECTED, 'Value must be a string',
516             $path, $schema_path );
517 18         52 return;
518             }
519              
520 86         214 my $len = length($value);
521              
522             # Extended validation
523 86 100       259 if ( $self->{extended} ) {
524 63 100       155 if ( exists $schema->{minLength} ) {
525 20 100       52 if ( $len < $schema->{minLength} ) {
526 6         42 $self->_add_error(
527             INSTANCE_STRING_MIN_LENGTH,
528             "String length $len is less than minimum $schema->{minLength}",
529             $path,
530             $schema_path
531             );
532             }
533             }
534              
535 63 100       135 if ( exists $schema->{maxLength} ) {
536 17 100       39 if ( $len > $schema->{maxLength} ) {
537 4         29 $self->_add_error( INSTANCE_STRING_MAX_LENGTH,
538             "String length $len exceeds maximum $schema->{maxLength}",
539             $path, $schema_path );
540             }
541             }
542              
543 63 100       134 if ( exists $schema->{pattern} ) {
544 16         28 my $pattern = $schema->{pattern};
545 16         26 my $pattern_ok = eval {
546 16 100       302 if ( $value !~ qr/$pattern/ ) {
547 8         30 $self->_add_error(
548             INSTANCE_STRING_PATTERN_MISMATCH,
549             "String does not match pattern: $pattern",
550             $path, $schema_path
551             );
552             }
553 16         40 1;
554             };
555 16 50       37 if ( !$pattern_ok ) {
556 0         0 $self->_add_error( INSTANCE_PATTERN_INVALID,
557             "Invalid regex pattern: $pattern",
558             $path, $schema_path );
559             }
560             }
561              
562 63 50       238 if ( exists $schema->{format} ) {
563 0         0 $self->_validate_format( $value, $schema->{format}, $path,
564             $schema_path );
565             }
566             }
567             }
568              
569             sub _validate_format {
570 0     0   0 my ( $self, $value, $format, $path, $schema_path ) = @_;
571              
572 0 0       0 if ( $format eq 'email' ) {
    0          
    0          
    0          
    0          
    0          
    0          
    0          
573 0 0       0 unless ( $value =~ $EMAIL_REGEX ) {
574 0         0 $self->_add_error( INSTANCE_FORMAT_EMAIL_INVALID,
575             'String is not a valid email address',
576             $path, $schema_path );
577             }
578             }
579             elsif ( $format eq 'uri' ) {
580 0 0       0 unless ( $value =~ $URI_REGEX ) {
581 0         0 $self->_add_error( INSTANCE_FORMAT_URI_INVALID,
582             'String is not a valid URI',
583             $path, $schema_path );
584             }
585             }
586             elsif ( $format eq 'date' ) {
587 0 0       0 unless ( $value =~ $DATE_REGEX ) {
588 0         0 $self->_add_error( INSTANCE_FORMAT_DATE_INVALID,
589             'String is not a valid date',
590             $path, $schema_path );
591             }
592             }
593             elsif ( $format eq 'time' ) {
594 0 0       0 unless ( $value =~ $TIME_REGEX ) {
595 0         0 $self->_add_error( INSTANCE_FORMAT_TIME_INVALID,
596             'String is not a valid time',
597             $path, $schema_path );
598             }
599             }
600             elsif ( $format eq 'date-time' ) {
601 0 0       0 unless ( $value =~ $DATETIME_REGEX ) {
602 0         0 $self->_add_error(
603             INSTANCE_FORMAT_DATETIME_INVALID,
604             'String is not a valid date-time',
605             $path, $schema_path
606             );
607             }
608             }
609             elsif ( $format eq 'uuid' ) {
610 0 0       0 unless ( $value =~ $UUID_REGEX ) {
611 0         0 $self->_add_error( INSTANCE_FORMAT_UUID_INVALID,
612             'String is not a valid UUID',
613             $path, $schema_path );
614             }
615             }
616             elsif ( $format eq 'ipv4' ) {
617 0 0       0 unless ( $value =~ $IPV4_REGEX ) {
618 0         0 $self->_add_error( INSTANCE_FORMAT_IPV4_INVALID,
619             'String is not a valid IPv4 address',
620             $path, $schema_path );
621             }
622             }
623             elsif ( $format eq 'hostname' ) {
624 0 0       0 unless ( $value =~ $HOSTNAME_REGEX ) {
625 0         0 $self->_add_error(
626             INSTANCE_FORMAT_HOSTNAME_INVALID,
627             'String is not a valid hostname',
628             $path, $schema_path
629             );
630             }
631             }
632              
633             # Other formats are not strictly enforced
634             }
635              
636             sub _validate_number {
637 91     91   192 my ( $self, $value, $type, $schema, $path, $schema_path ) = @_;
638              
639             # Decimal type can accept string representations for high precision
640 91 50 66     249 if ( $type eq 'decimal' && defined $value && !ref($value) ) {
      66        
641              
642             # Accept numeric values or string representations of numbers
643 10 100 100     27 if ( _is_numeric($value)
644             || $value =~ /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/ )
645             {
646 9         39 $self->_validate_numeric_constraints( $value, $schema, $path,
647             $schema_path );
648 9         29 return;
649             }
650             }
651              
652             # Must be a numeric value, not a string that looks like a number
653 82 100 66     299 unless ( defined $value && !ref($value) && _is_numeric($value) ) {
      100        
654 11         75 $self->_add_error( INSTANCE_NUMBER_EXPECTED, 'Value must be a number',
655             $path, $schema_path );
656 11         27 return;
657             }
658              
659 71         200 $self->_validate_numeric_constraints( $value, $schema, $path,
660             $schema_path );
661             }
662              
663             sub _validate_integer {
664 178     178   417 my ( $self, $value, $type, $schema, $path, $schema_path ) = @_;
665              
666             # int64 and uint64 can accept string representations for large values
667 178 100 100     1018 if ( ( $type eq 'int64' || $type eq 'uint64' )
      66        
      66        
      66        
668             && defined $value
669             && !ref($value)
670             && $value =~ /^-?\d+$/ )
671             {
672             # String representation of large integer - valid
673 18         36 my $range = $INT_RANGES{$type};
674              
675             # Use Math::BigInt for proper range checking with large values
676 18         4324 require Math::BigInt;
677 18         81383 my $big_val = Math::BigInt->new($value);
678 18         50910 my $big_min = Math::BigInt->new( $range->{min} );
679 18         1118 my $big_max = Math::BigInt->new( $range->{max} );
680              
681 18 100 66     1032 if ( $big_val < $big_min || $big_val > $big_max ) {
682 3         79 $self->_add_error( INSTANCE_INT_RANGE_INVALID,
683             "Value $value is not a valid $type",
684             $path, $schema_path );
685             }
686              
687 18         1097 $self->_validate_numeric_constraints( $value, $schema, $path,
688             $schema_path );
689 18         94 return;
690             }
691              
692             # Must be a numeric value, not a string
693 160 100 66     700 unless ( defined $value && !ref($value) && _is_numeric($value) ) {
      100        
694 18         70 $self->_add_error( INSTANCE_INTEGER_EXPECTED,
695             'Value must be an integer',
696             $path, $schema_path );
697 18         47 return;
698             }
699              
700             # Check it's actually an integer
701 142 100       414 if ( $value != int($value) ) {
702 3         11 $self->_add_error( INSTANCE_INTEGER_EXPECTED,
703             'Value must be an integer',
704             $path, $schema_path );
705 3         7 return;
706             }
707              
708             # Check range for specific integer types
709 139 50       357 if ( exists $INT_RANGES{$type} ) {
710 139         252 my $range = $INT_RANGES{$type};
711 139 100 100     769 if ( $value < $range->{min} || $value > $range->{max} ) {
712 16         94 $self->_add_error( INSTANCE_INT_RANGE_INVALID,
713             "Value $value is not a valid $type",
714             $path, $schema_path );
715             }
716             }
717              
718 139         441 $self->_validate_numeric_constraints( $value, $schema, $path,
719             $schema_path );
720             }
721              
722             sub _validate_big_integer {
723 0     0   0 my ( $self, $value, $type, $schema, $path, $schema_path ) = @_;
724              
725             # Big integers can be numbers or strings
726 0         0 my $num_value;
727              
728 0 0       0 if ( !defined $value ) {
729 0         0 $self->_add_error( INSTANCE_INTEGER_EXPECTED,
730             "Value must be a valid $type",
731             $path, $schema_path );
732 0         0 return;
733             }
734              
735 0 0       0 if ( ref($value) ) {
736 0         0 $self->_add_error( INSTANCE_INTEGER_EXPECTED,
737             "Value must be a valid $type",
738             $path, $schema_path );
739 0         0 return;
740             }
741              
742             # Accept both number and string representation
743 0 0 0     0 if ( looks_like_number($value) || $value =~ /^-?\d+$/ ) {
744 0         0 $num_value = $value;
745             }
746             else {
747 0         0 $self->_add_error( INSTANCE_INTEGER_EXPECTED,
748             "Value must be a valid $type",
749             $path, $schema_path );
750 0         0 return;
751             }
752              
753             # Check for unsigned
754 0 0 0     0 if ( $type eq 'uint128' && $num_value < 0 ) {
755 0         0 $self->_add_error( INSTANCE_INT_RANGE_INVALID,
756             "Value $value is not a valid $type",
757             $path, $schema_path );
758             }
759             }
760              
761             sub _validate_numeric_constraints {
762 237     237   563 my ( $self, $value, $schema, $path, $schema_path ) = @_;
763              
764 237 100       601 return unless $self->{extended};
765              
766 170 100       374 if ( exists $schema->{minimum} ) {
767 23 100       53 if ( $value < $schema->{minimum} ) {
768 6         41 $self->_add_error( INSTANCE_NUMBER_MINIMUM,
769             "Value $value is less than minimum $schema->{minimum}",
770             $path, $schema_path );
771             }
772             }
773              
774 170 100       369 if ( exists $schema->{maximum} ) {
775 18 100       39 if ( $value > $schema->{maximum} ) {
776 4         26 $self->_add_error( INSTANCE_NUMBER_MAXIMUM,
777             "Value $value exceeds maximum $schema->{maximum}",
778             $path, $schema_path );
779             }
780             }
781              
782 170 100       360 if ( exists $schema->{exclusiveMinimum} ) {
783 13 100       25 if ( $value <= $schema->{exclusiveMinimum} ) {
784 4         17 $self->_add_error(
785             INSTANCE_NUMBER_EXCLUSIVE_MINIMUM,
786             "Value $value must be greater than $schema->{exclusiveMinimum}",
787             $path,
788             $schema_path
789             );
790             }
791             }
792              
793 170 100       345 if ( exists $schema->{exclusiveMaximum} ) {
794 13 100       26 if ( $value >= $schema->{exclusiveMaximum} ) {
795 4         14 $self->_add_error(
796             INSTANCE_NUMBER_EXCLUSIVE_MAXIMUM,
797             "Value $value must be less than $schema->{exclusiveMaximum}",
798             $path, $schema_path
799             );
800             }
801             }
802              
803 170 100       499 if ( exists $schema->{multipleOf} ) {
804 19         28 my $mult = $schema->{multipleOf};
805 19 100 66     107 if ( $mult != 0 && ( $value / $mult ) != int( $value / $mult ) ) {
806 6         37 $self->_add_error( INSTANCE_NUMBER_MULTIPLE_OF,
807             "Value $value is not a multiple of $mult",
808             $path, $schema_path );
809             }
810             }
811             }
812              
813             sub _validate_object {
814 66     66   171 my ( $self, $value, $schema, $path, $schema_path ) = @_;
815              
816 66 100       221 unless ( ref($value) eq 'HASH' ) {
817 3         17 $self->_add_error( INSTANCE_OBJECT_EXPECTED, 'Value must be an object',
818             $path, $schema_path );
819 3         11 return;
820             }
821              
822 63   100     188 my $properties = $schema->{properties} // {};
823 63   100     226 my $required = $schema->{required} // [];
824 63         158 my $additional = $schema->{additionalProperties};
825              
826             # Check required properties
827 63         165 for my $prop (@$required) {
828 24 100       78 unless ( exists $value->{$prop} ) {
829 7         46 $self->_add_error(
830             INSTANCE_REQUIRED_PROPERTY_MISSING,
831             "Missing required property: $prop",
832             $path, "$schema_path/required"
833             );
834             }
835             }
836              
837             # Validate properties
838 63         178 for my $prop_name ( keys %$value ) {
839 90         183 my $prop_path = "$path/$prop_name";
840              
841 90 100       240 if ( exists $properties->{$prop_name} ) {
    100          
842 63         110 my $prop_schema = $properties->{$prop_name};
843 63         378 $self->_validate_value( $value->{$prop_name}, $prop_schema,
844             $prop_path, "$schema_path/properties/$prop_name" );
845             }
846             elsif ( defined $additional ) {
847 4 100       16 if ( _is_false($additional) ) {
    50          
848 2         32 $self->_add_error(
849             INSTANCE_ADDITIONAL_PROPERTY_NOT_ALLOWED,
850             "Additional property not allowed: $prop_name",
851             $prop_path,
852             "$schema_path/additionalProperties"
853             );
854             }
855             elsif ( ref($additional) eq 'HASH' ) {
856 2         10 $self->_validate_value( $value->{$prop_name}, $additional,
857             $prop_path, "$schema_path/additionalProperties" );
858             }
859             }
860             }
861              
862             # Extended validation
863 63 100       189 if ( $self->{extended} ) {
864 42         80 my $count = scalar( keys %$value );
865              
866 42 100       114 if ( exists $schema->{minProperties} ) {
867 4 100       9 if ( $count < $schema->{minProperties} ) {
868 2         10 $self->_add_error(
869             INSTANCE_MIN_PROPERTIES,
870             "Object has $count properties, minimum is $schema->{minProperties}",
871             $path,
872             $schema_path
873             );
874             }
875             }
876              
877 42 100       99 if ( exists $schema->{maxProperties} ) {
878 4 100       11 if ( $count > $schema->{maxProperties} ) {
879 1         6 $self->_add_error(
880             INSTANCE_MAX_PROPERTIES,
881             "Object has $count properties, maximum is $schema->{maxProperties}",
882             $path,
883             $schema_path
884             );
885             }
886             }
887              
888 42 50       153 if ( exists $schema->{dependentRequired} ) {
889 0         0 for my $prop ( keys %{ $schema->{dependentRequired} } ) {
  0         0  
890 0 0       0 if ( exists $value->{$prop} ) {
891 0         0 my $deps = $schema->{dependentRequired}{$prop};
892 0         0 for my $dep (@$deps) {
893 0 0       0 unless ( exists $value->{$dep} ) {
894 0         0 $self->_add_error(
895             INSTANCE_DEPENDENT_REQUIRED,
896             "Property '$prop' requires property '$dep'",
897             $path,
898             $schema_path
899             );
900             }
901             }
902             }
903             }
904             }
905             }
906             }
907              
908             sub _validate_array {
909 17     17   42 my ( $self, $value, $schema, $path, $schema_path ) = @_;
910              
911 17 100       53 unless ( ref($value) eq 'ARRAY' ) {
912 1         7 $self->_add_error( INSTANCE_ARRAY_EXPECTED, 'Value must be an array',
913             $path, $schema_path );
914 1         4 return;
915             }
916              
917             # Validate items
918 16 50       44 if ( exists $schema->{items} ) {
919 16         48 my $items_schema = $schema->{items};
920 16         55 for my $i ( 0 .. $#$value ) {
921 33         155 $self->_validate_value(
922             $value->[$i], $items_schema,
923             "$path/$i", "$schema_path/items"
924             );
925             }
926             }
927              
928             # Extended validation
929 16 100       50 if ( $self->{extended} ) {
930 13         26 my $count = scalar(@$value);
931              
932 13 100       75 if ( exists $schema->{minItems} ) {
933 7 100       19 if ( $count < $schema->{minItems} ) {
934 3         13 $self->_add_error( INSTANCE_MIN_ITEMS,
935             "Array has $count items, minimum is $schema->{minItems}",
936             $path, $schema_path );
937             }
938             }
939              
940 13 100       34 if ( exists $schema->{maxItems} ) {
941 7 100       19 if ( $count > $schema->{maxItems} ) {
942 2         18 $self->_add_error( INSTANCE_MAX_ITEMS,
943             "Array has $count items, maximum is $schema->{maxItems}",
944             $path, $schema_path );
945             }
946             }
947              
948 13 50       48 if ( exists $schema->{contains} ) {
949 0         0 my $contains_count = 0;
950 0         0 for my $item (@$value) {
951              
952             # Create a temporary validator to check
953 0         0 my $temp_errors = $self->{errors};
954 0         0 $self->{errors} = [];
955             $self->_validate_value( $item, $schema->{contains},
956 0         0 "$path/contains", "$schema_path/contains" );
957 0 0       0 if ( @{ $self->{errors} } == 0 ) {
  0         0  
958 0         0 $contains_count++;
959             }
960 0         0 $self->{errors} = $temp_errors;
961             }
962              
963 0   0     0 my $min_contains = $schema->{minContains} // 1;
964 0         0 my $max_contains = $schema->{maxContains};
965              
966 0 0       0 if ( $contains_count < $min_contains ) {
967 0         0 $self->_add_error(
968             INSTANCE_MIN_CONTAINS,
969             "Array must contain at least $min_contains matching items (found $contains_count)",
970             $path,
971             $schema_path
972             );
973             }
974              
975 0 0 0     0 if ( defined $max_contains && $contains_count > $max_contains ) {
976 0         0 $self->_add_error(
977             INSTANCE_MAX_CONTAINS,
978             "Array must contain at most $max_contains matching items (found $contains_count)",
979             $path,
980             $schema_path
981             );
982             }
983             }
984             }
985             }
986              
987             sub _validate_set {
988 11     11   28 my ( $self, $value, $schema, $path, $schema_path ) = @_;
989              
990 11 50       29 unless ( ref($value) eq 'ARRAY' ) {
991 0         0 $self->_add_error( INSTANCE_SET_EXPECTED,
992             'Value must be an array (set)',
993             $path, $schema_path );
994 0         0 return;
995             }
996              
997             # Check for uniqueness
998 11         18 my %seen;
999 11         35 for my $i ( 0 .. $#$value ) {
1000 27         61 my $key = $self->_value_to_key( $value->[$i] );
1001 27 100       81 if ( exists $seen{$key} ) {
1002 5         25 $self->_add_error( INSTANCE_SET_DUPLICATE,
1003             "Set contains duplicate value at index $i",
1004             "$path/$i", $schema_path );
1005             }
1006 27         62 $seen{$key} = 1;
1007             }
1008              
1009             # Validate items
1010 11 50       29 if ( exists $schema->{items} ) {
1011 11         15 my $items_schema = $schema->{items};
1012 11         31 for my $i ( 0 .. $#$value ) {
1013 27         81 $self->_validate_value(
1014             $value->[$i], $items_schema,
1015             "$path/$i", "$schema_path/items"
1016             );
1017             }
1018             }
1019             }
1020              
1021             sub _validate_map {
1022 15     15   38 my ( $self, $value, $schema, $path, $schema_path ) = @_;
1023              
1024 15 50       44 unless ( ref($value) eq 'HASH' ) {
1025 0         0 $self->_add_error( INSTANCE_MAP_EXPECTED,
1026             'Value must be an object (map)',
1027             $path, $schema_path );
1028 0         0 return;
1029             }
1030              
1031             # Validate values
1032 15 50       41 if ( exists $schema->{values} ) {
1033 15         30 my $values_schema = $schema->{values};
1034 15         55 for my $key ( keys %$value ) {
1035             $self->_validate_value(
1036 22         108 $value->{$key}, $values_schema,
1037             "$path/$key", "$schema_path/values"
1038             );
1039             }
1040             }
1041              
1042             # Extended validation
1043 15 100       64 if ( $self->{extended} ) {
1044 12         46 my $count = scalar( keys %$value );
1045              
1046             # Check minProperties (for object type)
1047 12 50       30 if ( exists $schema->{minProperties} ) {
1048 0 0       0 if ( $count < $schema->{minProperties} ) {
1049 0         0 $self->_add_error(
1050             INSTANCE_MAP_MIN_ENTRIES,
1051             "Map has $count entries, minimum is $schema->{minProperties}",
1052             $path,
1053             $schema_path
1054             );
1055             }
1056             }
1057              
1058             # Check maxProperties (for object type)
1059 12 50       61 if ( exists $schema->{maxProperties} ) {
1060 0 0       0 if ( $count > $schema->{maxProperties} ) {
1061 0         0 $self->_add_error(
1062             INSTANCE_MAP_MAX_ENTRIES,
1063             "Map has $count entries, maximum is $schema->{maxProperties}",
1064             $path,
1065             $schema_path
1066             );
1067             }
1068             }
1069              
1070             # Check minEntries (for map type)
1071 12 100       35 if ( exists $schema->{minEntries} ) {
1072 3 100       12 if ( $count < $schema->{minEntries} ) {
1073 1         7 $self->_add_error(
1074             INSTANCE_MAP_MIN_ENTRIES,
1075             "Map has $count entries, minimum is $schema->{minEntries}",
1076             $path,
1077             $schema_path
1078             );
1079             }
1080             }
1081              
1082             # Check maxEntries (for map type)
1083 12 100       29 if ( exists $schema->{maxEntries} ) {
1084 3 100       13 if ( $count > $schema->{maxEntries} ) {
1085 1         8 $self->_add_error(
1086             INSTANCE_MAP_MAX_ENTRIES,
1087             "Map has $count entries, maximum is $schema->{maxEntries}",
1088             $path,
1089             $schema_path
1090             );
1091             }
1092             }
1093              
1094             # Check keyNames pattern
1095 12 100       40 if ( exists $schema->{keyNames} ) {
1096 3         8 my $key_schema = $schema->{keyNames};
1097 3 50 33     32 if ( ref($key_schema) eq 'HASH' && exists $key_schema->{pattern} ) {
1098 3         8 my $pattern = $key_schema->{pattern};
1099 3         10 for my $key ( keys %$value ) {
1100 4 100       99 if ( $key !~ /$pattern/ ) {
1101 2         19 $self->_add_error(
1102             INSTANCE_MAP_KEY_PATTERN_MISMATCH,
1103             "Map key '$key' does not match pattern '$pattern'",
1104             "$path/$key",
1105             "$schema_path/keyNames/pattern"
1106             );
1107             }
1108             }
1109             }
1110             }
1111             }
1112             }
1113              
1114             sub _validate_tuple {
1115 7     7   20 my ( $self, $value, $schema, $path, $schema_path ) = @_;
1116              
1117 7 50       21 unless ( ref($value) eq 'ARRAY' ) {
1118 0         0 $self->_add_error( INSTANCE_TUPLE_EXPECTED,
1119             'Value must be an array (tuple)',
1120             $path, $schema_path );
1121 0         0 return;
1122             }
1123              
1124 7   50     22 my $properties = $schema->{properties} // {};
1125 7   50     20 my $tuple_order = $schema->{tuple} // [];
1126 7         12 my $expected_count = scalar(@$tuple_order);
1127 7         14 my $actual_count = scalar(@$value);
1128              
1129             # Check length
1130 7 100       16 if ( $actual_count != $expected_count ) {
1131 3         9 my $items = $schema->{items};
1132 3 50 33     17 if ( !defined $items || _is_false($items) ) {
1133 3 100       38 if ( $actual_count > $expected_count ) {
    50          
1134 1         8 $self->_add_error(
1135             INSTANCE_TUPLE_ADDITIONAL_ITEMS,
1136             "Tuple has $actual_count items but only $expected_count are defined",
1137             $path,
1138             $schema_path
1139             );
1140             }
1141             elsif ( $actual_count < $expected_count ) {
1142 2         18 $self->_add_error(
1143             INSTANCE_TUPLE_LENGTH_MISMATCH,
1144             "Tuple has $actual_count items but schema defines $expected_count",
1145             $path,
1146             $schema_path
1147             );
1148             }
1149             }
1150             }
1151              
1152             # Validate each tuple element
1153 7         32 for my $i ( 0 .. $#$tuple_order ) {
1154 18 100       36 last if $i >= $actual_count;
1155 16         51 my $prop_name = $tuple_order->[$i];
1156 16 50       35 if ( exists $properties->{$prop_name} ) {
1157             $self->_validate_value(
1158 16         143 $value->[$i], $properties->{$prop_name},
1159             "$path/$i", "$schema_path/properties/$prop_name"
1160             );
1161             }
1162             }
1163             }
1164              
1165             sub _validate_choice {
1166 11     11   28 my ( $self, $value, $schema, $path, $schema_path ) = @_;
1167              
1168 11         21 my $choices = $schema->{choices};
1169 11 50 33     49 unless ( defined $choices && ref($choices) eq 'HASH' ) {
1170 0         0 $self->_add_error(
1171             INSTANCE_CHOICE_MISSING_CHOICES,
1172             "Choice schema must have 'choices'",
1173             $path, $schema_path
1174             );
1175 0         0 return;
1176             }
1177              
1178 11         21 my $selector = $schema->{selector};
1179              
1180 11 100       25 if ( defined $selector ) {
1181              
1182             # Selector-based choice - value MUST be an object
1183 8 50       20 unless ( ref($value) eq 'HASH' ) {
1184 0         0 $self->_add_error( INSTANCE_CHOICE_EXPECTED,
1185             'Value must be an object (choice with selector)',
1186             $path, $schema_path );
1187 0         0 return;
1188             }
1189              
1190 8 100       22 unless ( exists $value->{$selector} ) {
1191 2         16 $self->_add_error(
1192             INSTANCE_CHOICE_SELECTOR_MISSING,
1193             "Choice requires selector property: $selector",
1194             $path, $schema_path
1195             );
1196 2         8 return;
1197             }
1198              
1199 6         11 my $choice_name = $value->{$selector};
1200 6 50 33     22 unless ( defined $choice_name && !ref($choice_name) ) {
1201 0         0 $self->_add_error(
1202             INSTANCE_CHOICE_SELECTOR_NOT_STRING,
1203             'Selector value must be a string',
1204             "$path/$selector", $schema_path
1205             );
1206 0         0 return;
1207             }
1208              
1209 6 100       17 unless ( exists $choices->{$choice_name} ) {
1210 2         21 $self->_add_error( INSTANCE_CHOICE_UNKNOWN,
1211             "Unknown choice: $choice_name",
1212             "$path/$selector", $schema_path );
1213 2         7 return;
1214             }
1215              
1216             # Validate against the selected choice schema
1217             $self->_validate_value(
1218 4         17 $value, $choices->{$choice_name},
1219             $path, "$schema_path/choices/$choice_name"
1220             );
1221             }
1222             else {
1223             # No selector - try to match against choices
1224 3         6 my $match_count = 0;
1225 3         3 my $matched_choice;
1226              
1227 3         12 for my $choice_name ( keys %$choices ) {
1228 6         8 my $temp_errors = $self->{errors};
1229 6         9 $self->{errors} = [];
1230             $self->_validate_value(
1231 6         23 $value, $choices->{$choice_name},
1232             $path, "$schema_path/choices/$choice_name"
1233             );
1234 6 100       9 if ( @{ $self->{errors} } == 0 ) {
  6         10  
1235 2         3 $match_count++;
1236 2         3 $matched_choice = $choice_name;
1237             }
1238 6         19 $self->{errors} = $temp_errors;
1239             }
1240              
1241 3 100       11 if ( $match_count == 0 ) {
    50          
1242 1         3 $self->_add_error( INSTANCE_CHOICE_NO_MATCH,
1243             'Value does not match any choice option',
1244             $path, $schema_path );
1245             }
1246             elsif ( $match_count > 1 ) {
1247 0         0 $self->_add_error(
1248             INSTANCE_CHOICE_MULTIPLE_MATCHES,
1249             "Value matches $match_count choices (should match exactly one)",
1250             $path,
1251             $schema_path
1252             );
1253             }
1254             }
1255             }
1256              
1257             sub _validate_date {
1258 15     15   34 my ( $self, $value, $path, $schema_path ) = @_;
1259              
1260 15 50 33     56 unless ( defined $value && !ref($value) ) {
1261 0         0 $self->_add_error( INSTANCE_DATE_EXPECTED, 'Date must be a string',
1262             $path, $schema_path );
1263 0         0 return;
1264             }
1265              
1266 15 100       204 unless ( $value =~ $DATE_REGEX ) {
1267 8         48 $self->_add_error( INSTANCE_DATE_FORMAT_INVALID,
1268             "Invalid date format: $value",
1269             $path, $schema_path );
1270 8         24 return;
1271             }
1272              
1273             # Additional calendar validation
1274 7 100       25 unless ( _is_valid_calendar_date($value) ) {
1275 2         9 $self->_add_error( INSTANCE_DATE_FORMAT_INVALID,
1276             "Invalid calendar date: $value",
1277             $path, $schema_path );
1278             }
1279             }
1280              
1281             # Helper to validate calendar dates using Time::Local
1282             sub _is_valid_calendar_date {
1283 7     7   16 my ($date_str) = @_;
1284 7 50       31 return 0 unless $date_str =~ /^(\d{4})-(\d{2})-(\d{2})$/;
1285              
1286 7         38 my ( $year, $month, $day ) = ( $1, $2, $3 );
1287              
1288             # Basic range check for month
1289 7 50 33     40 return 0 if $month < 1 || $month > 12;
1290 7 50 33     32 return 0 if $day < 1 || $day > 31;
1291              
1292             # Use Time::Local to validate the date - it throws an error for invalid dates
1293 7         14 my $valid = eval {
1294             # timelocal expects month 0-11, year as actual year
1295 7         48 Time::Local::timelocal( 0, 0, 0, $day, $month - 1, $year );
1296 5         557 1;
1297             };
1298 7 100       495 return $valid ? 1 : 0;
1299             }
1300              
1301             sub _validate_time {
1302 18     18   48 my ( $self, $value, $path, $schema_path ) = @_;
1303              
1304 18 50 33     73 unless ( defined $value && !ref($value) ) {
1305 0         0 $self->_add_error( INSTANCE_TIME_EXPECTED, 'Time must be a string',
1306             $path, $schema_path );
1307 0         0 return;
1308             }
1309              
1310 18 100       198 unless ( $value =~ $TIME_REGEX ) {
1311 8         38 $self->_add_error( INSTANCE_TIME_FORMAT_INVALID,
1312             "Invalid time format: $value",
1313             $path, $schema_path );
1314             }
1315             }
1316              
1317             sub _validate_datetime {
1318 12     12   30 my ( $self, $value, $path, $schema_path ) = @_;
1319              
1320 12 50 33     49 unless ( defined $value && !ref($value) ) {
1321 0         0 $self->_add_error( INSTANCE_DATETIME_EXPECTED,
1322             'DateTime must be a string',
1323             $path, $schema_path );
1324 0         0 return;
1325             }
1326              
1327 12 100       151 unless ( $value =~ $DATETIME_REGEX ) {
1328 5         26 $self->_add_error(
1329             INSTANCE_DATETIME_FORMAT_INVALID,
1330             "Invalid datetime format: $value",
1331             $path, $schema_path
1332             );
1333             }
1334             }
1335              
1336             sub _validate_duration {
1337 18     18   47 my ( $self, $value, $path, $schema_path ) = @_;
1338              
1339 18 50 33     76 unless ( defined $value && !ref($value) ) {
1340 0         0 $self->_add_error( INSTANCE_DURATION_EXPECTED,
1341             'Duration must be a string',
1342             $path, $schema_path );
1343 0         0 return;
1344             }
1345              
1346 18 100 100     334 unless ( $value =~ $DURATION_REGEX && $value ne 'P' && $value ne 'PT' ) {
      66        
1347 6         30 $self->_add_error(
1348             INSTANCE_DURATION_FORMAT_INVALID,
1349             "Invalid duration format: $value",
1350             $path, $schema_path
1351             );
1352             }
1353             }
1354              
1355             sub _validate_uuid {
1356 15     15   39 my ( $self, $value, $path, $schema_path ) = @_;
1357              
1358 15 50 33     64 unless ( defined $value && !ref($value) ) {
1359 0         0 $self->_add_error( INSTANCE_UUID_EXPECTED, 'UUID must be a string',
1360             $path, $schema_path );
1361 0         0 return;
1362             }
1363              
1364 15 100       161 unless ( $value =~ $UUID_REGEX ) {
1365 8         41 $self->_add_error( INSTANCE_UUID_FORMAT_INVALID,
1366             "Invalid UUID format: $value",
1367             $path, $schema_path );
1368             }
1369             }
1370              
1371             sub _validate_uri {
1372 15     15   32 my ( $self, $value, $path, $schema_path ) = @_;
1373              
1374 15 50 33     64 unless ( defined $value && !ref($value) ) {
1375 0         0 $self->_add_error( INSTANCE_URI_EXPECTED, 'URI must be a string',
1376             $path, $schema_path );
1377 0         0 return;
1378             }
1379              
1380 15 100       160 unless ( $value =~ $URI_REGEX ) {
1381 6         31 $self->_add_error( INSTANCE_URI_FORMAT_INVALID,
1382             "Invalid URI format: $value",
1383             $path, $schema_path );
1384             }
1385             }
1386              
1387             sub _validate_binary {
1388 11     11   28 my ( $self, $value, $path, $schema_path ) = @_;
1389              
1390 11 50 33     45 unless ( defined $value && !ref($value) ) {
1391 0         0 $self->_add_error( INSTANCE_BINARY_EXPECTED,
1392             'Binary must be a base64 string',
1393             $path, $schema_path );
1394 0         0 return;
1395             }
1396              
1397             # Validate base64 encoding - must be valid base64 characters only
1398             # and proper padding
1399 11         18 my $valid = 1;
1400              
1401             # Empty string is valid base64
1402 11 100       28 if ( $value eq '' ) {
1403 2         7 return;
1404             }
1405              
1406             # Base64 must only contain [A-Za-z0-9+/=]
1407 9 100       82 unless ( $value =~ /^[A-Za-z0-9+\/]*={0,2}$/ ) {
1408 1         4 $valid = 0;
1409             }
1410              
1411             # Length must be multiple of 4 after padding
1412 9 100 100     46 if ( $valid && length($value) % 4 != 0 ) {
1413 2         4 $valid = 0;
1414             }
1415              
1416             # Check for proper padding
1417 9 100 100     43 if ( $valid && $value =~ /=/ ) {
1418              
1419             # = can only appear at end
1420 5 50       25 unless ( $value =~ /^[A-Za-z0-9+\/]*={1,2}$/ ) {
1421 0         0 $valid = 0;
1422             }
1423             }
1424              
1425 9 100       32 unless ($valid) {
1426 3         9 $self->_add_error(
1427             INSTANCE_BINARY_ENCODING_INVALID,
1428             'Invalid base64 encoding',
1429             $path, $schema_path
1430             );
1431             }
1432             }
1433              
1434             sub _validate_jsonpointer {
1435 14     14   53 my ( $self, $value, $path, $schema_path ) = @_;
1436              
1437 14 50 33     83 unless ( defined $value && !ref($value) ) {
1438 0         0 $self->_add_error( INSTANCE_JSONPOINTER_EXPECTED,
1439             'JSON Pointer must be a string',
1440             $path, $schema_path );
1441 0         0 return;
1442             }
1443              
1444             # Empty string is valid JSON Pointer (root)
1445 14 100       44 return if $value eq '';
1446              
1447 12 100       239 unless ( $value =~ $JSONPOINTER_REGEX ) {
1448 4         22 $self->_add_error(
1449             INSTANCE_JSONPOINTER_FORMAT_INVALID,
1450             "Invalid JSON Pointer format: $value",
1451             $path, $schema_path
1452             );
1453             }
1454             }
1455              
1456             sub _validate_composition {
1457 392     392   1010 my ( $self, $value, $schema, $path, $schema_path ) = @_;
1458              
1459             # allOf
1460 392 100       892 if ( exists $schema->{allOf} ) {
1461 2         6 for my $i ( 0 .. $#{ $schema->{allOf} } ) {
  2         12  
1462             $self->_validate_value(
1463 4         24 $value, $schema->{allOf}[$i],
1464             $path, "$schema_path/allOf/$i"
1465             );
1466             }
1467             }
1468              
1469             # anyOf
1470 392 100       835 if ( exists $schema->{anyOf} ) {
1471 4         7 my $matched = 0;
1472 4         9 my $temp_errors = $self->{errors};
1473              
1474 4         6 for my $sub_schema ( @{ $schema->{anyOf} } ) {
  4         13  
1475 7         17 $self->{errors} = [];
1476 7         26 $self->_validate_value( $value, $sub_schema, $path,
1477             "$schema_path/anyOf" );
1478 7 100       10 if ( @{ $self->{errors} } == 0 ) {
  7         22  
1479 2         4 $matched = 1;
1480 2         6 last;
1481             }
1482             }
1483              
1484 4         11 $self->{errors} = $temp_errors;
1485              
1486 4 100       12 unless ($matched) {
1487 2         5 $self->_add_error( INSTANCE_ANY_OF_NONE_MATCHED,
1488             'Value must match at least one schema in anyOf',
1489             $path, $schema_path );
1490             }
1491             }
1492              
1493             # oneOf
1494 392 100       765 if ( exists $schema->{oneOf} ) {
1495 5         8 my $match_count = 0;
1496 5         11 my $temp_errors = $self->{errors};
1497              
1498 5         9 for my $sub_schema ( @{ $schema->{oneOf} } ) {
  5         13  
1499 10         24 $self->{errors} = [];
1500 10         33 $self->_validate_value( $value, $sub_schema, $path,
1501             "$schema_path/oneOf" );
1502 10 100       17 if ( @{ $self->{errors} } == 0 ) {
  10         30  
1503 5         10 $match_count++;
1504             }
1505             }
1506              
1507 5         15 $self->{errors} = $temp_errors;
1508              
1509 5 100       14 if ( $match_count != 1 ) {
1510 2         9 $self->_add_error(
1511             INSTANCE_ONE_OF_INVALID_COUNT,
1512             "Value must match exactly one schema in oneOf (matched $match_count)",
1513             $path,
1514             $schema_path
1515             );
1516             }
1517             }
1518              
1519             # not
1520 392 100       786 if ( exists $schema->{not} ) {
1521 4         10 my $temp_errors = $self->{errors};
1522 4         8 $self->{errors} = [];
1523 4         22 $self->_validate_value( $value, $schema->{not}, $path,
1524             "$schema_path/not" );
1525 4         9 my $matched = @{ $self->{errors} } == 0;
  4         14  
1526 4         17 $self->{errors} = $temp_errors;
1527              
1528 4 100       13 if ($matched) {
1529 1         7 $self->_add_error( INSTANCE_NOT_MATCHED,
1530             "Value must not match the schema in 'not'",
1531             $path, $schema_path );
1532             }
1533             }
1534              
1535             # if/then/else
1536 392 100       1065 if ( exists $schema->{if} ) {
1537 2         7 my $temp_errors = $self->{errors};
1538 2         6 $self->{errors} = [];
1539 2         15 $self->_validate_value( $value, $schema->{if}, $path,
1540             "$schema_path/if" );
1541 2         6 my $if_matched = @{ $self->{errors} } == 0;
  2         8  
1542 2         6 $self->{errors} = $temp_errors;
1543              
1544 2 50 33     13 if ( $if_matched && exists $schema->{then} ) {
    0 0        
1545 2         12 $self->_validate_value( $value, $schema->{then}, $path,
1546             "$schema_path/then" );
1547             }
1548             elsif ( !$if_matched && exists $schema->{else} ) {
1549 0         0 $self->_validate_value( $value, $schema->{else}, $path,
1550             "$schema_path/else" );
1551             }
1552             }
1553             }
1554              
1555             sub _resolve_ref {
1556 8     8   23 my ( $self, $ref, $root ) = @_;
1557              
1558             # Handle # prefix
1559 8         43 $ref =~ s/^#//;
1560              
1561 8 50 33     81 return $root if $ref eq '' || $ref eq '/';
1562              
1563 8         32 my @segments = split m{/}, $ref;
1564 8 50 33     71 shift @segments if @segments && $segments[0] eq '';
1565              
1566 8         18 my $current = $root;
1567              
1568 8         22 for my $segment (@segments) {
1569              
1570             # Unescape JSON Pointer tokens
1571 16         29 $segment =~ s/~1/\//g;
1572 16         27 $segment =~ s/~0/~/g;
1573              
1574 16 50       42 if ( ref($current) eq 'HASH' ) {
    0          
1575 16 50       39 return undef unless exists $current->{$segment};
1576 16         37 $current = $current->{$segment};
1577             }
1578             elsif ( ref($current) eq 'ARRAY' ) {
1579 0 0       0 return undef unless $segment =~ /^\d+$/;
1580 0         0 my $idx = int($segment);
1581 0 0       0 return undef if $idx >= @$current;
1582 0         0 $current = $current->[$idx];
1583             }
1584             else {
1585 0         0 return undef;
1586             }
1587             }
1588              
1589 8         24 return $current;
1590             }
1591              
1592             sub _values_equal {
1593 31     31   101 my ( $self, $a, $b ) = @_;
1594              
1595             # Handle undefined
1596 31 0 33     76 if ( !defined $a && !defined $b ) {
1597 0         0 return 1;
1598             }
1599 31 50 33     116 if ( !defined $a || !defined $b ) {
1600 0         0 return 0;
1601             }
1602              
1603             # Handle different types
1604 31         55 my $ref_a = ref($a);
1605 31         90 my $ref_b = ref($b);
1606              
1607 31 100       85 if ( $ref_a ne $ref_b ) {
1608 3         15 return 0;
1609             }
1610              
1611 28 100       73 if ( $ref_a eq '' ) {
    50          
    50          
1612              
1613             # Scalars
1614 27         101 return $a eq $b;
1615             }
1616             elsif ( $ref_a eq 'ARRAY' ) {
1617 0 0       0 return 0 if @$a != @$b;
1618 0         0 for my $i ( 0 .. $#$a ) {
1619 0 0       0 return 0 unless $self->_values_equal( $a->[$i], $b->[$i] );
1620             }
1621 0         0 return 1;
1622             }
1623             elsif ( $ref_a eq 'HASH' ) {
1624 0         0 my @keys_a = sort keys %$a;
1625 0         0 my @keys_b = sort keys %$b;
1626 0 0       0 return 0 if @keys_a != @keys_b;
1627 0         0 for my $i ( 0 .. $#keys_a ) {
1628 0 0       0 return 0 if $keys_a[$i] ne $keys_b[$i];
1629             return 0
1630             unless $self->_values_equal( $a->{ $keys_a[$i] },
1631 0 0       0 $b->{ $keys_b[$i] } );
1632             }
1633 0         0 return 1;
1634             }
1635              
1636             # Fallback
1637 1         10 return $a eq $b;
1638             }
1639              
1640             sub _value_to_key {
1641 27     27   43 my ( $self, $value ) = @_;
1642              
1643 27 50       61 if ( !defined $value ) {
    50          
    0          
    0          
1644 0         0 return 'null';
1645             }
1646             elsif ( !ref($value) ) {
1647 27 50       40 if ( _is_json_bool($value) ) {
1648 0 0       0 return $value ? 'true' : 'false';
1649             }
1650 27         60 return "s:$value";
1651             }
1652             elsif ( ref($value) eq 'ARRAY' ) {
1653             return
1654 0         0 'a:[' . join( ',', map { $self->_value_to_key($_) } @$value ) . ']';
  0         0  
1655             }
1656             elsif ( ref($value) eq 'HASH' ) {
1657             return 'o:{'
1658             . join( ',',
1659 0         0 map { "$_:" . $self->_value_to_key( $value->{$_} ) }
  0         0  
1660             sort keys %$value ) . '}';
1661             }
1662             else {
1663 0         0 return "?:$value";
1664             }
1665             }
1666              
1667             sub _is_false {
1668 14     14   34 my ($value) = @_;
1669 14 50       42 return 0 unless defined $value;
1670 14 0 0     44 return 1
      33        
1671             if ref($value) eq ''
1672             && ( $value eq '0' || $value eq 'false' || $value eq '' );
1673 14 100 100     33 return 1 if _is_json_bool($value) && !$value;
1674 7         62 return 0;
1675             }
1676              
1677             1;
1678              
1679             __END__