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   368 use strict;
  45         103  
  45         1926  
2 45     45   247 use warnings;
  45         97  
  45         4000  
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.632';
8              
9 45     45   882 use 5.020;
  45         175  
10 45     45   267 use Moo;
  45         96  
  45         316  
11 45     45   20340 use strictures 2;
  45         505  
  45         2088  
12 45     45   23686 use stable 0.031 'postderef';
  45         984  
  45         476  
13 45     45   10617 use experimental 'signatures';
  45         148  
  45         254  
14 45     45   3257 no autovivification warn => qw(fetch store exists delete);
  45         111  
  45         435  
15 45     45   4190 use if "$]" >= 5.022, experimental => 're_strict';
  45         128  
  45         1342  
16 45     45   4561 no if "$]" >= 5.031009, feature => 'indirect';
  45         122  
  45         3876  
17 45     45   368 no if "$]" >= 5.033001, feature => 'multidimensional';
  45         139  
  45         3298  
18 45     45   319 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  45         90  
  45         3310  
19 45     45   293 no if "$]" >= 5.041009, feature => 'smartmatch';
  45         116  
  45         2445  
20 45     45   268 no feature 'switch';
  45         128  
  45         1866  
21 45     45   304 use Mojo::URL;
  45         107  
  45         565  
22 45     45   2081 use Carp 'croak';
  45         121  
  45         4621  
23 45     45   336 use List::Util 1.29 'pairs';
  45         1081  
  45         4094  
24 45     45   333 use builtin::compat qw(refaddr blessed);
  45         109  
  45         560  
25 45     45   9400 use Safe::Isa 1.000008;
  45         1552  
  45         7441  
26 45     45   330 use MooX::TypeTiny;
  45         111  
  45         402  
27 45     45   44579 use Types::Standard 1.016003 qw(InstanceOf HashRef Str Map Dict ArrayRef Enum ClassName Undef Slurpy Optional Bool);
  45         868  
  45         502  
28 45     45   161099 use Types::Common::Numeric 'PositiveOrZeroInt';
  45         120  
  45         390  
29 45     45   43516 use JSON::Schema::Modern::Utilities qw(json_pointer_type canonical_uri_type E);
  45         111  
  45         3273  
