JSON-Validator/lib/JSON/Validator/Schema/OpenAPIv2.pm
package JSON::Validator::Schema::OpenAPIv2;
use Mojo::Base 'JSON::Validator::Schema::Draft4';
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(store => $self->store, _refs => {})->coerce('numbers,strings');
return [$validator->resolve($self->specification)->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} ||= {};
$definitions->{$params->{name}} ||= $params->{schema};
my $ref = {'$ref' => sprintf '%s#/definitions/%s', $self->id, $params->{name}};
$self->_register_ref($ref, schema => $definitions->{$params->{name}});
for my $route ($self->routes->each) {
my $op = $self->get([paths => @$route{qw(path method)}]);
for my $status (@{$params->{status}}) {
$op->{responses}{$status} ||= {description => $params->{description}, schema => $ref};
}
}
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 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;
for my $param (map {@$_} $self->_find_all_nodes([paths => $path, $method], 'parameters')) {
push @parameters, {%$param};
$parameters[-1]{type} ||= schema_type($param->{schema} || $param);
$parameters[-1]{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 @paths_with_weight;
for my $path (keys %{$self->get('/paths') || {}}) {
next if $path =~ $X_RE;
my $weight = '1';
$weight .= /^\{/ ? 0 : 1 for split '/', $path;
push @paths_with_weight, [int $weight, $path];
}
my @operations;
for (sort { $b->[0] <=> $a->[0] || $a->[1] cmp $b->[1] } @paths_with_weight) {
my $path = $_->[1];
next unless my $path_item = $self->get([paths => $path]);
$path_item = $self->get($path_item->{'$ref'} =~ s!^#!!r) if $path_item->{'$ref'};
for my $method (sort keys %$path_item) {
next if $method =~ $X_RE or $SKIP_KEYWORDS_IN_PATH{$method};
push @operations, {method => $method, operation_id => $path_item->{$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 _bundle_ref {
my ($self, $state, $source, $target) = @_;
# Recurse into the inner $ref to avoid invalid schema
my $source_state = $self->store->resolve($source->{'$ref'}, $state);
return $self->_bundle_ref($source_state, $source_state->{schema}, $target) if $source_state->{schema}{'$ref'};
# Replace "paths" inline
if (@{$state->{schema_path}} == 2 and $state->{schema_path}[0] eq 'paths') {
delete $state->{seen_schema}{$target}; # path definitions should not recurse
return [$source_state, $source_state->{schema}, $target, $state->{schema_path}];
}
return $self->SUPER::_bundle_ref($state, $source, $target);
}
sub _bundle_ref_path_expand {
my ($self, $state, $ref) = @_;
return (parameters => $ref) if $state->{schema}{in};
return ($1 => $2) if $ref =~ m!\b(definitions|parameters|responses)\b\.*(?:json|yaml)?(.+)$!;
return ($1, $2 ? ($2) : ()) if $ref =~ m!\b(x-[^/]+)/?\b(.*)!;
return (definitions => $ref);
}
sub _coerce_arrays {
my ($self, $val, $param) = @_;
my $data_type = data_type $val->{value};
my $schema = $param->{schema} || $param; # $param->{schema} is for OpenAPIv3
$schema = $self->get($schema->{'$ref'} =~ s!^#!!r) if $schema->{'$ref'};
my $schema_type = schema_type $schema;
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} || ($param->{type} eq 'array' ? 'csv' : '');
return $val->{value} = ref $val->{value} eq 'ARRAY' ? $val->{value} : [$val->{value}] if $format eq 'multi';
$val->{value} = $val->{value}[0] // '' if ref $val->{value} eq 'ARRAY';
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 _extract_ref_from_schema { $_[1]->{'$ref'} =~ /^\w+$/ ? "#/definitions/$_[1]->{'$ref'}" : $_[1]->{'$ref'} }
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';
}
# Resolve all $ref before returning the data
for my $items (@found) {
for my $schema (@$items) {
my %seen;
while (ref $schema eq 'HASH' and $schema->{'$ref'} and !ref $schema->{'$ref'}) {
last if $seen{$schema}++;
$schema = $self->_refs->{$schema}{schema}
// Carp::confess(qq(resolve() must be called before validate() to lookup "$schema->{'$ref'}".));
}
}
}
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 _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_id { }
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_parameter_format($val, $param) if $direction eq 'request' and $param->{in} ne 'body';
$self->_coerce_default_value($val, $param) unless $val->{exists};
if ($param->{in} eq 'body') {
push @errors, $self->_validate_body($direction, $val, $param);
next;
}
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, $state) = @_;
return unless $state->{schema}{required} and (not defined $data or not length $data);
return E $state->{path}, 'Missing property.';
}
sub _validate_type_object {
my ($self, $data, $state) = @_;
my ($path, $schema) = @$state{qw(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 $self->_get(['readOnly'], {path => [], schema => $schema->{properties}{$name}});
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 $ds = $self->get("/definitions/$name");
local $self->{inside_discriminator} = 1;
return $self->_validate($data, $self->_state($state, schema => $ds));
}
return (
@errors,
$self->_validate_type_object_min_max($_[1], $state),
$self->_validate_type_object_dependencies($_[1], $state),
$self->_validate_type_object_properties($_[1], $state),
);
}
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 errors
my $array_ref = $schema->errors;
See L<JSON::Validator::Schema/errors>.
=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 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 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 ($name, $param) = shift;
# $param = {name => $name, in => ..., schema => ..., ...}
return {exists => 1, value => \%all_params} unless defined $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 ($name, $param) = shift;
# $param = {name => $name, in => ..., ...}
return {exists => 1, value => \%all_params} unless defined $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