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