File Coverage

blib/lib/Yancy/Plugin/OpenAPI.pm
Criterion Covered Total %
statement 96 100 96.0
branch 42 64 65.6
condition 21 41 51.2
subroutine 10 10 100.0
pod 1 1 100.0
total 170 216 78.7


line stmt bran cond sub pod time code
1             package Yancy::Plugin::OpenAPI;
2             our $VERSION = '0.002';
3             # ABSTRACT: Generate an OpenAPI spec and API for a Yancy schema
4              
5             #pod =head1 SYNOPSIS
6             #pod
7             #pod use Mojolicious::Lite;
8             #pod plugin Yancy => 'sqlite:data.db';
9             #pod plugin OpenAPI => { route => '/api' };
10             #pod app->start;
11             #pod
12             #pod =head1 DESCRIPTION
13             #pod
14             #pod This plugin generates an OpenAPI specification from your database
15             #pod schema. The generated spec has endpoints to create, read, update,
16             #pod delete, and search for items in your database.
17             #pod
18             #pod =head1 CONFIGURATION
19             #pod
20             #pod These configuration keys can be part of the hash reference passed to the
21             #pod C call.
22             #pod
23             #pod =head2 route
24             #pod
25             #pod The base route path for the generated API. Can be a string or
26             #pod a L object.
27             #pod
28             #pod =head2 title
29             #pod
30             #pod The title of the API, used in the OpenAPI spec. See also L.
31             #pod
32             #pod =head2 info
33             #pod
34             #pod The C section of the OpenAPI spec. A hash reference.
35             #pod
36             #pod =head2 host
37             #pod
38             #pod The host key of the OpenAPI spec. Defaults to the value of
39             #pod L.
40             #pod
41             #pod =head2 model
42             #pod
43             #pod The L object to use. Defaults to
44             #pod L.
45             #pod
46             #pod =head2 default_controller
47             #pod
48             #pod The default controller to use for generated API routes. Defaults to
49             #pod C, the L controller.
50             #pod
51             #pod =head1 SEE ALSO
52             #pod
53             #pod L, L
54             #pod
55             #pod =cut
56              
57 1     1   606594 use Mojo::Base 'Mojolicious::Plugin';
  1         3  
  1         7  
58 1     1   217 use Mojo::JSON qw( true false );
  1         2  
  1         65  
59 1     1   5 use Mojo::Util qw( url_escape );
  1         2  
  1         40  
60 1     1   6 use Yancy::Util qw( json_validator );
  1         1  
  1         56  
61 1     1   7 use Sys::Hostname qw( hostname );
  1         2  
  1         2294  
