File Coverage

blib/lib/Raisin/Plugin/Swagger.pm
Criterion Covered Total %
statement 217 257 84.4
branch 98 146 67.1
condition 20 39 51.2
subroutine 31 33 93.9
pod 2 2 100.0
total 368 477 77.1


line stmt bran cond sub pod time code
1             #!perl
2             #PODNAME: Raisin::Plugin::Swagger
3             #ABSTRACT: Generates API description in Swagger 2/OpenAPI compatible format
4             # vim:ts=4:shiftwidth=4:expandtab:syntax=perl
5              
6 5     5   3049 use strict;
  5         13  
  5         156  
7 5     5   29 use warnings;
  5         10  
  5         267  
8              
9             package Raisin::Plugin::Swagger;
10             $Raisin::Plugin::Swagger::VERSION = '0.92';
11 5     5   96 use parent 'Raisin::Plugin';
  5         11  
  5         1341  
12              
13 5     5   296 use Carp 'croak';
  5         11  
  5         254  
14 5     5   2787 use Data::Dumper;
  5         26345  
  5         353  
15 5     5   44 use Digest::MD5 qw/md5_hex/;
  5         10  
  5         296  
16 5     5   34 use JSON::MaybeXS qw/encode_json/;
  5         10  
  5         306  
17 5     5   33 use List::Util qw/pairmap/;
  5         9  
  5         580  
18 5     5   40 use Scalar::Util qw(blessed);
  5         9  
  5         18089  
