controllers
Controllers
A controller is a subclass of MVC::Keayl::Controller. Each public method is an
action. The framework builds one controller instance per request, dispatches the
named action on it, and returns the response.
use MVC::Keayl::Controller;
class UsersController is MVC::Keayl::Controller {
method index {
'all users';
}
method show {
'user ' ~ self.params<id>;
}
}Per-request state
A controller instance carries the request, a fresh response, and the merged params for that request:
self.request; # the incoming Request
self.response; # the outgoing Response
self.params; # path, query, and body paramsEach instance gets its own response, so actions never share state across requests.
Params
self.params merges the path params (from routing), the query string, and the
request body into one MVC::Keayl::Parameters collection. Path params win over
the body, which wins over the query. Access is indifferent: a key is coerced to a
string, so params<id> and params{5} reach the same value.
Bracketed names build nested structures, the same way Rack does:
user[name]=Ada&user[email][email protected] # params<user><name>, params<user><email>
ids[]=1&ids[]=2 # params<ids> is ['1', '2']
user[roles][]=admin # params<user><roles> is ['admin']
users[][name]=A&users[][age]=1 # params<users>[0] is { name => 'A', age => '1' }The body is parsed by its Content-Type:
application/x-www-form-urlencodedparses like a query string.application/jsonparses the JSON object into params.multipart/form-dataparses text fields, and file fields into a{ filename, content, type }hash. The filecontentis aBlobof the raw uploaded bytes, so binary uploads (images, PDFs) survive byte-for-byte.
build-params(%path-params, $request) produces the merged Parameters for a
request, which the dispatcher passes to the controller.
Dispatch and implicit render
dispatch($action) runs the named action and returns the response. An action
interacts with the response directly:
method create {
self.response.status = 201;
self.response.body('created');
}When an action does not write to the response, its return value is rendered implicitly. An action whose body is still empty has its string return value written to the response:
method show {
'user ' ~ self.params<id>; # becomes the response body
}Only actions defined on the controller are dispatchable. Dispatching an unknown action, or one of the base controller's own methods, raises.
Render
render writes the response explicitly. The content modes set a default content
type and the body:
self.render(json => { ok => True }); # application/json
self.render(plain => 'hello'); # text/plain
self.render(html => '<b>hi</b>'); # text/html
self.render(body => 'id,name', content-type => 'text/csv');status sets the status, on its own or alongside content, and content-type
overrides the default:
self.render(plain => 'created', status => 201);
self.render(status => 204); # no bodyA template is rendered by name, by another action, or inline. Locals and a layout are passed as options:
self.render('show'); # the show template
self.render(action => 'edit'); # another action's template
self.render('show', locals => { user => $user });
self.render(inline => '<p>= name</p>');
self.render('show', layout => 'admin');
self.render('show', layout => False); # no layoutTemplate, inline, and layout rendering go through the controller's view renderer.
When a renderer is configured, an action that does not render explicitly
implicitly renders the template named after the action. An index action with no
render call renders the index template, so the explicit call is rarely needed.
Implicit render follows the request format. The default is html, but when the
request asks for another format (a path extension such as /feed.atom) and a
template exists for it, that template is rendered with the matching content type.
The format-specific template is name.<format>.<handler> (for example
index.xml.haml); the html layout is skipped for non-html formats. When no
template exists for the requested format, implicit render falls back to the html
template. Pass format to choose one explicitly:
self.render('index', format => 'xml'); # renders index.xml.haml, types it application/xmlThe handler is the file's last extension, so the default HAML handler covers the
markup formats it can emit (html, xml, atom, rss, svg). It does not produce JSON.
For JSON, render data directly with render(:json($data)) or
respond-to, or register a handler for a JSON template
language so index.json.<handler> resolves.
Rendering twice raises a double-render error.
Redirects and head
redirect-to sends a redirect with an empty body. It takes a path or URL, and an
optional status (numeric or named, defaulting to 302). :back redirects to the
Referer, with a fallback when there is none:
self.redirect-to('/dashboard');
self.redirect-to('https://example.com');
self.redirect-to('/new', status => 301);
self.redirect-to('/x', status => 'see-other'); # 303
self.redirect-to(:back, fallback => '/home');A named-route redirect is the path a URL helper produces, passed as the target.
head sends a status and headers with no body. The status is numeric or named,
and named arguments become headers (location becomes the Location header):
self.head(204);
self.head('not-found');
self.head('created', location => '/users/5');A redirect, like a render, marks the response performed, so a render or redirect after one raises a double-render error.
Sending files and data
send-data sends an in-memory payload, a string or a Blob, with a content type
and disposition. The default disposition is attachment:
self.send-data($csv, type => 'text/csv', filename => 'report.csv');
self.send-data($bytes, filename => 'image.png');
self.send-data($html, disposition => 'inline');send-file sends a file from disk. The content type is guessed from the
extension unless given, and the filename defaults to the basename:
self.send-file('tmp/report.csv');
self.send-file('public/logo.png', disposition => 'inline');send-file advertises Accept-Ranges: bytes and honours a Range request
header, replying with 206 Partial Content, a Content-Range header, and the
requested slice. Open-ended (bytes=100-) and suffix (bytes=-100) ranges both
work.
Callbacks
before-action, after-action, and around-action register callbacks on the
controller class. A callback is a method name or a block. Around callbacks
receive a continuation they invoke to run the rest of the chain. The registration
calls go after the class definition, because the class name is not yet bound to
the composed type while the body is still being built:
class UsersController is MVC::Keayl::Controller {
method authenticate { ... }
method timer($next) { ...; $next(); ... }
method show { ... }
}
UsersController.before-action('authenticate');
UsersController.around-action('timer');A request runs the before callbacks in order, then the around callbacks wrapping the action, then the after callbacks in reverse order.
only and except scope a callback to specific actions, and if / unless
gate it on a method or block:
UsersController.before-action('authenticate', except => <index show>);
UsersController.before-action('require-admin', if => 'is-admin');Declaring callbacks inside the class
To keep registration in the class body, call through $?CLASS, the handle to the
class being defined:
class UsersController is MVC::Keayl::Controller {
$?CLASS.before-action('authenticate', except => <index show>);
$?CLASS.around-action('timer');
method authenticate { ... }
method timer($next) { ...; $next(); ... }
method show { ... }
}Or attach a callback to its method with the is before-action, is
around-action, and is after-action traits. The method is the callback, and the
trait takes the same only / except / if / unless options:
class UsersController is MVC::Keayl::Controller {
method authenticate is before-action(except => <index show>) { ... }
method timer($next) is around-action { ...; $next(); ... }
method audit is after-action { ... }
method show { ... }
}A subclass inherits its parents' callbacks and can drop one with
skip-before-action (and skip-after-action / skip-around-action), with the
same only / except scoping:
PublicController.skip-before-action('authenticate');A before or around callback that renders or redirects halts the chain: the action and the remaining callbacks do not run.
Strong parameters
require and permit whitelist params before they reach the model. require
returns the value at a key, raising when it is missing or empty. permit returns
a new Parameters containing only the listed keys:
my $attrs = self.params.require('user').permit('name', 'email');permit accepts scalar keys, arrays (an empty-array spec), nested hashes, and
arrays of hashes:
self.params.require('user').permit(
'name', 'email',
roles => [], # an array of scalars
address => <street city>, # a nested hash with these keys
tags => <id name>, # an array of hashes with these keys
);A value that is not a permitted scalar (a nested hash or array) is dropped unless
it is permitted explicitly. permit-all is the escape hatch that marks the
parameters permitted and keeps every key.
Unpermitted keys are dropped. The action taken is configurable, globally with
MVC::Keayl::Parameters.unpermitted-action('raise') or per call with
permit(..., :on-unpermitted<raise>), which raises instead of dropping.
Expecting parameters
expect combines require and permit into one strict call. It returns the
permitted value at a key, raising X::MVC::Keayl::ParameterMissing when the key
is missing or when the value is the wrong shape:
my $user = self.params.expect(user => <name email>);The default rescue turns that exception into a 400, so a malformed payload (a
scalar where a hash is expected, or a missing key) is rejected without reaching
the model. expect follows the value's shape: a hash value permits the listed
keys, an array value permits each element as a hash, and an empty-array spec
returns an array of scalars:
self.params.expect(ids => []); # an array of scalars
self.params.expect(rows => <id name>); # an array of hashes, when rows is a list
self.params.expect('id'); # a required scalarWrapping parameters
wrap-parameters nests a JSON request body under a root key, so a client that
posts {"name": "Ada"} to UsersController reads it back as params<user>. The
key defaults to the controller name singularized, and only JSON requests are
wrapped:
UsersController.wrap-parameters;Pass an explicit key as the first argument, restrict the formats with :format,
and choose attributes with :include or :exclude:
UsersController.wrap-parameters('person', include => <name email>);Or declare it on the class with the is wrap-parameters trait:
class UsersController is MVC::Keayl::Controller is wrap-parameters('person', include => <name email>) { }Wrapping is skipped when the root key is already present in the params, so an explicitly nested body is left untouched.
Custom renderers
add-renderer registers a render option backed by a block, so render csv: $rows
dispatches to it. The block receives the controller, the rendered value, and the
remaining render options, sets its own content type, and returns the body:
MVC::Keayl::Controller.add-renderer('csv', -> $controller, $rows, %options {
$controller.response.content-type('text/csv');
$rows.map(*.join(',')).join("\n")
});
method export { self.render(csv => @rows) }Renderers are registered globally and are available to every controller.
Rescuing exceptions
rescue-from maps an exception type to a handler, a method name or a block, that
turns the exception into a response:
class ArticlesController is MVC::Keayl::Controller {
method not-found($error) { self.head(404) }
}
ArticlesController.rescue-from(X::MVC::Keayl::NotFound, 'not-found');Or attach the mapping to the handler method with the is rescue-from trait. The
method is the handler, and the trait takes the exception type (or several):
class ArticlesController is MVC::Keayl::Controller {
method not-found($error) is rescue-from(X::MVC::Keayl::NotFound) { self.head(404) }
}Lookup is inheritance-aware: when more than one registered type matches a raised exception, the most specific handler wins. A subclass inherits its parents' mappings and can override them.
The base controller ships default mappings: X::MVC::Keayl::NotFound becomes a
404, and X::MVC::Keayl::ParameterMissing and
X::MVC::Keayl::UnpermittedParameters become a 400. An exception with no matching
handler propagates.
Helpers and shared state
helper-method exposes a controller method to the template under its own name:
class UsersController is MVC::Keayl::Controller {
method current-user { ... }
}
UsersController.helper-method('current-user');Or mark the method with the is helper-method trait:
class UsersController is MVC::Keayl::Controller {
method current-user is helper-method { ... }
}assign records a value for the template:
method show {
self.assign('title', 'Profile');
self.render('show');
}When an action renders, the template locals are built from the helper-method
values, then the assigned values, then any explicit locals (which win). The
recorded assigns are also readable through self.assigns.
The ApplicationController pattern
Put shared callbacks, rescue handlers, and helpers on a base controller, and subclasses inherit them:
class ApplicationController is MVC::Keayl::Controller {
method authenticate { ... }
method site-name { ... }
}
ApplicationController.before-action('authenticate');
ApplicationController.helper-method('site-name');
class UsersController is ApplicationController { ... }Callbacks, rescue-from mappings, and helper-method declarations all collect
across the inheritance chain, and a subclass can add to or override what it
inherits.
Every class-level declaration shown here has an is trait form so it can be
written in the class header instead of a call after the class. The ones that
attach to a method (before-action, after-action, around-action,
rescue-from, helper-method) go on the method declaration; the ones that
configure the whole controller (layout, filter-parameters, add-flash-types,
protect-from-forgery, rate-limit, wrap-parameters,
http-basic-authenticate-with) go on the class:
class UsersController is MVC::Keayl::Controller
is layout('admin')
is protect-from-forgery
{
method authenticate is before-action(except => <index show>) { ... }
method not-found($error) is rescue-from(X::MVC::Keayl::NotFound) { self.head(404) }
method current-user is helper-method { ... }
}