Hatena::Groupcatalyst

dann@catalyst このページをアンテナに追加 RSSフィード

2008-03-20

CatalystのREST API用のベースクラス

| 02:23 |  CatalystのREST API用のベースクラス - dann@catalyst を含むブックマーク はてなブックマーク -  CatalystのREST API用のベースクラス - dann@catalyst  CatalystのREST API用のベースクラス - dann@catalyst のブックマークコメント

を作ってみた。

  • dispatch部分は、Catalyst::Controller::Resources
    • Catalyst::Controller::RESTは、dispatchする部分が綺麗にかけないので、Catalyst::Controller::Resourcesを使う
  • エンドポイントへのデータのデシリアライズとシリアライズはCatalyst::Action::SerializeとDeserializeを使う

という形にしてみた。

これで、Content-Typeによって、シリアライザ・デシリアライザが切り替わるので、APIで複数のフォーマットに簡単に対応できるんじゃないかなぁ。かつ、エンドポイントはRailsライクに綺麗に扱えると。

Catalyst::Controller::RESTを使ってみて、一通りやりたいことはできたのだけれどあまりにdispatchしたところのコードが汚くなるので、こりゃあかんということでいいとこどりをしてみた。

APIのベースクラス

package MyApp::Web::Controller::API;
use strict;
use warnings;
use Params::Validate qw(SCALAR OBJECT);
use base 'Catalyst::Controller::Resources';

__PACKAGE__->config(
    'stash_key' => 'entity',
    'default'   => 'text/x-json',
    'map'       => {
        'text/html'          => 'YAML::HTML',
        'text/xml'           => 'XML::Simple',
        'text/x-yaml'        => 'YAML',
        'text/x-json'        => 'JSON',
    }
);

sub begin : ActionClass('Deserialize') {
}

sub end : ActionClass('Serialize') {
}


=item status_ok

Returns a "200 OK" response.  Takes an "entity" to serialize.

Example:

  $self->status_ok(
    $c,
    entity => {
        radiohead => "Is a good band!",
    }
  );

=cut

sub status_ok {
    my $self = shift;
    my $c    = shift;
    my %p    = Params::Validate::validate( @_, { entity => 1, }, );

    $c->response->status(200);
    $self->_set_entity( $c, $p{'entity'} );
    return 1;
}

=item status_created

Returns a "201 CREATED" response.  Takes an "entity" to serialize,
and a "location" where the created object can be found.

Example:

  $self->status_created(
    $c,
    location => $c->req->uri->as_string,
    entity => {
        radiohead => "Is a good band!",
    }
  );

In the above example, we use the requested URI as our location.
This is probably what you want for most PUT requests.

=cut

sub status_created {
    my $self = shift;
    my $c    = shift;
    my %p    = Params::Validate::validate(
        @_,
        {
            location => { type     => SCALAR | OBJECT },
            entity   => { optional => 1 },
        },
    );

    my $location;
    if ( ref( $p{'location'} ) ) {
        $location = $p{'location'}->as_string;
    } else {
        $location = $p{'location'};
    }
    $c->response->status(201);
    $c->response->header( 'Location' => $location );
    $self->_set_entity( $c, $p{'entity'} );
    return 1;
}

=item status_accepted

Returns a "202 ACCEPTED" response.  Takes an "entity" to serialize.

Example:

  $self->status_accepted(
    $c,
    entity => {
        status => "queued",
    }
  );

=cut

sub status_accepted {
    my $self = shift;
    my $c    = shift;
    my %p    = Params::Validate::validate( @_, { entity => 1, }, );

    $c->response->status(202);
    $self->_set_entity( $c, $p{'entity'} );
    return 1;
}

=item status_bad_request

Returns a "400 BAD REQUEST" response.  Takes a "message" argument
as a scalar, which will become the value of "error" in the serialized
response.

Example:

  $self->status_bad_request(
    $c,
    message => "Cannot do what you have asked!",
  );

=cut

sub status_bad_request {
    my $self = shift;
    my $c    = shift;
    my %p    = Params::Validate::validate( @_, { message => { type => SCALAR }, }, );

    $c->response->status(400);
    $c->log->debug( "Status Bad Request: " . $p{'message'} ) if $c->debug;
    $self->_set_entity( $c, { error => $p{'message'} } );
    return 1;
}

=item status_not_found

Returns a "404 NOT FOUND" response.  Takes a "message" argument
as a scalar, which will become the value of "error" in the serialized
response.

Example:

  $self->status_not_found(
    $c,
    message => "Cannot find what you were looking for!",
  );

=cut

sub status_not_found {
    my $self = shift;
    my $c    = shift;
    my %p    = Params::Validate::validate( @_, { message => { type => SCALAR }, }, );

    $c->response->status(404);
    $c->log->debug( "Status Not Found: " . $p{'message'} ) if $c->debug;
  $self->_set_entity( $c, { error => $p{'message'} } );
    return 1;
}

sub _set_entity {
    my $self   = shift;
    my $c      = shift;
    my $entity = shift;
    if ( defined($entity) ) {
        $c->stash->{ $self->{'stash_key'} } = $entity;
    }
    return 1;
}

1;

APIを実装するサブクラス

package MyApp::Web::Controller::API::Articles;

use strict;
use warnings;
use base 'MyApp::Web::Controller::API';

=head1 DESCRIPTION

Catalyst Controller.

=head1 METHODS

=cut


=head2 index

=cut
sub list {
    my ($self, $c) = @_;

    // Implement me!

    $self->status_ok(
            $c,
            entity => {
                some => 'data',
                foo  => 'is real bar-y',
            },
    );

}

# POST /articles
sub create {
    my ($self, $c) = @_;
    my $article_data = $c->req->data;
    use Data::Dumper;
    warn Dumper $article_data;
    // Implement me!
}

# GET /articles/{article_id}
sub show {
    my ($self, $c, $article_id) = @_;
}

# PUT /articles/{article_id}
sub update {
    my ($self, $c, $article_id) = @_;
}

# DELETE /articles/{article_id}
sub destroy {
    my ($self, $c, $article_id) = @_;
}

# GET /articles/new
sub post {
    my ($self, $c) = @_;
}

# GET /articles/{article_id}/edit
sub edit {
    my ($self, $c, $article_id) = @_;
}


=head1 AUTHOR

dann,,,

=head1 LICENSE

This library is free software, you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut

1;

簡単なテスト

GET

curl -X GET -H 'Content-Type: text/x-json' http://192.168.0.30:3000/articles

POST

curl -X POST -H 'Content-Type: text/x-yaml' -T article.yml http://192.168.0.30:3000/articles

これくらいしか動作確認してないけど... 元のコード丸ぱくりだから、まぁ動くんじゃないかしら。

TODO

  • content typeでAPIの入出力のフォーマットを切り替えるというのが流行っていない気もするけれど、その辺も気がむいたら調査しよう
トラックバック - http://catalyst.g.hatena.ne.jp/dann/20080320