File Coverage

blib/lib/OpenAPI/Modern.pm
Criterion Covered Total %
statement 301 305 98.6
branch 123 142 86.6
condition 62 82 75.6
subroutine 49 49 100.0
pod 3 3 100.0
total 538 581 92.6


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