File Coverage

blib/lib/JSON/Schema/Modern/Document.pm
Criterion Covered Total %
statement 186 189 98.4
branch 40 48 83.3
condition 20 28 71.4
subroutine 43 45 95.5
pod 5 14 35.7
total 294 324 90.7


line stmt bran cond sub pod time code
1 45     45   251 use strict;
  45         94  
  45         1553  
2 45     45   191 use warnings;
  45         82  
  45         2858  
3             package JSON::Schema::Modern::Document;
4             # vim: set ts=8 sts=2 sw=2 tw=100 et :
5             # ABSTRACT: One JSON Schema document
6              
7             our $VERSION = '0.637';
8              
9 45     45   636 use 5.020;
  45         127  
10 45     45   182 use Moo;
  45         81  
  45         275  
11 45     45   14843 use strictures 2;
  45         419  
  45         1628  
12 45     45   16651 use stable 0.031 'postderef';
  45         788  
  45         314  
13 45     45   8101 use experimental 'signatures';
  45         87  
  45         145  
14 45     45   2333 no autovivification warn => qw(fetch store exists delete);
  45         77  
  45         362  
15 45     45   3018 use if "$]" >= 5.022, experimental => 're_strict';
  45         97  
  45         1027  
16 45     45   3138 no if "$]" >= 5.031009, feature => 'indirect';
  45         92  
  45         2883  
17 45     45   228 no if "$]" >= 5.033001, feature => 'multidimensional';
  45         92  
  45         2364  
18 45     45   248 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  45         73  
  45         2381  
19 45     45   263 no if "$]" >= 5.041009, feature => 'smartmatch';
  45         89  
  45         1785  
20 45     45   202 no feature 'switch';
  45         154  
  45         1225  
21 45     45   198 use Mojo::URL;
  45         109  
  45         483  
22 45     45   1409 use Carp 'croak';
  45         109  
  45         3181  
23 45     45   222 use List::Util 1.29 'pairs';
  45         967  
  45         3075  
24 45     45   264 use builtin::compat qw(refaddr blessed);
  45         81  
  45         413  
25 45     45   7282 use Safe::Isa 1.000008;
  45         931  
  45         5939  
26 45     45   288 use MooX::TypeTiny;
  45         85  
  45         323  
27 45     45   32046 use Types::Standard 1.016003 qw(InstanceOf HashRef Str Map Dict ArrayRef Enum ClassName Undef Slurpy Optional Bool);
  45         709  
  45         384  
28 45     45   109912 use Types::Common::Numeric 'PositiveOrZeroInt';
  45         82  
  45         351  
29 45     45   32710 use JSON::Schema::Modern::Utilities qw(json_pointer_type canonical_uri_type E);
  45         84  
  45         2502  
30 45     45   208 use namespace::clean;
  45         109  
  45         250  
