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   234 use strict;
  45         77  
  45         1477  
2 45     45   157 use warnings;
  45         75  
  45         2782  
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.635';
8              
9 45     45   583 use 5.020;
  45         124  
10 45     45   170 use Moo;
  45         131  
  45         281  
11 45     45   13816 use strictures 2;
  45         377  
  45         1641  
12 45     45   15528 use stable 0.031 'postderef';
  45         712  
  45         287  
13 45     45   7532 use experimental 'signatures';
  45         102  
  45         141  
14 45     45   2107 no autovivification warn => qw(fetch store exists delete);
  45         77  
  45         325  
15 45     45   3013 use if "$]" >= 5.022, experimental => 're_strict';
  45         98  
  45         1057  
16 45     45   3131 no if "$]" >= 5.031009, feature => 'indirect';
  45         115  
  45         2762  
17 45     45   197 no if "$]" >= 5.033001, feature => 'multidimensional';
  45         94  
  45         2041  
18 45     45   184 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  45         95  
  45         2312  
19 45     45   235 no if "$]" >= 5.041009, feature => 'smartmatch';
  45         107  
  45         1743  
20 45     45   208 no feature 'switch';
  45         132  
  45         1223  
21 45     45   219 use Mojo::URL;
  45         90  
  45         429  
22 45     45   1295 use Carp 'croak';
  45         66  
  45         2817  
23 45     45   254 use List::Util 1.29 'pairs';
  45         1053  
  45         3054  
24 45     45   248 use builtin::compat qw(refaddr blessed);
  45         70  
  45         404  
25 45     45   6861 use Safe::Isa 1.000008;
  45         944  
  45         5743  
26 45     45   248 use MooX::TypeTiny;
  45         82  
  45         355  
27 45     45   31826 use Types::Standard 1.016003 qw(InstanceOf HashRef Str Map Dict ArrayRef Enum ClassName Undef Slurpy Optional Bool);
  45         675  
  45         330  
28 45     45   103513 use Types::Common::Numeric 'PositiveOrZeroInt';
  45         108  
  45         306  
29 45     45   31088 use JSON::Schema::Modern::Utilities qw(json_pointer_type canonical_uri_type E);
  45         83  
  45         2506  
