File Coverage

blib/lib/Dancer2/Plugin/RPC/RESTISH.pm
Criterion Covered Total %
statement 160 162 98.7
branch 46 50 92.0
condition 44 55 80.0
subroutine 14 14 100.0
pod 1 1 100.0
total 265 282 93.9


line stmt bran cond sub pod time code
1             use Moo;
2 3     3   2810010 use Dancer2::Plugin ;#qw( plugin_keywords );
  3         24  
  3         22  
3 3     3   2331  
  3         35566  
  3         27  
4             with 'Dancer2::RPCPlugin';
5              
6             our $VERSION = '2.02';
7             use constant PLUGIN_NAME => 'restish';
8 3     3   9038  
  3         9  
  3         281  
9             has allow_origin => (
10             is => 'rw',
11             default => sub { {} },
12             );
13              
14             use Dancer2::RPCPlugin::CallbackResultFactory;
15 3     3   1538 use Dancer2::RPCPlugin::ErrorResponse;
  3         125276  
  3         185  
16 3     3   1385 use Dancer2::RPCPlugin::FlattenData;
  3         85069  
  3         210  
17 3     3   1377 use Dancer2::RPCPlugin::PluginNames;
  3         904  
  3         175  
18 3     3   21  
  3         6  
  3         272  
19             Dancer2::RPCPlugin::PluginNames->new->add_names(PLUGIN_NAME);
20             Dancer2::RPCPlugin::ErrorResponse->register_error_responses(
21             PLUGIN_NAME ,=> {
22             -32500 => 500,
23             -32601 => 403,
24             default => 400,
25             },
26             sprintf("as_%s_error", PLUGIN_NAME) => sub {
27             my $self = shift;
28 8     8   22  
29             return {
30             error_code => $self->error_code,
31 8         81 error_message => $self->error_message,
32             error_data => $self->error_data,
33             };
34             }
35             );
36              
37             use JSON;
38 3     3   1718 use Scalar::Util 'blessed';
  3         21805  
  3         17  
39 3     3   480 use Time::HiRes 'time';
  3         4  
  3         120  
40 3     3   23  
  3         7  
  3         21  
41             # A char between the HTTP-Method and the REST-route
42             our $_HM_POSTFIX = '@';
43              
44             plugin_keywords PLUGIN_NAME;
45              
46             my ($plugin, $endpoint, $arguments) = @_;
47             my $restish_args = $arguments->{plugin_args} || {};
48 16     16 1 87295  
49 16   100     117 $plugin->allow_origin->{$endpoint} = $restish_args->{cors_allow_origin} || '';
50              
51 16   100     136 my $dispatcher = $plugin->dispatch_builder(
52             $endpoint,
53             $arguments->{publish},
54             $arguments->{arguments},
55             plugin_setting(),
56             )->();
57 16         102  
58             my $lister = $plugin->partial_method_lister(
59             protocol => __PACKAGE__->rpcplugin_tag,
60             endpoint => $endpoint,
61             methods => [ sort keys %{ $dispatcher } ],
62             );
63 16         954282  
  16         297  