31              
32             extends 'Mojo::JSON::Pointer';
33              
34             has schema => (
35             is => 'ro',
36             required => 1,
37             );
38              
39             has canonical_uri => (
40             is => 'rwp',
41             isa => (InstanceOf['Mojo::URL'])->where(q{not defined $_->fragment}),
42             lazy => 1,
43             default => sub { Mojo::URL->new },
44             coerce => sub { $_[0]->$_isa('Mojo::URL') ? $_[0] : Mojo::URL->new($_[0]) },
45             );
46              
47             # this is also known as the retrieval uri in the OpenAPI specification
48             has original_uri => (
49             is => 'rwp',
50             isa => (InstanceOf['Mojo::URL'])->where(q{not defined $_->fragment}),
51             init_arg => undef,
52             );
53             *retrieval_uri = \&original_uri;
54              
55             has metaschema_uri => (
56             is => 'rwp',
57             isa => InstanceOf['Mojo::URL'],
58             coerce => sub { $_[0]->$_isa('Mojo::URL') ? $_[0] : Mojo::URL->new($_[0]) },
59             predicate => '_has_metaschema_uri',
60             # default not defined here, but might be defined in a subclass
61             );
62              
63             # "A JSON Schema resource is a schema which is canonically identified by an absolute URI."
64             # https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.4.3.5
65             has resource_index => (
66             is => 'bare',
67             isa => Map[my $resource_key_type = Str->where('!/#/'), my $resource_type = Dict[
68             canonical_uri => (InstanceOf['Mojo::URL'])->where(q{not defined $_->fragment}),
69             path => json_pointer_type, # JSON pointer relative to the document root
70             specification_version => Enum[qw(draft4 draft6 draft7 draft2019-09 draft2020-12)],
71             # the vocabularies used when evaluating instance data against schema
72             vocabularies => ArrayRef[ClassName->where(q{$_->DOES('JSON::Schema::Modern::Vocabulary')})],
73             anchors => Optional[HashRef[Dict[
74             canonical_uri => canonical_uri_type, # equivalent uri with json pointer fragment
75             path => json_pointer_type, # JSON pointer relative to the document root
76             dynamic => Optional[Bool],
77             ]]],
78             Slurpy[HashRef[Undef]], # no other fields allowed
79             ]],
80             init_arg => undef,
81             default => sub { {} },
82             );
83              
84 52     52 1 83428 sub resource_index { $_[0]->{resource_index}->%* }
85 18126     18126 0 214627 sub resource_pairs { pairs $_[0]->{resource_index}->%* }
86 3113     3113   342647 sub _get_resource { $_[0]->{resource_index}{$_[1]} }
87 0     0   0 sub _canonical_resources { values $_[0]->{resource_index}->%* }
88             sub _add_resource {
89 18654 100   18654   2312479 croak 'uri "'.$_[1].'" conflicts with an existing schema resource' if $_[0]->{resource_index}{$_[1]};
90 18653         415126 $_[0]->{resource_index}{$resource_key_type->($_[1])} = $resource_type->($_[2]);
91             }
92              
93             # for internal use only
94             has _checksum => (
95             is => 'rw',
96             isa => Str,
97             init_arg => undef,
98             );
99              
100             has errors => (
101             is => 'bare',
102             writer => '_set_errors',
103             isa => ArrayRef[InstanceOf['JSON::Schema::Modern::Error']],
104             lazy => 1,
105             default => sub { [] },
106             );
107              
108 200   100 200 1 8314 sub errors { ($_[0]->{errors}//[])->@* }
109 18271   100 18271 0 123277 sub has_errors { scalar(($_[0]->{errors}//[])->@*) }
110              
111             # json pointer => entity name (indexed by integer)
112             has _entities => (
113             is => 'ro',
114             isa => Map[json_pointer_type, PositiveOrZeroInt],
115             lazy => 1,
116             default => sub { {} },
117             );
118              
119             # in this class, the only entity type is 'schema', but subclasses add more
120 108626     108626   232568 sub __entities ($) { qw(schema) }
  108626         130505  
  108626         337949  
121 42108     42108   79690 sub __entity_type { Enum[$_[0]->__entities] }
122 42108     42108   3380587 sub __entity_index ($self, $entity) {
  42108         70292  
  42108         64922  
  42108         55636  
123 42108         89704 my @e = $self->__entities;
124 42108 50       127997 foreach my $i (0..$#e) { return $i if $e[$i] eq $entity; }
  42108         909340  
125 0         0 return undef;
126             }
127              
128 42107     42107   364977 sub _add_entity_location ($self, $location, $entity) {
  42107         58469  
  42107         60389  
  42107         59615  
  42107         46823  
129 42107         79537 $self->__entity_type->($entity); # verify string
130 42107         42656551 $self->_entities->{$location} = $self->__entity_index($entity); # store integer-mapped value
131             }
132              
133 24464     24464 0 60569 sub get_entity_at_location ($self, $location) {
  24464         34192  
  24464         37568  
  24464         29476  
134 24464 100       557008 return '' if not exists $self->_entities->{$location};
135 24410   33     472977 ($self->__entities)[ $self->_entities->{$location} ] // croak "missing mapping for ", $self->_entities->{$location};
136             }
137              
138             # note: not sorted
139 1     1 0 2 sub get_entity_locations ($self, $entity) {
  1         3  
  1         2  
  1         2  
140 1         3 $self->__entity_type->($entity); # verify string
141 1         961 my $index = $self->__entity_index($entity);
142 1         16 grep $self->{_entities}{$_} == $index, keys $self->{_entities}->%*;
143             }
144              
145             # shims for Mojo::JSON::Pointer
146 26807     26807 1 270999 sub data { shift->schema(@_) }
147 17913     17913 0 1369252 sub FOREIGNBUILDARGS { () }
148              
149             # for JSON serializers
150 0     0 1 0 sub TO_JSON { shift->schema }
151              
152             # note that this is always called, even in subclasses
153 17912     17912 0 374078 sub BUILD ($self, $args) {
  17912         30938  
  17912         25452  
  17912         24598  
154             # note! not a clone! Please don't change canonical_uri in-place.
155 17912         265961 $self->_set_original_uri($self->canonical_uri);
156              
157             # this should extract all identifiers, references, and entities, and set canonical_uri,
158             # metaschema_uri
159             my $state = $self->traverse(
160             $args->{evaluator} // JSON::Schema::Modern->new,
161             {
162             $args->{specification_version} ? $args->%{specification_version} : (),
163 17912 100 66     1681489 $args->{skip_ref_checks} ? $args->%{skip_ref_checks} : (),
    100          
164             },
165             );
166              
167 17912 100       91089 if ($state->{errors}->@*) {
168 149         2320 $self->_set_errors($state->{errors});
169 149         3382 return;
170             }
171              
172 17763         27833 my $seen_root;
173 17763         64171 foreach my $key (keys $state->{identifiers}->%*) {
174 2077         6549 my $value = $state->{identifiers}{$key};
175 2077         7111 $self->_add_resource($key => $value);
176              
177             # we're adding a non-anchor entry for the document root
178 2077 100       735095 ++$seen_root if $value->{path} eq '';
179             }
180              
181             # we only index the original uri if nothing in the schema itself identified a root resource:
182             # otherwise the top of the document would be unreferenceable.
183             $self->_add_resource($self->original_uri.'' => {
184             path => '',
185             canonical_uri => $self->canonical_uri,
186 17763 100       121511 $state->%{qw(specification_version vocabularies)},
187             })
188             if not $seen_root;
189              
190 17762   100     5239077 foreach my $ref (($state->{references}//[])->@*) {
191 3112         9930 my ($keyword, $path_location, $abs_target, $expected_entity) = @$ref;
192              
193             # look for resource locally; fall back to the evaluator's index
194 3112         12459 my $resource = $self->_get_resource(my $uri = $abs_target->clone->fragment(undef));
195 3112         431019 my $document = $self;
196              
197 3112 100       8713 if (not $resource) {
198 521 50       4339 $resource = $args->{evaluator}->_get_resource($uri) if $args->{evaluator};
199 521 100       69887 next if not $resource;
200 352         1379 $document = $resource->{document};
201             }
202              
203 2943         6235 my $fragment = $abs_target->fragment;
204 2943         10027 my $target_path;
205 2943 100 100     18244 if (not length $fragment or $fragment =~ m{^/}) {
    100 50        
206             ()= E({ %$state, keyword_path => $path_location, keyword => $keyword },
207             '%s target "%s" is a non-existent location', $keyword, $abs_target), next
208 2628 100 100     19559 if not $document->contains($target_path = $resource->{path}.($fragment//''));
209             }
210             elsif (my $subresource = ($resource->{anchors}//{})->{$fragment}) {
211 311         904 $target_path = $subresource->{path};
212             }
213             else {
214 4         45 ()= E({ %$state, keyword_path => $path_location, keyword => $keyword },
215             '%s target "%s" is a non-existent location', $keyword, $abs_target);
216 4         38 next;
217             }
218              
219 2935         62724 my $entity = $document->get_entity_at_location($target_path);
220 2935 100       7375 ()= E({ %$state, keyword_path => $path_location, keyword => $keyword },
221             '%s target "%s" is not a referenceable location', $keyword, $abs_target), next
222             if not $entity;
223              
224 2889 50       24892 ()= E({ %$state, keyword_path => $path_location, keyword => $keyword },
225             '%s target "%s" is the wrong object type (%s, expecting %s)',
226             $keyword, $abs_target, $entity, $expected_entity), next
227             if $entity ne $expected_entity;
228             }
229              
230 17762 100       240207 $self->_set_errors($state->{errors}) if $state->{errors}->@*;
231             }
232              
233             # a subclass's method will override this one
234 17911     17911 0 32061 sub traverse ($self, $evaluator, $config_override = {}) {
  17911         26869  
  17911         25323  
  17911         30551  
  17911         26244  
235             die 'wrong class - use JSON::Schema::Modern::Document::OpenAPI instead'
236 17911 50 66     138653 if ref $self->schema eq 'HASH' and exists $self->schema->{openapi};
237              
238 17911         39130 my $original_uri = $self->original_uri;
239              
240 17911 100       125835 my $state = $evaluator->traverse($self->schema,
241             {
242             initial_schema_uri => $original_uri,
243             $self->_has_metaschema_uri ? (metaschema_uri => $self->metaschema_uri) : (),
244             %$config_override,
245             }
246             );
247              
248 17911 50 33     137744 die 'original_uri has changed' if $self->original_uri ne $original_uri
249             or refaddr($self->original_uri) != refaddr($original_uri);
250              
251             # if the document identified a canonical uri for itself via '$id', or metaschema uri via '$schema',
252             # they overrides the initial values
253             # Note that subclasses of this class may choose to identify these values in a different way
254             # (e.g. "$self" in OpenAPI)
255 17911         3752501 $self->_set_canonical_uri($state->{initial_schema_uri});
256 17911         1158043 $self->_set_metaschema_uri($state->{metaschema_uri});
257              
258 17911         1864731 $self->_add_entity_location($_, 'schema') foreach $state->{subschemas}->@*;
259              
260 17911         262689 return $state;
261             }
262              
263 3     3 1 198 sub validate ($class, @args) {
  3         5  
  3         5  
  3         4  
264 3 50       13 croak 'bad argument list' if blessed($args[0]);
265              
266 3         13 my $args = $class->Moo::Object::BUILDARGS(@args);
267 3 50       20 my $document = blessed($class) ? $class : $class->new($args);
268              
269 3         10 my $doc_result = JSON::Schema::Modern::Result->new(errors => [ $document->errors ]);
270              
271             # ideally, the traverse phase run during document construction should have found all errors that a
272             # simple metaschema evaluation would reveal, but we'll do both just to make sure.
273 3   66     24 my $evaluator = $args->{evaluator} // JSON::Schema::Modern->new(validate_formats => 1);
274 3         55 my $eval_result = $evaluator->evaluate($document->schema, $document->metaschema_uri);
275              
276 3 100       10 if (my ($missing_resource) = grep $_->error =~ /EXCEPTION: unable to find resource/, $eval_result->errors) {
277 1         15 $missing_resource->{error} .= ' (did you forget to provide "evaluator" to ->validate?)';
278             }
279              
280 3         31 return $doc_result & $eval_result;
281             }
282              
283             # callback hook for Sereal::Encoder
284 7     7 0 37 sub FREEZE ($self, $serializer) { +{ %$self } }
  7         11  
  7         31  
  7         9  
  7         315  
285              
286             # callback hook for Sereal::Decoder
287 10     10 0 533603 sub THAW ($class, $serializer, $data) {
  10         14  
  10         12  
  10         11  
  10         12  
288 10         20 delete $data->{evaluator};
289              
290 10         13 my $self = bless($data, $class);
291              
292 10         16 foreach my $attr (qw(schema _entities)) {
293             croak "serialization missing attribute '$attr' on document for identifier '$self->{canonical_uri}': perhaps your serialized data was produced for an older version of $class?"
294 20 50       55 if not exists $self->{$attr};
295             }
296 10         54 return $self;
297             }
298              
299             1;
300              
301             __END__