File Coverage

blib/lib/JSON/Schema/Modern/Vocabulary/FormatAssertion.pm
Criterion Covered Total %
statement 71 71 100.0
branch 15 16 93.7
condition 6 6 100.0
subroutine 20 20 100.0
pod 0 3 0.0
total 112 116 96.5


line stmt bran cond sub pod time code
1 15     15   4963 use strict;
  15         42  
  15         598  
2 15     15   101 use warnings;
  15         36  
  15         995  
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.571';
8              
9 15     15   393 use 5.020;
  15         58  
10 15     15   99 use Moo;
  15         35  
  15         114  
11 15     15   6735 use strictures 2;
  15         164  
  15         889  
12 15     15   3384 use stable 0.031 'postderef';
  15         330  
  15         112  
13 15     15   2713 use experimental 'signatures';
  15         41  
  15         73  
14 15     15   1463 use if "$]" >= 5.022, experimental => 're_strict';
  15         66  
  15         194  
15 15     15   1537 no if "$]" >= 5.031009, feature => 'indirect';
  15         35  
  15         144  
16 15     15   782 no if "$]" >= 5.033001, feature => 'multidimensional';
  15         56  
  15         101  
17 15     15   779 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  15         47  
  15         112  
18 15     15   762 use JSON::Schema::Modern::Utilities qw(is_type E A assert_keyword_type abort);
  15         49  
  15         1354  
19 15     15   129 use Feature::Compat::Try;
  15         59  
  15         209  
20 15     15   2310 use namespace::clean;
  15         43  
  15         182  
