An Example Catalyst Plugin - Catalyst::Plugin::RunRequest
OVERVIEW
Port Web::Simple's feature run_test_request
to a Catalyst plugin.
INTRODUCTION
Generally I tell people there are few good reasons to write a Catalyst::Plugin. As a unit of reusability, its a pretty heavy hammer, since a plugin become part of your application and context, that means its available everywhere all the time and of course it also means that your $c and $app are a little bigger memory wise and you incur any startup penalties for every single request. With so many ways to wrap and reuse functionality (as a Model, ControllerRole or ActionRole, for example) there's nearly never a good reason to write a Plugin. And now that Catalyst is PSGI native, I usually say anything related to the global request or response is probably better off written as Plack::Middleware.
However, if you are looking for something that is global, and is related to application functionality, a plugin is still a rational option. Here's an example plugin I did that mimics some functionality from another Perl web framework, Web::Simple.
Web::Simple::Application->run_test_request
Web::Simple has a neat feature that makes it easy to run a sample request in a test case or via the command line. We'll leave the CLI for now (its a bit trickier based on the way Catalyst does setup) but as it turns out, mimicing the underlying function is very easy to do. It would make a rational plugin and its also a good opportunity to review some of Catalyst's PSGI guts.
Here's how it works in Web::Simple. You write a basic web application like so:
package MyWSApp; use Web::Simple; sub dispatch_request { sub (GET + /helloworld) { [ 200, [ 'Content-type', 'text/plain' ], [ 'Hello world!' ] ] }, sub (POST + /echo + %:val=) { [ 200, [ 'Content-type', 'text/plain' ], [ $_{val} ] ] }, } __PACKAGE__->run_if_script;
Now, in a test case you could write:
use Test::Most; use MyWSApp; { ok my $http_response = MyWSApp->run_test_request(GET '/helloworld'); is $http_response->content, 'Hello world!'; } { ok my $http_response = MyWSApp->run_test_request(POST '/echo', [val=>'posted']); is $http_response->content, 'posted'; } { ok my $http_response = MyWSApp->run_test_request('GET' => '/helloworld'); is $http_response->content, 'Hello world!'; } { ok my $http_response = MyWSApp->run_test_request('POST' => '/echo', {val=>'posted'}); is $http_response->content, 'posted'; } done_testing;
In Web::Simple the run_test_request
method accepts either a HTTP::Request
object (built by hand or via HTTP::Request::Common which is my personal
favored approach) or it can take a data structure so you don't need to load any
additional modules (if you look carefully at the given example, its very similar
to the HTTP::Request::Common style code). The idea here is to take any sort
of HTTP::Request and get the HTTP::Response out of it.
So that's nice, concise and simple. Sure, Catalyst::Runtime ships with Catalyst::Test and there's even more powerful testing options on CPAN, but there's something to say for having a straightup solution when writing trival test cases, or for when you want to demo some code. So, let's write a plugin for Catalyst that does this!
The Plugin
Lets take this line by line. Lets assume you've create a proper directory structure for a Perl distribution (look for links to Github near the end of the post for examples) for a new Catalyst plugin called 'Catalyst::Plugin::RunTestRequest'. Here's the start of the file that would go in 'lib/Catalyst/Plugin/RunTestRequest.pm'.
package Catalyst::Plugin::RunTestRequest; use Moose::Role; requires 'psgi_app';
In the post Moose port of Catalyst world, its best to write a new plugin
as a Moose::Role. We specify this role can only be composed into a class
that has the psgi_app
method, which we'll see in moment is needed by the
new method we are adding to your application class.
Next, let's write the method that will be composed into your application subclass:
sub run_test_request { my ($self, @req) = @_; my $http_request = $_test_request_spec_to_http_request->(@req); require HTTP::Message::PSGI; my $psgi_env = HTTP::Message::PSGI::req_to_psgi($http_request); my $psgi_response = $self->psgi_app->($psgi_env); my $http_response = HTTP::Message::PSGI::res_from_psgi($psgi_response); return $http_response; }
The first two lines creates the method and slurps up incoming args. We will
normalize @req
via an anonymous subroutine so that we are always dealing
with an instance of HTTP::Request (and I've simply cribbed a similiarly
named method from Web::Simple::Application, so I won't go into it, just give
a big shout out to the Web::Simple cabal). I'll link to the full file at
the end of the article.
Followed by the required module HTTP::Message::PSGI which lets us convert
between the various types of requests and responses we'll need to deal with.
We require
this module rather than declare it as a use
near the top of
the file so that we can avoid loading it in the cases when this method is
never called (thereby saving a bit of memory) and it we also don't call the
modules import
method, so we avoid importing unneeded functions into our
namespace.
Next we take the normalized HTTP::Request and convert it into a HashRef
that conforms to the PSGI specification. We pass this to the psgi_app
method, which is returning a PSGI application, basically a coderef that
wants that $psgi_env we just made. This is the coderef that kicks off the
full Catalyst request / response cycle. It returns a $psgi_response,
which we then convert to a HTTP::Response. Which gets returned to the
caller.
The full plugin looks like this (and again see links to Github near the end).
package Catalyst::Plugin::RunTestRequest; use Moose::Role; requires 'psgi_app'; our $VERSION = '0.001'; ## Block of code gratuitously stolen from Web::Simple::Application my $_test_request_spec_to_http_request = sub { my ($method, $path, @rest) = @_; # if it's a reference, assume a request object return $method if ref($method); if ($path =~ s/^(.*?)\@//) { my $basic = $1; require MIME::Base64; unshift @rest, 'Authorization:', 'Basic '.MIME::Base64::encode($basic); } require HTTP::Request; my $request = HTTP::Request->new($method => $path); my @params; while (my ($header, $value) = splice(@rest, 0, 2)) { unless ($header =~ s/:$//) { push @params, $header, $value; } $header =~ s/_/-/g; if ($header eq 'Content') { $request->content($value); } else { $request->headers->push_header($header, $value); } } if (($method eq 'POST' or $method eq 'PUT') and @params) { my $content = do { require URI; my $url = URI->new('http:'); $url->query_form(@params); $url->query; }; $request->header('Content-Type' => 'application/x-www-form-urlencoded'); $request->header('Content-Length' => length($content)); $request->content($content); } return $request; }; sub run_test_request { my ($self, @req) = @_; my $http_request = $_test_request_spec_to_http_request->(@req); require HTTP::Message::PSGI; my $psgi_env = HTTP::Message::PSGI::req_to_psgi($http_request); my $psgi_response = $self->psgi_app->($psgi_env); my $http_response = HTTP::Message::PSGI::res_from_psgi($psgi_response); return $http_response; } 1;
There's not a lot to it, most if it is the normalization code that lets you have a bit of flexibility using the method. Lets build a quick application.
Here's the application class:
package MyCatApp; use Catalyst 'RunTestRequest'; __PACKAGE__->setup;
Catalyst seems to get a bad rap as needing a lot of boilerplate, but really that's all you need for this simple application. Lets make a controller:
package MyCatApp::Controller::Root; use base 'Catalyst::Controller'; sub root : GET Path('/helloworld') { pop->res->body('Hello world!'); } sub test_post : POST Path('/echo') { my ($self, $c) = @_; $c->res->body( $c->req->body_parameters->{val} ); } 1;
Again, there's not a lot of work here. Its realy not many more lines than the Web::Simple version, but its spread across more files. That would make more sense down the road when you application has 20 controllers and nearly 100 URL endpoints, but for a simple, demo app like this its still not too bad :)
To be fair, Web::Simple is nice that it has built in support for matching on a POST parameter and gives you a default 'not found page', so we'd need a little more code to be completely comparable, but this is good enough for a demo.
Lets look at the test case. It looks nearly the same as the Web::Simple one:
use HTTP::Request::Common; use Test::Most; use MyCatApp; { ok my $http_response = MyCatApp->run_test_request(GET '/helloworld'); is $http_response->content, 'Hello world!'; } { ok my $http_response = MyCatApp->run_test_request(POST '/echo', [val=>'posted']); is $http_response->content, 'posted'; } { ok my $http_response = MyCatApp->run_test_request('GET' => '/helloworld'); is $http_response->content, 'Hello world!'; } { ok my $http_response = MyCatApp->run_test_request('POST' => '/echo', {val=>'posted'}); is $http_response->content, 'posted'; } done_testing;
And that's really it! Here's the full application with test cases and organized as a proper CPAN style distribution:
https://github.com/perl-catalyst/2013-Advent-Staging/tree/master/Catalyst-Plugin-RunTestRequest
Limitations
Since we run the HTTP::Request (or request specification) directly against
the psgi_app
method of Catalyst if you are using a standalone psgi
file that declares additional middleware or URL mountpoints, those additional
bits will not be tested. If you are using middleware are a critical part of
your Catalyst application, I recommend using the the new middleware
configuration option: Catalyst\PSGI-MIDDLEWARE. For mounting PSGI
applications you may prefer to consider Catalyst::Response\from_psgi_response
or look at the following independent distributions of you are on an older
version of Catalyst.
Catalyst::Plugin::EnableMiddleware, Catalyst::Action::FromPSGI.
What other things could you do with this?
I've never loved the way Catalyst::Plugin::SubRequest worked. It would be very easy to rewrite or offer another approach using this.
It might be nice to have a sort of commandline REPL that let you run test requests against your Catalyst application, with a full request/response trace.
Summary
We reviewed when one might wish to write a Catalyst plugin, what such a plugin looks like. We also took a look at how the PSGI underpinnings of Catalyst provide a useful gateway to provide novel features.
For More Information
Author
John Napiorkowski jjnapiork@cpan.org