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   1020 use strict;
  38         103  
  38         1701  
2 38     38   211 use warnings;
  38         84  
  38         3535  
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.634';
8              
9 38     38   708 use 5.020;
  38         152  
10 38     38   238 use Moo;
  38         98  
  38         332  
11 38     38   16944 use strictures 2;
  38         292  
  38         1739  
12 38     38   19450 use stable 0.031 'postderef';
  38         723  
  38         378  
13 38     38   7661 use experimental 'signatures';
  38         120  
  38         439  
14 38     38   2875 no autovivification warn => qw(fetch store exists delete);
  38         137  
  38         372  
15 38     38   3250 use if "$]" >= 5.022, experimental => 're_strict';
  38         99  
  38         979  
16 38     38   3822 no if "$]" >= 5.031009, feature => 'indirect';
  38         97  
  38         3011  
17 38     38   277 no if "$]" >= 5.033001, feature => 'multidimensional';
  38         98  
  38         3002  
18 38     38   257 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  38         107  
  38         2716  
19 38     38   278 no if "$]" >= 5.041009, feature => 'smartmatch';
  38         112  
  38         2080  
20 38     38   236 no feature 'switch';
  38         87  
  38         2102  
21 38     38   227 use JSON::Schema::Modern::Utilities qw(get_type E A assert_keyword_type abort);
  38         110  
  38         3686  
22 38     38   316 use Feature::Compat::Try;
  38         102  
  38         364  
23 38     38   3606 use if "$]" < 5.041010, 'List::Util' => 'any';
  38         105  
  38         2303  
24 38     38   233 use if "$]" >= 5.041010, experimental => 'keyword_any';
  38         111  
  38         683  
25 38     38   3504 use Scalar::Util 'looks_like_number';
  38         186  
  38         2808  
26 38     38   269 use namespace::clean;
  38         98  
  38         381  
27              
28             with 'JSON::Schema::Modern::Vocabulary';
29              
30 22     22 0 50 sub vocabulary ($class) {
  22         67  
  22         58  
31 22         193 'https://json-schema.org/draft/2020-12/vocab/format-assertion' => 'draft2020-12';
32             }
33              
34 19     19 0 36 sub evaluation_order ($class) { 2 }
  19         39  
  19         35  
  19         89  
