Introduction
Chained Actions in Catalyst give you a lot of power and design flexibility but continue to give newbies to the system a lot of trouble understanding how they work. In addition when you have a chain of actions that span controllers there is no current method to express or determine that relationship. In this blog I'll be exploring this issue and propose an idea I'd love to get feedback on.
Standard Example
Here's a simple example of Catalyst action chaining for an HTTP request of 'https://example.local/tasks/create'. This chain starts in the Root controller, passes into the Tasks controller and finalizes in the Tasks::Task controller. I will only show the actions most directly relevant to this endpoint. First the Root controller:
package Example::Controller::Root;
use Moose;
use MooseX::Attributes
extends 'Catalyst::ControllerPerRequest';
## ANY /...
sub root :Chained('/') CaptureArg(0) ($self, $c) { }
## ANY /...
sub private :Chained(root) PathPart('') ($self, $c) {
return $c->redirect_to_action('/session/show') && $c->detach
unless $c->user->authenticated;
}
# The order of the Action Roles is important!!
sub end :Action Does('RenderErrors') Does('RenderView') { }
__PACKAGE__->config(namespace=>'');
__PACKAGE__->meta->make_immutable;
Now the Tasks Controller:
package Example::Controller::Tasks;
use Moose;
use MooseX::Attributes
extends 'Catalyst::ControllerPerRequest';
has 'tasks', (
is=>'ro',
lazy=>1,
default => sub ($self, $c) {
return $c->user->search_related('tasks')->with_comments_labels;
}
);
# ANY /tasks/...
sub root :Chained('../private') PathPart('task') CaptureArgs(0) ($self, $c) { }
# GET /tasks/list
sub list :GET Chained('root') PartPart('list') Args(0) QueryModel() ($self, $c, $qm) {
return $self->view(tasks => $self->tasks->page($qm->page));
}
__PACKAGE__->meta->make_immutable;
Finally the Task::Tasks controller
package Example::Controller::Tasks;
use Moose;
use MooseX::Attributes;
use Types::Standard qw(Int);
extends 'Catalyst::ControllerPerRequest';
has 'tasks' => (
is => 'ro',
lazy => 1,
default => sub($self, $c) {
return $c->controller('Tasks')->tasks;
}
);
has 'task' => (is => 'rw');
# ANY /tasks/...
sub root :Chained('../root') PathPart('') CaptureArgs ($self, $c) { }
### CREATE ACTIONS ###
# ANY /tasks/...
sub build :Chained('root') PathPart('') CaptureArgs ($self, $c) {
$self->task($self->tasks->new_task);
}
# ANY /tasks/create/...
sub setup_create :Chained('build') PathPart('create') CaptureArgs ($self, $c) {
$self->view_for('create', task => $self->task);
}
# GET /tasks/create
sub show_create :GET Chained('setup_create') PathPart('') Args(0) ($self, $c) { return }
# POST /tasks/create
sub create :POST Chained('setup_create') PathPart('') Args(0) BodyModel() QueryModel() ($self, $c, $rm, $q) {
return $self->process_request($rm, $q);
}
__PACKAGE__->meta->make_immutable;
In this example creating a URL that has actions chained across three controllers requires quite a lot of non intuitive boilerplate, mostly attributes on the action methods. In addition the relationship between all three controllers is implicit but not discoverable. The thing I find newcomers get confused about is how to figure out which actions are part of the chain, as well as how to control which part of the URL any given action is trying to match.
I've been dog fooding code for my personal projects that looks more like this:
package Agendum::Controller::Root;
use CatalystX::Object;
use Agendum::Syntax;
extends 'Agendum::Controller';
## ANY /...
sub root :StartAt('/...') ($self, $c) { }
## ANY /...
sub private :Via('root') At('/...') ($self, $c) {
return $c->redirect_to_action('/session/show') && $c->detach
unless $c->user->authenticated;
}
# The order of the Action Roles is important!!
sub end :Action Does('RenderErrors') Does('RenderView') { }
__PACKAGE__->config(namespace=>'');
__PACKAGE__->meta->make_immutable;
package Agendum::Controller::Tasks;
use CatalystX::Object;
use Agendum::Syntax;
extends 'Agendum::Controller';
parent 'Root';
has 'tasks', (
is=>'ro',
lazy=>1,
default => sub ($self, $c) {
return $c->user->search_related('tasks')->with_comments_labels;
}
);
# ANY /tasks/...
sub root :ViaParent('private') At('/tasks/...') ($self, $c) { }
# GET /tasks/list
sub list :Via('root') Get('list') QueryModel() ($self, $c, $qm) {
return $self->view(tasks => $self->tasks->page($qm->page));
}
__PACKAGE__->meta->make_immutable;
package Agendum::Controller::Tasks::Task;
use CatalystX::Object;
use Agendum::Syntax;
use Types::Standard qw(Int);
extends 'Agendum::Controller';
parent 'Task';
has 'tasks' => (
is => 'ro',
lazy => 1,
default => sub($self, $c) {
return $self->parent->tasks;
}
);
has 'task' => (is => 'rw');
# ANY /tasks/...
sub root :ViaParent('root') At('/...') ($self, $c) { }
# ANY /tasks/...
sub build :Via('root') At('/...') ($self, $c) {
$self->task($self->tasks->new_task);
}
# ANY /tasks/create/...
sub setup_create :Via('build') At('create/...') ($self, $c) {
$self->view_for('create', task => $self->task);
}
# GET /tasks/create
sub show_create :Via('setup_create') Get('') ($self, $c) { }
# POST /tasks/create
sub create :Via('setup_create') Post('') BodyModel() QueryModel() ($self, $c, $rm, $q) {
return $self->process_request($rm, $q);
}
#... rest of controller
__PACKAGE__->meta->make_immutable;
In this proposal the chaining attribute syntax is collapsed into basically just two types of attributes; 'Via' (or ViaParent) to indicate the action we are chaining off, and 'At' (or its HTTP Method versions, 'Get', 'Post', etc), which expresses the part of the Url we are matching. Additionally we introduced a global keyword 'parent' which indicates the controller which is the 'parent' to the current. This lets us use the distinctive 'ViaParent' to make it clear that the action we are chaining off lies in the parent controller, not the current one.
The goal here is to both reduce the legacy syntax as well as make it more clear what the flow of actions are.
Thoughts?
Top comments (0)