NAME

OpenAPI::Modern - Validate HTTP requests and responses against an OpenAPI v3.1 document

VERSION

version 0.070

SYNOPSIS

my $openapi = OpenAPI::Modern->new(
  openapi_uri => '/api',
  openapi_schema => YAML::PP->new(boolean => 'JSON::PP')->load_string(<<'YAML'));
openapi: 3.1.0
info:
  title: Test API
  version: 1.2.3
paths:
  /foo/{foo_id}:
    parameters:
    - name: foo_id
      in: path
      required: true
      schema:
        pattern: ^[a-z]+$
    post:
      operationId: my_foo_request
      parameters:
      - name: My-Request-Header
        in: header
        required: true
        schema:
          pattern: ^[0-9]+$
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                hello:
                  type: string
                  pattern: ^[0-9]+$
      responses:
        200:
          description: success
          headers:
            My-Response-Header:
              required: true
              schema:
                pattern: ^[0-9]+$
          content:
            application/json:
              schema:
                type: object
                required: [ status ]
                properties:
                  status:
                    const: ok
YAML

say 'request:';
my $request = POST '/foo/bar',
  'My-Request-Header' => '123', 'Content-Type' => 'application/json', Host => 'example.com',
  Content => '{"hello": 123}';
my $results = $openapi->validate_request($request);
say $results;
say ''; # newline
say JSON::MaybeXS->new(convert_blessed => 1, canonical => 1, pretty => 1, indent_length => 2)->encode($results);

say 'response:';
my $response = Mojo::Message::Response->new(code => 200, message => 'OK');
$response->headers->content_type('application/json');
$response->headers->header('My-Response-Header', '123');
$response->body('{"status": "ok"}');
$results = $openapi->validate_response($response, { request => $request });
say $results;
say ''; # newline
say JSON::MaybeXS->new(convert_blessed => 1, canonical => 1, pretty => 1, indent_length => 2)->encode($results);

prints:

request:
'/request/body/hello': got integer, not string
'/request/body': not all properties are valid

{
  "errors" : [
    {
      "absoluteKeywordLocation" : "https://example.com/api#/paths/~1foo~1%7Bfoo_id%7D/post/requestBody/content/application~1json/schema/properties/hello/type",
      "error" : "got integer, not string",
      "instanceLocation" : "/request/body/hello",
      "keywordLocation" : "/paths/~1foo~1{foo_id}/post/requestBody/content/application~1json/schema/properties/hello/type"
    },
    {
      "absoluteKeywordLocation" : "https://example.com/api#/paths/~1foo~1%7Bfoo_id%7D/post/requestBody/content/application~1json/schema/properties",
      "error" : "not all properties are valid",
      "instanceLocation" : "/request/body",
      "keywordLocation" : "/paths/~1foo~1{foo_id}/post/requestBody/content/application~1json/schema/properties"
    }
  ],
  "valid" : false
}

response:
valid

{
  "valid" : true
}

DESCRIPTION

This module provides various tools for working with an OpenAPI Specification v3.1 document within your application. The JSON Schema evaluator is fully specification-compliant; the OpenAPI evaluator aims to be but some features are not yet available. My belief is that missing features are better than features that seem to work but actually cut corners for simplicity.

CONSTRUCTOR ARGUMENTS

If construction of the object is not successful, for example the document has a syntax error, the call to new() will throw an exception. Be careful about examining this exception, for it might be a JSON::Schema::Modern::Result object, which has a boolean overload of false when it contains errors! But you never do if ($@) { ... }, right?

openapi_uri

The URI that identifies the OpenAPI document. Ignored if "openapi_document" is provided.

It is used at runtime as the base for absolute URIs used in JSON::Schema::Modern::Result objects, along with the request's Host header and scheme (e.g. https), when available.

openapi_schema

