File Coverage

blib/lib/JSON/Schema/Modern/Document/OpenAPI.pm
Criterion Covered Total %
statement 354 370 95.6
branch 182 206 88.3
condition 114 143 79.7
subroutine 41 43 95.3
pod 7 10 70.0
total 698 772 90.4


line stmt bran cond sub pod time code
1 17     17   9232705 use strictures 2;
  17         161  
  17         745  
2             package JSON::Schema::Modern::Document::OpenAPI;
3             # vim: set ts=8 sts=2 sw=2 tw=100 et :
4             # ABSTRACT: One OpenAPI v3.0, v3.1 or v3.2 document
5             # KEYWORDS: JSON Schema data validation request response OpenAPI
6              
7             our $VERSION = '0.139';
8              
9 17     17   7318 use 5.020;
  17         62  
10 17     17   70 use utf8;
  17         27  
  17         117  
11 17     17   9624 use Moo;
  17         104439  
  17         80  
12 17     17   22351 use strictures 2;
  17         114  
  17         632  
13 17     17   7284 use stable 0.031 'postderef';
  17         306  
  17         145  
14 17     17   3217 use experimental 'signatures';
  17         29  
  17         45  
15 17     17   846 no autovivification warn => qw(fetch store exists delete);
  17         68  
  17         111  
16 17     17   1132 use if "$]" >= 5.022, experimental => 're_strict';
  17         28  
  17         405  
17 17     17   1247 no if "$]" >= 5.031009, feature => 'indirect';
  17         27  
  17         990  
18 17     17   66 no if "$]" >= 5.033001, feature => 'multidimensional';
  17         49  
  17         793  
19 17     17   81 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  17         29  
  17         750  
20 17     17   65 no if "$]" >= 5.041009, feature => 'smartmatch';
  17         45  
  17         609  
21 17     17   75 no feature 'switch';
  17         29  
  17         741  
22 17     17   10075 use JSON::Schema::Modern::Utilities 0.625 qw(E canonical_uri jsonp unjsonp is_equal json_pointer_type assert_keyword_type assert_uri_reference load_cached_document get_type);
  17         2286788  
  17         2544  
23 17     17   9465 use JSON::Schema::Modern::Result 0.630;
  17         2845565  
  17         763  
24 17     17   9102 use OpenAPI::Modern::Utilities qw(:constants add_vocab_and_default_schemas);
  17         57  
  17         3046  
25 17     17   112 use Carp qw(croak carp);
  17         27  
  17         847  
26 17     17   79 use Digest::MD5 'md5_hex';
  17         25  
  17         617  
27 17     17   65 use Clone 'clone';
  17         31  
  17         615  
28 17     17   62 use builtin::compat qw(blessed indexed);
  17         24  
  17         116  
29 17     17   2466 use MooX::TypeTiny 0.002002;
  17         363  
  17         133  
30 17     17   11927 use Types::Standard qw(HashRef ArrayRef Str Map Any);
  17         31  
  17         158  
31 17     17   45228 use namespace::clean;
  17         36  
  17         93  
