Chouette - REST API Framework
Chouette is a framework for making HTTP services. It is primarily designed for services that implement REST-like APIs using application/json as input and application/x-www-form-urlencoded as output, although this is somewhat flexible.
application/json
application/x-www-form-urlencoded
Why "chouette"? A backgammon chouette is a fast-paced, exciting game with lots going on at once, kind of like an asynchronous REST API server. :)
Chouette was extracted from numerous services I have built before, and its main purpose is to glue together the following of my modules in the way they were designed to be used:
Allows us to perform blocking operations without holding up other requests.
Makes exception handling simple and convenient. You can die anywhere and it will only affect the request being currently handled.
die
For random identifiers such as session tokens (obviously).
Structured logging, properly integrated with AnyEvent::Task so your tasks can log messages into the proper request log contexts.
Note that Chouette also depends on Log::Defer::Viz so log-defer-viz will be available for viewing logs.
log-defer-viz
To store the logs in files, and rotate them periodically. Also maintains a current symlink so you can simply run the following in a shell and you'll always see the latest logs as you need them:
$ log-defer-viz -F /var/myapi/logs/myapi.current.log
Chouette will always depend on the above modules, so if your app uses them it is sufficient to depend on Chouette alone.
Chouette
To start a server, create a Chouette object. The constructor accepts a hash ref with the following parameters. See the bin/myapi file below for a full example.
bin/myapi
config_file
This path is where the config file will be read from. If this parameter is not provided then no file will be used.
The file's format is YAML. The only required parameters in the file are var_dir and listen (though these can be defaulted with the config_defaults parameter below).
var_dir
listen
config_defaults
This hash is where you provide default config values. These values can be overridden by the config file.
You can use the config store for values specific to your application (it is accessible with the config method of the context), but here are the values that Chouette itself looks for:
config
var_dir - This directory must exist and be writable. Chouette will use this to store log files and AnyEvent::Task sockets.
listen - This is the location the Chouette server will listen on. Examples: 8080 127.0.0.1:8080 unix:/var/myapi/myapi.socket
8080
127.0.0.1:8080
unix:/var/myapi/myapi.socket
logging.file_prefix - The prefix for log file names (default is app).
logging.file_prefix
app
logging.timezone - Either gmtime or localtime (gmtime is default, see Log::File::Rolling).
logging.timezone
gmtime
localtime
middleware
Any array-ref of Plack::Middleware packages. Either strings representing packages, or an array-ref where the first element is the package and the rest are the arguments to the middleware.
The strings representing packages can either be prefixed with Plack::Middleware:: or not. If not, it will try the package as is and if that doesn't exist, it will try adding the Plack::Middleware:: prefix.
Plack::Middleware::
middleware => [ 'Plack::Middleware::ContentLength', 'ETag', ['Plack::Middleware::CrossOrigin', origins => '*'], ],
pre_route
A package and function that will be called with a context and callback. If the function determines the request processing should continue, it should call the callback.
See the lib/MyAPI/Auth.pm file below for an example of the function.
lib/MyAPI/Auth.pm
routes
Routes are specified when you create the Chouette object.
routes => { '/myapi/resource' => { POST => 'MyAPI::Resource::create', GET => 'MyAPI::Resource::get_all', }, '/myapi/resource/:resource_id' => { GET => 'MyAPI::Resource::get_by_id', POST => sub { my $c = shift; die "400: can't update ID " . $c->route_params->{resource_id}; }, }, },
For each route, it will try to require the package specified, and obtain the function specified for each HTTP method. If the package or function doesn't exists, an error will be thrown.
require
You can use :name elements in your routes to extract parameters. They are accessible via the route_params method of the context (see lib/MyAPI/Resource.pm below).
:name
route_params
lib/MyAPI/Resource.pm
Note that routes are combined with Regexp::Assemble so don't worry about having lots of routes, it doesn't loop over each one.
See the bin/myapi file below for an example.
tasks
This is a hash-ref of AnyEvent::Task servers/clients to create.
tasks => { db => { pkg => 'LPAPI::Task::DB', checkout_caching => 1, client => { timeout => 20, }, server => { hung_worker_timeout => 60, }, }, },
checkout_caching means that if a checkout is obtained and released, it will be maintained for the duration of the request and if another checkout for this task is obtained, then the original will be returned. This is useful for DBI for example, because we want the authenticate handler to run in the same transaction as the handler (for both correctness and efficiency reasons).
checkout_caching
Additional arguments to AnyEvent::Task::Client and <AnyEvent::Task::Server> can be passed in via client and server.
client
server
See the bin/myapi and lib/MyAPI/Task/PasswordHasher.pm files for an example.
lib/MyAPI/Task/PasswordHasher.pm
quiet
If set, suppress the "welcome" message:
=============================================================================== Chouette 0.100 PID = 31713 UID/GIDs = 1000/1000 4 20 24 27 30 46 113 129 1000 Listening on: http://0.0.0.0:8080 Follow log messages: log-defer-viz -F /var/myapi/logs/myapi.current.log ===============================================================================
After the Chouette object is obtained, you should call serve or run. They are basically the same except serve returns whereas run enters the AnyEvent event loop. These are equivalent:
serve
run
$chouette->run;
and
$chouette->serve; AE::cv->recv;
There is a Chouette::Context object passed into every handler. Typically we name it $c. It represents the current request and various related items.
Chouette::Context
$c
respond
The respond method sends a JSON response, which will be encoded from the first argument:
$c->respond({ a => 1, b => 2, });
Note: After responding, this method returns and your code continues. If you call respond again, an error will be logged but the second response will not be sent (it can't be -- the connection is probably already closed). If you wish to stop processing, you can die with the result from respond since it returns a special object for this purpose:
die $c->respond({ a => 1, });
respond takes an optional second argument which is the HTTP response code (defaults to 200):
$c->respond({ error => "access denied" }, 403);
Note that processing continues here again. If you wish to terminate the processing right away, prefix with die as above, or use the following shortcut:
die "403: access denied";
If you are happy with the Feersum default message ("Forbidden" in this case) you can just do:
die 403;
done
If you wish to stop processing but not send a response:
$c->done;
You will need to send a response later, usually from an async callback. Note: If the last reference to the context is destroyed without a response being sent, a 500 "internal server error" response will be sent.
You don't need to call done, you can just return from the handler. done is just for convenience if you are deeply nested in callbacks and don't want to worry about writing a bunch of returning logic.
return
respond_raw
Similar to respond except it doesn't assume JSON encoding:
$c->respond_raw(200, 'text/plain', 'some plain text');
logger
Returns the Log::Defer object associated with the request:
$c->logger->info("some stuff is happening"); { my $timer = $c->logger->timer('doing big_computation'); big_computation(); }
See the Log::Defer docs for more details. For viewing the log messages, check out Log::Defer::Viz.
Returns the config hash. See the "CHOUETTE OBJECT" section for details.
req
Returns the Plack::Request object created by this request.
my $name = $c->req->parameters->{name};
res
You would think this would return a Plack::Response object but this isn't yet implemented and will instead throw an error.
generate_token
Generates a Session::Token random string. The Session::Token generator is created when the first request comes in so as to avoid "cold" entropy pool immediately after a reboot (see Session::Token docs).
task
Returns an <AnyEvent::Task> checkout object for the task with the given name:
$c->task('db')->selectrow_hashref(q{ SELECT * FROM sometable WHERE id = ? }, undef, $id, sub { my ($dbh, $row) = @_; die $c->respond($row); });
See AnyEvent::Task for more details.
These files are a complete-ish Chouette application that I have extracted from a real-world app. Warning: untested!
#!/usr/bin/env perl use common::sense; use Chouette; my $chouette = Chouette->new({ config_file => '/etc/myapi.conf', config_defaults => { var_dir => '/var/myapi', listen => '8080', logging => { file_prefix => 'myapi', timezone => 'localtime', }, }, middleware => [ 'Plack::Middleware::ContentLength', ], pre_route => 'MyAPI::Auth::authenticate', routes => { '/myapi/unauth/login' => { POST => 'MyAPI::User::login', }, '/myapi/resource' => { POST => 'MyAPI::Resource::create', GET => 'MyAPI::Resource::get_all', }, '/myapi/resource/:resource_id' => { GET => 'MyAPI::Resource::get_by_id', }, }, tasks => { passwd => { pkg => 'MyAPI::Task::PasswordHasher', }, db => { pkg => 'MyAPI::Task::DB', checkout_caching => 1, ## so same dbh is used in authenticate and handler }, }, }); $chouette->run;
package MyAPI::Auth; use common::sense; sub authenticate { my ($c, $cb) = @_; if ($c->{env}->{PATH_INFO} =~ m{^/myapi/unauth/}) { return $cb->(); } my $session = $c->req->parameters->{session}; $c->task('db')->selectrow_hashref(q{ SELECT user_id FROM session WHERE session_token = ? }, undef, $session, sub { my ($dbh, $row) = @_; die 403 if !$row; $c->{user_id} = $row->{user_id}; $cb->(); }); } 1;
lib/MyAPI/User.pm
package MyAPI::User; use common::sense; sub login { my $c = shift; my $username = $c->req->parameters->{username}; my $password = $c->req->parameters->{password}; $c->task('db')->selectrow_hashref(q{ SELECT user_id, password_hashed FROM myuser WHERE username = ? }, undef, $username, sub { my ($dbh, $row) = @_; die 403 if !$row; $c->task('passwd')->verify_password($row->{password_hashed}, $password, sub { die 403 if !$_[1]; my $session_token = $c->generate_token(); $dbh->do(q{ INSERT INTO session (session_token, user_id) VALUES (?, ?) }, undef, $session_token, $row->{user_id}, sub { $dbh->commit(sub { die $c->respond({ sess => $session_token }); }); }); }); }); } 1;
package MyAPI::Auth; use common::sense; sub create { my $c = shift; die "500 not implemented"; } sub get_all { $c->logger->warn("denying access to get_all"); die 403; } sub get_by_id { my $c = shift; my $resource_id = $c->route_params->{resource_id}; die $c->respond({ resource_id => $resource_id, }); } 1;
package MyAPI::Task::PasswordHasher; use common::sense; use Authen::Passphrase::BlowfishCrypt; use Encode; sub new { my ($class, %args) = @_; my $self = {}; bless $self, $class; open($self->{dev_urandom}, '<:raw', '/dev/urandom') || die "open urandom: $!"; setpriority(0, $$, 19); ## renice our process so we don't hold up more important processes return $self; } sub hash_password { my ($self, $plaintext_passwd) = @_; read($self->{dev_urandom}, my $salt, 16) == 16 || die "bad read from urandom"; return Authen::Passphrase::BlowfishCrypt->new(cost => 10, salt => $salt, passphrase => encode_utf8($plaintext_passwd // '')) ->as_crypt; } sub verify_password { my ($self, $crypted_passwd, $plaintext_passwd) = @_; return Authen::Passphrase::BlowfishCrypt->from_crypt($crypted_passwd // '') ->match(encode_utf8($plaintext_passwd // '')); } 1;
lib/MyAPI/Task/DB.pm
package MyAPI::Task::DB; use common::sense; use AnyEvent::Task::Logger; use DBI; sub new { my $config = shift; my $dbh = DBI->connect("dbi:Pg:dbname=myapi", '', '', {AutoCommit => 0, RaiseError => 1, PrintError => 0, }) || die "couldn't connect to db"; return $dbh; } sub CHECKOUT_DONE { my ($dbh) = @_; $dbh->rollback; } 1;
More documentation can be found in the modules linked in the DESCRIPTION section.
Chouette github repo
Doug Hoyte, <doug@hcsw.org>
<doug@hcsw.org>
Copyright 2017 Doug Hoyte.
This module is licensed under the same terms as perl itself.
To install Chouette, copy and paste the appropriate command in to your terminal.
cpanm
cpanm Chouette
CPAN shell
perl -MCPAN -e shell install Chouette
For more information on module installation, please visit the detailed CPAN module installation guide.