File Coverage

blib/lib/OpenAPI/Modern.pm
Criterion Covered Total %
statement 929 955 97.2
branch 668 734 91.0
condition 403 484 83.2
subroutine 62 62 100.0
pod 4 6 66.6
total 2066 2241 92.1


line stmt bran cond sub pod time code
1 17     17   17746 use strictures 2;
  17         152  
  17         620  
2             package OpenAPI::Modern; # git description: v0.138-15-g85c5cae9
3             # vim: set ts=8 sts=2 sw=2 tw=100 et :
4             # ABSTRACT: Validate HTTP requests and responses against an OpenAPI v3.0, v3.1 or v3.2 document
5             # KEYWORDS: validation evaluation JSON Schema OpenAPI v3.0 v3.1 v3.2 Swagger HTTP request response
6              
7             our $VERSION = '0.139';
8              
9 17     17   6616 use 5.020;
  17         47  
10 17     17   70 use utf8;
  17         26  
  17         134  
11 17     17   331 use Moo;
  17         27  
  17         107  
12 17     17   5611 use strictures 2;
  17         95  
  17         520  
13 17     17   5668 use stable 0.031 'postderef';
  17         268  
  17         201  
14 17     17   2920 use experimental 'signatures';
  17         27  
  17         115  
15 17     17   758 no autovivification warn => qw(fetch store exists delete);
  17         28  
  17         128  
16 17     17   1136 use if "$]" >= 5.022, experimental => 're_strict';
  17         27  
  17         412  
17 17     17   1224 no if "$]" >= 5.031009, feature => 'indirect';
  17         40  
  17         1062  
18 17     17   78 no if "$]" >= 5.033001, feature => 'multidimensional';
  17         33  
  17         841  
19 17     17   86 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  17         22  
  17         949  
20 17     17   76 no if "$]" >= 5.041009, feature => 'smartmatch';
  17         25  
  17         651  
21 17     17   68 no feature 'switch';
  17         27  
  17         503  
22 17     17   75 use Carp qw(carp croak);
  17         35  
  17         1045  
23 17     17   89 use List::Util qw(first pairs);
  17         47  
  17         1383  
24 17     17   71 use if "$]" < 5.041010, 'List::Util' => qw(all any);
  17         43  
  17         879  
25 17     17   77 use if "$]" >= 5.041010, experimental => qw(keyword_all keyword_any);
  17         22  
  17         260  
26 17     17   1361 use builtin::compat qw(indexed blessed);
  17         30  
  17         149  
27 17     17   2553 use Feature::Compat::Try;
  17         25  
  17         123  
28 17     17   871 use Encode 2.89 ();
  17         323  
  17         294  
29 17     17   12493 use JSON::Schema::Modern;
  17         1501362  
  17         1108  
30 17     17   118 use JSON::Schema::Modern::Utilities 0.638 qw(jsonp unjsonp canonical_uri E abort is_equal true false get_type jsonp_set jsonp_get decode_media_type match_media_type);
  17         276  
  17         1833  
31 17     17   84 use OpenAPI::Modern::Utilities qw(add_vocab_and_default_schemas uri_decode intersect_types coerce_primitive uri_encode uri_encode_strict is_cookie_name is_cookie_value elem);
  17         23  
  17         1101  
32 17     17   95 use JSON::Schema::Modern::Document::OpenAPI;
  17         25  
  17         163  
33 17     17   503 use MooX::TypeTiny 0.002002;
  17         227  
  17         108  
34 17     17   11537 use Types::Standard qw(InstanceOf Bool);
  17         31  
  17         156  
35 17     17   21754 use Mojo::Util qw(url_unescape punycode_decode);
  17         45  
  17         1251  
36 17     17   94 use Mojo::Message::Request;
  17         27  
  17         145  
37 17     17   570 use Mojo::Message::Response;
  17         34  
  17         119  
38 17     17   469 use Clone 'clone';
  17         28  
  17         820  
39 17     17   71 use namespace::clean;
  17         43  
  17         136  