62              
63             # XXX: This uses strings from Yancy::I18N. They should probably be moved
64             # here, which means we need a way to add namespaces to I18N, or
65             # a separate plugin I18N object/namespace
66              
67             has moniker => 'openapi';
68             has route =>;
69             has model =>;
70             has app =>;
71              
72             sub register {
73 1     1 1 107 my ( $self, $app, $config ) = @_;
74 1         5 $self->app( $app );
75 1   33     17 $self->model( $config->{model} // $app->yancy->model );
76 1   50     197 $config->{default_controller} //= 'yancy';
77              
78             # XXX: Throw an error if there is already a route here
79 1         3 my $route = $app->yancy->routify( $config->{route} );
80 1         456 $self->route( $route );
81              
82             # First create the OpenAPI schema and API URL
83 1         7 my $spec = $self->_openapi_spec_from_schema( $config );
84 1         292 $self->_openapi_spec_add_mojo( $spec, $config );
85              
86 1         10 my $openapi = $app->plugin(
87             'Mojolicious::Plugin::OpenAPI' => {
88             route => $route,
89             spec => $spec,
90             default_response_name => '_Error',
91             },
92             );
93             }
94              
95             sub _openapi_find_schema_name {
96 2     2   6 my ( $self, $path, $pathspec ) = @_;
97 2 50       57 return $pathspec->{'x-schema'} if $pathspec->{'x-schema'};
98 2         6 my $schema_name;
99 2         4 for my $method ( grep !/^(parameters$|x-)/, keys %{ $pathspec } ) {
  2         16  
100 5         8 my $op_spec = $pathspec->{ $method };
101 5         6 my $schema;
102 5 100       16 if ( $method eq 'get' ) {
    100          
103             # d is in case only has "default" response
104 2         4 my ($response) = grep /^[2d]/, sort keys %{ $op_spec->{responses} };
  2         14  
105 2         4 my $response_spec = $op_spec->{responses}{$response};
106 2 50       7 next unless $schema = $response_spec->{schema};
107             } elsif ( $method =~ /^(put|post)$/ ) {
108             my @body_params = grep 'body' eq ($_->{in} // ''),
109 2 50       5 @{ $op_spec->{parameters} || [] },
110 2 100 50     5 @{ $pathspec->{parameters} || [] },
  2         15  
111             ;
112 2 50       6 die "No more than 1 'body' parameter allowed" if @body_params > 1;
113 2 50       17 next unless $schema = $body_params[0]->{schema};
114             }
115             next unless my $this_ref =
116             $schema->{'$ref'} ||
117             ( $schema->{items} && $schema->{items}{'$ref'} ) ||
118 5 100 33     27 ( $schema->{properties} && $schema->{properties}{items} && $schema->{properties}{items}{'$ref'} );
119 3 50       12 next unless $this_ref =~ s:^#/definitions/::;
120 3 50 66     14 die "$method '$path' = $this_ref but also '$schema_name'"
      66        
121             if $this_ref and $schema_name and $this_ref ne $schema_name;
122 3         7 $schema_name = $this_ref;
123             }
124 2 50       6 if ( !$schema_name ) {
125 0         0 ($schema_name) = $path =~ m#^/([^/]+)#;
126 0 0       0 die "No schema found in '$path'" if !$schema_name;
127             }
128 2         6 $schema_name;
129             }
130              
131             # mutates $spec
132             sub _openapi_spec_add_mojo {
133 1     1   4 my ( $self, $spec, $config ) = @_;
134 1         3 for my $path ( keys %{ $spec->{paths} } ) {
  1         4  
135 2         5 my $pathspec = $spec->{paths}{ $path };
136 2         8 my $schema = $self->_openapi_find_schema_name( $path, $pathspec );
137             die "Path '$path' had non-existent schema '$schema'"
138 2 50       6 if !$spec->{definitions}{$schema};
139 2         19 for my $method ( grep !/^(parameters$|x-)/, keys %{ $pathspec } ) {
  2         15  
140 5         7 my $op_spec = $pathspec->{ $method };
141 5         13 my $mojo = $self->_openapi_spec_infer_mojo( $path, $pathspec, $method, $op_spec );
142             # XXX Allow overriding controller on a per-schema basis
143             # This gives more control over how a certain schema's items
144             # are written/read from the database
145 5         10 $mojo->{controller} = $config->{default_controller};
146 5         9 $mojo->{schema} = $schema;
147 5         11 $op_spec->{ 'x-mojo-to' } = $mojo;
148             }
149             }
150             }
151              
152             # for a given OpenAPI operation, figures out right values for 'x-mojo-to'
153             # to hook it up to the correct CRUD operation
154             sub _openapi_spec_infer_mojo {
155 5     5   11 my ( $self, $path, $pathspec, $method, $op_spec ) = @_;
156             my @path_params = grep 'path' eq ($_->{in} // ''),
157 5 100       14 @{ $pathspec->{parameters} || [] },
158 5 100 100     6 @{ $op_spec->{parameters} || [] },
  5         35  
159             ;
160             my ($id_field) = grep defined,
161             (map $_->{'x-id-field'}, $op_spec, $pathspec),
162 5   66     25 (@path_params && $path_params[-1]{name});
163 5 100       16 if ( $method eq 'get' ) {
    100          
    100          
    50          
164             # heuristic: is per-item if have a param in path
165 2 100       5 if ( $id_field ) {
166             # per-item - GET = "read"
167             return {
168 1         4 action => 'get',
169             format => 'json',
170             };
171             }
172             else {
173             # per-schema - GET = "list"
174             return {
175 1         5 action => 'list',
176             format => 'json',
177             };
178             }
179             }
180             elsif ( $method eq 'post' ) {
181             return {
182 1         12 action => 'set',
183             format => 'json',
184             };
185             }
186             elsif ( $method eq 'put' ) {
187 1 50       3 die "'$method' $path needs id_field" if !$id_field;
188             return {
189 1         4 action => 'set',
190             format => 'json',
191             };
192             }
193             elsif ( $method eq 'delete' ) {
194 1 50       4 die "'$method' $path needs id_field" if !$id_field;
195             return {
196 1         4 action => 'delete',
197             format => 'json',
198             };
199             }
200             else {
201 0         0 die "Unknown method '$method'";
202             }
203             }
204              
205             sub _openapi_spec_from_schema {
206 1     1   2 my ( $self, $config ) = @_;
207 1         2 my ( %definitions, %paths );
208 1         3 my %parameters = (
209             '$limit' => {
210             name => '$limit',
211             type => 'integer',
212             in => 'query',
213             description => $self->app->l( 'OpenAPI $limit description' ),
214             },
215             '$offset' => {
216             name => '$offset',
217             type => 'integer',
218             in => 'query',
219             description => $self->app->l( 'OpenAPI $offset description' ),
220             },
221             '$order_by' => {
222             name => '$order_by',
223             type => 'string',
224             in => 'query',
225             pattern => '^(?:asc|desc):[^:,]+$',
226             description => $self->app->l( 'OpenAPI $order_by description' ),
227             },
228             '$match' => {
229             name => '$match',
230             type => 'string',
231             enum => [qw( any all )],
232             default => 'all',
233             in => 'query',
234             description => $self->app->l( 'OpenAPI $match description' ),
235             },
236             );
237 1         427 for my $schema_name ( $self->model->schema_names ) {
238             # Set some defaults so users don't have to type as much
239 1         36 my $schema = $self->model->schema( $schema_name )->info;
240 1 50       22 next if $schema->{ 'x-ignore' };
241 1   50     5 my $id_field = $schema->{ 'x-id-field' } // 'id';
242 1 50       4 my @id_fields = ref $id_field eq 'ARRAY' ? @$id_field : ( $id_field );
243 1   50     7 my $real_schema_name = ( $schema->{'x-view'} || {} )->{schema} // $schema_name;
      33        
244             my $props = $schema->{properties}
245 1   33     19 || $self->model->schema( $real_schema_name )->info->{properties};
246 1         5 my %props = %$props;
247              
248 1         3 $definitions{ $schema_name } = $schema;
249              
250 1         3 for my $prop ( keys %props ) {
251 3   50     8 $props{ $prop }{ type } ||= 'string';
252             }
253              
254             $paths{ '/' . $schema_name } = {
255             get => {
256             description => $self->app->l( 'OpenAPI list description' ),
257             parameters => [
258             { '$ref' => '#/parameters/%24limit' },
259             { '$ref' => '#/parameters/%24offset' },
260             { '$ref' => '#/parameters/%24order_by' },
261             { '$ref' => '#/parameters/%24match' },
262             map {
263 3         290 my $name = $_;
264 3 50       9 my $type = ref $props{ $_ }{type} eq 'ARRAY' ? $props{ $_ }{type}[0] : $props{ $_ }{type};
265 3 50 66     9 my $description = $self->app->l(
    50          
    100          
266             $type eq 'number' || $type eq 'integer' ? 'OpenAPI filter number description'
267             : $type eq 'boolean' ? 'OpenAPI filter boolean description'
268             : $type eq 'array' ? 'OpenAPI filter array description'
269             : 'OpenAPI filter string description'
270             );
271             {
272 3         312 name => $name,
273             in => 'query',
274             type => $type,
275             description => $self->app->l( 'OpenAPI filter description', $name ) . $description,
276             }
277             } grep !exists( $props{ $_ }{'$ref'} ), sort keys %props,
278             ],
279             responses => {
280             200 => {
281             description => $self->app->l( 'OpenAPI list response' ),
282             schema => {
283             type => 'object',
284             required => [qw( items total )],
285             properties => {
286             total => {
287             type => 'integer',
288             description => $self->app->l( 'OpenAPI list total description' ),
289             },
290             items => {
291             type => 'array',
292             description => $self->app->l( 'OpenAPI list items description' ),
293             items => { '$ref' => "#/definitions/" . url_escape $schema_name },
294             },
295             offset => {
296             type => 'integer',
297             description => $self->app->l( 'OpenAPI list offset description' ),
298             },
299             },
300             },
301             },
302             default => {
303             description => $self->app->l( 'Unexpected error' ),
304             schema => { '$ref' => '#/definitions/_Error' },
305             },
306             },
307             },
308             $schema->{'x-view'} ? () : (post => {
309             parameters => [
310             {
311             name => "newItem",
312             in => "body",
313             required => true,
314             schema => { '$ref' => "#/definitions/" . url_escape $schema_name },
315             },
316             ],
317             responses => {
318             201 => {
319             description => $self->app->l( 'OpenAPI create response' ),
320             schema => {
321             @id_fields > 1
322             ? (
323             type => 'array',
324             items => [
325             map +{
326 0         0 '$ref' => '#/' . join '/', map { url_escape $_ }
327             'definitions', $schema_name, 'properties', $_,
328             }, @id_fields,
329             ],
330             )
331             : (
332 1 50       5 '$ref' => '#/' . join '/', map { url_escape $_ }
  4 50       696  
333             'definitions', $schema_name, 'properties', $id_fields[0],
334             ),
335             },
336             },
337             default => {
338             description => $self->app->l( "Unexpected error" ),
339             schema => { '$ref' => "#/definitions/_Error" },
340             },
341             },
342             }),
343             };
344              
345             $paths{ sprintf '/%s/{%s}', $schema_name, $id_field } = {
346             parameters => [
347             map +{
348             name => $_,
349             in => 'path',
350             required => true,
351             type => 'string',
352             'x-mojo-placeholder' => '*',
353             }, @id_fields
354             ],
355              
356             get => {
357             description => $self->app->l( 'OpenAPI get description' ),
358             responses => {
359             200 => {
360             description => $self->app->l( 'OpenAPI get response' ),
361             schema => { '$ref' => "#/definitions/" . url_escape $schema_name },
362             },
363             default => {
364             description => $self->app->l( "Unexpected error" ),
365             schema => { '$ref' => '#/definitions/_Error' },
366             }
367             }
368             },
369              
370 1 50       126 $schema->{'x-view'} ? () : (put => {
371             description => $self->app->l( 'OpenAPI update description' ),
372             parameters => [
373             {
374             name => "newItem",
375             in => "body",
376             required => true,
377             schema => { '$ref' => "#/definitions/" . url_escape $schema_name },
378             }
379             ],
380             responses => {
381             200 => {
382             description => $self->app->l( 'OpenAPI update response' ),
383             schema => { '$ref' => "#/definitions/" . url_escape $schema_name },
384             },
385             default => {
386             description => $self->app->l( "Unexpected error" ),
387             schema => { '$ref' => "#/definitions/_Error" },
388             }
389             }
390             },
391              
392             delete => {
393             description => $self->app->l( 'OpenAPI delete description' ),
394             responses => {
395             204 => {
396             description => $self->app->l( 'OpenAPI delete response' ),
397             },
398             default => {
399             description => $self->app->l( "Unexpected error" ),
400             schema => { '$ref' => '#/definitions/_Error' },
401             },
402             },
403             }),
404             };
405             }
406              
407             return {
408             info => $config->{info} || { title => $config->{title} // "OpenAPI Spec", version => "1" },
409             swagger => '2.0',
410 1   50     910 host => $config->{host} // hostname(),
      33        
411             schemes => [qw( http )],
412             consumes => [qw( application/json )],
413             produces => [qw( application/json )],
414             definitions => {
415             _Error => {
416             'x-ignore' => 1, # In case we get round-tripped into a Yancy::Model
417             title => $self->app->l( 'OpenAPI error object' ),
418             type => 'object',
419             properties => {
420             errors => {
421             type => "array",
422             items => {
423             required => [qw( message )],
424             properties => {
425             message => {
426             type => "string",
427             description => $self->app->l( 'OpenAPI error message' ),
428             },
429             path => {
430             type => "string",
431             description => $self->app->l( 'OpenAPI error path' ),
432             }
433             }
434             }
435             }
436             }
437             },
438             %definitions,
439             },
440             paths => \%paths,
441             parameters => \%parameters,
442             };
443             }
444              
445             1;
446              
447             __END__