File Coverage

blib/lib/JSON/Schema/Modern/Document/OpenAPI.pm
Criterion Covered Total %
statement 163 163 100.0
branch 28 30 93.3
condition 15 17 88.2
subroutine 34 34 100.0
pod 0 1 0.0
total 240 245 97.9


line stmt bran cond sub pod time code
1 9     9   3189793 use strict;
  9         68  
  9         268  
2 9     9   48 use warnings;
  9         63  
  9         437  
3             package JSON::Schema::Modern::Document::OpenAPI; # git description: v0.020-5-g33cf24e
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.021';
9              
10 9     9   188 use 5.020; # for fc, unicode_strings features
  9         31  
11 9     9   502 use Moo;
  9         6011  
  9         62  
12 9     9   4721 use strictures 2;
  9         1550  
  9         349  
13 9     9   1719 use experimental qw(signatures postderef);
  9         22  
  9         68  
14 9     9   1563 use if "$]" >= 5.022, experimental => 're_strict';
  9         20  
  9         111  
15 9     9   731 no if "$]" >= 5.031009, feature => 'indirect';
  9         17  
  9         60  
16 9     9   440 no if "$]" >= 5.033001, feature => 'multidimensional';
  9         29  
  9         47  
17 9     9   382 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  9         25  
  9         57  
18 9     9   1008 use JSON::Schema::Modern::Utilities 0.525 qw(assert_keyword_exists assert_keyword_type E canonical_uri get_type);
  9         202785  
  9         699  
19 9     9   74 use Safe::Isa;
  9         19  
  9         1206  
20 9     9   658 use File::ShareDir 'dist_dir';
  9         21685  
  9         538  
21 9     9   802 use Path::Tiny;
  9         10562  
  9         508  
22 9     9   59 use List::Util qw(any pairs);
  9         15  
  9         601  
23 9     9   63 use Ref::Util 'is_plain_hashref';
  9         25  
  9         421  
24 9     9   656 use MooX::HandlesVia;
  9         685  
  9         61  
25 9     9   1027 use MooX::TypeTiny 0.002002;
  9         171  
  9         65  
26 9     9   6345 use Types::Standard qw(InstanceOf HashRef Str);
  9         20  
  9         80  
27 9     9   7026 use namespace::clean;
  9         20  
  9         88  
28              
29             extends 'JSON::Schema::Modern::Document';
30              
31 9     9   3427 use constant DEFAULT_DIALECT => 'https://spec.openapis.org/oas/3.1/dialect/base';
  9         18  
  9         808  
32              
33 9         571 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', # openapi document schema + custom json schema dialect
38             'oas/schema.json' => 'https://spec.openapis.org/oas/3.1/schema', # the main openapi document schema
39 9     9   56 };
  9         19  
40              
41 9     9   59 use constant DEFAULT_METASCHEMA => 'https://spec.openapis.org/oas/3.1/schema-base/latest';
  9         24  
  9         13552  
