File Coverage

blib/lib/JSON/Schema/Modern/Document.pm
Criterion Covered Total %
statement 183 186 98.3
branch 40 48 83.3
condition 20 28 71.4
subroutine 42 44 95.4
pod 5 14 35.7
total 290 320 90.6


line stmt bran cond sub pod time code
1 46     46   279 use strict;
  46         78  
  46         1554  
2 46     46   171 use warnings;
  46         74  
  46         2853  
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.641';
8              
9 46     46   656 use 5.020;
  46         136  
10 46     46   165 use Moo;
  46         96  
  46         307  
11 46     46   14313 use strictures 2;
  46         359  
  46         1572  
12 46     46   16381 use stable 0.031 'postderef';
  46         704  
  46         308  
13 46     46   7934 use experimental 'signatures';
  46         104  
  46         160  
14 46     46   2248 no autovivification warn => qw(fetch store exists delete);
  46         116  
  46         314  
15 46     46   3033 use if "$]" >= 5.022, experimental => 're_strict';
  46         76  
  46         1051  
16 46     46   3151 no if "$]" >= 5.031009, feature => 'indirect';
  46         100  
  46         2634  
17 46     46   200 no if "$]" >= 5.033001, feature => 'multidimensional';
  46         79  
  46         2012  
18 46     46   196 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  46         72  
  46         2087  
19 46     46   201 no if "$]" >= 5.041009, feature => 'smartmatch';
  46         111  
  46         1587  
20 46     46   196 no feature 'switch';
  46         92  
  46         1020  
21 46     46   176 use Mojo::URL;
  46         94  
  46         481  
22 46     46   1228 use Carp 'croak';
  46         85  
  46         3018  
23 46     46   279 use List::Util 1.29 'pairs';
  46         853  
  46         2899  
24 46     46   214 use builtin::compat qw(refaddr blessed);
  46         98  
  46         475  
25 46     46   6870 use MooX::TypeTiny;
  46         79  
  46         322  
26 46     46   31792 use Types::Standard 1.016003 qw(InstanceOf HashRef Str Map Dict ArrayRef Enum ClassName Undef Slurpy Optional Bool);
  46         720  
  46         384  
27 46     46   107757 use Types::Common::Numeric 'PositiveOrZeroInt';
  46         110  
  46         331  
28 46     46   32792 use JSON::Schema::Modern::Utilities qw(json_pointer_type canonical_uri_type E);
  46         84  
  46         2653  
29 46     46   207 use namespace::clean;
  46         73  
  46         200  
