File Coverage

blib/lib/Dancer/Plugin/RPC/RESTISH.pm
Criterion Covered Total %
statement 163 163 100.0
branch 46 48 95.8
condition 40 48 83.3
subroutine 20 20 100.0
pod 2 4 50.0
total 271 283 95.7


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