Dancer2-Plugin-RPC-RESTISH

 view release on metacpan or  search on metacpan

lib/Dancer2/Plugin/RPC/RESTISH.pm  view on Meta::CPAN

package Dancer2::Plugin::RPC::RESTISH;
use Moo;
use Dancer2::Plugin ;#qw( plugin_keywords );

with 'Dancer2::RPCPlugin';

our $VERSION = '2.02';
use constant PLUGIN_NAME => 'restish';

has allow_origin => (
    is      => 'rw',
    default => sub { {} },
);

use Dancer2::RPCPlugin::CallbackResultFactory;
use Dancer2::RPCPlugin::ErrorResponse;
use Dancer2::RPCPlugin::FlattenData;
use Dancer2::RPCPlugin::PluginNames;

Dancer2::RPCPlugin::PluginNames->new->add_names(PLUGIN_NAME);
Dancer2::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 JSON;
use Scalar::Util 'blessed';
use Time::HiRes 'time';

# A char between the HTTP-Method and the REST-route
our $_HM_POSTFIX = '@';

plugin_keywords PLUGIN_NAME;

sub restish {
    my ($plugin, $endpoint, $arguments) = @_;
    my $restish_args = $arguments->{plugin_args} || {};

    $plugin->allow_origin->{$endpoint} = $restish_args->{cors_allow_origin} || '';

    my $dispatcher = $plugin->dispatch_builder(
        $endpoint,
        $arguments->{publish},
        $arguments->{arguments},
        plugin_setting(),
    )->();

    my $lister = $plugin->partial_method_lister(
        protocol => __PACKAGE__->rpcplugin_tag,
        endpoint => $endpoint,
        methods  => [ sort keys %{ $dispatcher } ],
    );

    my $code_wrapper = $plugin->code_wrapper($arguments);
    my $callback = $arguments->{callback};

    $plugin->app->log(debug => "Starting restish-handler build: ", $lister);
    my $handle_call = sub {
        my ($dsl) = @_;
        my ($pi) = grep { ref($_) eq __PACKAGE__ } @{ $dsl->plugins };

        my $allow_origin = $pi->allow_origin->{$endpoint};
        my @allowed_origins = split(" ", $allow_origin);

        # we'll only handle requests that have either a JSON body or no body
        my $http_request = $dsl->app->request;
        my ($ct) = split(/;\s*/, $http_request->content_type // "", 2);
        $ct //= "";

        if ($http_request->body && ($ct ne 'application/json')) {
            $dsl->pass();
        }

        my $http_method  = uc($http_request->method);
        my $request_path = $http_request->path;

        ##### Cross Origin Resource Sharing(CORS)
        my $has_origin_header = grep {
            lc($_) eq 'origin'
        } $http_request->headers->header_field_names;
        my $has_origin = $http_request->header('Origin') || '';
        my $allowed_origin = ($allow_origin eq '*')
                          || grep { $_ eq $has_origin } @allowed_origins;

        my $is_preflight = $has_origin_header && ($http_method eq 'OPTIONS');
        $dsl->app->log(debug => "[RESTISH-CORS] Preflight from $has_origin")
            if $is_preflight;

        # with CORS, we do not allow mismatches on Origin
        if ($allow_origin && $has_origin && !$allowed_origin) {
            $dsl->app->log(
                debug => "[RESTISH-CORS] '$has_origin' not allowed ($allow_origin)"
            );
            $dsl->response->status(403);
            $dsl->response->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
            ? $http_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;
            $dsl->app->log(
                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";
                $dsl->app->log(debug => $msg);
                $dsl->response->status(200); # maybe 403?
                $dsl->response->content_type('text/plain');
                return $msg;
            }
            $dsl->app->log(
                warning => "$http_method => $request_path ($method_name) not found, pass()"
            );
            $dsl->pass();
        }
        $dsl->app->log(
            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_header) {
            my $allow_now = $allow_origin eq '*' ? '*' : $has_origin;
            $dsl->response->header('Access-Control-Allow-Origin' => $allow_now);
        }

        if ($is_preflight) { # Send more CORS headers and return.
            $dsl->app->log(
                debug => "[CORS] preflight-request: $request_path ($method_name)"
            );
            $dsl->response->status(204);
            $dsl->response->header(
                'Access-Control-Allow-Headers',
                $http_request->header('Access-Control-Request-Headers')
            ) if $http_request->header('Access-Control-Request-Headers');

            $dsl->response->header('Access-Control-Allow-Methods' => $found_method);
            return "";
        }

        $dsl->response->content_type ('application/json');
        my $method_args = $http_request->body
            ? from_json($http_request->body)
            : { };
        my $route_args = $http_request->params('route') // { };
        my $query_args = $http_request->params('query') // { };

        # We'll merge method_args and route_args, where route_args win:
        $method_args = {
            %$method_args,
            %$route_args,
            %$query_args,
        };
        $dsl->app->log(
            debug => "[handle_restish_request('$request_path' via '$found_match')] "
                   , $method_args
        );

        my $start_request = time();
        my $continue = eval {
            (my $match_re = $found_match) =~ s{:\w+}{[^/]+}g;
            local $Dancer2::RPCPlugin::ROUTE_INFO = {
                plugin        => PLUGIN_NAME,
                route_matched => $found_match,
                matched_re    => $match_re,
                endpoint      => $endpoint,
                rpc_method    => $method_name,
                full_path     => $http_request->path,
                http_method   => $http_method,
            };
            $callback
                ? $callback->($http_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,
            );
            $dsl->response->status($error_response->return_status(PLUGIN_NAME));
            $response = $error_response->as_restish_error;
        }
        elsif (   !blessed($continue)
               || !$continue->can('does')
               || !$continue->does('Dancer2::RPCPlugin::CallbackResult'))
        {
            my $error_response = error_response(
                error_code    => -32603,
                error_message => "Internal error: 'callback_result' wrong class "
                               . blessed($continue),
                error_data    => $method_args,
            );
            $dsl->response->status($error_response->return_status(PLUGIN_NAME));
            $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,
            );
            $dsl->response->status($error_response->return_status(PLUGIN_NAME));
            $response = $error_response->as_restish_error;
        }
        else {
            my $di = $dispatcher->{$found_match};
            my $handler = $di->code;
            my $package = $di->package;

            $response = eval {
                $code_wrapper->($handler, $package, $method_name, $method_args);
            };
            my $error = $@;

            $dsl->app->log(debug => "[handled_restish_response($method_name)] ", $response);
            $dsl->app->log(
                info => sprintf(
                    "[RPC::RESTISH] request for '%s' took %.4fs",
                    $request_path, time() - $start_request
                )
            );

            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,
                    );
                $dsl->response->status($error_response->return_status(PLUGIN_NAME));
                $response = $error_response->as_restish_error;
            }
            if (blessed($response) && $response->can('as_restish_error')) {
                $dsl->response->status($response->return_status(PLUGIN_NAME));
                $response = $response->as_restish_error;
            }
            elsif (blessed($response)) {
                $response = flatten_data($response);
            }
            $dsl->app->log(debug => "[handled_restish_response($request_path)] ", $response);
        }
        my $jsonise_options = {canonical => 1};
        if ($dsl->config->{encoding} && $dsl->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;
    };

    $plugin->app->log(debug => "Setting routes (restish): $endpoint ", $lister);
    # split the keys in $dispatcher so we can register methods for all
    for my $dispatch_route (keys %$dispatcher) {
        my ($hm, $route) = split(/$_HM_POSTFIX/, $dispatch_route, 2);
        my $dancer_route = "$endpoint/$route";
        $plugin->app->log(debug => "[restish] registering `$hm $dancer_route`");
        $plugin->app->add_route(
            method => lc($hm),
            regexp => $dancer_route,
            code   => $handle_call,
        );
        $plugin->app->add_route(
            method => 'options',
            regexp => $dancer_route,
            code   => $handle_call
        ) if $plugin->allow_origin;
    }

};

use namespace::autoclean;
1;

=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'],
        plugin_args => {
            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<$Dancer2::RPCPlugin::ROUTE_INFO>:

        local $Dancer2::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 Attributes

=over

=item B<allow_origin>

Where do we allow Origin to be from.

=back

=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.



( run in 1.536 second using v1.01-cache-2.11-cpan-ed4147ee29a )