Routing
Routes are declared in config/routes.raku with a declarative DSL. A routes
block builds a router that maps an incoming method and path to a controller
action or an inline handler.
use MVC::Keayl::Routing;
routes {
root to => 'home#index';
get '/users', to => 'users#index';
post '/users', to => 'users#create';
match '/search', to => 'search#run', via => <get post>;
}
The draw block#
routes (and its alias draw) takes a block and returns a
MVC::Keayl::Router. Inside the block the verb helpers register routes on that
router.
At boot the application loads the routes file with load-routes:
my $router = load-routes('config/routes.raku');
load-routes evaluates the file and returns the router its routes block built.
Verb helpers#
get, post, put, patch, delete, and options each declare a route for
that method. The target is given with to:
get '/users', to => 'users#index'; # controller#action
get '/ping', to => sub { 'pong' }; # inline callable
A 'controller#action' string target is split into a controller and an action.
A Callable target is kept as an inline handler. A GET route also answers
HEAD.
match and via#
match registers one path for several verbs. via accepts a single verb, a
list of verbs, the string 'all', or * for every verb:
match '/search', to => 'search#run', via => <get post>;
match '/health', to => 'health#show', via => 'all';
match '/any', to => 'catch#all', via => *;
root#
root maps GET / to a target and names the route root:
root to => 'home#index';
Path patterns#
A path can carry dynamic segments, a glob, optional groups, a format, and per-segment constraints. The matched values become params on recognition.
get '/users/:id', to => 'users#show'; # :id captures one segment
get '/files/*path', to => 'files#serve'; # *path captures the rest, slashes included
get '/users(/:id)', to => 'users#index'; # (...) is an optional group
get '/users/:id(.:format)', to => 'users#show'; # (.:format) peels off an extension
A :segment matches a single path segment, stopping at a / or a .. A
*glob matches everything that remains, including slashes. Anything inside
(...) is optional.
format => True appends an optional (.:format) segment without writing it out:
get '/users/:id', to => 'users#show', format => True;
defaults supplies values for params that are absent from the path, and
constraints restricts a segment to a pattern. A request whose segment fails the
constraint falls through to the next route:
get '/users/:id', to => 'users#show',
constraints => { id => /^\d+$/ },
defaults => { format => 'html' };
Resources#
resources declares the seven REST routes for a resource in one call:
resources 'users';
| Verb | Path | Action | Name |
|---|---|---|---|
| GET | /users |
index | users |
| POST | /users |
create | users |
| GET | /users/new |
new | new-user |
| GET | /users/:id |
show | user |
| GET | /users/:id/edit |
edit | edit-user |
| PATCH / PUT | /users/:id |
update | user |
| DELETE | /users/:id |
destroy | user |
Pass several names to declare more than one resource at once:
resources 'users', 'posts';
Limiting actions#
only and except choose which of the seven actions to generate:
resources 'users', :only<index show>;
resources 'photos', :except<destroy>;
Member and collection routes#
A block adds extra routes. member routes act on a single record (/:id),
collection routes act on the set:
resources 'photos', {
member {
get 'preview', to => 'photos#preview'; # GET /photos/:id/preview
}
collection {
get 'search', to => 'photos#search'; # GET /photos/search
}
}
on does the same for a single route without a block:
resources 'photos', {
get 'stats', to => 'photos#stats', on => 'collection';
}
Member route names are suffixed with the singular (preview-photo), collection
routes with the plural (search-photos).
Resource options#
| Option | Effect |
|---|---|
path |
Override the URL segment (/team instead of /people). |
as |
Override the helper name base. |
controller |
Override the target controller. |
module |
Prefix the controller (admin/posts). |
param |
Rename the member key (:slug instead of :id). |
path-names |
Rename the new and edit URL segments. |
resources 'people',
path => 'team',
controller => 'staff',
param => 'slug',
path-names => { new => 'neu', edit => 'bearbeiten' };
Singular resources#
resource declares a resource with no index and no :id, for a thing there is
only one of per request (a profile, an account):
resource 'profile';
| Verb | Path | Action | Name |
|---|---|---|---|
| GET | /profile/new |
new | new-profile |
| POST | /profile |
create | profile |
| GET | /profile |
show | profile |
| GET | /profile/edit |
edit | edit-profile |
| PATCH / PUT | /profile |
update | profile |
| DELETE | /profile |
destroy | profile |
The controller defaults to the plural (profiles). resource takes the same
options as resources (only, except, path, as, controller, module,
path-names, and member/collection blocks).
Nesting#
Resources nest inside a resource block. A nested resource is scoped under the parent member, and its key is named after the parent:
resources 'magazines', {
resources 'ads';
}
This produces /magazines/:magazine_id/ads, /magazines/:magazine_id/ads/:id,
and so on, with helper names prefixed by the parent singular (magazine-ads,
magazine-ad, new-magazine-ad, edit-magazine-ad). Plural and singular
resources nest either way, and nesting can go more than one level deep, though
nesting more than one level deep is usually a sign the routes want flattening.
Shallow nesting#
shallow keeps the collection routes (index, new, create) nested but lifts the
member routes (show, edit, update, destroy) to the top level, so member URLs stay
short:
resources 'magazines', :shallow, {
resources 'ads';
}
Collection routes stay at /magazines/:magazine_id/ads, while member routes move
to /ads/:id. The member helpers drop the parent prefix (ad rather than
magazine-ad). shallow-path overrides the shallow member segment and
shallow-prefix overrides the shallow member name prefix:
resources 'ads', :shallow, :shallow-path<a>, :shallow-prefix<x>;
Namespaces and scopes#
namespace prefixes the path, the controller module, and the helper name all at
once:
namespace 'admin', {
resources 'users'; # /admin/users => admin/users, named admin-users
}
scope controls each of those independently:
scope(path => 'api', module => 'v1', as => 'api', {
get '/ping', to => 'ping#show', as => 'ping'; # /api/ping => v1/ping#show, named api-ping
});
controller sets the controller for the routes inside, so a target can be just
an action and a bare path defaults its action:
controller 'photos', {
get '/preview', to => 'show'; # photos#show
get '/list'; # photos#list
}
An optional scope segment is written with parentheses, which suits an i18n locale prefix that may or may not be present:
scope('(:locale)', {
get '/about', to => 'pages#about'; # matches /about and /en/about
});
Scopes nest and compose their prefixes.
Concerns#
A concern is a reusable block of routes. Define it once with concern, then mix
it into resources with the concerns option or a concerns call inside a block:
concern 'commentable', { resources 'comments' };
resources 'posts', concerns => 'commentable';
resources 'photos', { concerns 'commentable' };
Concern routes nest under the resource that mixes them in, so
/posts/:post_id/comments and /photos/:photo_id/comments both appear.
Constraints and defaults#
A constraints block restricts the routes inside it. Segment keys constrain path
params, while subdomain, host, format, protocol, port, and method
constrain request attributes:
constraints(:id(/^\d+$/), {
get '/items/:id', to => 'items#show'; # /items/42 matches, /items/abc does not
});
constraints(:subdomain<api>, {
get '/data', to => 'data#index'; # only when the request subdomain is api
});
A custom constraint is a callable that receives the request context, or an object
with a matches method:
constraints(-> %context { %context<host>.ends-with('.internal') }, {
get '/admin', to => 'admin#index';
});
Request constraints are checked during recognition against a context hash:
$router.recognize('GET', '/data', context => { subdomain => 'api' });
A defaults block supplies default params for the routes inside it:
defaults(format => 'json', {
get '/api/users', to => 'users#index'; # params include format => 'json'
});
Redirects and mounting#
A route can redirect instead of dispatching to a controller. redirect takes a
string or a block that computes the location from the params, and an optional
status:
get '/stories', to => redirect('/articles');
get '/movies/:id', to => redirect(-> %params { '/films/' ~ %params<id> }, status => 302);
mount attaches a sub-app at a path. The mount matches the mount point and
everything below it, capturing the remainder as mounted_path:
mount $rack-app, at => '/legacy'; # matches /legacy and /legacy/...
URL helpers#
MVC::Keayl::Routing::UrlHelpers generates paths and URLs from named routes.
path-for fills the segments, turns leftover params into a sorted query string,
and handles anchor and trailing-slash:
my $helpers = MVC::Keayl::Routing::UrlHelpers.new(:$router);
$helpers.path-for('user', 5); # /users/5
$helpers.path-for('user', 5, page => 2); # /users/5?page=2
$helpers.path-for('user', 5, anchor => 'comments'); # /users/5#comments
url-for builds an absolute URL from default-url-options (host, protocol,
port), each overridable per call. The name-path and name-url helpers also
resolve through FALLBACK:
my $helpers = MVC::Keayl::Routing::UrlHelpers.new(:$router, :default-url-options({ host => 'example.com' }));
$helpers.url-for('user', 5); # http://example.com/users/5
$helpers.user-path(5); # /users/5
$helpers.users-url; # http://example.com/users
Custom and polymorphic helpers#
direct registers a helper computed by a block. polymorphic-path and
url-for dispatch a record to its route by class name and persistence state, and
resolve customizes that mapping:
direct 'homepage', -> { 'https://example.com' };
resolve 'Basket', -> $basket { ('cart',) };
$helpers.homepage-url; # https://example.com
$helpers.polymorphic-path($persisted-post); # /posts/7
$helpers.polymorphic-path($new-post); # /posts
$helpers.url-for($post); # dispatches polymorphically
Recognition#
The router answers recognize($method, $path), returning a match or an
undefined match when nothing fits. A context hash supplies request attributes
for request constraints:
my $match = $router.recognize('GET', '/users/42');
$match.controller; # 'users'
$match.action; # 'show'
$match.params; # { id => '42' }
$match.callable; # the inline handler, or an undefined Callable
$match.route; # the matched route
recognition-status distinguishes a hit from a wrong method and from an unknown
path, and allowed-methods lists the verbs a path answers:
$router.recognition-status('GET', '/about'); # 'found'
$router.recognition-status('POST', '/about'); # 'method-not-allowed'
$router.recognition-status('GET', '/missing'); # 'not-found'
$router.allowed-methods('/about'); # ('GET', 'HEAD')
route-named($name) looks a route up by its name, and route-table returns the
name / verbs / pattern / target of every route, which keayl routes prints.