Group
Extension

Yancy-Plugin-OpenAPI/lib/Yancy/Plugin/OpenAPI.pm

package Yancy::Plugin::OpenAPI;
our $VERSION = '0.002';
# ABSTRACT: Generate an OpenAPI spec and API for a Yancy schema

#pod =head1 SYNOPSIS
#pod
#pod   use Mojolicious::Lite;
#pod   plugin Yancy => 'sqlite:data.db';
#pod   plugin OpenAPI => { route => '/api' };
#pod   app->start;
#pod
#pod =head1 DESCRIPTION
#pod
#pod This plugin generates an OpenAPI specification from your database
#pod schema. The generated spec has endpoints to create, read, update,
#pod delete, and search for items in your database.
#pod
#pod =head1 CONFIGURATION
#pod
#pod These configuration keys can be part of the hash reference passed to the
#pod C<plugin> call.
#pod
#pod =head2 route
#pod
#pod The base route path for the generated API. Can be a string or
#pod a L<Mojolicious::Routes> object.
#pod
#pod =head2 title
#pod
#pod The title of the API, used in the OpenAPI spec. See also L</info>.
#pod
#pod =head2 info
#pod
#pod The C<info> section of the OpenAPI spec. A hash reference.
#pod
#pod =head2 host
#pod
#pod The host key of the OpenAPI spec. Defaults to the value of
#pod L<Sys::Hostname/hostname>.
#pod
#pod =head2 model
#pod
#pod The L<Yancy::Model> object to use. Defaults to
#pod L<Mojolicious::Plugin::Yancy/model>.
#pod
#pod =head2 default_controller
#pod
#pod The default controller to use for generated API routes. Defaults to
#pod C<yancy>, the L<Yancy::Controller::Yancy> controller.
#pod
#pod =head1 SEE ALSO
#pod
#pod L<Mojolicious::Plugin::OpenAPI>, L<Yancy>
#pod
#pod =cut

use Mojo::Base 'Mojolicious::Plugin';
use Mojo::JSON qw( true false );
use Mojo::Util qw( url_escape );
use Yancy::Util qw( json_validator );
use Sys::Hostname qw( hostname );

# XXX: This uses strings from Yancy::I18N. They should probably be moved
# here, which means we need a way to add namespaces to I18N, or
# a separate plugin I18N object/namespace

has moniker => 'openapi';
has route =>;
has model =>;
has app =>;

