Mojolicious-Plugin-Restify/lib/Mojolicious/Plugin/Restify.pm
package Mojolicious::Plugin::Restify;
use Mojo::Base 'Mojolicious::Plugin';
use Mojo::Util qw(camelize);
our $VERSION = '0.10';
sub register {
my ($self, $app, $conf) = @_;
$conf //= {};
# default HTTP method to instance method mappings for collections
$conf->{collection_method_map} //= {
get => 'list',
post => 'create',
};
# default HTTP method to instance method mappings for resource elements
$conf->{element_method_map} //= {
delete => 'delete',
get => 'read',
patch => 'patch',
put => 'update',
};
# over defaults to the standard route condition check (allow all)
$conf->{over} //= 'standard';
# resource_lookup methods are added to element resource routes by default
$conf->{resource_lookup} //= 1;
# When adding route conditions, warn developers if the exported conditions
# already exist.
if (exists $app->routes->conditions->{int}) {
$app->log->debug("The int route condition already exists, skipping");
}
else {
$app->routes->add_condition(
int => sub {
my ($r, $c, $captures, $pattern) = @_;
my $int
= defined $pattern
? ($captures->{$pattern} // $captures->{int})
: ($captures->{int} // '');
return 1 if $int =~ /^\d+$/;
}
);
}
if (exists $app->routes->conditions->{standard}) {
$app->log->debug("The standard route condition already exists, skipping");
}
else {
$app->routes->add_condition(standard => sub {1});
}
if (exists $app->routes->conditions->{uuid}) {
$app->log->debug("The uuid route condition already exists, skipping");
}
else {
$app->routes->add_condition(
uuid => sub {
my ($r, $c, $captures, $pattern) = @_;
my $uuid
= defined $pattern
? ($captures->{$pattern} // $captures->{uuid})
: ($captures->{uuid} // '');
return 1
if $uuid
=~ /^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$/i;
}
);
}
$app->routes->add_shortcut(
collection => sub {
my $r = shift;
my $path = shift;
my $options = ref $_[0] eq 'HASH' ? shift : {@_};
$options->{element} //= 1;
$options->{route_path} = $path;
$path =~ tr/-/_/;
$options->{route_name}
= $options->{prefix} ? "$options->{prefix}_$path" : $path;
local $options->{collection_method_map} = $options->{collection_method_map}
// $conf->{collection_method_map};
# generate "/$path" collection route
my $controller
= $options->{controller} ? "$options->{controller}-$path" : $path;
my $collection = $r->any("/$options->{route_path}")->to("$controller#");
# Map HTTP methods to instance methods/mojo actions
while (my ($http_method, $method) = each %{$options->{collection_method_map}}) {
$collection->$http_method->to("#$method")
->name("$options->{route_name}_$method");
}
return $options->{element}
? $collection->element($options->{route_path}, $options)
: $collection;
}
);
$app->routes->add_shortcut(
element => sub {
my $r = shift;
my $path = shift;
my $options = ref $_[0] eq 'HASH' ? shift : {@_};
$options->{over} //= $conf->{over};
$options->{placeholder} //= ':';
$options->{resource_lookup} //= $conf->{resource_lookup};
$path =~ tr/-/_/;
$options->{route_name}
= $options->{prefix} ? "$options->{prefix}_$path" : $path;
local $options->{element_method_map} = $options->{element_method_map}
// $conf->{element_method_map};
# 'over' was deprecated in favor of 'requires' in Mojolicious 8.67
my $requires_method = $r->can('over') ? 'over' : 'requires';
# generate "/$path/:id" element route with specific placeholder
my $element = $r->any("/$options->{placeholder}${path}_id")
->$requires_method($options->{over} => "${path}_id")->name($options->{route_name});
# Generate remaining CRUD routes for "/$path/:id", optionally creating a
# resource_lookup method for the resource $element.
#
# This method allows loading an object using the :id in resource_lookup,
# and have it accessible via the stash in DELETE, GET, PUT etc. methods
# in your controller.
my $under
= $options->{resource_lookup}
? $element->under->to('#resource_lookup')
->name("$options->{route_name}_resource_lookup")
: $element;
# Map HTTP methods to instance methods/mojo actions
while (my ($http_method, $method) = each %{$options->{element_method_map}}) {
$under->$http_method->to("#$method")
->name("$options->{route_name}_$method");
}
return $element;
}
);
$app->helper(
'restify.current_id' => sub {
my $c = shift;
my $name = $c->stash->{controller};
$name =~ s,^.*\-,,;
return $c->match->stack->[-1]->{"${name}_id"} // '';
}
);
$app->helper(
'restify.routes' => sub {
my ($self, $r, $routes, $defaults) = @_;
return unless $routes;
# Allow users to simplify their route creation using an array ref!
$routes = _arrayref_to_hashref($routes)
if ref $routes && ref $routes eq 'ARRAY';
$defaults //= {};
$defaults->{resource_lookup} //= $conf->{resource_lookup};
while (my ($name, $attrs) = each %$routes) {
my $paths = {};
my $options = {%$defaults};
if (ref $attrs eq 'ARRAY') {
$options = {%$options, %{$attrs->[-1]}} if ref $attrs->[-1] eq 'HASH';
$paths = shift @$attrs if ref $attrs->[0] eq 'HASH';
}
elsif (ref $attrs eq 'HASH') {
$paths = $attrs;
}
if (scalar keys %$paths) {
my $controller = $name;
$controller =~ tr/-/_/;
my $collection = $r->collection($name, {%$options, element => 0});
my $under
= $options->{resource_lookup}
? $collection->under->to($options->{controller}
? "$options->{controller}-$controller#resource_lookup"
: "$controller#resource_lookup")
->name("${controller}_resource_lookup")
: $collection;
my $endpoint
= $under->element($name, {%$options, resource_lookup => 0});
$options->{controller}
= $options->{controller}
? "$options->{controller}-$controller"
: $controller;
$options->{prefix}
= $options->{prefix}
? "$options->{prefix}_$controller"
: $controller;
$self->restify->routes($endpoint, $paths, $options);
}
else {
$r->collection($name, $options);
}
}
return;
}
);
}
# Aargh eurgh ma bwains!
sub _arrayref_to_hashref {
my $arrayref = shift;
return {} unless defined $arrayref;
my $hashref = {};
for my $path (@$arrayref) {
my $options;
if (ref $path and ref $path eq 'ARRAY') {
($path, $options) = @$path;
}
my @parts = split '/', $path;
if (@parts == 1) {
$hashref->{shift @parts} = defined $options ? [undef, $options] : undef;
}
else {
my $key = shift @parts;
$hashref->{$key} //= {};
$hashref->{$key}
= {%{$hashref->{$key}}, %{_arrayref_to_hashref([join '/', @parts])}};
}
}
return $hashref;
}
1;
=encoding utf8
=head1 NAME
Mojolicious::Plugin::Restify - Route shortcuts & helpers for REST collections
=head1 SYNOPSIS
# Mojolicious example (Mojolicious::Lite isn't supported)
package MyApp;
use Mojo::Base 'Mojolicious';
sub startup {
my $self = shift;
# imports the `collection' route shortcut and `restify' helpers
$self->plugin('Restify');
# add REST collection endpoints manually
my $r = $self->routes;
my $accounts = $r->collection('accounts'); # /accounts
$accounts->collection('invoices'); # /accounts/:accounts_id/invoices
# or add the equivalent REST routes with an ARRAYREF (the helper will
# create chained routes from the path 'accounts/invoices' so you don't need
# to set ['accounts', 'accounts/invoices'])
my $r = $self->routes;
$self->restify->routes($r, ['accounts/invoices']);
# or add the equivalent REST routes with a HASHREF (might be easier to
# visualise how collections are chained together)
my $r = $self->routes;
$self->restify->routes($r, {
accounts => {
invoices => undef
}
});
}
Next create your controller for accounts.
# Restify controller depicting the REST actions for the /accounts collection.
# (The name of the controller is the Mojo::Util::camelized version of the
# collection path.)
package MyApp::Controller::Accounts;
use Mojo::Base 'Mojolicious::Controller';
sub resource_lookup {
my $c = shift;
# To consistenly get the element's ID relative to the resource_lookup
# action, use the helper as shown below. If you need to access an element ID
# from a collection further up the chain, you can access it from the stash.
#
# The naming convention is the name of the collection appended with '_id'.
# E.g., $c->stash('accounts_id').
my $account = your_lookup_account_resource_func($c->restify->current_id);
# By stashing the $account here, it will now be available in the delete,
# read, patch, and update actions. This resource_lookup action is optional,
# but added to every collection by default to help reduce your code.
$c->stash(account => $account);
# must return a positive value to continue the dispatch chain
return 1 if $account;
# inform the end user that this specific resource does not exist
$c->reply->not_found and return 0;
}
sub create { ... }
sub delete { ... }
sub list { ... }
sub read {
my $c = shift;
# account was placed in the stash in the resource_lookup action
$c->render(json => $c->stash('account'));
}
sub patch { ... }
sub update { ... }
1;
=head1 DESCRIPTION
L<Mojolicious::Plugin::Restify> is a L<Mojolicious::Plugin>. It simplifies
generating all of the L<Mojolicious::Routes> for a typical REST I<collection>
endpoint (e.g., C</accounts> or C</invoices>) and maps the common HTTP verbs
(C<DELETE>, C<GET>, C<PATCH>, C<POST>, C<PUT>) to underlying controller class
methods.
For example, creating a I<collection> called C</accounts> would create the
routes as shown below. N.B. The C<over> option in the example below corresponds
to the name of a route condition. See L<Mojolicious::Routes/conditions>.
# The collection route shortcut below creates the following routes, and maps
# them to controllers of the camelized route's name.
#
# Pattern Methods Name Class::Method Name
# ------- ------- ---- ------------------
# /accounts * accounts
# +/ GET "accounts_list" Accounts::list
# +/ POST "accounts_create" Accounts::create
# +/:accounts_id * "accounts"
# +/ * "accounts_resource_lookup" Accounts::resource_lookup
# +/ DELETE "accounts_delete" Accounts::delete
# +/ GET "accounts_read" Accounts::read
# +/ PATCH "accounts_patch" Accounts::patch
# +/ PUT "accounts_update" Accounts::update
# expects the element id (:accounts_id) for this collection to be a uuid
my $route = $r->collection('accounts', over => 'uuid');
L<Mojolicious::Plugin::Restify> tries not to make too many assumptions, but the
author's recent experience writing a REST-based API using L<Mojolicious> has
helped shaped this plugin, and might unwittingly express some of his bias.
=head1 HELPERS
L<Mojolicious::Plugin::Restify> implements the following helpers.
=head2 restify->current_id
my $id = $c->restify->current_id;
Returns the I<element> id at the current point in the dispatch chain.
This is the only way to guarantee the correct I<element>'s resource ID in a
L<Mojolicious::Plugin::Restify> I<action>. The C<resource_lookup> I<action>,
which is added by default in both L</collection> and L</restify-routes>, is
added at different positions of the dispatch chain. As such, the router might
not have added the value of any placeholders to the
L<Mojolicious::Controller/stash> yet.
=head2 restify->routes
This helper is a wrapper around the L</collection> route shortcut. It
facilitates creating REST I<collections> using either an C<ARRAYREF> or
C<HASHREF>.
It takes a L<Mojolicious::Routes> object, the I<paths> to create, and optionally
I<options> which are passed to the L</collection> route shortcut.
# /accounts
# /accounts/1234
$self->restify->routes($self->routes, ['accounts'], {over => 'int'});
# /invoices
# /invoices/76be1f53-8363-4ac6-bd83-8b49e07b519c
$self->restify->routes($self->routes, ['invoices'], {over => 'uuid'});
Maybe you want to chain them.
# /accounts
# /accounts/1234
# /accounts/1234/invoices
# /accounts/1234/invoices/76be1f53-8363-4ac6-bd83-8b49e07b519c
$self->restify->routes(
$self->routes,
['accounts', ['accounts/invoices' => {over => 'uuid'}]],
{over => 'int'}
);
=over
=item ARRAYREF
Using the elements of the array, invokes L</collection>, passing any route-
specific options.
It will automatically create and chain parent routes if you pass a full path
e.g., C<['a/very/long/path']>. This is equivalent to the shell command
C<mkdir -p>.
my $restify_routes = [
# /area-codes
# /area-codes/:area_codes_id/numbers
'area-codes/numbers',
# /news
'news',
# /payments
['payments' => {over => 'int'}], # overrides default uuid route condition
# /users
# /users/:users_id/messages
# /users/:users_id/messages/:messages_id/recipients
'users/messages/recipients',
];
$self->restify->routes($self->routes, $restify_routes, {over => 'uuid'});
In its most basic form, C<REST> routes are created from a C<SCALAR>.
# /accounts
my $restify_routes = ['accounts'];
=item HASHREF
Using the key/values of the hash, invokes L</collection>, passing any route-
specific options.
It automatically chains routes to each parent, and progressively builds a
namespace as it traverses through each key.
N.B., This was implemented before the C<ARRAYREF> version, and is arguably a bit
more confusing. It might be dropped in a later version to simplify the API.
my $restify_routes = {
# /area-codes
# /area-codes/:area_codes_id/numbers
'area-codes' => {
'numbers' => undef
},
# /news
'news' => undef,
# /payments
'payments' => [undef, {over => 'int'}], # overrides default uuid route condition
# /users
# /users/:users_id/messages
# /users/:users_id/messages/:messages_id/recipients
'users' => {
'messages' => {
'recipients' => undef
}
},
};
$self->restify->routes($self->routes, $restify_routes, {over => 'uuid'});
=back
=head1 METHODS
L<Mojolicious::Plugin::Restify> inherits all methods from L<Mojolicious::Plugin>
and implements the following new ones.
=head2 register
$plugin->register(Mojolicious->new);
Register plugin in L<Mojolicious> application.
=head1 ROUTE CONDITIONS
L<Mojolicious::Plugin::Restify> implements the following route conditions. These
conditions can be used with the C<over> option in the L</collection> shortcut.
Checks are made for the existence of the C<int>, C<standard> and C<uuid>
conditions before adding them. This allows you to replace them with your own
conditions of the same name by creating them before registering this plugin.
See L<Mojolicious::Guides::Routing/Adding-conditions> to add your own.
=head2 int
# /numbers/1 # GOOD
# /numbers/0 # GOOD
# /numbers/one # BAD
# /numbers/-1 # BAD
# /numbers/0.114 # BAD (the standard :placeholder notation doesn't allow a '.')
my $r = $self->routes;
$r->collection('numbers', over => 'int');
A L<Mojolicious> route condition (see L<Mojolicious::Routes/conditions>) which
restricts a route's I<collection>'s I<element> id to whole positive integers
which are C<E<gt>= 0>.
=head2 standard
my $r = $self->routes;
$r->collection('numbers', over => 'standard');
A I<collection>'s I<element> resource ID is captured using
L<Mojolicious::Guides::Routing/Standard-placeholders>. This route condition
allows everything the standard placeholder allows, which is similar to the
regular expression C<([^/.]+)>.
This is the default I<over> option for a L</collection>.
=head2 uuid
# /uuids/8ebef0d0-d6cf-11e4-8830-0800200c9a66 GOOD
# /uuids/8EBEF0D0-D6CF-11E4-8830-0800200C9A66 GOOD
# /uuids/8ebef0d0d6cf11e488300800200c9a66 GOOD
# /uuids/malformed-uuid BAD
my $r = $self->routes;
$r->collection('uuids', over => 'uuid');
A L<Mojolicious> route condition (see L<Mojolicious::Routes/conditions>) which
restricts a route's I<collection>'s I<element> id to UUIDs only (with or without
the separating hyphens).
=head1 ROUTE SHORTCUTS
L<Mojolicious::Plugin::Restify> implements the following route shortcuts.
=head2 collection
my $r = $self->routes;
$r->collection('accounts');
$r->collection('accounts', collection_method_map => {delete => 'delete_collection'});
$r->collection('accounts', controller => 'differentmodule');
$r->collection('accounts', element => 0);
$r->collection('accounts', element_method_map => {get => 'read'});
$r->collection('accounts', over => 'uuid');
$r->collection('accounts', placeholder => '*');
$r->collection('accounts', prefix => 'v1');
$r->collection('accounts', resource_lookup => '0');
A L<Mojolicious route shortcut|Mojolicious::Routes/shortcuts> which helps
create the most common REST L<routes|Mojolicious::Routes::Route> for a
I<collection> endpoint and its associated I<element>.
A I<collection> endpoint (e.g., C</accounts>) supports I<list> (C<GET>) and
I<create> (C<POST>) actions. The I<collection>'s I<element> (e.g.,
C</accounts/:accounts_id>) supports I<delete> (C<DELETE>), I<read> (C<GET>),
I<patch> (C<PATCH>), and I<update> (C<PUT>) actions.
By default, every HTTP request to a I<collection>'s I<element> is routed through
a C<resource_lookup> I<action> (see L<Mojolicious::Routes::Route/under>). This
helps reduce the process of looking up a I<collection>'s resource to a single
location. See L</SYNOPSIS> for an example of its use.
=head4 options
The following options allow a I<collection> to be fine-tuned.
=over
=item collection_method_map
$r->collection(
'invoices',
{
collection_method_map => {
get => 'list',
post => 'create',
# delete => 'delete_collection', # delete all-the-things!
# put => 'update_collection' # update all-the-things!
}
}
);
The above represents the default HTTP method mappings for C<collections>. It's
possible to change the mappings globally (when importing the plugin) or per
collection (as above).
These HTTP method mappings only apply to the C<collection>. e.g., C</invoices>.
Please see C<element_method_map> if you want to apply different HTTP mappings
to an C<element> like C</invoices/:id>.
=item controller
# collection doesn't build a namespace for subroutes by default
my $accounts = $r->collection('accounts'); # MyApp::Controller::Accounts
$accounts->collection('invoices'); # MyApp::Controller::Invoices
# collection can build namespaces, but can be difficult to keep track of. Use
# the restify helper if namespaces are important to you.
#
# MyApp::Controller::Accounts
my $accounts = $r->collection('accounts');
# MyApp::Controller::Accounts::Invoices
my $invoices = $accounts->collection('invoices', controller => 'accounts');
# MyApp::Controller::Accounts::Invoices::Foo
$invoices->collection('foo', controller => 'accounts-invoices');
Prepends the controller name (which is automatically generated based on the path
name) with this option value if present. Used internally by L</restify> to build
a perlish namespace from the paths. L</collection> does not build a namespace by
default.
=item element
# GET,POST /messages 200
# DELETE,GET,PATCH,PUT,UPDATE /messages/1 200
$r->collection('messages'); # element routes are created by default
# GET,POST /messages 200
# DELETE,GET,PATCH,PUT,UPDATE /messages/1 404
$r->collection('messages', element => 0);
Enables or disables chaining an I<element> to the I<collection>. Disabling the
element portion of a I<collection> means that only the I<create> and I<list>
actions will be created.
=item element_method_map
$r->collection(
'invoices',
{
element_method_map => {
'delete' => 'delete',
'get' => 'read',
'patch' => 'patch',
'put' => 'update',
}
}
);
The above represents the default HTTP method mappings. It's possible to change
the mappings globally (when importing the plugin) or per collection (as above).
These HTTP method mappings only apply to the C<collection>'s C<element>. e.g.,
C</invoices/:id>.
=item over
$r->collection('invoices', over => 'int');
$r->collection('invoices', over => 'standard');
$r->collection('accounts', over => 'uuid');
Allows a I<collection>'s I<element> to be restricted to a specific data type
using Mojolicious' route conditions. L</int>, L</standard> and L</uuid> are
added automatically if they don't already exist.
=item placeholder
# /versions/:versions_id { versions_id => '123'}
# /versions/#versions_id { versions_id => '123.00'}
# /versions/*versions_id { versions_id => '123.00/1'}
The placeholder is used to capture the I<element> id within a route. It can be
one of C<standard ':'>, C<relaxed '#'> or C<wildcard '*'>. You might need to
adjust the placholder option in certain scenarios, but the C<standard>
placeholder should suffice for most normal REST endpoints.
$r->collection('/messages', placeholder => ':');
I<Elements> are chained to a I<collection> using the standard placeholder by
default. They match all characters except C</> and C<.>. See
L<Mojolicious::Guides::Routing/Standard-placeholders>.
$r->collection('/relaxed-messages', placeholder => '#');
Placeholders can be relaxed, matching all characters expect C</>. Useful if you
need to capture a domain name within a route. See
L<Mojolicious::Guides::Routing/Relaxed-placeholders>.
$r->collection('/wildcard-messages', placeholder => '*');
Or they can be greedy, matching everything, inclusive of C</> and C<.>. Useful
if you need to capture everything within a route. See
L<Mojolicious::Guides::Routing/Wildcard-placeholders>.
=item prefix
# without a prefix
$r->collection('invoices');
say $c->url_for('invoices', invoices_id => 1);
# with a prefix
$r->collection('invoices', prefix => 'v1');
say $c->url_for('v1_invoices', invoices_id => 1);
Adds a prefix to the automatically generated route
L<name|Mojolicious::Routes::Route/name> for each I<collection> and I<element>
I<action>.
=item resource_lookup
$r->collection('nolookup', resource_lookup => 0);
Enables or disables adding a C<resource_lookup> I<action> to the I<element> of
the I<collection>.
=back
=head2 element
my $r = $self->routes;
my $news = $r->get('/news')->to('foo#news');
$news->element('news');
A L<Mojolicious route shortcut|Mojolicious::Routes/shortcuts> called internally
by L</collection> to add the I<element> routes to a I<collection>. You shouldn't
need to call this shortcut directly.
When an element is added to a I<collection>'s route, the resource ID is captured
using a standard placeholder by default.
=head1 CREDITS
In alphabetical order:
=over 2
Castaway
DragoČ™-Robert Neagu
Toratora
=back
=head1 COPYRIGHT AND LICENSE
Copyright (C) 2015-2017, Paul Williams.
This program is free software, you can redistribute it and/or modify it under
the terms of the Artistic License version 2.0.
=head1 AUTHOR
Paul Williams <kwakwa@cpan.org>
=head1 SEE ALSO
L<Mojolicious>, L<Mojolicious::Plugin::REST>, L<Mojolicious::Plugin::RESTRoutes>.
=cut