Porting Reddit's URL Structure to Catalyst Using Chaining

Overview

When writing documentation and tutorials, it is very difficult not to bias youself towards contrived examples that conventiently avoid edge cases; or even not so edgey cases that don't lend themselves so easily to your model.

This tutorial seeks to do the opposite: take a URL that exemplifies some common cases where Chaining isn't as straightforward.

Reddit's URL structure has a pretty straightforward hierarchy, but requires some branching of the Chaining model to port to Catalyst.

Take a URL like: http://www.reddit.com/r/perl/comments/2c7y5p/perl_in_one_image/cjczh8b

It's a link to a comment on a post in a subreddit on reddit.

Each part of the URL has hierarchical significance as well as resoving to a resource:

http://www.reddit.com/ -> homepage
http://www.reddit.com/r -> redirect to /subreddits
http://www.reddit.com/r/perl -> homepage for $subreddit
http://www.reddit.com/r/perl/comments -> browse comments for $subreddit
http://www.reddit.com/r/perl/comments/2c7y5p -> comments page for a post
http://www.reddit.com/r/perl/comments/2c7y5p/perl_in_one_image -> comments page for a post
http://www.reddit.com/r/perl/comments/2c7y5p/perl_in_one_image/cjczh8b -> permalink for a specific comment

Trying to port this to Catalyst, the things that stick out are:

How do you have a URL that Actions chain off of but also is an endpoint itself? ( pretty much every URL above )

How do you represent a URL like '/r/perl/comments/2c7y5p/perl_in_one_image/cjczh8b' where the last three path parts are dynamic, without cheating and just having Args(3) do a custom dispatch?

The resulting app is at repo, and the important bits are below.

Synopsis

Packages are in separate files, but mushed together here for brevity.

    package Reddit::PP::Web::Controller::Root;

    #/ midpoint
    sub base : CaptureArgs(0) : PathPart('') : Chained('/') { ...

    #/ endpoint
    sub base_index : Args(0) : PathPart('') : Chained('base') { ...

    package Reddit::PP::Web::Controller::Subreddit;

    # /r/$subreddit_name midpoint
    sub base : CaptureArgs(1) : PathPart('r') : Chained('/root/base') { ...

    # /r endpoint (redirect)
    sub base_index : Args(0) : PathPart('') : Chained('base') { ...

    package Reddit::PP::Web::Controller::Subreddit::Comments;

    # /r/$subreddit_name/comments midpoint
    sub base : CaptureArgs(0) : PathPart('comments') : ChainedParent { ...

    # /r/$subreddit_name/comments endpoint
    sub browse : Args(0) : PathPart('') : Chained('base') { ...

    # /r/$subreddit_name/comments/$post_id endpoint
    sub view : Args(1) : PathPart('comments') : Chained('/subreddit/base') { ...

    # /r/$subreddit_name/comments/$post_id/$post_canonical_title midpoint
    sub with_title_base : CaptureArgs(2) : PathPart('comments') :
        Chained('/subreddit/base') { ...

    # /r/$subreddit_name/comments/$post_id/$post_canonical_title endpoint
    sub with_title : Args(0) : PathPart('') : Chained('with_title_base') { ...

    # /r/$subreddit_name/comments/$post_id/$post_canonical_title/$comment_id endpoint
    sub permalink_view : Args(1) : PathPart('') Chained('with_title_base') { ...

Action summary

    [debug] Loaded Chained actions:

    ----------------------+-------------------------------------------
    Path Spec             | Private
    ----------------------+-------------------------------------------
    /                     | /root/base (0)
                          | => /root/base_index
    /r/*                  | /root/base (0)
                          | -> /subreddit/base (1)
                          | => /subreddit/base_index
    /r/*/comments         | /root/base (0)
                          | -> /subreddit/base (1)
                          | -> /subreddit/comments/base (0)
                          | => /subreddit/comments/browse
    /r/*/comments/*/*/*   | /root/base (0)
                          | -> /subreddit/base (1)
                          | -> /subreddit/comments/with_title_base (2)
                          | => /subreddit/comments/permalink_view
    /r/*/comments/*       | /root/base (0)
                          | -> /subreddit/base (1)
                          | => /subreddit/comments/view
    /r/*/comments/*/*     | /root/base (0)
                          | -> /subreddit/base (1)
                          | -> /subreddit/comments/with_title_base (2)
                          | => /subreddit/comments/with_title
    ----------------------+-------------------------------------------

Configuration Description

Args, CaptureArgs

If you notice, every Action with Args is marked as an 'endpoint,' and every Action with CaptureArgs is marked as a 'midpoint.'

The Catalyst::DispatchType::Chained doc currently refer to what I'm calling midpoints as "The path parts that aren't endpoints."

A midpoint does not get a URL. You can think of it as a private method in your Web App. An endpoint does get a URL, it's like a public method that is actually open to the public - if your webapp is on the internet.

PathPart

PathPart is conceptually pretty simple to understand. It is the part of the URL routed to this Action. The default is the Action name itself. Really you'll see three types of arguments to PathPart

Not having it present at all: same URL as the name of the Action.

Present with a string: ex - base : CaptureArgs(0) : PathPart('comments')

Specify that you want 'comments' to match, not 'base.'

Zero width string: Match based on what the Action is chained to: ex - base_index : Args(0) : PathPart('') : Chained('base')

Specify that you want to match on the same URL as 'base.'

Chained, ChainedParent

Chained specifies what action to attach to. Without a leading slash it will search the same Controller as that Action.

With a leading slash it will resolve the 'absolute' path of the Action.

ChainedParent just means chain to the Action with the same name as this Action, but one level up:

    sub base : CaptureArgs(0) : PathPart('comments') : ChainedParent { ...

Notes

Some common Catalyst practices used in this App

Naming the top level midpoint Action in a Controller 'base' and top level endpoint action 'base_index' is a pretty common way of distinguishing those Actions.

Naming the top level Controller 'Root' to represent '/' The naming refers to the root of the path, it's not some super controller that will sudo make you a sandwich.

Using a Controller per significant PathPart is not strictly neccesary; the whole App could be defined in the 'Root' Controller, but defining ::Subreddit and ::Subbredit::Comments will allow for much cleaner Controllers later on as the code base grows and ages.

Some uncommon Catalyst practices used in this App

Having an init method in Web.pm where __PACKAGE__->setup and __PACKAGE__->meta->make_immutable are called, rather than having it just at the bottom makes it a little more verbose in the plackup script ( you need to call Reddit::PP::Web->init manually ) but allows for easier injection of dependencies and/or config ( ex: Reddit::PP::Web->init({ path_to_templates => '$FindBin::Bin/../root", configuration => $myconfig->{catalyst} })

    use base 'Catalyst::Controller'
    use Moose

instead of extends 'Catalyst::Controller' allows you to use attributes without a hideous BEGIN {} block in your Controllers.

See Also

Author