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