File Coverage

blib/lib/OpenAPI/Modern.pm
Criterion Covered Total %
statement 262 266 98.5
branch 86 98 87.7
condition 36 51 70.5
subroutine 42 42 100.0
pod 3 3 100.0
total 429 460 93.2


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