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   3175789 use strict;
  9         87  
  9         337  
2 9     9   51 use warnings;
  9         78  
  9         519  
3             package JSON::Schema::Modern::Document::OpenAPI; # git description: v0.019-17-ge120405
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.020';
9              
10 9     9   217 use 5.020; # for fc, unicode_strings features
  9         41  
11 9     9   519 use Moo;
  9         7016  
  9         57  
12 9     9   5149 use strictures 2;
  9         1681  
  9         353  
13 9     9   1863 use experimental qw(signatures postderef);
  9         21  
  9         88  
14 9     9   1739 use if "$]" >= 5.022, experimental => 're_strict';
  9         31  
  9         105  
15 9     9   792 no if "$]" >= 5.031009, feature => 'indirect';
  9         20  
  9         55  
16 9     9   436 no if "$]" >= 5.033001, feature => 'multidimensional';
  9         22  
  9         51  
17 9     9   373 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  9         25  
  9         61  
18 9     9   1210 use JSON::Schema::Modern::Utilities 0.525 qw(assert_keyword_exists assert_keyword_type E canonical_uri get_type);
  9         225193  
  9         777  
19 9     9   155 use Safe::Isa;
  9         19  
  9         1258  
20 9     9   751 use File::ShareDir 'dist_dir';
  9         22795  
  9         509  
21 9     9   949 use Path::Tiny;
  9         11901  
  9         516  
22 9     9   62 use List::Util qw(any pairs);
  9         31  
  9         644  
23 9     9   74 use Ref::Util 'is_plain_hashref';
  9         18  
  9         411  
24 9     9   751 use MooX::HandlesVia;
  9         821  
  9         85  
25 9     9   1149 use MooX::TypeTiny 0.002002;
  9         176  
  9         65  
26 9     9   6601 use Types::Standard qw(InstanceOf HashRef Str);
  9         21  
  9         90  
27 9     9   7187 use namespace::clean;
  9         21  
  9         92  
28              
29             extends 'JSON::Schema::Modern::Document';
30              
31 9     9   3546 use constant DEFAULT_DIALECT => 'https://spec.openapis.org/oas/3.1/dialect/base';
  9         18  
  9         826  
32              
33 9         630 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   65 };
  9         21  