30 45     45   317 use namespace::clean;
  45         95  
  45         291  
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 123134 sub resource_index { $_[0]->{resource_index}->%* }
85 18112     18112 0 310017 sub resource_pairs { pairs $_[0]->{resource_index}->%* }
86 3113     3113   518667 sub _get_resource { $_[0]->{resource_index}{$_[1]} }
87 0     0   0 sub _canonical_resources { values $_[0]->{resource_index}->%* }
88             sub _add_resource {
89 18640 100   18640   3282483 croak 'uri "'.$_[1].'" conflicts with an existing schema resource' if $_[0]->{resource_index}{$_[1]};
90 18639         609689 $_[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 13204 sub errors { ($_[0]->{errors}//[])->@* }
109 18257   100 18257 0 186237 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 108566     108566   338874 sub __entities ($) { qw(schema) }
  108566         159172  
  108566         476528  
121 42086     42086   118742 sub __entity_type { Enum[$_[0]->__entities] }
122 42086     42086   4781849 sub __entity_index ($self, $entity) {
  42086         105121  
  42086         85860  
  42086         72172  
123 42086         122646 my @e = $self->__entities;
124 42086 50       164461 foreach my $i (0..$#e) { return $i if $e[$i] eq $entity; }
  42086         1386523  
125 0         0 return undef;
126             }
127              
128 42085     42085   516788 sub _add_entity_location ($self, $location, $entity) {
  42085         79202  
  42085         81973  
  42085         70279  
  42085         69207  
129 42085         115061 $self->__entity_type->($entity); # verify string
130 42085         62147665 $self->_entities->{$location} = $self->__entity_index($entity); # store integer-mapped value
131             }
132              
133 24448     24448 0 87862 sub get_entity_at_location ($self, $location) {
  24448         49671  
  24448         62907  
  24448         41403  
134 24448 100       830869 return '' if not exists $self->_entities->{$location};
135 24394   33     755665 ($self->__entities)[ $self->_entities->{$location} ] // croak "missing mapping for ", $self->_entities->{$location};
136             }
137              
138             # note: not sorted
139 1     1 0 3 sub get_entity_locations ($self, $entity) {
  1         3  
  1         2  
  1         2  
140 1         5 $self->__entity_type->($entity); # verify string
141 1         1710 my $index = $self->__entity_index($entity);
142 1         21 grep $self->{_entities}{$_} == $index, keys $self->{_entities}->%*;
143             }
144              
145             # shims for Mojo::JSON::Pointer
146 26791     26791 1 387360 sub data { shift->schema(@_) }
147 17899     17899 0 2015689 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 17898     17898 0 547650 sub BUILD ($self, $args) {
  17898         44313  
  17898         41517  
  17898         34452  
154             # note! not a clone! Please don't change canonical_uri in-place.
155 17898         423817 $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 17898 100 66     2566042 $args->{skip_ref_checks} ? $args->%{skip_ref_checks} : (),
    100          
164             },
165             );
166              
167 17898 100       139372 if ($state->{errors}->@*) {
168 149         3839 $self->_set_errors($state->{errors});
169 149         5066 return;
170             }
171              
172 17749         39114 my $seen_root;
173 17749         94294 foreach my $key (keys $state->{identifiers}->%*) {
174 2077         9198 my $value = $state->{identifiers}{$key};
175 2077         9814 $self->_add_resource($key => $value);
176              
177             # we're adding a non-anchor entry for the document root
178 2077 100       1107828 ++$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 17749 100       169542 $state->%{qw(specification_version vocabularies)},
187             })
188             if not $seen_root;
189              
190 17748   100     8129394 foreach my $ref (($state->{references}//[])->@*) {
191 3112         14678 my ($keyword, $path_location, $abs_target, $expected_entity) = @$ref;
192              
193             # look for resource locally; fall back to the evaluator's index
194 3112         17805 my $resource = $self->_get_resource(my $uri = $abs_target->clone->fragment(undef));
195 3112         654084 my $document = $self;
196              
197 3112 100       11522 if (not $resource) {
198 521 50       6022 $resource = $args->{evaluator}->_get_resource($uri) if $args->{evaluator};
199 521 100       108455 next if not $resource;
200 352         1947 $document = $resource->{document};
201             }
202              
203 2943         8942 my $fragment = $abs_target->fragment;
204 2943         14218 my $target_path;
205 2943 100 100     24960 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     28042 if not $document->contains($target_path = $resource->{path}.($fragment//''));
209             }
210             elsif (my $subresource = ($resource->{anchors}//{})->{$fragment}) {
211 311         1256 $target_path = $subresource->{path};
212             }
213             else {
214 4         70 ()= E({ %$state, keyword_path => $path_location, keyword => $keyword },
215             '%s target "%s" is a non-existent location', $keyword, $abs_target);
216 4         59 next;
217             }
218              
219 2935         87604 my $entity = $document->get_entity_at_location($target_path);
220 2935 100       11210 ()= 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       37359 ()= 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 17748 100       340110 $self->_set_errors($state->{errors}) if $state->{errors}->@*;
231             }
232              
233             # a subclass's method will override this one
234 17897     17897 0 45025 sub traverse ($self, $evaluator, $config_override = {}) {
  17897         42636  
  17897         39956  
  17897         41228  
  17897         39273  
235             die 'wrong class - use JSON::Schema::Modern::Document::OpenAPI instead'
236 17897 50 66     205710 if ref $self->schema eq 'HASH' and exists $self->schema->{openapi};
237              
238 17897         59479 my $original_uri = $self->original_uri;
239              
240 17897 100       192627 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 17897 50 33     170807 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 17897         5442579 $self->_set_canonical_uri($state->{initial_schema_uri});
256 17897         1775886 $self->_set_metaschema_uri($state->{metaschema_uri});
257              
258 17897         2805990 $self->_add_entity_location($_, 'schema') foreach $state->{subschemas}->@*;
259              
260 17897         388392 return $state;
261             }
262              
263 3     3 1 7749 sub validate ($class, @args) {
  3         9  
  3         10  
  3         7  
264 3 50       18 croak 'bad argument list' if blessed($args[0]);
265              
266 3         23 my $args = $class->Moo::Object::BUILDARGS(@args);
267 3 50       35 my $document = blessed($class) ? $class : $class->new($args);
268              
269 3         13 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     42 my $evaluator = $args->{evaluator} // JSON::Schema::Modern->new(validate_formats => 1);
274 3         86 my $eval_result = $evaluator->evaluate($document->schema, $document->metaschema_uri);
275              
276 3 100       18 if (my ($missing_resource) = grep $_->error =~ /EXCEPTION: unable to find resource/, $eval_result->errors) {
277 1         47 $missing_resource->{error} .= ' (did you forget to provide "evaluator" to ->validate?)';
278             }
279              
280 3         63 return $doc_result & $eval_result;
281             }
282              
283             # callback hook for Sereal::Encoder
284 7     7 0 36 sub FREEZE ($self, $serializer) { +{ %$self } }
  7         11  
  7         13  
  7         11  
  7         305  
285              
286             # callback hook for Sereal::Decoder
287 10     10 0 972452 sub THAW ($class, $serializer, $data) {
  10         17  
  10         17  
  10         17  
  10         15  
288 10         29 delete $data->{evaluator};
289              
290 10         20 my $self = bless($data, $class);
291              
292 10         22 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       73 if not exists $self->{$attr};
295             }
296 10         75 return $self;
297             }
298              
299             1;
300              
301             __END__