Mojolicious-Plugin-OpenAPI-Modern

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

          - fix test incompatibilities with OpenAPI::Modern 0.040

0.004     2022-12-11 01:22:59Z
          - bump dependency on OpenAPI::Modern for new features and
            for compatibility in test results

0.003     2022-06-08 02:25:56Z
          - test fixes for error messages that changed

0.002     2022-03-25 23:47:55Z
          - new helper methods validate_request and validate_response

0.001     2022-02-16 06:14:12Z
          - First version, unleashed upon an unsuspecting world.

MANIFEST  view on Meta::CPAN

META.json
META.yml
README
dist.ini
lib/Mojolicious/Plugin/OpenAPI/Modern.pm
t/00-report-prereqs.dd
t/00-report-prereqs.t
t/initialization.t
t/lib/BasicApp.pm
t/lib/Helper.pm
t/validate_request_response.t
weaver.ini
xt/author/00-compile.t
xt/author/clean-namespaces.t
xt/author/distmeta.t
xt/author/eol.t
xt/author/kwalitee.t
xt/author/minimum-version.t
xt/author/mojibake.t
xt/author/no-tabs.t
xt/author/pod-coverage.t

lib/Mojolicious/Plugin/OpenAPI/Modern.pm  view on Meta::CPAN


    # leave room for other keys in our localized stash
    $stash->{openapi} = $openapi;
  }
  catch ($e) {
    die 'Cannot load OpenAPI document: ', $e;
  }

  $app->helper(openapi => sub ($) { $stash->{openapi} });

  $app->helper(validate_request => \&_validate_request);
  $app->helper(validate_response => \&_validate_response);

  $app->hook(after_dispatch => sub ($c) {
    $c->res->on(finish => sub ($res) { $config->{after_response}->($c) });
  }) if $config->{after_response};
}

# converts a config hash into values suitable for constructing an OpenAPI::Modern object
sub _process_configs ($config) {
  my $schema;
  if (exists $config->{schema}) {

lib/Mojolicious/Plugin/OpenAPI/Modern.pm  view on Meta::CPAN

  my %config_copy = %$config;
  delete @config_copy{qw(schema document_filename document_uri after_response)};
  warn 'unrecognized config option(s): ', join(', ', keys %config_copy) if keys %config_copy;

  return {
    openapi_uri    => $config->{document_uri} // $config->{document_filename} // '',
    openapi_schema => $schema,
  };
}

sub _validate_request ($c) {
  my $options = $c->stash->{openapi} //= {};
  return $c->openapi->validate_request($c->req, $options);
}

sub _validate_response ($c) {
  my $options = $c->stash->{openapi} //= {};
  local $options->{request} = $c->req;
  return $c->openapi->validate_response($c->res, $options);
}

1;

__END__

=pod

=encoding UTF-8

lib/Mojolicious/Plugin/OpenAPI/Modern.pm  view on Meta::CPAN

        },
        ...
      },
    },
    ...
  });

  $app->plugin('OpenAPI::Modern', $app->config->{openapi});

  # in a controller...
  my $result = $c->openapi->validate_request($c->req);

=head1 DESCRIPTION

This L<Mojolicious> plugin makes an L<OpenAPI::Modern> object available to the application.

There are many features to come.

=for stopwords openapi operationId subref

=head1 CONFIGURATION OPTIONS

lib/Mojolicious/Plugin/OpenAPI/Modern.pm  view on Meta::CPAN


An L<OpenAPI::Modern> object to use. If provided, all other options above are ignored.

=head2 after_response

A subref which runs after the response has been finalized, to allow you to perform validation on it.
You B<must not> mutate the response here, nor swap it out for a different response, so use this only
for telemetry and logging.

  my $after_response = sub ($c) {
    my $result = $c->validate_response;
    if ($result->valid) {
      $c->log->debug('response is valid');
    }
    else {
      # see JSON::Schema::Modern::Result for different output formats
      $c->log->error("response is invalid:\n", $result);
    }
  };