64             my $code_wrapper = $plugin->code_wrapper($arguments);
65             my $callback = $arguments->{callback};
66 16         1423641  
67 16         230862 $plugin->app->log(debug => "Starting restish-handler build: ", $lister);
68             my $handle_call = sub {
69 16         175 my ($dsl) = @_;
70             my ($pi) = grep { ref($_) eq __PACKAGE__ } @{ $dsl->plugins };
71 32     32   675067  
72 32         64 my $allow_origin = $pi->allow_origin->{$endpoint};
  32         281  
  32         448  
73             my @allowed_origins = split(" ", $allow_origin);
74 32         133  
75 32         139 # we'll only handle requests that have either a JSON body or no body
76             my $http_request = $dsl->app->request;
77             my ($ct) = split(/;\s*/, $http_request->content_type // "", 2);
78 32         120 $ct //= "";
79 32   100     190  
80 32   100     380 if ($http_request->body && ($ct ne 'application/json')) {
81             $dsl->pass();
82 32 100 100     105 }
83 1         39  
84             my $http_method = uc($http_request->method);
85             my $request_path = $http_request->path;
86 31         883  
87 31         183 ##### Cross Origin Resource Sharing(CORS)
88             my $has_origin_header = grep {
89             lc($_) eq 'origin'
90             } $http_request->headers->header_field_names;
91 31         188 my $has_origin = $http_request->header('Origin') || '';
  83         5333  
92             my $allowed_origin = ($allow_origin eq '*')
93 31   100     100 || grep { $_ eq $has_origin } @allowed_origins;
94              
95 31   66     1054 my $is_preflight = $has_origin_header && ($http_method eq 'OPTIONS');
96             $dsl->app->log(debug => "[RESTISH-CORS] Preflight from $has_origin")
97 31   100     94 if $is_preflight;
98 31 100       82  
99             # with CORS, we do not allow mismatches on Origin
100             if ($allow_origin && $has_origin && !$allowed_origin) {
101             $dsl->app->log(
102 31 100 66     421 debug => "[RESTISH-CORS] '$has_origin' not allowed ($allow_origin)"
      100        
103 1         4 );
104             $dsl->response->status(403);
105             $dsl->response->content_type('text/plain');
106 1         58 return "[CORS] $has_origin not allowed";
107 1         84 }
108 1         161  
109             # method_name should exist...
110             # we need to turn 'GET@some_resource/:id' into a regex that we can use
111             # to match this request so we know what thing to call...
112             (my $method_name = $request_path) =~ s{^$endpoint/}{};
113             my ($found_match, $found_method);
114 30         325 my @sorted_dispatch_keys = sort {
115 30         83 # reverse length of the regex we use to match
116             my ($am, $ar) = split(/\b$_HM_POSTFIX/, $a);
117             $ar =~ s{/:\w+}{/[^/]+};
118 30         123 my ($bm, $br) = split(/\b$_HM_POSTFIX/, $b);
  68         236  
119 68         141 $br =~ s{/:\w+}{/[^/]+};
120 68         196 length($br) <=> length($ar)
121 68         147 } keys %$dispatcher;
122 68         143  
123             my $preflight_method = $is_preflight
124             ? $http_request->header('Access-Control-Request-Method') // 'GET'
125 30 100 100     87 : undef;
126              
127             my $check_for_method;
128             for my $plugin_route (@sorted_dispatch_keys) {
129 30         196 my ($hm, $route) = split(/\b$_HM_POSTFIX/, $plugin_route, 2);
130 30         74 $hm = uc($hm);
131 59         233  
132 59         135 if ($allow_origin && $is_preflight) {
133             $check_for_method = $preflight_method;
134 59 100 100     151 }
135 14         15 else {
136             $check_for_method = $http_method;
137             }
138 45         75 next if $hm ne $check_for_method;
139              
140 59 100       119 (my $route_match = $route) =~ s{:\w+}{[^/]+}g;
141             $dsl->app->log(
142 35         89 debug => "[restish_find_route($check_for_method)]"
143 35         132 . " $method_name, $route ($route_match)"
144             );
145             if ($method_name =~ m{^$route_match$}) {
146             $found_match = $plugin_route;
147 35 100       2192 $found_method = $hm;
148 29         57 last;
149 29         51 }
150 29         95 }
151              
152             if (! $found_match) {
153             if ($allow_origin && $is_preflight) {
154 30 100       90 my $msg = "[CORS-preflight] failed for $preflight_method => $request_path";
155 1 50 33     8 $dsl->app->log(debug => $msg);
156 1         4 $dsl->response->status(200); # maybe 403?
157 1         3 $dsl->response->content_type('text/plain');
158 1         58 return $msg;
159 1         85 }
160 1         238 $dsl->app->log(
161             warning => "$http_method => $request_path ($method_name) not found, pass()"
162             );
163 0         0 $dsl->pass();
164             }
165 0         0 $dsl->app->log(
166             debug => "[restish_found_route($http_method)]"
167             . " $request_path ($method_name) ($found_match)"
168 29         88 );
169              
170             # Send the CORS 'Access-Control-Allow-Origin' header
171             if ($allow_origin && $has_origin_header) {
172             my $allow_now = $allow_origin eq '*' ? '*' : $has_origin;
173 29 100 66     1468 $dsl->response->header('Access-Control-Allow-Origin' => $allow_now);
174 9 50       29 }
175 9         121  
176             if ($is_preflight) { # Send more CORS headers and return.
177             $dsl->app->log(
178 29 100       1394 debug => "[CORS] preflight-request: $request_path ($method_name)"
179 4         12 );
180             $dsl->response->status(204);
181             $dsl->response->header(
182 4         242 'Access-Control-Allow-Headers',
183 4 50       331 $http_request->header('Access-Control-Request-Headers')
184             ) if $http_request->header('Access-Control-Request-Headers');
185              
186             $dsl->response->header('Access-Control-Allow-Methods' => $found_method);
187             return "";
188 4         198 }
189 4         294  
190             $dsl->response->content_type ('application/json');
191             my $method_args = $http_request->body
192 25         342 ? from_json($http_request->body)
193 25 100       6163 : { };
194             my $route_args = $http_request->params('route') // { };
195             my $query_args = $http_request->params('query') // { };
196 25   50     815  
197 25   50     420 # We'll merge method_args and route_args, where route_args win:
198             $method_args = {
199             %$method_args,
200 25         395 %$route_args,
201             %$query_args,
202             };
203             $dsl->app->log(
204             debug => "[handle_restish_request('$request_path' via '$found_match')] "
205 25         86 , $method_args
206             );
207              
208             my $start_request = time();
209             my $continue = eval {
210 25         1395 (my $match_re = $found_match) =~ s{:\w+}{[^/]+}g;
211 25         44 local $Dancer2::RPCPlugin::ROUTE_INFO = {
212 25         81 plugin => PLUGIN_NAME,
213 25         91 route_matched => $found_match,
214             matched_re => $match_re,
215             endpoint => $endpoint,
216             rpc_method => $method_name,
217             full_path => $http_request->path,
218             http_method => $http_method,
219             };
220             $callback
221             ? $callback->($http_request, $method_name, $method_args)
222 25 100       358 : callback_success();
223             };
224             my $error = $@;
225             my $response;
226 25         9780 if ($error) {
227 25         36 my $error_response = error_response(
228 25 100 66     305 error_code => -32500,
    100 66        
    100          
229 1         7 error_message => $error,
230             error_data => $method_args,
231             );
232             $dsl->response->status($error_response->return_status(PLUGIN_NAME));
233             $response = $error_response->as_restish_error;
234 1         62 }
235 1         14176 elsif ( !blessed($continue)
236             || !$continue->can('does')
237             || !$continue->does('Dancer2::RPCPlugin::CallbackResult'))
238             {
239             my $error_response = error_response(
240             error_code => -32603,
241 1         9 error_message => "Internal error: 'callback_result' wrong class "
242             . blessed($continue),
243             error_data => $method_args,
244             );
245             $dsl->response->status($error_response->return_status(PLUGIN_NAME));
246             $response = $error_response->as_restish_error;
247 1         66 }
248 1         14180 elsif (blessed($continue) && !$continue->success) {
249             my $error_response = error_response(
250             error_code => $continue->error_code,
251 2         78 error_message => $continue->error_message,
252             error_data => $method_args,
253             );
254             $dsl->response->status($error_response->return_status(PLUGIN_NAME));
255             $response = $error_response->as_restish_error;
256 2         1176 }
257 2         29469 else {
258             my $di = $dispatcher->{$found_match};
259             my $handler = $di->code;
260 21         559 my $package = $di->package;
261 21         75  
262 21         58 $response = eval {
263             $code_wrapper->($handler, $package, $method_name, $method_args);
264 21         36 };
265 21         76 my $error = $@;
266              
267 21         111883 $dsl->app->log(debug => "[handled_restish_response($method_name)] ", $response);
268             $dsl->app->log(
269 21         66 info => sprintf(
270 21         1193 "[RPC::RESTISH] request for '%s' took %.4fs",
271             $request_path, time() - $start_request
272             )
273             );
274              
275             if (my $error = $@) {
276             my $error_response = blessed($error) && $error->can('as_restish_error')
277 21 100       1301 ? $error
278 3 100 66     34 : error_response(
279             error_code => 500,
280             error_message => $error,
281             error_data => $method_args,
282             );
283             $dsl->response->status($error_response->return_status(PLUGIN_NAME));
284             $response = $error_response->as_restish_error;
285 3         183 }
286 3         45380 if (blessed($response) && $response->can('as_restish_error')) {
287             $dsl->response->status($response->return_status(PLUGIN_NAME));
288 21 100 100     132 $response = $response->as_restish_error;
    100          
289 1         16 }
290 1         14061 elsif (blessed($response)) {
291             $response = flatten_data($response);
292             }
293 1         7 $dsl->app->log(debug => "[handled_restish_response($request_path)] ", $response);
294             }
295 21         98 my $jsonise_options = {canonical => 1};
296             if ($dsl->config->{encoding} && $dsl->config->{encoding} =~ m{^utf-?8$}i) {
297 25         1139 $jsonise_options->{utf8} = 1;
298 25 100 66     376 }
299 13         423  
300             # non-refs will be send as-is
301             return ref($response)
302             ? to_json($response, $jsonise_options)
303 25 100       212 : $response;
304             };
305              
306 16         1524 $plugin->app->log(debug => "Setting routes (restish): $endpoint ", $lister);
307             # split the keys in $dispatcher so we can register methods for all
308 16         119 for my $dispatch_route (keys %$dispatcher) {
309             my ($hm, $route) = split(/$_HM_POSTFIX/, $dispatch_route, 2);
310 16         808 my $dancer_route = "$endpoint/$route";
311 25         85701 $plugin->app->log(debug => "[restish] registering `$hm $dancer_route`");
312 25         99 $plugin->app->add_route(
313 25         172 method => lc($hm),
314 25         1342 regexp => $dancer_route,
315             code => $handle_call,
316             );
317             $plugin->app->add_route(
318             method => 'options',
319 25 50       266770 regexp => $dancer_route,
320             code => $handle_call
321             ) if $plugin->allow_origin;
322             }
323              
324             };
325              
326             use namespace::autoclean;
327             1;
328 3     3   5834  
  3         8  
  3         37  
