File Coverage

blib/lib/Mojolicious/Plugin/OpenAPI.pm
Criterion Covered Total %
statement 179 193 92.7
branch 81 104 77.8
condition 71 101 70.3
subroutine 18 19 94.7
pod 1 1 100.0
total 350 418 83.7


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::OpenAPI;
2 48     48   378436 use Mojo::Base 'Mojolicious::Plugin';
  48         110  
  48         318  
3              
4 48     48   30856 use JSON::Validator;
  48         1445632  
  48         341  
5 48     48   1691 use Mojo::JSON;
  48         107  
  48         1492  
6 48     48   246 use Mojo::Util;
  48         100  
  48         1257  
7 48     48   22900 use Mojolicious::Plugin::OpenAPI::Parameters;
  48         134  
  48         662  
8              
9 48   50 48   2051 use constant DEBUG => $ENV{MOJO_OPENAPI_DEBUG} || 0;
  48         100  
  48         145113  
10              
11             our $VERSION = '5.07';
12              
13             has route => sub {undef};
14             has validator => sub { JSON::Validator::Schema->new; };
15              
16             sub register {
17 61     61 1 52861 my ($self, $app, $config) = @_;
18              
19 61   66     441 $self->validator(JSON::Validator->new->schema($config->{url} || $config->{spec})->schema);
20 61 100       904290 $self->validator->coerce($config->{coerce}) if defined $config->{coerce};
21              
22 61 50 66     567 if (my $class = $config->{version_from_class} // ref $app) {
23 61 100       863 $self->validator->data->{info}{version} = sprintf '%s', $class->VERSION if $class->VERSION;
24             }
25              
26 61 100       907 my $errors = $config->{skip_validating_specification} ? [] : $self->validator->errors;
27 61 100       13662374 die @$errors if @$errors;
28              
29 59 100       843 unless ($app->defaults->{'openapi.base_paths'}) {
30 51         1294 $app->helper('openapi.spec' => \&_helper_get_spec);
31 51         21626 $app->helper('openapi.valid_input' => \&_helper_valid_input);
32 51         13807 $app->helper('openapi.validate' => \&_helper_validate);
33 51         13858 $app->helper('reply.openapi' => \&_helper_reply);
34 51         19750 $app->hook(before_render => \&_before_render);
35 51         1332 $app->renderer->add_handler(openapi => \&_render);
36             }
37              
38 59   50     1485 $self->{log_level} = $ENV{MOJO_OPENAPI_LOG_LEVEL} || $config->{log_level} || 'warn';
39 59         366 $self->_build_route($app, $config);
40              
41             # This plugin is required
42 59         1055 my @plugins = (Mojolicious::Plugin::OpenAPI::Parameters->new->register($app, $config));
43              
44 59 50       25662 for my $plugin (@{$config->{plugins} || [qw(+Cors +SpecRenderer +Security)]}) {
  59         604  
45 177 50       33693 $plugin = "Mojolicious::Plugin::OpenAPI::$plugin" if $plugin =~ s!^\+!!;
46 177 50       17860 eval "require $plugin;1" or Carp::confess("require $plugin: $@");
47 177         2638 push @plugins, $plugin->new->register($app, {%$config, openapi => $self});
48             }
49              
50 59 50       338 my %default_response = %{$config->{default_response} || {}};
  59         489  
51 59   100     621 $default_response{name} ||= $config->{default_response_name} || 'DefaultResponse';
      33        
52 59   100     619 $default_response{status} ||= $config->{default_response_codes} || [400, 401, 404, 500, 501];
      33        
53 59         177 $default_response{location} = 'definitions';
54 59 100       146 $self->validator->add_default_response(\%default_response) if @{$default_response{status}};
  59         493  
55              
56 59         84322 $self->_add_routes($app, $config);
57              
58 57         913 return $self;
59             }
60              
61             sub _add_routes {
62 59     59   223 my ($self, $app, $config) = @_;
63 59   100     372 my $op_spec_to_route = $config->{op_spec_to_route} || '_op_spec_to_route';
64 59         144 my (@routes, %uniq);
65              
66 59         257 for my $route ($self->validator->routes->each) {
67 128         17417 my $op_spec = $self->validator->get([paths => @$route{qw(path method)}]);
68 128   100     14594 my $name = $op_spec->{'x-mojo-name'} || $op_spec->{operationId};
69 128         224 my $r;
70              
71             die qq([OpenAPI] operationId "$op_spec->{operationId}" is not unique)
72 128 100 100     691 if $op_spec->{operationId} and $uniq{o}{$op_spec->{operationId}}++;
73 127 100 100     686 die qq([OpenAPI] Route name "$name" is not unique.) if $name and $uniq{r}{$name}++;
74              
75 126 100 100     529 if (!$op_spec->{'x-mojo-to'} and $name) {
76 108         329 $r = $self->route->root->find($name);
77 108         13542 warn "[OpenAPI] Found existing route by name '$name'.\n" if DEBUG and $r;
78 108 100       393 $self->route->add_child($r) if $r;
79             }
80 126 100       6442 if (!$r) {
81 26         95 my $http_method = $route->{method};
82 26         112 my $route_path = $self->_openapi_path_to_route_path(@$route{qw(method path)});
83 26   66     133 $name ||= $op_spec->{operationId};
84 26         39 warn "[OpenAPI] Creating new route for '$route_path'.\n" if DEBUG;
85 26         85 $r = $self->route->$http_method($route_path);
86 26 100       8042 $r->name("$self->{route_prefix}$name") if $name;
87             }
88              
89 126         606 $r->to(format => undef, 'openapi.method' => $route->{method}, 'openapi.path' => $route->{path});
90 126         3897 $self->$op_spec_to_route($op_spec, $r, $config);
91 126         292 warn "[OpenAPI] Add route $route->{method} @{[$r->to_string]} (@{[$r->name // '']})\n" if DEBUG;
92              
93 126         322 push @routes, $r;
94             }
95              
96 57         452 $app->plugins->emit_hook(openapi_routes_added => $self, \@routes);
97             }
98              
99             sub _before_render {
100 381     381   486338 my ($c, $args) = @_;
101 381 100       974 return unless _self($c);
102 377   100     1884 my $handler = $args->{handler} || 'openapi';
103              
104             # Call _render() for response data
105 377 100 66     1701 return if $handler eq 'openapi' and exists $c->stash->{openapi} or exists $args->{openapi};
      66        
106              
107             # Fallback to default handler for things like render_to_string()
108 227 100       2556 return $args->{handler} = $c->app->renderer->default_handler unless exists $args->{handler};
109              
110             # Call _render() for errors
111 13   100     61 my $status = $args->{status} || $c->stash('status') || '200';
112 13 50 66     126 if ($handler eq 'openapi' and ($status eq '404' or $status eq '500')) {
      66        
113 9         20 $args->{handler} = 'openapi';
114 9         20 $args->{status} = $status;
115             $c->stash(
116             status => $args->{status},
117             openapi => {
118             errors => [{message => $c->res->default_message($args->{status}) . '.', path => '/'}],
119             status => $args->{status},
120             }
121 9         37 );
122             }
123             }
124              
125             sub _build_route {
126 59     59   194 my ($self, $app, $config) = @_;
127 59         273 my $validator = $self->validator;
128 59         489 my $base_path = $validator->base_url->path->to_string;
129 59         14375 my $route = $config->{route};
130              
131 59 50 66     340 $route = $route->any($base_path) if $route and !$route->pattern->unparsed;
132 59 100       472 $route = $app->routes->any($base_path) unless $route;
133 59         21350 $base_path = $route->to_string;
134 59         1862 $base_path =~ s!/$!!;
135              
136 59         131 push @{$app->defaults->{'openapi.base_paths'}}, [$base_path, $self];
  59         271  
137 59         976 $route->to({format => undef, handler => 'openapi', 'openapi.object' => $self});
138 59         1680 $validator->base_url($base_path);
139              
140 59 100 100     25330 if (my $spec_route_name = $config->{spec_route_name} || $validator->get('/x-mojo-name')) {
141 4         304 $self->{route_prefix} = "$spec_route_name.";
142             }
143              
144 59   100     5621 $self->{route_prefix} //= '';
145 59         293 $self->route($route);
146             }
147              
148             sub _helper_get_spec {
149 37     37   63144 my $c = shift;
150 37   100     180 my $path = shift // 'for_current';
151 37         142 my $self = _self($c);
152              
153             # Get spec by valid JSON pointer
154 37 100 66     305 return $self->validator->get($path) if ref $path or $path =~ m!^/! or !length $path;
      66        
155              
156             # Find spec by current request
157 36         73 my ($stash) = grep { $_->{'openapi.path'} } reverse @{$c->match->stack};
  67         352  
  36         88  
158 36 100       182 return undef unless $stash;
159              
160 34         104 my $jp = [paths => $stash->{'openapi.path'}];
161 34 100       128 push @$jp, $stash->{'openapi.method'} if $path ne 'for_path'; # Internal for now
162 34         131 return $self->validator->get($jp);
163             }
164              
165             sub _helper_reply {
166 0     0   0 my $c = shift;
167 0 0       0 my $status = ref $_[0] ? 200 : shift;
168 0         0 my $output = shift;
169 0         0 my @args = @_;
170              
171 0         0 Mojo::Util::deprecated(
172             '$c->reply->openapi() is DEPRECATED in favor of $c->render(openapi => ...)');
173              
174 0 0       0 if (UNIVERSAL::isa($output, 'Mojo::Asset')) {
175 0         0 my $h = $c->res->headers;
176 0 0 0     0 if (!$h->content_type and $output->isa('Mojo::Asset::File')) {
177 0         0 my $types = $c->app->types;
178 0 0       0 my $type = $output->path =~ /\.(\w+)$/ ? $types->type($1) : undef;
179 0   0     0 $h->content_type($type || $types->type('bin'));
180             }
181 0         0 return $c->reply->asset($output);
182             }
183              
184 0 0       0 push @args, status => $status if $status;
185 0         0 return $c->render(@args, openapi => $output);
186             }
187              
188             sub _helper_valid_input {
189 178     178   1513919 my $c = shift;
190 178 100       651 return undef if $c->res->code;
191 172 100       2353 return $c unless my @errors = _helper_validate($c);
192 31         331 $c->stash(status => 400)
193             ->render(data => $c->openapi->build_response_body({errors => \@errors, status => 400}));
194 31         10666 return undef;
195             }
196              
197             sub _helper_validate {
198 174     174   22740 my $c = shift;
199 174         512 my $self = _self($c);
200 174         606 my @errors = $self->validator->validate_request([@{$c->stash}{qw(openapi.method openapi.path)}],
  174         885  
201             $c->openapi->build_schema_request);
202             $c->openapi->coerce_request_parameters(
203 174         51908 delete $c->stash->{'openapi.evaluated_request_parameters'});
204 174         1421 return @errors;
205             }
206              
207             sub _log {
208 29     29   75 my ($self, $c, $dir) = (shift, shift, shift);
209 29         66 my $log_level = $self->{log_level};
210              
211 29         105 $c->app->log->$log_level(
212             sprintf 'OpenAPI %s %s %s %s',
213             $dir, $c->req->method,
214             $c->req->url->path,
215             Mojo::JSON::encode_json(@_)
216             );
217             }
218              
219             sub _op_spec_to_route {
220 124     124   316 my ($self, $op_spec, $r, $config) = @_;
221 124   100     583 my $op_to = $op_spec->{'x-mojo-to'} // [];
222             my @args
223 124 50       496 = ref $op_to eq 'ARRAY' ? @$op_to : ref $op_to eq 'HASH' ? %$op_to : $op_to ? ($op_to) : ();
    50          
    100          
224              
225             # x-mojo-to: controller#action
226 124 100 100     492 $r->to(shift @args) if @args and $args[0] =~ m!#!;
227              
228 124         894 my ($constraints, @to) = ($r->pattern->constraints);
229 124 50 0     1017 $constraints->{format} //= $config->{format} if $config->{format};
230 124         416 while (my $arg = shift @args) {
231 7 100 33     55 if (ref $arg eq 'ARRAY') { %$constraints = (%$constraints, @$arg) }
  1 100       5  
    50          
232 1         15 elsif (ref $arg eq 'HASH') { push @to, %$arg }
233 5         17 elsif (!ref $arg and @args) { push @to, $arg, shift @args }
234             }
235              
236 124 100       377 $r->to(@to) if @to;
237             }
238              
239             sub _render {
240 159     159   16548 my ($renderer, $c, $output, $args) = @_;
241 159         424 my $stash = $c->stash;
242 159 50       1205 return unless exists $stash->{openapi};
243 159 50       363 return unless my $self = _self($c);
244              
245 159   100     1039 my $status = $args->{status} || $stash->{status} || 200;
246 159         548 my $method_path_status = [@$stash{qw(openapi.method openapi.path)}, $status];
247 159   100     746 my $op_spec
248             = $method_path_status->[0] && $self->validator->parameters_for_response($method_path_status);
249 159         28217 my @errors;
250              
251 159         329 delete $args->{encoding};
252 159         336 $args->{status} = $status;
253 159   100     777 $stash->{format} ||= 'json';
254              
255 159 100 66     544 if ($op_spec) {
    100          
256 137         421 @errors = $self->validator->validate_response($method_path_status,
257             $c->openapi->build_schema_response);
258             $c->openapi->coerce_response_parameters(
259 137         50173 delete $stash->{'openapi.evaluated_response_parameters'});
260 137 100       552 $args->{status} = $errors[0]->path eq '/header/Accept' ? 400 : 500 if @errors;
    100          
261             }
262             elsif (ref $stash->{openapi} eq 'HASH' and ref $stash->{openapi}{errors} eq 'ARRAY') {
263 20   33     93 $args->{status} ||= $stash->{openapi}{status};
264 20         31 @errors = @{$stash->{openapi}{errors}};
  20         67  
265             }
266             else {
267 2         3 $args->{status} = 501;
268 2         9 @errors = ({message => qq(No response rule for "$status".)});
269             }
270              
271 159 100       532 $self->_log($c, '>>>', \@errors) if @errors;
272 159         6637 $stash->{status} = $args->{status};
273             $$output = $c->openapi->build_response_body(
274 159 100       484 @errors ? {errors => \@errors, status => $args->{status}} : $stash->{openapi});
275             }
276              
277             sub _openapi_path_to_route_path {
278 26     26   73 my ($self, $http_method, $path) = @_;
279 4         26 my %params = map { ($_->{name}, $_) }
280 26         69 grep { $_->{in} eq 'path' } @{$self->validator->parameters_for_request([$http_method, $path])};
  23         8018  
  26         73  
281              
282 26         7775 $path =~ s/{([^}]+)}/{
283 4         9 my $name = $1;
  4         15  
284 4   100     33 my $type = $params{$name}{'x-mojo-placeholder'} || ':';
285 4         21 "<$type$name>";
286             }/ge;
287              
288 26         73 return $path;
289             }
290              
291             sub _self {
292 792     792   1305 my $c = shift;
293 792         1920 my $self = $c->stash('openapi.object');
294 792 100       8069 return $self if $self;
295 15         51 my $path = $c->req->url->path->to_string;
296 15         1022 return +(map { $_->[1] } grep { $path =~ /^$_->[0]/ } @{$c->stash('openapi.base_paths')})[0];
  11         49  
  24         374  
  15         44  
297             }
298              
299             1;
300              
301             =encoding utf8
302              
303             =head1 NAME
304              
305             Mojolicious::Plugin::OpenAPI - OpenAPI / Swagger plugin for Mojolicious
306              
307             =head1 SYNOPSIS
308              
309             # It is recommended to use Mojolicious::Plugin::OpenAPI with a "full app".
310             # See the links after this example for more information.
311             use Mojolicious::Lite;
312              
313             # Because the route name "echo" matches the "x-mojo-name", this route
314             # will be moved under "basePath", resulting in "POST /api/echo"
315             post "/echo" => sub {
316              
317             # Validate input request or return an error document
318             my $c = shift->openapi->valid_input or return;
319              
320             # Generate some data
321             my $data = {body => $c->req->json};
322              
323             # Validate the output response and render it to the user agent
324             # using a custom "openapi" handler.
325             $c->render(openapi => $data);
326             }, "echo";
327              
328             # Load specification and start web server
329             plugin OpenAPI => {url => "data:///swagger.yaml"};
330             app->start;
331              
332             __DATA__
333             @@ swagger.yaml
334             swagger: "2.0"
335             info: { version: "0.8", title: "Echo Service" }
336             schemes: ["https"]
337             basePath: "/api"
338             paths:
339             /echo:
340             post:
341             x-mojo-name: "echo"
342             parameters:
343             - { in: "body", name: "body", schema: { type: "object" } }
344             responses:
345             200:
346             description: "Echo response"
347             schema: { type: "object" }
348              
349             See L or
350             L for more in depth
351             information about how to use L with a "full app".
352             Even with a "lite app" it can be very useful to read those guides.
353              
354             Looking at the documentation for
355             L can be especially
356             useful. (The logic is the same for OpenAPIv2 and OpenAPIv3)
357              
358             =head1 DESCRIPTION
359              
360             L is L that add routes and
361             input/output validation to your L application based on a OpenAPI
362             (Swagger) specification. This plugin supports both version L<2.0|/schema> and
363             L<3.x|/schema>, though 3.x I have some missing features.
364              
365             Have a look at the L for references to plugins and other useful
366             documentation.
367              
368             Please report in L
369             or open pull requests to enhance the 3.0 support.
370              
371             =head1 HELPERS
372              
373             =head2 openapi.spec
374              
375             $hash = $c->openapi->spec($json_pointer)
376             $hash = $c->openapi->spec("/info/title")
377             $hash = $c->openapi->spec;
378              
379             Returns the OpenAPI specification. A JSON Pointer can be used to extract a
380             given section of the specification. The default value of C<$json_pointer> will
381             be relative to the current operation. Example:
382              
383             {
384             "paths": {
385             "/pets": {
386             "get": {
387             // This datastructure is returned by default
388             }
389             }
390             }
391             }
392              
393             =head2 openapi.validate
394              
395             @errors = $c->openapi->validate;
396              
397             Used to validate a request. C<@errors> holds a list of
398             L objects or empty list on valid input.
399              
400             Note that this helper is only for customization. You probably want
401             L in most cases.
402              
403             =head2 openapi.valid_input
404              
405             $c = $c->openapi->valid_input;
406              
407             Returns the L object if the input is valid or
408             automatically render an error document if not and return false. See
409             L for example usage.
410              
411             =head1 HOOKS
412              
413             L will emit the following hooks on the
414             L object.
415              
416             =head2 openapi_routes_added
417              
418             Emitted after all routes have been added by this plugin.
419              
420             $app->hook(openapi_routes_added => sub {
421             my ($openapi, $routes) = @_;
422              
423             for my $route (@$routes) {
424             ...
425             }
426             });
427              
428             This hook is EXPERIMENTAL and subject for change.
429              
430             =head1 RENDERER
431              
432             This plugin register a new handler called C. The special thing about
433             this handler is that it will validate the data before sending it back to the
434             user agent. Examples:
435              
436             $c->render(json => {foo => 123}); # without validation
437             $c->render(openapi => {foo => 123}); # with validation
438              
439             This handler will also use L to format the output data. The code
440             below shows the default L which generates JSON data:
441              
442             $app->plugin(
443             OpenAPI => {
444             renderer => sub {
445             my ($c, $data) = @_;
446             return Mojo::JSON::encode_json($data);
447             }
448             }
449             );
450              
451             =head1 ATTRIBUTES
452              
453             =head2 route
454              
455             $route = $openapi->route;
456              
457             The parent L object for all the OpenAPI endpoints.
458              
459             =head2 validator
460              
461             $jv = $openapi->validator;
462              
463             Holds either a L or a
464             L object.
465              
466             =head1 METHODS
467              
468             =head2 register
469              
470             $openapi = $openapi->register($app, \%config);
471             $openapi = $app->plugin(OpenAPI => \%config);
472              
473             Loads the OpenAPI specification, validates it and add routes to
474             L<$app|Mojolicious>. It will also set up L and adds a
475             L hook for auto-rendering of error
476             documents. The return value is the object instance, which allow you to access
477             the L after you load the plugin.
478              
479             C<%config> can have:
480              
481             =head3 coerce
482              
483             See L for possible values that C can take.
484              
485             Default: booleans,numbers,strings
486              
487             The default value will include "defaults" in the future, once that is stable enough.
488              
489             =head3 default_response
490              
491             Instructions for
492             L. (Also used
493             for OpenAPIv3)
494              
495             =head3 format
496              
497             Set this to a default list of file extensions that your API accepts. This value
498             can be overwritten by
499             L.
500              
501             This config parameter is EXPERIMENTAL and subject for change.
502              
503             =head3 log_level
504              
505             C is used when logging invalid request/response error messages.
506              
507             Default: "warn".
508              
509             =head3 op_spec_to_route
510              
511             C can be provided if you want to add route definitions
512             without using "x-mojo-to". Example:
513              
514             $app->plugin(OpenAPI => {op_spec_to_route => sub {
515             my ($plugin, $op_spec, $route) = @_;
516              
517             # Here are two ways to customize where to dispatch the request
518             $route->to(cb => sub { shift->render(openapi => ...) });
519             $route->to(ucfirst "$op_spec->{operationId}#handle_request");
520             }});
521              
522             This feature is EXPERIMENTAL and might be altered and/or removed.
523              
524             =head3 plugins
525              
526             A list of OpenAPI classes to extend the functionality. Default is:
527             L,
528             L and
529             L.
530              
531             $app->plugin(OpenAPI => {plugins => [qw(+Cors +SpecRenderer +Security)]});
532              
533             You can load your own plugins by doing:
534              
535             $app->plugin(OpenAPI => {plugins => [qw(+SpecRenderer My::Cool::OpenAPI::Plugin)]});
536              
537             =head3 renderer
538              
539             See L.
540              
541             =head3 route
542              
543             C can be specified in case you want to have a protected API. Example:
544              
545             $app->plugin(OpenAPI => {
546             route => $app->routes->under("/api")->to("user#auth"),
547             url => $app->home->rel_file("cool.api"),
548             });
549              
550             =head3 skip_validating_specification
551              
552             Used to prevent calling L for the
553             specification.
554              
555             =head3 spec_route_name
556              
557             Name of the route that handles the "basePath" part of the specification and
558             serves the specification. Defaults to "x-mojo-name" in the specification at
559             the top level.
560              
561             =head3 spec, url
562              
563             See L for the different C formats that is
564             accepted.
565              
566             C is an alias for "url", which might make more sense if your
567             specification is written in perl, instead of JSON or YAML.
568              
569             Here are some common uses:
570              
571             $app->plugin(OpenAPI => {url => $app->home->rel_file('openapi.yaml'));
572             $app->plugin(OpenAPI => {url => 'https://example.com/swagger.json'});
573             $app->plugin(OpenAPI => {spec => JSON::Validator::Schema::OpenAPIv3->new(...)});
574             $app->plugin(OpenAPI => {spec => {swagger => "2.0", paths => {...}, ...}});
575              
576             =head3 version_from_class
577              
578             Can be used to overridden C in the API specification, from the
579             return value from the C method in C.
580              
581             Defaults to the current C<$app>. This can be disabled by setting the
582             "version_from_class" to zero (0).
583              
584             =head1 AUTHORS
585              
586             Henrik Andersen
587              
588             Ilya Rassadin
589              
590             Jan Henning Thorsen
591              
592             Joel Berger
593              
594             =head1 COPYRIGHT AND LICENSE
595              
596             Copyright (C) Jan Henning Thorsen
597              
598             This program is free software, you can redistribute it and/or modify it under
599             the terms of the Artistic License version 2.0.
600              
601             =head1 SEE ALSO
602              
603             =over 2
604              
605             =item * L
606              
607             Guide for how to use this plugin with OpenAPI version 2.0 spec.
608              
609             =item * L
610              
611             Guide for how to use this plugin with OpenAPI version 3.0 spec.
612              
613             =item * L
614              
615             Plugin to add Cross-Origin Resource Sharing (CORS).
616              
617             =item * L
618              
619             Plugin for handling security definitions in your schema.
620              
621             =item * L
622              
623             Plugin for exposing your spec in human readable or JSON format.
624              
625             =item * L
626              
627             Official OpenAPI website.
628              
629             =back
630              
631             =cut