File Coverage

blib/lib/JSON/Schema/Modern/Document/OpenAPI.pm
Criterion Covered Total %
statement 185 185 100.0
branch 30 32 93.7
condition 13 14 92.8
subroutine 36 36 100.0
pod 0 1 0.0
total 264 268 98.5


line stmt bran cond sub pod time code
1 11     11   6172873 use strict;
  11         96  
  11         363  
2 11     11   88 use warnings;
  11         80  
  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.045';
9              
10 11     11   262 use 5.020;
  11         44  
11 11     11   676 use Moo;
  11         7176  
  11         89  
12 11     11   5920 use strictures 2;
  11         1650  
  11         570  
13 11     11   2629 use experimental qw(signatures postderef);
  11         26  
  11         125  
14 11     11   2376 use if "$]" >= 5.022, experimental => 're_strict';
  11         31  
  11         220  
15 11     11   1108 no if "$]" >= 5.031009, feature => 'indirect';
  11         28  
  11         98  
16 11     11   624 no if "$]" >= 5.033001, feature => 'multidimensional';
  11         28  
  11         147  
17 11     11   516 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  11         53  
  11         201  
18 11     11   1089 use JSON::Schema::Modern::Utilities 0.525 qw(E canonical_uri jsonp);
  11         270748  
  11         826  
19 11     11   101 use Safe::Isa;
  11         31  
  11         1752  
20 11     11   644 use File::ShareDir 'dist_dir';
  11         23429  
  11         753  
21 11     11   998 use Path::Tiny;
  11         12895  
  11         779  
22 11     11   111 use List::Util qw(any pairs);
  11         36  
  11         908  
23 11     11   100 use Ref::Util 'is_plain_hashref';
  11         26  
  11         572  
24 11     11   594 use MooX::HandlesVia;
  11         762  
  11         119  
25 11     11   1492 use MooX::TypeTiny 0.002002;
  11         299  
  11         114  
26 11     11   9068 use Types::Standard qw(InstanceOf HashRef Str Enum);
  11         29  
  11         125  
27 11     11   24334 use namespace::clean;
  11         27  
  11         116  
28              
29             extends 'JSON::Schema::Modern::Document';
30              
31 11     11   5226 use constant DEFAULT_DIALECT => 'https://spec.openapis.org/oas/3.1/dialect/base';
  11         33  
  11         1310  
32              
33 11         823 use constant DEFAULT_SCHEMAS => {
34             # local filename => identifier to add the schema as
35             'oas/dialect/base.schema.json' => 'https://spec.openapis.org/oas/3.1/dialect/base', # metaschema for json schemas contained within openapi documents
36             'oas/meta/base.schema.json' => 'https://spec.openapis.org/oas/3.1/meta/base', # vocabulary definition
37             'oas/schema-base.json' => 'https://spec.openapis.org/oas/3.1/schema-base', # the main openapi document schema + draft2020-12 jsonSchemaDialect
38             'oas/schema.json' => 'https://spec.openapis.org/oas/3.1/schema', # the main openapi document schema + permissive jsonSchemaDialect
39             'strict-schema.json' => 'https://raw.githubusercontent.com/karenetheridge/OpenAPI-Modern/master/share/strict-schema.json',
40             'strict-dialect.json' => 'https://raw.githubusercontent.com/karenetheridge/OpenAPI-Modern/master/share/strict-dialect.json',
41 11     11   82 };
  11         35  
42              
43 11     11   108 use constant DEFAULT_BASE_METASCHEMA => 'https://spec.openapis.org/oas/3.1/schema-base/2022-10-07';
  11         29  
  11         626  
44 11     11   76 use constant DEFAULT_METASCHEMA => 'https://spec.openapis.org/oas/3.1/schema/2022-10-07';
  11         31  
  11         23877  
