package JSON::Validator::Schema::OpenAPIv2; use Mojo::Base 'JSON::Validator::Schema::Draft4'; use JSON::Validator::Ref; use JSON::Validator::Util qw(E data_type negotiate_content_type schema_type); use Mojo::Collection; my $X_RE = qr{^x-}; # Some of the keywords are OpenAPIv3 keywords my %SKIP_KEYWORDS_IN_PATH = map { ($_, 1) } qw(description parameters servers summary); has errors => sub { my $self = shift; my $validator = $self->new(%$self, allow_invalid_ref => 0)->resolve($self->specification); return [$validator->validate($self->resolve->data)]; }; has moniker => 'openapiv2'; has specification => 'http://swagger.io/v2/schema.json'; sub add_default_response { my ($self, $params) = ($_[0], shift->_params_for_add_default_response(@_)); my $definitions = $self->data->{definitions} ||= {}; my $ref = $definitions->{$params->{name}} ||= $params->{schema}; my %schema = ('$ref' => "#/definitions/$params->{name}"); tie %schema, 'JSON::Validator::Ref', $ref, $schema{'$ref'}, $schema{'$ref'}; for my $route ($self->routes->each) { my $op = $self->get([paths => @$route{qw(path method)}]); $op->{responses}{$_} ||= {description => $params->{description}, schema => \%schema} for @{$params->{status}}; } return $self; } sub allow_invalid_ref { my $self = shift; return $self->{allow_invalid_ref} || 0 unless @_; delete $self->{errors}; $self->{allow_invalid_ref} = shift; $self->data($self->{data}) if $self->{data}; return $self; } sub base_url { my ($self, $url) = @_; my $spec = $self->data; # Get unless ($url) { $url = Mojo::URL->new; my @host_port = split ':', ($spec->{host} // ''); $url->host($host_port[0]) if $host_port[0]; $url->port($host_port[1]) if $host_port[1]; $url->path($spec->{basePath} || '/'); $url->scheme($spec->{schemes} && $spec->{schemes}[0] || undef); $url->host('localhost') if $url->scheme and !$url->host; return $url; } # Set $url = Mojo::URL->new($url)->to_abs($self->base_url); $spec->{host} = $url->host_port if $url->host_port; $spec->{schemes}[0] = $url->scheme if $url->scheme; $spec->{basePath} = $url->path->to_string || '/'; return $self; } sub coerce { my $self = shift; return $self->SUPER::coerce(@_) if @_; $self->{coerce} ||= {booleans => 1, numbers => 1, strings => 1}; return $self->{coerce}; } sub data { my $self = shift; return $self->{data} ||= {} unless @_; if ($self->allow_invalid_ref) { my $clone = $self->new(%$self, allow_invalid_ref => 0); $self->{data} = $clone->data(shift)->bundle({replace => 1})->data; } else { $self->{data} = $self->_resolve(shift); } delete $self->{errors}; return $self; } sub new { my $self = shift->SUPER::new(@_); $self->coerce; # make sure this attribute is built $self; } sub parameters_for_request { my $self = shift; my ($method, $path) = (lc $_[0][0], $_[0][1]); my $cache_key = "parameters_for_request:$method:$path"; return $self->{cache}{$cache_key} if $self->{cache}{$cache_key}; return undef unless $self->get([paths => $path, $method]); my @accepts = map {@$_} $self->_find_all_nodes([paths => $path, $method], 'consumes'); my @parameters = map {@$_} $self->_find_all_nodes([paths => $path, $method], 'parameters'); for my $param (@parameters) { $param->{type} ||= schema_type($param->{schema} || $param); $param->{accepts} = \@accepts if $param->{in} eq 'body'; } return $self->{cache}{$cache_key} = \@parameters; } sub parameters_for_response { my $self = shift; my ($method, $path, $status) = (lc $_[0][0], $_[0][1], $_[0][2] || 200); $status ||= 200; my $cache_key = "parameters_for_response:$method:$path:$status"; return $self->{cache}{$cache_key} if $self->{cache}{$cache_key}; my $responses = $self->get([paths => $path, $method, 'responses']); my $response = $responses->{$status} || $responses->{default}; return undef unless $response; my @parameters; if (my $headers = $response->{headers}) { push @parameters, map { +{%{$headers->{$_}}, in => 'header', name => $_} } sort keys %$headers; } my @accepts = $self->_find_all_nodes([paths => $path, $method], 'produces'); if (exists $response->{schema}) { push @parameters, {%$response, in => 'body', name => 'body', accepts => pop @accepts || []}; } return $self->{cache}{$cache_key} = \@parameters; } sub routes { my $self = shift; my @sorted_paths = map { $_->[0] } sort { $a->[1] <=> $b->[1] || length $a->[0] <=> length $b->[0] } map { [$_, /\{/ ? 1 : 0] } grep { $_ !~ $X_RE } keys %{$self->get('/paths') || {}}; my @operations; for my $path (@sorted_paths) { next unless my $methods = $self->get([paths => $path]); for my $method (sort keys %$methods) { next if $method =~ $X_RE or $SKIP_KEYWORDS_IN_PATH{$method}; push @operations, {method => $method, operation_id => $methods->{$method}{operationId}, path => $path}; } } return Mojo::Collection->new(@operations); } sub validate_request { my ($self, $method_path, $req) = @_; my $parameters = $self->parameters_for_request($method_path); my %get; for my $in (qw(body cookie formData header path query)) { $get{$in} = ref $req->{$in} eq 'CODE' ? $req->{$in} : sub { my ($name, $param) = @_; return {exists => exists $req->{$in}, value => $req->{$in}} unless defined $name; return {exists => exists $req->{$in}{$name}, value => $req->{$in}{$name}}; }; } return $self->_validate_request_or_response(request => $parameters, \%get); } sub validate_response { my ($self, $method_path_status, $res) = @_; my $parameters = $self->parameters_for_response($method_path_status); my %get; for my $in (qw(body cookie header)) { $get{$in} = ref $res->{$in} eq 'CODE' ? $res->{$in} : sub { my ($name, $param) = @_; return {exists => exists $res->{$in}{$name}, value => $res->{$in}{$name}}; }; } return $self->_validate_request_or_response(response => $parameters, \%get); } sub _build_formats { my $self = shift; return { 'binary' => sub {undef}, 'byte' => JSON::Validator::Formats->can('check_byte'), 'date' => JSON::Validator::Formats->can('check_date'), 'date-time' => JSON::Validator::Formats->can('check_date_time'), 'double' => JSON::Validator::Formats->can('check_double'), 'email' => JSON::Validator::Formats->can('check_email'), 'float' => JSON::Validator::Formats->can('check_float'), 'hostname' => JSON::Validator::Formats->can('check_hostname'), 'int32' => JSON::Validator::Formats->can('check_int32'), 'int64' => JSON::Validator::Formats->can('check_int64'), 'ipv4' => JSON::Validator::Formats->can('check_ipv4'), 'ipv6' => JSON::Validator::Formats->can('check_ipv6'), 'password' => sub {undef}, 'regex' => JSON::Validator::Formats->can('check_regex'), 'uri' => JSON::Validator::Formats->can('check_uri'), }; } sub _coerce_arrays { my ($self, $val, $param) = @_; my $data_type = data_type $val->{value}; my $schema_type = schema_type $param; return $val->{value} = [$val->{value}] if $schema_type eq 'array' and $data_type ne 'array'; return $val->{value} = @{$val->{value}} ? $val->{value}[-1] : undef if $schema_type ne 'array' and $data_type eq 'array'; } sub _coerce_default_value { my ($self, $val, $param) = @_; if ($param->{schema} and exists $param->{schema}{default}) { @$val{qw(exists value)} = (1, $param->{schema}{default}); } elsif (exists $param->{default}) { @$val{qw(exists value)} = (1, $param->{default}); } } sub _coerce_parameter_format { my ($self, $val, $param) = @_; return unless $val->{exists}; return unless my $format = $param->{collectionFormat}; return $val->{value} = ref $val->{value} eq 'ARRAY' ? $val->{value} : [$val->{value}] if $format eq 'multi'; return $val->{value} = [split /\|/, $val->{value}] if $format eq 'pipes'; return $val->{value} = [split /[ ]/, $val->{value}] if $format eq 'ssv'; return $val->{value} = [split /\t/, $val->{value}] if $format eq 'tsv'; return $val->{value} = [split /,/, $val->{value}]; } sub _default_response_schema { return { type => 'object', required => ['errors'], properties => { errors => { type => 'array', items => { type => 'object', required => ['message'], properties => {message => {type => 'string'}, path => {type => 'string'}}, }, }, }, }; } sub _definitions_path_for_ref { my ($self, $ref) = @_; my $path = Mojo::Path->new($ref->fqn =~ m!^.*#/(definitions|parameters|responses/.+)$!)->to_dir->parts; return $path->[0] ? $path : ['definitions']; } sub _find_all_nodes { my ($self, $pointer, $leaf) = @_; my @found; push @found, $self->data->{$leaf} if ref $self->data->{$leaf} eq 'ARRAY'; my @path; for my $p (@$pointer) { push @path, $p; my $node = $self->get([@path]); push @found, $node->{$leaf} if ref $node->{$leaf} eq 'ARRAY'; } return @found; } sub _get_parameter_value { my ($self, $param, $get) = @_; my $val = $get->{$param->{in}}->($param->{name}, $param); @$val{qw(in name)} = (@$param{qw(in name)}); return $val; } sub _params_for_add_default_response { my $self = shift; my $params = shift || {}; return { description => $params->{description} || 'Default response.', name => $params->{name} || 'DefaultResponse', schema => $params->{schema} || _default_response_schema(), status => $params->{status} || [400, 401, 404, 500, 501], }; } sub _prefix_error_path { return join '', "/$_[0]", $_[1] =~ /\w/ ? ($_[1]) : (); } sub _resolve_ref { my ($self, $ref_url, $base_url, $root) = @_; $ref_url = "#/definitions/$ref_url" if $ref_url =~ /^\w+$/; return $self->SUPER::_resolve_ref($ref_url, $base_url, $root); } sub _validate_body { my ($self, $direction, $val, $param) = @_; if ($val->{accept}) { $val->{content_type} = negotiate_content_type($param->{accepts}, $val->{accept}); $val->{valid} = $val->{content_type} ? 1 : 0; return E "/header/Accept", [join(', ', @{$param->{accepts}}), type => $val->{accept}] unless $val->{valid}; } if (@{$param->{accepts}} and $val->{content_type}) { my $negotiated = negotiate_content_type($param->{accepts}, $val->{content_type}); $val->{valid} = $negotiated ? 1 : 0; return E "/$param->{name}", [join(', ', @{$param->{accepts}}) => type => $val->{content_type}] unless $negotiated; } if ($param->{required} and !$val->{exists}) { $val->{valid} = 0; return E "/$param->{name}", [qw(object required)]; } if ($val->{exists}) { local $self->{"validate_$direction"} = 1; $val->{content_type} //= $param->{accepts}[0]; my @errors = map { $_->path(_prefix_error_path($param->{name}, $_->path)); $_ } $self->validate($val->{value}, $param->{schema}); $val->{valid} = @errors ? 0 : 1; return @errors; } return; } sub _validate_request_or_response { my ($self, $direction, $parameters, $get) = @_; my @errors; for my $param (@$parameters) { my $val = $self->_get_parameter_value($param, $get); $self->_coerce_default_value($val, $param) unless $val->{exists}; if ($param->{in} eq 'body') { push @errors, $self->_validate_body($direction, $val, $param); next; } $self->_coerce_parameter_format($val, $param) if $direction eq 'request'; if ($val->{exists}) { $self->_coerce_arrays($val, $param); local $self->{"validate_$direction"} = 1; my @e = map { $_->path(_prefix_error_path($param->{name}, $_->path)); $_ } $self->validate($val->{value}, $param->{schema} || $param); push @errors, @e; $val->{valid} = @e ? 0 : 1; } elsif ($param->{required}) { push @errors, E "/$param->{name}", [qw(object required)]; $val->{valid} = 0; } } return @errors; } sub _validate_type_file { my ($self, $data, $path, $schema) = @_; return unless $schema->{required} and (not defined $data or not length $data); return E $path => 'Missing property.'; } sub _validate_type_object { my ($self, $data, $path, $schema) = @_; return E $path, [object => type => data_type $data] if ref $data ne 'HASH'; return shift->SUPER::_validate_type_object(@_) unless $self->{validate_request}; my (@errors, %ro); for my $name (keys %{$schema->{properties} || {}}) { next unless $schema->{properties}{$name}{readOnly}; push @errors, E "$path/$name", "Read-only." if exists $data->{$name}; $ro{$name} = 1; } local $schema->{required} = [grep { !$ro{$_} } @{$schema->{required} || []}]; my $discriminator = $schema->{discriminator}; if ($discriminator and !$self->{inside_discriminator}) { return E $path, "Discriminator $discriminator has no value." unless my $name = $data->{$discriminator}; return E $path, "No definition for discriminator $name." unless my $dschema = $self->get("/definitions/$name"); local $self->{inside_discriminator} = 1; # prevent recursion return $self->_validate($data, $path, $dschema); } return ( @errors, $self->_validate_type_object_min_max($_[1], $path, $schema), $self->_validate_type_object_dependencies($_[1], $path, $schema), $self->_validate_type_object_properties($_[1], $path, $schema), ); } 1; =encoding utf8 =head1 NAME JSON::Validator::Schema::OpenAPIv2 - OpenAPI version 2 / Swagger =head1 SYNOPSIS use JSON::Validator; my $schema = JSON::Validator->new->schema("...")->schema; # Check for specification errors my $errors = $schema->errors; # Returns a list of zero or more JSON::Validator::Error objects my @request_errors = $schema->validate_request( [get => "/path"], {body => sub { return {exists => 1, value => {}} }}, ); # Returns a list of zero or more JSON::Validator::Error objects my @response_errors = $schema->validate_response( [get => "/path", 200], {body => sub { return {exists => 1, value => {}} }}, ); =head1 DESCRIPTION This class represents L<http://swagger.io/v2/schema.json>. =head1 ATTRIBUTES =head2 moniker $str = $schema->moniker; $schema = $schema->moniker("openapiv2"); Used to get/set the moniker for the given schema. Default value is "openapiv2". =head2 specification my $str = $schema->specification; my $schema = $schema->specification($str); Defaults to "L<http://swagger.io/v2/schema.json>". =head1 METHODS =head2 add_default_response $schema = $schema->add_default_response(\%params); Used to add a default response schema for operations that does not already have one. C<%params> can be: =over 2 =item * description The human readable description added to the operation. Defaults: "Default response." =item * name The name used in the specification under "/components/schemas/". Defaults: "DefaultResponse" =item * schema The schema to add. The default schema below might change, but the basics will stay the same: { type: "object", required: ["errors"], properties: { errors: { type: "array", items: { type: "object", required: ["message"], properties: { message: {type: "string"}, path: {type: "string"} } } } } } =item * status A list of status codes to apply the default schema to. Default: C<[400, 401, 404, 500, 501]>. =back =head2 allow_invalid_ref $bool = $schema->allow_invalid_ref; $schema = $schema->allow_invalid_ref(1); Setting this to true will replace all C<$ref>s in the schema before validating it. This can be useful if you have a complex schema that you want to split into different files where OpenAPIv2 normally does not allow you to. Setting this attribute will not work if the schema has recursive C<$ref>s. This method is highly EXPERIMENTAL, and it is not advices to use this method. =head2 base_url $url = $schema->base_url; $schema = $schema->base_url($url); Can get or set the default URL for this schema. C<$url> can be either a L<Mojo::URL> object or a plain string. This method will read or write "basePath", "host" and/or "schemas" in L</data>. =head2 coerce my $schema = $schema->coerce({booleans => 1, numbers => 1, strings => 1}); my $hash_ref = $schema->coerce; Coercion is enabled by default, since headers, path parts, query parameters, ... are in most cases strings. See also L<JSON::Validator/coerce>. =head2 data my $hash_ref = $schema->data; my $schema = $schema->data($bool); my $schema = $schema->data($hash_ref); my $schema = $schema->data($url); Same as L</JSON::Validator::Schema/data>, but will bundle the schema if L</allow_invalid_ref> is set. =head2 new $schema = JSON::Validator::Schema::OpenAPIv2->new(\%attrs); $schema = JSON::Validator::Schema::OpenAPIv2->new; Same as L<JSON::Validator::Schema/new>, but will also build L/coerce>. =head2 parameters_for_request $parameters = $schema->parameters_for_request([$method, $path]); Finds all the request parameters defined in the schema, including inherited parameters. Returns C<undef> if the C<$path> and C<$method> cannot be found. Example return value: [ {in => "query", name => "q"}, {in => "body", name => "body", accepts => ["application/json"]}, ] The return value MUST not be mutated. =head2 parameters_for_response $array_ref = $schema->parameters_for_response([$method, $path, $status]); Finds the response parameters defined in the schema. Returns C<undef> if the C<$path>, C<$method> and C<$status> cannot be found. Will default to the "default" response definition if C<$status> could not be found and "default" exists. Example return value: [ {in => "header", name => "X-Foo"}, {in => "body", name => "body", accepts => ["application/json"]}, ] The return value MUST not be mutated. =head2 routes $collection = $schema->routes; Used to gather all available routes in the schema and return them sorted. The result is a L<Mojo::Collection> object, where each item has a hash looking like this: { method => 'get', path => '/user/{id}', operation_id => 'getUser', # Might be undef() } =head2 validate_request @errors = $schema->validate_request([$method, $path], \%req); This method can be used to validate a HTTP request. C<%req> should contain key/value pairs representing the request parameters. Example: %req = ( body => sub { my ($param_name, $param_for_request) = shift; return {exists => 1, value => \%all_params} unless defined $param_name; return {exists => 1, value => "..."}; }, formData => {email => "..."}, header => {"X-Request-Base" => "..."}, path => {id => "..."}, query => {limit => 42}, ); "formData", "header", "path" and "query" can be either a hash-ref, a hash-like object or a code ref, while "body" MUST be a code ref. The return value from the code ref will get mutated, making it possible to check if an individual parameter was validated or not. # Before: "exists" and "value" must be present my @evaluated; $req{query} = sub { push @evaluated, {exists => 1, value => 42}, return $evaluated[-1] }; # Validate $schema->validate_request(get => "/user"], \%req); # After: "in", "name" and "valid" are added $evaluated[-1] ==> {exists => 1, value => 42, in => "query", name => "foo", valid => 1}; A plain hash-ref will I</not> get mutated. The body hash-ref can also have a "content_type" key. This will be checked against the list of valid request or response content types in the spec. =head2 validate_response @errors = $schema->validate_response([$method, $path, $status], \%res); This method can be used to validate a HTTP response. C<%res> should contain key/value pairs representing the response parameters. Example: %res = ( body => sub { my ($param_name, $param_for_response) = shift; return {exists => 1, value => \%all_params} unless defined $param_name; return {accept => "application/json", exists => 1, value => "..."}; }, header => {"Location" => "..."}, ); C<%res> follows the same rules as C<%req> in L</validate_request>, but also supports "accept", instead of specifying "content_type". "accept" should have the same format as an "Accept" HTTP header. =head1 SEE ALSO L<JSON::Validator>, L<Mojolicious::Plugin::OpenAPI>, L<http://openapi-specification-visual-documentation.apihandyman.io/> =cut