File Coverage

blib/lib/JSON/Schema/Modern/Document/OpenAPI.pm
Criterion Covered Total %
statement 207 207 100.0
branch 35 38 92.1
condition 16 17 94.1
subroutine 38 38 100.0
pod 1 2 50.0
total 297 302 98.3


line stmt bran cond sub pod time code
1 11     11   6508791 use strict;
  11         91  
  11         375  
2 11     11   71 use warnings;
  11         24  
  11         605  
3             package JSON::Schema::Modern::Document::OpenAPI;
4             # vim: set ts=8 sts=2 sw=2 tw=100 et :
5             # ABSTRACT: One OpenAPI v3.1 document
6             # KEYWORDS: JSON Schema data validation request response OpenAPI
7              
8             our $VERSION = '0.047';
9              
10 11     11   265 use 5.020;
  11         55  
11 11     11   621 use Moo;
  11         6745  
  11         88  
12 11     11   5779 use strictures 2;
  11         114  
  11         525  
13 11     11   2537 use stable 0.031 'postderef';
  11         214  
  11         91  
14 11     11   2164 use experimental 'signatures';
  11         31  
  11         68  
15 11     11   993 use if "$]" >= 5.022, experimental => 're_strict';
  11         46  
  11         177  
16 11     11   1203 no if "$]" >= 5.031009, feature => 'indirect';
  11         42  
  11         111  
17 11     11   639 no if "$]" >= 5.033001, feature => 'multidimensional';
  11         36  
  11         126  
18 11     11   667 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  11         44  
  11         77  
19 11     11   1164 use JSON::Schema::Modern::Utilities 0.525 qw(E canonical_uri jsonp);
  11         283243  
  11         921  
20 11     11   105 use Safe::Isa;
  11         27  
  11         1830  
21 11     11   712 use File::ShareDir 'dist_dir';
  11         23512  
  11         741  
22 11     11   1044 use Path::Tiny;
  11         13571  
  11         747  
23 11     11   87 use List::Util 'pairs';
  11         25  
  11         892  
24 11     11   86 use Ref::Util 'is_plain_hashref';
  11         32  
  11         574  
25 11     11   626 use MooX::HandlesVia;
  11         805  
  11         102  
26 11     11   1850 use MooX::TypeTiny 0.002002;
  11         229  
  11         142  
27 11     11   9465 use Types::Standard qw(InstanceOf HashRef Str Enum);
  11         31  
  11         138  
28 11     11   24413 use Storable 'dclone';
  11         27  
  11         619  
29 11     11   88 use namespace::clean;
  11         27  
  11         208  
30              
31             extends 'JSON::Schema::Modern::Document';
32              
33 11     11   5189 use constant DEFAULT_DIALECT => 'https://spec.openapis.org/oas/3.1/dialect/base';
  11         25  
  11         1230  
34              
35 11         708 use constant DEFAULT_SCHEMAS => {
36             # local filename => identifier to add the schema as
37             'oas/dialect/base.schema.json' => 'https://spec.openapis.org/oas/3.1/dialect/base', # metaschema for json schemas contained within openapi documents
38             'oas/meta/base.schema.json' => 'https://spec.openapis.org/oas/3.1/meta/base', # vocabulary definition
39             'oas/schema-base.json' => 'https://spec.openapis.org/oas/3.1/schema-base', # the main openapi document schema + draft2020-12 jsonSchemaDialect
40             'oas/schema.json' => 'https://spec.openapis.org/oas/3.1/schema', # the main openapi document schema + permissive jsonSchemaDialect
41             'strict-schema.json' => 'https://raw.githubusercontent.com/karenetheridge/OpenAPI-Modern/master/share/strict-schema.json',
42             'strict-dialect.json' => 'https://raw.githubusercontent.com/karenetheridge/OpenAPI-Modern/master/share/strict-dialect.json',
43 11     11   81 };
  11         24  
44              
45 11     11   71 use constant DEFAULT_BASE_METASCHEMA => 'https://spec.openapis.org/oas/3.1/schema-base/2022-10-07';
  11         24  
  11         623  
46 11     11   75 use constant DEFAULT_METASCHEMA => 'https://spec.openapis.org/oas/3.1/schema/2022-10-07';
  11         22  
  11         27553  