=head1 METHODS

lib/Mojolicious/Plugin/OpenAPI/Modern.pm  view on Meta::CPAN


=head1 HELPERS

These methods are made available on the C<$c> object (the invocant of all controller methods,
and therefore other helpers).

=head2 openapi

The L<OpenAPI::Modern> object; it holds your OpenAPI specification and is reused between requests.

=head2 validate_request

  my $result = $c->openapi->validate_request;

Passes C<< $c->req >> to L<OpenAPI::Modern/validate_request> and returns a
L<JSON::Schema::Modern::Result> object.

Note that the matching L<Mojo::Routes::Route> object for this request is I<not> used to find the
OpenAPI path-item that corresponds to this request: only information in the request URI itself is
used (although some information in the route may be used in future features).

You might want to define an C<under> route action that calls C<validate_request> and short-circuits
with an HTTP 400 response on validation failure.

=head2 validate_response

  my $result = $c->openapi->validate_response;

Passes C<< $c->res >> and C<< $c->req >> to L<OpenAPI::Modern/validate_response> and returns a
L<JSON::Schema::Modern::Result> object.

As this can only be called in the parts of the dispatch flow where the response has already been
rendered and finalized, a hook has been set up for you; you can access it by providing a subref to the
L</after_response> configuration value:

  $app->config->{openapi}{after_response} //= sub ($c) {
    my $result = $c->validate_response;
    # ... do something with the validation result
  };

Note that the matching L<Mojo::Routes::Route> object for this request is I<not> used to find the
OpenAPI path-item that corresponds to this request and response: only information in the request URI
itself is used (although some information in the route may be used in future features).

=head1 STASH VALUES

This plugin stores all its data under the C<openapi> hashref for use by controllers and templates,

t/lib/BasicApp.pm  view on Meta::CPAN


Class::Method::Modifiers::before('Test::Mojo::_request_ok' => sub {
  undef $LAST_VALIDATE_REQUEST_STASH;
  undef $LAST_VALIDATE_RESPONSE_RESULT;
  undef $LAST_VALIDATE_RESPONSE_STASH;
});

sub startup ($self) {
  my $config = $self->config->{openapi};
  $config->{after_response} //= sub ($c) {
    $LAST_VALIDATE_RESPONSE_RESULT = $c->validate_response;
    $LAST_VALIDATE_RESPONSE_STASH = $c->stash('openapi');
  };
  $self->plugin('OpenAPI::Modern', $config);

  my $routes = $self->routes;

  $routes->any('/skip_validate_request' => sub ($c) { $c->render(text => 'ok', format => 'txt') });

  $routes->any('/foo*catchall' => sub ($c) {
    my $result = $c->validate_request;
    $LAST_VALIDATE_REQUEST_STASH = $c->stash('openapi');
    $c->render(
      status => $c->req->query_params->param('status') // ($result->valid ? 200 : 400),
      json => {
        result => $result,
      },
    );
  });
}

t/validate_request_response.t  view on Meta::CPAN

my $openapi_preamble = {
  openapi => $::OAD_VERSION,
  info => {
    title => 'Test API with raw schema',
    version => '1.2.3',
  },
};

my $doc_uri_rel = Mojo::URL->new('/api');

