File Coverage

blib/lib/OpenAPI/Modern.pm
Criterion Covered Total %
statement 325 331 98.1
branch 145 158 91.7
condition 48 72 66.6
subroutine 47 47 100.0
pod 3 3 100.0
total 568 611 92.9


line stmt bran cond sub pod time code
1 5     5   639892 use strict;
  5         55  
  5         151  
2 5     5   29 use warnings;
  5         10  
  5         253  
3             package OpenAPI::Modern; # git description: v0.046-13-g0e7dae9
4             # vim: set ts=8 sts=2 sw=2 tw=100 et :
5             # ABSTRACT: Validate HTTP requests and responses against an OpenAPI v3.1 document
6             # KEYWORDS: validation evaluation JSON Schema OpenAPI v3.1 Swagger HTTP request response
7              
8             our $VERSION = '0.047';
9              
10 5     5   119 use 5.020;
  5         19  
11 5     5   614 use utf8;
  5         24  
  5         42  
12 5     5   2462 use Moo;
  5         29371  
  5         34  
13 5     5   6577 use strictures 2;
  5         50  
  5         236  
14 5     5   1075 use stable 0.031 'postderef';
  5         104  
  5         41  
15 5     5   884 use experimental 'signatures';
  5         11  
  5         17  
16 5     5   410 use if "$]" >= 5.022, experimental => 're_strict';
  5         10  
  5         55  
17 5     5   507 no if "$]" >= 5.031009, feature => 'indirect';
  5         20  
  5         42  
18 5     5   293 no if "$]" >= 5.033001, feature => 'multidimensional';
  5         10  
  5         28  
19 5     5   257 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  5         18  
  5         29  
20 5     5   192 use Carp 'croak';
  5         17  
  5         319  
21 5     5   2089 use Safe::Isa;
  5         1940  
  5         638  
22 5     5   3960 use Ref::Util qw(is_plain_hashref is_plain_arrayref is_ref);
  5         7540  
  5         359  
23 5     5   34 use List::Util 'first';
  5         12  
  5         374  
24 5     5   33 use Scalar::Util 'looks_like_number';
  5         11  
  5         222  
25 5     5   2110 use Feature::Compat::Try;
  5         1321  
  5         26  
26 5     5   12054 use Encode 2.89;
  5         93  
  5         449  
27 5     5   2402 use URI::Escape ();
  5         7997  
  5         181  
28 5     5   3399 use JSON::Schema::Modern 0.560;
  5         3214460  
  5         320  
29 5     5   55 use JSON::Schema::Modern::Utilities 0.531 qw(jsonp unjsonp canonical_uri E abort is_equal is_elements_unique);
  5         102  
  5         541  
30 5     5   2351 use JSON::Schema::Modern::Document::OpenAPI;
  5         19  
  5         59  
31 5     5   259 use MooX::TypeTiny 0.002002;
  5         106  
  5         38  
32 5     5   4069 use Types::Standard 'InstanceOf';
  5         99  
  5         32  
33 5     5   8051 use constant { true => JSON::PP::true, false => JSON::PP::false };
  5         13  
  5         29  
34 5     5   3702 use Mojo::Message::Request;
  5         418144  
  5         51  
35 5     5   2842 use Mojo::Message::Response;
  5         38217  
  5         51  
36 5     5   261 use namespace::clean;
  5         13  
  5         64  
