DEV Community

John Napiorkowski
John Napiorkowski

Posted on

Perl Catalyst: Thoughts on Chained Actions

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

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

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

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

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)