File Coverage

blib/lib/Dancer/Plugin/RPC/RESTISH.pm
Criterion Covered Total %
statement 172 172 100.0
branch 48 50 96.0
condition 40 49 81.6
subroutine 20 20 100.0
pod 2 2 100.0
total 282 293 96.2


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