package JSONSchema::Validator::OAS30; # ABSTRACT: Validator for OpenAPI Specification 3.0 use strict; use warnings; use Carp 'croak'; use JSONSchema::Validator::JSONPointer; use JSONSchema::Validator::Error 'error'; use JSONSchema::Validator::Constraints::OAS30; use JSONSchema::Validator::URIResolver; use JSONSchema::Validator::Util 'json_decode'; use parent 'JSONSchema::Validator::Draft4'; use constant SPECIFICATION => 'OAS30'; use constant ID => 'https://spec.openapis.org/oas/3.0/schema/2019-04-02'; use constant ID_FIELD => ''; sub new { my ($class, %params) = @_; $params{using_id_with_ref} = 0; my $self = $class->create(%params); my $validate_deprecated = $params{validate_deprecated} // 1; $self->{validate_deprecated} = $validate_deprecated; my $constraints = JSONSchema::Validator::Constraints::OAS30->new(validator => $self, strict => $params{strict} // 0); $self->{constraints} = $constraints; return $self; } sub validate_deprecated { shift->{validate_deprecated} } sub validate_schema { my ($self, $instance, %params) = @_; my $schema = $params{schema} // $self->schema; my $instance_path = $params{instance_path} // '/'; my $schema_path = $params{schema_path} // '/'; my $direction = $params{direction}; my $scope = $params{scope}; croak 'param "direction" is required' unless $direction; croak '"direction" must have one of values: "request", "response"' if $direction ne 'request' && $direction ne 'response'; croak 'No schema specified' unless defined $schema; push @{$self->scopes}, $scope if $scope; my ($errors, $warnings) = ([], []); my $result = $self->_validate_schema($instance, $schema, $instance_path, $schema_path, { errors => $errors, warnings => $warnings, direction => $direction } ); pop @{$self->scopes} if $scope; return $result, $errors, $warnings; } sub _schema_keys { my ($self, $schema, $instance_path, $data) = @_; # if ref exists other preperties MUST be ignored return '$ref' if $schema->{'$ref'}; return ('deprecated') if $schema->{deprecated} && !$self->validate_deprecated; if (grep { $_ eq 'discriminator' } keys %$schema) { my $status = $data->{discriminator}{$instance_path} // 0; return ('discriminator') unless $status; # status is 1 return grep { $_ ne 'discriminator' } keys %$schema; } return keys %$schema; } sub validate_request { my ($self, %params) = @_; my $method = lc($params{method} or croak 'param "method" is required'); my $openapi_path = $params{openapi_path} or croak 'param "openapi_path" is required'; my $get_user_param = $self->_wrap_params($params{parameters}); my $user_body = $params{parameters}{body} // []; # [exists, content-type, value] my $base_ptr = $self->json_pointer->xget('paths', $openapi_path); return 1, [], [] unless $base_ptr; my $schema_params = {query => {}, header => {}, path => {}, cookie => {}}; # Common Parameter Object my $common_params_ptr = $base_ptr->xget('parameters'); $self->_fill_parameters($schema_params, $common_params_ptr); # Operation Object my $operation_ptr = $base_ptr->xget($method); return 1, [], [] unless $operation_ptr; my ($result, $context) = (1, {errors => [], warnings => [], direction => 'request'}); if ($operation_ptr->xget('deprecated')) { push @{$context->{warnings}}, error(message => "method $method of $openapi_path is deprecated"); return $result, $context->{errors}, $context->{warnings} unless $self->validate_deprecated; } # Parameter Object my $params_ptr = $operation_ptr->xget('parameters'); $self->_fill_parameters($schema_params, $params_ptr); # validate path, query, header, cookie my $r = $self->_validate_params($context, $schema_params, $get_user_param); $result = 0 unless $r; # validate body my $body_ptr = $operation_ptr->xget('requestBody'); $r = $self->_validate_body($context, $user_body, $body_ptr); $result = 0 unless $r; return $result, $context->{errors}, $context->{warnings}; } sub validate_response { my ($self, %params) = @_; my $method = lc($params{method} or croak 'param "method" is required'); my $openapi_path = $params{openapi_path} or croak 'param "openapi_path" is required'; my $http_status = $params{status} or croak 'param "status" is required'; my $get_user_param = $self->_wrap_params($params{parameters}); my $user_body = $params{parameters}{body} // []; # [exists, content-type, value] my $base_ptr = $self->json_pointer->xget('paths', $openapi_path, $method); return 1, [], [] unless $base_ptr; my ($result, $context) = (1, {errors => [], warnings => [], direction => 'response'}); if ($base_ptr->xget('deprecated')) { push @{$context->{warnings}}, error(message => "method $method of $openapi_path is deprecated"); return $result, $context->{errors}, $context->{warnings} unless $self->validate_deprecated; } my $responses_ptr = $base_ptr->xget('responses'); return $result, $context->{errors}, $context->{warnings} unless $responses_ptr; my $status_ptr = $responses_ptr->xget($http_status); $status_ptr = $responses_ptr->xget('default') unless $status_ptr; unless ($status_ptr) { push @{$context->{errors}}, error(message => "unspecified response with status code $http_status"); return 0, $context->{errors}, $context->{warnings}; } my $schema_params = {header => {}}; $self->_fill_parameters($schema_params, $status_ptr->xget('headers')); # validate headers my $r = $self->_validate_params($context, $schema_params, $get_user_param); $result = 0 unless $r; # validate body my ($exists, $content_type, $data) = @$user_body; return $result, $context->{errors}, $context->{warnings} unless $exists; ($r, my $errors, my $warnings) = $self->_validate_content($context, $status_ptr, $content_type, $data); unless ($r) { push @{$context->{errors}}, error(message => 'response body error', context => $errors); $result = 0; } push @{$context->{warnings}}, error(message => 'response body warning', context => $warnings) if @$warnings; return $result, $context->{errors}, $context->{warnings}; } sub _validate_body { my ($self, $ctx, $user_body, $body_ptr) = @_; # body in specification is omit return 1 unless $body_ptr; # skip validation if user not specify body return 1 unless $user_body && @$user_body; my ($exists, $content_type, $data) = @$user_body; unless ($exists) { my $required = $body_ptr->xget('required'); if ($required) { push @{$ctx->{errors}}, error(message => q{body is required}); return 0; } return 1; } my ($result, $errors, $warnings) = $self->_validate_content($ctx, $body_ptr, $content_type, $data); push @{$ctx->{errors}}, error(message => 'request body error', context => $errors) if @$errors; push @{$ctx->{warnings}}, error(message => 'request body warning', context => $warnings) if @$warnings; return $result; } sub _validate_params { my ($self, $ctx, $schema_params, $get_user_param) = @_; my $result = 1; for my $type (keys %$schema_params) { next unless %{$schema_params->{$type}}; # skip validation if user not specify getter for such params type next unless $get_user_param->{$type}; my $r = $self->_validate_type_params($ctx, $type, $schema_params->{$type}, $get_user_param->{$type}); $result = 0 unless $r; } return $result; } sub _validate_type_params { my ($self, $ctx, $type, $params, $get_user_param) = @_; my $result = 1; for my $param (keys %$params) { my $data_ptr = $params->{$param}; my ($exists, $value) = $get_user_param->($param); ($exists, $value) = $get_user_param->(lc $param) if !$exists && $type eq 'header'; unless ($exists) { if ($data_ptr->xget('required')) { push @{$ctx->{errors}}, error(message => qq{$type param "$param" is required}); $result = 0; } next; } if ($data_ptr->xget('deprecated')) { push @{$ctx->{warnings}}, error(message => qq{$type param "$param" is deprecated}); next unless $self->validate_deprecated; } next unless $data_ptr->xget('schema') || $data_ptr->xget('content'); $value = [$value] if ref $value ne 'ARRAY'; for my $v (@$value) { my ($r, $errors, $warnings); if ($data_ptr->xget('schema')) { my $schema_ptr = $data_ptr->xget('schema'); ($r, $errors, $warnings) = $self->validate_schema($v, schema => $schema_ptr->value, path => '/', direction => $ctx->{direction}, scope => $schema_ptr->scope ); } elsif ($data_ptr->xget('content')) { ($r, $errors, $warnings) = $self->_validate_content($ctx, $data_ptr, undef, $v); } unless ($r) { push @{$ctx->{errors}}, error(message => qq{$type param "$param" has error}, context => $errors); $result = 0; } push @{$ctx->{warnings}}, error(message => qq{$type param "$param" has warning}, context => $warnings) if @$warnings; } } return $result; } # ptr - JSONSchema::Validator::JSONPointer # content_type - string|null # data - string|HASH|ARRAY sub _validate_content { my ($self, $ctx, $ptr, $content_type, $data) = @_; my $content_ptr = $ptr->xget('content'); # content in body is required but in params is optional return 1, [], [] unless $content_ptr; my $ctype_ptr; if ($content_type) { $ctype_ptr = $content_ptr->xget($content_type); unless ($ctype_ptr) { return 0, [error(message => qq{content with content-type $content_type is omit in schema})], []; } } else { my $mtype_map = $content_ptr->value; my @keys = $content_ptr->keys(raw => 1); return 0, [error(message => qq{schema must has exactly one content_type})], [] unless scalar(@keys) == 1; $content_type = $keys[0]; $ctype_ptr = $content_ptr->xget($content_type); } unless (ref $data) { if (index($content_type, 'application/json') != -1) { eval { $data = json_decode($data); }; } # do we need to support other content-type? } my $schema_ptr = $ctype_ptr->xget('schema'); my $schema_prop_ptr = $schema_ptr->xget('properties'); if ( $schema_prop_ptr && $content_type && ( index($content_type ,'application/x-www-form-urlencoded') != -1 || index($content_type, 'multipart/') != -1 ) && ref $data eq 'HASH' ) { for my $property_name ($schema_prop_ptr->keys(raw => 1)) { my $property_ctype_ptr = $ctype_ptr->xget('encoding', $property_name, 'contentType'); my $property_ctype = $property_ctype_ptr ? $property_ctype_ptr->value : ''; unless ($property_ctype) { my $prop_type_ptr = $schema_prop_ptr->xget($property_name, 'type'); $property_ctype = $prop_type_ptr && $prop_type_ptr->value eq 'object' ? 'application/json' : ''; } if ( index($property_ctype, 'application/json') != -1 && exists $data->{$property_name} && !ref $data->{$property_name} ) { eval { $data->{$property_name} = json_decode($data->{$property_name}); }; } # do we need to support other content-type? } } return $self->validate_schema($data, schema => $schema_ptr->value, path => '/', direction => $ctx->{direction}, scope => $schema_ptr->scope ); } sub _fill_parameters { my ($self, $hash, $ptr) = @_; return unless ref $ptr->value; if (ref $ptr->value eq 'ARRAY') { for my $p ($ptr->keys) { my $param_ptr = $ptr->get($p); my $param = $param_ptr->value; my ($name, $in) = @$param{qw/name in/}; $hash->{$in}{$name} = $param_ptr; } } elsif (ref $ptr->value eq 'HASH') { # currently used for headers in response my $in = 'header'; for my $name ($ptr->keys(raw => 1)) { my $param_ptr = $ptr->xget($name); $hash->{$in}{$name} = $param_ptr; } } } sub _wrap_params { my ($self, $parameters) = @_; my $get_user_param = {}; for my $type (qw/path query header cookie/) { next unless $parameters->{$type}; if (ref $parameters->{$type} eq 'CODE') { $get_user_param->{$type} = $parameters->{$type}; } elsif (ref $parameters->{$type} eq 'HASH') { my $data = $parameters->{$type}; $data = +{ map { lc $_ => $data->{$_} } keys %$data } if $type eq 'header'; $get_user_param->{$type} = sub { my $param = shift; return (exists($data->{$param}), $data->{$param}); } } else { croak qq{param "$type" must be hashref or coderef}; } } return $get_user_param; } sub json_pointer { my $self = shift; return JSONSchema::Validator::JSONPointer->new( scope => $self->scope, value => $self->schema, validator => $self ); } 1; __END__ =pod =encoding UTF-8 =head1 NAME JSONSchema::Validator::OAS30 - Validator for OpenAPI Specification 3.0 =head1 VERSION version 0.010 =head1 SYNOPSIS $validator = JSONSchema::Validator::OAS30->new(schema => {...}); my ($result, $errors, $warnings) = $validator->validate_request( method => 'GET', openapi_path => '/user/{id}/profile', parameters => { path => { id => 1234 }, query => { details => 'short' }, header => { header => 'header value' }, cookie => { name => 'value' }, body => [$is_exists, $content_type, $data] } ); my ($result, $errors, $warnings) = $validator->validate_response( method => 'GET', openapi_path => '/user/{id}/profile', status => '200', parameters => { header => { header => 'header value' }, body => [$is_exists, $content_type, $data] } ); =head1 DESCRIPTION OpenAPI specification 3.0 validator with minimum dependencies. =head1 CLASS METHODS =head2 new Creates JSONSchema::Validator::OAS30 object. $validator = JSONSchema::Validator::OAS30->new(schema => {...}); =head3 Parameters =head4 schema Scheme according to which validation occurs. =head4 strict Use strong type checks. Default value is 0. =head4 scheme_handlers At the moment, the validator can load a resource using the http, https protocols. You can add other protocols yourself. sub loader { my $uri = shift; ... } $validator = JSONSchema::Validator::Draft4->new(schema => {...}, scheme_handlers => {ftp => \&loader}); =head4 validate_deprecated Validate method/parameter/schema with deprecated mark. Default value is 1. =head1 METHODS =head2 validate_request Validate request specified by method and openapi_path. =head3 Parameters =head4 method HTTP method of request. =head4 openapi_path OpenAPI path of request. Need to specify OpenAPI path, not the real path of request. =head4 parameters Parameters of request. It is an object that contains the following keys: C, C, C
, C and C. Keys C, C, C
, C are hash objects which contains key/value pairs. Key C is a array reference which contains 3 values. The first value is a boolean flag that means if there is a body. The second value is a Content-Type of request. The third value is a data of value. # post params my ($result, $errors, $warnings) = $validator->validate_request( method => 'POST', parameters => { path => {user => 'adam'}, body => [1, 'application/x-www-form-urlencoded', {key => 'value'}] } ); # for file upload my ($result, $errors, $warnings) = $validator->validate_request( method => 'POST', parameters => { body => [1, 'multipart/form-data', {key => 'value', file => 'binary data'}] } ); # for multiple file upload for the same name my ($result, $errors, $warnings) = $validator->validate_request( method => 'POST', parameters => { body => [1, 'multipart/form-data', {key => 'value', files => ['binary data1', 'binary data2']}] } ); =head2 validate_response Validate response specified by method, openapi_path and http status code. =head3 Parameters =head4 method HTTP method of request. =head4 openapi_path OpenAPI path of request. Need to specify OpenAPI path, not the real path of request. =head4 status HTTP response status code. =head4 parameters Parameters of response. It is an object that contains the following keys: C
and C. Key C
are hash objects which contains key/value pairs. Key C is a array reference which contains 3 values. The first value is a boolean flag that means if there is a body. The second value is a Content-Type of response. The third value is a data of value. # to validate application/json response my ($result, $errors, $warnings) = $validator->validate_response( method => 'GET', openapi_path => '/user/{id}', status => '404', parameters => { header => {name => 'value'}, body => [1, 'application/json', {message => 'user not found'}] } ); =head1 AUTHORS =over 4 =item * Alexey Stavrov =item * Ivan Putintsev =item * Anton Fedotov =item * Denis Ibaev =item * Andrey Khozov =back =head1 COPYRIGHT AND LICENSE This software is Copyright (c) 2021 by Alexey Stavrov. This is free software, licensed under: The MIT (X11) License =cut