WebService-Zaqar/lib/WebService/Zaqar.pm
package WebService::Zaqar;
use strict;
use warnings;
use 5.010;
use Carp;
use autodie;
use utf8;
use Moo;
use HTTP::Request;
use JSON;
use Net::HTTP::Spore;
use List::Util qw/first/;
use Scalar::Util qw/blessed weaken/;
use Data::UUID;
use Try::Tiny;
use File::ShareDir;
our $VERSION = '0.010';
has 'base_url' => (is => 'ro',
writer => '_set_base_url');
has 'token' => (is => 'ro',
writer => '_set_token',
clearer => '_clear_token',
predicate => 'has_token');
has 'spore_client' => (is => 'ro',
lazy => 1,
builder => '_build_spore_client');
has 'spore_description_file' => (is => 'ro',
default => sub { File::ShareDir::dist_file('WebService-Zaqar', 'marconi.spore.json') });
has 'client_uuid' => (is => 'ro',
lazy => 1,
builder => '_build_uuid');
has 'wants_auth' => (is => 'ro',
default => sub { 0 });
has 'rackspace_keystone_endpoint' => (is => 'ro',
predicate => 1);
has 'rackspace_username' => (is => 'ro',
predicate => 1);
has 'rackspace_api_key' => (is => 'ro',
predicate => 1);
sub _build_uuid {
return Data::UUID->new->create_str;
}
sub _build_spore_client {
my $self = shift;
my $client = Net::HTTP::Spore->new_from_spec($self->spore_description_file,
base_url => $self->base_url);
# all payloads serialized/deserialized to/from JSON -- except if
# you're receiving 401 or 403
$client->enable('+WebService::Zaqar::Middleware::Format::JSONSometimes');
# set X-Auth-Token header to the Cloud Identity token, if
# available (local instances don't use that, for instance)
my $twin = $self; # gonna close over this one
weaken $twin;
$client->enable('+WebService::Zaqar::Middleware::Auth::DynamicHeader',
header_name => 'X-Auth-Token',
header_value_callback => sub {
# HTTP::Headers says, if the value of the
# header is undef, the field is removed
return $twin ? $twin->has_token ? $twin->token : undef : undef
});
# all requests should contain a Date header with an RFC 1123 date
$client->enable('+WebService::Zaqar::Middleware::DateHeader');
# each client using the queue should provide an UUID; the docs
# recommend that for a given client it should persist between
# restarts
$client->enable('Header',
header_name => 'Client-ID',
header_value => $self->client_uuid);
$client->enable('+WebService::Zaqar::Middleware::JustCallIt');
return $client;
}
sub do_request {
my ($self, $coderef, $options, @rest) = @_;
# here undef retries means retry until it works, 0 retries means
# don't retry, other integers mean retry that many times
my $max_retries = $options->{retries};
my $current_retries = 0;
RETRY: {
my $return_value;
try {
$return_value = $coderef->($self, @rest);
} catch {
my $exception = $_;
if (blessed($exception)
and $exception->isa('Net::HTTP::Spore::Response')) {
if ($exception->code == 401) {
if (defined $max_retries and $current_retries >= $max_retries) {
croak('Server returned 401 Unauthorized but we already retried too many times');
}
$current_retries++;
# re-authentication needed
if ($self->wants_auth) {
$self->rackspace_authenticate(
$self->rackspace_keystone_endpoint,
$self->rackspace_username,
$self->rackspace_api_key);
goto RETRY;
} else {
# ... but not wanted!
croak('Server returned 401 Unauthorized but we are not planning on authenticating!');
}
}
# rethrow the contents of the exception instead of just
# the unhelpful HTTP 400
if ($exception->code == 599) {
croak($exception->body->{error});
}
# some other SPORE exception
croak $exception;
}
# wasn't a Spore exception, rethrow
croak $exception;
};
return $return_value;
}
}
sub rackspace_authenticate {
my ($self, $cloud_identity_uri, $username, $apikey) = @_;
my $request = HTTP::Request->new('POST', $cloud_identity_uri,
[ 'Content-Type' => 'application/json' ],
JSON::encode_json({
auth => {
'RAX-KSKEY:apiKeyCredentials' => {
username => $username,
apiKey => $apikey } } }));
my $response = $self->spore_client->api_useragent->request($request);
my $content = $response->decoded_content;
my $structure = JSON::decode_json($content);
my $token = $structure->{access}->{token}->{id};
$self->_set_token($token);
# the doc says we should read the catalog to determine the
# endpoint...
# my $catalog = first { $_->{name} eq 'cloudQueues'
# and $_->{type} eq 'rax:queues' } @{$structure->{serviceCatalog}};
return $token;
}
sub BUILD {
my $self = shift;
if ($self->wants_auth
and (not $self->has_rackspace_keystone_endpoint
or not $self->has_rackspace_username
or not $self->has_rackspace_api_key)) {
croak('Authentication required but not all Rackspace attributes provided');
}
# if ($self->has_rackspace_username) {
# # uhhh, ok, so SOME Rackspace docs say this header is
# # necessary, but others don't mention it; when I add it to a
# # request it always 403s and without it it seems to work, so
# # uh, yeah.
# $self->spore_client->enable('Header',
# header_name => 'X-Project-Id',
# header_value => '921182');
# }
}
our $AUTOLOAD;
sub AUTOLOAD {
my $method_name = $AUTOLOAD;
my ($self, @rest) = @_;
my $current_class = ref $self;
$method_name =~ s/^${current_class}:://;
$self->spore_client->$method_name(@rest);
}
1;
__END__
=pod
=head1 NAME
WebService::Zaqar -- Wrapper around the Zaqar (aka Marconi) message queue API
=head1 SYNOPSIS
use WebService::Zaqar;
my $client = WebService::Zaqar->new(
# base_url => 'https://dfw.queues.api.rackspacecloud.com/',
base_url => 'http://localhost:8888',
spore_description_file => 'share/marconi.spore.json');
# for Rackspace only
my $token = $client->rackspace_authenticate('https://identity.api.rackspacecloud.com/v2.0/tokens',
$rackspace_account,
$rackspace_key);
$client->create_queue(queue_name => 'pets');
$client->post_messages(queue_name => 'pets',
payload => [
{ ttl => 120,
body => [ 'pony', 'horse', 'warhorse' ] },
{ ttl => 120,
body => [ 'little dog', 'dog', 'large dog' ] } ]);
$client->post_messages(queue_name => 'pets',
payload => [
{ ttl => 120,
body => [ 'aleax', 'archon', 'ki-rin' ] } ]);
=head1 DESCRIPTION
This library is a L<Net::HTTP::Spore>-based client for the message
queue component of OpenStack,
L<Zaqar|https://wiki.openstack.org/wiki/Marconi/specs/api/v1>
(previously known as "Marconi").
On top of allowing you to make requests to a Zaqar endpoint, this
library also supports Rackspace authentication using their L<Cloud
Identity|http://docs.rackspace.com/queues/api/v1.0/cq-gettingstarted/content/Generating_Auth_Token.html>
token system; see C<do_request>.
=head1 ATTRIBUTES
=head2 base_url
(read-only string)
The base URL for all API queries, except for the Rackspace-specific
authentication.
=head2 client_uuid
(read-only string, defaults to a new UUID)
All API queries B<should> contain a "Client-ID" header (in practice,
some appear to work without this header). If you do not provide a
value, a new one will be built with L<Data::UUID>.
The docs recommend reusing the same client UUID between restarts of
the client.
=head2 rackspace_api_key
(read-only optional string)
API key for Rackspace authentication endpoints.
=head2 rackspace_keystone_endpoint
(read-only optional string)
URL for Rackspace authentication endpoints.
=head2 rackspace_username
(read-only optional string)
Your Rackspace API username.
=head2 spore_client
(read-only object)
This is the L<Net::HTTP::Spore> client build with the
C<spore_description_file> attribute. All API method calls will be
delegated to this object.
=head2 spore_description_file
(read-only required file path or URL)
Path to the SPORE specification file or remote resource.
A spec file for Zaqar v1.0 is provided in the distribution (see
F<share/marconi.spec.json>).
=head2 token
(read-only string with default predicate)
The token is automatically set when calling C<rackspace_authenticate>
successfully. Once set, it will be sent in the "X-Auth-Token" header
with each query.
Rackspace invalidates the token after 24h, at which point all the
queries will start returning "401 Unauthorized". Consider using
C<do_request> to manage this for you.
=head2 wants_auth
(read-only boolean, defaults to false)
If this attribute is set to true, you are indicating that the endpoint
needs authentication. This means that when a request wrapped with
C<do_request> fails with "401 Unauthorized", the client will try
(re-)authenticating with C<rackspace_authenticate>, using the values
in C<rackspace_keystone_endpoint>, C<rackspace_username> and
C<rackspace_api_key>.
=head1 METHODS
=head2 DELEGATED METHODS
All methods listed in L<the API
docs|https://wiki.openstack.org/wiki/Marconi/specs/api/v1> are
implemented by the SPORE client. When a body is required, you must
provide it via the C<payload> parameter.
See the F<share/marconi.spore.json> file for the list of methods and
their parameters.
All those methods can be called with an instance of
L<WebService::Zaqar> as invocant; they will be delegated to the SPORE
client.
Unlike "regular" SPORE-based clients, you may use the special
C<__url__> parameter to provide an already-built URL directly. This
is helpful when trying to follow links provided by the API itself.
E.g. when you make a claim on a queue, the server does not return the
claim and message IDs; instead it returns URLs to the claim and
messages, which you are then supposed to call if you want to release
or update the claim, delete a message, etc.
my $response = $client->claim_messages(queue_name => 'potato');
my $claim_href = $response->header('Location');
$client->release_claim(__url__ => $claim_href);
=head2 do_request
my $response = $client->do_request(sub { $client->list_queues(limit => 20) },
{ retries => 2 },
@etc);
This method can be used to manage token generation. The first
argument should be a coderef; it will be executing within a C<try { }>
statement. If the coderef throws a blessed exception of class
L<Net::HTTP::Spore::Response> (or a subclass thereof), that response's
status is "401 Unauthorized", and C<wants_auth> was set to a true
value, C<rackspace_authenticate> will be called and the coderef will
be retried.
If the exception has another status code, it will be rethrown as-is,
without retrying. This generally leads to a somewhat cryptic "HTTP
response: 403" exception, since L<Net::HTTP::Spore::Response> objects
stringify to their status code. If the status code was 599 (internal
exception), the response's error message will be thrown instead.
If the exception is not a L<Net::HTTP::Spore::Response> instance at
all, it will be rethrown directly.
Otherwise, the coderef's return value is returned.
The second argument is a hashref of options. Currently only "retries"
is implemented:
=over 4
=item if "retries" is undefined or not provided, C<do_request> will
retry indefinitely until successful
=item if "retries" is 0, C<do_request> will not retry
=item if "retries" is any other integer, C<do_request> will retry up
to that many times.
=back
The coderef will be called with the original invocant of C<do_request>
and the rest of the arguments of C<do_request> as parameters.
=head2 rackspace_authenticate
my $token = $client->rackspace_authenticate('https://identity.api.rackspacecloud.com/v2.0/tokens',
$rackspace_account,
$rackspace_key);
Sends an HTTP request to a L<Cloud
Identity|http://docs.rackspace.com/queues/api/v1.0/cq-gettingstarted/content/Generating_Auth_Token.html>
endpoint (or compatible) and sets the token received.
See also L</token>.
=head1 SPORE MIDDLEWARES ENABLED
The following modifications are applied to requests before they are
made, in order:
=over 4
=item serializing the body to JSON
=item setting the C<X-Auth-Token> header to the authentication token,
if available
=item setting the C<Date> header to the current date in RFC 1123
format
=item setting the C<Client-ID> header to the value of the
C<client_uuid> attribute
=item if the C<__url__> parameter is provided to the method call,
replace the request path and querystring params with its value
=back
The following modifications are applied to responses before they are
returned, in order:
=over 4
=item deserializing the body from JSON, except for 401 and 403
responses, which are likely to come from Keystone instead and are
plain text.
=back
=head1 SEE ALSO
L<Net::HTTP::Spore>
=head1 AUTHOR
Fabrice Gabolde <fgabolde@weborama.com>
=head1 COPYRIGHT AND LICENSE
Copyright (C) 2014 Weborama
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
02110-1301, USA.
=cut