47              
48             has '+evaluator' => (
49             required => 1,
50             );
51              
52             has '+metaschema_uri' => (
53             default => DEFAULT_BASE_METASCHEMA,
54             );
55              
56             has json_schema_dialect => (
57             is => 'rwp',
58             isa => InstanceOf['Mojo::URL'],
59             coerce => sub { $_[0]->$_isa('Mojo::URL') ? $_[0] : Mojo::URL->new($_[0]) },
60             );
61              
62             my $reffable_entities = Enum[qw(response parameter example request-body header security-scheme link callbacks path-item)];
63              
64             # json pointer => entity name
65             has entities => (
66             is => 'bare',
67             isa => HashRef[$reffable_entities],
68             handles_via => 'Hash',
69             handles => {
70             _add_entity_location => 'set',
71             get_entity_at_location => 'get',
72             },
73             lazy => 1,
74             default => sub { {} },
75              
76             );
77              
78             # operationId => document path
79             has operationIds => (
80             is => 'bare',
81             isa => HashRef[Str],
82             handles_via => 'Hash',
83             handles => {
84             _add_operationId => 'set',
85             get_operationId_path => 'get',
86             },
87             lazy => 1,
88             default => sub { {} },
89             );
90              
91 119     119 0 710613 sub traverse ($self, $evaluator) {
  119         332  
  119         315  
  119         279  
92 119         602 $self->_add_vocab_and_default_schemas;
93              
94 119         161561 my $schema = $self->schema;
95 119         2697 my $state = {
96             initial_schema_uri => $self->canonical_uri,
97             traversed_schema_path => '',
98             schema_path => '',
99             data_path => '',
100             errors => [],
101             evaluator => $evaluator,
102             identifiers => [],
103             configs => {},
104             spec_version => $evaluator->SPECIFICATION_VERSION_DEFAULT,
105             vocabularies => [],
106             };
107              
108             # this is an abridged form of https://spec.openapis.org/oas/3.1/schema/latest
109             # just to validate the parts of the document we need to verify before parsing jsonSchemaDialect
110             # and switching to the real metaschema for this document
111 119         2795 state $top_schema = {
112             '$schema' => 'https://json-schema.org/draft/2020-12/schema',
113             type => 'object',
114             required => ['openapi'],
115             properties => {
116             openapi => {
117             type => 'string',
118             pattern => '', # just here for the callback so we can customize the error
119             },
120             jsonSchemaDialect => {
121             type => 'string',
122             format => 'uri',
123             },
124             },
125             };
126 116         411 my $top_result = $self->evaluator->evaluate(
127             $schema, $top_schema,
128             {
129             effective_base_uri => DEFAULT_METASCHEMA,
130             callbacks => {
131 116     116   243 pattern => sub ($data, $schema, $state) {
  116         966262  
  116         295  
  116         296  
132 116 100       1086 return $data =~ /^3\.1\.[0-9]+(-.+)?$/ ? 1 : E($state, 'unrecognized openapi version %s', $data);
133             },
134             },
135             },
136 119         1836 );
137 119 100       162254 if (not $top_result) {
138 5         211 $_->mode('evaluate') foreach $top_result->errors;
139 5         607 push $state->{errors}->@*, $top_result->errors;
140 5         244 return $state;
141             }
142              
143             # /jsonSchemaDialect: https://spec.openapis.org/oas/v3.1.0#specifying-schema-dialects
144             {
145 114   66     2115 my $json_schema_dialect = $self->json_schema_dialect // $schema->{jsonSchemaDialect};
  114         1298  
146              
147             # "If [jsonSchemaDialect] is not set, then the OAS dialect schema id MUST be used for these Schema Objects."
148 114   100     885 $json_schema_dialect //= DEFAULT_DIALECT;
149              
150             # traverse an empty schema with this metaschema uri to confirm it is valid
151 114         3211 my $check_metaschema_state = $evaluator->traverse({}, {
152             metaschema_uri => $json_schema_dialect,
153             initial_schema_uri => $self->canonical_uri->clone->fragment('/jsonSchemaDialect'),
154             });
155              
156             # we cannot continue if the metaschema is invalid
157 114 100       524006 if ($check_metaschema_state->{errors}->@*) {
158             # these errors should be mode=traverse
159 1         5 push $state->{errors}->@*, $check_metaschema_state->{errors}->@*;
160 1         10 return $state;
161             }
162              
163 113         655 $state->@{qw(spec_version vocabularies)} = $check_metaschema_state->@{qw(spec_version vocabularies)};
164 113         3157 $self->_set_json_schema_dialect($json_schema_dialect);
165             }
166              
167             # evaluate the document against its metaschema to find any errors, to identify all schema
168             # resources within to add to the global resource index, and to extract all operationIds
169 113         16502 my (@json_schema_paths, @operation_paths);
170 209         544 my $result = $self->evaluator->evaluate(
171             $schema, $self->metaschema_uri,
172             {
173             short_circuit => 1,
174             callbacks => {
175 209     209   400 '$dynamicRef' => sub ($, $schema, $state) {
  209         104439  
  209         532  
176 209 50       1312 push @json_schema_paths, $state->{data_path} if $schema->{'$dynamicRef'} eq '#meta';
177 209         618 return 1;
178             },
179 4569     4569   6802 '$ref' => sub ($data, $schema, $state) {
  4569         27020128  
  4569         8708  
  4569         7027  
  4569         7430  
180 4569         27817 my ($entity) = ($schema->{'$ref'} =~ m{#/\$defs/([^/]+?)(?:-or-reference)?$});
181 4569 100 100     22189 $self->_add_entity_location($state->{data_path}, $entity)
182             if $entity and $reffable_entities->check($entity);
183              
184             push @operation_paths, [ $data->{operationId} => $state->{data_path} ]
185 4569 100 100     101873 if $schema->{'$ref'} eq '#/$defs/operation' and defined $data->{operationId};
186              
187 4569         11565 return 1;
188             },
189             },
190             },
191 113         2117 );
192              
193 113 100       99831 if (not $result) {
194 6         256 $_->mode('evaluate') foreach $result->errors;
195 6         1298 push $state->{errors}->@*, $result->errors;
196 6         331 return $state;
197             }
198              
199             # "Templated paths with the same hierarchy but different templated names MUST NOT exist as they
200             # are identical."
201 107         2133 my %seen_path;
202 107         911 foreach my $path (sort keys $schema->{paths}->%*) {
203 100         759 my $normalized = $path =~ s/\{[^\}]+\}/\x00/r;
204 100 100       668 if (my $first_path = $seen_path{$normalized}) {
205 2         15 ()= E({ %$state, data_path => jsonp('/paths', $path),
206             initial_schema_uri => Mojo::URL->new(DEFAULT_METASCHEMA) },
207             'duplicate of templated path %s', $first_path);
208 2         1700 $state->{errors}[-1]->mode('evaluate');
209 2         34 next;
210             }
211 98         452 $seen_path{$normalized} = $path;
212             }
213              
214 107 100       614 return $state if $state->{errors}->@*;
215              
216             # disregard paths that are not the root of each embedded subschema.
217             # Because the callbacks are executed after the keyword has (recursively) finished evaluating,
218             # for each nested schema group. the schema paths appear longest first, with the parent schema
219             # appearing last. Therefore we can whittle down to the parent schema for each group by iterating
220             # through the full list in reverse, and checking if it is a child of the last path we chose to save.
221 106         324 my @real_json_schema_paths;
222 106         625 for (my $idx = $#json_schema_paths; $idx >= 0; --$idx) {
223 207 100 100     1727 next if $idx != $#json_schema_paths
224             and substr($json_schema_paths[$idx], 0, length($real_json_schema_paths[-1])+1)
225             eq $real_json_schema_paths[-1].'/';
226              
227 161         517 push @real_json_schema_paths, $json_schema_paths[$idx];
228 161         767 $self->_traverse_schema($self->get($json_schema_paths[$idx]), { %$state, schema_path => $json_schema_paths[$idx] });
229             }
230              
231 106         505 foreach my $pair (@operation_paths) {
232 24         5183 my ($operation_id, $path) = @$pair;
233 24 100       523 if (my $existing = $self->get_operationId_path($operation_id)) {
234 6         673 ()= E({ %$state, data_path => $path .'/operationId',
235             initial_schema_uri => Mojo::URL->new(DEFAULT_METASCHEMA) },
236             'duplicate of operationId at %s', $existing);
237             }
238             else {
239 18         2042 $self->_add_operationId($operation_id => $path);
240             }
241             }
242              
243 106         2148 $_->mode('evaluate') foreach $state->{errors}->@*;
244 106         1161 return $state;
245             }
246              
247 4     4 1 16451 sub recursive_get ($self, $json_pointer) {
  4         9  
  4         10  
  4         5  
248 4         103 my $base = $self->canonical_uri;
249 4         52 my $uri = Mojo::URL->new->fragment($json_pointer);
250 4         76 my ($depth, $schema);
251              
252 4         17 while ($uri) {
253 59 100       314 die 'maximum evaluation depth exceeded' if $depth++ > $self->evaluator->max_traversal_depth;
254 58         176 $uri = Mojo::URL->new($uri)->to_abs($base);
255 58         27962 my $schema_info = $self->evaluator->_fetch_from_uri($uri);
256 58 100       51239 die('unable to find resource ', $uri) if not $schema_info;
257 57         126 $schema = $schema_info->{schema};
258 57         92 $base = $schema_info->{canonical_uri};
259 57         471 $uri = $schema->{'$ref'};
260             }
261              
262 2         222 $schema = dclone($schema);
263 2 50       31 return wantarray ? ($schema, $base) : $schema;
264             }
265              
266             ######## NO PUBLIC INTERFACES FOLLOW THIS POINT ########
267              
268 119     119   289 sub _add_vocab_and_default_schemas ($self) {
  119         260  
  119         257  
269 119         408 my $js = $self->evaluator;
270 119         849 $js->add_vocabulary('JSON::Schema::Modern::Vocabulary::OpenAPI');
271              
272 5         33 $js->add_format_validation(
273 5     5   8 int32 => +{ type => 'integer', sub => sub ($x) {
  5         138920  
274 5         38 require Math::BigInt;
275 5         29 $x = Math::BigInt->new($x);
276 5         470 my $bound = Math::BigInt->new(2) ** 31;
277 5 100       1862 $x >= -$bound && $x < $bound;
278             } },
279 5     5   8 int64 => +{ type => 'integer', sub => sub ($x) {
  5         57418  
  5         11  
280 5         35 require Math::BigInt;
281 5         27 $x = Math::BigInt->new($x);
282 5         391 my $bound = Math::BigInt->new(2) ** 63;
283 5 100       1960 $x >= -$bound && $x < $bound;
284             } },
285 6     6   13 float => +{ type => 'number', sub => sub ($) { 1 } },
  6         13  
  6         65476  
286 6     6   16 double => +{ type => 'number', sub => sub ($) { 1 } },
  6         15  
  6         66110  
287 1     1   3 password => +{ type => 'string', sub => sub ($) { 1 } },
  1         3  
  1         18674  
288 119         256895 );
289              
290 119         89129 foreach my $pairs (pairs DEFAULT_SCHEMAS->%*) {
291 714         333487 my ($filename, $uri) = @$pairs;
292 714         14763 my $document = $js->add_schema($uri,
293             $js->_json_decoder->decode(path(dist_dir('OpenAPI-Modern'), $filename)->slurp_raw));
294 714 100       32301252 $js->add_schema($uri.'/latest', $document) if $uri =~ /schema(-base)?$/;
295             }
296             }
297              
298             # https://spec.openapis.org/oas/v3.1.0#schema-object
299 161     161   12820 sub _traverse_schema ($self, $schema, $state) {
  161         315  
  161         289  
  161         273  
  161         294  
300 161 100 100     1135 return if not is_plain_hashref($schema) or not keys %$schema;
301              
302             my $subschema_state = $self->evaluator->traverse($schema, {
303             %$state, # so we don't have to enumerate everything that may be in config_override
304             initial_schema_uri => canonical_uri($state),
305             traversed_schema_path => $state->{traversed_schema_path}.$state->{schema_path},
306 124         936 metaschema_uri => $self->json_schema_dialect,
307             });
308              
309 124         217834 push $state->{errors}->@*, $subschema_state->{errors}->@*;
310 124 50       520 return if $subschema_state->{errors}->@*;
311              
312 124         1549 push $state->{identifiers}->@*, $subschema_state->{identifiers}->@*;
313             }
314              
315             1;
316              
317             __END__
318              
319             =pod
320              
321             =encoding UTF-8
322              
323             =head1 NAME
324              
325             JSON::Schema::Modern::Document::OpenAPI - One OpenAPI v3.1 document
326              
327             =head1 VERSION
328              
329             version 0.047
330              
331             =head1 SYNOPSIS
332              
333             use JSON::Schema::Modern;
334             use JSON::Schema::Modern::Document::OpenAPI;
335              
336             my $js = JSON::Schema::Modern->new;
337             my $openapi_document = JSON::Schema::Modern::Document::OpenAPI->new(
338             evaluator => $js,
339             canonical_uri => 'https://example.com/v1/api',
340             schema => $schema,
341             metaschema_uri => 'https://example.com/my_custom_dialect',
342             );
343              
344             =head1 DESCRIPTION
345              
346             Provides structured parsing of an OpenAPI document, suitable as the base for more tooling such as
347             request and response validation, code generation or form generation.
348              
349             The provided document must be a valid OpenAPI document, as specified by the schema identified by
350             C<https://spec.openapis.org/oas/3.1/schema-base/latest> (an alias for the latest document available)
351              
352             and the L<OpenAPI v3.1 specification|https://spec.openapis.org/oas/v3.1.0>.
353              
354             =head1 ATTRIBUTES
355              
356             These values are all passed as arguments to the constructor.
357              
358             This class inherits all options from L<JSON::Schema::Modern::Document> and implements the following
359             new ones:
360              
361             =head2 evaluator
362              
363             =for stopwords metaschema schemas
364              
365             A L<JSON::Schema::Modern> object. Unlike in the parent class, this is B<REQUIRED>, because loaded
366             vocabularies, metaschemas and resource identifiers must be stored here as they are discovered in the
367             OpenAPI document. This is the object that will be used for subsequent evaluation of data against
368             schemas in the document, either manually or perhaps via a web framework plugin (coming soon).
369              
370             =head2 metaschema_uri
371              
372             The URI of the schema that describes the OpenAPI document itself. Defaults to
373             C<https://spec.openapis.org/oas/3.1/schema-base/latest> (an alias for the latest document
374             available).
375              
376             =head2 json_schema_dialect
377              
378             The URI of the metaschema to use for all embedded L<JSON Schemas|https://json-schema.org/> in the
379             document.
380              
381             Overrides the value of C<jsonSchemaDialect> in the document, or the specification default
382             (C<https://spec.openapis.org/oas/3.1/dialect/base>).
383              
384             If you specify your own dialect here or in C<jsonSchemaDialect>, then you need to add the
385             vocabularies and schemas to the implementation yourself. (see C<JSON::Schema::Modern/add_vocabulary>
386             and C<JSON::Schema::Modern/add_schema>).
387              
388             Note this is B<NOT> the same as L<JSON::Schema::Modern::Document/metaschema_uri>, which contains the
389             URI describing the entire document (and is not a metaschema in this case, as the entire document is
390             not a JSON Schema). Note that you may need to explicitly set that attribute as well if you change
391             C<json_schema_dialect>, as the default metaschema used by the default C<metaschema_uri> can no
392             longer be assumed.
393              
394             =head1 METHODS
395              
396             =head2 get_operationId_path
397              
398             Returns the json pointer location of the operation containing the provided C<operationId> (suitable
399             for passing to C<< $document->get(..) >>), or C<undef> if the location does not exist in the
400             document.
401              
402             =head2 recursive_get
403              
404             Given a json pointer, get the schema at that location, resolving any C<$ref>s along the way.
405             Returns the schema data in scalar context, or a tuple of the schema data and the canonical URI of
406             the referenced location in list context.
407              
408             =head1 SEE ALSO
409              
410             =over 4
411              
412             =item *
413              
414             L<Mojolicious::Plugin::OpenAPI::Modern>
415              
416             =item *
417              
418             L<OpenAPI::Modern>
419              
420             =item *
421              
422             L<JSON::Schema::Modern>
423              
424             =item *
425              
426             L<https://json-schema.org>
427              
428             =item *
429              
430             L<https://www.openapis.org/>
431              
432             =item *
433              
434             L<https://oai.github.io/Documentation/>
435              
436             =item *
437              
438             L<https://spec.openapis.org/oas/v3.1.0>
439              
440             =back
441              
442             =head1 SUPPORT
443              
444             Bugs may be submitted through L<https://github.com/karenetheridge/OpenAPI-Modern/issues>.
445              
446             I am also usually active on irc, as 'ether' at C<irc.perl.org> and C<irc.libera.chat>.
447              
448             You can also find me on the L<JSON Schema Slack server|https://json-schema.slack.com> and L<OpenAPI
449             Slack server|https://open-api.slack.com>, which are also great resources for finding help.
450              
451             =head1 AUTHOR
452              
453             Karen Etheridge <ether@cpan.org>
454              
455             =head1 COPYRIGHT AND LICENCE
456              
457             This software is copyright (c) 2021 by Karen Etheridge.
458              
459             This is free software; you can redistribute it and/or modify it under
460             the same terms as the Perl 5 programming language system itself.
461              
462             Some schema files have their own licence, in share/oas/LICENSE.
463              
464             =cut