Dancer-Plugin-RPC-RESTISH
view release on metacpan or search on metacpan
lib/Dancer/Plugin/RPC/RESTISH.pm view on Meta::CPAN
package Dancer::Plugin::RPC::RESTISH;
use v5.10;
use Dancer ':syntax';
use Dancer::Plugin;
no if $] >= 5.018, warnings => 'experimental::smartmatch';
our $VERSION = '1.01';
use constant PLUGIN_NAME => 'restish';
use Dancer::RPCPlugin::CallbackResult;
use Dancer::RPCPlugin::DispatchFromConfig;
use Dancer::RPCPlugin::DispatchFromPod;
use Dancer::RPCPlugin::DispatchItem;
use Dancer::RPCPlugin::DispatchMethodList;
use Dancer::RPCPlugin::ErrorResponse;
use Dancer::RPCPlugin::FlattenData;
use Dancer::RPCPlugin::PluginNames;
Dancer::RPCPlugin::PluginNames->new->add_names(PLUGIN_NAME);
Dancer::RPCPlugin::ErrorResponse->register_error_responses(
PLUGIN_NAME ,=> {
-32500 => 500,
-32601 => 403,
default => 400,
},
sprintf("as_%s_error", PLUGIN_NAME) => sub {
my $self = shift;
return {
error_code => $self->error_code,
error_message => $self->error_message,
error_data => $self->error_data,
};
}
);
use Scalar::Util 'blessed';
# A char between the HTTP-Method and the REST-route
our $_HM_POSTFIX = '@';
my %dispatch_builder_map = (
pod => \&build_dispatcher_from_pod,
config => \&build_dispatcher_from_config,
);
register PLUGIN_NAME ,=> sub {
my ($self, $endpoint, $arguments) = plugin_args(@_);
my $allow_origin = $arguments->{cors_allow_origin} || '';
my @allowed_origins = split(' ', $allow_origin);
my $publisher;
given ($arguments->{publish} // 'config') {
when (exists $dispatch_builder_map{$_}) {
$publisher = $dispatch_builder_map{$_};
$arguments->{arguments} = plugin_setting() if $_ eq 'config';
}
default {
$publisher = $_;
}
}
my $dispatcher = $publisher->($arguments->{arguments}, $endpoint);
my $lister = Dancer::RPCPlugin::DispatchMethodList->new();
$lister->set_partial(
protocol => PLUGIN_NAME,
endpoint => $endpoint,
methods => [ sort keys %{ $dispatcher } ],
);
my $code_wrapper = $arguments->{code_wrapper}
? $arguments->{code_wrapper}
: sub {
my $code = shift;
my $pkg = shift;
$code->(@_);
};
my $callback = $arguments->{callback};
debug("Starting restish-handler build: ", $lister);
my $handle_call = sub {
# we'll only handle requests that have either a JSON body or no body
my ($ct) = (split /;\s*/, request->content_type, 2);
if (request->body && ($ct ne 'application/json')) {
pass();
}
my $http_method = uc(request->method);
my $request_path = request->path;
my $has_origin = request->header('Origin') || '';
my $allowed_origin = ($allow_origin eq '*')
|| grep { $_ eq $has_origin } @allowed_origins;
my $is_preflight = $has_origin && ($http_method eq 'OPTIONS');
# with CORS, we do not allow mismatches on Origin
if ($allow_origin && $has_origin && !$allowed_origin) {
debug("[RESTISH-CORS] '$has_origin' not allowed ($allow_origin)");
status(403);
content_type('text/plain');
return "[CORS] $has_origin not allowed";
}
# method_name should exist...
# we need to turn 'GET@some_resource/:id' into a regex that we can use
# to match this request so we know what thing to call...
(my $method_name = $request_path) =~ s{^$endpoint/}{};
my ($found_match, $found_method);
my @sorted_dispatch_keys = sort {
# reverse length of the regex we use to match
my ($am, $ar) = split(/\b$_HM_POSTFIX/, $a);
$ar =~ s{/:\w+}{/[^/]+};
my ($bm, $br) = split(/\b$_HM_POSTFIX/, $b);
$br =~ s{/:\w+}{/[^/]+};
length($br) <=> length($ar)
} keys %$dispatcher;
my $preflight_method = $is_preflight
? request->header('Access-Control-Request-Method') // 'GET'
: undef;
my $check_for_method;
for my $plugin_route (@sorted_dispatch_keys) {
my ($hm, $route) = split(/\b$_HM_POSTFIX/, $plugin_route, 2);
$hm = uc($hm);
if ($allow_origin && $is_preflight) {
$check_for_method = $preflight_method;
}
else {
$check_for_method = $http_method;
}
next if $hm ne $check_for_method;
(my $route_match = $route) =~ s{:\w+}{[^/]+}g;
debug("[restish_find_route($check_for_method => $method_name, $route ($route_match)");
if ($method_name =~ m{^$route_match$}) {
$found_match = $plugin_route;
$found_method = $hm;
last;
}
}
if (! $found_match) {
if ($allow_origin && $is_preflight) {
my $msg = "[CORS-preflight] failed for $preflight_method => $request_path";
debug($msg);
status(200); # maybe 403?
content_type 'text/plain';
return $msg;
}
warning("$http_method => $request_path ($method_name) not found, pass()");
pass();
}
debug("[restish_found_route($http_method => $request_path ($method_name) ($found_match)");
# Send the CORS 'Access-Control-Allow-Origin' header
if ($allow_origin && $has_origin) {
my $allow_now = $allow_origin eq '*' ? '*' : $has_origin;
header('Access-Control-Allow-Origin' => $allow_now);
}
if ($is_preflight) { # Send more CORS headers and return.
debug("[CORS] preflight-request: $request_path ($method_name)");
status(200);
header(
'Access-Control-Allow-Headers',
request->header('Access-Control-Request-Headers')
) if request->header('Access-Control-Request-Headers');
header('Access-Control-Allow-Methods' => $found_method);
content_type 'text/plain';
return "";
}
content_type 'application/json';
my $method_args = request->body
? from_json(request->body)
: { };
my $route_args = request->params('route') // { };
my $query_args = request->params('query');
# We'll merge method_args and route_args, where route_args win:
$method_args = {
%$method_args,
%$route_args,
%$query_args,
};
debug("[handling_restish_request('$request_path' via '$found_match')] ", $method_args);
my Dancer::RPCPlugin::CallbackResult $continue = eval {
(my $match_re = $found_match) =~ s{:\w+}{[^/]+}g;
local $Dancer::RPCPlugin::ROUTE_INFO = {
plugin => PLUGIN_NAME,
route_matched => $found_match,
matched_re => $match_re,
endpoint => $endpoint,
rpc_method => $method_name,
full_path => request->path,
http_method => $http_method,
};
$callback
? $callback->(request(), $method_name, $method_args)
: callback_success();
};
my $error = $@;
my $response;
if ($error) {
my $error_response = error_response(
error_code => -32500,
error_message => $error,
error_data => $method_args,
);
status $error_response->return_status('restish');
$response = $error_response->as_restish_error;
}
elsif (!blessed($continue) || !$continue->isa('Dancer::RPCPlugin::CallbackResult')) {
my $error_response = error_response(
error_code => -32603,
error_message => "Internal error: 'callback_result' wrong class "
. blessed($continue),
error_data => $method_args,
);
status($error_response->return_status('restish'));
$response = $error_response->as_restish_error;
}
elsif (blessed($continue) && !$continue->success) {
my $error_response = error_response(
error_code => $continue->error_code,
error_message => $continue->error_message,
error_data => $method_args,
);
status $error_response->return_status('restish');
$response = $error_response->as_restish_error;
}
else {
my Dancer::RPCPlugin::DispatchItem $di = $dispatcher->{$found_match};
my $handler = $di->code;
my $package = $di->package;
$response = eval {
$code_wrapper->($handler, $package, $method_name, $method_args);
};
if (my $error = $@) {
my $error_response = blessed($error) && $error->can('as_restish_error')
? $error
: error_response(
error_code => 500,
error_message => $error,
error_data => $method_args,
);
status($error_response->return_status('restish'));
$response = $error_response->as_restish_error;
}
if (blessed($response) && $response->can('as_restish_error')) {
status($response->return_status('restish'));
$response = $response->as_restish_error;
}
elsif (blessed($response)) {
$response = flatten_data($response);
}
debug("[handled_restish_response($request_path)] ", $response);
}
my $jsonise_options = {canonical => 1};
if (config->{encoding} && config->{encoding} =~ m{^utf-?8$}i) {
$jsonise_options->{utf8} = 1;
}
# non-refs will be send as-is
return ref($response)
? to_json($response, $jsonise_options)
: $response;
};
debug("setting routes (restish): $endpoint ", $lister);
# split the keys in $dispatcher so we can register 'any' methods for all
# the handler will know what to do...
for my $dispatch_route (keys %$dispatcher) {
my ($hm, $route) = split(/$_HM_POSTFIX/, $dispatch_route, 2);
my $dancer_route = "$endpoint/$route";
debug("[restish] registering `any $dancer_route` ($hm)");
any $dancer_route, $handle_call;
}
};
sub build_dispatcher_from_pod {
my ($pkgs, $endpoint) = @_;
debug("[build_dispatcher_from_pod]");
return dispatch_table_from_pod(
plugin => 'restish',
packages => $pkgs,
endpoint => $endpoint,
);
}
sub build_dispatcher_from_config {
my ($config, $endpoint) = @_;
debug("[build_dispatcher_from_config]");
return dispatch_table_from_config(
plugin => 'restish',
config => $config,
endpoint => $endpoint,
);
}
register_plugin();
true;
=head1 NAME
Dancer::Plugin::RPC::RESTISH - Simple plugin to implement a restish interface.
=head1 SYNOPSIS
In the Controler-bit:
use Dancer::Plugin::RPC::RESTISH;
restish '/endpoint' => {
publish => 'pod',
arguments => ['MyProject::Admin'],
cors_allow_origin => '*',
};
and in the Model-bit (B<MyProject::Admin>):
package MyProject::Admin;
=for restish GET@ability/:id rpc_get_ability_details
=cut
sub rpc_get_ability_details {
my %args = @_; # contains: {"id": 42}
return {
# datastructure
};
}
1;
=head1 DESCRIPTION
RESTISH is an implementation of REST that lets you bind routes to code in the
style the rest of L<Dancer::Plugin::RPC> modules do. One must realise that this
basically binds REST-paths to RPC-methods (that's not ideal, but saves a lot of
code).
B<This version only supports JSON as data serialisation>.
=head2 restish '/base_path' => \%publisher_arguments
See L<Dancer::Plugin::RPC>, L<Dancer::Plugin::RPC::JSONRPC>,
L<Dancer::Plugin::RPC::RESTRPC>, L<Dancer::Plugin::RPC::XMLRPC> for more
information about the C<%publisher_arguments>.
=head2 Implement the routes for RESTISH
The plugin registers Dancer-C<any> route-handlers for the C<base_path> +
C<method_path> and the route-handler looks for a data-handler that matches the path
and HTTP-method.
Method-paths can contain colon-prefixed parameters native to Dancer. These
parameters will be merged with the content-parameters and the query-parameters
into a single hash which will be passed to the code as the parameters.
Method-paths are prefixed by a HTTP-method followed by B<@>:
=over
=item publisher => 'config'
plugins:
'RPC::RESTISH':
'/rest':
'MyProject::Admin':
'GET@resources': 'get_all_resourses'
'POST@resource': 'create_resource'
'GET@resource/:id': 'get_resource'
'PATCH@resource/:id': 'update_resource'
'DELETE@resource/:id': 'delete_resource'
=item publisher => 'pod'
=for restish GET@resources get_all_resources /rest
=for restish POST@resource create_resource /rest
=for restish GET@resource/:id get_resource /rest
=for restish PATCH@resource/:id update_resource /rest
=for restish DELETE@resource/:id delete_resource /rest
The third argument (the base_path) is optional.
=back
The plugin for RESTISH also adds 2 fields to C<$Dancer::RPCPlugin::ROUTE_INFO>:
local $Dancer::RPCPlugin::ROUTE_INFO = {
plugin => PLUGIN_NAME,
endpoint => $endpoint,
rpc_method => $method_name,
full_path => request->path,
http_method => $http_method,
# These two are added
route_matched => $found_match, # PATCH@resource/:id
matched_re => $match_re, # PATCH@resource/[^/]+
};
=head2 CORS (Cross-Origin Resource Sharing)
If one wants the service to be directly called from javascript in a browser, one
has to consider CORS as browsers enforce that. This means that the actual
request is preceded by what's called a I<preflight request> that uses the
HTTP-method B<OPTIONS> with a number of header-fields.
=over
=item Origin
=item Access-Control-Request-Method
=back
The plugin supports considering these CORS requests, by special casing these
B<OPTIONS> requests and always sending the C<Access-Control-Allow-Origin> header
as set in the config options.
=head3 cors_allow_origin => $list_of_urls | '*'
If left out, no attempt to honour a CORS B<OPTIONS> request will be done and the
request will be passed.
When set to a value, the B<OPTIONS> request will be executed, for any http-method in
the C<Access-Control-Request-Method> header. The response to the B<OPTIONS>
request will also contain every C<Access-Control-Allow-*> header that was
requested as C<Access-Control-Request-*> header.
When set, all responses will contain the C<Access-Control-Allow-Origin>-header
with either C<*> if that was set, or the value of the actual C<Origin>-header
that was passed and equals one the preset values.
=head1 INTERNAL
=head2 build_dispatcher_from_config
Creates a (partial) dispatch table from data passed from the (YAML)-config file.
=head2 build_dispatcher_from_pod
Creates a (partial) dispatch table from data provided in POD.
=head1 LICENSE
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
See:
=over 4
( run in 0.328 second using v1.01-cache-2.11-cpan-0bd6704ced7 )