19              
20             my %DEFAULTS;
21             my %SETTINGS;
22              
23             my $HTTP_OK = 200;
24              
25             sub build {
26 3     3 1 13 my $self = shift;
27              
28             $self->register(
29 17     17   60 swagger_build_spec => sub { $self->_spec_20 },
30 3     3   1153 swagger_setup => sub { %SETTINGS = @_ },
31 3         36 swagger_security => \&swagger_security,
32             );
33              
34 3         12 1;
35             }
36              
37             sub swagger_security {
38 0     0 1 0 my %p = @_;
39              
40 0 0       0 croak 'Invalid `type`' unless grep { $p{type} eq $_ } qw/basic api_key oauth2/;
  0         0  
41              
42 0         0 my %security;
43              
44 0 0       0 if ($p{type} eq 'basic') {
    0          
    0          
45             $security{ $p{name} } = {
46 0         0 type => 'basic',
47             };
48             }
49             elsif ($p{type} eq 'api_key') {
50 0 0       0 croak 'Invalid `in`' unless grep { $p{in} eq $_ } qw/query header/;
  0         0  
51              
52             $security{ $p{name} } = {
53             type => 'apiKey',
54             name => $p{name},
55             in => $p{in},
56 0         0 };
57             }
58             elsif ($p{type} eq 'oauth2') {
59 0 0       0 croak 'Invalid `flow`' unless grep { $p{flow} eq $_ } qw/implicit password application accessCode/;
  0         0  
60              
61             $security{ $p{name} } = {
62             type => 'oauth2',
63             flow => $p{flow},
64             scopes => $p{scopes},
65 0         0 };
66              
67 0 0       0 if (grep { $p{flow} eq $_ } qw/implicit accessCode/) {
  0         0  
68 0         0 $security{ $p{name} }{authorizationUrl} = $p{authorization_url};
69             }
70              
71 0 0       0 if (grep { $p{flow} eq $_ } qw/password application accessCode/) {
  0         0  
72 0         0 $security{ $p{name} }{tokenUrl} = $p{token_url};
73             }
74             }
75              
76             $SETTINGS{security} = {
77 0 0       0 %{ $SETTINGS{security} || {} },
  0         0  
78             %security,
79             };
80             }
81              
82             sub _spec_20 {
83 18     18   36 my $self = shift;
84 18 100       59 return 1 if $self->{built};
85              
86 2         12 my $app = $self->app;
87 2         7 my $req = $app->req;
88 2         8 my $routes = $app->routes->routes;
89              
90 2 50       28 my @content_types = $app->format
91             ? $app->format
92             : qw(application/x-yaml application/json);
93              
94 2         28 my $base_path = $req->base->as_string;
95             ### Respect proxied requests
96             # A proxy map is used to fill the "basePath" attribute.
97 2   50     578 my $_base = $req->env->{HTTP_X_FORWARDED_SCRIPT_NAME} || q(/);
98 2         43 $base_path =~ s#http(?:s?)://[^/]+/#$_base#msix;
99              
100 2         9 $DEFAULTS{consumes} = \@content_types;
101 2         6 $DEFAULTS{produces} = \@content_types;
102              
103             my %spec = (
104             swagger => '2.0',
105             info => _info_object($app),
106             ### Respect proxied requests
107             # The frontend hostname is used if set.
108             host => $req->env->{HTTP_X_FORWARDED_HOST}
109             || $req->env->{SERVER_NAME}
110             || $req->env->{HTTP_HOST},
111 2   33     12 basePath => $base_path,
112             schemes => [$req->scheme],
113             consumes => \@content_types,
114             produces => \@content_types,
115             paths => _paths_object($routes),
116             definitions => _definitions_object($routes),
117             #parameters => undef,
118             #responses => undef,
119             securityDefinitions => _security_definitions_object(),
120             security => _security_object(),
121             #tags => undef,
122             #externalDocs => undef,
123             );
124              
125 2         11 my $tags = _tags_object($self->app);
126 2 50       8 if (scalar @$tags) {
127 0         0 $spec{tags} = $tags;
128             }
129              
130             # routes
131             $self->app->add_route(
132             method => 'GET',
133             path => '/swagger',
134 0     0   0 code => sub { \%spec }
135 2         8 );
136              
137             # mark as built
138 2         17 $self->{built} = 1;
139              
140 2         12 \%spec;
141             }
142              
143             sub _contact_object {
144 2     2   4 my $contact = shift;
145 2         12 my %obj;
146 2         8 for (qw(name url email)) {
147 6 50       24 $obj{$_} = $contact->{$_} if $contact->{$_};
148             }
149 2         339 \%obj;
150             }
151              
152             sub _license_object {
153 2     2   4 my $license = shift;
154             my %obj = (
155             name => $license->{name},
156 2         9 );
157 2 50       8 $obj{url} = $license->{url} if $license->{url};
158 2         5 \%obj;
159             }
160              
161             sub _info_object {
162 4     4   10 my $app = shift;
163              
164             my %obj = (
165 4   100     31 title => $SETTINGS{title} || 'API',
      100        
166             version => $app->api_version || '0.0.1',
167             );
168              
169 4 100       17 $obj{description} = $SETTINGS{description} if $SETTINGS{description};
170 4 100       13 $obj{termsOfService} = $SETTINGS{terms_of_service} if $SETTINGS{terms_of_service};
171              
172 4 100       8 $obj{contact} = _contact_object($SETTINGS{contact}) if keys %{ $SETTINGS{contact} };
  4         33  
173 4 100       9 $obj{license} = _license_object($SETTINGS{license}) if keys %{ $SETTINGS{license} };
  4         47  
174              
175 4         22 \%obj;
176             }
177              
178             sub _security_object {
179 2   0 2   4 my @obj = map { { $_->{name} => $_->{scopes} || [] } } values %{ $SETTINGS{security} };
  0         0  
  2         20  
180 2         22 \@obj;
181             }
182              
183 2 50   2   14 sub _security_definitions_object { $SETTINGS{security} || {} }
184              
185             sub _paths_object {
186 2     2   75 my $routes = shift;
187              
188 2         4 my %obj;
189 2         66 for my $r (sort { $a->path cmp $b->path } @$routes) {
  73         385  
190 26 100       162 next if lc($r->method) eq 'options';
191              
192 20         104 my $path = $r->path;
193 20         153 $path =~ s#:([^/]+)#{$1}#msixg;
194              
195 20         41 $obj{ $path }{ lc($r->method) } = _operation_object($r);
196             }
197              
198 2         16 \%obj;
199             }
200              
201             sub _operation_object {
202 20     20   28 my $r = shift;
203              
204 20         42 my $path = $r->path;
205 20         82 $path =~ tr#/:#_#;
206 20         38 my $operation_id = lc($r->method) . $path;
207              
208             my %obj = (
209             consumes => $DEFAULTS{consumes},
210             #deprecated => 'false',
211             description => $r->desc || '',
212             #externalDocs => '',
213             operationId => $operation_id,
214             produces => $r->produces || $DEFAULTS{produces},
215             responses => {
216             default => {
217             description => 'Unexpected error',
218             #examples => '',
219             #headers => '',
220             #schema => '',
221             },
222             # Adds a response object from route's entity if it exists
223 20   50     120 %{ _response_object($r) },
  20   33     233  
      100        
224             },
225             #schemes => [],
226             #security => {}, # TODO per operation permissions
227             summary => $r->summary || '',
228             tags => $r->tags,
229             );
230              
231 20         59 my $params = _parameters_object($r->method, $r->params);
232 20 100       67 $obj{parameters} = $params if scalar @$params;
233              
234 20         75 \%obj;
235             }
236              
237             sub _response_object {
238 21     21   33 my $r = shift;
239 21 100       47 return {} unless $r->entity;
240              
241 1         8 my $name = $r->entity;
242              
243 1   50     6 my %obj = (
244             $HTTP_OK => {
245             description => $r->desc || $r->summary || '',
246             schema => {
247             '$ref' => sprintf('#/definitions/%s', _name_for_object($name)),
248             }
249             },
250             );
251              
252 1         4 \%obj;
253             }
254              
255             sub _parameters_object {
256 28     28   10763 my ($method, $pp) = @_;
257              
258 28         98 my @obj;
259 28         59 for my $p (@$pp) {
260 43         104 my ($type) = _param_type($p->type);
261              
262             # Available: query, header, path, formData or body
263 43         78 my $location = do {
264 43 100       108 if ($p->in) { $p->in }
  2 100       6  
    100          
    100          
265 14         65 elsif ($p->named) { 'path' }
266 1         7 elsif ($type eq 'object') { 'body' }
267 17         146 elsif ($method =~ /patch|post|put/i) { 'formData' }
268 9         77 else { 'query' }
269             };
270              
271 43         88 my $ptype = _param_type_object($p);
272 43 100       131 if (_type_name($p->type) =~ /^HashRef$/ ) {
273 1         14 $ptype->{schema}{'$ref'} = delete $ptype->{'$ref'};
274             }
275              
276             # If the type is an Enum, set type to string and give the enum values.
277 43 100       226 if (_type_is_enum($p->type)) {
278 1         20 $ptype->{type} = 'string';
279 1         3 $ptype->{enum} = $p->type->values;
280             }
281              
282 43 100 100     107 my %param = (
283             description => $p->desc || '',
284             in => $location,
285             name => $p->name,
286             required => $p->required ? JSON::MaybeXS::true : JSON::MaybeXS::false,
287             %$ptype,
288             );
289 43 100       802 $param{default} = $p->default if defined $p->default;
290              
291              
292 43         321 push @obj, \%param;
293             }
294              
295 28         68 \@obj;
296             }
297              
298             sub _definitions_object {
299 3     3   21 my $routes = shift;
300 3         6 my @objects;
301              
302 3         10 for my $r (@$routes) {
303 27 100       141 if ($r->entity) {
304 1         8 push @objects, $r->entity;
305             }
306              
307 27         102 my @pp = @{ $r->params };
  27         59  
308 27         124 while (my $p = pop @pp) {
309 41 100       157 next unless _type_name($p->type) =~ /^HashRef$/;
310 2 50       21 push @pp, @{ $p->enclosed || [] };
  2         6  
311 2         12 push @objects, $p;
312             }
313             }
314              
315 3         23 my %definitions = map { %{ _schema_object($_) } }
  4         6  
  4         9  
316             _collect_nested_definitions(@objects);
317 3         16 \%definitions;
318             }
319              
320             sub _collect_nested_definitions {
321 5     5   13 my @objects = @_;
322 5 100       29 return () unless scalar @objects;
323              
324 2         3 my @nested;
325 2         5 for my $obj (@objects) {
326 4 50       25 if( $obj->can('enclosed') ) {
327 4 50       6 for my $expose ( @{ $obj->enclosed || [] } ) {
  4         11  
328 9 100       49 if (exists $expose->{'using'} ){
329 1         3 push @nested, $expose->{using};
330             }
331             }
332             }
333             }
334 2         8 push @objects, _collect_nested_definitions(@nested);
335              
336 2         5 return @objects;
337             }
338              
339              
340             sub _schema_object {
341 6     6   5309 my $p = shift;
342 6 50       29 return unless _type_name($p->type) =~ /^HashRef$/;
343              
344 6         58 my (@required, %properties);
345 6 50       8 for my $pp (@{ $p->enclosed || [] }) {
  6         16  
346 14         69 $properties{ _type_name($pp) } = _param_type_object($pp);
347              
348 14 100       79 push @required, _type_name($pp) if $pp->required;
349             }
350              
351 6         39 my %object = (
352             _name_for_object($p) => {
353             type => 'object',
354             required => \@required,
355             properties => \%properties,
356             }
357             );
358 6         32 \%object;
359             }
360              
361             sub _tags_object {
362 2     2   6 my $app = shift;
363              
364 2         54 my %tags;
365 2         5 for my $r (@{ $app->routes->routes }) {
  2         38  
366 26 50       63 next unless $_;
367 0         0 $tags{ $_ }++ for @{ $r->tags };
  0         0  
368             }
369              
370 2         5 my @tags;
371 2         6 for my $t (keys %tags) {
372 0   0     0 my $tag = {
373             name => $t,
374             description => $app->resource_desc($t) || '',
375             #externalDocs => {
376             # description => '',
377             # url => '', #R
378             #},
379             };
380 0         0 push @tags, $tag;
381             }
382              
383 2         6 \@tags;
384             }
385              
386             # get the name of a type
387             sub _type_name {
388 342     342   1030 my $type = shift;
389 342 50 33     787 if ($type && $type->can('display_name')) {
    0 0        
390 342         3848 return $type->display_name;
391             }
392             elsif ($type && $type->can('name')) {
393             # fall back to name() (e.g. Moose types do not have display_name)
394 0         0 return $type->name;
395             }
396             else {
397 0         0 return "$type";
398             }
399             }
400              
401             sub _param_type_object {
402 57     57   80 my $p = shift;
403 57         80 my %item;
404              
405 57         105 my $tt = $p->type;
406              
407              
408 57 100       209 if (_type_name($tt) =~ /^Maybe\[/) {
409 2         17 $item{nullable} = JSON::MaybeXS::true;
410 2         11 $tt = $tt->type_parameter;
411             }
412              
413 57 100       299 if (_type_name($tt) =~ /^HashRef$/ ) {
    50          
    100          
414 3 50       26 $item{'$ref'} = sprintf('#/definitions/%s', _name_for_object($p->can('using')?$p->using:$p));
415             }
416             elsif (_type_name($tt) =~ /^HashRef\[.*\]$/) {
417 0         0 $item{'type'} = 'object';
418 0         0 $item{'additionalProperties'} = {
419             '$ref' => sprintf('#/definitions/%s', _name_for_object($p->using))
420             };
421             }
422             elsif (_type_name($tt) =~ /^ArrayRef/) {
423 3         20 $item{type} = 'array';
424              
425 3         8 my $type;
426             my $format;
427              
428             # Loop down to find type beneath coercion.
429 3         8 while (!defined $type) {
430 4 50       12 if($tt->can('type_parameter')) {
431 4         40 ($type, $format) = _param_type($tt->type_parameter);
432             }
433             else {
434 0         0 ($type, $format) = ('object', '' );
435             }
436 4 100       14 $tt = $tt->parent if !defined $type;
437             }
438              
439 3 100       8 if ($type eq 'object') {
440 2         6 $item{items} = {}; # {} is the "any-type" schema.
441 2 100 66     18 if ($p->can('using') && $p->using) {
    50          
442 1         8 $item{items}{'$ref'} = sprintf('#/definitions/%s', _name_for_object($p->using));
443             }
444             elsif ($tt->can("type_parameter")) {
445 1         12 my ($subscript_type, $subscript_format) = _param_type($tt->type_parameter);
446 1 50       5 if (defined $subscript_type) {
447 1         3 $item{items}->{type} = $subscript_type;
448 1 50       8 $item{items}->{format} = $subscript_format if defined $subscript_format;
449             }
450             }
451             }
452             else {
453 1         3 $item{items}->{type} = $type;
454 1 50       3 $item{items}->{format} = $format if $format;
455 1 50       3 $item{description} = $p->desc if $p->desc;
456             }
457             }
458             else {
459 51         234 my ($type, $format) = _param_type($tt);
460 51         150 $item{type} = $type;
461 51 100       104 $item{format} = $format if $format;
462 51 100       124 $item{description} = $p->desc if $p->desc;
463             }
464 57         386 \%item;
465             }
466              
467             sub _param_type {
468 99     99   268 my $t = shift;
469 99 100 66     319 if ($t && $t->can('name')) { # allow nested types as Str in ArrayRef[Str]
470 98 100       1352 if ($t->name =~ /int/i) { 'integer', 'int32' }
  34 50       222  
    50          
    50          
    100          
    50          
    50          
    50          
    50          
    50          
    100          
471 0         0 elsif ($t->name =~ /long/i) { 'integer', 'int64' }
472 0         0 elsif ($t->name =~ /num|float|real/i) { 'number', 'float' }
473 0         0 elsif ($t->name =~ /double/i) { 'number', 'double' }
474 54         1054 elsif ($t->name =~ /str/i) { 'string', undef }
475 0         0 elsif ($t->name =~ /byte/i) { 'string', 'byte' }
476 0         0 elsif ($t->name =~ /bool/i) { 'boolean', undef }
477 0         0 elsif ($t->name =~ /datetime/i) { 'string', 'date-time' }
478 0         0 elsif ($t->name =~ /date/i) { 'string', 'date' }
479 0         0 elsif ($t->name =~ /password/i) { 'string', 'password' }
480 4         140 elsif ($t->name =~ /hashref/i) { 'object', undef }
481             else {
482 6 100       217 if (_type_name($t) =~ /ArrayRef/) { 'array', undef }
  2         18  
483 4         33 else { 'object', undef } # fallback
484             }
485             }
486             else {
487 1         2 { $t, undef }
  1         3  
488             }
489             }
490              
491             sub _name_for_object {
492 12     12   37 my $obj = shift;
493              
494 12         19 local $Data::Dumper::Deparse = 1;
495 12         17 local $Data::Dumper::Indent = 0;
496 12         21 local $Data::Dumper::Maxdepth = 2;
497 12         15 local $Data::Dumper::Purity = 0;
498 12         64 local $Data::Dumper::Sortkeys = 1;
499 12         16 local $Data::Dumper::Terse = 1;
500              
501 12         88 my $fingerprint = md5_hex(Data::Dumper->Dump([$obj], [qw/obj/]));
502 12         1391 my $objname = ucfirst($obj->name);
503             #--- $ref values must be RFC3986 compliant URIs ---
504 12         66 $objname =~ s/::/-/g;
505 12         115 sprintf '%s-%s', $objname, uc(substr($fingerprint, 0, 10));
506             }
507              
508             sub _type_is_enum {
509 43     43   161 my $type = shift;
510              
511 43 100 66     89 return 1 if $type->isa('Moose::Meta::TypeConstraint::Enum')
512             or $type->isa('Type::Tiny::Enum');
513              
514 42         840 return 0;
515             }
516              
517             1;
518              
519             __END__