32              
33             extends 'JSON::Schema::Modern::Document';
34              
35             our @CARP_NOT = qw(Sereal Sereal::Decoder JSON::Schema::Modern::Document);
36              
37             has '+schema' => (
38             isa => HashRef,
39             );
40              
41             # json pointer => entity name (indexed by integer); overrides parent
42             # these aren't all the different types of objects; for now we only track those that are the valid
43             # target of a $ref keyword in an openapi document.
44 8396     8396   5769883 sub __entities { qw(schema response parameter example request-body header security-scheme link callbacks path-item media-type) }
45              
46             # operationId => document path
47             has _operationIds => (
48             is => 'ro',
49             isa => HashRef[json_pointer_type],
50             lazy => 1,
51             default => sub { {} },
52             );
53              
54             *get_operationId_path = \&operationId_path; # deprecated
55              
56 169     169 1 2721 sub operationId_path { $_[0]->_operationIds->{$_[1]} }
57 82     82   349 sub _add_operationId { $_[0]->_operationIds->{$_[1]} = json_pointer_type->($_[2]) }
58              
59             # tag name => document path of tag object
60             has _tags => (
61             is => 'bare',
62             isa => HashRef[json_pointer_type],
63             lazy => 1,
64             default => sub { {} },
65             );
66              
67 5     5 1 1951 sub tag_path { $_[0]->{_tags}{$_[1]} }
68              
69             # tag name => document path of operation
70             has _operation_tags => (
71             is => 'bare',
72             isa => HashRef[ArrayRef[json_pointer_type]],
73             lazy => 1,
74             default => sub { {} },
75             );
76              
77 6   100 6 1 44 sub operations_with_tag { ($_[0]->{_operation_tags}{$_[1]}//[])->@* }
78              
79             # the minor.major version of the OpenAPI specification used for this document
80             has oas_version => (
81             is => 'rwp',
82             isa => Str->where(q{/^[1-9]\.(?:0|[1-9][0-9]*)\z/}),
83             );
84              
85             # list of /paths/* path templates, in canonical search order
86             has path_templates => (
87             is => 'rwp',
88             isa => ArrayRef[Str],
89             );
90              
91             has defaults => (
92             is => 'rwp',
93             isa => Map[json_pointer_type, Any],
94             lazy => 1,
95             default => sub { {} },
96             );
97              
98 1     1 1 2120 sub default { $_[0]->defaults->{$_[1]} }
99              
100             # we define the sub directly, rather than using an 'around', since our root base class is not
101             # Moo::Object, so we never got a BUILDARGS to modify
102 398     398 0 3390328 sub BUILDARGS ($class, @args) {
  398         805  
  398         1149  
  398         725  
103 398         2030 my $args = $class->Moo::Object::BUILDARGS(@args); # we do not inherit from Moo::Object
104              
105             carp 'json_schema_dialect has been removed as a constructor attribute: use jsonSchemaDialect in your document instead'
106 398 100       4352 if exists $args->{json_schema_dialect};
107              
108             carp 'specification_version argument is ignored by this subclass: use jsonSchemaDialect in your document instead'
109 398 100       1854 if defined(delete($args->{specification_version}));
110              
111 398         5337 return $args;
112             }
113              
114             # (probably) temporary, until the parent class evaluator is completely removed
115 0     0 1 0 sub evaluator { die 'improper attempt to use of document evaluator' }
116              
117             # called by this class's base class constructor, in order to validate the integrity of the document
118             # and identify all important details about this document, such as entity locations, referenceable
119             # identifiers, operationIds, etc.
120 396     396 0 131246 sub traverse ($self, $evaluator, $config_override = {}) {
  396         707  
  396         766  
  396         726  
  396         687  
121 396 50       1963 croak join(', ', sort keys %$config_override), ' not supported as a config override in traverse'
122             if keys %$config_override;
123              
124 396         4908 my $state = {
125             initial_schema_uri => $self->canonical_uri,
126             traversed_keyword_path => '',
127             keyword_path => '',
128             data_path => '',
129             errors => [],
130             evaluator => $evaluator,
131             identifiers => {},
132             # note that this is the JSON Schema specification version, not OpenAPI version
133             specification_version => $evaluator->SPECIFICATION_VERSION_DEFAULT,
134             vocabularies => [],
135             subschemas => [],
136             references => [],
137             depth => 0,
138             traverse => 1,
139             };
140              
141 396         15853 my $schema = $self->schema;
142              
143 396 100       2327 ()= E($state, 'missing openapi version'), return $state if not exists $schema->{openapi};
144             ()= E($state, 'bad openapi version: "%s"', $schema->{openapi}//''), return $state
145 395 100 100     4049 if ($schema->{openapi}//'') !~ /^\d+\.\d+\.\d+(-.+)?\z/a;
      100        
146              
147 393         2715 my @oad_version = split /[.-]/, $schema->{openapi};
148 393         7591 $self->_set_oas_version(join('.', @oad_version[0..1]));
149              
150             my ($max_supported) = grep {
151 393         7630 my @supported = split /\./;
  1179         2525  
152 1179 100       4990 $supported[0] == $oad_version[0] && $supported[1] == $oad_version[1]
153             } reverse SUPPORTED_OAD_VERSIONS->@*;
154              
155 393 100       1139 ()= E($state, 'unrecognized/unsupported openapi version: "%s"', $schema->{openapi}), return $state
156             if not defined $max_supported;
157             carp 'WARNING: your document was written for version ', $schema->{openapi},
158 389 100 66     10137 ' but this implementation has only been tested up to ', $max_supported,
159             ': this may be okay but you should upgrade your OpenAPI::Modern installation soon',"\n"
160             if defined $oad_version[2] and (split(/\./, $max_supported))[2] < $oad_version[2];
161              
162 389         2994 add_vocab_and_default_schemas($evaluator, $self->oas_version);
163              
164 389 100       761941 if (exists $schema->{'$self'}) {
165 8         72 my $state = { %$state, keyword => '$self', initial_schema_uri => Mojo::URL->new };
166              
167 8 100 66     202 if ($oad_version[0] == 3 and $oad_version[1] < 2) {
168 1         5 ()= E($state, 'additional property not permitted');
169 1         230 return $state;
170             }
171              
172             return $state
173             if not assert_keyword_type($state, $schema, 'string')
174             or not assert_uri_reference($state, $schema)
175 7 100 66     37 or not ($schema->{'$self'} !~ /#/ || E($state, '$self cannot contain a fragment'));
      66        
      66        
176             }
177              
178             # determine canonical uri using rules from v3.2.0 ยง4.1.2.2.1, "Establishing the Base URI"
179             $self->_set_canonical_uri($state->{initial_schema_uri} =
180 386   66     5413 Mojo::URL->new($schema->{'$self'}//())->to_abs($self->retrieval_uri));
181              
182             # /jsonSchemaDialect: https://spec.openapis.org/oas/latest#specifying-schema-dialects
183             {
184 386 100       129427 if (exists $schema->{jsonSchemaDialect}) {
  386         2040  
185 11         134 my $state = { %$state, keyword => 'jsonSchemaDialect' };
186 11 100 100     77 return $state
187             if not assert_keyword_type($state, $schema, 'string')
188             or not assert_uri_reference($state, $schema);
189             }
190              
191             # v3.2.0 ยง4.24.7, "Specifying Schema Dialects": "If [jsonSchemaDialect] is not set, then the OAS
192             # dialect schema id MUST be used for these Schema Objects."
193             # v3.2.0 ยง4.1.2.2, "Relative References in API Description URIs": "Unless specified otherwise,
194             # all fields that are URIs MAY be relative references as defined by RFC3986 Section 4.2."
195             my $json_schema_dialect = exists $schema->{jsonSchemaDialect}
196             ? Mojo::URL->new($schema->{jsonSchemaDialect})->to_abs($self->canonical_uri)
197 384 100       6094 : DEFAULT_DIALECT->{$self->oas_version};
198              
199             # continue to support the old strict dialect and metaschema which didn't have "3.1" in the $id
200 384 100       4602 if ($json_schema_dialect eq (STRICT_DIALECT->{3.1} =~ s{/3.1/}{/}r)) {
201 1         118 $json_schema_dialect =~ s{share/\K}{3.1/};
202 1         202 $schema->{jsonSchemaDialect} = $json_schema_dialect; # allow the 'const' check to pass
203             }
204             $self->_set_metaschema_uri($self->metaschema_uri =~ s{share/\K}{3.1/}r)
205 384 100 100     3137 if $self->_has_metaschema_uri and $self->metaschema_uri eq (STRICT_METASCHEMA->{3.1} =~ s{/3.1/}{/}r);
206              
207             # we used to always preload these, so we need to do it as needed for users who are using them
208             load_cached_document($evaluator, STRICT_DIALECT->{$self->oas_version})
209             if $self->_has_metaschema_uri and $self->metaschema_uri eq (STRICT_METASCHEMA->{$self->oas_version}//'')
210 384 100 50     5490 or $json_schema_dialect eq (STRICT_DIALECT->{$self->oas_version}//'');
      100        
      100        
      66        
211              
212 384 100 66     47286 if ($json_schema_dialect eq DEFAULT_DIALECT->{'3.0'}
213             or $json_schema_dialect eq (DEFAULT_DIALECT->{'3.0'} =~ s/\b\d{4}-\d{2}-\d{2}\b/latest/r)) {
214 24 50       90 croak '3.0 dialect with a non-3.0 OAD is not currently supported' if $self->oas_version ne '3.0';
215              
216 24         112 $evaluator->add_vocabulary('JSON::Schema::Modern::Vocabulary::OpenAPI_3_0');
217             $evaluator->_set_metaschema_vocabulary_classes($json_schema_dialect => [
218 24         245 $state->@{qw(specification_version vocabularies)} =
219             ('draft4', ['JSON::Schema::Modern::Vocabulary::OpenAPI_3_0'])
220             ]);
221             }
222             else {
223             # traverse an empty schema with this dialect uri to confirm it is valid, and add an entry in
224             # the evaluator's _metaschema_vocabulary_classes
225 360         7212 my $check_metaschema_state = $evaluator->traverse({}, {
226             metaschema_uri => $json_schema_dialect,
227             initial_schema_uri => $self->canonical_uri->clone->fragment('/jsonSchemaDialect'),
228             traversed_keyword_path => '/jsonSchemaDialect',
229             });
230              
231             # we cannot continue if the metaschema is invalid
232 360 100       158798 if ($check_metaschema_state->{errors}->@*) {
233 2         10 push $state->{errors}->@*, $check_metaschema_state->{errors}->@*;
234 2         22 return $state;
235             }
236              
237 358         3371 $state->@{qw(specification_version vocabularies)} = $check_metaschema_state->@{qw(specification_version vocabularies)};
238             }
239              
240             # subsequent '$schema' keywords can still override this
241 382         22097 $state->{json_schema_dialect} = $json_schema_dialect;
242              
243 382 100       9146 $self->_set_metaschema_uri(DEFAULT_METASCHEMA->{$self->oas_version})
244             if not $self->_has_metaschema_uri;
245              
246             load_cached_document($evaluator, STRICT_METASCHEMA->{$self->oas_version})
247 382 100 100     45221 if $self->_has_metaschema_uri and $self->metaschema_uri eq (STRICT_METASCHEMA->{$self->oas_version}//'');
      66        
248             }
249              
250             $state->{identifiers}{$state->{initial_schema_uri}} = {
251             path => '',
252             canonical_uri => $state->{initial_schema_uri},
253             specification_version => $state->{specification_version},
254             vocabularies => $state->{vocabularies}, # reference, not copy
255 382         91237 };
256              
257 382         51762 my $metaschema_doc;
258              
259             # evaluate the document against its metaschema to find any errors, to identify all schema
260             # resources within to add to the global resource index, and to extract all operationIds
261 382         840 my (@json_schema_paths, @operation_paths, %bad_path_item_refs, @server_paths, %tag_operation_paths, @bad_3_0_paths, @references);
262 784         1803 my $result = $evaluator->evaluate(
263             $schema, $self->metaschema_uri,
264             {
265             collect_annotations => 0,
266             validate_formats => 1,
267             callbacks => {
268             # we avoid producing errors here so we don't create extra errors for "not all additional
269             # properties are valid" etc
270 784     784   3818020 '$dynamicRef' => sub ($, $schema, $state) {
  784         1448  
  784         1313  
271             # Note that if we are using the default metaschema
272             # https://spec.openapis.org/oas//schema/, we will only find the root of each
273             # schema, not all subschemas. We will traverse each of these schemas later using
274             # jsonSchemaDialect to find all subschemas and their $ids.
275 784 50       4829 push @json_schema_paths, $state->{data_path} if $schema->{'$dynamicRef'} eq '#meta';
276 784         1784 return 1;
277             },
278 11680     11680   51943266 '$ref' => sub ($data, $schema, $state) {
  11680         20972  
  11680         15151  
  11680         13765  
  11680         12497  
279 11680         14108 my $entity;
280              
281 11680 100       47636 if ($self->oas_version eq '3.0') {
282             # strip '#/definitions/'; convert CamelCase to kebab-case
283 155 50       1606 if ($entity = lc join('-', split /(?=[A-Z])/, substr($schema->{'$ref'}, 14))) {
284 155 100       462 if ($entity eq 'schema') {
285             push @bad_3_0_paths, [ items => $state->{data_path} ]
286 33 100 100     232 if ($data->{type}//'') eq 'array' and not exists $data->{items};
      100        
287              
288             push @bad_3_0_paths, [ minimum => $state->{data_path} ]
289 33 100 100     190 if exists $data->{exclusiveMinimum} and not exists $data->{minimum};
290              
291             push @bad_3_0_paths, [ maximum => $state->{data_path} ]
292 33 100 100     146 if exists $data->{exclusiveMaximum} and not exists $data->{maximum};
293             }
294              
295             # "$ref" in path-item is not represented in the schema by a Reference object
296             push @references, [ '$ref', $state->{data_path}, Mojo::URL->new($data->{'$ref'})->to_abs($self->canonical_uri), 'path-item' ]
297 155 100 100     483 if $entity eq 'path-item' and exists $data->{'$ref'};
298              
299 155 100       1527 if ($entity eq 'reference') {
300 16   66     70 $metaschema_doc //= $evaluator->_get_resource($self->metaschema_uri)->{document};
301              
302             # in the 3.0 metaschema, entities are identified via:
303             # "oneOf": [ { "$ref": "#/definitions/Foo" }, { "$ref": "#/definitions/Reference" } ]
304 16   50     1286 my $schema_path = ($state->{initial_schema_uri}->fragment//'').$state->{keyword_path};
305 16 50       193 if ($schema_path =~ s{/oneOf/\K([01])\z}{$1 ^ 1}e) {
  16         94  
306 16         66 $entity = lc join('-', split /(?=[A-Z])/, substr($metaschema_doc->get($schema_path)->{'$ref'}, 14));
307 16 100       1056 $entity .= 's' if $entity eq 'callback';
308 16         94 push @references, [ '$ref', $state->{data_path}, Mojo::URL->new($data->{'$ref'})->to_abs($self->canonical_uri), $entity ];
309             }
310             }
311              
312 155 100       6540 $entity .= 's' if $entity eq 'callback';
313 155 100       458 undef $entity if not grep $entity eq $_, __entities;
314              
315             # no need to push to @json_schema_paths, as all schema entities are already found
316             # via $refs above, and there are no embedded identifiers to be identified
317             }
318             }
319             else {
320             # we only need to special-case path-item, because this is the only entity that is
321             # referenced in the schema without an -or-reference
322             ($entity) = (($schema->{'$ref'} =~ m{#/\$defs/([^/]+?)(?:-or-reference)\z}),
323 11525         53792 ($schema->{'$ref'} =~ m{#/\$defs/(path-item)\z}));
324              
325             push @references, [ '$ref', $state->{data_path}, Mojo::URL->new($data->{'$ref'})->to_abs($self->canonical_uri), 'path-item' ]
326 11525 100 100     50788 if ($entity//'') eq 'path-item' and exists $data->{'$ref'};
      100        
327              
328 11525 100       39015 if ($schema->{'$ref'} eq '#/$defs/reference') {
329 109         508 my ($e) = ($state->{initial_schema_uri}->fragment =~ m{/\$defs/([^/]+?)(?:-or-reference)\z});
330 109         1809 push @references, [ '$ref', $state->{data_path}, Mojo::URL->new($data->{'$ref'})->to_abs($self->canonical_uri), $e ];
331             }
332             }
333              
334 11680 100       67239 $self->_add_entity_location($state->{data_path}, $entity) if $entity;
335              
336 11680 100 100     135702 if ($schema->{'$ref'} eq '#/$defs/operation' or $schema->{'$ref'} eq '#/definitions/Operation') {
337             push @operation_paths, [ $data->{operationId} => $state->{data_path} ]
338 439 100       1919 if defined $data->{operationId};
339              
340 17     17   53652 { use autovivification 'store';
  17         23  
  17         108  
  439         791  
341             push $tag_operation_paths{$_}->@*, $state->{data_path}
342 439   100     2516 foreach ($data->{tags}//[])->@*;
343             }
344             }
345              
346             # path-items are weird and allow mixing of fields adjacent to a $ref, which is burdensome
347             # to properly support (see https://github.com/OAI/OpenAPI-Specification/issues/3734)
348 11680 100 100     29856 if ($entity and $entity eq 'path-item' and exists $data->{'$ref'}) {
      100        
349 31         141 my %path_item = $data->%*;
350 31         108 delete @path_item{qw(summary description $ref)};
351 31 100       144 $bad_path_item_refs{$state->{data_path}} = join(', ', sort keys %path_item) if keys %path_item;
352             }
353              
354 11680 100       24880 push @server_paths, $state->{data_path} if $schema->{'$ref'} eq '#/$defs/server';
355              
356 11680         23556 return 1;
357             },
358             },
359             },
360 382         9849 );
361              
362 382 100       504322 if (not $result->valid) {
363 13         343 foreach my $e ($result->errors) {
364 58 50 50     473 if (($e->keyword//'') eq 'not'
      66        
      66        
365             and $e->absolute_keyword_location->fragment eq '/$defs/parameters/not'
366             and $e->absolute_keyword_location->clone->fragment(undef) eq DEFAULT_METASCHEMA->{$self->oas_version}
367             ) {
368 1         469 push $state->{errors}->@*, $e->clone(
369             keyword_location => '',
370             absolute_keyword_location => undef,
371             error => 'cannot use query and querystring together',
372             );
373             }
374              
375 58         275 push $state->{errors}->@*, $e;
376             }
377              
378 13         167 return $state;
379             }
380              
381 369 100       9954 $self->_set_defaults($result->defaults) if $result->defaults;
382              
383 369         4181 foreach my $pair (@operation_paths) {
384 88         49939 my ($operation_id, $path) = @$pair;
385 88 100       361 if (my $existing = $self->operationId_path($operation_id)) {
386 6         84 ()= E({ %$state, keyword_path => $path .'/operationId' },
387             'duplicate of operationId at %s', $existing);
388             }
389             else {
390 82         1161 $self->_add_operationId($operation_id => $path);
391             }
392             }
393              
394             ()= E({ %$state, keyword_path => $_->[1] },
395             $_->[0] eq 'items' ? '"items" must be present if type is "array"'
396             : $_->[0] eq 'minimum' ? '"minimum" must be present when "exclusiveMinimum" is used'
397             : $_->[0] eq 'maximum' ? '"maximum" must be present when "exclusiveMaximum" is used'
398             : die
399 369 50       47220 ) foreach @bad_3_0_paths;
    100          
    100          
400              
401             # v3.2.0 ยง4.8.1, "Patterned Fields": "When matching URLs, concrete (non-templated) paths would be
402             # matched before their templated counterparts."
403              
404             # caution, Schwartzian transform ahead!
405             $self->_set_path_templates(my $sorted_paths = [
406             map $_->[0], # remove transformed entries
407 209 50       2377 sort { $a->[1] cmp $b->[1] || $a->[0] cmp $b->[0] } # sort by the transformed entries
408             map [ $_, s/\{[^{}]+\}/\x{10FFFF}/rg ], # transform template names into the highest Unicode char
409             grep !/^x-/, # remove extension keywords
410 369   100     11150 keys(($schema->{paths}//{})->%*) # all entries in /paths/*
411             ]);
412              
413 369         5591 my %seen_path;
414 369         1153 foreach my $path (@$sorted_paths) {
415             # see ABNF at v3.2.0 ยง4.8.2
416 407 50       1702 die "invalid path: $path" if substr($path, 0, 1) ne '/'; # schema validation catches this
417 407 100       3383 ()= E({ %$state, keyword_path => jsonp('/paths', $path) }, 'invalid path template "%s"', $path)
418             if grep !/^(?:\{[^{}]+\}|%[0-9A-Fa-f]{2}|[:@!\$&'()*+,;=A-Za-z0-9._~-]+)+\z/,
419             split('/', substr($path, 1)); # split by segment, omitting leading /
420              
421 407         1137 my %seen_names;
422 407         1373 foreach my $name ($path =~ /\{([^{}]+)\}/g) {
423             # v3.2.0 ยง4.8.1, "Patterned Fields": "Templated paths with the same hierarchy but different
424             # templated names MUST NOT exist as they are identical."
425 178 100       854 if (++$seen_names{$name} == 2) {
426 2         9 ()= E({ %$state, keyword_path => jsonp('/paths', $path) },
427             'duplicate path template variable "%s"', $name);
428             }
429             }
430              
431 407         1429 my $normalized = $path =~ s/\{[^{}]+\}/\x00/gr;
432 407 100       1276 if (my $first_path = $seen_path{$normalized}) {
433 3         29 ()= E({ %$state, keyword_path => jsonp('/paths', $path) },
434             'duplicate of templated path "%s"', $first_path);
435 3         955 next;
436             }
437 404         1189 $seen_path{$normalized} = $path;
438             }
439              
440 369         955 foreach my $path_item (sort keys %bad_path_item_refs) {
441             ()= E({ %$state, keyword_path => $path_item },
442 4         528 'invalid keywords used adjacent to $ref in a path-item: %s', $bad_path_item_refs{$path_item});
443             }
444              
445 369         866 my %seen_url; # indexed by servers object
446 369         851 foreach my $server_location (@server_paths) {
447 78         2427 my $server = $self->get($server_location);
448              
449             # see ABNF at v3.2.0 ยง4.6
450             ()= E({ %$state, keyword_path => $server_location.'/url' },
451             'invalid server url "%s"', $server->{url}), next
452 78 100       3480 if $server->{url} !~ /^(?:\{[^{}]+\}|%[0-9A-Fa-f]{2}|[\x21\x24\x26-\x3B\x3D\x40-\x5B\x5D\x5F\x61-\x7A\x7E\xA0-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}\x{E000}-\x{F8FF}\x{F0000}-\x{FFFFD}\x{100000}-\x{10FFFD}])+\z/;
453              
454 66         224 my $normalized = $server->{url} =~ s/\{[^{}]+\}/\x00/gr;
455 66         201 my @url_variables = $server->{url} =~ /\{([^{}]+)\}/g;
456              
457 66         205 my $servers_location = $server_location =~ s{/[0-9]+\z}{}r;
458              
459 66 100 100     357 if (my $first_url = ($seen_url{$servers_location}//{})->{$normalized}) {
460 6         61 ()= E({ %$state, keyword_path => $server_location.'/url' },
461             'duplicate of templated server url "%s"', $first_url);
462             }
463 17     17   26148 { use autovivification 'store'; $seen_url{$servers_location}->{$normalized} = $server->{url}; }
  17         27  
  17         76  
  66         984  
  66         175  
464              
465 66         106 my $variables_obj = $server->{variables};
466 66 100       109 if (not $variables_obj) {
467             # missing 'variables': needs variables/$varname/default
468 33 100       70 ()= E({ %$state, keyword_path => $server_location },
469             '"variables" property is required for templated server urls') if @url_variables;
470 33         490 next;
471             }
472              
473 33         35 my %seen_names;
474 33         47 foreach my $name (@url_variables) {
475             ()= E({ %$state, keyword_path => $server_location },
476             'duplicate server template variable "%s"', $name)
477 49 100       551 if ++$seen_names{$name} == 2;
478              
479             ()= E({ %$state, keyword_path => $server_location.'/variables' },
480             'missing "variables" definition for server template variable "%s"', $name)
481 49 100 100     624 if $seen_names{$name} == 1 and not exists $variables_obj->{$name};
482             }
483              
484 33         82 foreach my $varname (keys $variables_obj->%*) {
485             ()= E({ %$state, keyword_path => jsonp($server_location, 'variables', $varname, 'default') },
486             'server default is not a member of enum')
487             if exists $variables_obj->{$varname}{enum}
488 49 100 100     892 and not grep $variables_obj->{$varname}{default} eq $_, $variables_obj->{$varname}{enum}->@*;
489             }
490             }
491              
492             # name -> index; for duplicates, will contain the first index where the tag can be found
493 369   100     3408 my %tag_to_index = reverse indexed map $_->{name}, ($schema->{tags}//[])->@*;
494              
495 369   100     2710 foreach my $tag_idx (0 .. ($schema->{tags}//[])->$#*) {
496 12         303 my $tag = $schema->{tags}[$tag_idx];
497             ()= E({ %$state, keyword_path => '/tags/'.$tag_idx.'/name' },
498             'duplicate of tag at /tags/%d: "%s"', $tag_to_index{$tag->{name}}, $tag->{name})
499 12 100       82 if $tag_to_index{$tag->{name}} != $tag_idx;
500              
501             ()= E({ %$state, keyword_path => '/tags/'.$tag_idx.'/parent' },
502             'parent of tag "%s" does not exist: "%s"', $tag->{name}, $tag->{parent})
503 12 100 100     537 if exists $tag->{parent} and not exists $tag_to_index{$tag->{parent}};
504              
505 12         239 my @seen;
506 12         33 while (defined $tag->{parent}) {
507 12         23 push @seen, $tag->{name};
508 12 100       30 last if not defined $tag_to_index{$tag->{parent}};
509 11         23 $tag = $schema->{tags}[$tag_to_index{$tag->{parent}}];
510              
511             ()= E({ %$state, keyword_path => '/tags/'.$tag_idx.'/parent' },
512             'circular reference between tags: '.join(' -> ', map '"'.$_.'"', @seen, $tag->{name})),
513             last
514 11 100       125 if grep $_ eq $tag->{name}, @seen;
515             }
516             }
517              
518 369 100       2300 return $state if $state->{errors}->@*;
519              
520 363         2134 $self->{_tags} = (HashRef[json_pointer_type])->({ map +($_ => '/tags/'.$tag_to_index{$_}), keys %tag_to_index });
521 363         777865 $self->{_operation_tags} = (HashRef[ArrayRef[json_pointer_type]])->(\%tag_operation_paths);
522              
523             # disregard paths that are not the root of each embedded subschema.
524             # Because the callbacks are executed after the keyword has (recursively) finished evaluating,
525             # for each nested schema group. the schema paths appear longest first, with the parent schema
526             # appearing last. Therefore we can whittle down to the parent schema for each group by iterating
527             # through the full list in reverse, and checking if it is a child of the last path we chose to save.
528             # When the default metaschema is being used, there is no pruning to be done, as only the root of
529             # each embedded schema will be found via callbacks.
530 363         1080917 my @real_json_schema_paths;
531 363         2051 for (my $idx = $#json_schema_paths; $idx >= 0; --$idx) {
532 780 50 66     3826 next if $idx != $#json_schema_paths
533             and substr($json_schema_paths[$idx], 0, length($real_json_schema_paths[-1])+1)
534             eq $real_json_schema_paths[-1].'/';
535              
536 780         1717 push @real_json_schema_paths, $json_schema_paths[$idx];
537             }
538              
539 363 50       2539 push $state->{references}->@*, @references if $state->{references};
540              
541 363         5063 $self->_traverse_schema({ %$state, keyword_path => $_ }) foreach reverse @real_json_schema_paths;
542 363 100       1883 return $state if $state->{errors}->@*;
543              
544 359         2220 $self->_add_entity_location($_, 'schema') foreach $state->{subschemas}->@*;
545              
546 359         17423 return $state;
547             }
548              
549             # just like the base class's version, except we skip the evaluate step because we already did
550             # that as part of traverse.
551 6     6 1 583018 sub validate ($class, %args) {
  6         15  
  6         23  
  6         10  
552 6         21 my $with_defaults = delete $args{with_defaults};
553              
554 6 50       175 my $document = blessed($class) ? $class : $class->new(%args);
555 6 50       2803 return JSON::Schema::Modern::Result->new(
556             errors => [ $document->errors ],
557             $with_defaults ? (defaults => $document->defaults) : (),
558             );
559             }
560              
561 7     7 1 4672 sub upgrade ($self, $to_version = SUPPORTED_OAD_VERSIONS->[-1]) {
  7         18  
  7         14  
  7         14  
562 7 50       32 croak 'cannot upgrade an invalid document' if $self->errors;
563              
564 7 100       368 croak 'new openapi version must be a dotted tuple or triple'
565             if $to_version !~ /^(3\.\d+)(?:\.\d+)?\z/a;
566 6         24 my $to_oas_version = $1;
567 6 100       186 croak 'requested upgrade to an unsupported version: ', $to_version
568             if not grep $to_oas_version eq $_, OAS_VERSIONS->@*;
569              
570 5 100       104 ($to_version) = grep /^$to_version\./, SUPPORTED_OAD_VERSIONS->@* if $to_version =~ /^(3\.\d+)\z/a;
571              
572 5         21 my $schema = $self->schema;
573              
574 5         19 my $from_version = $schema->{openapi};
575 5 100       23 return $schema if $from_version eq $to_version;
576              
577 4         25 my ($from_oas_version) = $schema->{openapi} =~ /^(3\.\d+)\.\d+\b/a;
578 4 100       278 croak 'downgrading is not supported' if $from_oas_version > $to_oas_version;
579              
580 3         10 $schema->{openapi} = $to_version;
581              
582 3 100       13 return $schema if $from_oas_version eq $to_oas_version;
583              
584 2 100       7 if ($from_oas_version eq '3.0') {
585 1 50 33     13 delete $schema->{paths} if not keys $schema->{paths}->%* and exists $schema->{components};
586              
587 1         11 foreach my $media_type_path ($self->get_entity_locations('media-type')) {
588 1         40 my $media_type = (unjsonp($media_type_path))[-1];
589              
590             # convert {"schema": {"type": "string", "format": "binary"}} to {}
591 1 50       25 if ($media_type eq 'application/octet-stream') {
592 1         7 my $media_type_obj = $self->get($media_type_path);
593 1         51 my $schema = $media_type_obj->{schema};
594 1 50 33     32 if ($schema and keys %$schema == 2
      50        
      33        
      50        
      33        
595             and ($schema->{type}//'') eq 'string' and ($schema->{format}//'') eq 'binary') {
596 1         4 delete $media_type_obj->{schema};
597 1         26 delete $self->_entities->{$media_type_path.'/schema'};
598             }
599             }
600             }
601              
602 1         17 foreach my $schema_path ($self->get_entity_locations('schema')) {
603 6         32 my $subschema = $self->get($schema_path);
604              
605 6 100       196 if (exists $subschema->{nullable}) {
606             $subschema->{type} = [ $subschema->{type}, 'null' ]
607 2 100 66     10 if delete $subschema->{nullable} and exists $subschema->{type};
608             }
609              
610             $subschema->{const} = (delete $subschema->{enum})->[0]
611 6 100 100     41 if exists $subschema->{enum} and $subschema->{enum}->@* == 1;
612              
613 6 100       11 $subschema->{exclusiveMinimum} = delete $subschema->{minimum} if delete $subschema->{exclusiveMinimum};
614 6 100       17 $subschema->{exclusiveMaximum} = delete $subschema->{maximum} if delete $subschema->{exclusiveMaximum};
615              
616 6 100       17 $subschema->{examples} = [ delete $subschema->{example} ] if exists $subschema->{example};
617              
618 6 100       13 if (exists $subschema->{format}) {
619 2 100       10 if ($subschema->{format} eq 'binary') {
    50          
620 1         4 $subschema->{contentMediaType} = 'application/octet-stream';
621 1         3 delete $subschema->{format};
622             }
623             elsif ($subschema->{format} eq 'base64') {
624 1         3 $subschema->{contentEncoding} = 'base64';
625 1         2 delete $subschema->{format};
626             }
627             }
628             }
629             }
630              
631 2 100       9 if ($to_oas_version ge '3.2') {
632 1         5 foreach my $schema_path ($self->get_entity_locations('response')) {
633 2         38 my $subschema = $self->get($schema_path);
634             delete $subschema->{description}
635 2 100 66     80 if exists $subschema->{description} and $subschema->{description} eq '';
636             }
637             }
638              
639 2         15 return $schema;
640             }
641              
642             ######## NO PUBLIC INTERFACES FOLLOW THIS POINT ########
643              
644             # https://spec.openapis.org/oas/latest#schema-object
645             # traverse this JSON Schema and identify all errors, subschema locations, and referenceable
646             # identifiers
647 780     780   1500 sub _traverse_schema ($self, $state) {
  780         1246  
  780         1232  
  780         969  
648 780         3615 my $schema = $self->get($state->{keyword_path});
649              
650 780 100 100     44014 if (get_type($schema) eq 'boolean' or not keys %$schema) {
651 163         2617 push $state->{subschemas}->@*, $state->{keyword_path};
652 163         1134 return;
653             }
654              
655             my $subschema_state = $state->{evaluator}->traverse($schema, {
656             initial_schema_uri => canonical_uri($state),
657             traversed_keyword_path => $state->{traversed_keyword_path}.$state->{keyword_path},
658             metaschema_uri => $state->{json_schema_dialect}, # can be overridden with the '$schema' keyword
659 617         8676 });
660              
661 617         817471 push $state->{errors}->@*, $subschema_state->{errors}->@*;
662 617         2647 push $state->{subschemas}->@*, $subschema_state->{subschemas}->@*;
663 617 50 50     3485 push $state->{references}->@*, ($subschema_state->{references}//[])->@* if $state->{references};
664              
665 617         10087 foreach my $new_uri (sort keys $subschema_state->{identifiers}->%*) {
666 29 100       100 if (not $state->{identifiers}{$new_uri}) {
667 22         78 $state->{identifiers}{$new_uri} = $subschema_state->{identifiers}{$new_uri};
668 22         253 next;
669             }
670              
671 7         15 my $existing = $state->{identifiers}{$new_uri};
672 7         17 my $new = $subschema_state->{identifiers}{$new_uri};
673              
674 7 100       26 if (not is_equal(
675             { canonical_uri => $new->{canonical_uri}.'', map +($_ => $new->{$_}), qw(path specification_version vocabularies) },
676             { canonical_uri => $existing->{canonical_uri}.'', map +($_ => $existing->{$_}), qw(path specification_version vocabularies) })) {
677             ()= E({ %$state, keyword_path => $new->{path} },
678             'duplicate canonical uri "%s" found (original at path "%s")',
679 2         1373 $new_uri, $existing->{path});
680 2         395 next;
681             }
682              
683 5         5274 foreach my $anchor (sort keys $new->{anchors}->%*) {
684 5 100 100     30 if (my $existing_anchor = ($existing->{anchors}//{})->{$anchor}) {
685             ()= E({ %$state, keyword_path => $new->{anchors}{$anchor}{path} },
686             'duplicate anchor uri "%s" found (original at path "%s")',
687             $new->{canonical_uri}->clone->fragment($anchor),
688 2         29 $existing->{anchors}{$anchor}{path});
689 2         915 next;
690             }
691              
692 17     17   51914 use autovivification 'store';
  17         31  
  17         74  
693 3         76 $existing->{anchors}{$anchor} = $new->{anchors}{$anchor};
694             }
695             }
696             }
697              
698             # Given a jsonSchemaDialect uri, generate a new schema that wraps the standard OAD schema
699             # to set the jsonSchemaDialect value for the #meta dynamic reference.
700             # This metaschema does not allow subschemas to select their own $schema; for that, you
701             # should construct your own, based on DEFAULT_BASE_METASCHEMA.
702 0     0   0 sub _dynamic_metaschema_uri ($self, $json_schema_dialect, $evaluator) {
  0         0  
  0         0  
  0         0  
  0         0  
703 0         0 $json_schema_dialect .= '';
704 0         0 my $dialect_uri = 'https://custom-dialect.example.com/' . md5_hex($json_schema_dialect);
705 0 0       0 return $dialect_uri if $evaluator->_get_resource($dialect_uri);
706              
707             # we use the definition of https://spec.openapis.org/oas//schema-base/ but swap out
708             # the dialect reference.
709 0         0 my $schema = clone($evaluator->_get_resource(DEFAULT_BASE_METASCHEMA->{$self->oas_version})->{document}->schema);
710 0         0 $schema->{'$id'} = $dialect_uri;
711 0         0 $schema->{'$defs'}{dialect}{const} = $json_schema_dialect;
712 0         0 $schema->{'$defs'}{schema}{'$ref'} = $json_schema_dialect;
713              
714 0         0 $evaluator->add_document(
715             Mojo::URL->new($dialect_uri),
716             JSON::Schema::Modern::Document->new(
717             schema => $schema,
718             evaluator => $evaluator,
719             ));
720              
721 0         0 return $dialect_uri;
722             }
723              
724             # FREEZE is defined by parent class
725              
726             # callback hook for Sereal::Decoder
727 1     1 0 1840 sub THAW ($class, $serializer, $data) {
  1         2  
  1         2  
  1         1  
  1         3  
728 1         2 delete $data->{evaluator};
729              
730 1 50       6 if (defined(my $dialect = delete $data->{json_schema_dialect})) {
731 0         0 carp "use of no-longer-supported constructor argument: json_schema_dialect = \"$dialect\"; use \"jsonSchemaDialect\": \"...\" in your OpenAPI document itself";
732             }
733              
734 1         2 my $self = bless($data, $class);
735 1 50       5 $self->{oas_version} = OAS_VERSIONS->[-1] if not exists $self->{oas_version};
736              
737 1         2 foreach my $attr (qw(schema _entities)) {
738             croak "serialization missing attribute '$attr': perhaps your serialized data was produced for an older version of $class?"
739 2 50       6 if not exists $self->{$attr};
740             }
741              
742 1         4 return $self;
743             }
744              
745             1;
746              
747             __END__