The data structure describing the OpenAPI v3.1 document (as specified at https://spec.openapis.org/oas/v3.1.0). Ignored if "openapi_document" is provided.

openapi_document

The JSON::Schema::Modern::Document::OpenAPI document that holds the OpenAPI information to be used for validation. If it is not provided to the constructor, then both "openapi_uri" and "openapi_schema" MUST be provided, and "evaluator" will also be used if provided.

evaluator

The JSON::Schema::Modern object to use for all URI resolution and JSON Schema evaluation. Ignored if "openapi_document" is provided. Optional.

ACCESSORS/METHODS

openapi_uri

The URI that identifies the OpenAPI document.

openapi_schema

The data structure describing the OpenAPI document. See "https://spec.openapis.org/oas/v3.1.0" in the specification.

openapi_document

The JSON::Schema::Modern::Document::OpenAPI document that holds the OpenAPI information to be used for validation.

document_get

my $parameter_data = $openapi->document_get('/paths/~1foo~1{foo_id}/get/parameters/0');

Fetches the subschema at the provided JSON pointer. Proxies to "get" in JSON::Schema::Modern::Document::OpenAPI. This is not recursive (does not follow $ref chains) -- for that, use $openapi->recursive_get(Mojo::URL->new->fragment($json_pointer)), see "recursive_get".

evaluator

The JSON::Schema::Modern object to use for all URI resolution and JSON Schema evaluation.

validate_request

$result = $openapi->validate_request(
  $request,
  # optional second argument can contain any combination of:
  my $options = {
    path_template => '/foo/{arg1}/bar/{arg2}',
    operation_id => 'my_operation_id',
    path_captures => { arg1 => 1, arg2 => 2 },
    method => 'get',
  },
);

Validates an HTTP::Request, Plack::Request, Catalyst::Request or Mojo::Message::Request object against the corresponding OpenAPI v3.1 document, returning a JSON::Schema::Modern::Result object.

Absolute URIs in the result object are constructed by resolving the openapi document path against the "openapi_uri", as well as the Host header of the request if a host component is not included in the "openapi_uri".

The second argument is an optional hashref that contains extra information about the request, corresponding to the values expected by "find_path" below. It is populated with some information about the request: save it and pass it to a later "validate_response" (corresponding to a response for this request) to improve performance.

validate_response

$result = $openapi->validate_response(
  $response,
  {
    path_template => '/foo/{arg1}/bar/{arg2}',
    request => $request,
  },
);

Validates an HTTP::Response, Plack::Response, Catalyst::Response or Mojo::Message::Response object against the corresponding OpenAPI v3.1 document, returning a JSON::Schema::Modern::Result object.

Absolute URIs in the result object are constructed by resolving the openapi document path against the "openapi_uri", as well as the Host header of the request if the request is provided and if a host component is not included in the "openapi_uri".

The second argument is an optional hashref that contains extra information about the request corresponding to the response, as in "find_path".

request is also accepted as a key in the hashref, representing the original request object that corresponds to this response (as not all HTTP libraries link to the request in the response object).

find_path

$result = $self->find_path($options);

Uses information in the request to determine the relevant parts of the OpenAPI specification. request should be provided if available, but additional data can be used instead (which is populated by earlier "validate_request" or "find_path" calls to the same request).

The single argument is a hashref that contains information about the request. Possible values include:

  • request: the object representing the HTTP request. Should be provided when available.

  • path_template: a string representing the request URI, with placeholders in braces (e.g. /pets/{petId}); see https://spec.openapis.org/oas/v3.1.0#paths-object.

  • operation_id: a string corresponding to the operationId at a particular path-template and HTTP location under /paths

  • path_captures: a hashref mapping placeholders in the path to their actual values in the request URI

  • method: the HTTP method used by the request (used case-insensitively)

All of these values are optional (unless request is omitted), and will be derived from the request URI as needed (albeit less efficiently than if they were provided). All passed-in values MUST be consistent with each other and the request URI.

When successful, the options hash will be populated with keys path_template, path_captures, method, and operation_id, and the return value is true. When not successful, the options hash will be populated with key errors, an arrayref containing a JSON::Schema::Modern::Error object, and the return value is false. Other values may also be populated if they can be successfully calculated.

In addition, these values are populated in the options hash (when available):

You can find the associated operation object by using either operation_uri, or by calling $openapi->openapi_document->get_operationId_path($operation_id) (see "get_operationId_path" in JSON::Schema::Modern::Document::OpenAPI) (note that the latter will be removed in a subsequent release, in order to support operations existing in other documents).

Note that the /servers section of the OpenAPI document is not used for path matching at this time, for either scheme and host matching nor path prefixes. For now, if you use a path prefix in servers entries you will need to add this to the path templates under `/paths`.

recursive_get

Given a uri or uri-reference, get the definition at that location, following any $refs along the way. Include the expected definition type (one of schema, response, parameter, example, request-body, header, security-scheme, link, callbacks, or path-item) for validation of the entire reference chain.

Returns the data in scalar context, or a tuple of the data and the canonical URI of the referenced location in list context.

If the provided location is relative, the main openapi document is used for the base URI. If you have a local json pointer you want to resolve, you can turn it into a uri-reference by prepending #.

my $schema = $openapi->recursive_get('#/components/parameters/Content-Encoding', 'parameter');

# starts with a JSON::Schema::Modern object (TODO)
my $schema = $js->recursive_get('https:///openapi_doc.yaml#/components/schemas/my_object')
my $schema = $js->recursive_get('https://localhost:1234/my_spec#/$defs/my_object')

canonical_uri

An accessor that delegates to "canonical_uri" in JSON::Schema::Modern::Document.

schema

An accessor that delegates to "schema" in JSON::Schema::Modern::Document.

get_media_type

An accessor that delegates to "get_media_type" in JSON::Schema::Modern.

add_media_type

A setter that delegates to "add_media_type" in JSON::Schema::Modern.

CACHING

Very large OpenAPI documents may take a noticeable time to be loaded and parsed. You can reduce the impact to your preforking application by loading all necessary documents at startup, and impact can be further reduced by saving objects to cache and then reloading them (perhaps by using a timestamp or checksum to determine if a fresh reload is needed).

sub get_openapi (...) {
  my $serialized_file = Path::Tiny::path($serialized_filename);
  my $openapi_file = Path::Tiny::path($openapi_filename);
  my $openapi;
  if ($serialized_file->stat->mtime < $openapi_file->stat->mtime)) {
    $openapi = OpenAPI::Modern->new(
      openapi_uri => '/api',
      openapi_schema => decode_json($openapi_file->slurp_raw), # your openapi document
    );
    $openapi->evaluator->add_schema(decode_json(...));  # any other needed schemas
    my $frozen = Sereal::Encoder->new({ freeze_callbacks => 1 })->encode($openapi);
    $serialized_file->spew_raw($frozen);
  }
  else {
    my $frozen = $serialized_file->slurp_raw;
    $openapi = Sereal::Decoder->new->decode($frozen);
  }

  # add custom format validations, media types and encodings here
  $openapi->evaluator->add_media_type(...);

  return $openapi;
}