329             =head1 NAME
330              
331             Dancer::Plugin::RPC::RESTISH - Simple plugin to implement a restish interface.
332              
333             =head1 SYNOPSIS
334              
335             In the Controler-bit:
336              
337             use Dancer::Plugin::RPC::RESTISH;
338             restish '/endpoint' => {
339             publish => 'pod',
340             arguments => ['MyProject::Admin'],
341             plugin_args => {
342             cors_allow_origin => '*',
343             },
344             };
345              
346             and in the Model-bit (B<MyProject::Admin>):
347              
348             package MyProject::Admin;
349            
350             =for restish GET@ability/:id rpc_get_ability_details
351            
352             =cut
353            
354             sub rpc_get_ability_details {
355             my %args = @_; # contains: {"id": 42}
356             return {
357             # datastructure
358             };
359             }
360             1;
361              
362             =head1 DESCRIPTION
363              
364             RESTISH is an implementation of REST that lets you bind routes to code in the
365             style the rest of L<Dancer::Plugin::RPC> modules do. One must realise that this
366             basically binds REST-paths to RPC-methods (that's not ideal, but saves a lot of
367             code).
368              
369             B<This version only supports JSON as data serialisation>.
370              
371             =head2 restish '/base_path' => \%publisher_arguments
372              
373             See L<Dancer::Plugin::RPC>, L<Dancer::Plugin::RPC::JSONRPC>,
374             L<Dancer::Plugin::RPC::RESTRPC>, L<Dancer::Plugin::RPC::XMLRPC> for more
375             information about the C<%publisher_arguments>.
376              
377             =head2 Implement the routes for RESTISH
378              
379             The plugin registers Dancer-C<any> route-handlers for the C<base_path> +
380             C<method_path> and the route-handler looks for a data-handler that matches the path
381             and HTTP-method.
382              
383             Method-paths can contain colon-prefixed parameters native to Dancer. These
384             parameters will be merged with the content-parameters and the query-parameters
385             into a single hash which will be passed to the code as the parameters.
386              
387             Method-paths are prefixed by a HTTP-method followed by B<@>:
388              
389             =over
390              
391             =item publisher => 'config'
392              
393             plugins:
394             'RPC::RESTISH':
395             '/rest':
396             'MyProject::Admin':
397             'GET@resources': 'get_all_resourses'
398             'POST@resource': 'create_resource'
399             'GET@resource/:id': 'get_resource'
400             'PATCH@resource/:id': 'update_resource'
401             'DELETE@resource/:id': 'delete_resource'
402              
403             =item publisher => 'pod'
404              
405             =for restish GET@resources get_all_resources /rest
406             =for restish POST@resource create_resource /rest
407             =for restish GET@resource/:id get_resource /rest
408             =for restish PATCH@resource/:id update_resource /rest
409             =for restish DELETE@resource/:id delete_resource /rest
410              
411             The third argument (the base_path) is optional.
412              
413             =back
414              
415             The plugin for RESTISH also adds 2 fields to C<$Dancer2::RPCPlugin::ROUTE_INFO>:
416              
417             local $Dancer2::RPCPlugin::ROUTE_INFO = {
418             plugin => PLUGIN_NAME,
419             endpoint => $endpoint,
420             rpc_method => $method_name,
421             full_path => request->path,
422             http_method => $http_method,
423             # These two are added
424             route_matched => $found_match, # PATCH@resource/:id
425             matched_re => $match_re, # PATCH@resource/[^/]+
426             };
427              
428             =head2 CORS (Cross-Origin Resource Sharing)
429              
430             If one wants the service to be directly called from javascript in a browser, one
431             has to consider CORS as browsers enforce that. This means that the actual
432             request is preceded by what's called a I<preflight request> that uses the
433             HTTP-method B<OPTIONS> with a number of header-fields.
434              
435             =over
436              
437             =item Origin
438              
439             =item Access-Control-Request-Method
440              
441             =back
442              
443             The plugin supports considering these CORS requests, by special casing these
444             B<OPTIONS> requests and always sending the C<Access-Control-Allow-Origin> header
445             as set in the config options.
446              
447             =head3 cors_allow_origin => $list_of_urls | '*'
448              
449             If left out, no attempt to honour a CORS B<OPTIONS> request will be done and the
450             request will be passed.
451              
452             When set to a value, the B<OPTIONS> request will be executed, for any http-method in
453             the C<Access-Control-Request-Method> header. The response to the B<OPTIONS>
454             request will also contain every C<Access-Control-Allow-*> header that was
455             requested as C<Access-Control-Request-*> header.
456              
457             When set, all responses will contain the C<Access-Control-Allow-Origin>-header
458             with either C<*> if that was set, or the value of the actual C<Origin>-header
459             that was passed and equals one the preset values.
460              
461             =head1 INTERNAL
462              
463             =head2 Attributes
464              
465             =over
466              
467             =item B<allow_origin>
468              
469             Where do we allow Origin to be from.
470              
471             =back
472              
473             =head2 build_dispatcher_from_config
474              
475             Creates a (partial) dispatch table from data passed from the (YAML)-config file.
476              
477             =head2 build_dispatcher_from_pod
478              
479             Creates a (partial) dispatch table from data provided in POD.
480              
481             =begin pod-coverage
482              
483             =head2 PLUGIN_NAME
484              
485             The name for this plugin.
486              
487             =end pod-coverage
488              
489             =head1 LICENSE
490              
491             This library is free software; you can redistribute it and/or modify
492             it under the same terms as Perl itself.
493              
494             See:
495              
496             =over 4
497              
498             =item * L<http://www.perl.com/perl/misc/Artistic.html>
499              
500             =item * L<http://www.gnu.org/copyleft/gpl.html>
501              
502             =back
503              
504             This program is distributed in the hope that it will be useful,
505             but WITHOUT ANY WARRANTY; without even the implied warranty of
506             MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
507              
508             =head1 COPYRIGHT
509              
510             E<copy> MMXX - Abe Timmerman <abeltje@cpan.org>
511              
512             =cut