37              
38             has openapi_document => (
39             is => 'ro',
40             isa => InstanceOf['JSON::Schema::Modern::Document::OpenAPI'],
41             required => 1,
42             handles => {
43             openapi_uri => 'canonical_uri', # Mojo::URL
44             openapi_schema => 'schema', # hashref
45             document_get => 'get', # data access using a json pointer
46             },
47             );
48              
49             # held separately because $document->evaluator is a weak ref
50             has evaluator => (
51             is => 'ro',
52             isa => InstanceOf['JSON::Schema::Modern'],
53             required => 1,
54             handles => [ qw(get_media_type add_media_type) ],
55             );
56              
57             around BUILDARGS => sub ($orig, $class, @args) {
58             my $args = $class->$orig(@args);
59              
60             if (exists $args->{openapi_document}) {
61             $args->{evaluator} = $args->{openapi_document}->evaluator;
62             }
63             else {
64             # construct document out of openapi_uri, openapi_schema, evaluator, if provided.
65             croak 'missing required constructor arguments: either openapi_document, or openapi_uri'
66             if not exists $args->{openapi_uri};
67             croak 'missing required constructor arguments: either openapi_document, or openapi_schema'
68             if not exists $args->{openapi_schema};
69              
70             $args->{evaluator} //= JSON::Schema::Modern->new(validate_formats => 1, max_traversal_depth => 80);
71             $args->{openapi_document} = JSON::Schema::Modern::Document::OpenAPI->new(
72             canonical_uri => $args->{openapi_uri},
73             schema => $args->{openapi_schema},
74             evaluator => $args->{evaluator},
75             );
76              
77             # if there were errors, this will die with a JSON::Schema::Modern::Result object
78             $args->{evaluator}->add_schema($args->{openapi_document});
79             }
80              
81             return $args;
82             };
83              
84 123     123 1 436826 sub validate_request ($self, $request, $options = {}) {
  123         296  
  123         281  
  123         249  
  123         227  
85             croak '$request and $options->{request} are inconsistent'
86 123 50 33     962 if $request and $options->{request} and $request != $options->{request};
      33        
87              
88 123         3429 my $state = {
89             data_path => '/request',
90             initial_schema_uri => $self->openapi_uri, # the canonical URI as of the start or last $id, or the last traversed $ref
91             traversed_schema_path => '', # the accumulated traversal path as of the start, or last $id, or up to the last traversed $ref
92             schema_path => '', # the rest of the path, since the last $id or the last traversed $ref
93             effective_base_uri => Mojo::URL->new->scheme('https')->host($request->headers->header('Host')),
94             annotations => [],
95             };
96              
97             try {
98             $options->{request} //= $request;
99             my $path_ok = $self->find_path($options);
100             $request = $options->{request}; # now guaranteed to be a Mojo::Message::Request
101             $state->{errors} = delete $options->{errors};
102             return $self->_result($state, 1) if not $path_ok;
103              
104             my ($path_template, $path_captures) = $options->@{qw(path_template path_captures)};
105             my $path_item = $self->openapi_document->schema->{paths}{$path_template};
106             my $method = lc $request->method;
107             my $operation = $path_item->{$method};
108              
109             $state->{schema_path} = jsonp('/paths', $path_template);
110              
111             # PARAMETERS
112             # { $in => { $name => 'path-item'|$method } } as we process each one.
113             my $request_parameters_processed;
114              
115             # first, consider parameters at the operation level.
116             # parameters at the path-item level are also considered, if not already seen at the operation level
117             foreach my $section ($method, 'path-item') {
118             foreach my $idx (0 .. (($section eq $method ? $operation : $path_item)->{parameters}//[])->$#*) {
119             my $state = { %$state, schema_path => jsonp($state->{schema_path},
120             ($section eq $method ? $method : ()), 'parameters', $idx) };
121             my $param_obj = ($section eq $method ? $operation : $path_item)->{parameters}[$idx];
122             while (my $ref = $param_obj->{'$ref'}) {
123             $param_obj = $self->_resolve_ref('parameter', $ref, $state);
124             }
125              
126             my $fc_name = $param_obj->{in} eq 'header' ? fc($param_obj->{name}) : $param_obj->{name};
127              
128             abort($state, 'duplicate %s parameter "%s"', $param_obj->{in}, $param_obj->{name})
129             if ($request_parameters_processed->{$param_obj->{in}}{$fc_name} // '') eq $section;
130             next if exists $request_parameters_processed->{$param_obj->{in}}{$fc_name};
131             $request_parameters_processed->{$param_obj->{in}}{$fc_name} = $section;
132              
133             $state->{data_path} = jsonp($state->{data_path},
134             ((grep $param_obj->{in} eq $_, qw(path query)) ? 'uri' : ()), $param_obj->{in},
135             $param_obj->{name});
136             my $valid =
137             $param_obj->{in} eq 'path' ? $self->_validate_path_parameter($state, $param_obj, $path_captures)
138             : $param_obj->{in} eq 'query' ? $self->_validate_query_parameter($state, $param_obj, $request->url)
139             : $param_obj->{in} eq 'header' ? $self->_validate_header_parameter($state, $param_obj->{name}, $param_obj, [ $request->headers->header($param_obj->{name}) // () ])
140             : $param_obj->{in} eq 'cookie' ? $self->_validate_cookie_parameter($state, $param_obj, $request)
141             : abort($state, 'unrecognized "in" value "%s"', $param_obj->{in});
142             }
143             }
144              
145             # 3.2 "Each template expression in the path MUST correspond to a path parameter that is included in
146             # the Path Item itself and/or in each of the Path Item’s Operations."
147             # We could validate this at document parse time, except the path-item can also be reached via a
148             # $ref and the referencing path could be from another document and is therefore unknowable until
149             # runtime.
150             foreach my $path_name (sort keys $path_captures->%*) {
151             abort({ %$state, data_path => jsonp($state->{data_path}, qw(uri path), $path_name) },
152             'missing path parameter specification for "%s"', $path_name)
153             if not exists $request_parameters_processed->{path}{$path_name};
154             }
155              
156             $state->{schema_path} = jsonp($state->{schema_path}, $method);
157              
158             ()= E({ %$state, data_path => jsonp($state->{data_path}, 'header') },
159             'RFC9112 §6.2-2: A sender MUST NOT send a Content-Length header field in any message that contains a Transfer-Encoding header field')
160             if defined $request->headers->content_length and $request->content->is_chunked;
161              
162             # RFC9112 §6.3-7: A user agent that sends a request that contains a message body MUST send
163             # either a valid Content-Length header field or use the chunked transfer coding.
164             ()= E({ %$state, data_path => jsonp($state->{data_path}, 'header') }, 'missing header: Content-Length')
165             if $request->body_size and not $request->headers->content_length
166             and not $request->content->is_chunked;
167              
168             $state->{data_path} = jsonp($state->{data_path}, 'body');
169              
170             if (my $body_obj = $operation->{requestBody}) {
171             $state->{schema_path} = jsonp($state->{schema_path}, 'requestBody');
172              
173             while (my $ref = $body_obj->{'$ref'}) {
174             $body_obj = $self->_resolve_ref('request-body', $ref, $state);
175             }
176              
177             if ($request->body_size) {
178             $self->_validate_body_content($state, $body_obj->{content}, $request);
179             }
180             elsif ($body_obj->{required}) {
181             ()= E({ %$state, keyword => 'required' }, 'request body is required but missing');
182             }
183             }
184             else {
185             # we presume that no body specification for GET and HEAD requests -> no body is expected
186             ()= E($state, 'unspecified body is present in %s request', uc $method)
187             if ($method eq 'get' or $method eq 'head')
188             and $request->headers->content_length // $request->body_size;
189             }
190             }
191             catch ($e) {
192             if ($e->$_isa('JSON::Schema::Modern::Result')) {
193             return $e;
194             }
195             elsif ($e->$_isa('JSON::Schema::Modern::Error')) {
196             push @{$state->{errors}}, $e;
197             }
198             else {
199             ()= E($state, 'EXCEPTION: '.$e);
200             }
201             }
202              
203 123         14665 return $self->_result($state);
  120         25839  
204             }
205              
206 64     64 1 222597 sub validate_response ($self, $response, $options = {}) {
  64         145  
  64         131  
  64         115  
  64         100  
207             # handle the existence of HTTP::Response::request
208 64 100       272 if (my $request = $response->$_call_if_can('request')) {
209             croak '$response->request and $options->{request} are inconsistent'
210 2 50 33     171 if $request and $options->{request} and $request != $options->{request};
      33        
211 2   33     10 $options->{request} //= $request;
212             }
213              
214 64         3428 my $state = {
215             data_path => '/response',
216             initial_schema_uri => $self->openapi_uri, # the canonical URI as of the start or last $id, or the last traversed $ref
217             traversed_schema_path => '', # the accumulated traversal path as of the start, or last $id, or up to the last traversed $ref
218             schema_path => '', # the rest of the path, since the last $id or the last traversed $ref
219             annotations => [],
220             };
221              
222             try {
223             my $path_ok = $self->find_path($options);
224             $state->{errors} = delete $options->{errors};
225             return $self->_result($state, 1) if not $path_ok;
226              
227             my ($path_template, $path_captures) = $options->@{qw(path_template path_captures)};
228             my $method = lc $options->{method};
229             my $operation = $self->openapi_document->schema->{paths}{$path_template}{$method};
230              
231             return $self->_result($state) if not exists $operation->{responses};
232              
233             $state->{effective_base_uri} = Mojo::URL->new->scheme('https')->host($options->{request}->headers->host)
234             if $options->{request};
235             $state->{schema_path} = jsonp('/paths', $path_template, $method);
236              
237             $response = _convert_response($response); # now guaranteed to be a Mojo::Message::Response
238              
239             if (my $error = $response->error) {
240             ()= E($state, $error->{message});
241             return $self->_result($state, 1);
242             }
243              
244             if ($response->headers->header('Transfer-Encoding')) {
245             ()= E({ %$state, data_path => jsonp($state->{data_path}, qw(header Transfer-Encoding)) },
246             'RFC9112 §6.1-10: A server MUST NOT send a Transfer-Encoding header field in any response with a status code of 1xx (Informational) or 204 (No Content)')
247             if $response->is_info or $response->code == 204;
248              
249             # connect method is not supported in openapi 3.1.0, but this may be possible in the future
250             ()= E({ %$state, data_path => jsonp($state->{data_path}, qw(header Transfer-Encoding)) },
251             'RFC9112 §6.1-10: A server MUST NOT send a Transfer-Encoding header field in any 2xx (Successful) response to a CONNECT request')
252             if $response->is_success and $method eq 'connect';
253             }
254              
255             ()= E({ %$state, data_path => jsonp($state->{data_path}, 'header') },
256             'RFC9112 §6.2-2: A sender MUST NOT send a Content-Length header field in any message that contains a Transfer-Encoding header field')
257             if defined $response->headers->content_length and $response->content->is_chunked;
258              
259 118     118   902 my $response_name = first { exists $operation->{responses}{$_} }
260             $response->code, substr(sprintf('%03s', $response->code), 0, -2).'XX', 'default';
261              
262             if (not $response_name) {
263             ()= E({ %$state, keyword => 'responses' }, 'no response object found for code %s', $response->code);
264             return $self->_result($state);
265             }
266              
267             my $response_obj = $operation->{responses}{$response_name};
268             $state->{schema_path} = jsonp($state->{schema_path}, 'responses', $response_name);
269             while (my $ref = $response_obj->{'$ref'}) {
270             $response_obj = $self->_resolve_ref('response', $ref, $state);
271             }
272              
273             foreach my $header_name (sort keys(($response_obj->{headers}//{})->%*)) {
274             next if fc $header_name eq fc 'Content-Type';
275             my $state = { %$state, schema_path => jsonp($state->{schema_path}, 'headers', $header_name) };
276             my $header_obj = $response_obj->{headers}{$header_name};
277             while (my $ref = $header_obj->{'$ref'}) {
278             $header_obj = $self->_resolve_ref('header', $ref, $state);
279             }
280              
281             ()= $self->_validate_header_parameter({ %$state,
282             data_path => jsonp($state->{data_path}, 'header', $header_name) },
283             $header_name, $header_obj, [ $response->headers->header($header_name) // () ]);
284             }
285              
286             $self->_validate_body_content({ %$state, data_path => jsonp($state->{data_path}, 'body') },
287             $response_obj->{content}, $response)
288             if exists $response_obj->{content} and $response->headers->content_length // $response->body_size;
289             }
290             catch ($e) {
291             if ($e->$_isa('JSON::Schema::Modern::Result')) {
292             return $e;
293             }
294             elsif ($e->$_isa('JSON::Schema::Modern::Error')) {
295             push @{$state->{errors}}, $e;
296             }
297             else {
298             ()= E($state, 'EXCEPTION: '.$e);
299             }
300             }
301              
302 64         3577 return $self->_result($state);
  50         7659  
303             }
304              
305 252     252 1 332475 sub find_path ($self, $options) {
  252         507  
  252         476  
  252         467  
306             # now guaranteed to be a Mojo::Message::Request
307 252 100       1308 $options->{request} = _convert_request($options->{request}) if $options->{request};
308              
309             my $state = {
310             data_path => '/request/uri/path',
311             initial_schema_uri => $self->openapi_uri, # the canonical URI as of the start or last $id, or the last traversed $ref
312             traversed_schema_path => '', # the accumulated traversal path as of the start, or last $id, or up to the last traversed $ref
313             schema_path => '', # the rest of the path, since the last $id or the last traversed $ref
314             errors => $options->{errors} //= [],
315 252 100 50     5886 $options->{request} ? ( effective_base_uri => Mojo::URL->new->scheme('https')->host($options->{request}->headers->host) ) : (),
316             };
317              
318 252 100 100     21810 if ($options->{request} and my $error = $options->{request}->error) {
319 3         54 ()= E({ %$state, data_path => '/request' }, $error->{message});
320 3         3057 return $self->_result($state, 1);
321             }
322              
323 249         1671 my ($method, $path_template);
324              
325             # method from options
326 249 100       1124 if (exists $options->{method}) {
    100          
327 68         186 $method = lc $options->{method};
328             return E({ %$state, data_path => '/request/method' }, 'wrong HTTP method %s', $options->{request}->method)
329 68 100 66     267 if $options->{request} and lc $options->{request}->method ne $method;
330             }
331             elsif ($options->{request}) {
332 177         712 $method = $options->{method} = lc $options->{request}->method;
333             }
334              
335             # path_template and method from operation_id from options
336 247 100       1843 if (exists $options->{operation_id}) {
337 20         464 my $operation_path = $self->openapi_document->get_operationId_path($options->{operation_id});
338             return E({ %$state, keyword => 'paths' }, 'unknown operation_id "%s"', $options->{operation_id})
339 20 100       2336 if not $operation_path;
340 18 100       139 return E({ %$state, schema_path => $operation_path, keyword => 'operationId' },
341             'operation id does not have an associated path') if $operation_path !~ m{^/paths/};
342 16         99 (undef, undef, $path_template, $method) = unjsonp($operation_path);
343              
344             return E({ %$state, schema_path => jsonp('/paths', $path_template) },
345             'operation does not match provided path_template')
346 16 100 100     498 if exists $options->{path_template} and $options->{path_template} ne $path_template;
347              
348             return E({ %$state, data_path => '/request/method', schema_path => $operation_path },
349             'wrong HTTP method %s', $options->{method})
350 14 100 100     135 if $options->{method} and lc $options->{method} ne $method;
351              
352 10         34 $options->{operation_path} = $operation_path;
353 10         65 $options->{method} = lc $method;
354             }
355              
356 237 100       1173 croak 'at least one of $options->{request}, $options->{method} and $options->{operation_id} must be provided'
357             if not $method;
358              
359             # path_template from options
360 235 100       745 if (exists $options->{path_template}) {
361 184         463 $path_template = $options->{path_template};
362              
363 184         993 my $path_item = $self->openapi_document->schema->{paths}{$path_template};
364 184 100       612 return E({ %$state, keyword => 'paths' }, 'missing path-item "%s"', $path_template) if not $path_item;
365              
366             return E({ %$state, data_path => '/request/method', schema_path => jsonp('/paths', $path_template), keyword => $method },
367             'missing operation for HTTP method "%s"', $method)
368 180 100       750 if not $path_item->{$method};
369             }
370              
371             # path_template from request URI
372 229 100 100     1026 if (not $path_template and $options->{request} and my $uri_path = $options->{request}->url->path) {
      66        
373 41         1083 my $schema = $self->openapi_document->schema;
374             croak 'servers not yet supported when matching request URIs'
375 41 50 33     179 if exists $schema->{servers} and $schema->{servers}->@*;
376              
377             # sorting (ascii-wise) gives us the desired results that concrete path components sort ahead of
378             # templated components, except when the concrete component is a non-ascii character or matches [|}~].
379 41         248 foreach $path_template (sort keys $schema->{paths}->%*) {
380 43         402 my $path_pattern = $path_template =~ s!\{[^/}]+\}!([^/?#]*)!gr;
381 43 100       750 next if $uri_path !~ m/^$path_pattern$/;
382              
383 36         2361 $options->{path_template} = $path_template;
384              
385             # perldoc perlvar, @-: $n coincides with "substr $_, $-[n], $+[n] - $-[n]" if "$-[n]" is defined
386 36         327 my @capture_values = map
387             Encode::decode('UTF-8', URI::Escape::uri_unescape(substr($uri_path, $-[$_], $+[$_]-$-[$_]))), 1 .. $#-;
388 36         2179 my @capture_names = ($path_template =~ m!\{([^/?#}]+)\}!g);
389 36         70 my %path_captures; @path_captures{@capture_names} = @capture_values;
  36         119  
390              
391 36         81 my $indexes = [];
392 36 100       178 return E({ %$state, keyword => 'paths' }, 'duplicate path capture name %s', $capture_names[$indexes->[0]])
393             if not is_elements_unique(\@capture_names, $indexes);
394              
395             return E({ %$state, keyword => 'paths' }, 'provided path_captures values do not match request URI')
396 34 100 66     622 if $options->{path_captures} and not is_equal($options->{path_captures}, \%path_captures);
397              
398 32         87 $options->{path_captures} = \%path_captures;
399             return E({ %$state, data_path => '/request/method', schema_path => jsonp('/paths', $path_template), keyword => $method },
400             'missing operation for HTTP method "%s"', $method)
401 32 100       199 if not exists $schema->{paths}{$path_template}{$method};
402              
403 30         328 $options->{operation_id} = $self->openapi_document->schema->{paths}{$path_template}{$method}{operationId};
404 30 100       121 delete $options->{operation_id} if not defined $options->{operation_id};
405 30         104 $options->{operation_path} = jsonp('/paths', $path_template, $method);
406 30         643 return 1;
407             }
408              
409 5         344 return E({ %$state, keyword => 'paths' }, 'no match found for URI path "%s"', $uri_path);
410             }
411              
412 188 100       826 croak 'at least one of $options->{request}, $options->{path_template} and $options->{operation_id} must be provided'
413             if not $path_template;
414              
415 186         809 $options->{operation_path} = jsonp('/paths', $path_template, $method);
416              
417             # note: we aren't doing anything special with escaped slashes. this bit of the spec is hazy.
418 186         3317 my @capture_names = ($path_template =~ m!\{([^/}]+)\}!g);
419             return E({ %$state, keyword => 'paths', _schema_path_suffix => $path_template },
420             'provided path_captures names do not match path template "%s"', $path_template)
421             if exists $options->{path_captures}
422 186 100 100     1250 and not is_equal([ sort keys $options->{path_captures}->%* ], [ sort @capture_names ]);
423              
424 182 100       10549 if (not $options->{request}) {
425             $options->@{qw(path_template operation_id)} =
426 62         390 ($path_template, $self->openapi_document->schema->{paths}{$path_template}{$method}{operationId});
427 62 100       240 delete $options->{operation_id} if not defined $options->{operation_id};
428 62         266 return 1;
429             }
430              
431             # if we're still here, we were passed path_template in options or we calculated it from
432             # operation_id, and now we verify it against path_captures and the request URI.
433 120         518 my $uri_path = $options->{request}->url->path;
434              
435             # 3.2: "The value for these path parameters MUST NOT contain any unescaped “generic syntax”
436             # characters described by [RFC3986]: forward slashes (/), question marks (?), or hashes (#)."
437 120         2332 my $path_pattern = $path_template =~ s!\{[^/}]+\}!([^/?#]*)!gr;
438             return E({ %$state, keyword => 'paths', _schema_path_suffix => $path_template },
439 120 100       1488 'provided %s does not match request URI', exists $options->{path_template} ? 'path_template' : 'operation_id')
    100          
440             if $uri_path !~ m/^$path_pattern$/;
441              
442             # perldoc perlvar, @-: $n coincides with "substr $_, $-[n], $+[n] - $-[n]" if "$-[n]" is defined
443 112         7631 my @capture_values = map
444             Encode::decode('UTF-8', URI::Escape::uri_unescape(substr($uri_path, $-[$_], $+[$_]-$-[$_]))), 1 .. $#-;
445             return E({ %$state, keyword => 'paths', _schema_path_suffix => $path_template },
446             'provided path_captures values do not match request URI')
447             if exists $options->{path_captures}
448 112 100 100     4674 and not is_equal([ map $_.'', $options->{path_captures}->@{@capture_names} ], \@capture_values);
449              
450 110         7624 my %path_captures; @path_captures{@capture_names} = @capture_values;
  110         365  
451             $options->@{qw(path_template path_captures operation_id)} =
452 110         976 ($path_template, \%path_captures, $self->openapi_document->schema->{paths}{$path_template}{$method}{operationId});
453 110 100       472 delete $options->{operation_id} if not defined $options->{operation_id};
454 110         368 $options->{operation_path} = jsonp('/paths', $path_template, $method);
455 110         1791 return 1;
456             }
457              
458             ######## NO PUBLIC INTERFACES FOLLOW THIS POINT ########
459              
460 24     24   48 sub _validate_path_parameter ($self, $state, $param_obj, $path_captures) {
  24         41  
  24         43  
  24         41  
  24         35  
  24         35  
461             # 'required' is always true for path parameters
462             return E({ %$state, keyword => 'required' }, 'missing path parameter: %s', $param_obj->{name})
463 24 100       109 if not exists $path_captures->{$param_obj->{name}};
464              
465 22         106 $self->_validate_parameter_content($state, $param_obj, \ $path_captures->{$param_obj->{name}});
466             }
467              
468 103     103   3029 sub _validate_query_parameter ($self, $state, $param_obj, $uri) {
  103         165  
  103         147  
  103         157  
  103         139  
  103         156  
469             # parse the query parameters out of uri
470 103         315 my $query_params = +{ $uri->query->pairs->@* };
471              
472             # TODO: support different styles.
473             # for now, we only support style=form and do not allow for multiple values per
474             # property (i.e. 'explode' is not checked at all.)
475             # (other possible style values: spaceDelimited, pipeDelimited, deepObject)
476              
477 103 100       5447 if (not exists $query_params->{$param_obj->{name}}) {
478             return E({ %$state, keyword => 'required' }, 'missing query parameter: %s', $param_obj->{name})
479 54 100       229 if $param_obj->{required};
480 52         536 return 1;
481             }
482              
483             # TODO: check 'allowReserved': if true, do not use percent-decoding
484             return E({ %$state, keyword => 'allowReserved' }, 'allowReserved: true is not yet supported')
485 49 100 100     299 if $param_obj->{allowReserved} // 0;
486              
487 48         261 $self->_validate_parameter_content($state, $param_obj, \ $query_params->{$param_obj->{name}});
488             }
489              
490             # validates a header, from either the request or the response
491 56     56   31896 sub _validate_header_parameter ($self, $state, $header_name, $header_obj, $headers) {
  56         103  
  56         97  
  56         120  
  56         106  
  56         91  
  56         105  
492 56 100       325 return 1 if grep fc $header_name eq fc $_, qw(Accept Content-Type Authorization);
493              
494             # NOTE: for now, we will only support a single header value.
495             # TODO: explode multiple values into an array when type: array is requested.
496 53         473 @$headers = map s/^\s*//r =~ s/\s*$//r, @$headers;
497              
498 53 100       188 if (not @$headers) {
499             return E({ %$state, keyword => 'required' }, 'missing header: %s', $header_name)
500 10 100       55 if $header_obj->{required};
501 4         49 return 1;
502             }
503              
504 43         184 $self->_validate_parameter_content($state, $header_obj, \ $headers->[0]);
505             }
506              
507 2     2   5 sub _validate_cookie_parameter ($self, $state, $param_obj, $request) {
  2         6  
  2         5  
  2         4  
  2         3  
  2         5  
508 2         10 return E($state, 'cookie parameters not yet supported');
509             }
510              
511 112     112   192 sub _validate_parameter_content ($self, $state, $param_obj, $content_ref) {
  112         193  
  112         229  
  112         184  
  112         169  
  112         183  
512 112 100       315 if (exists $param_obj->{content}) {
513             abort({ %$state, keyword => 'content' }, 'more than one media type entry present')
514 32 50       154 if keys $param_obj->{content}->%* > 1; # TODO: remove, when the spec schema is updated
515 32         114 my ($media_type) = keys $param_obj->{content}->%*; # there can only be one key
516 32         110 my $schema = $param_obj->{content}{$media_type}{schema};
517              
518 32         765 my $media_type_decoder = $self->get_media_type($media_type); # case-insensitive, wildcard lookup
519 32 100       10664 if (not $media_type_decoder) {
520             # don't fail if the schema would pass on any input
521 4 100       23 return if is_plain_hashref($schema) ? !keys %$schema : $schema;
    100          
522              
523 2         36 abort({ %$state, keyword => 'content', _schema_path_suffix => $media_type},
524             'EXCEPTION: unsupported media type "%s": add support with $openapi->add_media_type(...)', $media_type)
525             }
526              
527             try {
528             $content_ref = $media_type_decoder->($content_ref);
529             }
530             catch ($e) {
531             return E({ %$state, keyword => 'content', _schema_path_suffix => $media_type },
532             'could not decode content as %s: %s', $media_type, $e =~ s/^(.*)\n/$1/r);
533             }
534              
535 28         78 $state = { %$state, schema_path => jsonp($state->{schema_path}, 'content', $media_type, 'schema') };
  22         553  
536 22         444 return $self->_evaluate_subschema($content_ref->$*, $schema, $state);
537             }
538              
539             # TODO: handle multi-valued elements like headers and query parameters, when type=array
540             # requested, and consider 'style' and 'explode' settings
541              
542 80         459 $state = { %$state, schema_path => jsonp($state->{schema_path}, 'schema'), stringy_numbers => 1 };
543 80         1137 $self->_evaluate_subschema($content_ref->$*, $param_obj->{schema}, $state);
544             }
545              
546 86     86   1516 sub _validate_body_content ($self, $state, $content_obj, $message) {
  86         165  
  86         152  
  86         137  
  86         145  
  86         142  
547             # does not include charset
548 86   100     277 my $content_type = fc((split(/;/, $message->headers->content_type//'', 2))[0] // '');
      100        
549              
550 86 100       2014 return E({ %$state, data_path => $state->{data_path} =~ s{body}{header/Content-Type}r, keyword => 'content' },
551             'missing header: Content-Type')
552             if not length $content_type;
553              
554 183     183   724 my $media_type = (first { $content_type eq fc } keys $content_obj->%*)
555 82 100 100 34   782 // (first { m{([^/]+)/\*$} && fc($content_type) =~ m{^\F\Q$1\E/[^/]+$} } keys $content_obj->%*);
  34         557  
556 82 100 100     566 $media_type = '*/*' if not defined $media_type and exists $content_obj->{'*/*'};
557 82 100       348 return E({ %$state, keyword => 'content' }, 'incorrect Content-Type "%s"', $content_type)
558             if not defined $media_type;
559              
560 78 50       303 if (exists $content_obj->{$media_type}{encoding}) {
561 0         0 my $state = { %$state, schema_path => jsonp($state->{schema_path}, 'content', $media_type) };
562             # 4.8.14.1 "The key, being the property name, MUST exist in the schema as a property."
563 0         0 foreach my $property (sort keys $content_obj->{$media_type}{encoding}->%*) {
564             ()= E({ $state, schema_path => jsonp($state->{schema_path}, 'schema', 'properties', $property) },
565             'encoding property "%s" requires a matching property definition in the schema')
566 0 0 0     0 if not exists(($content_obj->{$media_type}{schema}{properties}//{})->{$property});
567             }
568              
569             # 4.8.14.1 "The encoding object SHALL only apply to requestBody objects when the media type is
570             # multipart or application/x-www-form-urlencoded."
571 0 0 0     0 return E({ %$state, keyword => 'encoding' }, 'encoding not yet supported')
572             if $content_type =~ m{^multipart/} or $content_type eq 'application/x-www-form-urlencoded';
573             }
574              
575             # TODO: handle Content-Encoding header; https://github.com/OAI/OpenAPI-Specification/issues/2868
576 78         317 my $content_ref = \ $message->body;
577              
578             # decode the charset, for text content
579 78 100 100     1975 if ($content_type =~ m{^text/} and my $charset = $message->content->charset) {
580             try {
581             $content_ref = \ Encode::decode($charset, $content_ref->$*, Encode::FB_CROAK | Encode::LEAVE_SRC);
582             }
583 10         298 catch ($e) {
584             return E({ %$state, keyword => 'content', _schema_path_suffix => $media_type },
585             'could not decode content as %s: %s', $charset, $e =~ s/^(.*)\n/$1/r);
586             }
587             }
588              
589 74         1666 my $schema = $content_obj->{$media_type}{schema};
590              
591             # use the original Content-Type, NOT the possibly wildcard media type from the openapi document
592             # lookup is case-insensitive and falls back to wildcard definitions
593 74         1807 my $media_type_decoder = $self->get_media_type($content_type);
594 74 100   12   26376 $media_type_decoder = sub ($content_ref) { $content_ref } if $media_type eq '*/*';
  12         23  
  12         36  
  12         28  
  12         26  
595 74 100       246 if (not $media_type_decoder) {
596             # don't fail if the schema would pass on any input
597 4 100 33     44 return if not defined $schema or is_plain_hashref($schema) ? !keys %$schema : $schema;
    50          
598              
599 4         83 abort({ %$state, keyword => 'content', _schema_path_suffix => $media_type },
600             'EXCEPTION: unsupported Content-Type "%s": add support with $openapi->add_media_type(...)', $content_type)
601             }
602              
603             try {
604             $content_ref = $media_type_decoder->($content_ref);
605             }
606             catch ($e) {
607             return E({ %$state, keyword => 'content', _schema_path_suffix => $media_type },
608             'could not decode content as %s: %s', $media_type, $e =~ s/^(.*)\n/$1/r);
609             }
610              
611 70 100       201 return if not defined $schema;
  66         1499  
612              
613 64         443 $state = { %$state, schema_path => jsonp($state->{schema_path}, 'content', $media_type, 'schema') };
614 64         1360 $self->_evaluate_subschema($content_ref->$*, $schema, $state);
615             }
616              
617             # wrap a result object around the errors
618 190     190   516 sub _result ($self, $state, $exception = 0) {
  190         354  
  190         387  
  190         356  
  190         306  
619             return JSON::Schema::Modern::Result->new(
620             output_format => $self->evaluator->output_format,
621             formatted_annotations => 0,
622             valid => !$state->{errors}->@*,
623             $exception ? ( exception => 1 ) : (),
624             !$state->{errors}->@*
625             ? (annotations => $state->{annotations}//[])
626 190 100 50     5481 : (errors => $state->{errors}),
    100          
627             );
628             }
629              
630 92     92   173 sub _resolve_ref ($self, $entity_type, $ref, $state) {
  92         174  
  92         161  
  92         167  
  92         145  
  92         154  
631 92         370 my $uri = Mojo::URL->new($ref)->to_abs($state->{initial_schema_uri});
632 92         46016 my $schema_info = $self->evaluator->_fetch_from_uri($uri);
633 92 100       65737 abort({ %$state, keyword => '$ref' }, 'EXCEPTION: unable to find resource %s', $uri)
634             if not $schema_info;
635              
636             abort({ %$state, keyword => '$ref' }, 'EXCEPTION: maximum evaluation depth exceeded')
637 82 100       604 if $state->{depth}++ > $self->evaluator->max_traversal_depth;
638              
639             abort({ %$state, keyword => '$ref' }, 'EXCEPTION: bad $ref to %s: not a "%s"', $schema_info->{canonical_uri}, $entity_type)
640 80 100       2047 if $schema_info->{document}->get_entity_at_location($schema_info->{document_path}) ne $entity_type;
641              
642 78         8100 $state->{initial_schema_uri} = $schema_info->{canonical_uri};
643 78         373 $state->{traversed_schema_path} = $state->{traversed_schema_path}.$state->{schema_path}.jsonp('/$ref');
644 78         451 $state->{schema_path} = '';
645              
646 78         914 return $schema_info->{schema};
647             }
648              
649             # evaluates data against the subschema at the current state location
650 166     166   396 sub _evaluate_subschema ($self, $data, $schema, $state) {
  166         293  
  166         327  
  166         310  
  166         250  
  166         251  
651             # boolean schema
652 166 100       543 if (not is_plain_hashref($schema)) {
653 18 100       88 return 1 if $schema;
654              
655 16         176 my @location = unjsonp($state->{data_path});
656 16 50       333 my $location =
    100          
    100          
    100          
657             $location[-1] eq 'body' ? join(' ', @location[-2..-1])
658             : $location[-2] eq 'query' ? 'query parameter'
659             : $location[-2] eq 'path' ? 'path parameter' # this should never happen
660             : $location[-2] eq 'header' ? join(' ', @location[-3..-2])
661             : $location[-2]; # cookie
662 16         78 return E($state, '%s not permitted', $location);
663             }
664              
665 148 100       500 return 1 if !keys(%$schema); # schema is {}
666              
667             my $result = $self->evaluator->evaluate(
668             $data, canonical_uri($state),
669             {
670             data_path => $state->{data_path},
671             traversed_schema_path => $state->{traversed_schema_path}.$state->{schema_path},
672             effective_base_uri => $state->{effective_base_uri},
673             collect_annotations => 1,
674 142 100       606 $state->{stringy_numbers} ? ( stringy_numbers => 1 ) : (),
675             },
676             );
677              
678 142         550025 push $state->{errors}->@*, $result->errors;
679 142         12239 push $state->{annotations}->@*, $result->annotations;
680              
681 142         12995 return $result;
682             }
683              
684             # results may be unsatisfactory if not a valid HTTP request.
685 182     182   384 sub _convert_request ($request) {
  182         346  
  182         313  
686 182 100       1339 return $request if $request->isa('Mojo::Message::Request');
687 93 50       380 if ($request->isa('HTTP::Request')) {
688 93         415 my $req = Mojo::Message::Request->new;
689 93 100       747 if (not defined $request->headers->content_length) {
690 89         3359 my $length = length $request->content_ref->$*;
691 89 100       1492 $req->headers->content_length($length) if $length;
692             }
693 93         2227 $req->parse($request->as_string);
694 93         79198 return $req;
695             }
696 0         0 croak 'unknown type '.ref($request);
697             }
698              
699             # results may be unsatisfactory if not a valid HTTP response.
700 53     53   98 sub _convert_response ($response) {
  53         93  
  53         85  
701 53 100       361 return $response if $response->isa('Mojo::Message::Response');
702 27 50       107 if ($response->isa('HTTP::Response')) {
703 27         105 my $res = Mojo::Message::Response->new;
704 27 100       247 if (not defined $response->headers->content_length) {
705 22         931 my $length = length $response->content_ref->$*;
706 22 100       345 $res->headers->content_length($length) if $length;
707             }
708 27         897 $res->parse($response->as_string);
709 27         17806 return $res;
710             }
711 0           croak 'unknown type '.ref($response);
712             }
713              
714             1;
715              
716             __END__
717              
718             =pod
719              
720             =encoding UTF-8
721              
722             =head1 NAME
723              
724             OpenAPI::Modern - Validate HTTP requests and responses against an OpenAPI v3.1 document
725              
726             =head1 VERSION
727              
728             version 0.047
729              
730             =head1 SYNOPSIS
731              
732             my $openapi = OpenAPI::Modern->new(
733             openapi_uri => '/api',
734             openapi_schema => YAML::PP->new(boolean => 'JSON::PP')->load_string(<<'YAML'));
735             openapi: 3.1.0
736             info:
737             title: Test API
738             version: 1.2.3
739             paths:
740             /foo/{foo_id}:
741             parameters:
742             - name: foo_id
743             in: path
744             required: true
745             schema:
746             pattern: ^[a-z]+$
747             post:
748             operationId: my_foo_request
749             parameters:
750             - name: My-Request-Header
751             in: header
752             required: true
753             schema:
754             pattern: ^[0-9]+$
755             requestBody:
756             required: true
757             content:
758             application/json:
759             schema:
760             type: object
761             properties:
762             hello:
763             type: string
764             pattern: ^[0-9]+$
765             responses:
766             200:
767             description: success
768             headers:
769             My-Response-Header:
770             required: true
771             schema:
772             pattern: ^[0-9]+$
773             content:
774             application/json:
775             schema:
776             type: object
777             required: [ status ]
778             properties:
779             status:
780             const: ok
781             YAML
782              
783             say 'request:';
784             my $request = POST '/foo/bar',
785             'My-Request-Header' => '123', 'Content-Type' => 'application/json', Host => 'example.com',
786             Content => '{"hello": 123}';
787             my $results = $openapi->validate_request($request);
788             say $results;
789             say ''; # newline
790             say JSON::MaybeXS->new(convert_blessed => 1, canonical => 1, pretty => 1, indent_length => 2)->encode($results);
791              
792             say 'response:';
793             my $response = Mojo::Message::Response->new(code => 200, message => 'OK');
794             $response->headers->content_type('application/json');
795             $response->headers->header('My-Response-Header', '123');
796             $response->body('{"status": "ok"}');
797             $results = $openapi->validate_response($response, { request => $request });
798             say $results;
799             say ''; # newline
800             say JSON::MaybeXS->new(convert_blessed => 1, canonical => 1, pretty => 1, indent_length => 2)->encode($results);
801              
802             prints:
803              
804             request:
805             '/request/body/hello': got integer, not string
806             '/request/body': not all properties are valid
807              
808             {
809             "errors" : [
810             {
811             "absoluteKeywordLocation" : "https://example.com/api#/paths/~1foo~1%7Bfoo_id%7D/post/requestBody/content/application~1json/schema/properties/hello/type",
812             "error" : "got integer, not string",
813             "instanceLocation" : "/request/body/hello",
814             "keywordLocation" : "/paths/~1foo~1{foo_id}/post/requestBody/content/application~1json/schema/properties/hello/type"
815             },
816             {
817             "absoluteKeywordLocation" : "https://example.com/api#/paths/~1foo~1%7Bfoo_id%7D/post/requestBody/content/application~1json/schema/properties",
818             "error" : "not all properties are valid",
819             "instanceLocation" : "/request/body",
820             "keywordLocation" : "/paths/~1foo~1{foo_id}/post/requestBody/content/application~1json/schema/properties"
821             }
822             ],
823             "valid" : false
824             }
825              
826             response:
827             valid
828              
829             {
830             "valid" : true
831             }
832              
833             =head1 DESCRIPTION
834              
835             This module provides various tools for working with an
836             L<OpenAPI Specification v3.1 document|https://spec.openapis.org/oas/v3.1.0#openapi-document> within
837             your application. The JSON Schema evaluator is fully specification-compliant; the OpenAPI evaluator
838             aims to be but some features are not yet available. My belief is that missing features are better
839             than features that seem to work but actually cut corners for simplicity.
840              
841             =for Pod::Coverage BUILDARGS
842              
843             =for stopwords schemas jsonSchemaDialect metaschema subschema perlish operationId
844              
845             =head1 CONSTRUCTOR ARGUMENTS
846              
847             If construction of the object is not successful, for example the document has a syntax error, the
848             call to C<new()> will throw an exception. Be careful about examining this exception, for it might be
849             a L<JSON::Schema::Modern::Result> object, which has a boolean overload of false when it contains
850             errors! But you never do C<if ($@) { ... }>, right?
851              
852             =head2 openapi_uri
853              
854             The URI that identifies the OpenAPI document.
855             Ignored if L</openapi_document> is provided.
856              
857             If it is not absolute, it is resolved at runtime against the request's C<Host> header (when available)
858             and the https scheme is assumed.
859              
860             =head2 openapi_schema
861              
862             The data structure describing the OpenAPI v3.1 document (as specified at
863             L<https://spec.openapis.org/oas/v3.1.0>). Ignored if L</openapi_document> is provided.
864              
865             =head2 openapi_document
866              
867             The L<JSON::Schema::Modern::Document::OpenAPI> document that holds the OpenAPI information to be
868             used for validation. If it is not provided to the constructor, then L</openapi_uri> and
869             L</openapi_schema> B<MUST> be provided, and L</evaluator> will also be used if provided.
870              
871             =head2 evaluator
872              
873             The L<JSON::Schema::Modern> object to use for all URI resolution and JSON Schema evaluation.
874             Ignored if L</openapi_document> is provided. Optional.
875              
876             =head1 ACCESSORS/METHODS
877              
878             =head2 openapi_uri
879              
880             The URI that identifies the OpenAPI document.
881              
882             =head2 openapi_schema
883              
884             The data structure describing the OpenAPI document. See L<the specification/https://spec.openapis.org/oas/v3.1.0>.
885              
886             =head2 openapi_document
887              
888             The L<JSON::Schema::Modern::Document::OpenAPI> document that holds the OpenAPI information to be
889             used for validation.
890              
891             =head2 document_get
892              
893             my $parameter_data = $openapi->document_get('/paths/~1foo~1{foo_id}/get/parameters/0');
894              
895             Fetches the subschema at the provided JSON pointer.
896             Proxies to L<JSON::Schema::Modern::Document::OpenAPI/get>.
897             This is not recursive (does not follow C<$ref> chains) -- for that, use
898             C<< $openapi->openapi_document->recursive_get($json_pointer) >>, in
899             L<JSON::Schema::Modern::Document::OpenAPI/recursive_get>.
900              
901             =head2 evaluator
902              
903             The L<JSON::Schema::Modern> object to use for all URI resolution and JSON Schema evaluation.
904              
905             =head2 validate_request
906              
907             $result = $openapi->validate_request(
908             $request,
909             # optional second argument can contain any combination of:
910             my $options = {
911             path_template => '/foo/{arg1}/bar/{arg2}',
912             operation_id => 'my_operation_id',
913             path_captures => { arg1 => 1, arg2 => 2 },
914             method => 'get',
915             },
916             );
917              
918             Validates an L<HTTP::Request> or L<Mojo::Message::Request>
919             object against the corresponding OpenAPI v3.1 document, returning a
920             L<JSON::Schema::Modern::Result> object.
921              
922             The second argument is an optional hashref that contains extra information about the request,
923             corresponding to the values expected by L</find_path> below. It is populated with some information
924             about the request:
925             save it and pass it to a later L</validate_response> (corresponding to a response for this request)
926             to improve performance.
927              
928             =head2 validate_response
929              
930             $result = $openapi->validate_response(
931             $response,
932             {
933             path_template => '/foo/{arg1}/bar/{arg2}',
934             request => $request,
935             },
936             );
937              
938             Validates an L<HTTP::Response> or L<Mojo::Message::Response>
939             object against the corresponding OpenAPI v3.1 document, returning a
940             L<JSON::Schema::Modern::Result> object.
941              
942             The second argument is an optional hashref that contains extra information about the request
943             corresponding to the response, as in L</find_path>.
944              
945             C<request> is also accepted as a key in the hashref, representing the original request object that
946             corresponds to this response (as not all HTTP libraries link to the request in the response object).
947              
948             =head2 find_path
949              
950             $result = $self->find_path($options);
951              
952             Uses information in the request to determine the relevant parts of the OpenAPI specification.
953             C<request> should be provided if available, but additional data can be used instead
954             (which is populated by earlier L</validate_request> or L</find_path> calls to the same request).
955              
956             The single argument is a hashref that contains information about the request. Possible values
957             include:
958              
959             =over 4
960              
961             =item *
962              
963             C<request>: the object representing the HTTP request. Should be provided when available.
964              
965             =item *
966              
967             C<path_template>: a string representing the request URI, with placeholders in braces (e.g. C</pets/{petId}>); see L<https://spec.openapis.org/oas/v3.1.0#paths-object>.
968              
969             =item *
970              
971             C<operation_id>: a string corresponding to the L<operationId|https://swagger.io/docs/specification/paths-and-operations/#operationid> at a particular path-template and HTTP location under C</paths>
972              
973             =item *
974              
975             C<path_captures>: a hashref mapping placeholders in the path to their actual values in the request URI
976              
977             =item *
978              
979             C<method>: the HTTP method used by the request (used case-insensitively)
980              
981             =back
982              
983             All of these values are optional (unless C<request> is omitted), and will be derived from the
984             request URI as needed (albeit less
985             efficiently than if they were provided). All passed-in values MUST be consistent with each other and
986             the request URI.
987              
988             When successful, the options hash will be populated with keys C<path_template>, C<path_captures>,
989             C<method>, and C<operation_id>,
990             and the return value is true.
991             When not successful, the options hash will be populated with key C<errors>, an arrayref containing
992             a L<JSON::Schema::Modern::Error> object, and the return value is false. Other values may also be
993             populated if they can be successfully calculated.
994              
995             In addition, this value is populated in the options hash (when available):
996              
997             * C<operation_path>: a json pointer string indicating the document location of the operation that
998             was just evaluated against the request
999             * C<request> (not necessarily what was passed in: this is always a L<Mojo::Message::Request>)
1000              
1001             Note that the L<C</servers>|https://spec.openapis.org/oas/v3.1.0#server-object> section of the
1002             OpenAPI document is not used for path matching at this time, for either scheme and host matching nor
1003             path prefixes.
1004              
1005             =head2 canonical_uri
1006              
1007             An accessor that delegates to L<JSON::Schema::Modern::Document/canonical_uri>.
1008              
1009             =head2 schema
1010              
1011             An accessor that delegates to L<JSON::Schema::Modern::Document/schema>.
1012              
1013             =head2 get_media_type
1014              
1015             An accessor that delegates to L<JSON::Schema::Modern/get_media_type>.
1016              
1017             =head2 add_media_type
1018              
1019             A setter that delegates to L<JSON::Schema::Modern/add_media_type>.
1020              
1021             =head1 ON THE USE OF JSON SCHEMAS
1022              
1023             Embedded JSON Schemas, through the use of the C<schema> keyword, are fully draft2020-12-compliant,
1024             as per the spec, and implemented with L<JSON::Schema::Modern>. Unless overridden with the use of the
1025             L<jsonSchemaDialect|https://spec.openapis.org/oas/v3.1.0#specifying-schema-dialects> keyword, their
1026             metaschema is L<https://spec.openapis.org/oas/3.1/dialect/base>, which allows for use of the
1027             OpenAPI-specific keywords (C<discriminator>, C<xml>, C<externalDocs>, and C<example>), as defined in
1028             L<the specification/https://spec.openapis.org/oas/v3.1.0#schema-object>. Format validation is turned
1029             B<on>, and the use of content* keywords is off (see
1030             L<JSON::Schema::Modern/validate_content_schemas>).
1031              
1032             References (with the C<$ref>) keyword may reference any position within the entire OpenAPI document;
1033             as such, json pointers are relative to the B<root> of the document, not the root of the subschema
1034             itself. References to other documents are also permitted, provided those documents have been loaded
1035             into the evaluator in advance (see L<JSON::Schema::Modern/add_schema>).
1036              
1037             Values are generally treated as strings for the purpose of schema evaluation. However, if the top
1038             level of the schema contains C<"type": "number"> or C<"type": "integer">, then the value will be
1039             (attempted to be) coerced into a number before being passed to the JSON Schema evaluator.
1040             Type coercion will B<not> be done if the C<type> keyword is omitted.
1041             This lets you use numeric keywords such as C<maximum> and C<multipleOf> in your schemas.
1042             It also resolves inconsistencies that can arise when request and response objects are created
1043             manually in a test environment (as opposed to being parsed from incoming network traffic) and can
1044             therefore inadvertently contain perlish numbers rather than strings.
1045              
1046             =head1 LIMITATIONS
1047              
1048             Only certain permutations of OpenAPI documents are supported at this time:
1049              
1050             =over 4
1051              
1052             =item *
1053              
1054             for all parameter types, only C<explode: true> is supported
1055              
1056             =item *
1057              
1058             for path parameters, only C<style: simple> is supported
1059              
1060             =item *
1061              
1062             for query parameters, only C<style: form> is supported
1063              
1064             =item *
1065              
1066             cookie parameters are not checked at all yet
1067              
1068             =item *
1069              
1070             for query and header parameters, only the first value of each name is considered
1071              
1072             =back
1073              
1074             =head1 SEE ALSO
1075              
1076             =over 4
1077              
1078             =item *
1079              
1080             L<Mojolicious::Plugin::OpenAPI::Modern>
1081              
1082             =item *
1083              
1084             L<JSON::Schema::Modern::Document::OpenAPI>
1085              
1086             =item *
1087              
1088             L<JSON::Schema::Modern>
1089              
1090             =item *
1091              
1092             L<https://json-schema.org>
1093              
1094             =item *
1095              
1096             L<https://www.openapis.org/>
1097              
1098             =item *
1099              
1100             L<https://oai.github.io/Documentation/>
1101              
1102             =item *
1103              
1104             L<https://spec.openapis.org/oas/v3.1.0>
1105              
1106             =back
1107              
1108             =head1 SUPPORT
1109              
1110             Bugs may be submitted through L<https://github.com/karenetheridge/OpenAPI-Modern/issues>.
1111              
1112             I am also usually active on irc, as 'ether' at C<irc.perl.org> and C<irc.libera.chat>.
1113              
1114             You can also find me on the L<JSON Schema Slack server|https://json-schema.slack.com> and L<OpenAPI
1115             Slack server|https://open-api.slack.com>, which are also great resources for finding help.
1116              
1117             =head1 AUTHOR
1118              
1119             Karen Etheridge <ether@cpan.org>
1120              
1121             =head1 COPYRIGHT AND LICENCE
1122              
1123             This software is copyright (c) 2021 by Karen Etheridge.
1124              
1125             This is free software; you can redistribute it and/or modify it under
1126             the same terms as the Perl 5 programming language system itself.
1127              
1128             Some schema files have their own licence, in share/oas/LICENSE.
1129              
1130             =cut