One of my web projects has several directories which contain, essentially, static files with a little bit of dynamic content in the wrapper. This dynamic content is specific to each user—a change in navigation, some customization.
The prototype of this project split the responsibility for serving the site between the frontend web server and the backend Plack server. As you can imagine, it worked well enough for a prototype, but it clearly needed replacement once the project became serious.
I didn't want to invest a lot of effort into writing a bunch of unique controllers for each directory with templated files (/about, /contact, /resources, et cetera), but I want to share behavior between all of these routes.
Essentially I have several subdirectories in my templates/ directory which correspond to these routes. Each subdirectory contains one or more templates for each resource within that route, such as /about/jobs and /contact/press. Something in my Catalyst application needs to be able to resolve those requests to those templates.
That's easy enough.
I ended up factoring this code into a parametric role to apply to controllers because I want to reuse this code elsewhere, and because it needs a bit of customization. Here's all it is:
package Stockalyzer::Controller::Role::HasStaticPath;
# ABSTRACT: maps controller methods to static routes
use Modern::Perl;
use MooseX::Role::Parameterized;
parameter 'routes', required => 1;
parameter 'for', required => 1;
role
{
my $p = shift;
my $class = $p->for;
for my $route (@{ $p->routes })
{
my $template = $route . '.tt';
method $route => sub
{
my ($self, $c) = @_;
my $args = $c->req->args;
my $page = $args->[0] || 'index';
return $c->res->redirect( "/$route/$page" ) if $page =~ s/\..+$//;
$c->stash(
template => $template,
page => $page,
);
};
$class->config( action => { $route => { Local => '' } } );
}
};
1;
A few things stand out. This role has two required parameters, an array reference of routes to add to the composing controller and the name of the controller. The latter will be obvious shortly.
Each route corresponds to a single template file which itself has a
page
attribute. I do this so that each subdirectory can have its
own layout. (You can accomplish this in many ways with Template Toolkit, but
this approach works best for me right now.)
This action doesn't use Catalyst's own path handling to handle any
arguments, so it effectively takes as many path components as you can provide.
If there's no path component beyond the name of the route, this request
defaults to the index
page. (The redirection is a temporary
measure because the system has a few links to static pages such as
/about/jobs.html
we're changing to /about/jobs
.)
The only remaining interesting part of the code is the call to the
controller's config()
method. This is the reason for the
for
parameter to this role. Because these controller methods come
from the role, and because the actual body of the method is a closure, Catalyst
can't easily process the normal function attributes you normally use to select
special behaviors. I want to define these methods like:
sub about :Local { ... }
sub contact :Local { ... }
... and so the alternate approach is to set configuration parameters for each method. That's all the final line of code in the role application block does.
Using this code is easy. My root controller contains a single line:
with 'Stockalyzer::Controller::Role::HasStaticPath'
=> { routes => [qw( about strategy )], for => __PACKAGE__ };
The best part is that if I want to add a new subdirectory, mapping it in Catalyst means adding a single entry to this list. Better yet, if I want to add features to these subdirectories (and I do, per How Would You Track User Behavior with Plack and Catalyst?, for the purpose of cohort logging), I can add it in one place.
All of this demonstrates one of my favorite features of Modern Perl: well-designed abstractions are available when they're necessary. (One of my other favorite features of Modern Perl is that someone's probably already done this, and I just have to find the right plugin on the CPAN to make it happen.)