File Coverage

blib/lib/JSON/Schema/Modern/Document/OpenAPI.pm
Criterion Covered Total %
statement 187 187 100.0
branch 30 32 93.7
condition 16 17 94.1
subroutine 36 36 100.0
pod 0 1 0.0
total 269 273 98.5


line stmt bran cond sub pod time code
1 11     11   6134210 use strict;
  11         93  
  11         375  
2 11     11   76 use warnings;
  11         41  
  11         576  
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.046';
9              
10 11     11   255 use 5.020;
  11         45  
11 11     11   592 use Moo;
  11         6956  
  11         96  
12 11     11   5936 use strictures 2;
  11         141  
  11         549  
13 11     11   2593 use stable 0.031 'postderef';
  11         222  
  11         100  
14 11     11   2175 use experimental 'signatures';
  11         43  
  11         59  
15 11     11   1000 use if "$]" >= 5.022, experimental => 're_strict';
  11         44  
  11         238  
16 11     11   1130 no if "$]" >= 5.031009, feature => 'indirect';
  11         30  
  11         161  
17 11     11   565 no if "$]" >= 5.033001, feature => 'multidimensional';
  11         40  
  11         155  
18 11     11   529 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  11         46  
  11         87  
19 11     11   1117 use JSON::Schema::Modern::Utilities 0.525 qw(E canonical_uri jsonp);
  11         280813  
  11         891  
20 11     11   105 use Safe::Isa;
  11         46  
  11         1757  
21 11     11   627 use File::ShareDir 'dist_dir';
  11         24338  
  11         676  
22 11     11   1130 use Path::Tiny;
  11         13763  
  11         672  
23 11     11   89 use List::Util 'pairs';
  11         44  
  11         998  
24 11     11   83 use Ref::Util 'is_plain_hashref';
  11         68  
  11         573  
25 11     11   618 use MooX::HandlesVia;
  11         906  
  11         112  
26 11     11   1924 use MooX::TypeTiny 0.002002;
  11         226  
  11         109  
27 11     11   9149 use Types::Standard qw(InstanceOf HashRef Str Enum);
  11         31  
  11         145  
28 11     11   24162 use namespace::clean;
  11         39  
  11         149  
29              
30             extends 'JSON::Schema::Modern::Document';
31              
32 11     11   5324 use constant DEFAULT_DIALECT => 'https://spec.openapis.org/oas/3.1/dialect/base';
  11         27  
  11         1305  
33              
34 11         828 use constant DEFAULT_SCHEMAS => {
35             # local filename => identifier to add the schema as
36             'oas/dialect/base.schema.json' => 'https://spec.openapis.org/oas/3.1/dialect/base', # metaschema for json schemas contained within openapi documents
37             'oas/meta/base.schema.json' => 'https://spec.openapis.org/oas/3.1/meta/base', # vocabulary definition
38             'oas/schema-base.json' => 'https://spec.openapis.org/oas/3.1/schema-base', # the main openapi document schema + draft2020-12 jsonSchemaDialect
39             'oas/schema.json' => 'https://spec.openapis.org/oas/3.1/schema', # the main openapi document schema + permissive jsonSchemaDialect
40             'strict-schema.json' => 'https://raw.githubusercontent.com/karenetheridge/OpenAPI-Modern/master/share/strict-schema.json',
41             'strict-dialect.json' => 'https://raw.githubusercontent.com/karenetheridge/OpenAPI-Modern/master/share/strict-dialect.json',
42 11     11   99 };
  11         25  
43              
44 11     11   86 use constant DEFAULT_BASE_METASCHEMA => 'https://spec.openapis.org/oas/3.1/schema-base/2022-10-07';
  11         40  
  11         784  
45 11     11   77 use constant DEFAULT_METASCHEMA => 'https://spec.openapis.org/oas/3.1/schema/2022-10-07';
  11         30  
  11         23747  
