Modernize Chained Actions in Perl Catalyst MVC

jjn1056

John Napiorkowski

Posted on July 22, 2023

Modernize Chained Actions in Perl Catalyst MVC

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;
Enter fullscreen mode Exit fullscreen mode

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('/')
Enter fullscreen mode Exit fullscreen mode

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('')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

This is saying 'add "posts"' to the path of the URL we are matching (https://myapp/posts). Finally:

CaptureArgs(0)
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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('')
Enter fullscreen mode Exit fullscreen mode

Add no paths to the URL we are matching

CaptureArgs(1)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Only match if the method is GET

Chained('find')
Enter fullscreen mode Exit fullscreen mode

We're chaining off the action named 'find', which again is the here in this controller based on the relative naming.

PathPart('')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Everything is handled with a limited subset of attributes, basically At and Via. Lets look at the root action:

Via('../root')
Enter fullscreen mode Exit fullscreen mode

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/...')
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

This action continues matching under the root action (/posts/...)

At({}/...')
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

Continue the match from the "find" action

Get('')
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
jjn1056
John Napiorkowski

Posted on July 22, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related