"Next Action" - Improving Perl Catalyst Chained Actions
Tags: perl, catalystmvc, webdev, programming
Published: 2023-07-14 22:34:40

One of the features I enjoy the most about Perl's Catalyst MVC web framework is action chaining. In action chaining you can use the chain of command pattern to logically group and reuse actions that can span and share a URL.  Here's an example for a controller that is a classic CRUD style setup for webpages to view a list of posts, edit, delete and view an individual post and create a post. For ease of explanation I'm leaving off a lot of stuff I'd normally do like validation on incoming POST parameters, etc.

First a root controller that everything chains off from:

    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->stash->{user} = $c->user;
    }

    __PACKAGE__->config(namespace=>'');
    __PACKAGE__->meta->make_immutable;

Next, the posts controller that continues the chain and defines endpoints:

    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) {
      $c->stash->{posts} = $c->stash->{user}->posts;
    }

    # GET /posts
    sub list :GET Chained('root') PathPart('') Args(0) ($self, $c) {
    }

    # POST /posts
    sub create :POST Chained('root') PathPart('') Args(0) ($self, $c) {
      $c->stash->{posts}->create($c->req->body_parameters);
    }

    # GET /posts/new
    sub build :GET Chained('root') PathPart('new') Args(0) ($self, $c) {
      $c->stash->{new_post} = $c->stash->{posts}->build;
    }

    # URL-PART: /posts/{$id}/...
    sub find :Chained('root') PathPart('') CaptureArgs(1) ($self, $c, $id) {
      $c->stash->{post} = $c->stash->{posts}->find($id);
    }

    # GET /posts/{$id}
    sub show :GET Chained('find') PathPart('') ($self, $c) {
    }

    # DELETE /posts/{$id}
    sub delete :DELETE Chained('find') PathPart('') ($self, $c) {
      $c->stash->{post}->delete;
    }

    # GET /posts/{$id}/edit
    sub edit :GET Via('find') At('edit') ($self, $c) {
    }

    # PATCH /posts/{$id}
    sub update :PATCH Via('find') PathPart('') ($self, $c) {
      $c->stash->{post}->update($c->req->body_parameters);
    }

    __PACKAGE__->meta->make_immutable;

In this flow we use the stash a lot just to pass information from one action to another.  Its a lot of extra typing that makes the code look messy and in a more real life example its easy to lose track of exactly whats in the stash at a given moment.  Additionally you have a lot of namespace pollution in the stash (stash keys that are used by intermediate actions stick around to the end) and of course you have the classic issue with the stash of introducing hard to spot bugs with typos in the stash call:

    $c->stash->{Posts}...

Instead, what if there was a way to directly pass arguments from a chain action directly to its children (and only its children?).   Lets see what that would look like:

    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;`

Next, the posts controller that continues the chain and defines endpoints:

    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);
    }

    # GET /posts
    sub list :GET Chained('root') PathPart('') Args(0) ($self, $c, $posts) {
    }

    # POST /posts
    sub create :POST Chained('root') PathPart('') Args(0) ($self, $c, $posts) {
      $posts->create($c->req->body_parameters);
    }

    # GET /posts/new
    sub build :GET Chained('root') PathPart('new') Args(0) ($self, $c, $posts) {
      my $new_post = $posts->build;
    }

    # 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('') ($self, $c, $post) {
    }

    # DELETE /posts/{$id}
    sub delete :DELETE Chained('find') PathPart('') ($self, $c, $post) {
      $post->delete;
    }

    # GET /posts/{$id}/edit
    sub edit :GET Chained('find') PathPart('edit') ($self, $c, $post) {
    }

    # PATCH /posts/{$id}
    sub update :PATCH Chained('find') PathPart('') ($self, $c, $post) {
      $post->update($c->req->body_parameters);
    }

    __PACKAGE__->meta->make_immutable;

In this version, we've cleaned up the code considerably as well as tightened the relationships between actions.  There's no unneeded stash namespace pollution and of course we have named variables where a typo is going to give you a compile time error not a nasty hard to figure out error at runtime.

This is a very small tweak to chaining that doesn't monumentally change how your code works but I find that especially with very complicated applications it really aids in understanding and reduces overall boilerplate mess that you end up with in classic Catalyst chained actions. Adding this does not significantly change how chaining works, but I believe it is a small, iterative refinement that plays nice in existing code as well as in any greenfield project.

In this example we passed a single variable from one action to another, however the "next_action" method accepts lists as well.  Just be careful when doing this and the receiving action has args or captures since those get tacked onto the end of the argument list so if you need to pass arbitrary length lists you should pass it as a reference or wrap it in an object.

The next action feature is a proposed update to Catalyst chaining that exists as a pull request right now (https://github.com/perl-catalyst/catalyst-runtime/pull/184) for your review and comments.  You can view a larger example of code using it here (https://github.com/jjn1056/Valiant/tree/main/example) but just a warning that also contains some other new ideas that Catalyst developers might be unfamiliar with.  Stay tuned for more on that later :)

**One More Thing...**

When you invoke "$c->action->next(@args)" in your code, it does more than send arguments to the connected children actions.   It creates a call stack that reverses when you hit the last action in the chain, returning control back upward the action chain and allowing you to return arguments.  This
can be a great way to return control to a parent action. Example:

    sub a :Chained('/') PathPart('') CaptureArgs(0) ($self, $c) {
      my $ret = $c->action->next(my $info1 = 'aaa'); # $ret = 'from b from c'
    }

    sub b :Chained('a') PathPart('') CaptureArgs(0) ($self, $c, $info1) {
      my $ret = $c->action->next(my $info2 = 'bbb'); # $ret = 'from c';
      return "from b $ret";
    }

    sub c :Chained('b') PathPart('') Args(0) ($self, $c, $info2) {
      return 'from c';
    }

Please note however that due to existing limitation in Catalyst you will need to return only a scalar so if you want to return an array or hash of stuff you will need to make it an arrayref or hashref.