Modernize Chained Actions in Perl Catalyst MVC
John Napiorkowski
Posted on July 22, 2023
Now that the lastest release of Perl Catalyst is out with enhancements to chained actions, lets talk a bit about making actually using chained actions nicer!
From my previous postings you probably gather I'm a big fan of chained actions in Catalyst. In brief, chained actions are an implementation of the chain of command pattern applied to URL routing that promote reusability and elegance in your code. Like all of Catalyst's routing options they are declared as subroutine attributes on your action methods which are parsed at application startup and used to create 'action chains' associated with URLs for your website. However the built in attributes are a bit verbose and potentially confusing. Let's look again at the root controller of my previous post on the 'next action' feature:
package Example::Controller::Root;
use Moose;
use MooseX::MethodAttributes;
use feature 'signatures';
extends 'Catalyst::Controller;
# URL-PART: /...
sub root :Chained('/') PathPart('') CaptureArgs(0) ($self, $c) {
$c->action->next(my $user = $c->user);
}
__PACKAGE__->config(namespace=>'');
__PACKAGE__->meta->make_immutable;
Here we declare a 'root' action that declares the base of a chain which a tree of chained routes can hook into in order to declare all the actual endpoints in your web application. It declares three subroutine attributes. Lets look at each one in turn:
Chained('/')
This declares that the action is the base of the chain. the 'Chained' attribute however does double duty as you will see later one, which I think is probably part of the confusion I see often. For now think this is saying 'the following action runs when we hit any URL starting with "https://myapp/", in other words the root of all your endpoints.
PathPart('')
This declares any URL parts that are part of the match. Since we want to match starting with the actual root of the website we are explicitly saying there is no part. You have to actually say "PathPart('')" here because if you said "PathPart()" that would set the match to the name of the action (in this case root). Again this can be confusing since a lot of people would think PathPart() and PathPart('') would match the same thing. They don't.
CaptureArgs(0)
By saying 'CaptureArgs' you are saying 'this action is part of a chain, not the end of a chain.' It defines the number of captures following the PathPart. You'd use this to match a URL with for example an ID that you want to use in a database lookup. I understand it's not super clear :). And there's room for more confusion, because as with PathPart you have to be careful with setting the value part of the attribute since leaving it empty (saying "CaptureArgs") doesn't mean "capture no args" it means "capture unlimited args". "Args" is the same way, BTW.
Overall it's quite a bit of work and possible confusion just to say 'Match https://myapp/...". I love using Chained actions but the verbosity and oddness of some of the defaults does make it tough on people new to the framework.
Let's look in part at the "Posts" controller from that earlier blog. I'm only going to reproduce it in part so you can get the idea.
package Example::Controller::Posts;
use Moose;
use MooseX::MethodAttributes;
use feature 'signatures';
extends 'Catalyst::Controller;
# URL-PART: /posts/...
sub root :Chained('../root') PathPart('posts') CaptureArgs(0) ($self, $c, $user) {
$c->action->next(my $posts = $user->posts);
}
# URL-PART: /posts/{$id}/...
sub find :Chained('root') PathPart('') CaptureArgs(1) ($self, $c, $posts, $id) {
$c->action->next(my $post = $posts->find($id));
}
# GET /posts/{$id}
sub show :GET Chained('find') PathPart('') Args(0) ($self, $c, $post) {
}
__PACKAGE__->meta->make_immutable;
Ok so here we chained off the root action in the Root controller and actually define a single endpoint, https://myapp/posts/{id} where {id} is a placeholder for a database id representing a post. Here you can see how the "Chained" attribute is doing double duty. In the root controller it's used to declare the top of the chain, but here it's used to say "this is the action I'm chaining from". Remember in Catalyst every action has a private name which is based on the namespace of the controller it's in and the name of the method used to declare it. Generally the namespace of the controller is based on its package name, so the namespace for the Posts controller is 'posts'. However following convention we set the namespace of the Root controller to '', which is why the root action in the Root controller is named 'root', while the root action in the Posts controller is named '/posts/root'. So when the root action in the Posts controller declares
Chained('../root')
That means "I'm chaining off the action called '../root'". To aid in reusability we allow you to use relative paths in your action names (this works for creating URLs as well BTW). So think using unix style directory tranversal '/posts/../root' == '/root', which is as I already said the name of the root controller's action called root :). So what we are saying here is that we chain off the action that matches URL https://myapp/...
PathPart('posts')
This is saying 'add "posts"' to the path of the URL we are matching (https://myapp/posts). Finally:
CaptureArgs(0)
Like before we aren't capturing any args yet but we still need to have this here so that Catalyst knows this is not an endpoint but rather an intermediate action in the chain. I think it's a lot of typing just to say "I'm matching https://myapp/posts/...". Let's look at the "find" action's attributes.
Chained('root')
Again, this is saying "I'm chaining off the action named "root". Remember when declaring a relative action its always relative to the current controller, so that means we are chaining off the root action in the Posts controller (which matches https://myapp/posts/...). You could have said "Chained('/posts/root'), but that's needlessly verbose.
PathPart('')
Add no paths to the URL we are matching
CaptureArgs(1)
But capture one argument. You can see in the body of the action we grab that to do a database lookup. So this means we are matching "https://myapp/posts/{$id}..." in this action. Again it's not an endpoint to itself. The final action 'show' we look at next. Here's a breakdown of it's attributes
GET
Only match if the method is GET
Chained('find')
We're chaining off the action named 'find', which again is the here in this controller based on the relative naming.
PathPart('')
No additional paths in the URL are to be matched. When using Chained actions you'll see lots and lots of "PathPart('')" and "CaptureArgs(0)"!
Args(0)
We're not matching any more args in the path but we need to say this here to tell the dispatcher that this is an endpoint.
Ok so that's a ton of typing to create one endpoint "https://myapp/posts/{$id}"! Part of that is of course the fact that when using chaining properly you are going to break up your actions into the smallest reusable bits possible. But you also have a lot of repeated and somewhat confusing subroutine attributes, often with rules that are not immediately easy to get. Is there anything we can do to improve things?
There's been several swings at improving chaining but the one that I've found most enduring is my
Catalyst::ControllerRole::At. This is a role you can aggregate into your controllers that offers a more concise and I think more clear syntax for chaining. You can add this as a role directly to the controller, as we will do in the example for clarity, but what I usually do is declare a custom base controller for my projects that using this role so that we can reduce redundancy in our controllers. Let's redo the Root controller using this role:
package Example::Controller::Root;
use Moose;
use MooseX::MethodAttributes;
use feature 'signatures';
extends 'Catalyst::Controller;
with 'Catalyst::ControllerRole::At';
# URL-PART: /...
sub root :At('/...') ($self, $c) {
$c->action->next(my $user = $c->user);
}
__PACKAGE__->config(namespace=>'');
__PACKAGE__->meta->make_immutable;
So I reduced declaring the root of the action tree to one attribute called 'At'. Here I'm saying "At '/' (the root of the website) and ... (continue matching anything)". Improvement or not? You tell me in the comments, but it's at least shorter :). Let's see what the Posts controller looks like using it. As before lets just do one endpoint to get the idea:
package Example::Controller::Posts;
use Moose;
use MooseX::MethodAttributes;
use feature 'signatures';
extends 'Catalyst::Controller;
with 'Catalyst::ControllerRole::At';
# URL-PART: /posts/...
sub root :Via('../root') At('posts/...') ($self, $c, $user) {
$c->action->next(my $posts = $user->posts);
}
# URL-PART: /posts/{$id}/...
sub find :Via('root') At('{}/..') ($self, $c, $posts, $id) {
$c->action->next(my $post = $posts->find($id));
}
# GET /posts/{$id}
sub show :Via('find') Get('') ($self, $c, $post) {
}
__PACKAGE__->meta->make_immutable;
Everything is handled with a limited subset of attributes, basically At and Via. Lets look at the root action:
Via('../root')
Basically this replaced "Chained('../root')" but has the upside of being shorter and also it no longer does double duty of declaring either the chain start or the action we are continuing. It means "this action is via the action called '../root', or the root action of the Root controller as discussed earlier.
At('posts/...')
Match URL path 'posts' and continue (this is not an endpoint but rather an intermediate action).
Now lets look at the "find" action:
Via('root')
This action continues matching under the root action (/posts/...)
At({}/...')
A little more interesting, this is matching a placeholder of one argument and then continuing. Check the docs for Catalyst::ControllerRole::At you can see you can declare type constraints and named arguments here, but for simplicity I'm not doing that now. Lastly the "show" action:
Via('find')
Continue the match from the "find" action
Get('')
This is just an alias for ":GET At('')". When you want to match a particular HTTP method, as is often the case with endpoints, you we've setup aliases for the common ones (GET, POST, PUT, etc.). See the docs. It just saves a bit of typing.
So in the end the terminal action "show" defines an endpoint like https://myapp/posts/{$id} and it runs thru 4 actions in total (these are the action names):
ACTION NAME URL or URL part matched
/root https://myapp/...
/posts/root https://myapp/posts/...
/posts/find https://myapp/posts/{$id}/...
/posts/show https://myapp/posts/{$id}
Here's a redo of the full Posts controller from my previous blog using this:
package Example::Controller::Posts;
use Moose;
use MooseX::MethodAttributes;
use feature 'signatures';
extends 'Catalyst::Controller;
with 'Catalyst::ControllerRole::At';
# URL-PART: /posts/...
sub root :Via('../root') At('posts/...') ($self, $c, $user) {
$c->action->next(my $posts = $user->posts);
}
# GET /posts
sub list :Via('root') Get('') ($self, $c, $posts) {
}
# POST /posts
sub create :Via('root') Post('') ($self, $c, $posts) {
$posts->create($c->req->body_parameters);
}
# GET /posts/new
sub build :Via('root') Get('new') Args(0) ($self, $c, $posts) {
my $new_post = $posts->build;
}
# URL-PART: /posts/{$id}/...
sub find :Via('root') At('{}/..') ($self, $c, $posts, $id) {
$c->action->next(my $post = $posts->find($id));
}
# GET /posts/{$id}
sub show :Via('find') Get('') ($self, $c, $post) {
}
# DELETE /posts/{$id}
sub delete :Via('find') Delete('') ($self, $c, $post) {
$post->delete;
}
# GET /posts/{$id}/edit
sub edit :Via('find') Get('edit') ($self, $c, $post) {
}
# PATCH /posts/{$id}
sub update :Via('find') Patch('') ($self, $c, $post) {
$post->update($c->req->body_parameters);
}
__PACKAGE__->meta->make_immutable;
Hopefully this role makes using chained actions less verbose and more comprehensible. Thoughts? Room for improvements? You tell me!
Final Thoughts
You should review the full documentation of Catalyst::ControllerRole::At to see more examples of the different types of path matching available as well as some potentially interesting features like named arguments and type constraints on your arguments. It also offers some path aliasing to promote more reusable code. Not every idea in this role is probably a good one but unless people play with it and tell me what's good, what's bad and what's ugly I won't know the best way to refactor.
Posted on July 22, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.