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   3189069 use strict;
  9         63  
  9         259  
2 9     9   46 use warnings;
  9         65  
  9         427  
3             package JSON::Schema::Modern::Document::OpenAPI; # git description: v0.018-9-g27f7e33
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.019';
9              
10 9     9   173 use 5.020; # for fc, unicode_strings features
  9         30  
11 9     9   532 use Moo;
  9         6122  
  9         63  
12 9     9   4597 use strictures 2;
  9         1577  
  9         362  
13 9     9   1819 use experimental qw(signatures postderef);
  9         20  
  9         89  
14 9     9   1622 use if "$]" >= 5.022, experimental => 're_strict';
  9         19  
  9         96  
15 9     9   746 no if "$]" >= 5.031009, feature => 'indirect';
  9         29  
  9         64  
16 9     9   401 no if "$]" >= 5.033001, feature => 'multidimensional';
  9         24  
  9         39  
17 9     9   393 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  9         32  
  9         70  
18 9     9   927 use JSON::Schema::Modern::Utilities 0.525 qw(assert_keyword_exists assert_keyword_type E canonical_uri get_type);
  9         189340  
  9         734  
19 9     9   103 use Safe::Isa;
  9         22  
  9         1160  
20 9     9   540 use File::ShareDir 'dist_dir';
  9         19662  
  9         482  
21 9     9   761 use Path::Tiny;
  9         10105  
  9         528  
22 9     9   58 use List::Util qw(any pairs);
  9         16  
  9         625  
23 9     9   62 use Ref::Util 'is_plain_hashref';
  9         25  
  9         395  
24 9     9   496 use MooX::HandlesVia;
  9         671  
  9         79  
25 9     9   1122 use MooX::TypeTiny 0.002002;
  9         164  
  9         61  
26 9     9   6400 use Types::Standard qw(InstanceOf HashRef Str);
  9         29  
  9         104  
27 9     9   7160 use namespace::clean;
  9         19  
  9         88  
28              
29             extends 'JSON::Schema::Modern::Document';
30              
31 9     9   3375 use constant DEFAULT_DIALECT => 'https://spec.openapis.org/oas/3.1/dialect/base';
  9         19  
  9         824  