subtest 'validate_request helper' => sub {
  my $t = Test::Mojo->new(
    'BasicApp',
    {
      openapi => {
        document_uri => $doc_uri_rel,
        schema => YAML::PP->new(boolean => 'JSON::PP')->load_string("openapi: $::OAD_VERSION\n".<<'YAML')} });
info:
  title: Test API with raw schema
  version: 1.2.3
components:

t/validate_request_response.t  view on Meta::CPAN

      responses:
        200:
          $ref: '#/components/responses/validation_response'
        400:
          $ref: '#/components/responses/validation_response'
        500:
          description: this response code is produced via ?status=500 in the request
          content:
            application/json:
              schema: false
  /skip_validate_request:
    get:
      operationId: operation_skip_validate_request
      responses:
        200:
          description: request not validated; response body not permitted
          content:
            text/plain:
              schema:
                false
YAML

  $t->post_ok('/foo/hi/there')
    ->status_is(400, 'path_template cannot be found')
    ->json_is({
      result => my $expected_result = {

t/validate_request_response.t  view on Meta::CPAN


  memory_cycle_ok($t->app);


  cmp_result(
    $BasicApp::LAST_VALIDATE_REQUEST_STASH,
    my $expected_stash = superhashof({
      method => 'POST',
      request => isa('Mojo::Message::Request'),
    }),
    'stash is set in validate_request',
  );

  cmp_result(
    $BasicApp::LAST_VALIDATE_RESPONSE_STASH,
    $expected_stash,
    'stash is set in validate_response',
  );

  cmp_result(
    $BasicApp::LAST_VALIDATE_RESPONSE_RESULT->TO_JSON,
    $expected_result,
    'validate_response attempts to parse the request URI again, producing the same result',
  );


  $t->get_ok('/foo/hi')
    ->status_is(400, 'wrong HTTP method')
    ->json_is({
      result => $expected_result = {
        valid => false,
        errors => [
          {

t/validate_request_response.t  view on Meta::CPAN

    });

  memory_cycle_ok($t->app);

  cmp_result(
    $BasicApp::LAST_VALIDATE_REQUEST_STASH,
    $expected_stash = superhashof({
      method => 'GET',
      request => isa('Mojo::Message::Request'),
    }),
    'stash is set in validate_request',
  );

  cmp_result(
    $BasicApp::LAST_VALIDATE_RESPONSE_STASH,
    $expected_stash,
    'stash is set in validate_response',
  );

  cmp_result(
    $BasicApp::LAST_VALIDATE_RESPONSE_RESULT->TO_JSON,
    $expected_result,
    'validate_response attempts to parse the request URI again, producing the same result',
  );


  $t->post_ok('/foo/123')
    ->status_is(400, 'path parameter will fail validation')
    ->json_is({
      result => {
        valid => false,
        errors => [
          {

t/validate_request_response.t  view on Meta::CPAN

  memory_cycle_ok($t->app);

  cmp_result(
    $BasicApp::LAST_VALIDATE_REQUEST_STASH,
    $expected_stash = superhashof({
      method => 'POST',
      operation_id => 'operation_foo',
      path_template => '/foo/{foo_id}',
      request => isa('Mojo::Message::Request'),
    }),
    'stash is set in validate_request',
  );

  cmp_result(
    $BasicApp::LAST_VALIDATE_RESPONSE_STASH,
    $expected_stash,
    'stash is set in validate_response',
  );

  cmp_result(
    $BasicApp::LAST_VALIDATE_RESPONSE_RESULT->TO_JSON,
    { valid => true },
    'validate_response ran successfully',
  );


  $t->post_ok('/foo/hi', { 'Content-Type' => 'text/plain' }, '123')
    ->status_is(400, 'valid path; body does not match')
    ->json_is({
      result => {
        valid => false,
        errors => [
          {

t/validate_request_response.t  view on Meta::CPAN


  cmp_result(
    $BasicApp::LAST_VALIDATE_REQUEST_STASH,
    superhashof({
      method => 'POST',
      operation_id => 'operation_foo',
      path_template => '/foo/{foo_id}',
      path_captures => { foo_id => 'hi' },
      request => isa('Mojo::Message::Request'),
    }),
    'stash is set in validate_request',
  );

  $t->post_ok('/foo/hi?status=500', { 'Content-Type' => 'text/plain' }, 'hi')
    ->status_is(500, 'custom status code')
    ->json_is({
      result => { valid => true },
    });

  memory_cycle_ok($t->app);

  cmp_result(
    $BasicApp::LAST_VALIDATE_REQUEST_STASH,
    $expected_stash = superhashof({
      method => 'POST',
      operation_id => 'operation_foo',
      path_template => '/foo/{foo_id}',
      path_captures => { foo_id => 'hi' },
      request => isa('Mojo::Message::Request'),
    }),
    'stash is set in validate_request',
  );

  cmp_result(
    $BasicApp::LAST_VALIDATE_RESPONSE_STASH,
    $expected_stash,
    'stash is set in validate_response',
  );

  cmp_result(
    $BasicApp::LAST_VALIDATE_RESPONSE_RESULT->TO_JSON,
    {
      valid => false,
      errors => [
        {
          instanceLocation => '/response/body/content',
          keywordLocation => jsonp(qw(/paths /foo/{foo_id} post responses 500 content application/json schema)),
          absoluteKeywordLocation => $doc_uri_rel->clone->fragment(jsonp(qw(/paths /foo/{foo_id} post responses 500 content application/json schema)))->to_string,
          error => 'response body not permitted',
        },
      ],
    },
    'validate_response does not like error responses',
  );


  $t->get_ok('/skip_validate_request')
    ->status_is(200)
    ->content_is('ok');

  memory_cycle_ok($t->app);

  cmp_result(
    $BasicApp::LAST_VALIDATE_REQUEST_STASH,
    undef,
    'stash was not set in validate_request',
  );

  cmp_result(
    $BasicApp::LAST_VALIDATE_RESPONSE_STASH,
    superhashof({
      method => 'GET',
      operation_id => 'operation_skip_validate_request',
      path_template => '/skip_validate_request',
      path_captures => {},
    }),
    'stash is set in validate_response, even though validate_request never ran',
  );

  cmp_result(
    $BasicApp::LAST_VALIDATE_RESPONSE_RESULT->TO_JSON,
    {
      valid => false,
      errors => [
        {
          instanceLocation => '/response/body/content',
          keywordLocation => jsonp(qw(/paths /skip_validate_request get responses 200 content text/plain schema)),
          absoluteKeywordLocation => $doc_uri_rel->clone->fragment(jsonp(qw(/paths /skip_validate_request get responses 200 content text/plain schema)))->to_string,
          error => 'response body not permitted',
        },
      ],
    },
    'response from this endpoint never passes the specification',
  );
};

had_no_warnings() if $ENV{AUTHOR_TESTING};
done_testing;

xt/author/eol.t  view on Meta::CPAN

use Test::More 0.88;
use Test::EOL;

my @files = (
    'lib/Mojolicious/Plugin/OpenAPI/Modern.pm',
    't/00-report-prereqs.dd',
    't/00-report-prereqs.t',
    't/initialization.t',
    't/lib/BasicApp.pm',
    't/lib/Helper.pm',
    't/validate_request_response.t',
    'xt/author/00-compile.t',
    'xt/author/clean-namespaces.t',
    'xt/author/distmeta.t',
    'xt/author/eol.t',
    'xt/author/kwalitee.t',
    'xt/author/minimum-version.t',
    'xt/author/mojibake.t',
    'xt/author/no-tabs.t',
    'xt/author/pod-coverage.t',
    'xt/author/pod-spell.t',

xt/author/no-tabs.t  view on Meta::CPAN

use Test::More 0.88;
use Test::NoTabs;

my @files = (
    'lib/Mojolicious/Plugin/OpenAPI/Modern.pm',
    't/00-report-prereqs.dd',
    't/00-report-prereqs.t',
    't/initialization.t',
    't/lib/BasicApp.pm',
    't/lib/Helper.pm',
    't/validate_request_response.t',
    'xt/author/00-compile.t',
    'xt/author/clean-namespaces.t',
    'xt/author/distmeta.t',
    'xt/author/eol.t',
    'xt/author/kwalitee.t',
    'xt/author/minimum-version.t',
    'xt/author/mojibake.t',
    'xt/author/no-tabs.t',
    'xt/author/pod-coverage.t',
    'xt/author/pod-spell.t',



( run in 2.471 seconds using v1.01-cache-2.11-cpan-39bf76dae61 )