30 45     45   194 use namespace::clean;
  45         66  
  45         205  
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 73751 sub resource_index { $_[0]->{resource_index}->%* }
85 18126     18126 0 180595 sub resource_pairs { pairs $_[0]->{resource_index}->%* }
86 3113     3113   331496 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   2178481 croak 'uri "'.$_[1].'" conflicts with an existing schema resource' if $_[0]->{resource_index}{$_[1]};
90 18653         396299 $_[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 8416 sub errors { ($_[0]->{errors}//[])->@* }
109 18271   100 18271 0 112645 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   219075 sub __entities ($) { qw(schema) }
  108626         109151  
  108626         312552  
121 42108     42108   74932 sub __entity_type { Enum[$_[0]->__entities] }
122 42108     42108   3204196 sub __entity_index ($self, $entity) {
  42108         74645  
  42108         60856  
  42108         52026  
123 42108         87970 my @e = $self->__entities;
124 42108 50       116857 foreach my $i (0..$#e) { return $i if $e[$i] eq $entity; }
  42108         881154  
125 0         0 return undef;
126             }
127              
128 42107     42107   349110 sub _add_entity_location ($self, $location, $entity) {
  42107         54075  
  42107         55220  
  42107         51228  
  42107         45340  
129 42107         84312 $self->__entity_type->($entity); # verify string
130 42107         40231031 $self->_entities->{$location} = $self->__entity_index($entity); # store integer-mapped value
131             }
132              
133 24464     24464 0 50130 sub get_entity_at_location ($self, $location) {
  24464         30697  
  24464         38800  
  24464         26524  
134 24464 100       537689 return '' if not exists $self->_entities->{$location};
135 24410   33     455864 ($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         3  
  1         1  
140 1         16 $self->__entity_type->($entity); # verify string
141 1         1292 my $index = $self->__entity_index($entity);
142 1         17 grep $self->{_entities}{$_} == $index, keys $self->{_entities}->%*;
143             }
144              
145             # shims for Mojo::JSON::Pointer
146 26807     26807 1 244183 sub data { shift->schema(@_) }
147 17913     17913 0 1341742 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 347324 sub BUILD ($self, $args) {
  17912         27273  
  17912         23493  
  17912         22181  
154             # note! not a clone! Please don't change canonical_uri in-place.
155 17912         251258 $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     1582601 $args->{skip_ref_checks} ? $args->%{skip_ref_checks} : (),
    100          
164             },
165             );
166              
167 17912 100       85221 if ($state->{errors}->@*) {
168 149         2313 $self->_set_errors($state->{errors});
169 149         3274 return;
170             }
171              
172 17763         27131 my $seen_root;
173 17763         56156 foreach my $key (keys $state->{identifiers}->%*) {
174 2077         5990 my $value = $state->{identifiers}{$key};
175 2077         6619 $self->_add_resource($key => $value);
176              
177             # we're adding a non-anchor entry for the document root
178 2077 100       680251 ++$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       114787 $state->%{qw(specification_version vocabularies)},
187             })
188             if not $seen_root;
189              
190 17762   100     4835758 foreach my $ref (($state->{references}//[])->@*) {
191 3112         8780 my ($keyword, $path_location, $abs_target, $expected_entity) = @$ref;
192              
193             # look for resource locally; fall back to the evaluator's index
194 3112         10961 my $resource = $self->_get_resource(my $uri = $abs_target->clone->fragment(undef));
195 3112         420876 my $document = $self;
196              
197 3112 100       8510 if (not $resource) {
198 521 50       3737 $resource = $args->{evaluator}->_get_resource($uri) if $args->{evaluator};
199 521 100       69110 next if not $resource;
200 352         1336 $document = $resource->{document};
201             }
202              
203 2943         6325 my $fragment = $abs_target->fragment;
204 2943         9731 my $target_path;
205 2943 100 100     16372 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     18862 if not $document->contains($target_path = $resource->{path}.($fragment//''));
209             }
210             elsif (my $subresource = ($resource->{anchors}//{})->{$fragment}) {
211 311         900 $target_path = $subresource->{path};
212             }
213             else {
214 4         42 ()= 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         57668 my $entity = $document->get_entity_at_location($target_path);
220 2935 100       7011 ()= 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       24661 ()= 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       214225 $self->_set_errors($state->{errors}) if $state->{errors}->@*;
231             }
232              
233             # a subclass's method will override this one
234 17911     17911 0 27873 sub traverse ($self, $evaluator, $config_override = {}) {
  17911         24689  
  17911         23040  
  17911         28042  
  17911         24574  
235             die 'wrong class - use JSON::Schema::Modern::Document::OpenAPI instead'
236 17911 50 66     116449 if ref $self->schema eq 'HASH' and exists $self->schema->{openapi};
237              
238 17911         36729 my $original_uri = $self->original_uri;
239              
240 17911 100       115238 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     118733 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         3600922 $self->_set_canonical_uri($state->{initial_schema_uri});
256 17911         1076133 $self->_set_metaschema_uri($state->{metaschema_uri});
257              
258 17911         1768838 $self->_add_entity_location($_, 'schema') foreach $state->{subschemas}->@*;
259              
260 17911         252683 return $state;
261             }
262              
263 3     3 1 204 sub validate ($class, @args) {
  3         5  
  3         6  
  3         5  
264 3 50       10 croak 'bad argument list' if blessed($args[0]);
265              
266 3         13 my $args = $class->Moo::Object::BUILDARGS(@args);
267 3 50       24 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     28 my $evaluator = $args->{evaluator} // JSON::Schema::Modern->new(validate_formats => 1);
274 3         69 my $eval_result = $evaluator->evaluate($document->schema, $document->metaschema_uri);
275              
276 3 100       13 if (my ($missing_resource) = grep $_->error =~ /EXCEPTION: unable to find resource/, $eval_result->errors) {
277 1         17 $missing_resource->{error} .= ' (did you forget to provide "evaluator" to ->validate?)';
278             }
279              
280 3         32 return $doc_result & $eval_result;
281             }
282              
283             # callback hook for Sereal::Encoder
284 7     7 0 33 sub FREEZE ($self, $serializer) { +{ %$self } }
  7         8  
  7         10  
  7         8  
  7         232  
285              
286             # callback hook for Sereal::Decoder
287 10     10 0 469621 sub THAW ($class, $serializer, $data) {
  10         26  
  10         12  
  10         12  
  10         9  
288 10         26 delete $data->{evaluator};
289              
290 10         16 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       72 if not exists $self->{$attr};
295             }
296 10         65 return $self;
297             }
298              
299             1;
300              
301             __END__