35              
36 28     28 0 119 sub keywords ($class, $spec_version) {
  28         54  
  28         54  
  28         56  
37             return (
38 28 100       320 $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}$/;
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             fc($uri->to_unsafe_string) eq fc($_[0]) && $uri->is_abs && $_[0] !~ /[^[:ascii:]]/;
163             },
164             'uri-reference' => sub {
165             fc(Mojo::URL->new($_[0])->to_unsafe_string) eq fc($_[0]) && $_[0] !~ /[^[:ascii:]]/;
166             },
167             iri => sub { Mojo::URL->new($_[0])->is_abs },
168             uuid => sub { $_[0] =~ /^[[:xdigit:]]{8}-(?:[[:xdigit:]]{4}-){3}[[:xdigit:]]{12}\z/ },
169             'json-pointer' => sub { (!length($_[0]) || $_[0] =~ m{^/}) && $_[0] !~ m{~(?![01])} },
170             'relative-json-pointer' => sub { $_[0] =~ m{^(?:0|[1-9][0-9]*)(?:#\z|\z|/)} && $_[0] !~ m{~(?![01])} },
171             regex => sub {
172             local $SIG{__WARN__} = sub { die @_ };
173             eval { qr/$_[0]/; 1 };
174             },
175              
176             'iri-reference' => sub { 1 },
177             # uri-template is not implemented, but user can add a custom definition
178             };
179              
180             my %formats_by_spec_version = (
181             draft4 => [qw(
182             date-time
183             email
184             hostname
185             ipv4
186             ipv6
187             uri
188             )],
189             );
190             $formats_by_spec_version{draft6} = [$formats_by_spec_version{draft4}->@*, qw(
191             uri-reference
192             uri-template
193             json-pointer
194             )];
195             $formats_by_spec_version{draft7} = [$formats_by_spec_version{draft6}->@*, qw(
196             iri
197             iri-reference
198             idn-email
199             idn-hostname
200             relative-json-pointer
201             regex
202             date
203             time
204             )];
205             $formats_by_spec_version{'draft2019-09'} =
206             $formats_by_spec_version{'draft2020-12'} = [$formats_by_spec_version{draft7}->@*, qw(duration uuid)];
207              
208 888     888   1762 sub _get_default_format_validation ($class, $state, $format) {
  888         1849  
  888         1595  
  888         1632  
  888         1428  
209             # all core formats are of type string (so far)
210             return { type => 'string', sub => $formats->{$format} }
211             if grep $format eq $_, $formats_by_spec_version{$state->{specification_version}}->@*
212 888 100 100     14792 and $formats->{$format};
213             }
214             }
215              
216             my $warnings = {
217             email => sub { require Email::Address::XS; Email::Address::XS->VERSION(1.04); 1 },
218             hostname => sub { require Data::Validate::Domain; Data::Validate::Domain->VERSION(0.13); 1 },
219             'idn-hostname' => sub { require Data::Validate::Domain; Data::Validate::Domain->VERSION(0.13); require Net::IDN::Encode; 1 },
220             'date-time' => sub { require Time::Moment; require DateTime::Format::RFC3339; 1 },
221             date => sub { require Time::Moment; 1 },
222             };
223             $warnings->{'idn-email'} = $warnings->{email};
224              
225 25     25   62 sub _traverse_keyword_format ($class, $schema, $state) {
  25         60  
  25         71  
  25         60  
  25         49  
226 25 50       111 return if not assert_keyword_type($state, $schema, 'string');
227              
228             # warn when prereq is missing for a format implementation
229 25 100       137 if (my $warn_sub = $warnings->{$schema->{format}}) {
230 1         3 try { $warn_sub->() } catch ($e) { warn $e }
  1         4  
  1         192  
231             }
232              
233             # §7.2.2 (draft2020-12) "When the Format-Assertion vocabulary is declared with a value of true,
234             # implementations MUST provide full validation support for all of the formats defined by this
235             # specification. Implementations that cannot provide full validation support MUST refuse to
236             # process the schema."
237             return E($state, 'unimplemented core format "%s"', $schema->{format})
238             if $schema->{format} eq 'uri-template'
239 25 100 100     144 and not $state->{evaluator}->_get_format_validation($schema->{format});
240              
241             # unimplemented custom formats are detected at runtime, only if actually evaluated
242              
243 23         79 return 1;
244             }
245              
246             # Note that this method is only callable in draft2020-12 and later, because this vocabulary does not
247             # exist in previous versions
248 27     27   83 sub _eval_keyword_format ($class, $data, $schema, $state) {
  27         57  
  27         70  
  27         53  
  27         55  
  27         64  
249 27         171 A($state, $schema->{format});
250              
251             # unimplemented core formats were already detected in the traverse phase
252              
253             my $spec = $state->{evaluator}->_get_format_validation($schema->{format})
254 27   100     204 // $class->_get_default_format_validation($state, $schema->{format});
255              
256             # §7.2.3 (draft2020-12) "When the Format-Assertion vocabulary is specified, implementations MUST
257             # fail upon encountering unknown formats."
258 27 100       109 abort($state, 'unimplemented custom format "%s"', $schema->{format}) if not $spec;
259              
260 26         125 my $type = get_type($data);
261 26 100       98 $type = 'number' if $type eq 'integer';
262              
263             return 1 if
264 0         0 not ref $spec->{type} eq 'ARRAY' ? any { $type eq $_ } $spec->{type}->@* : $type eq $spec->{type}
265             and not ($state->{stringy_numbers} and $type eq 'string'
266 26 50 33     230 and ref $spec->{type} eq 'ARRAY' ? any { $_ eq 'number' } $spec->{type}->@* : $spec->{type} eq 'number'
  0 50 33     0  
    100 66        
      100        
267             and looks_like_number($data));
268              
269 25 100       119 return E($state, 'not a valid %s', $schema->{format}) if not $spec->{sub}->($data);
270 11         92 return 1;
271             }
272              
273             1;
274              
275             __END__