40              
41 9     9   56 use constant DEFAULT_METASCHEMA => 'https://spec.openapis.org/oas/3.1/schema-base/latest';
  9         16  
  9         14300  
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 390271 sub traverse ($self, $evaluator) {
  88         266  
  88         194  
  88         179  
71 88         379 $self->_add_vocab_and_default_schemas;
72              
73 88         110007 my $schema = $self->schema;
74 88         1651 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       1857 if ((my $type = get_type($schema)) ne 'object') {
88 1         30 ()= E($state, 'invalid document type: %s', $type);
89 1         619 return $state;
90             }
91              
92             # /openapi: https://spec.openapis.org/oas/v3.1.0#openapi-object
93 87 100 66     1872 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       6220 if ($schema->{openapi} !~ /^3\.1\.[0-9]+(-.+)?$/) {
97 1         12 ()= E({ %$state, keyword => 'openapi' }, 'unrecognized openapi version %s', $schema->{openapi});
98 1         603 return $state;
99             }
100              
101              
102             # /jsonSchemaDialect: https://spec.openapis.org/oas/v3.1.0#specifying-schema-dialects
103             {
104 85         200 return $state if exists $schema->{jsonSchemaDialect}
105 85 100 100     434 and not assert_keyword_type({ %$state, keyword => 'jsonSchemaDialect' }, $schema, 'string');
106              
107 84   66     725 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     515 $json_schema_dialect //= DEFAULT_DIALECT;
111              
112             # traverse an empty schema with this metaschema uri to confirm it is valid
113 84         1555 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       16706 if ($check_metaschema_state->{errors}->@*) {
120 1         51 push $state->{errors}->@*, $check_metaschema_state->{errors}->@*;
121 1         8 return $state;
122             }
123              
124 83         384 $state->@{qw(spec_version vocabularies)} = $check_metaschema_state->@{qw(spec_version vocabularies)};
125 83         1913 $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         10632 my (@json_schema_paths, @operation_paths);
131 126         316 my $result = $self->evaluator->evaluate(
132             $self->schema,
133             $self->metaschema_uri,
134             {
135             callbacks => {
136 126     126   301 '$dynamicRef' => sub ($, $schema, $state) {
  126         98215  
  126         285  
137 126 50       824 push @json_schema_paths, $state->{data_path} if $schema->{'$dynamicRef'} eq '#meta';
138             },
139 3139     3139   4379 '$ref' => sub ($data, $schema, $state) {
  3139         18558286  
  3139         6194  
  3139         5608  
  3139         4917  
140             push @operation_paths, [ $data->{operationId} => $state->{data_path} ]
141 3139 100 100     13427 if $schema->{'$ref'} eq '#/$defs/operation' and defined $data->{operationId};
142             },
143             },
144             },
145 83         1554 );
146              
147 83 100       111546 if (not $result) {
148 4         136 push $state->{errors}->@*, $result->errors;
149 4         205 return $state;
150             }
151              
152 79         1315 my @real_json_schema_paths;
153 79         412 foreach my $path (sort @json_schema_paths) {
154             # disregard paths that are not the root of each embedded subschema.
155 126 100   196   795 next if any { return $path =~ m{^\Q$_\E(?:/|\z)} } @real_json_schema_paths;
  196         3263  
156              
157 110         613 unshift @real_json_schema_paths, $path;
158 110         540 $self->_traverse_schema($self->get($path), { %$state, schema_path => $path });
159             }
160              
161 79         279 foreach my $pair (@operation_paths) {
162 30         4276 my ($operation_id, $path) = @$pair;
163 30 100       507 if (my $existing = $self->get_operationId($operation_id)) {
164 6         501 ()= E({ %$state, keyword => 'operationId', schema_path => $path },
165             'duplicate of operationId at %s', $existing);
166             }
167             else {
168 24         2165 $self->_add_operationId($operation_id => $path);
169             }
170             }
171              
172 79         1881 return $state;
173             }
174              
175             ######## NO PUBLIC INTERFACES FOLLOW THIS POINT ########
176              
177 88     88   195 sub _add_vocab_and_default_schemas ($self) {
  88         186  
  88         154  
178 88         247 my $js = $self->evaluator;
179 88         567 $js->add_vocabulary('JSON::Schema::Modern::Vocabulary::OpenAPI');
180              
181 5         9 $js->add_format_validation(
182 5     5   8 int32 => +{ type => 'integer', sub => sub ($x) {
  5         14309  
183 5         27 require Math::BigInt;
184 5         23 $x = Math::BigInt->new($x);
185 5         374 my $bound = Math::BigInt->new(2) ** 31;
186 5 100       1415 $x >= -$bound && $x < $bound;
187             } },
188 5     5   8 int64 => +{ type => 'integer', sub => sub ($x) {
  5         14147  
  5         10  
189 5         24 require Math::BigInt;
190 5         20 $x = Math::BigInt->new($x);
191 5         298 my $bound = Math::BigInt->new(2) ** 63;
192 5 100       1600 $x >= -$bound && $x < $bound;
193             } },
194 6     6   8 float => +{ type => 'number', sub => sub ($) { 1 } },
  6         13  
  6         17715  
195 6     6   11 double => +{ type => 'number', sub => sub ($) { 1 } },
  6         12  
  6         17471  
196 1     1   3 password => +{ type => 'string', sub => sub ($) { 1 } },
  1         3  
  1         2853  
197 88         150905 );
198              
199 88         55273 foreach my $pairs (pairs DEFAULT_SCHEMAS->%*) {
200 352         199282 my ($filename, $uri) = @$pairs;
201 352         6468 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       16998858 $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   7819 sub _traverse_schema ($self, $schema, $state) {
  110         205  
  110         180  
  110         182  
  110         170  
209 110 100 100     723 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         560 metaschema_uri => $self->json_schema_dialect,
216             });
217              
218 84         3175 push $state->{errors}->@*, $subschema_state->{errors}->@*;
219 84 50       301 return if $subschema_state->{errors}->@*;
220              
221 84         770 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.020
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