30              
31             extends 'Mojo::JSON::Pointer';
32              
33             has schema => (
34             is => 'ro',
35             required => 1,
36             );
37              
38             has canonical_uri => (
39             is => 'rwp',
40             isa => (InstanceOf['Mojo::URL'])->where(q{not defined $_->fragment}),
41             lazy => 1,
42             default => sub { Mojo::URL->new },
43             coerce => sub { blessed($_[0]) && $_[0]->isa('Mojo::URL') ? $_[0] : Mojo::URL->new($_[0]) },
44             );
45              
46             # this is also known as the retrieval uri in the OpenAPI specification
47             has original_uri => (
48             is => 'rwp',
49             isa => (InstanceOf['Mojo::URL'])->where(q{not defined $_->fragment}),
50             init_arg => undef,
51             );
52             *retrieval_uri = \&original_uri;
53              
54             has metaschema_uri => (
55             is => 'rwp',
56             isa => InstanceOf['Mojo::URL'],
57             coerce => sub { blessed($_[0]) && $_[0]->isa('Mojo::URL') ? $_[0] : Mojo::URL->new($_[0]) },
58             predicate => '_has_metaschema_uri',
59             # default not defined here, but might be defined in a subclass
60             );
61              
62             # "A JSON Schema resource is a schema which is canonically identified by an absolute URI."
63             # https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.4.3.5
64             has resource_index => (
65             is => 'bare',
66             isa => Map[my $resource_key_type = Str->where('!/#/'), my $resource_type = Dict[
67             canonical_uri => (InstanceOf['Mojo::URL'])->where(q{not defined $_->fragment}),
68             path => json_pointer_type, # JSON pointer relative to the document root
69             specification_version => Enum[qw(draft4 draft6 draft7 draft2019-09 draft2020-12)],
70             # the vocabularies used when evaluating instance data against schema
71             vocabularies => ArrayRef[ClassName->where(q{$_->DOES('JSON::Schema::Modern::Vocabulary')})],
72             anchors => Optional[HashRef[Dict[
73             canonical_uri => canonical_uri_type, # equivalent uri with json pointer fragment
74             path => json_pointer_type, # JSON pointer relative to the document root
75             dynamic => Optional[Bool],
76             ]]],
77             Slurpy[HashRef[Undef]], # no other fields allowed
78             ]],
79             init_arg => undef,
80             default => sub { {} },
81             );
82              
83 52     52 1 76786 sub resource_index { $_[0]->{resource_index}->%* }
84 18126     18126 0 196532 sub resource_pairs { pairs $_[0]->{resource_index}->%* }
85 3113     3113   327959 sub _get_resource { $_[0]->{resource_index}{$_[1]} }
86 0     0   0 sub _canonical_resources { values $_[0]->{resource_index}->%* }
87             sub _add_resource {
88 18654 100   18654   2229727 croak 'uri "'.$_[1].'" conflicts with an existing schema resource' if $_[0]->{resource_index}{$_[1]};
89 18653         409818 $_[0]->{resource_index}{$resource_key_type->($_[1])} = $resource_type->($_[2]);
90             }
91              
92             # for internal use only
93             has _checksum => (
94             is => 'rw',
95             isa => Str,
96             init_arg => undef,
97             );
98              
99             has errors => (
100             is => 'bare',
101             writer => '_set_errors',
102             isa => ArrayRef[InstanceOf['JSON::Schema::Modern::Error']],
103             lazy => 1,
104             default => sub { [] },
105             );
106              
107 200   100 200 1 16456 sub errors { ($_[0]->{errors}//[])->@* }
108 18271   100 18271 0 108423 sub has_errors { scalar(($_[0]->{errors}//[])->@*) }
109              
110             # json pointer => entity name (indexed by integer)
111             has _entities => (
112             is => 'ro',
113             isa => Map[json_pointer_type, PositiveOrZeroInt],
114             lazy => 1,
115             default => sub { {} },
116             );
117              
118             # in this class, the only entity type is 'schema', but subclasses add more
119 108626     108626   221778 sub __entities ($) { qw(schema) }
  108626         114195  
  108626         327898  
120 42108     42108   76089 sub __entity_type { Enum[$_[0]->__entities] }
121 42108     42108   3286161 sub __entity_index ($self, $entity) {
  42108         70746  
  42108         61667  
  42108         53434  
122 42108         85448 my @e = $self->__entities;
123 42108 50       120723 foreach my $i (0..$#e) { return $i if $e[$i] eq $entity; }
  42108         899270  
124 0         0 return undef;
125             }
126              
127 42107     42107   355862 sub _add_entity_location ($self, $location, $entity) {
  42107         54913  
  42107         56249  
  42107         53182  
  42107         49366  
128 42107         79727 $self->__entity_type->($entity); # verify string
129 42107         42187712 $self->_entities->{$location} = $self->__entity_index($entity); # store integer-mapped value
130             }
131              
132 24464     24464 0 52139 sub get_entity_at_location ($self, $location) {
  24464         34125  
  24464         36122  
  24464         28694  
133 24464 100       499099 return '' if not exists $self->_entities->{$location};
134 24410   33     438512 ($self->__entities)[ $self->_entities->{$location} ] // croak "missing mapping for ", $self->_entities->{$location};
135             }
136              
137             # note: not sorted
138 1     1 0 2 sub get_entity_locations ($self, $entity) {
  1         2  
  1         2  
  1         1  
139 1         3 $self->__entity_type->($entity); # verify string
140 1         1034 my $index = $self->__entity_index($entity);
141 1         17 grep $self->{_entities}{$_} == $index, keys $self->{_entities}->%*;
142             }
143              
144             # shims for Mojo::JSON::Pointer
145 26807     26807 1 256622 sub data { shift->schema(@_) }
146 17913     17913 0 1284501 sub FOREIGNBUILDARGS { () }
147              
148             # for JSON serializers
149 0     0 1 0 sub TO_JSON { shift->schema }
150              
151             # note that this is always called, even in subclasses
152 17912     17912 0 360877 sub BUILD ($self, $args) {
  17912         29204  
  17912         25660  
  17912         22582  
153             # note! not a clone! Please don't change canonical_uri in-place.
154 17912         248808 $self->_set_original_uri($self->canonical_uri);
155              
156             # this should extract all identifiers, references, and entities, and set canonical_uri,
157             # metaschema_uri
158             my $state = $self->traverse(
159             $args->{evaluator} // JSON::Schema::Modern->new,
160             {
161             $args->{specification_version} ? $args->%{specification_version} : (),
162 17912 100 66     1204599 $args->{skip_ref_checks} ? $args->%{skip_ref_checks} : (),
    100          
163             },
164             );
165              
166 17912 100       88647 if ($state->{errors}->@*) {
167 149         2394 $self->_set_errors($state->{errors});
168 149         3150 return;
169             }
170              
171 17763         26403 my $seen_root;
172 17763         54344 foreach my $key (keys $state->{identifiers}->%*) {
173 2077         5892 my $value = $state->{identifiers}{$key};
174 2077         6802 $self->_add_resource($key => $value);
175              
176             # we're adding a non-anchor entry for the document root
177 2077 100       669772 ++$seen_root if $value->{path} eq '';
178             }
179              
180             # we only index the original uri if nothing in the schema itself identified a root resource:
181             # otherwise the top of the document would be unreferenceable.
182             $self->_add_resource($self->original_uri.'' => {
183             path => '',
184             canonical_uri => $self->canonical_uri,
185 17763 100       118481 $state->%{qw(specification_version vocabularies)},
186             })
187             if not $seen_root;
188              
189 17762   100     4928574 foreach my $ref (($state->{references}//[])->@*) {
190 3112         9538 my ($keyword, $path_location, $abs_target, $expected_entity) = @$ref;
191              
192             # look for resource locally; fall back to the evaluator's index
193 3112         10848 my $resource = $self->_get_resource(my $uri = $abs_target->clone->fragment(undef));
194 3112         415730 my $document = $self;
195              
196 3112 100       7883 if (not $resource) {
197 521 50       3900 $resource = $args->{evaluator}->_get_resource($uri) if $args->{evaluator};
198 521 100       66120 next if not $resource;
199 352         1226 $document = $resource->{document};
200             }
201              
202 2943         6180 my $fragment = $abs_target->fragment;
203 2943         9482 my $target_path;
204 2943 100 100     17485 if (not length $fragment or $fragment =~ m{^/}) {
    100 50        
205             ()= E({ %$state, keyword_path => $path_location, keyword => $keyword },
206             '%s target "%s" is a non-existent location', $keyword, $abs_target), next
207 2628 100 100     20108 if not $document->contains($target_path = $resource->{path}.($fragment//''));
208             }
209             elsif (my $subresource = ($resource->{anchors}//{})->{$fragment}) {
210 311         833 $target_path = $subresource->{path};
211             }
212             else {
213 4         44 ()= E({ %$state, keyword_path => $path_location, keyword => $keyword },
214             '%s target "%s" is a non-existent location', $keyword, $abs_target);
215 4         35 next;
216             }
217              
218 2935         59861 my $entity = $document->get_entity_at_location($target_path);
219 2935 100       7240 ()= E({ %$state, keyword_path => $path_location, keyword => $keyword },
220             '%s target "%s" is not a referenceable location', $keyword, $abs_target), next
221             if not $entity;
222              
223 2889 50       24190 ()= E({ %$state, keyword_path => $path_location, keyword => $keyword },
224             '%s target "%s" is the wrong object type (%s, expecting %s)',
225             $keyword, $abs_target, $entity, $expected_entity), next
226             if $entity ne $expected_entity;
227             }
228              
229 17762 100       238010 $self->_set_errors($state->{errors}) if $state->{errors}->@*;
230             }
231              
232             # a subclass's method will override this one
233 17911     17911 0 28958 sub traverse ($self, $evaluator, $config_override = {}) {
  17911         27029  
  17911         23699  
  17911         29057  
  17911         22762  
234             die 'wrong class - use JSON::Schema::Modern::Document::OpenAPI instead'
235 17911 50 66     116110 if ref $self->schema eq 'HASH' and exists $self->schema->{openapi};
236              
237 17911         38521 my $original_uri = $self->original_uri;
238              
239 17911 100       118333 my $state = $evaluator->traverse($self->schema,
240             {
241             initial_schema_uri => $original_uri,
242             $self->_has_metaschema_uri ? (metaschema_uri => $self->metaschema_uri) : (),
243             %$config_override,
244             }
245             );
246              
247 17911 50 33     115791 die 'original_uri has changed' if $self->original_uri ne $original_uri
248             or refaddr($self->original_uri) != refaddr($original_uri);
249              
250             # if the document identified a canonical uri for itself via '$id', or metaschema uri via '$schema',
251             # they overrides the initial values
252             # Note that subclasses of this class may choose to identify these values in a different way
253             # (e.g. "$self" in OpenAPI)
254 17911         3601395 $self->_set_canonical_uri($state->{initial_schema_uri});
255 17911         710522 $self->_set_metaschema_uri($state->{metaschema_uri});
256              
257 17911         1658584 $self->_add_entity_location($_, 'schema') foreach $state->{subschemas}->@*;
258              
259 17911         260659 return $state;
260             }
261              
262 3     3 1 189 sub validate ($class, @args) {
  3         5  
  3         7  
  3         4  
263 3 50       11 croak 'bad argument list' if blessed($args[0]);
264              
265 3         15 my $args = $class->Moo::Object::BUILDARGS(@args);
266 3 50       22 my $document = blessed($class) ? $class : $class->new($args);
267              
268 3         10 my $doc_result = JSON::Schema::Modern::Result->new(errors => [ $document->errors ]);
269              
270             # ideally, the traverse phase run during document construction should have found all errors that a
271             # simple metaschema evaluation would reveal, but we'll do both just to make sure.
272 3   66     27 my $evaluator = $args->{evaluator} // JSON::Schema::Modern->new(validate_formats => 1);
273 3         59 my $eval_result = $evaluator->evaluate($document->schema, $document->metaschema_uri);
274              
275 3 100       13 if (my ($missing_resource) = grep $_->error =~ /EXCEPTION: unable to find resource/, $eval_result->errors) {
276 1         30 $missing_resource->{error} .= ' (did you forget to provide "evaluator" to ->validate?)';
277             }
278              
279 3         35 return $doc_result & $eval_result;
280             }
281              
282             # callback hook for Sereal::Encoder
283 7     7 0 39 sub FREEZE ($self, $serializer) { +{ %$self } }
  7         9  
  7         12  
  7         9  
  7         292  
284              
285             # callback hook for Sereal::Decoder
286 10     10 0 471720 sub THAW ($class, $serializer, $data) {
  10         36  
  10         13  
  10         11  
  10         12  
287 10         8892 delete $data->{evaluator};
288              
289 10         25 my $self = bless($data, $class);
290              
291 10         16 foreach my $attr (qw(schema _entities)) {
292             croak "serialization missing attribute '$attr' on document for identifier '$self->{canonical_uri}': perhaps your serialized data was produced for an older version of $class?"
293 20 50       73 if not exists $self->{$attr};
294             }
295 10         50 return $self;
296             }
297              
298             1;
299              
300             __END__