File Coverage

blib/lib/JSON/Schema/Modern/Vocabulary/FormatAssertion.pm
Criterion Covered Total %
statement 104 106 98.1
branch 19 22 86.3
condition 16 21 76.1
subroutine 26 26 100.0
pod 0 3 0.0
total 165 178 92.7


line stmt bran cond sub pod time code
1 38     38   1002 use strict;
  38         107  
  38         1640  
2 38     38   263 use warnings;
  38         88  
  38         3518  
3             package JSON::Schema::Modern::Vocabulary::FormatAssertion;
4             # vim: set ts=8 sts=2 sw=2 tw=100 et :
5             # ABSTRACT: Implementation of the JSON Schema Format-Assertion vocabulary
6              
7             our $VERSION = '0.632';
8              
9 38     38   654 use 5.020;
  38         154  
10 38     38   237 use Moo;
  38         118  
  38         264  
11 38     38   16633 use strictures 2;
  38         365  
  38         1713  
12 38     38   19025 use stable 0.031 'postderef';
  38         787  
  38         633  
13 38     38   7950 use experimental 'signatures';
  38         114  
  38         204  
14 38     38   2758 no autovivification warn => qw(fetch store exists delete);
  38         108  
  38         375  
15 38     38   3360 use if "$]" >= 5.022, experimental => 're_strict';
  38         85  
  38         920  
16 38     38   3576 no if "$]" >= 5.031009, feature => 'indirect';
  38         102  
  38         6008  
17 38     38   241 no if "$]" >= 5.033001, feature => 'multidimensional';
  38         97  
  38         2980  
18 38     38   250 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  38         130  
  38         2565  
19 38     38   299 no if "$]" >= 5.041009, feature => 'smartmatch';
  38         118  
  38         2162  
20 38     38   279 no feature 'switch';
  38         127  
  38         1866  
21 38     38   231 use JSON::Schema::Modern::Utilities qw(get_type E A assert_keyword_type abort);
  38         132  
  38         3722  
22 38     38   289 use Feature::Compat::Try;
  38         87  
  38         342  
23 38     38   3675 use if "$]" < 5.041010, 'List::Util' => 'any';
  38         102  
  38         2124  
24 38     38   234 use if "$]" >= 5.041010, experimental => 'keyword_any';
  38         84  
  38         736  
25 38     38   3558 use Scalar::Util 'looks_like_number';
  38         98  
  38         2744  
26 38     38   275 use namespace::clean;
  38         123  
  38         404  
27              
28             with 'JSON::Schema::Modern::Vocabulary';
29              
30 22     22 0 95 sub vocabulary ($class) {
  22         55  
  22         45  
31 22         116 'https://json-schema.org/draft/2020-12/vocab/format-assertion' => 'draft2020-12';
32             }
33              
34 19     19 0 39 sub evaluation_order ($class) { 2 }
  19         40  
  19         49  
  19         104  