42              
43             has '+evaluator' => (
44             required => 1,
45             );
46              
47             has '+metaschema_uri' => (
48             default => DEFAULT_METASCHEMA,
49             );
50              
51             has json_schema_dialect => (
52             is => 'rwp',
53             isa => InstanceOf['Mojo::URL'],
54             coerce => sub { $_[0]->$_isa('Mojo::URL') ? $_[0] : Mojo::URL->new($_[0]) },
55             );
56              
57             # operationId => document path
58             has operationIds => (
59             is => 'bare',
60             isa => HashRef[Str],
61             handles_via => 'Hash',
62             handles => {
63             _add_operationId => 'set',
64             get_operationId => 'get',
65             },
66             lazy => 1,
67             default => sub { {} },
68             );
69              
70 88     88 0 367504 sub traverse ($self, $evaluator) {
  88         204  
  88         182  
  88         186  
71 88         384 $self->_add_vocab_and_default_schemas;
72              
73 88         121756 my $schema = $self->schema;
74 88         1689 my $state = {
75             initial_schema_uri => $self->canonical_uri,
76             traversed_schema_path => '',
77             schema_path => '',
78             data_path => '',
79             errors => [],
80             evaluator => $evaluator,
81             identifiers => [],
82             configs => {},
83             spec_version => $evaluator->SPECIFICATION_VERSION_DEFAULT,
84             vocabularies => [],
85             };
86              
87 88 100       1871 if ((my $type = get_type($schema)) ne 'object') {
88 1         28 ()= E($state, 'invalid document type: %s', $type);
89 1         599 return $state;
90             }
91              
92             # /openapi: https://spec.openapis.org/oas/v3.1.0#openapi-object
93 87 100 66     1771 return $state if not assert_keyword_exists({ %$state, keyword => 'openapi' }, $schema)
94             or not assert_keyword_type({ %$state, keyword => 'openapi' }, $schema, 'string');
95              
96 86 100       6100 if ($schema->{openapi} !~ /^3\.1\.[0-9]+(-.+)?$/) {
97 1         8 ()= E({ %$state, keyword => 'openapi' }, 'unrecognized openapi version %s', $schema->{openapi});
98 1         652 return $state;
99             }
100              
101              
102             # /jsonSchemaDialect: https://spec.openapis.org/oas/v3.1.0#specifying-schema-dialects
103             {
104 85         255 return $state if exists $schema->{jsonSchemaDialect}
105 85 100 100     418 and not assert_keyword_type({ %$state, keyword => 'jsonSchemaDialect' }, $schema, 'string');
106              
107 84   66     733 my $json_schema_dialect = $self->json_schema_dialect // $schema->{jsonSchemaDialect};
108              
109             # "If [jsonSchemaDialect] is not set, then the OAS dialect schema id MUST be used for these Schema Objects."
110 84   100     530 $json_schema_dialect //= DEFAULT_DIALECT;
111              
112             # traverse an empty schema with this metaschema uri to confirm it is valid
113 84         2000 my $check_metaschema_state = $evaluator->traverse({}, {
114             metaschema_uri => $json_schema_dialect,
115             initial_schema_uri => $self->canonical_uri->clone->fragment('/jsonSchemaDialect'),
116             });
117              
118             # we cannot continue if the metaschema is invalid
119 84 100       18081 if ($check_metaschema_state->{errors}->@*) {
120 1         36 push $state->{errors}->@*, $check_metaschema_state->{errors}->@*;
121 1         9 return $state;
122             }
123              
124 83         386 $state->@{qw(spec_version vocabularies)} = $check_metaschema_state->@{qw(spec_version vocabularies)};
125 83         1957 $self->_set_json_schema_dialect($json_schema_dialect);
126             }
127              
128             # evaluate the document against its metaschema to find any errors, to identify all schema
129             # resources within to add to the global resource index, and to extract all operationIds
130 83         10111 my (@json_schema_paths, @operation_paths);
131 126         292 my $result = $self->evaluator->evaluate(
132             $self->schema,
133             $self->metaschema_uri,
134             {
135             callbacks => {
136 126     126   224 '$dynamicRef' => sub ($, $schema, $state) {
  126         101747  
  126         270  
137 126 50       772 push @json_schema_paths, $state->{data_path} if $schema->{'$dynamicRef'} eq '#meta';
138             },
139 3139     3139   3995 '$ref' => sub ($data, $schema, $state) {
  3139         18364285  
  3139         5992  
  3139         5460  
  3139         4631  
140             push @operation_paths, [ $data->{operationId} => $state->{data_path} ]
141 3139 100 100     12852 if $schema->{'$ref'} eq '#/$defs/operation' and defined $data->{operationId};
142             },
143             },
144             },
145 83         1575 );
146              
147 83 100       108083 if (not $result) {
148 4         128 push $state->{errors}->@*, $result->errors;
149 4         240 return $state;
150             }
151              
152 79         1303 my @real_json_schema_paths;
153 79         378 foreach my $path (sort @json_schema_paths) {
154             # disregard paths that are not the root of each embedded subschema.
155 126 100   196   847 next if any { return $path =~ m{^\Q$_\E(?:/|\z)} } @real_json_schema_paths;
  196         2955  
156              
157 110         587 unshift @real_json_schema_paths, $path;
158 110         505 $self->_traverse_schema($self->get($path), { %$state, schema_path => $path });
159             }
160              
161 79         323 foreach my $pair (@operation_paths) {
162 30         4340 my ($operation_id, $path) = @$pair;
163 30 100       518 if (my $existing = $self->get_operationId($operation_id)) {
164 6         521 ()= E({ %$state, keyword => 'operationId', schema_path => $path },
165             'duplicate of operationId at %s', $existing);
166             }
167             else {
168 24         2154 $self->_add_operationId($operation_id => $path);
169             }
170             }
171              
172 79         1938 return $state;
173             }
174              
175             ######## NO PUBLIC INTERFACES FOLLOW THIS POINT ########
176              
177 88     88   192 sub _add_vocab_and_default_schemas ($self) {
  88         210  
  88         164  
178 88         274 my $js = $self->evaluator;
179 88         517 $js->add_vocabulary('JSON::Schema::Modern::Vocabulary::OpenAPI');
180              
181 5         12 $js->add_format_validation(
182 5     5   10 int32 => +{ type => 'integer', sub => sub ($x) {
  5         15998  
183 5         38 require Math::BigInt;
184 5         41 $x = Math::BigInt->new($x);
185 5         452 my $bound = Math::BigInt->new(2) ** 31;
186 5 100       1585 $x >= -$bound && $x < $bound;
187             } },
188 5     5   9 int64 => +{ type => 'integer', sub => sub ($x) {
  5         15823  
  5         12  
189 5         38 require Math::BigInt;
190 5         38 $x = Math::BigInt->new($x);
191 5         407 my $bound = Math::BigInt->new(2) ** 63;
192 5 100       2005 $x >= -$bound && $x < $bound;
193             } },
194 6     6   13 float => +{ type => 'number', sub => sub ($) { 1 } },
  6         17  
  6         19123  
195 6     6   14 double => +{ type => 'number', sub => sub ($) { 1 } },
  6         15  
  6         18947  
196 1     1   2 password => +{ type => 'string', sub => sub ($) { 1 } },
  1         3  
  1         3174  
197 88         147381 );
198              
199 88         54533 foreach my $pairs (pairs DEFAULT_SCHEMAS->%*) {
200 352         180594 my ($filename, $uri) = @$pairs;
201 352         6040 my $document = $js->add_schema($uri,
202             $js->_json_decoder->decode(path(dist_dir('JSON-Schema-Modern-Document-OpenAPI'), $filename)->slurp_raw));
203 352 100       16772263 $js->add_schema($uri.'/latest', $document) if $uri =~ /schema(-base)?$/;
204             }
205             }
206              
207             # https://spec.openapis.org/oas/v3.1.0#schema-object
208 110     110   7618 sub _traverse_schema ($self, $schema, $state) {
  110         213  
  110         209  
  110         184  
  110         165  
209 110 100 100     822 return if not is_plain_hashref($schema) or not keys %$schema;
210              
211             my $subschema_state = $self->evaluator->traverse($schema, {
212             %$state, # so we don't have to ennumerate everything that may be in config_override
213             initial_schema_uri => canonical_uri($state),
214             traversed_schema_path => $state->{traversed_schema_path}.$state->{schema_path},
215 84         619 metaschema_uri => $self->json_schema_dialect,
216             });
217              
218 84         3115 push $state->{errors}->@*, $subschema_state->{errors}->@*;
219 84 50       476 return if $subschema_state->{errors}->@*;
220              
221 84         864 push $state->{identifiers}->@*, $subschema_state->{identifiers}->@*;
222             }
223              
224             1;
225              
226             __END__
227              
228             =pod
229              
230             =encoding UTF-8
231              
232             =head1 NAME
233              
234             JSON::Schema::Modern::Document::OpenAPI - One OpenAPI v3.1 document
235              
236             =head1 VERSION
237              
238             version 0.021
239              
240             =head1 SYNOPSIS
241              
242             use JSON::Schema::Modern;
243             use JSON::Schema::Modern::Document::OpenAPI;
244              
245             my $js = JSON::Schema::Modern->new;
246             my $openapi_document = JSON::Schema::Modern::Document::OpenAPI->new(
247             evaluator => $js,
248             canonical_uri => 'https://example.com/v1/api',
249             schema => $schema,
250             metaschema_uri => 'https://example.com/my_custom_dialect',
251             );
252              
253             =head1 DESCRIPTION
254              
255             Provides structured parsing of an OpenAPI document, suitable as the base for more tooling such as
256             request and response validation, code generation or form generation.
257              
258             The provided document must be a valid OpenAPI document, as specified by the schema identified by
259             C<https://spec.openapis.org/oas/3.1/schema-base/latest> (an alias for the latest document available)
260              
261             and the L<OpenAPI v3.1 specification|https://spec.openapis.org/oas/v3.1.0>.
262              
263             =head1 ATTRIBUTES
264              
265             These values are all passed as arguments to the constructor.
266              
267             This class inherits all options from L<JSON::Schema::Modern::Document> and implements the following new ones:
268              
269             =head2 evaluator
270              
271             =for stopwords metaschema schemas
272              
273             A L<JSON::Schema::Modern> object. Unlike in the parent class, this is B<REQUIRED>, because loaded
274             vocabularies, metaschemas and resource identifiers must be stored here as they are discovered in the
275             OpenAPI document. This is the object that will be used for subsequent evaluation of data against
276             schemas in the document, either manually or perhaps via a web framework plugin (coming soon).
277              
278             =head2 metaschema_uri
279              
280             The URI of the schema that describes the OpenAPI document itself. Defaults to
281             C<https://spec.openapis.org/oas/3.1/schema-base/latest> (an alias for the latest document available).
282              
283             =head2 json_schema_dialect
284              
285             The URI of the metaschema to use for all embedded L<JSON Schemas|https://json-schema.org/> in the
286             document.
287              
288             Overrides the value of C<jsonSchemaDialect> in the document, or the specification default
289             (C<https://spec.openapis.org/oas/3.1/dialect/base>).
290              
291             If you specify your own dialect here or in C<jsonSchemaDialect>, then you need to add the
292             vocabularies and schemas to the implementation yourself. (see C<JSON::Schema::Modern/add_vocabulary>
293             and C<JSON::Schema::Modern/add_schema>).
294              
295             Note this is B<NOT> the same as L<JSON::Schema::Modern::Document/metaschema_uri>, which contains the
296             URI describing the entire document (and is not a metaschema in this case, as the entire document is
297             not a JSON Schema). Note that you may need to explicitly set that attribute as well if you change
298             C<json_schema_dialect>, as the default metaschema used by the default C<metaschema_uri> can no
299             longer be assumed.
300              
301             =head1 METHODS
302              
303             =head2 get_operationId
304              
305             Returns the json pointer location of the operation containing the provided C<operationId> (suitable
306             for passing to C<< $document->get(..) >>), or C<undef> if it is not contained in the document.
307              
308             =head1 SUPPORT
309              
310             Bugs may be submitted through L<https://github.com/karenetheridge/JSON-Schema-Modern-Document-OpenAPI/issues>.
311              
312             I am also usually active on irc, as 'ether' at C<irc.perl.org> and C<irc.libera.chat>.
313              
314             You can also find me on the L<JSON Schema Slack server|https://json-schema.slack.com> and L<OpenAPI Slack
315             server|https://open-api.slack.com>, which are also great resources for finding help.
316              
317             =head1 AUTHOR
318              
319             Karen Etheridge <ether@cpan.org>
320              
321             =head1 COPYRIGHT AND LICENCE
322              
323             This software is copyright (c) 2021 by Karen Etheridge.
324              
325             This is free software; you can redistribute it and/or modify it under
326             the same terms as the Perl 5 programming language system itself.
327              
328             =cut