32              
33 9         596 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   66 use constant DEFAULT_METASCHEMA => 'https://spec.openapis.org/oas/3.1/schema-base/latest';
  9         15  
  9         13960  
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 53     53 0 382493 sub traverse ($self, $evaluator) {
  53         138  
  53         106  
  53         100  
71 53         237 $self->_add_vocab_and_default_schemas;
72              
73 53         66525 my $schema = $self->schema;
74 53         1028 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 53 100       1223 if ((my $type = get_type($schema)) ne 'object') {
88 1         45 ()= E($state, 'invalid document type: %s', $type);
89 1         628 return $state;
90             }
91              
92             # /openapi: https://spec.openapis.org/oas/v3.1.0#openapi-object
93 52 100 66     1094 return $state if not assert_keyword_exists({ %$state, keyword => 'openapi' }, $schema)
94             or not assert_keyword_type({ %$state, keyword => 'openapi' }, $schema, 'string');
95              
96 51 100       3685 if ($schema->{openapi} !~ /^3\.1\.[0-9]+(-.+)?$/) {
97 1         8 ()= E({ %$state, keyword => 'openapi' }, 'unrecognized openapi version %s', $schema->{openapi});
98 1         588 return $state;
99             }
100              
101              
102             # /jsonSchemaDialect: https://spec.openapis.org/oas/v3.1.0#specifying-schema-dialects
103             {
104 50         124 return $state if exists $schema->{jsonSchemaDialect}
105 50 100 100     312 and not assert_keyword_type({ %$state, keyword => 'jsonSchemaDialect' }, $schema, 'string');
106              
107 49   66     502 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 49   100     285 $json_schema_dialect //= DEFAULT_DIALECT;
111              
112             # traverse an empty schema with this metaschema uri to confirm it is valid
113 49         1008 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 49 100       13365 if ($check_metaschema_state->{errors}->@*) {
120 1         32 push $state->{errors}->@*, $check_metaschema_state->{errors}->@*;
121 1         8 return $state;
122             }
123              
124 48         218 $state->@{qw(spec_version vocabularies)} = $check_metaschema_state->@{qw(spec_version vocabularies)};
125 48         1160 $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 48         5961 my (@json_schema_paths, @operation_paths);
131 66         157 my $result = $self->evaluator->evaluate(
132             $self->schema,
133             $self->metaschema_uri,
134             {
135             callbacks => {
136 66     66   133 '$dynamicRef' => sub ($, $schema, $state) {
  66         63930  
  66         143  
137 66 50       407 push @json_schema_paths, $state->{data_path} if $schema->{'$dynamicRef'} eq '#meta';
138             },
139 1666     1666   2376 '$ref' => sub ($data, $schema, $state) {
  1666         9509708  
  1666         3492  
  1666         2707  
  1666         2628  
140             push @operation_paths, [ $data->{operationId} => $state->{data_path} ]
141 1666 100 100     6789 if $schema->{'$ref'} eq '#/$defs/operation' and defined $data->{operationId};
142             },
143             },
144             },
145 48         903 );
146              
147 48 100       83534 if (not $result) {
148 4         148 push $state->{errors}->@*, $result->errors;
149 4         210 return $state;
150             }
151              
152 44         723 my @real_json_schema_paths;
153 44         207 foreach my $path (sort @json_schema_paths) {
154             # disregard paths that are not the root of each embedded subschema.
155 66 100   112   438 next if any { return $path =~ m{^\Q$_\E(?:/|\z)} } @real_json_schema_paths;
  112         1666  
156              
157 58         339 unshift @real_json_schema_paths, $path;
158 58         307 $self->_traverse_schema($self->get($path), { %$state, schema_path => $path });
159             }
160              
161 44         141 foreach my $pair (@operation_paths) {
162 21         3886 my ($operation_id, $path) = @$pair;
163 21 100       373 if (my $existing = $self->get_operationId($operation_id)) {
164 6         511 ()= E({ %$state, keyword => 'operationId', schema_path => $path },
165             'duplicate of operationId at %s', $existing);
166             }
167             else {
168 15         1316 $self->_add_operationId($operation_id => $path);
169             }
170             }
171              
172 44         1247 return $state;
173             }
174              
175             ######## NO PUBLIC INTERFACES FOLLOW THIS POINT ########
176              
177 53     53   114 sub _add_vocab_and_default_schemas ($self) {
  53         102  
  53         124  
178 53         154 my $js = $self->evaluator;
179 53         321 $js->add_vocabulary('JSON::Schema::Modern::Vocabulary::OpenAPI');
180              
181 5         10 $js->add_format_validation(
182 5     5   9 int32 => +{ type => 'integer', sub => sub ($x) {
  5         17606  
183 5         29 require Math::BigInt;
184 5         24 $x = Math::BigInt->new($x);
185 5         382 my $bound = Math::BigInt->new(2) ** 31;
186 5 100       1549 $x >= -$bound && $x < $bound;
187             } },
188 5     5   9 int64 => +{ type => 'integer', sub => sub ($x) {
  5         17539  
  5         11  
189 5         27 require Math::BigInt;
190 5         24 $x = Math::BigInt->new($x);
191 5         337 my $bound = Math::BigInt->new(2) ** 63;
192 5 100       1839 $x >= -$bound && $x < $bound;
193             } },
194 6     6   11 float => +{ type => 'number', sub => sub ($) { 1 } },
  6         15  
  6         21379  
195 6     6   13 double => +{ type => 'number', sub => sub ($) { 1 } },
  6         16  
  6         21006  
196 1     1   2 password => +{ type => 'string', sub => sub ($) { 1 } },
  1         3  
  1         3521  
197 53         86634 );
198              
199 53         37674 foreach my $pairs (pairs DEFAULT_SCHEMAS->%*) {
200 212         119926 my ($filename, $uri) = @$pairs;
201 212         3936 my $document = $js->add_schema($uri,
202             $js->_json_decoder->decode(path(dist_dir('JSON-Schema-Modern-Document-OpenAPI'), $filename)->slurp_raw));
203 212 100       10232677 $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 58     58   4032 sub _traverse_schema ($self, $schema, $state) {
  58         119  
  58         100  
  58         86  
  58         106  
209 58 100 100     447 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 45         314 metaschema_uri => $self->json_schema_dialect,
216             });
217              
218 45         1695 push $state->{errors}->@*, $subschema_state->{errors}->@*;
219 45 50       167 return if $subschema_state->{errors}->@*;
220              
221 45         410 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.019
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