File Coverage

blib/lib/OpenAPI/Modern.pm
Criterion Covered Total %
statement 302 306 98.6
branch 127 146 86.9
condition 62 82 75.6
subroutine 49 49 100.0
pod 3 3 100.0
total 543 586 92.6


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