See also "CACHING" in JSON::Schema::Modern.

ON THE USE OF JSON SCHEMAS

Embedded JSON Schemas, through the use of the schema keyword, are fully draft2020-12-compliant, as per the spec, and implemented with JSON::Schema::Modern. Unless overridden with the use of the jsonSchemaDialect keyword, their metaschema is https://spec.openapis.org/oas/3.1/dialect/base, which allows for use of the OpenAPI-specific keywords (discriminator, xml, externalDocs, and example), as defined in "https://spec.openapis.org/oas/v3.1.0#schema-object" in the specification. Format validation is turned on, and the use of content* keywords is off (see "validate_content_schemas" in JSON::Schema::Modern).

References (with the $ref) keyword may reference any position within the entire OpenAPI document; as such, json pointers are relative to the root of the document, not the root of the subschema itself. References to other documents are also permitted, provided those documents have been loaded into the evaluator in advance (see "add_schema" in JSON::Schema::Modern).

Values are generally treated as strings for the purpose of schema evaluation. However, if the top level of the schema contains "type": "number" or "type": "integer", then the value will be (attempted to be) coerced into a number before being passed to the JSON Schema evaluator. Type coercion will not be done if the type keyword is omitted. This lets you use numeric keywords such as maximum and multipleOf in your schemas. It also resolves inconsistencies that can arise when request and response objects are created manually in a test environment (as opposed to being parsed from incoming network traffic) and can therefore inadvertently contain perlish numbers rather than strings.

LIMITATIONS

All message validation is done using Mojolicious objects (Mojo::Message::Request and Mojo::Message::Response). If messages of other types are passed, conversion is done on a best-effort basis, but since different implementations have different levels of adherence to the RFC specs, some validation errors may occur e.g. if a certain required header is missing on the original. For best results in validating real messages from the network, parse them directly into Mojolicious messages (see "parse" in Mojo::Message).

Only certain permutations of OpenAPI documents are supported at this time:

  • for path parameters, only style: simple and explode: false is supported

  • for query parameters, only style: form and explode: true is supported, only the first value of each parameter name is considered, and allowEmptyValue and allowReserved are not checked

  • cookie parameters are not checked at all yet

  • application/x-www-form-urlencoded and multipart/* messages are not yet supported

  • server fields in definitions are completely ignored, and not considered when parsing request URIs.

  • OpenAPI descriptions must be contained in a single document; $references to other documents are not fully supported at this time.

  • The use of $ref within a path-item object is not permitted.

  • Security schemes in the OpenAPI description, and the use of any Authorization headers in requests, are not currently supported.

SEE ALSO

SUPPORT

Bugs may be submitted through https://github.com/karenetheridge/OpenAPI-Modern/issues.

I am also usually active on irc, as 'ether' at irc.perl.org and irc.libera.chat.

You can also find me on the JSON Schema Slack server and OpenAPI Slack server, which are also great resources for finding help.

AUTHOR

Karen Etheridge <ether@cpan.org>

COPYRIGHT AND LICENCE

This software is copyright (c) 2021 by Karen Etheridge.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.

Some schema files have their own licence, in share/oas/LICENSE.