46              
47             has '+evaluator' => (
48             required => 1,
49             );
50              
51             has '+metaschema_uri' => (
52             default => DEFAULT_BASE_METASCHEMA,
53             );
54              
55             has json_schema_dialect => (
56             is => 'rwp',
57             isa => InstanceOf['Mojo::URL'],
58             coerce => sub { $_[0]->$_isa('Mojo::URL') ? $_[0] : Mojo::URL->new($_[0]) },
59             );
60              
61             my $reffable_entities = Enum[qw(response parameter example request-body header security-scheme link callbacks path-item)];
62              
63             # json pointer => entity name
64             has entities => (
65             is => 'bare',
66             isa => HashRef[$reffable_entities],
67             handles_via => 'Hash',
68             handles => {
69             _add_entity_location => 'set',
70             get_entity_at_location => 'get',
71             },
72             lazy => 1,
73             default => sub { {} },
74              
75             );
76              
77             # operationId => document path
78             has operationIds => (
79             is => 'bare',
80             isa => HashRef[Str],
81             handles_via => 'Hash',
82             handles => {
83             _add_operationId => 'set',
84             get_operationId_path => 'get',
85             },
86             lazy => 1,
87             default => sub { {} },
88             );
89              
90 118     118 0 582202 sub traverse ($self, $evaluator) {
  118         347  
  118         280  
  118         228  
91 118         561 $self->_add_vocab_and_default_schemas;
92              
93 118         14401 my $schema = $self->schema;
94 118         3213 my $state = {
95             initial_schema_uri => $self->canonical_uri,
96             traversed_schema_path => '',
97             schema_path => '',
98             data_path => '',
99             errors => [],
100             evaluator => $evaluator,
101             identifiers => [],
102             configs => {},
103             spec_version => $evaluator->SPECIFICATION_VERSION_DEFAULT,
104             vocabularies => [],
105             };
106              
107             # this is an abridged form of https://spec.openapis.org/oas/3.1/schema/latest
108             # just to validate the parts of the document we need to verify before parsing jsonSchemaDialect
109             # and switching to the real metaschema for this document
110 118         3178 state $top_schema = {
111             '$schema' => 'https://json-schema.org/draft/2020-12/schema',
112             type => 'object',
113             required => ['openapi'],
114             properties => {
115             openapi => {
116             type => 'string',
117             pattern => '', # just here for the callback so we can customize the error
118             },
119             jsonSchemaDialect => {
120             type => 'string',
121             format => 'uri',
122             },
123             },
124             };
125 115         354 my $top_result = $self->evaluator->evaluate(
126             $schema, $top_schema,
127             {
128             effective_base_uri => DEFAULT_METASCHEMA,
129             callbacks => {
130 115     115   243 pattern => sub ($data, $schema, $state) {
  115         944820  
  115         354  
  115         324  
131 115 100       991 return $data =~ /^3\.1\.[0-9]+(-.+)?$/ ? 1 : E($state, 'unrecognized openapi version %s', $data);
132             },
133             },
134             },
135 118         1740 );
136 118 100       160721 if (not $top_result) {
137 5         189 $_->mode('evaluate') foreach $top_result->errors;
138 5         637 push $state->{errors}->@*, $top_result->errors;
139 5         248 return $state;
140             }
141              
142             # /jsonSchemaDialect: https://spec.openapis.org/oas/v3.1.0#specifying-schema-dialects
143             {
144 113   66     2214 my $json_schema_dialect = $self->json_schema_dialect // $schema->{jsonSchemaDialect};
  113         1350  
145              
146             # "If [jsonSchemaDialect] is not set, then the OAS dialect schema id MUST be used for these Schema Objects."
147 113   100     829 $json_schema_dialect //= DEFAULT_DIALECT;
148              
149             # traverse an empty schema with this metaschema uri to confirm it is valid
150 113         2616 my $check_metaschema_state = $evaluator->traverse({}, {
151             metaschema_uri => $json_schema_dialect,
152             initial_schema_uri => $self->canonical_uri->clone->fragment('/jsonSchemaDialect'),
153             });
154              
155             # we cannot continue if the metaschema is invalid
156 113 100       521857 if ($check_metaschema_state->{errors}->@*) {
157             # these errors should be mode=traverse
158 1         5 push $state->{errors}->@*, $check_metaschema_state->{errors}->@*;
159 1         10 return $state;
160             }
161              
162 112         699 $state->@{qw(spec_version vocabularies)} = $check_metaschema_state->@{qw(spec_version vocabularies)};
163 112         3044 $self->_set_json_schema_dialect($json_schema_dialect);
164             }
165              
166             # evaluate the document against its metaschema to find any errors, to identify all schema
167             # resources within to add to the global resource index, and to extract all operationIds
168 112         16936 my (@json_schema_paths, @operation_paths);
169 200         585 my $result = $self->evaluator->evaluate(
170             $schema, $self->metaschema_uri,
171             {
172             short_circuit => 1,
173             callbacks => {
174 200     200   638 '$dynamicRef' => sub ($, $schema, $state) {
  200         87907  
  200         504  
175 200 50       1172 push @json_schema_paths, $state->{data_path} if $schema->{'$dynamicRef'} eq '#meta';
176 200         561 return 1;
177             },
178 4427     4427   6444 '$ref' => sub ($data, $schema, $state) {
  4427         26263035  
  4427         8351  
  4427         7321  
  4427         6554  
179 4427         25693 my ($entity) = ($schema->{'$ref'} =~ m{#/\$defs/([^/]+?)(?:-or-reference)?$});
180 4427 100 100     20217 $self->_add_entity_location($state->{data_path}, $entity)
181             if $entity and $reffable_entities->check($entity);
182              
183             push @operation_paths, [ $data->{operationId} => $state->{data_path} ]
184 4427 100 100     97453 if $schema->{'$ref'} eq '#/$defs/operation' and defined $data->{operationId};
185              
186 4427         10128 return 1;
187             },
188             },
189             },
190 112         2094 );
191              
192 112 100       94258 if (not $result) {
193 6         236 $_->mode('evaluate') foreach $result->errors;
194 6         1278 push $state->{errors}->@*, $result->errors;
195 6         332 return $state;
196             }
197              
198             # "Templated paths with the same hierarchy but different templated names MUST NOT exist as they
199             # are identical."
200 106         2214 my %seen_path;
201 106         944 foreach my $path (sort keys $schema->{paths}->%*) {
202 99         736 my $normalized = $path =~ s/\{[^}]+\}/\x00/r;
203 99 100       571 if (my $first_path = $seen_path{$normalized}) {
204 2         12 ()= E({ %$state, data_path => jsonp('/paths', $path),
205             initial_schema_uri => Mojo::URL->new(DEFAULT_METASCHEMA) },
206             'duplicate of templated path %s', $first_path);
207 2         1252 $state->{errors}[-1]->mode('evaluate');
208 2         24 next;
209             }
210 97         475 $seen_path{$normalized} = $path;
211             }
212              
213 106 100       706 return $state if $state->{errors}->@*;
214              
215             # disregard paths that are not the root of each embedded subschema.
216             # Because the callbacks are executed after the keyword has (recursively) finished evaluating,
217             # for each nested schema group. the schema paths appear longest first, with the parent schema
218             # appearing last. Therefore we can whittle down to the parent schema for each group by iterating
219             # through the full list in reverse, and checking if it is a child of the last path we chose to save.
220 105         285 my @real_json_schema_paths;
221 105         681 for (my $idx = $#json_schema_paths; $idx >= 0; --$idx) {
222 198 100 100     1679 next if $idx != $#json_schema_paths
223             and substr($json_schema_paths[$idx], 0, length($real_json_schema_paths[-1])+1)
224             eq $real_json_schema_paths[-1].'/';
225              
226 152         474 push @real_json_schema_paths, $json_schema_paths[$idx];
227 152         734 $self->_traverse_schema($self->get($json_schema_paths[$idx]), { %$state, schema_path => $json_schema_paths[$idx] });
228             }
229              
230 105         536 foreach my $pair (@operation_paths) {
231 24         4941 my ($operation_id, $path) = @$pair;
232 24 100       512 if (my $existing = $self->get_operationId_path($operation_id)) {
233 6         650 ()= E({ %$state, data_path => $path .'/operationId',
234             initial_schema_uri => Mojo::URL->new(DEFAULT_METASCHEMA) },
235             'duplicate of operationId at %s', $existing);
236             }
237             else {
238 18         2024 $self->_add_operationId($operation_id => $path);
239             }
240             }
241              
242 105         1988 $_->mode('evaluate') foreach $state->{errors}->@*;
243 105         1097 return $state;
244             }
245              
246             ######## NO PUBLIC INTERFACES FOLLOW THIS POINT ########
247              
248 118     118   317 sub _add_vocab_and_default_schemas ($self) {
  118         300  
  118         237  
249 118         365 my $js = $self->evaluator;
250 118         783 $js->add_vocabulary('JSON::Schema::Modern::Vocabulary::OpenAPI');
251              
252 5         10 $js->add_format_validation(
253 5     5   7 int32 => +{ type => 'integer', sub => sub ($x) {
  5         130664  
254 5         29 require Math::BigInt;
255 5         21 $x = Math::BigInt->new($x);
256 5         395 my $bound = Math::BigInt->new(2) ** 31;
257 5 100       1581 $x >= -$bound && $x < $bound;
258             } },
259 5     5   8 int64 => +{ type => 'integer', sub => sub ($x) {
  5         54829  
  5         11  
260 5         27 require Math::BigInt;
261 5         21 $x = Math::BigInt->new($x);
262 5         324 my $bound = Math::BigInt->new(2) ** 63;
263 5 100       1897 $x >= -$bound && $x < $bound;
264             } },
265 6     6   12 float => +{ type => 'number', sub => sub ($) { 1 } },
  6         13  
  6         62647  
266 6     6   13 double => +{ type => 'number', sub => sub ($) { 1 } },
  6         13  
  6         61155  
267 1     1   5 password => +{ type => 'string', sub => sub ($) { 1 } },
  1         3  
  1         22602  
268 118         262701 );
269              
270 118         93225 foreach my $pairs (pairs DEFAULT_SCHEMAS->%*) {
271 708         475492 my ($filename, $uri) = @$pairs;
272 708         14496 my $document = $js->add_schema($uri,
273             $js->_json_decoder->decode(path(dist_dir('OpenAPI-Modern'), $filename)->slurp_raw));
274 708 100       31751374 $js->add_schema($uri.'/latest', $document) if $uri =~ /schema(-base)?$/;
275             }
276             }
277              
278             # https://spec.openapis.org/oas/v3.1.0#schema-object
279 152     152   12579 sub _traverse_schema ($self, $schema, $state) {
  152         304  
  152         270  
  152         298  
  152         314  
280 152 100 100     1120 return if not is_plain_hashref($schema) or not keys %$schema;
281              
282             my $subschema_state = $self->evaluator->traverse($schema, {
283             %$state, # so we don't have to enumerate everything that may be in config_override
284             initial_schema_uri => canonical_uri($state),
285             traversed_schema_path => $state->{traversed_schema_path}.$state->{schema_path},
286 116         830 metaschema_uri => $self->json_schema_dialect,
287             });
288              
289 116         201920 push $state->{errors}->@*, $subschema_state->{errors}->@*;
290 116 50       517 return if $subschema_state->{errors}->@*;
291              
292 116         1473 push $state->{identifiers}->@*, $subschema_state->{identifiers}->@*;
293             }
294              
295             1;
296              
297             __END__
298              
299             =pod
300              
301             =encoding UTF-8
302              
303             =head1 NAME
304              
305             JSON::Schema::Modern::Document::OpenAPI - One OpenAPI v3.1 document
306              
307             =head1 VERSION
308              
309             version 0.046
310              
311             =head1 SYNOPSIS
312              
313             use JSON::Schema::Modern;
314             use JSON::Schema::Modern::Document::OpenAPI;
315              
316             my $js = JSON::Schema::Modern->new;
317             my $openapi_document = JSON::Schema::Modern::Document::OpenAPI->new(
318             evaluator => $js,
319             canonical_uri => 'https://example.com/v1/api',
320             schema => $schema,
321             metaschema_uri => 'https://example.com/my_custom_dialect',
322             );
323              
324             =head1 DESCRIPTION
325              
326             Provides structured parsing of an OpenAPI document, suitable as the base for more tooling such as
327             request and response validation, code generation or form generation.
328              
329             The provided document must be a valid OpenAPI document, as specified by the schema identified by
330             C<https://spec.openapis.org/oas/3.1/schema-base/latest> (an alias for the latest document available)
331              
332             and the L<OpenAPI v3.1 specification|https://spec.openapis.org/oas/v3.1.0>.
333              
334             =head1 ATTRIBUTES
335              
336             These values are all passed as arguments to the constructor.
337              
338             This class inherits all options from L<JSON::Schema::Modern::Document> and implements the following
339             new ones:
340              
341             =head2 evaluator
342              
343             =for stopwords metaschema schemas
344              
345             A L<JSON::Schema::Modern> object. Unlike in the parent class, this is B<REQUIRED>, because loaded
346             vocabularies, metaschemas and resource identifiers must be stored here as they are discovered in the
347             OpenAPI document. This is the object that will be used for subsequent evaluation of data against
348             schemas in the document, either manually or perhaps via a web framework plugin (coming soon).
349              
350             =head2 metaschema_uri
351              
352             The URI of the schema that describes the OpenAPI document itself. Defaults to
353             C<https://spec.openapis.org/oas/3.1/schema-base/latest> (an alias for the latest document
354             available).
355              
356             =head2 json_schema_dialect
357              
358             The URI of the metaschema to use for all embedded L<JSON Schemas|https://json-schema.org/> in the
359             document.
360              
361             Overrides the value of C<jsonSchemaDialect> in the document, or the specification default
362             (C<https://spec.openapis.org/oas/3.1/dialect/base>).
363              
364             If you specify your own dialect here or in C<jsonSchemaDialect>, then you need to add the
365             vocabularies and schemas to the implementation yourself. (see C<JSON::Schema::Modern/add_vocabulary>
366             and C<JSON::Schema::Modern/add_schema>).
367              
368             Note this is B<NOT> the same as L<JSON::Schema::Modern::Document/metaschema_uri>, which contains the
369             URI describing the entire document (and is not a metaschema in this case, as the entire document is
370             not a JSON Schema). Note that you may need to explicitly set that attribute as well if you change
371             C<json_schema_dialect>, as the default metaschema used by the default C<metaschema_uri> can no
372             longer be assumed.
373              
374             =head1 METHODS
375              
376             =head2 get_operationId_path
377              
378             Returns the json pointer location of the operation containing the provided C<operationId> (suitable
379             for passing to C<< $document->get(..) >>), or C<undef> if the location does not exist in the
380             document.
381              
382             =head1 SEE ALSO
383              
384             =over 4
385              
386             =item *
387              
388             L<Mojolicious::Plugin::OpenAPI::Modern>
389              
390             =item *
391              
392             L<OpenAPI::Modern>
393              
394             =item *
395              
396             L<JSON::Schema::Modern>
397              
398             =item *
399              
400             L<https://json-schema.org>
401              
402             =item *
403              
404             L<https://www.openapis.org/>
405              
406             =item *
407              
408             L<https://oai.github.io/Documentation/>
409              
410             =item *
411              
412             L<https://spec.openapis.org/oas/v3.1.0>
413              
414             =back
415              
416             =head1 SUPPORT
417              
418             Bugs may be submitted through L<https://github.com/karenetheridge/OpenAPI-Modern/issues>.
419              
420             I am also usually active on irc, as 'ether' at C<irc.perl.org> and C<irc.libera.chat>.
421              
422             You can also find me on the L<JSON Schema Slack server|https://json-schema.slack.com> and L<OpenAPI
423             Slack server|https://open-api.slack.com>, which are also great resources for finding help.
424              
425             =head1 AUTHOR
426              
427             Karen Etheridge <ether@cpan.org>
428              
429             =head1 COPYRIGHT AND LICENCE
430              
431             This software is copyright (c) 2021 by Karen Etheridge.
432              
433             This is free software; you can redistribute it and/or modify it under
434             the same terms as the Perl 5 programming language system itself.
435              
436             Some schema files have their own licence, in share/oas/LICENSE.
437              
438             =cut