21              
22             with 'JSON::Schema::Modern::Vocabulary';
23              
24             sub vocabulary {
25 15     15 0 83 'https://json-schema.org/draft/2020-12/vocab/format-assertion' => 'draft2020-12';
26             }
27              
28 10     10 0 48 sub evaluation_order { 2 }
29              
30             sub keywords {
31 17     17 0 202 qw(format);
32             }
33              
34             {
35             # for now, all built-in formats are constrained to the 'string' type
36              
37             my $is_email = sub {
38             Email::Address::XS->parse($_[0])->is_valid;
39             };
40             my $is_hostname = sub {
41             Data::Validate::Domain::is_domain($_[0]);
42             };
43             my $idn_decode = sub {
44             try { return Net::IDN::Encode::domain_to_ascii($_[0]) } catch ($e) { return $_[0]; }
45             };
46             my $is_ipv4 = sub {
47             my @o = split(/\./, $_[0], 5);
48             @o == 4 && (grep /^(?:0|[1-9][0-9]{0,2})$/, @o) == 4 && (grep $_ < 256, @o) == 4;
49             };
50             # https://datatracker.ietf.org/doc/html/rfc3339#appendix-A with some additions for the 2000 version
51             # as defined in https://en.wikipedia.org/wiki/ISO_8601#Durations
52             my $duration_re = do {
53             my $num = qr{[0-9]+(?:[.,][0-9]+)?};
54             my $second = qr{${num}S};
55             my $minute = qr{${num}M};
56             my $hour = qr{${num}H};
57             my $time = qr{T(?=[0-9])(?:$hour)?(?:$minute)?(?:$second)?};
58             my $day = qr{${num}D};
59             my $month = qr{${num}M};
60             my $year = qr{${num}Y};
61             my $week = qr{${num}W};
62             my $date = qr{(?=[0-9])(?:$year)?(?:$month)?(?:$day)?};
63             qr{^P(?:(?=.)(?:$date)?(?:$time)?|$week)$};
64             };
65              
66             my $formats = +{
67             'date-time' => sub {
68             # https://www.rfc-editor.org/rfc/rfc3339.html#section-5.6
69             $_[0] =~ m/^\d{4}-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.\d+)?(?:Z|[+-](\d\d):(\d\d))$/ia
70             && $1 >= 1 && $1 <= 12 # date-month
71             && $2 >= 1 && $2 <= 31 # date-mday
72             && $3 <= 23 # time-hour
73             && $4 <= 59 # time-minute
74             && $5 <= 60 # time-second
75             && (!defined $6 || $6 <= 23) # time-hour in time-numoffset
76             && (!defined $7 || $7 <= 59) # time-minute in time-numoffset
77              
78             # Time::Moment does month+day sanity check (with leap years), but not leap seconds
79             && ($5 <= 59 && eval { Time::Moment->from_string(uc($_[0])) }
80             || do {
81             require DateTime::Format::RFC3339;
82             eval { DateTime::Format::RFC3339->parse_datetime($_[0]) };
83             });
84             },
85             date => sub {
86             # https://www.rfc-editor.org/rfc/rfc3339.html#section-5.6 full-date
87             $_[0] =~ m/^(\d{4})-(\d\d)-(\d\d)$/a
88             && $2 >= 1 && $2 <= 12 # date-month
89             && $3 >= 1 && $3 <= 31 # date-mday
90             && eval { Time::Moment->new(year => $1, month => $2, day => $3) };
91             },
92             time => sub {
93             return if $_[0] !~ /^(\d\d):(\d\d):(\d\d)(?:\.\d+)?([Zz]|([+-])(\d\d):(\d\d))$/a
94             or $1 > 23
95             or $2 > 59
96             or $3 > 60
97             or (defined($6) and $6 > 23)
98             or (defined($7) and $7 > 59);
99              
100             return 1 if $3 <= 59;
101             return $1 == 23 && $2 == 59 if uc($4) eq 'Z';
102              
103             my $sign = $5 eq '+' ? 1 : -1;
104             my $hour_zulu = $1 - $6*$sign;
105             my $min_zulu = $2 - $7*$sign;
106             $hour_zulu -= 1 if $min_zulu < 0;
107              
108             return $hour_zulu%24 == 23 && $min_zulu%60 == 59;
109             },
110             duration => sub { $_[0] =~ $duration_re && $_[0] !~ m{[.,][0-9]+[A-Z].} },
111             email => sub { $is_email->($_[0]) && $_[0] !~ /[^[:ascii:]]/ },
112             'idn-email' => $is_email,
113             hostname => $is_hostname,
114             'idn-hostname' => sub { $is_hostname->($idn_decode->($_[0])) },
115             ipv4 => $is_ipv4,
116             ipv6 => sub {
117             ($_[0] =~ /^(?:[[:xdigit:]]{0,4}:){0,8}[[:xdigit:]]{0,4}$/
118             || $_[0] =~ /^(?:[[:xdigit:]]{0,4}:){1,6}((?:[0-9]{1,3}\.){3}[0-9]{1,3})$/
119             && $is_ipv4->($1))
120             && $_[0] !~ /:::/
121             && $_[0] !~ /^:[^:]/
122             && $_[0] !~ /[^:]:$/
123             && do {
124             my $double_colons = ()= ($_[0] =~ /::/g);
125             my $colon_components = grep length, split(/:+/, $_[0], -1);
126             ($double_colons == 1
127             && ((!defined $1 && $colon_components < 8) || (defined $1 && $colon_components < 7)))
128             ||
129             ($double_colons == 0
130             && ((!defined $1 && $colon_components == 8) || (defined $1 && $colon_components == 7)));
131             };
132             },
133             uri => sub {
134             my $uri = Mojo::URL->new($_[0]);
135             fc($uri->to_unsafe_string) eq fc($_[0]) && $uri->is_abs && $_[0] !~ /[^[:ascii:]]/;
136             },
137             'uri-reference' => sub {
138             fc(Mojo::URL->new($_[0])->to_unsafe_string) eq fc($_[0]) && $_[0] !~ /[^[:ascii:]]/;
139             },
140             iri => sub { Mojo::URL->new($_[0])->is_abs },
141             uuid => sub { $_[0] =~ /^[[:xdigit:]]{8}-(?:[[:xdigit:]]{4}-){3}[[:xdigit:]]{12}$/ },
142             'json-pointer' => sub { (!length($_[0]) || $_[0] =~ m{^/}) && $_[0] !~ m{~(?![01])} },
143             'relative-json-pointer' => sub { $_[0] =~ m{^(?:0|[1-9][0-9]*)(?:#$|$|/)} && $_[0] !~ m{~(?![01])} },
144             regex => sub {
145             local $SIG{__WARN__} = sub { die @_ };
146             eval { qr/$_[0]/; 1 ? 1 : 0 };
147             },
148              
149             'iri-reference' => sub { 1 },
150             'uri-template' => sub { 1 },
151             };
152              
153             my %formats_by_spec_version = (
154             draft7 => [qw(
155             date-time
156             date
157             time
158             email
159             idn-email
160             hostname
161             idn-hostname
162             ipv4
163             ipv6
164             uri
165             uri-reference
166             iri
167             json-pointer
168             relative-json-pointer
169             regex
170             iri-reference
171             uri-template
172             )],
173             );
174             $formats_by_spec_version{'draft2019-09'} =
175             $formats_by_spec_version{'draft2020-12'} = [$formats_by_spec_version{draft7}->@*, qw(duration uuid)];
176              
177 316     316   574 sub _get_default_format_validation ($self, $state, $format) {
  316         572  
  316         532  
  316         540  
  316         542  
178             return $formats->{$format}
179 316 100       2366 if grep $format eq $_, $formats_by_spec_version{$state->{spec_version}}->@*;
180             }
181             }
182              
183 18     18   35 sub _traverse_keyword_format ($self, $schema, $state) {
  18         72  
  18         39  
  18         33  
  18         35  
184 18 50       62 return if not assert_keyword_type($state, $schema, 'string');
185 18         76 return 1;
186             }
187              
188 349     349   626 sub _eval_keyword_format ($self, $data, $schema, $state) {
  349         646  
  349         662  
  349         559  
  349         600  
  349         568  
189             abort($state, 'unimplemented format "%s"', $schema->{format})
190 349 100       969 if $schema->{format} eq 'uri-template';
191              
192             try {
193             if ($schema->{format} eq 'date-time' or $schema->{format} eq 'date') {
194             require Time::Moment;
195             }
196             elsif ($schema->{format} eq 'email' or $schema->{format} eq 'idn-email') {
197             require Email::Address::XS; Email::Address::XS->VERSION(1.04);
198             }
199             # FIXME:
200             # draft7 hostname uses RFC1034
201             # draft2019-09+ hostname uses RFC1123
202             elsif ($schema->{format} eq 'hostname' or $schema->{format} eq 'idn-hostname') {
203             require Data::Validate::Domain;
204             }
205              
206             if ($schema->{format} eq 'idn-hostname') {
207             require Net::IDN::Encode;
208             }
209             }
210 347         935 catch ($e) {
211             abort($state, 'EXCEPTION: cannot validate format "%s": %s', $schema->{format}, $e);
212             }
213              
214             # first check the subrefs from JSON::Schema::Modern->new(format_validations => { ... })
215             # and fall back to the default formats, which are all defined only for strings
216 316         7763 my $evaluator_spec = $state->{evaluator}->_get_format_validation($schema->{format});
217 316         31233 my $default_spec = $self->_get_default_format_validation($state, $schema->{format});
218              
219 316 100       1430 my $spec =
    100          
    100          
220             $evaluator_spec ? ($default_spec ? +{ type => 'string', sub => $evaluator_spec } : $evaluator_spec)
221             : $default_spec ? +{ type => 'string', sub => $default_spec }
222             : undef;
223              
224 316         1363 A($state, $schema->{format});
225             return E($state, 'not a%s %s', $schema->{format} =~ /^[aeio]/ ? 'n' : '', $schema->{format})
226 316 100 100     1278 if $spec and is_type($spec->{type}, $data) and not $spec->{sub}->($data);
    100 100        
227              
228 169         918 return 1;
229             }
230              
231             1;
232              
233             __END__
234              
235             =pod
236              
237             =encoding UTF-8
238              
239             =head1 NAME
240              
241             JSON::Schema::Modern::Vocabulary::FormatAssertion - Implementation of the JSON Schema Format-Assertion vocabulary
242              
243             =head1 VERSION
244              
245             version 0.571
246              
247             =head1 DESCRIPTION
248              
249             =for Pod::Coverage vocabulary evaluation_order keywords
250              
251             =for stopwords metaschema
252              
253             Implementation of the JSON Schema Draft 2020-12 "Format-Assertion" vocabulary, indicated in metaschemas
254             with the URI C<https://json-schema.org/draft/2020-12/vocab/format-assertion> and formally specified in
255             L<https://json-schema.org/draft/2020-12/json-schema-validation.html#section-7>.
256              
257             Support is also provided for
258              
259             =over 4
260              
261             =item *
262              
263             the equivalent Draft 2019-09 keyword, indicated in metaschemas with the URI C<https://json-schema.org/draft/2019-09/vocab/format> and formally specified in L<https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-02#section-7>.
264              
265             =item *
266              
267             the equivalent Draft 7 keyword, as formally specified in L<https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-01#section-7>.
268              
269             =back
270              
271             Assertion behaviour can be enabled by
272             L<https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.8.1.2/referencing this vocabulary explicitly>
273             in a metaschema's C<$vocabulary> keyword, or by toggling the
274             L<JSON::Schema::Modern/validate_formats> option.
275              
276             Overrides to particular format implementations, or additions of new ones, can be done through
277             L<JSON::Schema::Modern/format_validations>.
278              
279             Format C<uri-template> is not yet implemented.
280             Use of this format will always result in an error.
281              
282             =head1 SEE ALSO
283              
284             =over 4
285              
286             =item *
287              
288             L<JSON::Schema::Modern/Format Validation>
289              
290             =back
291              
292             =for stopwords OpenAPI
293              
294             =head1 SUPPORT
295              
296             Bugs may be submitted through L<https://github.com/karenetheridge/JSON-Schema-Modern/issues>.
297              
298             I am also usually active on irc, as 'ether' at C<irc.perl.org> and C<irc.libera.chat>.
299              
300             You can also find me on the L<JSON Schema Slack server|https://json-schema.slack.com> and L<OpenAPI Slack
301             server|https://open-api.slack.com>, which are also great resources for finding help.
302              
303             =head1 AUTHOR
304              
305             Karen Etheridge <ether@cpan.org>
306              
307             =head1 COPYRIGHT AND LICENCE
308              
309             This software is copyright (c) 2020 by Karen Etheridge.
310              
311             This is free software; you can redistribute it and/or modify it under
312             the same terms as the Perl 5 programming language system itself.
313              
314             =cut