sub register {
  my ( $self, $app, $config ) = @_;
  $self->app( $app );
  $self->model( $config->{model} // $app->yancy->model );
  $config->{default_controller} //= 'yancy';

  # XXX: Throw an error if there is already a route here
  my $route = $app->yancy->routify( $config->{route} );
  $self->route( $route );

  # First create the OpenAPI schema and API URL
  my $spec = $self->_openapi_spec_from_schema( $config );
  $self->_openapi_spec_add_mojo( $spec, $config );

  my $openapi = $app->plugin(
    'Mojolicious::Plugin::OpenAPI' => {
      route => $route,
      spec => $spec,
      default_response_name => '_Error',
    },
  );
}

sub _openapi_find_schema_name {
  my ( $self, $path, $pathspec ) = @_;
  return $pathspec->{'x-schema'} if $pathspec->{'x-schema'};
  my $schema_name;
  for my $method ( grep !/^(parameters$|x-)/, keys %{ $pathspec } ) {
    my $op_spec = $pathspec->{ $method };
    my $schema;
    if ( $method eq 'get' ) {
      # d is in case only has "default" response
      my ($response) = grep /^[2d]/, sort keys %{ $op_spec->{responses} };
      my $response_spec = $op_spec->{responses}{$response};
      next unless $schema = $response_spec->{schema};
    } elsif ( $method =~ /^(put|post)$/ ) {
      my @body_params = grep 'body' eq ($_->{in} // ''),
        @{ $op_spec->{parameters} || [] },
        @{ $pathspec->{parameters} || [] },
        ;
      die "No more than 1 'body' parameter allowed" if @body_params > 1;
      next unless $schema = $body_params[0]->{schema};
    }
    next unless my $this_ref =
      $schema->{'$ref'} ||
      ( $schema->{items} && $schema->{items}{'$ref'} ) ||
      ( $schema->{properties} && $schema->{properties}{items} && $schema->{properties}{items}{'$ref'} );
    next unless $this_ref =~ s:^#/definitions/::;
    die "$method '$path' = $this_ref but also '$schema_name'"
      if $this_ref and $schema_name and $this_ref ne $schema_name;
    $schema_name = $this_ref;
  }
  if ( !$schema_name ) {
    ($schema_name) = $path =~ m#^/([^/]+)#;
    die "No schema found in '$path'" if !$schema_name;
  }
  $schema_name;
}

# mutates $spec
sub _openapi_spec_add_mojo {
  my ( $self, $spec, $config ) = @_;
  for my $path ( keys %{ $spec->{paths} } ) {
    my $pathspec = $spec->{paths}{ $path };
    my $schema = $self->_openapi_find_schema_name( $path, $pathspec );
    die "Path '$path' had non-existent schema '$schema'"
      if !$spec->{definitions}{$schema};
    for my $method ( grep !/^(parameters$|x-)/, keys %{ $pathspec } ) {
      my $op_spec = $pathspec->{ $method };
      my $mojo = $self->_openapi_spec_infer_mojo( $path, $pathspec, $method, $op_spec );
      # XXX Allow overriding controller on a per-schema basis
      # This gives more control over how a certain schema's items
      # are written/read from the database
      $mojo->{controller} = $config->{default_controller};
      $mojo->{schema} = $schema;
      $op_spec->{ 'x-mojo-to' } = $mojo;
    }
  }
}

# for a given OpenAPI operation, figures out right values for 'x-mojo-to'
# to hook it up to the correct CRUD operation
sub _openapi_spec_infer_mojo {
  my ( $self, $path, $pathspec, $method, $op_spec ) = @_;
  my @path_params = grep 'path' eq ($_->{in} // ''),
    @{ $pathspec->{parameters} || [] },
    @{ $op_spec->{parameters} || [] },
    ;
  my ($id_field) = grep defined,
    (map $_->{'x-id-field'}, $op_spec, $pathspec),
    (@path_params && $path_params[-1]{name});
  if ( $method eq 'get' ) {
    # heuristic: is per-item if have a param in path
    if ( $id_field ) {
      # per-item - GET = "read"
      return {
        action => 'get',
        format => 'json',
      };
    }
    else {
      # per-schema - GET = "list"
      return {
        action => 'list',
        format => 'json',
      };
    }
  }
  elsif ( $method eq 'post' ) {
    return {
      action => 'set',
      format => 'json',
    };
  }
  elsif ( $method eq 'put' ) {
    die "'$method' $path needs id_field" if !$id_field;
    return {
      action => 'set',
      format => 'json',
    };
  }
  elsif ( $method eq 'delete' ) {
    die "'$method' $path needs id_field" if !$id_field;
    return {
      action => 'delete',
      format => 'json',
    };
  }
  else {
    die "Unknown method '$method'";
  }
}

sub _openapi_spec_from_schema {
  my ( $self, $config ) = @_;
  my ( %definitions, %paths );
  my %parameters = (
    '$limit' => {
      name => '$limit',
      type => 'integer',
      in => 'query',
      description => $self->app->l( 'OpenAPI $limit description' ),
    },
    '$offset' => {
      name => '$offset',
      type => 'integer',
      in => 'query',
      description => $self->app->l( 'OpenAPI $offset description' ),
    },
    '$order_by' => {
      name => '$order_by',
      type => 'string',
      in => 'query',
      pattern => '^(?:asc|desc):[^:,]+$',
      description => $self->app->l( 'OpenAPI $order_by description' ),
    },
    '$match' => {
      name => '$match',
      type => 'string',
      enum => [qw( any all )],
      default => 'all',
      in => 'query',
      description => $self->app->l( 'OpenAPI $match description' ),
    },
  );
  for my $schema_name ( $self->model->schema_names ) {
    # Set some defaults so users don't have to type as much
    my $schema = $self->model->schema( $schema_name )->info;
    next if $schema->{ 'x-ignore' };
    my $id_field = $schema->{ 'x-id-field' } // 'id';
    my @id_fields = ref $id_field eq 'ARRAY' ? @$id_field : ( $id_field );
    my $real_schema_name = ( $schema->{'x-view'} || {} )->{schema} // $schema_name;
    my $props = $schema->{properties}
      || $self->model->schema( $real_schema_name )->info->{properties};
    my %props = %$props;

    $definitions{ $schema_name } = $schema;

    for my $prop ( keys %props ) {
      $props{ $prop }{ type } ||= 'string';
    }

    $paths{ '/' . $schema_name } = {
      get => {
        description => $self->app->l( 'OpenAPI list description' ),
        parameters => [
          { '$ref' => '#/parameters/%24limit' },
          { '$ref' => '#/parameters/%24offset' },
          { '$ref' => '#/parameters/%24order_by' },
          { '$ref' => '#/parameters/%24match' },
          map {
            my $name = $_;
            my $type = ref $props{ $_ }{type} eq 'ARRAY' ? $props{ $_ }{type}[0] : $props{ $_ }{type};
            my $description = $self->app->l(
               $type eq 'number' || $type eq 'integer' ? 'OpenAPI filter number description'
               : $type eq 'boolean' ? 'OpenAPI filter boolean description'
               : $type eq 'array' ? 'OpenAPI filter array description'
               : 'OpenAPI filter string description'
            );
            {
              name => $name,
              in => 'query',
              type => $type,
              description => $self->app->l( 'OpenAPI filter description', $name ) . $description,
            }
          } grep !exists( $props{ $_ }{'$ref'} ), sort keys %props,
        ],
        responses => {
          200 => {
            description => $self->app->l( 'OpenAPI list response' ),
            schema => {
              type => 'object',
              required => [qw( items total )],
              properties => {
                total => {
                  type => 'integer',
                  description => $self->app->l( 'OpenAPI list total description' ),
                },
                items => {
                  type => 'array',
                  description => $self->app->l( 'OpenAPI list items description' ),
                  items => { '$ref' => "#/definitions/" . url_escape $schema_name },
                },
                offset => {
                  type => 'integer',
                  description => $self->app->l( 'OpenAPI list offset description' ),
                },
              },
            },
          },
          default => {
            description => $self->app->l( 'Unexpected error' ),
            schema => { '$ref' => '#/definitions/_Error' },
          },
        },
      },
      $schema->{'x-view'} ? () : (post => {
        parameters => [
          {
            name => "newItem",
            in => "body",
            required => true,
            schema => { '$ref' => "#/definitions/" . url_escape $schema_name },
          },
        ],
        responses => {
          201 => {
            description => $self->app->l( 'OpenAPI create response' ),
            schema => {
              @id_fields > 1
              ? (
                type => 'array',
                items => [
                  map +{
                    '$ref' => '#/' . join '/', map { url_escape $_ }
                      'definitions', $schema_name, 'properties', $_,
                  }, @id_fields,
                ],
              )
              : (
                '$ref' => '#/' . join '/', map { url_escape $_ }
                  'definitions', $schema_name, 'properties', $id_fields[0],
              ),
            },
          },
          default => {
            description => $self->app->l( "Unexpected error" ),
            schema => { '$ref' => "#/definitions/_Error" },
          },
        },
      }),
    };

    $paths{ sprintf '/%s/{%s}', $schema_name, $id_field } = {
      parameters => [
        map +{
          name => $_,
          in => 'path',
          required => true,
          type => 'string',
          'x-mojo-placeholder' => '*',
        }, @id_fields
      ],

      get => {
        description => $self->app->l( 'OpenAPI get description' ),
        responses => {
          200 => {
            description => $self->app->l( 'OpenAPI get response' ),
            schema => { '$ref' => "#/definitions/" . url_escape $schema_name },
          },
          default => {
            description => $self->app->l( "Unexpected error" ),
            schema => { '$ref' => '#/definitions/_Error' },
          }
        }
      },

      $schema->{'x-view'} ? () : (put => {
        description => $self->app->l( 'OpenAPI update description' ),
        parameters => [
          {
            name => "newItem",
            in => "body",
            required => true,
            schema => { '$ref' => "#/definitions/" . url_escape $schema_name },
          }
        ],
        responses => {
          200 => {
            description => $self->app->l( 'OpenAPI update response' ),
            schema => { '$ref' => "#/definitions/" . url_escape $schema_name },
          },
          default => {
            description => $self->app->l( "Unexpected error" ),
            schema => { '$ref' => "#/definitions/_Error" },
          }
        }
      },

      delete => {
        description => $self->app->l( 'OpenAPI delete description' ),
        responses => {
          204 => {
            description => $self->app->l( 'OpenAPI delete response' ),
          },
          default => {
            description => $self->app->l( "Unexpected error" ),
            schema => { '$ref' => '#/definitions/_Error' },
          },
        },
      }),
    };
  }

  return {
    info => $config->{info} || { title => $config->{title} // "OpenAPI Spec", version => "1" },
    swagger => '2.0',
    host => $config->{host} // hostname(),
    schemes => [qw( http )],
    consumes => [qw( application/json )],
    produces => [qw( application/json )],
    definitions => {
      _Error => {
        'x-ignore' => 1, # In case we get round-tripped into a Yancy::Model
        title => $self->app->l( 'OpenAPI error object' ),
        type => 'object',
        properties => {
          errors => {
            type => "array",
            items => {
              required => [qw( message )],
              properties => {
                message => {
                  type => "string",
                  description => $self->app->l( 'OpenAPI error message' ),
                },
                path => {
                  type => "string",
                  description => $self->app->l( 'OpenAPI error path' ),
                }
              }
            }
          }
        }
      },
      %definitions,
    },
    paths => \%paths,
    parameters => \%parameters,
  };
}

1;

__END__

=pod

=head1 NAME

Yancy::Plugin::OpenAPI - Generate an OpenAPI spec and API for a Yancy schema

=head1 VERSION

version 0.002

=head1 SYNOPSIS

  use Mojolicious::Lite;
  plugin Yancy => 'sqlite:data.db';
  plugin OpenAPI => { route => '/api' };
  app->start;

=head1 DESCRIPTION

This plugin generates an OpenAPI specification from your database
schema. The generated spec has endpoints to create, read, update,
delete, and search for items in your database.

=head1 CONFIGURATION

These configuration keys can be part of the hash reference passed to the
C<plugin> call.

=head2 route

The base route path for the generated API. Can be a string or
a L<Mojolicious::Routes> object.

=head2 title

The title of the API, used in the OpenAPI spec. See also L</info>.

=head2 info

The C<info> section of the OpenAPI spec. A hash reference.

=head2 host

The host key of the OpenAPI spec. Defaults to the value of
L<Sys::Hostname/hostname>.

=head2 model

The L<Yancy::Model> object to use. Defaults to
L<Mojolicious::Plugin::Yancy/model>.

=head2 default_controller

The default controller to use for generated API routes. Defaults to
C<yancy>, the L<Yancy::Controller::Yancy> controller.

=head1 SEE ALSO

L<Mojolicious::Plugin::OpenAPI>, L<Yancy>

=head1 AUTHOR

Doug Bell <preaction@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2021 by Doug Bell.

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

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.