40              
41             has openapi_document => (
42             is => 'ro',
43             isa => InstanceOf['JSON::Schema::Modern::Document::OpenAPI'],
44             required => 1,
45             handles => {
46             openapi_uri => 'canonical_uri', # Mojo::URL
47             openapi_schema => 'schema', # hashref
48             document_get => 'get', # data access using a json pointer
49             },
50             );
51              
52             has evaluator => (
53             is => 'ro',
54             isa => InstanceOf['JSON::Schema::Modern'],
55             required => 1,
56             handles => [ qw(get_media_type add_media_type) ], # deprecated; may be removed
57             );
58              
59             our $DEBUG;
60             has debug => (
61             is => 'ro',
62             isa => Bool,
63             default => $DEBUG,
64             );
65              
66             has with_defaults => (
67             is => 'ro',
68             isa => Bool,
69             );
70              
71             around BUILDARGS => sub ($orig, $class, @args) {
72             my $args = $class->$orig(@args);
73              
74             croak 'missing required constructor arguments: either openapi_document or openapi_schema'
75             if not exists $args->{openapi_document} and not exists $args->{openapi_schema};
76              
77             my $had_document = exists $args->{openapi_document};
78              
79             my $extra_args = { %$args };
80             delete $extra_args->@{qw(openapi_document openapi_schema openapi_uri evaluator debug)};
81              
82             $args->{evaluator} //= JSON::Schema::Modern->new(
83             validate_formats => 1,
84             max_depth => 80,
85             %$extra_args, # may include with_defaults, or other arguments recognized by JSM
86             );
87              
88             $args->{openapi_document} //= JSON::Schema::Modern::Document::OpenAPI->new(
89             exists $args->{openapi_uri} ? (canonical_uri => $args->{openapi_uri}) : (),
90             schema => $args->{openapi_schema},
91             evaluator => $args->{evaluator},
92             );
93              
94             # add the OpenAPI vacabulary, formats and metaschemas to the evaluator if they weren't there already
95             add_vocab_and_default_schemas($args->{evaluator}) if $had_document;
96              
97             # if there were errors, this will die with a JSON::Schema::Modern::Result object
98             $args->{evaluator}->add_document($args->{openapi_document});
99              
100             return $args;
101             };
102              
103 329     329 1 137024 sub validate_request ($self, $request, $options = {}) {
  329         500  
  329         463  
  329         668  
  329         440  
104 329 100       2265 croak 'missing request' if not $request;
105              
106             croak '$request and $options->{request} are inconsistent'
107 324 100 66     1769 if $options->{request} and $request != $options->{request};
108              
109             # mostly populated by find_path_item
110 322 100       1640 my $state = {
111             data_path => '/request',
112             $self->with_defaults ? (defaults => {}) : (),
113             };
114              
115 322         673 try {
116 322   33     1719 $options->{request} //= $request;
117 322         1237 my $path_ok = $self->find_path_item($options, $state);
118 322         5043 delete $options->{errors};
119              
120 322         784 my $path_item = delete $options->{_path_item}; # after following path-item $refs
121 322         890 my $operation = delete $options->{_operation};
122 322         1035 my $ops = delete $options->{_operation_path_suffix}; # jsonp-encoded
123              
124             # Reporting a failed find_path_item as an exception will result in a recommended response of
125             # [ 500, Internal Server Error ], which is warranted if we consider the lack of a specification
126             # entry for this incoming request as an unexpected, server-side error.
127             # Callers can decide if this should instead be reported as a [ 404, Not Found ], but that sort
128             # of response is likely to leave oversights in the specification go unnoticed.
129 322 100       796 return $self->_result($state, 1) if not $path_ok;
130              
131 311         895 my $server = { $options->{uri_captures}->%* };
132 311         1113 delete $server->@{keys $options->{path_captures}->%*};
133 311 100       1009 $state->{data} = keys %$server ? { request => { uri => { server => $server } } } : {};
134              
135 311         659 $request = $options->{request}; # now guaranteed to be a Mojo::Message::Request
136              
137             # PARAMETERS
138             # { $in => { $name => path-item|operation } } as we process each one.
139 311         380 my $request_parameters_processed = {};
140 311         427 my %seen_q;
141              
142             # first, consider parameters at the operation level.
143             # parameters at the path-item level are also considered, if not already seen at the operation level
144             SECTION:
145 311         523 foreach my $section (qw(operation path-item)) {
146             ENTRY:
147 606 100 100     3638 foreach my $idx (0 .. (($section eq 'operation' ? $operation : $path_item)->{parameters}//[])->$#*) {
148 843 100       8700 my $state = { %$state, keyword_path => $state->{keyword_path}.($section eq 'operation' ? $ops : '').'/parameters/'.$idx };
149 843 100       3131 my $param_obj = ($section eq 'operation' ? $operation : $path_item)->{parameters}[$idx];
150 843         2848 while (defined(my $ref = $param_obj->{'$ref'})) {
151 88         335 $param_obj = $self->_resolve_ref('parameter', $ref, $state);
152             }
153              
154 835 100       3243 my $fc_name = $param_obj->{in} eq 'header' ? fc($param_obj->{name}) : $param_obj->{name};
155              
156             # v3.2.0 §4.10.1:"The list MUST NOT include duplicated parameters. A unique parameter is
157             # defined by a combination of a name and location."
158             abort($state, 'duplicate %s parameter "%s"', $param_obj->{in}, $param_obj->{name})
159 835 100 100     4136 if (($request_parameters_processed->{$param_obj->{in}}//{})->{$fc_name} // '') eq $section;
      100        
160              
161 831         1910 ++$seen_q{$param_obj->{in}};
162             abort({ %$state, data_path => '/request/uri/query' }, 'cannot use query and querystring together')
163 831 100 100     2516 if $seen_q{query} and $seen_q{querystring};
164              
165             abort({ %$state, data_path => '/request/uri/query' }, 'cannot use more than one querystring')
166 829 100 100     2333 if ($seen_q{querystring}//0) >= 2;
167              
168             # v3.2.0 §4.10.1: "If a[n operation-level] parameter is already defined at the Path Item,
169             # the new definition will override it but can never remove it."
170 17     17   23076 { use autovivification qw(exists store);
  17         29  
  17         153  
  827         832  
171 827 100       2294 next ENTRY if exists $request_parameters_processed->{$param_obj->{in}}{$fc_name};
172 811         2316 $request_parameters_processed->{$param_obj->{in}}{$fc_name} = $section;
173             }
174              
175             my $valid = $self->_validate_parameter({ %$state, depth => $state->{depth}+1,
176             data_path => ($param_obj->{in} eq 'path' ? '/request/uri/path'
177             : $param_obj->{in} eq 'query' ? '/request/uri/query'
178             : $param_obj->{in} eq 'header' ? '/request/header'
179             : $param_obj->{in} eq 'cookie' ? '/request/header/Cookie'
180             : $param_obj->{in} eq 'querystring' ? '/request/uri/query' : die) },
181             $param_obj,
182             $param_obj->{in} eq 'path' ? (path_captures => $options->{path_captures})
183             : $param_obj->{in} eq 'query' ? (params => $request->url->query->clone)
184             : $param_obj->{in} eq 'header' ? (name => $param_obj->{name}, headers => $request->headers)
185             : $param_obj->{in} eq 'cookie' ? (headers => $request->headers)
186 811 50       11441 : $param_obj->{in} eq 'querystring' ? (params => $request->url->query->clone)
    100          
    100          
    100          
    100          
    50          
    100          
    100          
    100          
    100          
187             : die);
188             }
189             }
190              
191             # v3.2.0 §4.8.2, "Path Templating": "Each template expression in the path MUST correspond to a
192             # path parameter that is included in the Path Item itself and/or in each of the Path Item’s
193             # Operations."
194             # We could validate this at document parse time, except the path-item can also be reached via a
195             # $ref and the referencing path could be from another document and is therefore unknowable until
196             # runtime.
197 287         1337 foreach my $path_name (sort keys $options->{path_captures}->%*) {
198             abort({ %$state, data_path => jsonp('/request/uri/path', $path_name) },
199             'missing path parameter specification for "%s"', $path_name)
200 146 100 100     814 if not exists(($request_parameters_processed->{path}//{})->{$path_name});
201             }
202              
203 285         944 $state->{keyword_path} .= $ops;
204              
205             # RFC9112 §6.2-2: "A sender MUST NOT send a Content-Length header field in any message that
206             # contains a Transfer-Encoding header field."
207 285 100 100     1255 ()= E({ %$state, data_path => '/request/header/Content-Length', },
208             'Content-Length cannot appear together with Transfer-Encoding')
209             if defined $request->headers->content_length and $request->content->is_chunked;
210              
211             # RFC9112 §6.3-7: "A user agent that sends a request that contains a message body MUST send
212             # either a valid Content-Length header field or use the chunked transfer coding."
213 285 100 100     9194 ()= E({ %$state, data_path => '/request/header',
      66        
214             recommended_response => [ 411 ] }, 'missing header: Content-Length')
215             if $request->body_size and not $request->headers->content_length
216             and not $request->content->is_chunked;
217              
218 285 100       9612 if (my $body_obj = $operation->{requestBody}) {
219 152         399 $state->{keyword_path} .= '/requestBody';
220              
221 152         838 while (defined(my $ref = $body_obj->{'$ref'})) {
222 12         43 $body_obj = $self->_resolve_ref('request-body', $ref, $state);
223             }
224              
225 150 100       385 if ($request->body_size) {
    100          
226 119         1449 $state->{data_path} = '/request/body';
227 119         1646 $self->_validate_body_content({ %$state, depth => $state->{depth}+1 }, $body_obj->{content}, $request);
228             }
229             elsif ($body_obj->{required}) {
230 5         166 ()= E({ %$state, keyword => 'required' }, 'request body is required but missing');
231             }
232             }
233             else {
234 133         379 $state->{data_path} = '/request/body';
235             # we presume that no body specification for GET and HEAD requests -> no body is expected
236 133 100 100     447 ()= E($state, 'unspecified body is present in %s request', $request->method)
      66        
      100        
237             if ($request->method eq 'GET' or $request->method eq 'HEAD')
238             and $request->headers->content_length // $request->body_size;
239             }
240             }
241             catch ($e) {
242 34 50 33     11877 if (blessed($e) and $e->isa('JSON::Schema::Modern::Result')) {
    50 33        
243 0         0 return $e;
244             }
245             elsif (blessed($e) and $e->isa('JSON::Schema::Modern::Error')) {
246 34         72 push @{$state->{errors}}, $e;
  34         184  
247             }
248             else {
249 0         0 ()= E({ %$state, exception => 1 }, 'EXCEPTION: '.$e);
250             }
251             }
252              
253 311         9211 return $self->_result($state);
254             }
255              
256 79     79 1 208678 sub validate_response ($self, $response, $options = {}) {
  79         126  
  79         138  
  79         133  
  79         125  
257 79 100       422 croak 'missing response' if not $response;
258              
259             # handle the existence of HTTP::Response::request
260 78 100 100     397 if (my $request = $response->can('request') && $response->request) {
261             croak '$response->request and $options->{request} are inconsistent'
262 4 100 66     225 if $request and $options->{request} and $request != $options->{request};
      66        
263 3   33     20 $options->{request} //= $request;
264             }
265              
266             croak '$response and $options->{response} are inconsistent'
267 77 50 33     1030 if $options->{response} and $response != $options->{response};
268              
269             # mostly populated by find_path_item
270 77 100       427 my $state = {
271             data_path => '/response',
272             $self->with_defaults ? (defaults => {}) : (),
273             };
274              
275 77         168 try {
276             # we only need the operation location, and do not need to verify the request uri, path template
277             # and path_captures, so do not pass unnecessary information to find_path_item
278 77         237 my $fp_options = +{ %$options };
279 77 100       424 delete $fp_options->@{qw(request uri method path_template path_captures)} if exists $options->{operation_id};
280 77 100 66     573 delete $fp_options->@{qw(request uri path_captures)} if exists $options->{path_template} and exists $options->{method};
281 77         271 my $path_ok = $self->find_path_item($fp_options, $state);
282 77         2099 $options->@{keys $fp_options->%*} = values $fp_options->%*;
283              
284 77         230 delete $options->@{qw(errors _path_item)};
285 77         166 my $operation = delete $options->{_operation};
286              
287 77 100       211 return $self->_result($state, 1, 1) if not $path_ok;
288              
289 72         221 $state->{data} = {};
290 72         281 $state->{keyword_path} .= delete $options->{_operation_path_suffix}; # jsonp-encoded
291              
292             # now guaranteed to be a Mojo::Message::Response
293 72         259 $options->{response} = $response = _convert_response($response);
294              
295 72 100       276 return $self->_result($state, 0, 1) if not exists $operation->{responses};
296              
297 69 100       192 if ($response->headers->header('Transfer-Encoding')) {
298 8 100 100     152 ()= E({ %$state, data_path => '/response/header/Transfer-Encoding' },
299             '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)"')
300             if $response->is_info or $response->code == 204;
301              
302             ()= E({ %$state, data_path => '/response/header/Transfer-Encoding' },
303             'RFC9112 §6.1-10: "A server MUST NOT send a Transfer-Encoding header field in any 2xx (Successful) response to a CONNECT request"')
304 8 100 100     1140 if $response->is_success and $options->{method} eq 'CONNECT';
305             }
306              
307             # RFC9112 §6.2-2: "A sender MUST NOT send a Content-Length header field in any message that
308             # contains a Transfer-Encoding header field."
309 69 100 100     1641 ()= E({ %$state, data_path => '/response/header/Content-Length' },
310             'Content-Length cannot appear together with Transfer-Encoding')
311             if defined $response->headers->content_length and $response->content->is_chunked;
312              
313             # RFC9112 §6.3-7: "A user agent that sends a request that contains a message body MUST send
314             # either a valid Content-Length header field or use the chunked transfer coding."
315 69 100 100     2289 ()= E({ %$state, data_path => '/response/header' }, 'missing header: Content-Length')
      100        
316             if $response->body_size and not $response->headers->content_length
317             and not $response->content->is_chunked;
318              
319 69 100       3347 if (not $response->code) {
320 1         6 ()= E($state, 'Failed to parse response: %s', $response->error->{message});
321 1         248 return $self->_result($state, 0, 1);
322             }
323              
324 170     170   984 my $response_name = first { exists $operation->{responses}{$_} }
325 68         508 $response->code, substr(sprintf('%03s', $response->code), 0, -2).'XX', 'default';
326              
327 68 100       304 if (not $response_name) {
328 2         24 ()= E({ %$state, keyword => 'responses', data_path => $state->{data_path}.'/code' },
329             'no response object found for code %s', $response->code);
330 2         477 return $self->_result($state, 0, 1);
331             }
332              
333 66         227 my $response_obj = $operation->{responses}{$response_name};
334 66         321 $state->{keyword_path} = jsonp($state->{keyword_path}, 'responses', $response_name);
335 66         913 while (defined(my $ref = $response_obj->{'$ref'})) {
336 32         117 $response_obj = $self->_resolve_ref('response', $ref, $state);
337             }
338              
339 62   100     390 foreach my $header_name (sort keys(($response_obj->{headers}//{})->%*)) {
340 48 100       180 next if fc $header_name eq fc 'Content-Type';
341 40         278 my $state = { %$state, keyword_path => jsonp($state->{keyword_path}, 'headers', $header_name) };
342 40         651 my $header_obj = $response_obj->{headers}{$header_name};
343 40         155 while (defined(my $ref = $header_obj->{'$ref'})) {
344 4         10 $header_obj = $self->_resolve_ref('header', $ref, $state);
345             }
346              
347 40         350 my $valid = $self->_validate_parameter({ %$state, depth => $state->{depth}+1,
348             data_path => '/response/header' },
349             $header_obj, name => $header_name, headers => $response->headers);
350             }
351              
352             # FIXME: can we have a 'required' property here, just like in request?
353             # why do we check for the 'content' property here, and Content-Length, but not for request?
354              
355             $self->_validate_body_content({ %$state, data_path => '/response/body', depth => $state->{depth}+1 },
356             $response_obj->{content}, $response)
357 62 100 66     460 if exists $response_obj->{content} and $response->headers->content_length // $response->body_size;
      100        
358             }
359             catch ($e) {
360 6 50 33     2407 if (blessed($e) and $e->isa('JSON::Schema::Modern::Result')) {
    50 33        
361 0         0 $e->recommended_response(undef); # responses don't have responses
362 0         0 return $e;
363             }
364             elsif (blessed($e) and $e->isa('JSON::Schema::Modern::Error')) {
365 6         30 push @{$state->{errors}}, $e;
  6         28  
366             }
367             else {
368 0         0 ()= E({ %$state, exception => 1 }, 'EXCEPTION: '.$e);
369             }
370             }
371              
372 66         1804 return $self->_result($state, 0, 1);
373             }
374              
375             # deprecated, but we'll continue to support it
376             *find_path = \&find_path_item;
377              
378 570     570 1 715715 sub find_path_item ($self, $options, $state = {}) {
  570         886  
  570         671  
  570         1031  
  570         859  
379 570   100     2295 $state->{data_path} //= '';
380 570         14839 $state->{initial_schema_uri} = $self->openapi_uri; # the canonical URI as of the start or last $id, or the last traversed $ref
381 570         25005 $state->{traversed_keyword_path} = ''; # the accumulated traversal path as of the start, or last $id, or up to the last traversed $ref
382 570         1805 $state->{keyword_path} = ''; # the rest of the path, since the last $id or the last traversed $ref
383 570         2056 $state->{document} = $self->openapi_document;
384             $state->@{qw(specification_version vocabularies)} =
385 570         7046 $self->openapi_document->_get_resource($self->openapi_document->canonical_uri)->@{qw(specification_version vocabularies)};
386 570         112034 $state->{dynamic_scope} = [ $self->openapi_uri ];
387 570   50     21801 $state->{errors} = $options->{errors} //= [];
388 570   50     2712 $state->{annotations} //= [];
389 570         1855 $state->{depth} = 0;
390 570 100 100     3339 $state->{debug} = $options->{debug} = {} if $DEBUG or $self->debug;
391              
392             return E({ %$state, exception => 1, recommended_response => [ 500 ] },
393             'at least one of $options->{request}, ($options->{uri} and $options->{method}), ($options->{path_template} and $options->{method}), or $options->{operation_id} must be provided')
394             if not $options->{request}
395             and not (exists $options->{uri} and exists $options->{method})
396             and not ($options->{path_template} and exists $options->{method})
397 570 100 66     3751 and not exists $options->{operation_id};
      100        
      100        
      100        
      100        
398              
399             return E({ %$state, exception => 1, recommended_response => [ 500 ] },
400             'provided path_captures values must be strings')
401             if exists $options->{path_captures}
402 36 100 66     1312 and any { $_ ne 'string' and $_ ne 'number' and $_ ne 'integer' }
403 566 100 100     2219 map get_type($_), values $options->{path_captures}->%*;
404              
405             # now guaranteed to be a Mojo::Message::Request
406 565 100       2077 if ($options->{request}) {
    100          
407 334         1255 $options->{request} = _convert_request($options->{request});
408              
409             # requests don't have response codes, so if 'error' is set, it is some sort of parsing error
410 334 100       1694 if (my $error = $options->{request}->error) {
411 8         135 return E({ %$state, data_path => '/request', recommended_response => [ 500 ] }, 'Failed to parse request: %s', $error->{message});
412             }
413              
414             return E({ %$state, data_path => '/request/uri', recommended_response => [ 500 ] },
415             'mismatched uri "%s"', $options->{request}->url)
416 326 100 66     3148 if exists $options->{uri} and $options->{request}->url ne $options->{uri};
417 325         1167 $options->{uri} = $options->{request}->url; # a Mojo::URL object
418              
419             return E({ %$state, data_path => '/request/method', recommended_response => [ 500 ] },
420             'wrong HTTP method "%s"', $options->{request}->method)
421 325 100 66     2543 if exists $options->{method} and $options->{request}->method ne $options->{method};
422 323         919 $options->{method} = $options->{request}->method;
423             }
424             elsif (exists $options->{uri}) {
425             $options->{uri} = Mojo::URL->new($options->{uri}.'')
426 121 100 66     939 if not blessed($options->{uri}) or not $options->{uri}->isa('Mojo::URL');
427             }
428              
429             # method from operation_id from options
430 554 100       12025 if (exists $options->{operation_id}) {
431             # FIXME: what if the operation is defined in another document? Need to look it up across
432             # all documents, and localize $state->{initial_schema_uri}
433 81         497 my $operation_path = $self->openapi_document->operationId_path($options->{operation_id});
434             return E({ %$state, recommended_response => [ 500 ] }, 'unknown operation_id "%s"', $options->{operation_id})
435 81 100       685 if not $operation_path;
436              
437             # The path_template cannot be unambiguously found by looking at the path of the operation:
438             # the operation path may be under /components/pathItems or /webhooks, or the intended /paths
439             # entry might contain a $ref to this location.
440             # We can do a URI -> path_template lookup later on, which will succeed if the operation is
441             # reachable from a /paths entry, but as this can possibly match more than once, in order to
442             # provide an unambiguous result, provide the operation_id as well.
443              
444             # the operation path always ends with the method, but casing may vary depending on the parent
445 77         294 my @parts = unjsonp($operation_path);
446 77 100 100     1603 my ($path_item_path, $method) = $parts[-2] ne 'additionalOperations'
447             # differentiate between these operation paths:
448             # /components/pathItems/additionalOperations/get (method = 'GET') vs
449             # /components/pathItems/additionalOperations/additionalOperations/get (method = 'get')
450             || $self->openapi_document->get_entity_at_location(jsonp(@parts[0..$#parts-1])) eq 'path-item'
451             ? (jsonp(@parts[0..$#parts-1]), uc($parts[-1]))
452             : (jsonp(@parts[0..$#parts-2]), $parts[-1]);
453              
454             return E({ %$state, ($options->{uri} ? (data_path => '/request/method') : ()), keyword_path => $operation_path.'/operationId' },
455             'operation at operation_id does not match %s method "%s"%s',
456             $options->{uri} ? 'request' : 'provided HTTP', $options->{method},
457             (!$options->{uri} && $options->{method} eq lc $options->{method}
458             && exists $self->openapi_document->get($path_item_path)->{$options->{method}}
459             ? (' (should be '.uc $options->{method}.')') : ''))
460 77 100 100     1534 if exists $options->{method} and $options->{method} ne $method;
    100 100        
    100          
    100          
461              
462 65         171 $options->{method} = $method;
463              
464 65 100 100     305 if (not $options->{path_template} and not $options->{uri}) {
465             # some operations don't live under a /paths/$path_template (even via a $ref), e.g. webhooks or
466             # callbacks, but they are still usable via operationId for validating responses
467 27         73 $state->{keyword_path} = $path_item_path;
468 27         438 $options->{_path_item} = $self->document_get($path_item_path);
469 27         1805 $options->{_operation} = $self->document_get($operation_path);
470 27         1409 $options->{_operation_path_suffix} = substr($operation_path, length($path_item_path));
471              
472             # FIXME: this is not accurate if the operation lives in another document
473             # (and in that case, get_operation_uri_by_id can be returned as-is)
474 27         135 $options->{operation_uri} = $state->{initial_schema_uri}->clone->fragment($operation_path);
475 27         4049 return 1;
476             }
477             }
478              
479 511         2543 my $schema = $self->openapi_document->schema;
480              
481             # path_template from options
482             return E({ %$state, ($options->{uri} ? (data_path => '/request/uri') : ()),
483             keyword => 'paths' }, 'missing path "%s"', $options->{path_template})
484 511 100 100     2917 if exists $options->{path_template} and not exists $schema->{paths}{$options->{path_template}};
    100          
485              
486 508         775 my $captures; # hashref of template variable names -> concrete values from the uri
487              
488 508 100 66     3309 if (not $options->{path_template} and $options->{uri}) {
    100          
489             # derive path_template and capture values from the request URI
490              
491 411         4001 foreach my $pt ($self->openapi_document->path_templates->@*) {
492 689         5230 my $local_state = +{ %$state };
493 689         3474 $local_state->{path_item} = $schema->{paths}{$pt};
494 689         2291 $local_state->{keyword_path} = jsonp('/paths', $pt);
495 689         10130 $captures = $self->_match_uri($options->@{qw(method uri)}, $pt, $local_state);
496              
497 689 100       55504 if ($captures) {
498             # a URI can match multiple /paths entries, and an operationId can be reachable from multiple
499             # /paths entries, so keep searching until both are a match
500             next if exists $options->{operation_id}
501             and (not exists $local_state->{operation}{operationId}
502 395 100 100     1607 or $local_state->{operation}{operationId} ne $options->{operation_id});
      100        
503              
504 378         3127 %$state = %$local_state;
505 378         1585 $options->{path_template} = $pt;
506 378         1242 last;
507             }
508              
509             # something went wrong, but the match succeeded so we will stop iterating
510 294 100       1946 if ($local_state->{errors}->@*) {
511 7         54 %$state = %$local_state;
512 7         24 $options->{path_template} = $pt;
513 7         40 $options->@{qw(_path_item _operation _operation_path_suffix)} = $state->@{qw(path_item operation operation_path_suffix)};
514 7         81 return;
515             }
516             }
517              
518             return E({ %$state, data_path => '/request', keyword => 'paths' },
519             'no match found for request %s %s',
520             $options->{method}, $options->{uri}->clone->query('')->fragment(undef))
521 404 100       1854 if not exists $options->{path_template};
522             }
523              
524             elsif ($options->{uri}) {
525             # we were passed path_template in options, and now we verify it against the request URI
526 26         188 $state->{path_item} = $schema->{paths}{$options->{path_template}};
527 26         233 $state->{keyword_path} = jsonp('/paths', $options->{path_template});
528 26         362 $captures = $self->_match_uri($options->@{qw(method uri path_template)}, $state);
529              
530 26 100       1039 if (not $captures) {
531             # no path-item and operation found that matches the request's method and uri
532 8         24 delete $options->{operation_id};
533              
534             # the initial match succeeded, but something else went wrong
535 8 50       30 if ($state->{errors}->@*) {
536 0         0 $options->@{qw(_path_item _operation _operation_path_suffix)} = $state->@{qw(path_item operation operation_path_suffix)};
537 0         0 return;
538             }
539              
540             return E({ %$state, data_path => '/request/uri', recommended_response => [ 500 ] },
541             'provided path_template does not match request URI "%s"',
542 8         100 $options->{uri}->clone->query('')->fragment(undef));
543             }
544              
545 18 100 100     174 if (exists $options->{operation_id}
      100        
546             and (not exists $state->{operation}{operationId}
547             or $state->{operation}{operationId} ne $options->{operation_id})) {
548 8         38 delete $options->@{qw(_path_item _operation _operation_path_suffix path_captures uri_captures operation_uri)};
549             return E({ %$state, keyword_path => $state->{keyword_path}.$state->{operation_path_suffix}
550             .(exists $state->{operation}{operationId} ? '/operationId' : ''),
551             recommended_response => [ 500 ] },
552             'provided path_template and operation_id do not match request %s %s',
553 8 100       128 $options->{method}, $options->{uri}->clone->query('')->fragment(undef));
554             }
555             }
556              
557             else {
558             # we were provided $options->{path_template}, and we have already confirmed that it exists.
559 71         385 $state->{path_item} = $schema->{paths}{$options->{path_template}};
560 71         288 $state->{keyword_path} = jsonp('/paths', $options->{path_template});
561 71         1073 while (defined(my $ref = $state->{path_item}{'$ref'})) {
562 3         14 $state->{path_item} = $self->_resolve_ref('path-item', $ref, $state);
563             }
564              
565             $state->@{qw(operation operation_path_suffix)} =
566             elem($options->{method}, [qw(GET PUT POST DELETE OPTIONS HEAD PATCH TRACE QUERY)])
567             ? ($state->{path_item}{lc $options->{method}}, '/'.lc $options->{method})
568 71 100 50     520 : (($state->{path_item}{additionalOperations}//{})->{$options->{method}}, jsonp('/additionalOperations', $options->{method}));
569              
570             return E({ %$state, recommended_response => [ 405 ] },
571             'missing operation for HTTP method "%s" under "%s"%s', $options->@{qw(method path_template)},
572             exists $options->{method} && $options->{method} eq lc $options->{method}
573             && exists $state->{path_item}{$options->{method}} ? (' (should be '.uc $options->{method}.')') : '')
574 71 100 66     354 if not $state->{operation};
    100          
575              
576             return E({ %$state, keyword_path => $state->{keyword_path}.$state->{operation_path_suffix}
577             .(exists $state->{operation}{operationId} ? '/operationId' : ''),
578             recommended_response => [ 500 ] },
579             'templated operation does not match provided operation_id')
580             if exists $options->{operation_id}
581             and (not exists $state->{operation}{operationId}
582 66 100 66     297 or $state->{operation}{operationId} ne $options->{operation_id});
    50 100        
583             }
584              
585 451         3207 $options->@{qw(_path_item _operation _operation_path_suffix)} = $state->@{qw(path_item operation operation_path_suffix)};
586              
587             # if initial_schema_uri still points to the head of the entry document, then we have not followed
588             # a $ref and the path-item is located at /paths/
589             $options->{operation_uri} = $state->{initial_schema_uri}->clone
590 451   66     2082 ->fragment(($state->{initial_schema_uri}->fragment // $state->{keyword_path}).$options->{_operation_path_suffix});
591              
592             $options->{operation_id} = $options->{_operation}{operationId}
593 451 100       67250 if exists $options->{_operation}{operationId};
594              
595 451         2291 my @path_capture_names = ($options->{path_template} =~ /\{([^{}]+)\}/g);
596             return E({ %$state, $options->{uri} ? (data_path => '/request/uri') : (), recommended_response => [ 500 ] }, 'provided path_captures names do not match path template "%s"', $options->{path_template})
597             if exists $options->{path_captures}
598 451 100 100     1991 and not is_equal([ sort keys $options->{path_captures}->%* ], [ sort @path_capture_names ]);
    100          
599              
600 443 100       3905 return 1 if not $captures;
601              
602 381         1093 my @uri_capture_names = keys %$captures;
603              
604 381 100       1181 if (exists $options->{uri_captures}) {
605             return E({ %$state, $options->{uri} ? (data_path => '/request/uri') : (), recommended_response => [ 500 ] },
606             'provided uri_captures names do not match extracted values')
607 4 50       30 if not is_equal([ sort keys $options->{uri_captures}->%* ], [ sort @uri_capture_names ]);
    100          
608              
609             # $equal_state will contain { path => '/0' } indicating the index of the mismatch
610 3 100       420 if (not is_equal([ $options->{uri_captures}->@{@uri_capture_names} ], [ $captures->@{@uri_capture_names} ], my $equal_state = { stringy_numbers => 1 })) {
611             return E({ %$state, data_path => '/request/uri', recommended_response => [ 500 ] },
612 1         148 'provided uri_captures values do not match request URI (value for %s differs)', $uri_capture_names[substr($equal_state->{path}, 1)]);
613             }
614             }
615             else {
616 377         1101 $options->{uri_captures} = $captures;
617             }
618              
619 379 100       1260 if (exists $options->{path_captures}) {
620             # $equal_state will contain { path => '/0' } indicating the index of the mismatch
621 21 100       151 if (not is_equal([ $options->{path_captures}->@{@path_capture_names} ], [ $captures->@{@path_capture_names} ], my $equal_state = { stringy_numbers => 1 })) {
622             return E({ %$state, data_path => '/request/uri', recommended_response => [ 500 ] },
623 3         308 'provided path_captures values do not match request URI (value for %s differs)', $path_capture_names[substr($equal_state->{path}, 1)]);
624             }
625             }
626             else {
627 358         2057 $options->{path_captures} = +{ $captures->%{@path_capture_names} };
628             }
629              
630 376         3386 return 1;
631             }
632              
633             # TODO: this should (also?) be available at JSON::Schema::Modern
634 9     9 1 19894 sub recursive_get ($self, $uri_reference, $entity_type = undef) {
  9         21  
  9         16  
  9         11  
  9         11  
635 9         208 my $base = $self->openapi_document->canonical_uri;
636 9         59 my $ref = $uri_reference;
637 9         14 my ($depth, $schema);
638 9         14 my $parent_obj = {};
639              
640 9         20 while ($ref) {
641 33 100       355 croak 'maximum evaluation depth exceeded' if $depth++ > $self->evaluator->max_depth;
642 32         98 my $uri = Mojo::URL->new($ref)->to_abs($base);
643              
644 32         9204 my $schema_info = $self->evaluator->_fetch_from_uri($uri);
645              
646 32 100       19413 croak 'unable to find resource "', $uri, '"' if not $schema_info;
647 31         140 my $this_entity = $schema_info->{document}->get_entity_at_location($schema_info->{document_path});
648 31 100 100     175 croak sprintf('bad $ref to %s: not a%s "%s"', $schema_info->{canonical_uri}, ($entity_type =~ /^[aeiou]/ ? 'n' : ''), $entity_type)
    100          
649             if $entity_type and $this_entity ne $entity_type;
650              
651 29   66     66 $entity_type //= $this_entity;
652 29         62 $schema = $schema_info->{schema};
653 29         61 $base = $schema_info->{canonical_uri};
654 29 100 100     176 if (defined($ref = $schema->{'$ref'}) and not elem($this_entity, [qw(schema callbacks)])) {
655             # OAS reference object or path-item object: copy summary, description
656             $parent_obj->{summary} = $schema->{summary}
657             if elem($this_entity, [qw(response example path-item)])
658 22 50 66     45 and exists $schema->{summary} and not exists $parent_obj->{summary};
      66        
659             $parent_obj->{description} = $schema->{description}
660 22 100 100     256 if exists $schema->{description} and not exists $parent_obj->{description};
661             }
662             }
663              
664 5         58 $schema = clone($schema);
665 5         16 $schema->{$_} = $parent_obj->{$_} foreach keys %$parent_obj;
666              
667 5 50       45 return wantarray ? ($schema, $base) : $schema;
668             }
669              
670             ######## NO PUBLIC INTERFACES FOLLOW THIS POINT ########
671              
672             # given a request's method and uri, and a path_template, check that these match (taking into
673             # consideration additional information in the current path-item), and extract capture values.
674             # returns false on error, possibly adding errors to $state.
675 715     715   935 sub _match_uri ($self, $method, $uri, $path_template, $state) {
  715         1038  
  715         1121  
  715         946  
  715         1118  
  715         800  
  715         752  
676 715         1863 $uri = $uri->clone->fragment(undef)->query('');
677              
678             # RFC9112 §3.2.1-3: "If the target URI's path component is empty, the client MUST send "/" as the
679             # path within the origin-form of request-target." This also lets us match a path template of "/".
680 715         44578 $uri->path->leading_slash(1); # no effect on stringified URI unless path is empty
681              
682             # v3.2.0 §4.8.2, "Path Templating": "The value for these path parameters MUST NOT contain any
683             # unescaped “generic syntax” characters described by RFC3986 Section 3: forward slashes (/),
684             # question marks (?), or hashes (#)."
685 715 100       41979 my $path_pattern = join '',
686             map +(substr($_, 0, 1) eq '{' ? '([^/?#]*)' : quotemeta($_)),
687             split /(\{[^{}]+\})/, $path_template;
688              
689             # if the uri doesn't match against the path alone, we can immediately bail (and keep looking for
690             # another /paths entry that might match)... this also saves us needless parsing of server objects
691 17     17   101653 do { use autovivification 'store'; push $state->{debug}{uri_patterns}->@*, $path_pattern.'\z' }
  17         257  
  17         170  
  7         30  
692 715 100       3003 if exists $state->{debug};
693 715 100       15029 return if $uri !~ m/$path_pattern\z/;
694              
695             # identify the unmatched part of the request URI, to be later matched against server urls
696 437         88548 my $uri_prefix = substr($uri, 0, -length($&));
697              
698             # extract all capture values from path template variables: ($1 .. $n)
699             # perldoc perlvar, @-: $n coincides with "substr $_, $-[n], $+[n] - $-[n]" if "$-[n]" is defined
700             # We do not url-decode here, because we may need to parse the string for style delimiters first
701 437         72077 my @path_capture_values = map substr($uri, $-[$_], $+[$_]-$-[$_]), 1 .. $#-;
702              
703             # we set aside $state for potential restoration because we might still encounter issues later on
704             # that require us to keep iterating for another URI match
705 437         56392 my $local_state = +{ %$state };
706              
707 437         2584 while (defined(my $ref = $local_state->{path_item}{'$ref'})) {
708 41         172 $local_state->{path_item} = $self->_resolve_ref('path-item', $ref, $local_state);
709             }
710              
711             # v3.2.0 §4.8.1, "Patterned Fields": "In case of ambiguous matching, it’s up to the tooling to
712             # decide which one to use."
713             # There could be another paths entry that matches this URI that does have this method
714             # implemented, so we return false and keep searching. Since we may still match to the wrong URI,
715             # the correct operation can be forced to match by explicitly passing the corresponding
716             # path_template or (preferably) operationId to be used in the search.
717              
718             $local_state->@{qw(operation operation_path_suffix)} =
719             elem($method, [qw(GET PUT POST DELETE OPTIONS HEAD PATCH TRACE QUERY)])
720             ? ($local_state->{path_item}{lc $method}, '/'.lc $method)
721 437 100 100     2494 : (($local_state->{path_item}{additionalOperations}//{})->{$method}, jsonp('/additionalOperations', $method));
722              
723 437 100       1626 return if not $local_state->{operation};
724              
725             # we need to keep track of the traversed path to the servers object, as well as its absolute
726             # location, for usage in error objects
727             my ($servers, $more_keyword_path, $base_schema_uri) =
728             exists $local_state->{operation}{servers}
729             ? ($local_state->{operation}{servers}, $local_state->{operation_path_suffix})
730             : exists $local_state->{path_item}{servers}
731             ? ($local_state->{path_item}{servers}, '')
732             : exists $self->openapi_document->schema->{servers}
733 426 100       5368 ? ($self->openapi_document->schema->{servers}, '', $self->openapi_uri)
    100          
    100          
734             : ();
735              
736             # v3.2.0 §4.1.1, "OpenAPI Object -> servers": "If the servers field is not provided, or is an
737             # empty array, the default value would be an array consisting of a single Server Object with a
738             # url value of `/`."
739 426 100 100     2405 $servers = [{ url => '/' }] if not $servers or not @$servers;
740              
741 426         1967 my @path_capture_names = ($path_template =~ /\{([^{}]+)\}/g);
742              
743 426         3519 foreach my $index_and_server (pairs indexed @$servers) {
744 464         1013 my ($index, $server) = @$index_and_server;
745              
746             # We need a full uri to match against the full uri taken from the request (scheme and host)
747             # we fall back to using the request's scheme, host and port, otherwise the match can never
748             # succeed.
749             # But before we apply URI logic to the server url, we must first protect any templated sections
750             # so they are not altered by normalization. We use NUL, as it is unchanged in the host during
751             # punycode-encoding
752 464         2075 my $normalized_server_url = Mojo::URL->new($server->{url} =~ s/\{[^{}]+\}/\x00/gr)
753             ->to_abs($self->openapi_document->retrieval_uri)
754             ->to_abs($uri);
755              
756             # strips slash if path is '/'; otherwise has no effect on stringified URI
757 464         126437 $normalized_server_url->path->leading_slash(0);
758              
759 464 100       7390 my $server_pattern = join '',
760             map +($_ eq '%00' ? '([^/?#]*)' : quotemeta($_)),
761             split /(%00)/, $normalized_server_url; # all NULs appear as %00 in the stringified form
762 17     17   10609 do { use autovivification 'store'; push $state->{debug}{uri_patterns}->@*, '^'.$server_pattern }
  17         108  
  17         79  
  7         25  
763 464 100       68286 if exists $state->{debug};
764 464 100       5284 next if $uri_prefix !~ m/^$server_pattern\z/;
765              
766             # extract all capture values from server variables: ($1 .. $n)...
767             # perldoc perlvar, @-: $n coincides with "substr $_, $-[n], $+[n] - $-[n]" if "$-[n]" is defined
768 420         1407 my @server_capture_values = map substr($uri_prefix, $-[$_], $+[$_]-$-[$_]), 1 .. $#-;
769              
770             # ...and punycode-decode those from the host, and url-unescape those from the path
771 420   100     1061 my $host_variable_count = ()= ($normalized_server_url->host//'') =~ /\x00/g;
772 420 100       3126 @server_capture_values = (
773             (map +(/^xn--(.+)\z/ ? punycode_decode($1) : $_), @server_capture_values[0 .. $host_variable_count-1]),
774             (map uri_decode($_), @server_capture_values[$host_variable_count .. $#server_capture_values]));
775              
776             # we have a match, so preserve our new $state values created via _resolve_ref
777 420         5658 %$state = %$local_state;
778              
779 420         1911 my @server_capture_names = ($server->{url} =~ /\{([^{}]+)\}/g);
780              
781 420         916 my ($valid, %seen) = (1);
782 420         863 foreach my $name (@server_capture_names, @path_capture_names) {
783             # TODO: ideally this should be caught at document load time, but the use of $refs between
784             # /paths entries and path-items makes this difficult
785             $valid = E({ %$state, keyword => 'url', data_path => '/request/uri',
786             defined $base_schema_uri
787             ? (initial_schema_uri => $base_schema_uri, traversed_keyword_path => '', keyword_path => '/servers/'.$index)
788             : (keyword_path => $state->{keyword_path}.$more_keyword_path.'/servers/'.$index) },
789             'duplicate template name "%s" in server url and path template', $name)
790 232 100       885 if $seen{$name}++;
    100          
791             }
792 420 100       1579 return if not $valid;
793              
794 417         945 my %captures;
795 417         689 @captures{@server_capture_names} = @server_capture_values;
796              
797 417         807 foreach my $name (@server_capture_names) {
798 18 100 50     132 next if not exists((($server->{variables}//{})->{$name}//{})->{enum});
      50        
799              
800             $valid = E({ %$state, data_path => '/request/uri', keyword => 'enum',
801             defined $base_schema_uri
802             ? (initial_schema_uri => $base_schema_uri, traversed_keyword_path => '',
803             keyword_path => jsonp('/servers', $index, 'variables', $name))
804             : (keyword_path => jsonp($state->{keyword_path}.$more_keyword_path, 'servers', $index, 'variables', $name)) },
805             'server url value does not match any of the allowed values')
806 15 100       96 if not elem($captures{$name}, $server->{variables}{$name}{enum});
    100          
807             }
808              
809 417 100       1871 return if not $valid;
810              
811 413         782 @captures{@path_capture_names} = @path_capture_values;
812 413         6609 return \%captures;
813             }
814              
815             # no match against any servers urls
816 6         95 return;
817             }
818              
819             # $param_obj can be a parameter object or a header object
820             # %args can contain any of:
821             # - when $param_obj->{in} is 'path': 'path_captures', a hashref
822             # - when $param_obj->{in} is 'query' or 'querystring': 'params', a Mojo::Parameters object
823             # - when $param_obj->{in} is 'header' or 'cookie': 'headers', a Mojo::Headers object
824             # - when $param_obj->{in} is missing (i.e a header object): 'name', the parameter name
825 1461     1461   28640158 sub _validate_parameter ($self, $state, $param_obj, %args) {
  1461         2315  
  1461         2320  
  1461         2034  
  1461         3938  
  1461         1868  
826 1461   100     5419 my $in = $param_obj->{in} // 'header'; # header objects do not have an 'in' property
827 1461   66     4966 my $name = $param_obj->{name} // $args{name}; # ..or a 'name' property
828              
829 1461         4272 my ($path_captures, $params, $headers) = @args{qw(path_captures params headers)};
830 1461         3697 my $error_count = $state->{errors}->@*;
831              
832             # when $data_ref is false, value is missing; otherwise it is a reference to the deserialized data
833 1461 50       15541 my $data_ref =
    100          
    100          
    100          
    100          
834             $in eq 'path' ? $self->_deserialize_path_parameter({ %$state }, $param_obj, $path_captures)
835             : $in eq 'query' ? $self->_deserialize_query_parameter({ %$state }, $param_obj, $params)
836             : $in eq 'header' ? $self->_deserialize_header_parameter({ %$state }, $param_obj, $name, $headers)
837             : $in eq 'cookie' ? $self->_deserialize_cookie_parameter({ %$state }, $param_obj, $headers)
838             : $in eq 'querystring' ? $self->_deserialize_querystring_parameter({ %$state }, $param_obj, $params)
839             : die;
840              
841 1451 100       29707 return if $state->{errors}->@* > $error_count;
842              
843 1312         2260 my $obj = $param_obj; # $obj can be a parameter, header or media-type object; contains 'schema'
844              
845 1312 100       4435 if (exists $obj->{content}) {
846 277         1010 my ($media_type) = keys $obj->{content}->%*;
847 277         753 $obj = $obj->{content}{$media_type};
848 277         983 $state->{keyword_path} = jsonp($state->{keyword_path}, 'content', $media_type);
849 277         3846 while (defined(my $ref = $obj->{'$ref'})) {
850 52         161 $obj = $self->_resolve_ref('media-type', $ref, $state);
851             }
852             }
853              
854 1312 100       5898 $state->{data_path} = jsonp($state->{data_path}, $name) if $in ne 'querystring';
855              
856 1312 100       12888 if (not $data_ref) {
857             # value is missing, but not required - populate defaults
858             $state->{defaults}{$state->{data_path}} =
859             ref $obj->{schema}{default} ? clone($obj->{schema}{default}) : $obj->{schema}{default}
860             if $state->{defaults} and exists $obj->{schema}
861 365 50 100     1827 and ref $obj->{schema} eq 'HASH' and exists $obj->{schema}{default};
    100 100        
      66        
862              
863 365         3009 return 1;
864             }
865              
866 947         4888 jsonp_set($state->{data}, $state->{data_path}, $data_ref->$*);
867              
868 947         39414 my $valid = 1;
869              
870             $valid = 0 if exists $obj->{schema} and not $self->_evaluate_subschema($data_ref, $obj->{schema},
871 947 100 100     14553 { %$state, depth => $state->{depth}+1, keyword_path => $state->{keyword_path}.'/schema' });
872              
873 947 100       22301 if (exists $obj->{itemSchema}) {
874 16 100       86 return E({ %$state, keyword_path => $state->{keyword_path}.'/itemSchema' },
875             'deserialized %s parameter content is not an array', $in)
876             if ref $data_ref->$* ne 'ARRAY';
877              
878 14         52 foreach my $idx (0..$data_ref->$*->$#*) {
879             $valid = 0 if not $self->_evaluate_subschema(\ $data_ref->$*->[$idx], $obj->{itemSchema},
880             { %$state, depth => $state->{depth}+1, data_path => $state->{data_path}.'/'.$idx,
881 16 100       392 keyword_path => $state->{keyword_path}.'/itemSchema' });
882             }
883             }
884              
885 945         8928 return $valid;
886             }
887              
888             # returns false or reference to deserialized data
889 346     346   489 sub _deserialize_path_parameter ($self, $state, $param_obj, $path_captures) {
  346         551  
  346         607  
  346         515  
  346         503  
  346         592  
890             # 'required' is always true for path parameters
891             # v3.2.0 §4.12.2.1: "If "in" is "path", the name field MUST correspond to a single template
892             # expression occurring within the path field in the Paths Object."
893             return E({ %$state, keyword => 'required' }, 'missing path parameter: %s', $param_obj->{name})
894 346 100       1789 if not exists $path_captures->{$param_obj->{name}};
895              
896 342         1179 my $data = $path_captures->{$param_obj->{name}};
897              
898             # path parameters are always percent-encoded
899 342 100 50     2012 return E({ %$state, data_path => jsonp($state->{data_path}, $param_obj->{name}) },
900             'non-ascii character detected in parameter value: not deserializable')
901             if ($data//'') =~ /[^\x00-\x7F]/;
902              
903             return $self->_deserialize_content(\ uri_decode($data),
904             { %$state, data_path => jsonp($state->{data_path}, $param_obj->{name}) },
905             $param_obj->{content}, ((keys $param_obj->{content}->%*)[0])x2)
906 340 100       1322 if exists $param_obj->{content};
907              
908             return $self->_deserialize_style($data,
909             { %$state, data_path => jsonp($state->{data_path}, $param_obj->{name}) },
910             style => $param_obj->{style}//'simple',
911             explode => $param_obj->{explode}//false,
912 291   100     1858 $param_obj->%{qw(in name schema)},
      100        
913             );
914             }
915              
916             # returns false or reference to deserialized data
917 454     454   784 sub _deserialize_query_parameter ($self, $state, $param_obj, $params) {
  454         782  
  454         783  
  454         810  
  454         722  
  454         555  
918 454 50 33     3998 croak '$params must be a Mojo::Parameters object'
919             if not blessed($params) or not $params->isa('Mojo::Parameters');
920              
921 454 100       5260 if (exists $param_obj->{content}) {
922 110         400 my $data = $params->param($param_obj->{name});
923 110 100 66     9852 if (not defined $data or ($param_obj->{allowEmptyValue} and not length $data)) {
      100        
924             return E({ %$state, keyword => 'required' }, 'missing query parameter: %s', $param_obj->{name})
925 64 100       255 if $param_obj->{required};
926 62         290 return;
927             }
928              
929             return $self->_deserialize_content(\$data,
930             { %$state, data_path => jsonp($state->{data_path}, $param_obj->{name}) },
931 46         381 $param_obj->{content}, ((keys $param_obj->{content}->%*)[0])x2);
932             }
933              
934             # Note that since we already percent-decoded all extracted query components via $params->parse
935             # (called by param, every_param and pairs), we do not do so again here. If the user wishes to
936             # embed delimiter characters (',' or '&') within the value and not have them confused with the
937             # chosen style decoding, they must be escaped first and decoded by the application.
938              
939 344   100     1950 my $style = $param_obj->{style}//'form';
940 344 100 100     2665 my $explode = $param_obj->{explode} // ($style eq 'form' ? true : false);
941              
942 344         1771 my $error_count = $state->{errors}->@*;
943             my $data_ref = $self->_deserialize_style($params,
944             { %$state, data_path => jsonp($state->{data_path}, $param_obj->{name}) },
945             style => $style,
946             explode => $explode,
947             allowEmptyValue => $param_obj->{allowEmptyValue}//false,
948 344   100     2351 $param_obj->%{qw(in name schema)},
949             );
950              
951 343 100       7867 return if $state->{errors}->@* > $error_count;
952              
953 320 100       941 if (not $data_ref) {
954 89 100       313 if ($param_obj->{required}) {
955             my @types = $self->_type_in_schema($param_obj->{schema},
956 24         352 { %$state, keyword_path => $state->{keyword_path}.'/schema' });
957             return E({ %$state, keyword => 'required' },
958             $style eq 'form' && $explode && @types != 6 && elem('object', \@types)
959             ? 'missing query parameters'
960 24 100 100     342 : ('missing query parameter: %s', $param_obj->{name}));
961             }
962              
963 65         244 return;
964             }
965              
966 231         542 return $data_ref;
967             }
968              
969             # validates a header, from either the request or the response
970             # $header_obj can be a parameter object or a header object ('in' and 'name' might be absent)
971             # returns false or reference to deserialized data
972 399     399   573 sub _deserialize_header_parameter ($self, $state, $header_obj, $header_name, $headers) {
  399         602  
  399         601  
  399         606  
  399         593  
  399         545  
  399         553  
973 399 50 33     2575 croak '$headers must be a Mojo::Headers object'
974             if not blessed($headers) or not $headers->isa('Mojo::Headers');
975              
976 399 100       1923 return if grep fc $header_name eq fc $_, qw(Accept Content-Type Authorization);
977              
978             # temporary, until the ABNF is enforced in the OAD schema
979 396 50       1287 return E($state, 'non-ascii character detected in header name: not deserializable')
980             if $header_name =~ /[^\x00-\x7F]/;
981              
982 396 100       1377 if (not $headers->every_header($header_name)->@*) {
983             return E({ %$state, keyword => 'required' }, 'missing header: %s', $header_name)
984 191 100       1152 if $header_obj->{required};
985 178         347 return;
986             }
987              
988             return E({ %$state, data_path => jsonp($state->{data_path}, $header_name) },
989             'wide character detected in header value: not deserializable')
990 205 100       1510 if any { /[^\x00-\xFF]/ } $headers->every_header($header_name)->@*;
  228         1238  
991              
992             # validate as a single comma-concatenated string, presumably to be decoded
993             return $self->_deserialize_content(\ $headers->header($header_name),
994             { %$state, data_path => jsonp($state->{data_path}, $header_name) },
995             $header_obj->{content}, ((keys $header_obj->{content}->%*)[0])x2)
996 203 100       935 if exists $header_obj->{content};
997              
998             # RFC9112 §5.1-3: "The field line value does not include that leading or trailing whitespace: OWS
999             # occurring before the first non-whitespace octet of the field line value, or after the last
1000             # non-whitespace octet of the field line value, is excluded by parsers when extracting the field
1001             # line value from a field line."
1002              
1003             # RFC9110 §5.3-1: "A recipient MAY combine multiple field lines within a field section that have
1004             # the same field name into one field line, without changing the semantics of the message, by
1005             # appending each subsequent field line value to the initial field line value in order, separated
1006             # by a comma (",") and optional whitespace (OWS, defined in Section 5.6.3). For consistency, use
1007             # comma SP."
1008              
1009             # In order to deserialize from a single string using the "simple" style, we concatenate all header
1010             # lines together, after removing leading and trailing whitespace and then pct-encoding the result
1011             # (as it is decoded again after splitting on delimiters). OWS after commas are parsed out later.
1012              
1013             return $self->_deserialize_style(
1014             # , and = delimiters are not percent-encoded
1015             Mojo::Util::url_escape(join(',',
1016             map s/^\s*//r =~ s/\s*\z//r, $headers->every_header($header_name)->@*),
1017             '^A-Za-z0-9\-._~:/?#[\]@!$&\'()*+,;='), # unreserved and reserved
1018             { %$state, data_path => jsonp($state->{data_path}, $header_name) },
1019             in => 'header',
1020             style => $header_obj->{style}//'simple',
1021             explode => $header_obj->{explode}//false,
1022             name => $header_name,
1023             schema => $header_obj->{schema},
1024 171   100     504 strip_internal_ws => 1,
      100        
1025             );
1026             }
1027              
1028             # returns false or reference to deserialized data
1029 219     219   490 sub _deserialize_cookie_parameter ($self, $state, $param_obj, $headers) {
  219         415  
  219         500  
  219         410  
  219         448  
  219         418  
1030 219 50 33     2184 croak '$headers must be a Mojo::Headers object'
1031             if not blessed($headers) or not $headers->isa('Mojo::Headers');
1032              
1033 219         950 my $cookie = $headers->every_header('Cookie');
1034              
1035 219 100       1546 if (not @$cookie) {
1036 38 100       133 return E({ %$state, keyword => 'required' }, 'missing header: Cookie') if $param_obj->{required};
1037 35         81 return;
1038             }
1039              
1040 181 50       669 return E($state, 'RFC6265 §5.4: "When the user agent generates an HTTP request, the user agent MUST NOT attach more than one Cookie header field."') if @$cookie > 1;
1041              
1042 181         559 my $error_count = $state->{errors}->@*;
1043              
1044             # parse into individual cookie parameters as per RFC6265 §4.2.1
1045 181         2193 my $data = $cookie->[0] =~ s/^[\x09\x20]*|[\x09\x20]*\z//gr;
1046 181         1358 my @pairs = map [ split /=/, $_, 2 ], split /; /, $data;
1047              
1048 181 100       1030 if (my @missing_values = grep !defined $_->[1], @pairs) {
1049             ()= E({ %$state, data_path => jsonp($state->{data_path}, $_->[0]) },
1050 4         28 'cookie-string "%s" is missing a value', $_->[0] =~ s/"/\\"/gr) foreach @missing_values;
1051             }
1052              
1053 181 100       2422 if (my @bad_names = grep !is_cookie_name($_), map $_->[0], @pairs) {
1054             ()= E({ %$state, data_path => jsonp($state->{data_path}, $_) },
1055 3         22 'invalid cookie name: "%s"', s/"/\\"/gr) foreach @bad_names;
1056             }
1057              
1058 181 100 100     1879 if (my @bad_values = grep defined $_->[1] && !is_cookie_value($_->[1]), @pairs) {
1059             ()= E({ %$state, data_path => jsonp($state->{data_path}, $_->[0]) },
1060 5         38 'invalid cookie value: "%s"', $_->[1] =~ s/"/\\"/gr) foreach @bad_values;
1061             }
1062              
1063 181 100       2146 return if $state->{errors}->@* > $error_count;
1064              
1065 169 100       711 if (exists $param_obj->{content}) {
1066 20   100     125 my $data = ((grep +($_->[0] eq $param_obj->{name}), @pairs)[-1]//[])->[1];
1067              
1068 20 100       67 if (not defined $data) {
1069             return E({ %$state, keyword => 'required' }, 'missing cookie parameter: %s', $param_obj->{name})
1070 2 100       7 if $param_obj->{required};
1071 1         4 return;
1072             }
1073              
1074             return $self->_deserialize_content(\$data,
1075             { %$state, data_path => jsonp($state->{data_path}, $param_obj->{name}) },
1076 18         164 $param_obj->{content}, ((keys $param_obj->{content}->%*)[0])x2);
1077             }
1078              
1079 149   100     726 my $style = $param_obj->{style}//'form';
1080 149   100     1039 my $explode = $param_obj->{explode}//true;
1081              
1082 149 100       883 if ($style eq 'form') {
1083             my @types = $self->_type_in_schema($param_obj->{schema},
1084             { %$state, data_path => jsonp($state->{data_path}, $param_obj->{name}),
1085 96         815 keyword_path => $state->{keyword_path}.'/schema' });
1086              
1087 95 100 100     796 if ($explode and @types != 6 and elem('object', \@types)) {
      100        
1088 18 100       71 if (@pairs > 1) {
1089 1         7 return E({ %$state, data_path => jsonp($state->{data_path}, $param_obj->{name}),
1090             keyword => 'style' },
1091             'cannot deserialize into an object with style=form, explode=true when multiple cookies are present');
1092             }
1093              
1094             # deserialize this entire cookie string as form-style-encoded
1095 17 100       115 $data = Mojo::Parameters->new(@pairs ? join('=', $pairs[0]->@*) : ());
1096             }
1097             else {
1098             # find the matching cookie parameter and parse it as form-style-encoded
1099             # we construct the object as a string, not as pairs, so any embedded '&' and '=' do not become
1100             # encoded
1101 77         537 my @data = grep +(uri_decode($_->[0]) eq $param_obj->{name}), @pairs;
1102 77 100 50     4556 $data = Mojo::Parameters->new(@data ? uri_encode_strict($param_obj->{name}).'='.($data[-1]->[1]//'') : ());
1103             }
1104             }
1105              
1106             # for style=cookie, we send the entire header string to be parsed
1107              
1108 147 50       4023 die if $error_count != $state->{errors}->@*;
1109             my $data_ref = $self->_deserialize_style($data,
1110             { %$state, data_path => jsonp($state->{data_path}, $param_obj->{name}) },
1111             style => $style,
1112             explode => $explode,
1113 147         1069 $param_obj->%{qw(in name schema)},
1114             );
1115              
1116 147 100       1888 return if $state->{errors}->@* > $error_count;
1117              
1118 145 100       564 if (not $data_ref) {
1119 21 100       90 if ($param_obj->{required}) {
1120             my @types = $self->_type_in_schema($param_obj->{schema},
1121 8         120 { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1122             return E({ %$state, keyword => 'required' },
1123             $style eq 'form' && $explode && @types != 6 && elem('object', \@types)
1124             ? 'missing cookie parameters'
1125 8 100 100     100 : ('missing cookie parameter: %s', $param_obj->{name}));
1126             }
1127             }
1128              
1129 137         641 return $data_ref;
1130             }
1131              
1132             # returns false or reference to deserialized data
1133 43     43   63 sub _deserialize_querystring_parameter ($self, $state, $param_obj, $params) {
  43         56  
  43         112  
  43         74  
  43         75  
  43         62  
1134 43 50 33     291 die '$params must be a Mojo::Parameters object'
1135             if not blessed($params) or not $params->isa('Mojo::Parameters');
1136              
1137             # note: if something has caused the Mojo::Parameters object to be normalized (e.g. calling
1138             # 'pairs'), the raw string value is lost -- we could try stringifying it again, but if the
1139             # data is incompatible with application/x-www-form-urlencoded, we may not be able to recover
1140             # all the intended data.
1141              
1142 43 100       174 if (not exists $params->{string}) {
1143             # $params->clone (done above) will create empty pairs if {string} is missing
1144             carp 'request uri querystring has been lost: see L for how to avoid'
1145 11 100 66     299 if exists $params->{pairs} and $params->{pairs}->@*;
1146              
1147 11 100       60 return E({ %$state, keyword => 'required' }, 'missing querystring') if $param_obj->{required};
1148 8         18 return;
1149             }
1150              
1151 32         91 my $data = $params->{string};
1152              
1153             # query parameters are always percent-encoded
1154 32 50       161 return E($state, 'non-ascii character detected in parameter value: not deserializable')
1155             if $data =~ /[^\x00-\x7F]/;
1156              
1157             # Replace "+" with whitespace, unescape and decode as in Mojo::Parameters::pairs
1158             # We do not UTF-8-decode the content: this is the responsibility of the media-type decoder.
1159 32         134 $data = url_unescape($data =~ s/\+/ /gr);
1160              
1161             return $self->_deserialize_content(\$data, $state,
1162 32         662 $param_obj->{content}, ((keys $param_obj->{content}->%*)[0])x2);
1163             }
1164              
1165             # Data comes in as a string, or for some styles as a Mojo::Parameters object.
1166             # When parsing fails, $state->{errors} is populated and false is returned;
1167             # otherwise, the return value is a reference to the fully deserialized data, parsed into the correct
1168             # type(s) (which may be undef = null), or undef if no data was extracted
1169             # %opt is (:$in, :$style, :$explode, :$allowEmptyValue, :$name, :$schema, $strip_internal_ws)
1170 953     953   32872 sub _deserialize_style ($self, $data, $state, %opt) {
  953         1534  
  953         1552  
  953         1419  
  953         4012  
  953         1569  
1171             # numbers and builtin bools can be treated as strings, but reject undef and references
1172 953 50 66     5958 croak 'only strings can be deserialized' if not defined $data
      33        
1173             or (ref $data and ref $data ne 'Mojo::Parameters');
1174              
1175             my ($in, $style, $explode, $allowEmptyValue, $name, $schema, $strip_internal_ws) =
1176 953         4268 @opt{qw(in style explode allowEmptyValue name schema strip_internal_ws)};
1177              
1178             my @types = $self->_type_in_schema($schema, { %$state,
1179 953         8925 keyword_path => $state->{keyword_path}.'/schema' });
1180              
1181 950 100 100     11769 if ($style eq 'simple' or $style eq 'matrix' or $style eq 'label') {
    100 100        
    100 100        
    100          
    50          
1182             # RFC6570 §3.2.1: "A variable that is undefined (§2.3) has no value and is ignored by the
1183             # expansion process. If all of the variables in an expression are undefined, then the
1184             # expression's expansion is the empty string."
1185 460 100 100     1513 if ($data eq '' and ($style ne 'simple' or @types != 6)) {
      100        
1186 68 100       278 if (elem('null', \@types)) {
    100          
    100          
1187 10         59 return \undef;
1188             }
1189             elsif (elem('array', \@types)) {
1190             # RFC6570 §2.3-6: "A variable defined as a list value is considered undefined if the list
1191             # contains zero members."
1192 26         190 return \ [];
1193             }
1194             elsif (elem('object', \@types)) {
1195             # RFC6570 §2.3-6: "A variable defined as an associative array of (name, value) pairs is
1196             # considered undefined if the array contains zero members or if all member names in the
1197             # array are associated with undefined values."
1198 26         186 return \ {};
1199             }
1200             }
1201              
1202 398 50       1398 my $prefix =
    100          
    100          
1203             $style eq 'simple' ? ''
1204             : $style eq 'label' ? '\.'
1205             : $style eq 'matrix' ? ';'
1206             : die;
1207              
1208 398 100 100     2405 return E({ %$state, keyword => 'style' },
1209             'data does not match indicated style "%s" (invalid prefix)', $style)
1210             if length $prefix and $data !~ s/^$prefix//;
1211              
1212 397 50 100     2427 my $delimiter =
    100          
    100          
    100          
    100          
1213             $style eq 'simple' && $strip_internal_ws ? ',(?:%20)?'
1214             : !$explode ? ','
1215             : $style eq 'simple' ? ','
1216             : $style eq 'matrix' ? ';'
1217             : $style eq 'label' ? '\.'
1218             : die;
1219              
1220 397         1988 my @errors;
1221              
1222             # if all types are acceptable, fall through to returning string immediately
1223              
1224             # this block is for:
1225             # style=simple, explode=true, object
1226             # style=label, explode=true, object
1227             # style=matrix, explode=true, object
1228             # style=matrix, explode=true, array
1229             # but NOT: explode=false (any style), or style=simple/label, explode=true for arrays.
1230 397 100 100     1808 if (@types != 6 and $explode
      100        
      100        
1231             and ((($style eq 'simple' or $style eq 'label') and elem('object', \@types))
1232             or ($style eq 'matrix' and elem([qw(object array)], \@types)))) {
1233              
1234 66         1449 my @values = split($delimiter, $data, -1);
1235              
1236 66 100       241 my $type = elem('object', \@types) ? 'object' : 'array';
1237 66         160 my $idx = -1;
1238              
1239             # we only make one attempt, even if both types are requested, because any errors when
1240             # deserializing to an object will also occur for array
1241             # RFC6570 §3.2.1-7: for arrays with style=matrix and explode=true, expand as an object,
1242             # where the key names are the parameter name
1243              
1244             my @keys_and_values = map uri_decode($_),
1245             map {
1246 66         169 ++$idx;
  189         211  
1247             # RFC6570 §3.2.1-6: empty value does not use '='
1248 189         430 my ($key, $val) = split(/=/, $_, 2);
1249              
1250 189 100 100     776 ()= E({ %$state, keyword => 'style', errors => \@errors },
    100          
1251             'data does not match indicated style "%s" for %s (invalid separator at %s)',
1252             $style, $type, $type eq 'object' ? 'key "'.$key.'"' : 'index '.$idx)
1253             if defined $val and not length $val;
1254 189   100     3167 ($key//'', $val//'');
      100        
1255             }
1256             @values;
1257              
1258             my $data = $type eq 'object' ? +{ @keys_and_values }
1259             : [ map {
1260 66 50 66     1839 ()= E({ %$state, keyword => 'style', errors => \@errors },
  28 100       139  
    100          
1261             'data does not match indicated style "matrix" for array (invalid element name%s)',
1262             defined $_->[0] ? ' at "'.$_->[0].'"' : '')
1263             if not defined $_->[0] or $_->[0] ne $name;
1264 28         290 $_->[1];
1265             }
1266             pairs @keys_and_values
1267             ];
1268              
1269 66 100       216 if (not @errors) {
1270 54 100       670 $self->_coerce_object_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' }) if $type eq 'object';
1271 54 100       324 $self->_coerce_array_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' }) if $type eq 'array';
1272 54         496 return \$data;
1273             }
1274             }
1275              
1276             # process matrix prefix for primitives, and for array and object when explode=false
1277 343 100 66     3386 if ($style eq 'matrix'
      100        
      66        
      66        
1278             and (not $explode or elem([qw(string number boolean null)], \@types))
1279             # '=' is included after the name iff the variable's value is not empty; name is still encoded
1280             and ($data !~ s/^([^{}=]+)(?:=(?=.)|$)// or uri_decode($1) ne $name)) {
1281 2         53 ()= E({ %$state, keyword => 'style', errors => \@errors },
1282             'data does not match indicated style "matrix" (invalid prefix)');
1283 2         398 push $state->{errors}->@*, @errors;
1284 2         13 return;
1285             }
1286              
1287             # this block is for:
1288             # all styles, explode=false, objects
1289             # style=simple or label, explode=false, array
1290             # style=simple or label, explode=true, array
1291             # style=matrix, explode=false, array
1292              
1293 341 100 100     4085 if (@types != 6 and elem([qw(array object)], \@types)) {
1294 144         3458 my @values = map uri_decode($_), split($delimiter, $data, -1);
1295              
1296 144 100 100     3865 if (not $explode and elem('object', \@types)) {
1297 59 100       235 if (not @values % 2) {
1298 49         248 $data = +{ @values };
1299 49         625 $self->_coerce_object_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1300 49         508 return \$data;
1301             }
1302             # fall through to primitive
1303             }
1304              
1305 95 100 100     467 if (elem('array', \@types)
      100        
1306             and (elem($style, [qw(simple label)]) or ($style eq 'matrix' and not $explode))) {
1307 78         231 $data = \@values;
1308 78         1292 $self->_coerce_array_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1309 78         797 return \$data;
1310             }
1311             }
1312              
1313 214         812 $data = uri_decode($data);
1314 214 100 100     12076 return \$data if @types == 6 or coerce_primitive(\$data, \@types);
1315              
1316 26 100       85 if (@errors) {
1317 8         40 push $state->{errors}->@*, @errors;
1318 8         53 return;
1319             }
1320             }
1321              
1322             elsif ($style eq 'form') {
1323             # $data is a Mojo::Parameters object for this style
1324 313 50       1115 croak 'form style requires a parameter object' if ref $data ne 'Mojo::Parameters';
1325 313         646 my $params = $data;
1326              
1327             # if all types are acceptable, fall through to returning string immediately
1328              
1329 313 100 100     1242 if ($explode and @types != 6) {
1330 208 100       2658 if (elem('array', \@types)) {
1331 38         164 $data = $params->every_param($name);
1332 38 100       6117 $data = [ grep length, @$data ] if $allowEmptyValue;
1333 38         676 $self->_coerce_array_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1334 38 100       340 return @$data ? \$data : ();
1335             }
1336              
1337 170 100       504 if (elem('object', \@types)) {
1338             # treat the entire querystring as the hash of keys and values; if duplicate, last entry wins
1339 40         141 $data = +{ $params->pairs->@* };
1340 40 100       4715 delete $data->@{grep +(!length $data->{$_}), keys %$data} if $allowEmptyValue;
1341 40         708 $self->_coerce_object_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1342 40 100       370 return keys %$data ? \$data : ();
1343             }
1344             }
1345              
1346             # single parameter value used for primitives, and for array and object when explode=false
1347             # (explode=false is not valid with array, object for cookies)
1348             # if the parameter name appears more than once, the last value will be used
1349 235         1815 $data = $params->param($name);
1350 235 100 100     27511 return if not defined $data or $allowEmptyValue and not length $data;
      100        
1351              
1352 168 100 100     1589 if ($in ne 'cookie' and not $explode and @types != 6 and elem([qw(array object)], \@types)) {
      100        
      66        
1353             # note: all ',' characters will be seen as delimiters, unless double encoding is done (and an
1354             # extra encoding pass done in the application). If this is a problem, switch from
1355             # explode=false to explode=true.
1356 35         167 my @values = split /,/, $data, -1;
1357 35 100 100     170 if (not @values % 2 and elem('object', \@types)) {
1358 15         67 $data = +{ @values };
1359 15         249 $self->_coerce_object_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1360 15         122 return \$data;
1361             }
1362 20 100       60 if (elem('array', \@types)) {
1363 18         44 $data = \@values;
1364 18         307 $self->_coerce_array_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1365 18         162 return \$data;
1366             }
1367             }
1368              
1369 135 100 100     1294 return \$data if @types == 6 or coerce_primitive(\$data, \@types);
1370             }
1371              
1372             elsif ($style eq 'spaceDelimited' or $style eq 'pipeDelimited') {
1373 94 50       461 croak 'query parameters require a parameter object' if ref $data ne 'Mojo::Parameters';
1374              
1375 94 100       404 return E({ %$state, keyword => 'explode' }, 'explode=true is not supported for style=%s', $style)
1376             if $explode;
1377              
1378 90 100       1002 return E({ %$state, keyword => 'style' }, '%s style can only deserialize to arrays or objects', $style)
1379             if not elem([qw(array object)], \@types);
1380              
1381             # $data argument is a Mojo::Parameters object for this style
1382 84         451 $data = $data->param($name);
1383 84 100 66     15704 return if not defined $data or $allowEmptyValue and not length $data;
      100        
1384              
1385             # we do NOT perform another decoding pass here:
1386             # v3.2.0 §E.6 "Percent-Encoding and Illegal or Reserved Delimiters": For maximum
1387             # interoperability, it is RECOMMENDED to either define and document an additional escape
1388             # convention while percent-encoding the delimiters for these styles [deepObject, pipeDelimited,
1389             # and spaceDelimited], or to avoid these styles entirely. The exact method of additional
1390             # encoding/escaping is left to the API designer, and is expected to be performed before
1391             # serialization and encoding described in this specification, and reversed after this
1392             # specification’s encoding and serialization steps are reversed. This keeps it outside of the
1393             # processes governed by this specification.
1394              
1395             # if the parameter name appears more than once, the last value will be used
1396 72 50       748 my $delimiter = $style eq 'spaceDelimited' ? ' ' : $style eq 'pipeDelimited' ? '\|' : die;
    100          
1397 72         1700 my @values = split /$delimiter/, $data, -1;
1398              
1399 72 100 100     426 if (not @values % 2 and elem('object', \@types)) {
1400 24         110 $data = +{ @values };
1401 24         310 $self->_coerce_object_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1402 24         167 return \$data;
1403             }
1404 48 100       126 if (elem('array', \@types)) {
1405 44         75 $data = \@values;
1406 44         579 $self->_coerce_array_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1407 44         284 return \$data;
1408             }
1409             }
1410              
1411             elsif ($style eq 'deepObject') {
1412 30 50       142 croak 'query parameters require a parameter object' if ref $data ne 'Mojo::Parameters';
1413              
1414             # v3.1.1 §4.8.12.2.2: "Note that despite false being the default for deepObject, the combination
1415             # of false with deepObject is undefined."
1416             # v3.2.0 §4.12.2.2: "...when style is "deepObject", [explode] has no effect."
1417 30 50 66     138 return E({ %$state, keyword => 'explode' }, '"explode" cannot be false with style=deepObject')
1418             if not $explode and $self->openapi_document->oas_version < '3.2';
1419              
1420 30 100       374 return E({ %$state, keyword => 'style' }, 'deepObject style can only deserialize to objects')
1421             if not elem('object', \@types);
1422              
1423             # $data is a Mojo::Parameters object for this style
1424 28 50       100 croak 'query parameters require a parameter object' if ref $data ne 'Mojo::Parameters';
1425 28         50 my $params = $data;
1426 28         76 $data = {};
1427 28         79 foreach my $pair (pairs $params->pairs->@*) {
1428 91 100       5537 if ($pair->[0] =~ /^([^\[]*)\[([^\]]*)\]\z/) {
1429             # we do NOT perform another decoding pass here: see v3.2.0 §E.6 (as above).
1430 71 100 100     244 $data->{$2} = $pair->[1] if $1 eq $name and (not $allowEmptyValue or length $pair->[1]);
      100        
1431             }
1432             }
1433              
1434 28         497 $self->_coerce_object_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1435 28 100       243 return keys %$data ? \$data : ();
1436             }
1437              
1438             elsif ($style eq 'cookie') {
1439             # v3.2.0 §4.12.3 "Analogous to form, but following RFC6265 Cookie syntax rules, meaning that
1440             # name-value pairs are separated by a semicolon followed by a single space (e.g. n1=v1; n2=v2),
1441             # and no percent-encoding or other escaping is applied; data values that require any sort of
1442             # escaping MUST be provided in escaped form."
1443              
1444 53         413 my @pairs = map [ split /=/, $_, 2 ], split /; /, $data;
1445              
1446 53 50       333 if (my @missing_values = grep !defined $_->[1], @pairs) {
1447             ()= E({ %$state, keyword => 'style' }, 'cookie-string "%s" is missing a value', $_->[0])
1448 0         0 foreach @missing_values;
1449 0         0 return;
1450             }
1451              
1452             # if all types are acceptable, fall through to returning string immediately
1453              
1454 53 100 66     232 if ($explode and @types != 6) {
1455 51 100       632 if (elem('array', \@types)) {
1456 5 100       59 $data = [ map +($_->[0] eq $name ? $_->[1] : ()), @pairs ];
1457 5         64 $self->_coerce_array_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1458 5 50       49 return @$data ? \$data : ();
1459             }
1460              
1461 46 100       129 if (elem('object', \@types)) {
1462             # treat the entire header string as the hash of keys and values; if duplicate, last entry wins
1463 17         100 $data = +{ map @$_, @pairs };
1464 17         246 $self->_coerce_object_elements($data, $schema, { %$state, keyword_path => $state->{keyword_path}.'/schema' });
1465 17 50       188 return keys %$data ? \$data : ();
1466             }
1467             }
1468              
1469             # single parameter value used for primitives; explode=false is not valid with array, object
1470             # if the parameter name appears more than once, the last value will be used
1471              
1472 31         137 my @every_param = grep +($_->[0] eq $name), @pairs;
1473 31   100     125 $data = ($every_param[-1]//[])->[1];
1474              
1475 31 100       105 return if not defined $data;
1476 28 50 66     181 return \$data if @types == 6 or coerce_primitive(\$data, \@types);
1477             }
1478              
1479             else {
1480 0         0 die 'unsupported style ', $style;
1481             }
1482              
1483 31 100       465 return E({ %$state, keyword => 'style' },
    50          
    100          
1484             'cannot deserialize to %s type%s%s', !@types ? 'any' : 'requested', @types > 1 ? 's' : '',
1485             @types ? ' ('.join(', ', @types).')' : '');
1486             }
1487              
1488 167     167   1937 sub _validate_body_content ($self, $state, $content_obj, $message) {
  167         279  
  167         245  
  167         286  
  167         243  
  167         253  
1489 167         478 my $content_type = $message->headers->content_type;
1490              
1491 167 100       2622 return E({ %$state, data_path => $state->{data_path} =~ s{body\z}{header}r, keyword => 'content' },
1492             'missing header: Content-Type')
1493             if not length $content_type;
1494              
1495 163         1329 my $media_type = match_media_type($content_type, [ keys $content_obj->%* ]);
1496 163 100       11101 return E({ %$state, keyword => 'content', recommended_response => [ 415 ] },
1497             'incorrect Content-Type "%s"', $content_type)
1498             if not defined $media_type;
1499              
1500 159         561 my $content_ref = \ $message->body;
1501              
1502 159         4391 $content_ref = $self->_deserialize_content($content_ref, { %$state }, $content_obj, $media_type, $content_type);
1503 151 100       3906 return if not $content_ref;
1504 139         520 $state->{data_path} .= '/content';
1505              
1506 139         745 jsonp_set($state->{data}, $state->{data_path}, $content_ref->$*);
1507              
1508 139         5894 my $media_type_obj = $content_obj->{$media_type};
1509 139         503 $state->{keyword_path} = jsonp($state->{keyword_path}, 'content', $media_type);
1510 139         1970 while (defined(my $ref = $media_type_obj->{'$ref'})) {
1511 4         19 $media_type_obj = $self->_resolve_ref('media-type', $ref, $state);
1512             }
1513              
1514 139         288 my $valid = 1;
1515              
1516 139 100       4333 if (exists $media_type_obj->{schema}) {
1517             $valid = $self->_evaluate_subschema($content_ref, $media_type_obj->{schema},
1518 125         1616 { %$state, depth => $state->{depth}+1, keyword_path => $state->{keyword_path}.'/schema' });
1519             }
1520              
1521 139 100       7686 if (exists $media_type_obj->{itemSchema}) {
1522 8 100       63 return E({ %$state, keyword_path => $state->{keyword_path}.'/itemSchema' },
1523             'deserialized message content is not an array')
1524             if ref $content_ref->$* ne 'ARRAY';
1525              
1526 6         29 foreach my $idx (0..$content_ref->$*->$#*) {
1527             $valid = 0 if not $self->_evaluate_subschema(\ $content_ref->$*->[$idx],
1528             $media_type_obj->{itemSchema},
1529             { %$state, depth => $state->{depth}+1, data_path => $state->{data_path}.'/'.$idx,
1530 10 100       606 keyword_path => $state->{keyword_path}.'/itemSchema' });
1531             }
1532             }
1533              
1534 137         1963 return $valid;
1535             }
1536              
1537             # $media_type is the media-type property to be used under the content object;
1538             # $content_type is what is used for the content decoding (the Content-Type header of the message)
1539             # returns false or reference to deserialized data
1540 336     336   6443 sub _deserialize_content ($self, $content_ref, $state, $content_obj, $media_type, $content_type) {
  336         507  
  336         456  
  336         461  
  336         422  
  336         599  
  336         475  
  336         465  
1541 336         1150 $state->{keyword_path} = jsonp($state->{keyword_path}, 'content', $media_type);
1542              
1543             # TODO: respect Content-Encoding header
1544              
1545 336         4345 my $deserialized_content_ref;
1546 336         577 try {
1547             # case-insensitive, wildcard lookup; text/* supports charset;
1548             # returns undef if no suitable decoder can be found
1549 336         1193 $deserialized_content_ref = decode_media_type($content_type, $content_ref);
1550             }
1551             catch ($e) {
1552 20         3046 return E($state, 'could not decode content as %s: %s', $content_type, $e =~ s/^(.*)\n/$1/r);
1553             }
1554              
1555 316         42630 my $saved_state = { %$state };
1556              
1557 316         1078 my $media_type_obj = $content_obj->{$media_type};
1558 316         1412 while (defined(my $ref = $media_type_obj->{'$ref'})) {
1559 36         154 $media_type_obj = $self->_resolve_ref('media-type', $ref, $state);
1560             }
1561              
1562 316 100       745 if (not $deserialized_content_ref) {
1563             # don't fail, and return the original data, if the best-matching media-type object is under */*
1564             # or the schema would pass on any input
1565             return $content_ref if $media_type eq '*/*'
1566 18 100       154 or all { ref $_ eq 'HASH' ? !keys %$_ : $_ }
1567 30 100 100     291 ($media_type_obj->{schema}//(), $media_type_obj->{itemSchema}//());
      100        
      100        
1568              
1569             # coming soon!
1570 14 100       64 abort($saved_state, 'EXCEPTION: unimplemented media type "%s"', $content_type =~ s/;.*\z//r)
1571             if match_media_type($content_type, ['multipart/*']);
1572              
1573 10         766 abort($saved_state, 'EXCEPTION: unsupported media type "%s": add support with JSON::Schema::Modern::Utilities::add_media_type(...)', $content_type =~ s/;.*\z//r);
1574             }
1575              
1576             # coming soon!
1577 286 100 100     981 if (match_media_type($content_type, ['application/x-www-form-urlencoded', 'multipart/*'])
1578 20     20   215 and my $keyword = first { exists $content_obj->{$media_type}{$_} } qw(encoding prefixEncoding itemEncoding)) {
1579 2         30 return E({ %$state, keyword => $keyword }, '%s keyword not yet implemented', $keyword);
1580             }
1581              
1582 284         27859 return $deserialized_content_ref;
1583             }
1584              
1585             # wrap a result object around the errors
1586 399     399   676 sub _result ($self, $state, $is_exception = 0, $is_response = 0) {
  399         609  
  399         506  
  399         555  
  399         456  
  399         612  
1587 399 50 66     1020 croak 'no errors provided for exception' if $is_exception and not $state->{errors}->@*;
1588             return JSON::Schema::Modern::Result->new(
1589             output_format => $self->evaluator->output_format,
1590             formatted_annotations => 0,
1591             valid => !$state->{errors}->@*,
1592             $is_exception ? (exception => 1) : (), # -> recommended_response: [ 500, 'Internal Server Error' ]
1593             !$state->{errors}->@*
1594             ? (annotations => $state->{annotations}//[])
1595             : (errors => $state->{errors}),
1596             $is_response ? (recommended_response => undef) : (), # responses don't have responses
1597             $state->%{data},
1598 399 100 50     12286 $state->{defaults} ? $state->%{defaults} : (),
    100          
    100          
    100          
1599             );
1600             }
1601              
1602 351     351   567 sub _resolve_ref ($self, $entity_type, $ref, $state, $keyword = '$ref') {
  351         510  
  351         495  
  351         518  
  351         537  
  351         596  
  351         395  
1603 351         2051 $self->openapi_document->__entity_type->($entity_type);
1604 351         456714 my $uri = Mojo::URL->new($ref)->to_abs($state->{initial_schema_uri});
1605              
1606 351         150068 my $schema_info = $self->evaluator->_fetch_from_uri($uri);
1607 351 100       252573 abort({ %$state, keyword => $keyword }, 'EXCEPTION: unable to find resource "%s"', $uri)
1608             if not $schema_info;
1609              
1610             abort({ %$state, keyword => $keyword }, 'EXCEPTION: maximum evaluation depth exceeded')
1611 337 100       2407 if $state->{depth}++ > $self->evaluator->max_depth;
1612              
1613             abort({ %$state, keyword => $keyword }, 'EXCEPTION: bad %s to %s: not a "%s"', $keyword, $schema_info->{canonical_uri}, $entity_type)
1614 335 100       1679 if $schema_info->{document}->get_entity_at_location($schema_info->{document_path}) ne $entity_type;
1615              
1616 333         2326 my $scope_uri = $schema_info->{canonical_uri}->clone->fragment(undef);
1617 333 100       40815 push $state->{dynamic_scope}->@*, $scope_uri if $state->{dynamic_scope}->[-1] ne $scope_uri;
1618              
1619 333         97025 $state->@{qw(document specification_version vocabularies)} = $schema_info->@{qw(document specification_version vocabularies)};
1620 333         912 $state->{initial_schema_uri} = $schema_info->{canonical_uri};
1621 333         1502 $state->{traversed_keyword_path} = $state->{traversed_keyword_path}.$state->{keyword_path}.'/'.$keyword;
1622 333         689 $state->{keyword_path} = '';
1623              
1624 333         5618 return $schema_info->{schema};
1625             }
1626              
1627             # as _resolve_ref, but uses dynamic scope resolution, and only handles schema entities
1628 3     3   7 sub _resolve_dynamicRef ($self, $ref, $state) {
  3         5  
  3         4  
  3         3  
  3         5  
1629 3         41 JSON::Schema::Modern::Vocabulary::Core->VERSION('0.630');
1630 3         24 my $uri = JSON::Schema::Modern::Vocabulary::Core->__resolve_dynamicRef($ref, $state);
1631 3         6944 return $self->_resolve_ref('schema', $uri, $state, '$dynamicRef');
1632             }
1633              
1634             # determines the type(s) expected in a schema: array, object, null, boolean, string, number
1635             # (integers will be treated as numbers as they are not a distinct core type)
1636 1461     1461   25422 sub _type_in_schema ($self, $schema, $state) {
  1461         2143  
  1461         1855  
  1461         1970  
  1461         1863  
1637 1461 50       3897 return $schema ? (qw(array object boolean string number), $state->{vocabularies}[0] =~ /::OpenAPI_3_0\z/ ? () : 'null') : ('string')
    100          
    100          
1638             if ref $schema ne 'HASH';
1639              
1640 1410         6050 my $schema_info = $self->evaluator->_fetch_from_uri(my $uri = canonical_uri($state));
1641 1410 50       1091694 abort($state, 'EXCEPTION: unable to find resource "%s"', $uri) if not $schema_info;
1642              
1643 1410 100 100     10445 if (my $types = ($schema_info->{document}{_type_in_schema}//={})->{$schema_info->{document_path}}) {
1644 335         4744 return @$types;
1645             }
1646              
1647             # as in __eval_keyword_id...
1648 1075 100       4207 my $id_keyword = $state->{specification_version} eq 'draft4' ? 'id' : '$id';
1649 1075 100 66     7308 if (exists $schema->{$id_keyword} and $schema->{$id_keyword} !~ /^#/) {
    100          
1650             # these will all be correct when we are at the schema root, or if we are here via a $ref,
1651             # but not if we are organically passing through this subschema and pass an '$id'.
1652 4         12 $state->{initial_schema_uri} = $schema_info->{canonical_uri};
1653 4         14 $state->{traversed_keyword_path} = $state->{traversed_keyword_path}.$state->{keyword_path};
1654 4         10 $state->{keyword_path} = '';
1655 4         16 $state->@{qw(specification_version vocabularies)} = $schema_info->@{qw(specification_version vocabularies)};
1656             push $state->{dynamic_scope}->@*, $state->{initial_schema_uri}
1657 4 50       21 if $state->{dynamic_scope}->[-1] ne $schema_info->{canonical_uri};
1658             }
1659             elsif (exists $schema->{'$schema'}) {
1660 4         25 $state->@{qw(specification_version vocabularies)} = $self->evaluator->_get_metaschema_vocabulary_classes($schema->{'$schema'})->@*;
1661             }
1662              
1663 1075         2901 my @types;
1664              
1665 1075 100       3305 if (defined(my $ref = $schema->{'$ref'})) {
1666             {
1667 46         66 my $schema = $self->_resolve_ref('schema', $ref, my $state = { %$state });
  46         484  
1668 42         289 push @types, [ $self->_type_in_schema($schema, $state) ];
1669             }
1670              
1671             # no other keywords are valid adjacent to '$ref' in drafts 4-7
1672 42 100       317 return $types[0]->@* if $state->{specification_version} =~ /^draft[467]\z/;
1673             }
1674              
1675 1063 100 66     3692 if (defined(my $ref = $schema->{'$dynamicRef'}) and $state->{specification_version} !~ /^draft(?:[467]|2019-09)\z/) {
1676 1         12 my $schema = $self->_resolve_dynamicRef($ref, my $state = { %$state });
1677 1         5 push @types, [ $self->_type_in_schema($schema, $state) ];
1678             }
1679              
1680             # v3.2.0 §4.24.4.2: "When inspecting schemas, given a starting point schema, implementations MUST
1681             # examine that schema and all schemas that can be reached from it by following only $ref and allOf
1682             # keywords... When searching schemas for type, if the type keyword’s value is a list of types and
1683             # the serialized value can be successfully parsed as more than one of the types in the list, and
1684             # no other findable type keyword disambiguates the actual required type, the behavior is
1685             # implementation-defined."
1686             # "Schema Objects that do not contain type MUST be considered to allow all types.."
1687              
1688 1063 100       3085 if (exists $schema->{type}) {
1689             push @types, [ ref $schema->{type} eq 'ARRAY' ? ($schema->{type}->@*) : ($schema->{type}),
1690 933 100 100     7719 $state->{vocabularies}[0] =~ /::OpenAPI_3_0\z/ && $schema->{nullable} ? 'null' : () ]
    100          
1691             }
1692             else {
1693             push @types, [ qw(array object boolean string number),
1694 130 100       719 $state->{vocabularies}[0] =~ /::OpenAPI_3_0\z/ ? () : 'null' ];
1695             }
1696              
1697 1063 100       3260 push @types, [ get_type($schema->{const}) ] if exists $schema->{const};
1698              
1699 1063 100       4745 push @types, [ map get_type($_), $schema->{enum}->@* ] if exists $schema->{enum};
1700              
1701             push @types, map [ $self->_type_in_schema($schema->{allOf}[$_],
1702             { %$state, keyword_path => $state->{keyword_path}.'/allOf/'.$_ }) ], 0..$schema->{allOf}->$#*
1703 1063 100       4052 if exists $schema->{allOf};
1704              
1705             push @types, [ map $self->_type_in_schema($schema->{anyOf}[$_],
1706             { %$state, keyword_path => $state->{keyword_path}.'/anyOf/'.$_ }), 0..$schema->{anyOf}->$#* ]
1707 1063 100       2939 if exists $schema->{anyOf};
1708              
1709             push @types, [ map $self->_type_in_schema($schema->{oneOf}[$_],
1710             { %$state, keyword_path => $state->{keyword_path}.'/oneOf/'.$_ }), 0..$schema->{oneOf}->$#* ]
1711 1063 100       2552 if exists $schema->{oneOf};
1712              
1713 1063 100       2932 if (exists $schema->{not}) {
1714 5         7 my %not_types; @not_types{qw(array object boolean string number null)} = ()x6;
  5         26  
1715              
1716 5         52 delete $not_types{$_} foreach $self->_type_in_schema($schema->{not},
1717             { %$state, keyword_path => $state->{keyword_path}.'/not' });
1718              
1719 5         36 push @types, [ keys %not_types ];
1720             }
1721              
1722 1063         3973 my @final_types = intersect_types(@types);
1723 1063         4649 $schema_info->{document}{_type_in_schema}{$schema_info->{document_path}} = \@final_types;
1724              
1725 1063         14980 return @final_types;
1726             }
1727              
1728             # given an object, use the subschema for each value to determine the correct type for that value
1729 248     248   32762 sub _coerce_object_elements ($self, $data, $schema, $state) {
  248         519  
  248         529  
  248         432  
  248         366  
  248         349  
1730 248 100       760 return if ref $data ne 'HASH';
1731 245 50       663 return if ref $schema ne 'HASH';
1732 245 100       1011 return if not keys %$data;
1733              
1734 214   100     1215 $state->{_level} //= 0;
1735 214         375 my @object_coercions;
1736              
1737 214 100       796 if (defined(my $ref = $schema->{'$ref'})) {
1738             {
1739 12         17 my $schema = $self->_resolve_ref('schema', $ref, my $state = { %$state });
  12         93  
1740 12         206 push @object_coercions, $self->_coerce_object_elements($data, $schema, { %$state, _level => $state->{_level}+1 });
1741             }
1742              
1743             # no other keywords are valid adjacent to '$ref' in drafts 4-7
1744 12 50       137 return $object_coercions[0] if $state->{specification_version} =~ /^draft[467]\z/;
1745             }
1746              
1747 214 100 66     899 if (defined(my $ref = $schema->{'$dynamicRef'}) and $state->{specification_version} !~ /^draft(?:[467]|2019-09)\z/) {
1748 1         7 my $schema = $self->_resolve_dynamicRef($ref, my $state = { %$state });
1749 1         16 push @object_coercions, $self->_coerce_object_elements($data, $schema, { %$state, _level => $state->{_level}+1 });
1750             }
1751              
1752 214 100       762 if (exists $schema->{allOf}) {
1753 2         10 foreach my $idx (0..$schema->{allOf}->$#*) {
1754             push @object_coercions, $self->_coerce_object_elements($data, $schema->{allOf}[$idx],
1755 5         51 { %$state, _level => $state->{_level}+1, keyword_path => $state->{keyword_path}.'/allOf/'.$idx });
1756             }
1757             }
1758              
1759             # we do not support anyOf, oneOf etc here to combine the sets of property constraints, as the work
1760             # involved in resolving ambiguities for each subschema as its own dataset is too great.
1761              
1762 214         386 my $property_coercions = {};
1763 214         1083 foreach my $property (sort keys $data->%*) {
1764 588 100       1233 next if ref $data->{$property};
1765 586         776 my @types;
1766 586         2113 my $state = { %$state, data_path => jsonp($state->{data_path}, $property) };
1767              
1768             push @types, [ $self->_type_in_schema($schema->{properties}{$property},
1769             { %$state, keyword_path => jsonp($state->{keyword_path}, 'properties', $property) }) ]
1770 586 100 100     7488 if exists(($schema->{properties}//{})->{$property});
1771              
1772             push @types, [ $self->_type_in_schema($schema->{patternProperties},
1773             { %$state, keyword_path => $state->{keyword_path}.'/patternProperties' }) ]
1774 586 50 66     2007 if exists $schema->{patternProperties} and $property =~ m/$schema->{patternProperties}/;
1775              
1776             push @types, [ $self->_type_in_schema($schema->{additionalProperties},
1777             { %$state, keyword_path => $state->{keyword_path}.'/additionalProperties' }) ]
1778 586 100 100     1595 if exists $schema->{additionalProperties} and @types == 0;
1779              
1780 586 100       1759 if (@types) {
1781 107         287 $property_coercions->{$property} = [ intersect_types(@types) ];
1782             }
1783             }
1784              
1785 214         377 push @object_coercions, $property_coercions;
1786              
1787             # combine hashes together by performing an intersection of types for each individual property
1788             # consider unevaluatedProperties now for each property that doesn't have representation already.
1789 214         348 my %final_object_coercions;
1790 214         738 foreach my $property (sort keys $data->%*) {
1791 588 100       1130 next if ref $data->{$property};
1792 586 100 66     2649 if (my @property_coercions = map $_->{$property} // (), @object_coercions) {
    100          
1793 153   66     429 $final_object_coercions{$property} = [ intersect_types(map $_->{$property} // (), @object_coercions) ];
1794             }
1795             elsif (exists $schema->{unevaluatedProperties}) {
1796             $final_object_coercions{$property} = [ $self->_type_in_schema($schema->{unevaluatedProperties},
1797 4         34 { %$state, keyword_path => $state->{keyword_path}.'/unevaluatedProperties' }) ];
1798             }
1799             }
1800              
1801 214 100       946 return \%final_object_coercions if delete $state->{_level}; # unwind the recursion by one level
1802              
1803             # we are at the top of the recursion stack.
1804 196         666 foreach my $property (keys %final_object_coercions) {
1805             # incoercible elements will be left as-is
1806 103         265 coerce_primitive(\$data->{$property}, $final_object_coercions{$property});
1807             }
1808             }
1809              
1810             # given an array, use the subschema for each item to determine the correct type for that value
1811 230     230   20678 sub _coerce_array_elements ($self, $data, $schema, $state) {
  230         459  
  230         452  
  230         297  
  230         407  
  230         360  
1812 230 100       683 return if ref $data ne 'ARRAY';
1813 227 100       640 return if ref $schema ne 'HASH';
1814 225 100       616 return if not @$data;
1815              
1816 197   100     1127 $state->{_level} //= 0;
1817 197         266 my @array_coercions;
1818              
1819 197 100       794 if (defined(my $ref = $schema->{'$ref'})) {
1820             {
1821 18         25 my $schema = $self->_resolve_ref('schema', $ref, my $state = { %$state });
  18         124  
1822 18         251 push @array_coercions, $self->_coerce_array_elements($data, $schema, { %$state, _level => $state->{_level}+1 });
1823             }
1824              
1825             # no other keywords are valid adjacent to '$ref' in drafts 4-7
1826 18 50       231 return $array_coercions[0] if $state->{specification_version} =~ /^draft[467]\z/;
1827             }
1828              
1829 197 100 66     756 if (defined(my $ref = $schema->{'$dynamicRef'}) and $state->{specification_version} !~ /^draft(?:[467]|2019-09)\z/) {
1830 1         9 my $schema = $self->_resolve_dynamicRef($ref, my $state = { %$state });
1831 1         15 push @array_coercions, $self->_coerce_array_elements($data, $schema, { %$state, _level => $state->{_level}+1 });
1832             }
1833              
1834 197 100       734 if (exists $schema->{allOf}) {
1835 4         40 foreach my $idx (0..$schema->{allOf}->$#*) {
1836             push @array_coercions, $self->_coerce_array_elements($data, $schema->{allOf}[$idx],
1837 9         109 { %$state, _level => $state->{_level}+1, keyword_path => $state->{keyword_path}.'/allOf/'.$idx });
1838             }
1839             }
1840              
1841             # we do not support anyOf, oneOf etc here to combine the sets of property constraints, as the work
1842             # involved in resolving ambiguities for each subschema as its own dataset is too great.
1843              
1844 197         310 my $item_coercions = [];
1845 197         718 foreach my $idx (0..$data->$#*) {
1846 554 100       1190 next if ref $data->[$idx];
1847 552         607 my @types;
1848 552         3191 my $state = { %$state, data_path => $state->{data_path}.'/'.$idx };
1849              
1850 552 50       2386 my ($array_items, $schema_items) = $state->{specification_version} !~ /^draft(?:[467]|2019-09)\z/
1851             ? qw(prefixItems items)
1852             : qw(items additionalItems);
1853              
1854             push @types, [ $self->_type_in_schema($schema->{$array_items}[$idx],
1855             { %$state, keyword_path => $state->{keyword_path}.'/'.$array_items.'/'.$idx }) ]
1856 552 100 100     2149 if exists $schema->{$array_items} and $idx <= $schema->{$array_items}->$#*;
1857              
1858             push @types, [ $self->_type_in_schema($schema->{$schema_items},
1859             { %$state, keyword_path => $state->{keyword_path}.'/'.$schema_items }) ]
1860 552 100 100     2219 if exists $schema->{$schema_items} and @types == 0;
1861              
1862 552 100       1757 if (@types) {
1863 142         329 $item_coercions->[$idx] = [ intersect_types(@types) ];
1864             }
1865             }
1866              
1867 197         361 push @array_coercions, $item_coercions;
1868              
1869             # combine arrayrefs together by performing an intersection of types for each individual item
1870             # consider unevaluatedItems now for each property that doesn't have representation already.
1871 197         271 my @final_array_coercions;
1872 197         492 foreach my $idx (0..$data->$#*) {
1873 554 100       958 next if ref $data->[$idx];
1874 552 100 66     2212 if (my @item_coercions = map $_->[$idx] // (), @array_coercions) {
    100          
1875 207   66     627 $final_array_coercions[$idx] = [ intersect_types(map $_->[$idx] // (), @array_coercions) ];
1876             }
1877             elsif (exists $schema->{unevaluatedItems}) {
1878             $final_array_coercions[$idx] = [ $self->_type_in_schema($schema->{unevaluatedItems},
1879 3         41 { %$state, keyword_path => $state->{keyword_path}.'/unevaluatedItems' }) ];
1880             }
1881             }
1882              
1883 197 100       965 return \@final_array_coercions if delete $state->{_level}; # unwind the recursion by one level
1884              
1885             # we are at the top of the recursion stack.
1886 171         504 foreach my $idx (0..$data->$#*) {
1887             # incoercible elements will be left as-is
1888 466 100       1076 coerce_primitive(\$data->[$idx], $final_array_coercions[$idx])
1889             if defined $final_array_coercions[$idx];
1890             }
1891             }
1892              
1893             # evaluates data against the subschema at the current state location
1894 605     605   938 sub _evaluate_subschema ($self, $dataref, $schema, $state) {
  605         1013  
  605         926  
  605         915  
  605         732  
  605         734  
1895             # boolean schema
1896 605 100       1404 if (ref $schema ne 'HASH') {
1897 43 100       169 return 1 if $schema;
1898              
1899 31         265 my @location = unjsonp($state->{data_path});
1900 31 50 66     557 my $location =
    100          
    100          
    100          
    100          
    100          
    100          
1901             $location[-1] eq 'content' ? join(' ', @location[-3..-2]) # request|response body
1902             : $location[-1] =~ /^[0-9]+\z/ ? 'item' # body item
1903             : $location[-2] eq 'query' ? 'query parameter' # query
1904             : $location[-2] eq 'path' ? 'path parameter' # path
1905             : $location[-2] eq 'header' ? join(' ', @location[-3..-2]) # header
1906             : $location[-3] eq 'header' && $location[-2] eq 'Cookie' ? 'cookie parameter' # cookie
1907             : $location[-1] eq 'query' ? 'query parameter' # querystring
1908             : die 'unknown location';
1909 31         140 return E($state, '%s not permitted', $location);
1910             }
1911              
1912 562 100       1465 return 1 if !keys(%$schema); # schema is {}
1913              
1914             # this is not necessarily the canonical uri of the location, but it is still a valid location
1915 546         2312 my $uri = $state->{initial_schema_uri}->clone;
1916 546   100     70566 $uri->fragment(($uri->fragment//'').$state->{keyword_path});
1917              
1918             my $result = $self->evaluator->evaluate(
1919             $dataref->$*,
1920             # reference by uri ensures all schema information is available to the evaluator, e.g. dialect
1921             $uri,
1922             {
1923             data_path => $state->{data_path},
1924             traversed_keyword_path => $state->{traversed_keyword_path}.$state->{keyword_path},
1925             $state->{stringy_numbers} ? (stringy_numbers => 1) : (),
1926 546 50       12493 $state->{with_defaults} ? (with_defaults => 1) : (),
    50          
1927             },
1928             );
1929              
1930 546         874659 push $state->{errors}->@*, $result->errors;
1931 546         21424 push $state->{annotations}->@*, $result->annotations;
1932              
1933 546         24251 jsonp_set($state->{data}, $state->{data_path}, jsonp_get($result->data, $state->{data_path}));
1934              
1935             $state->{defaults}->%* = (
1936             $state->{defaults}->%*, $result->defaults->%*
1937 546 100 66     51791 ) if $state->{defaults} and $result->defaults;
1938              
1939 546         9038 return $result->valid;
1940             }
1941              
1942             # results may be unsatisfactory if not a valid HTTP request.
1943 334     334   481 sub _convert_request ($request) {
  334         588  
  334         424  
1944 334 100       2525 return $request if $request->isa('Mojo::Message::Request');
1945              
1946 165         744 my $req = Mojo::Message::Request->new;
1947              
1948 165 100 33     1229 if ($request->isa('HTTP::Request')) {
    50          
1949 157         639 $req->method($request->method);
1950 157         2996 $req->url(Mojo::URL->new($request->uri));
1951 157 50       15873 $req->version($request->protocol =~ s{^HTTP/(\d\.\d)\z}{$1}r) if $request->protocol;
1952 157         4328 $req->headers->add(@$_) foreach pairs $request->headers->flatten;
1953              
1954 157         30914 my $body = $request->content;
1955 157 100       2535 $req->body($body) if length $body;
1956             }
1957             # note: Dancer2::Core::Request inherits from Plack::Request
1958             elsif ($request->isa('Plack::Request') or $request->isa('Catalyst::Request')) {
1959 0         0 $req->parse($request->env);
1960              
1961             my $plack_request = $request->isa('Plack::Request') ? $request
1962 0 0       0 : do { +require Plack::Request; Plack::Request->new($request->env) };
  0         0  
  0         0  
1963              
1964 0         0 my $body = $plack_request->content;
1965 0 0       0 $req->body($body) if length $body;
1966              
1967             # Plack is unable to distinguish between %2F and /, so the raw (undecoded) uri can be passed
1968             # here. see PSGI::FAQ
1969 0 0       0 $req->url(Mojo::URL->new($request->env->{REQUEST_URI})) if exists $request->env->{REQUEST_URI};
1970             }
1971             else {
1972 8         61 return $req->error({ message => 'unknown type '.ref($request) });
1973             }
1974              
1975             # we could call $req->fix_headers here to add a missing Content-Length or Host, but proper
1976             # requests from the network should always have these set.
1977              
1978 157         2743 $req->finish;
1979 157         2898 return $req;
1980             }
1981              
1982             # results may be unsatisfactory if not a valid HTTP response.
1983 72     72   112 sub _convert_response ($response) {
  72         91  
  72         80  
1984 72 100       515 return $response if $response->isa('Mojo::Message::Response');
1985              
1986 36         122 my $res = Mojo::Message::Response->new;
1987              
1988 36 100 33     246 if ($response->isa('HTTP::Response')) {
    50          
    50          
1989 35         137 $res->code($response->code);
1990 35 50       549 $res->version($response->protocol =~ s{^HTTP/(\d\.\d)\z}{$1}r) if $response->protocol;
1991 35         868 $res->headers->add(@$_) foreach pairs $response->headers->flatten;
1992 35         6030 my $body = $response->content;
1993 35 100       456 $res->body($body) if length $body;
1994             }
1995             elsif ($response->isa('Plack::Response') or $response->isa('Dancer2::Core::Response')) {
1996 0         0 $res->code($response->status);
1997 0         0 $res->headers->add(@$_) foreach pairs $response->headers->psgi_flatten_without_sort->@*;
1998 0         0 my $body = $response->content;
1999 0 0       0 $res->body($body) if length $body;
2000             }
2001             elsif ($response->isa('Catalyst::Response')) {
2002 0         0 $res->code($response->status);
2003 0         0 HTTP::Headers->VERSION('6.07');
2004 0         0 $res->headers->add(@$_) foreach pairs $response->headers->flatten;
2005 0         0 my $body = $response->body;
2006 0 0       0 $res->body($body) if length $body;
2007             }
2008             else {
2009 1         17 return $res->error({ message => 'unknown type '.ref($response) });
2010             }
2011              
2012             # we could call $res->fix_headers here to add a missing Content-Length, but proper responses from
2013             # the network should always have it set.
2014              
2015 35         853 $res->finish;
2016 35         664 return $res;
2017             }
2018              
2019             # callback hook for Sereal::Encoder
2020 1     1 0 9989 sub FREEZE ($self, $serializer) { +{ %$self } }
  1         3  
  1         2  
  1         2  
  1         14  
2021              
2022             # callback hook for Sereal::Decoder
2023 1     1 0 246 sub THAW ($class, $serializer, $data) {
  1         2  
  1         1  
  1         2  
  1         2  
2024 1         2 my $self = bless($data, $class);
2025              
2026 1         3 foreach my $attr (qw(openapi_document evaluator)) {
2027             croak "serialization missing attribute '$attr': perhaps your serialized data was produced for an older version of $class?"
2028 2 50       7 if not exists $self->{$attr};
2029             }
2030              
2031 1         8 return $self;
2032             }
2033              
2034             1;
2035              
2036             __END__