35              
36 28     28 0 125 sub keywords ($class, $spec_version) {
  28         60  
  28         57  
  28         52  
37             return (
38 28 100       399 $spec_version !~ /^draft(?:[467]|2019-09)\z/ ? qw(format) : (),
39             );
40             }
41              
42             # these definitions are shared with the FormatAnnotation vocabulary
43             {
44             # for now, all built-in formats are constrained to the 'string' type
45              
46             my $is_email = sub { # email, idn-email
47             require Email::Address::XS; Email::Address::XS->VERSION(1.04);
48             Email::Address::XS->parse_bare_address($_[0])->is_valid;
49             };
50             my $is_hostname = sub { # hostname, idn-hostname
51             # FIXME: draft7 hostname uses RFC1034, draft2019-09+ hostname uses RFC1123
52             require Data::Validate::Domain; Data::Validate::Domain->VERSION(0.13);
53             Data::Validate::Domain::is_domain($_[0],
54             { domain_disable_tld_validation => 1, domain_allow_single_label => 1 });
55             };
56             my $idn_decode = sub { # idn-hostname
57             require Net::IDN::Encode;
58             try { return Net::IDN::Encode::domain_to_ascii($_[0]) } catch ($e) { return $_[0]; }
59             };
60             my $is_ipv4 = sub { # ipv4, ipv6
61             my @o = split(/\./, $_[0], 5);
62             @o == 4 && (grep /^(?:0|[1-9][0-9]{0,2})\z/, @o) == 4 && (grep $_ < 256, @o) == 4;
63             };
64             # https://datatracker.ietf.org/doc/html/rfc3339#appendix-A with some additions for the 2000 version
65             # as defined in https://en.wikipedia.org/wiki/ISO_8601#Durations
66             my $duration_re = do { # duration
67             my $num = qr{[0-9]+(?:[.,][0-9]+)?};
68             my $second = qr{${num}S};
69             my $minute = qr{${num}M};
70             my $hour = qr{${num}H};
71             my $time = qr{T(?=[0-9])(?:$hour)?(?:$minute)?(?:$second)?};
72             my $day = qr{${num}D};
73             my $month = qr{${num}M};
74             my $year = qr{${num}Y};
75             my $week = qr{${num}W};
76             my $date = qr{(?=[0-9])(?:$year)?(?:$month)?(?:$day)?};
77             qr{^P(?:(?=.)(?:$date)?(?:$time)?|$week)\z};
78             };
79              
80             my $formats = +{
81             'date-time' => sub {
82             # https://www.rfc-editor.org/rfc/rfc3339.html#section-5.6
83             $_[0] =~ m/^\d{4}-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.\d+)?(?:Z|[+-](\d\d):(\d\d))\z/ia
84             && $1 >= 1 && $1 <= 12 # date-month
85             && $2 >= 1 && $2 <= 31 # date-mday
86             && $3 <= 23 # time-hour
87             && $4 <= 59 # time-minute
88             && $5 <= 60 # time-second
89             && (!defined $6 || $6 <= 23) # time-hour in time-numoffset
90             && (!defined $7 || $7 <= 59) # time-minute in time-numoffset
91              
92             # Time::Moment does month+day sanity check (with leap years), but not leap seconds
93             && ($5 <= 59
94             && do {
95             require Time::Moment;
96             eval { Time::Moment->from_string(uc($_[0])) };
97             } || do {
98             require DateTime::Format::RFC3339;
99             eval { DateTime::Format::RFC3339->parse_datetime($_[0]) };
100             });
101             },
102             date => sub {
103             # https://www.rfc-editor.org/rfc/rfc3339.html#section-5.6 full-date
104             $_[0] =~ m/^(\d{4})-(\d\d)-(\d\d)\z/a
105             && $2 >= 1 && $2 <= 12 # date-month
106             && $3 >= 1 && $3 <= 31 # date-mday
107             && do {
108             require Time::Moment;
109             eval { Time::Moment->new(year => $1, month => $2, day => $3) };
110             };
111             },
112             time => sub {
113             return if $_[0] !~ /^(\d\d):(\d\d):(\d\d)(?:\.\d+)?([Zz]|([+-])(\d\d):(\d\d))\z/a
114             or $1 > 23
115             or $2 > 59
116             or $3 > 60
117             or (defined($6) and $6 > 23)
118             or (defined($7) and $7 > 59);
119              
120             return 1 if $3 <= 59;
121             return $1 == 23 && $2 == 59 if uc($4) eq 'Z';
122              
123             my $sign = $5 eq '+' ? 1 : -1;
124             my $hour_zulu = $1 - $6*$sign;
125             my $min_zulu = $2 - $7*$sign;
126             $hour_zulu -= 1 if $min_zulu < 0;
127              
128             return $hour_zulu%24 == 23 && $min_zulu%60 == 59;
129             },
130             duration => sub { $_[0] =~ $duration_re && $_[0] !~ m{[.,][0-9]+[A-Z].} },
131             email => sub { $is_email->($_[0]) && $_[0] !~ /[^[:ascii:]]/ },
132             'idn-email' => $is_email,
133             hostname => $is_hostname,
134             'idn-hostname' => sub { $is_hostname->($idn_decode->($_[0])) },
135             ipv4 => $is_ipv4,
136             ipv6 => sub {
137             ($_[0] =~ /^(?:[[:xdigit:]]{0,4}:){0,8}[[:xdigit:]]{0,4}\z/
138             || $_[0] =~ /^(?:[[:xdigit:]]{0,4}:){1,6}((?:[0-9]{1,3}\.){3}[0-9]{1,3})\z/
139             && $is_ipv4->($1))
140             && $_[0] !~ /:::/
141             && $_[0] !~ /^:[^:]/
142             && $_[0] !~ /[^:]:\z/
143             && do {
144             my $double_colons = ()= ($_[0] =~ /::/g);
145             my $colon_components = grep length, split(/:+/, $_[0], -1);
146             ($double_colons == 1
147             && ((!defined $1 && $colon_components < 8) || (defined $1 && $colon_components < 7)))
148             ||
149             ($double_colons == 0
150             && ((!defined $1 && $colon_components == 8) || (defined $1 && $colon_components == 7)));
151             };
152             },
153             uri => sub {
154             my $uri = Mojo::URL->new($_[0]);
155             fc($uri->to_unsafe_string) eq fc($_[0]) && $uri->is_abs && $_[0] !~ /[^[:ascii:]]/;
156             },
157             'uri-reference' => sub {
158             fc(Mojo::URL->new($_[0])->to_unsafe_string) eq fc($_[0]) && $_[0] !~ /[^[:ascii:]]/;
159             },
160             iri => sub { Mojo::URL->new($_[0])->is_abs },
161             uuid => sub { $_[0] =~ /^[[:xdigit:]]{8}-(?:[[:xdigit:]]{4}-){3}[[:xdigit:]]{12}\z/ },
162             'json-pointer' => sub { (!length($_[0]) || $_[0] =~ m{^/}) && $_[0] !~ m{~(?![01])} },
163             'relative-json-pointer' => sub { $_[0] =~ m{^(?:0|[1-9][0-9]*)(?:#\z|\z|/)} && $_[0] !~ m{~(?![01])} },
164             regex => sub {
165             local $SIG{__WARN__} = sub { die @_ };
166             eval { qr/$_[0]/; 1 };
167             },
168              
169             'iri-reference' => sub { 1 },
170             # uri-template is not implemented, but user can add a custom definition
171             };
172              
173             my %formats_by_spec_version = (
174             draft4 => [qw(
175             date-time
176             email
177             hostname
178             ipv4
179             ipv6
180             uri
181             )],
182             );
183             $formats_by_spec_version{draft6} = [$formats_by_spec_version{draft4}->@*, qw(
184             uri-reference
185             uri-template
186             json-pointer
187             )];
188             $formats_by_spec_version{draft7} = [$formats_by_spec_version{draft6}->@*, qw(
189             iri
190             iri-reference
191             idn-email
192             idn-hostname
193             relative-json-pointer
194             regex
195             date
196             time
197             )];
198             $formats_by_spec_version{'draft2019-09'} =
199             $formats_by_spec_version{'draft2020-12'} = [$formats_by_spec_version{draft7}->@*, qw(duration uuid)];
200              
201 876     876   1798 sub _get_default_format_validation ($class, $state, $format) {
  876         1789  
  876         1796  
  876         1598  
  876         1560  
202             # all core formats are of type string (so far)
203             return { type => 'string', sub => $formats->{$format} }
204             if grep $format eq $_, $formats_by_spec_version{$state->{specification_version}}->@*
205 876 100 100     14752 and $formats->{$format};
206             }
207             }
208              
209             my $warnings = {
210             email => sub { require Email::Address::XS; Email::Address::XS->VERSION(1.04); 1 },
211             hostname => sub { require Data::Validate::Domain; Data::Validate::Domain->VERSION(0.13); 1 },
212             'idn-hostname' => sub { require Data::Validate::Domain; Data::Validate::Domain->VERSION(0.13); require Net::IDN::Encode; 1 },
213             'date-time' => sub { require Time::Moment; require DateTime::Format::RFC3339; 1 },
214             date => sub { require Time::Moment; 1 },
215             };
216             $warnings->{'idn-email'} = $warnings->{email};
217              
218 25     25   70 sub _traverse_keyword_format ($class, $schema, $state) {
  25         66  
  25         85  
  25         58  
  25         65  
219 25 50       147 return if not assert_keyword_type($state, $schema, 'string');
220              
221             # warn when prereq is missing for a format implementation
222 25 100       174 if (my $warn_sub = $warnings->{$schema->{format}}) {
223 1         4 try { $warn_sub->() } catch ($e) { warn $e }
  1         3  
  1         235  
224             }
225              
226             # §7.2.2 (draft2020-12) "When the Format-Assertion vocabulary is declared with a value of true,
227             # implementations MUST provide full validation support for all of the formats defined by this
228             # specification. Implementations that cannot provide full validation support MUST refuse to
229             # process the schema."
230             return E($state, 'unimplemented core format "%s"', $schema->{format})
231             if $schema->{format} eq 'uri-template'
232 25 100 100     196 and not $state->{evaluator}->_get_format_validation($schema->{format});
233              
234             # unimplemented custom formats are detected at runtime, only if actually evaluated
235              
236 23         100 return 1;
237             }
238              
239             # Note that this method is only callable in draft2020-12 and later, because this vocabulary does not
240             # exist in previous versions
241 27     27   74 sub _eval_keyword_format ($class, $data, $schema, $state) {
  27         71  
  27         102  
  27         66  
  27         59  
  27         64  
242 27         219 A($state, $schema->{format});
243              
244             # unimplemented core formats were already detected in the traverse phase
245              
246             my $spec = $state->{evaluator}->_get_format_validation($schema->{format})
247 27   100     236 // $class->_get_default_format_validation($state, $schema->{format});
248              
249             # §7.2.3 (draft2020-12) "When the Format-Assertion vocabulary is specified, implementations MUST
250             # fail upon encountering unknown formats."
251 27 100       152 abort($state, 'unimplemented custom format "%s"', $schema->{format}) if not $spec;
252              
253 26         129 my $type = get_type($data);
254 26 100       133 $type = 'number' if $type eq 'integer';
255              
256             return 1 if
257 0         0 not ref $spec->{type} eq 'ARRAY' ? any { $type eq $_ } $spec->{type}->@* : $type eq $spec->{type}
258             and not ($state->{stringy_numbers} and $type eq 'string'
259 26 50 33     234 and ref $spec->{type} eq 'ARRAY' ? any { $_ eq 'number' } $spec->{type}->@* : $spec->{type} eq 'number'
  0 50 33     0  
    100 66        
      100        
260             and looks_like_number($data));
261              
262 25 100       136 return E($state, 'not a valid %s', $schema->{format}) if not $spec->{sub}->($data);
263 11         74 return 1;
264             }
265              
266             1;
267              
268             __END__