45              
46             has '+evaluator' => (
47             required => 1,
48             );
49              
50             has '+metaschema_uri' => (
51             default => DEFAULT_BASE_METASCHEMA,
52             );
53              
54             has json_schema_dialect => (
55             is => 'rwp',
56             isa => InstanceOf['Mojo::URL'],
57             coerce => sub { $_[0]->$_isa('Mojo::URL') ? $_[0] : Mojo::URL->new($_[0]) },
58             );
59              
60             my $reffable_entities = Enum[qw(response parameter example request-body header security-scheme link callbacks path-item)];
61              
62             # json pointer => entity name
63             has entities => (
64             is => 'bare',
65             isa => HashRef[$reffable_entities],
66             handles_via => 'Hash',
67             handles => {
68             _add_entity_location => 'set',
69             get_entity_at_location => 'get',
70             },
71             lazy => 1,
72             default => sub { {} },
73              
74             );
75              
76             # operationId => document path
77             has operationIds => (
78             is => 'bare',
79             isa => HashRef[Str],
80             handles_via => 'Hash',
81             handles => {
82             _add_operationId => 'set',
83             get_operationId_path => 'get',
84             },
85             lazy => 1,
86             default => sub { {} },
87             );
88              
89 116     116 0 578513 sub traverse ($self, $evaluator) {
  116         312  
  116         280  
  116         241  
90 116         581 $self->_add_vocab_and_default_schemas;
91              
92 116         67980 my $schema = $self->schema;
93 116         2498 my $state = {
94             initial_schema_uri => $self->canonical_uri,
95             traversed_schema_path => '',
96             schema_path => '',
97             data_path => '',
98             errors => [],
99             evaluator => $evaluator,
100             identifiers => [],
101             configs => {},
102             spec_version => $evaluator->SPECIFICATION_VERSION_DEFAULT,
103             vocabularies => [],
104             };
105              
106             # this is an abridged form of https://spec.openapis.org/oas/3.1/schema/latest
107             # just to validate the parts of the document we need to verify before parsing jsonSchemaDialect
108             # and switching to the real metaschema for this document
109 116         3005 state $top_schema = {
110             '$schema' => 'https://json-schema.org/draft/2020-12/schema',
111             type => 'object',
112             required => ['openapi'],
113             properties => {
114             openapi => {
115             type => 'string',
116             pattern => '', # just here for the callback so we can customize the error
117             },
118             jsonSchemaDialect => {
119             type => 'string',
120             format => 'uri',
121             },
122             },
123             };
124 113         326 my $top_result = $self->evaluator->evaluate(
125             $schema, $top_schema,
126             {
127             effective_base_uri => DEFAULT_METASCHEMA,
128             callbacks => {
129 113     113   212 pattern => sub ($data, $schema, $state) {
  113         917292  
  113         314  
  113         248  
130 113 100       1010 return $data =~ /^3\.1\.[0-9]+(-.+)?$/ ? 1 : E($state, 'unrecognized openapi version %s', $data);
131             },
132             },
133             },
134 116         1677 );
135 116 100       159066 if (not $top_result) {
136 5         196 $_->mode('evaluate') foreach $top_result->errors;
137 5         625 push $state->{errors}->@*, $top_result->errors;
138 5         262 return $state;
139             }
140              
141             # /jsonSchemaDialect: https://spec.openapis.org/oas/v3.1.0#specifying-schema-dialects
142             {
143 111   66     2093 my $json_schema_dialect = $self->json_schema_dialect // $schema->{jsonSchemaDialect};
  111         1270  
144              
145             # "If [jsonSchemaDialect] is not set, then the OAS dialect schema id MUST be used for these Schema Objects."
146 111   100     785 $json_schema_dialect //= DEFAULT_DIALECT;
147              
148             # traverse an empty schema with this metaschema uri to confirm it is valid
149 111         2523 my $check_metaschema_state = $evaluator->traverse({}, {
150             metaschema_uri => $json_schema_dialect,
151             initial_schema_uri => $self->canonical_uri->clone->fragment('/jsonSchemaDialect'),
152             });
153              
154             # we cannot continue if the metaschema is invalid
155 111 100       501276 if ($check_metaschema_state->{errors}->@*) {
156             # these errors should be mode=traverse
157 1         7 push $state->{errors}->@*, $check_metaschema_state->{errors}->@*;
158 1         10 return $state;
159             }
160              
161 110         663 $state->@{qw(spec_version vocabularies)} = $check_metaschema_state->@{qw(spec_version vocabularies)};
162 110         2954 $self->_set_json_schema_dialect($json_schema_dialect);
163             }
164              
165             # evaluate the document against its metaschema to find any errors, to identify all schema
166             # resources within to add to the global resource index, and to extract all operationIds
167 110         15519 my (@json_schema_paths, @operation_paths);
168 200         575 my $result = $self->evaluator->evaluate(
169             $schema, $self->metaschema_uri,
170             {
171             callbacks => {
172 200     200   502 '$dynamicRef' => sub ($, $schema, $state) {
  200         86380  
  200         463  
173 200 50       1160 push @json_schema_paths, $state->{data_path} if $schema->{'$dynamicRef'} eq '#meta';
174 200         576 return 1;
175             },
176 4399     4399   6619 '$ref' => sub ($data, $schema, $state) {
  4399         25763432  
  4399         8211  
  4399         7035  
  4399         6883  
177 4399         25495 my ($entity) = ($schema->{'$ref'} =~ m{#/\$defs/([^/]+?)(?:-or-reference)?$});
178 4399 100 100     19780 $self->_add_entity_location($state->{data_path}, $entity)
179             if $entity and $reffable_entities->check($entity);
180              
181             push @operation_paths, [ $data->{operationId} => $state->{data_path} ]
182 4399 100 100     94503 if $schema->{'$ref'} eq '#/$defs/operation' and defined $data->{operationId};
183              
184 4399         10162 return 1;
185             },
186             },
187             },
188 110         2019 );
189              
190 110 100       88801 if (not $result) {
191 6         250 $_->mode('evaluate') foreach $result->errors;
192 6         1336 push $state->{errors}->@*, $result->errors;
193 6         316 return $state;
194             }
195              
196             # "Templated paths with the same hierarchy but different templated names MUST NOT exist as they
197             # are identical."
198 104         2075 my %seen_path;
199 104         920 foreach my $path (sort keys $schema->{paths}->%*) {
200 97         645 my $normalized = $path =~ s/\{[^}]+\}/\x00/r;
201 97 100       647 if (my $first_path = $seen_path{$normalized}) {
202 2         13 ()= E({ %$state, data_path => jsonp('/paths', $path),
203             initial_schema_uri => Mojo::URL->new(DEFAULT_METASCHEMA) },
204             'duplicate of templated path %s', $first_path);
205 2         1559 $state->{errors}[-1]->mode('evaluate');
206 2         32 next;
207             }
208 95         492 $seen_path{$normalized} = $path;
209             }
210              
211 104 100       647 return $state if $state->{errors}->@*;
212              
213 103         284 my @real_json_schema_paths;
214 103         442 foreach my $path (sort @json_schema_paths) {
215             # disregard paths that are not the root of each embedded subschema.
216 198 100   315   1272 next if any { $path =~ m{^\Q$_\E(?:/|\z)} } @real_json_schema_paths;
  315         5587  
217              
218 152         834 unshift @real_json_schema_paths, $path;
219 152         739 $self->_traverse_schema($self->get($path), { %$state, schema_path => $path });
220             }
221              
222 103         513 foreach my $pair (@operation_paths) {
223 24         4989 my ($operation_id, $path) = @$pair;
224 24 100       473 if (my $existing = $self->get_operationId_path($operation_id)) {
225 6         593 ()= E({ %$state, data_path => $path .'/operationId',
226             initial_schema_uri => Mojo::URL->new(DEFAULT_METASCHEMA) },
227             'duplicate of operationId at %s', $existing);
228             }
229             else {
230 18         1882 $self->_add_operationId($operation_id => $path);
231             }
232             }
233              
234 103         1915 $_->mode('evaluate') foreach $state->{errors}->@*;
235 103         1023 return $state;
236             }
237              
238             ######## NO PUBLIC INTERFACES FOLLOW THIS POINT ########
239              
240 116     116   245 sub _add_vocab_and_default_schemas ($self) {
  116         301  
  116         307  
241 116         379 my $js = $self->evaluator;
242 116         776 $js->add_vocabulary('JSON::Schema::Modern::Vocabulary::OpenAPI');
243              
244 5         10 $js->add_format_validation(
245 5     5   11 int32 => +{ type => 'integer', sub => sub ($x) {
  5         130847  
246 5         27 require Math::BigInt;
247 5         20 $x = Math::BigInt->new($x);
248 5         389 my $bound = Math::BigInt->new(2) ** 31;
249 5 100       1634 $x >= -$bound && $x < $bound;
250             } },
251 5     5   8 int64 => +{ type => 'integer', sub => sub ($x) {
  5         55092  
  5         11  
252 5         26 require Math::BigInt;
253 5         21 $x = Math::BigInt->new($x);
254 5         329 my $bound = Math::BigInt->new(2) ** 63;
255 5 100       1865 $x >= -$bound && $x < $bound;
256             } },
257 6     6   11 float => +{ type => 'number', sub => sub ($) { 1 } },
  6         14  
  6         62183  
258 6     6   10 double => +{ type => 'number', sub => sub ($) { 1 } },
  6         13  
  6         61115  
259 1     1   3 password => +{ type => 'string', sub => sub ($) { 1 } },
  1         3  
  1         17860  
260 116         250916 );
261              
262 116         87887 foreach my $pairs (pairs DEFAULT_SCHEMAS->%*) {
263 696         403433 my ($filename, $uri) = @$pairs;
264 696         13702 my $document = $js->add_schema($uri,
265             $js->_json_decoder->decode(path(dist_dir('OpenAPI-Modern'), $filename)->slurp_raw));
266 696 100       30837417 $js->add_schema($uri.'/latest', $document) if $uri =~ /schema(-base)?$/;
267             }
268             }
269              
270             # https://spec.openapis.org/oas/v3.1.0#schema-object
271 152     152   12438 sub _traverse_schema ($self, $schema, $state) {
  152         293  
  152         307  
  152         254  
  152         235  
272 152 100 100     1076 return if not is_plain_hashref($schema) or not keys %$schema;
273              
274             my $subschema_state = $self->evaluator->traverse($schema, {
275             %$state, # so we don't have to enumerate everything that may be in config_override
276             initial_schema_uri => canonical_uri($state),
277             traversed_schema_path => $state->{traversed_schema_path}.$state->{schema_path},
278 116         859 metaschema_uri => $self->json_schema_dialect,
279             });
280              
281 116         198519 push $state->{errors}->@*, $subschema_state->{errors}->@*;
282 116 50       476 return if $subschema_state->{errors}->@*;
283              
284 116         1252 push $state->{identifiers}->@*, $subschema_state->{identifiers}->@*;
285             }
286              
287             1;
288              
289             __END__
290              
291             =pod
292              
293             =encoding UTF-8
294              
295             =head1 NAME
296              
297             JSON::Schema::Modern::Document::OpenAPI - One OpenAPI v3.1 document
298              
299             =head1 VERSION
300              
301             version 0.045
302              
303             =head1 SYNOPSIS
304              
305             use JSON::Schema::Modern;
306             use JSON::Schema::Modern::Document::OpenAPI;
307              
308             my $js = JSON::Schema::Modern->new;
309             my $openapi_document = JSON::Schema::Modern::Document::OpenAPI->new(
310             evaluator => $js,
311             canonical_uri => 'https://example.com/v1/api',
312             schema => $schema,
313             metaschema_uri => 'https://example.com/my_custom_dialect',
314             );
315              
316             =head1 DESCRIPTION
317              
318             Provides structured parsing of an OpenAPI document, suitable as the base for more tooling such as
319             request and response validation, code generation or form generation.
320              
321             The provided document must be a valid OpenAPI document, as specified by the schema identified by
322             C<https://spec.openapis.org/oas/3.1/schema-base/latest> (an alias for the latest document available)
323              
324             and the L<OpenAPI v3.1 specification|https://spec.openapis.org/oas/v3.1.0>.
325              
326             =head1 ATTRIBUTES
327              
328             These values are all passed as arguments to the constructor.
329              
330             This class inherits all options from L<JSON::Schema::Modern::Document> and implements the following
331             new ones:
332              
333             =head2 evaluator
334              
335             =for stopwords metaschema schemas
336              
337             A L<JSON::Schema::Modern> object. Unlike in the parent class, this is B<REQUIRED>, because loaded
338             vocabularies, metaschemas and resource identifiers must be stored here as they are discovered in the
339             OpenAPI document. This is the object that will be used for subsequent evaluation of data against
340             schemas in the document, either manually or perhaps via a web framework plugin (coming soon).
341              
342             =head2 metaschema_uri
343              
344             The URI of the schema that describes the OpenAPI document itself. Defaults to
345             C<https://spec.openapis.org/oas/3.1/schema-base/latest> (an alias for the latest document
346             available).
347              
348             =head2 json_schema_dialect
349              
350             The URI of the metaschema to use for all embedded L<JSON Schemas|https://json-schema.org/> in the
351             document.
352              
353             Overrides the value of C<jsonSchemaDialect> in the document, or the specification default
354             (C<https://spec.openapis.org/oas/3.1/dialect/base>).
355              
356             If you specify your own dialect here or in C<jsonSchemaDialect>, then you need to add the
357             vocabularies and schemas to the implementation yourself. (see C<JSON::Schema::Modern/add_vocabulary>
358             and C<JSON::Schema::Modern/add_schema>).
359              
360             Note this is B<NOT> the same as L<JSON::Schema::Modern::Document/metaschema_uri>, which contains the
361             URI describing the entire document (and is not a metaschema in this case, as the entire document is
362             not a JSON Schema). Note that you may need to explicitly set that attribute as well if you change
363             C<json_schema_dialect>, as the default metaschema used by the default C<metaschema_uri> can no
364             longer be assumed.
365              
366             =head1 METHODS
367              
368             =head2 get_operationId_path
369              
370             Returns the json pointer location of the operation containing the provided C<operationId> (suitable
371             for passing to C<< $document->get(..) >>), or C<undef> if the location does not exist in the
372             document.
373              
374             =head1 SEE ALSO
375              
376             =over 4
377              
378             =item *
379              
380             L<Mojolicious::Plugin::OpenAPI::Modern>
381              
382             =item *
383              
384             L<OpenAPI::Modern>
385              
386             =item *
387              
388             L<JSON::Schema::Modern>
389              
390             =item *
391              
392             L<https://json-schema.org>
393              
394             =item *
395              
396             L<https://www.openapis.org/>
397              
398             =item *
399              
400             L<https://oai.github.io/Documentation/>
401              
402             =item *
403              
404             L<https://spec.openapis.org/oas/v3.1.0>
405              
406             =back
407              
408             =head1 SUPPORT
409              
410             Bugs may be submitted through L<https://github.com/karenetheridge/OpenAPI-Modern/issues>.
411              
412             I am also usually active on irc, as 'ether' at C<irc.perl.org> and C<irc.libera.chat>.
413              
414             You can also find me on the L<JSON Schema Slack server|https://json-schema.slack.com> and L<OpenAPI
415             Slack server|https://open-api.slack.com>, which are also great resources for finding help.
416              
417             =head1 AUTHOR
418              
419             Karen Etheridge <ether@cpan.org>
420              
421             =head1 COPYRIGHT AND LICENCE
422              
423             This software is copyright (c) 2021 by Karen Etheridge.
424              
425             This is free software; you can redistribute it and/or modify it under
426             the same terms as the Perl 5 programming language system itself.
427              
428             Some schema files have their own licence, in share/oas/LICENSE.
429              
430             =cut