File Coverage

blib/lib/Dancer/Plugin/RPC/RESTISH.pm
Criterion Covered Total %
statement 176 176 100.0
branch 50 52 96.1
condition 40 49 81.6
subroutine 20 20 100.0
pod 2 2 100.0
total 288 299 96.3


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