File Coverage

blib/lib/WebService/Zaqar.pm
Criterion Covered Total %
statement 28 30 93.3
branch n/a
condition n/a
subroutine 10 10 100.0
pod n/a
total 38 40 95.0


line stmt bran cond sub pod time code
1             package WebService::Zaqar;
2              
3 2     2   215342 use strict;
  2         3  
  2         63  
4 2     2   9 use warnings;
  2         3  
  2         46  
5 2     2   32 use 5.010;
  2         8  
  2         58  
6 2     2   6 use Carp;
  2         2  
  2         136  
7 2     2   10 use autodie;
  2         1  
  2         14  
8 2     2   7119 use utf8;
  2         3  
  2         19  
9              
10 2     2   1169 use Moo;
  2         23637  
  2         10  
11 2     2   4241 use HTTP::Request;
  2         36371  
  2         66  
12 2     2   1232 use JSON;
  2         17958  
  2         6  
13 2     2   610 use Net::HTTP::Spore;
  0            
  0            
14             use List::Util qw/first/;
15             use Scalar::Util qw/blessed/;
16             use Data::UUID;
17             use Try::Tiny;
18              
19             our $VERSION = '0.007';
20              
21             has 'base_url' => (is => 'ro',
22             writer => '_set_base_url');
23             has 'token' => (is => 'ro',
24             writer => '_set_token',
25             clearer => '_clear_token',
26             predicate => 'has_token');
27             has 'spore_client' => (is => 'ro',
28             lazy => 1,
29             builder => '_build_spore_client');
30             has 'spore_description_file' => (is => 'ro',
31             required => 1);
32             has 'client_uuid' => (is => 'ro',
33             lazy => 1,
34             builder => '_build_uuid');
35              
36             has 'wants_auth' => (is => 'ro',
37             default => sub { 0 });
38             has 'rackspace_keystone_endpoint' => (is => 'ro',
39             predicate => 1);
40             has 'rackspace_username' => (is => 'ro',
41             predicate => 1);
42             has 'rackspace_api_key' => (is => 'ro',
43             predicate => 1);
44              
45             sub _build_uuid {
46             return Data::UUID->new->create_str;
47             }
48              
49             sub _build_spore_client {
50             my $self = shift;
51             my $client = Net::HTTP::Spore->new_from_spec($self->spore_description_file,
52             base_url => $self->base_url);
53             # all payloads serialized/deserialized to/from JSON -- except if
54             # you're receiving 401 or 403
55             $client->enable('+WebService::Zaqar::Middleware::Format::JSONSometimes');
56             # set X-Auth-Token header to the Cloud Identity token, if
57             # available (local instances don't use that, for instance)
58             $client->enable('+WebService::Zaqar::Middleware::Auth::DynamicHeader',
59             header_name => 'X-Auth-Token',
60             header_value_callback => sub {
61             # HTTP::Headers says, if the value of the
62             # header is undef, the field is removed
63             return $self->has_token ? $self->token : undef
64             });
65             # all requests should contain a Date header with an RFC 1123 date
66             $client->enable('+WebService::Zaqar::Middleware::DateHeader');
67             # each client using the queue should provide an UUID; the docs
68             # recommend that for a given client it should persist between
69             # restarts
70             $client->enable('Header',
71             header_name => 'Client-ID',
72             header_value => $self->client_uuid);
73             $client->enable('+WebService::Zaqar::Middleware::JustCallIt');
74             return $client;
75             }
76              
77             sub do_request {
78             my ($self, $coderef, $options, @rest) = @_;
79             # here undef retries means retry until it works, 0 retries means
80             # don't retry, other integers mean retry that many times
81             my $max_retries = $options->{retries};
82             my $current_retries = 0;
83             RETRY: {
84             my $return_value;
85             try {
86             $return_value = $coderef->($self, @rest);
87             } catch {
88             my $exception = $_;
89             if (blessed($exception)
90             and $exception->isa('Net::HTTP::Spore::Response')) {
91             if ($exception->code == 401) {
92             if (defined $max_retries and $current_retries >= $max_retries) {
93             croak('Server returned 401 Unauthorized but we already retried too many times');
94             }
95             $current_retries++;
96             # re-authentication needed
97             if ($self->wants_auth) {
98             $self->rackspace_authenticate(
99             $self->rackspace_keystone_endpoint,
100             $self->rackspace_username,
101             $self->rackspace_api_key);
102             goto RETRY;
103             } else {
104             # ... but not wanted!
105             croak('Server returned 401 Unauthorized but we are not planning on authenticating!');
106             }
107             }
108             # rethrow the contents of the exception instead of just
109             # the unhelpful HTTP 400
110             if ($exception->code == 599) {
111             croak($exception->body->{error});
112             }
113             # some other SPORE exception, try to display useful stuff
114             my $body = $exception->body;
115             croak(sprintf(q{HTTP %s: %s},
116             $exception->code,
117             ref($exception->body) ? JSON::encode_json($exception->body)
118             : $exception->body || '(no response contents)'))
119             }
120             # wasn't a Spore exception, rethrow
121             croak $exception;
122             };
123             return $return_value;
124             }
125             }
126              
127             sub rackspace_authenticate {
128             my ($self, $cloud_identity_uri, $username, $apikey) = @_;
129             my $request = HTTP::Request->new('POST', $cloud_identity_uri,
130             [ 'Content-Type' => 'application/json' ],
131             JSON::encode_json({
132             auth => {
133             'RAX-KSKEY:apiKeyCredentials' => {
134             username => $username,
135             apiKey => $apikey } } }));
136             my $response = $self->spore_client->api_useragent->request($request);
137             my $content = $response->decoded_content;
138             my $structure = JSON::decode_json($content);
139             my $token = $structure->{access}->{token}->{id};
140             $self->_set_token($token);
141             # the doc says we should read the catalog to determine the
142             # endpoint...
143             # my $catalog = first { $_->{name} eq 'cloudQueues'
144             # and $_->{type} eq 'rax:queues' } @{$structure->{serviceCatalog}};
145             return $token;
146             }
147              
148             sub BUILD {
149             my $self = shift;
150             if ($self->wants_auth
151             and (not $self->has_rackspace_keystone_endpoint
152             or not $self->has_rackspace_username
153             or not $self->has_rackspace_api_key)) {
154             croak('Authentication required but not all Rackspace attributes provided');
155             }
156             # if ($self->has_rackspace_username) {
157             # # uhhh, ok, so SOME Rackspace docs say this header is
158             # # necessary, but others don't mention it; when I add it to a
159             # # request it always 403s and without it it seems to work, so
160             # # uh, yeah.
161             # $self->spore_client->enable('Header',
162             # header_name => 'X-Project-Id',
163             # header_value => '921182');
164             # }
165             }
166              
167             our $AUTOLOAD;
168             sub AUTOLOAD {
169             my $method_name = $AUTOLOAD;
170             my ($self, @rest) = @_;
171             my $current_class = ref $self;
172             $method_name =~ s/^${current_class}:://;
173             $self->spore_client->$method_name(@rest);
174             }
175              
176             1;
177             __END__
178             =pod
179              
180             =head1 NAME
181              
182             WebService::Zaqar -- Wrapper around the Zaqar (aka Marconi) message queue API
183              
184             =head1 SYNOPSIS
185              
186             use WebService::Zaqar;
187             my $client = WebService::Zaqar->new(
188             # base_url => 'https://dfw.queues.api.rackspacecloud.com/',
189             base_url => 'http://localhost:8888',
190             spore_description_file => 'share/marconi.spore.json');
191            
192             # for Rackspace only
193             my $token = $client->rackspace_authenticate('https://identity.api.rackspacecloud.com/v2.0/tokens',
194             $rackspace_account,
195             $rackspace_key);
196            
197             $client->create_queue(queue_name => 'pets');
198             $client->post_messages(queue_name => 'pets',
199             payload => [
200             { ttl => 120,
201             body => [ 'pony', 'horse', 'warhorse' ] },
202             { ttl => 120,
203             body => [ 'little dog', 'dog', 'large dog' ] } ]);
204             $client->post_messages(queue_name => 'pets',
205             payload => [
206             { ttl => 120,
207             body => [ 'aleax', 'archon', 'ki-rin' ] } ]);
208              
209             =head1 DESCRIPTION
210              
211             This library is a L<Net::HTTP::Spore>-based client for the message
212             queue component of OpenStack,
213             L<Zaqar|https://wiki.openstack.org/wiki/Marconi/specs/api/v1>
214             (previously known as "Marconi").
215              
216             On top of allowing you to make requests to a Zaqar endpoint, this
217             library also supports Rackspace authentication using their L<Cloud
218             Identity|http://docs.rackspace.com/queues/api/v1.0/cq-gettingstarted/content/Generating_Auth_Token.html>
219             token system; see C<do_request>.
220              
221             =head1 ATTRIBUTES
222              
223             =head2 base_url
224              
225             (read-only string)
226              
227             The base URL for all API queries, except for the Rackspace-specific
228             authentication.
229              
230             =head2 client_uuid
231              
232             (read-only string, defaults to a new UUID)
233              
234             All API queries B<should> contain a "Client-ID" header (in practice,
235             some appear to work without this header). If you do not provide a
236             value, a new one will be built with L<Data::UUID>.
237              
238             The docs recommend reusing the same client UUID between restarts of
239             the client.
240              
241             =head2 rackspace_api_key
242              
243             (read-only optional string)
244              
245             API key for Rackspace authentication endpoints.
246              
247             =head2 rackspace_keystone_endpoint
248              
249             (read-only optional string)
250              
251             URL for Rackspace authentication endpoints.
252              
253             =head2 rackspace_username
254              
255             (read-only optional string)
256              
257             Your Rackspace API username.
258              
259             =head2 spore_client
260              
261             (read-only object)
262              
263             This is the L<Net::HTTP::Spore> client build with the
264             C<spore_description_file> attribute. All API method calls will be
265             delegated to this object.
266              
267             =head2 spore_description_file
268              
269             (read-only required file path or URL)
270              
271             Path to the SPORE specification file or remote resource.
272              
273             A spec file for Zaqar v1.0 is provided in the distribution (see
274             F<share/marconi.spec.json>).
275              
276             =head2 token
277              
278             (read-only string with default predicate)
279              
280             The token is automatically set when calling C<rackspace_authenticate>
281             successfully. Once set, it will be sent in the "X-Auth-Token" header
282             with each query.
283              
284             Rackspace invalidates the token after 24h, at which point all the
285             queries will start returning "401 Unauthorized". Consider using
286             C<do_request> to manage this for you.
287              
288             =head2 wants_auth
289              
290             (read-only boolean, defaults to false)
291              
292             If this attribute is set to true, you are indicating that the endpoint
293             needs authentication. This means that when a request wrapped with
294             C<do_request> fails with "401 Unauthorized", the client will try
295             (re-)authenticating with C<rackspace_authenticate>, using the values
296             in C<rackspace_keystone_endpoint>, C<rackspace_username> and
297             C<rackspace_api_key>.
298              
299             =head1 METHODS
300              
301             =head2 DELEGATED METHODS
302              
303             All methods listed in L<the API
304             docs|https://wiki.openstack.org/wiki/Marconi/specs/api/v1> are
305             implemented by the SPORE client. When a body is required, you must
306             provide it via the C<payload> parameter.
307              
308             See the F<share/marconi.spore.json> file for the list of methods and
309             their parameters.
310              
311             All those methods can be called with an instance of
312             L<WebService::Zaqar> as invocant; they will be delegated to the SPORE
313             client.
314              
315             Unlike "regular" SPORE-based clients, you may use the special
316             C<__url__> parameter to provide an already-built URL directly. This
317             is helpful when trying to follow links provided by the API itself.
318             E.g. when you make a claim on a queue, the server does not return the
319             claim and message IDs; instead it returns URLs to the claim and
320             messages, which you are then supposed to call if you want to release
321             or update the claim, delete a message, etc.
322              
323             my $response = $client->claim_messages(queue_name => 'potato');
324             my $claim_href = $response->header('Location');
325             $client->release_claim(__url__ => $claim_href);
326              
327             =head2 do_request
328              
329             my $response = $client->do_request(sub { $client->list_queues(limit => 20) },
330             { retries => 2 },
331             @etc);
332              
333             This method can be used to manage token generation. The first
334             argument should be a coderef; it will be executing within a C<try { }>
335             statement. If the coderef throws a blessed exception of class
336             L<Net::HTTP::Spore::Response> (or a subclass thereof), that response's
337             status is "401 Unauthorized", and C<wants_auth> was set to a true
338             value, C<rackspace_authenticate> will be called and the coderef will
339             be retried.
340              
341             If the exception has another status code, it will be rethrown as-is,
342             without retrying. This generally leads to a somewhat cryptic "HTTP
343             response: 403" exception, since L<Net::HTTP::Spore::Response> objects
344             stringify to their status code. If the status code was 599 (internal
345             exception), the response's error message will be thrown instead.
346              
347             If the exception is not a L<Net::HTTP::Spore::Response> instance at
348             all, it will be rethrown directly.
349              
350             Otherwise, the coderef's return value is returned.
351              
352             The second argument is a hashref of options. Currently only "retries"
353             is implemented:
354              
355             =over 4
356              
357             =item if "retries" is undefined or not provided, C<do_request> will
358             retry indefinitely until successful
359              
360             =item if "retries" is 0, C<do_request> will not retry
361              
362             =item if "retries" is any other integer, C<do_request> will retry up
363             to that many times.
364              
365             =back
366              
367             The coderef will be called with the original invocant of C<do_request>
368             and the rest of the arguments of C<do_request> as parameters.
369              
370             =head2 rackspace_authenticate
371              
372             my $token = $client->rackspace_authenticate('https://identity.api.rackspacecloud.com/v2.0/tokens',
373             $rackspace_account,
374             $rackspace_key);
375              
376             Sends an HTTP request to a L<Cloud
377             Identity|http://docs.rackspace.com/queues/api/v1.0/cq-gettingstarted/content/Generating_Auth_Token.html>
378             endpoint (or compatible) and sets the token received.
379              
380             See also L</token>.
381              
382             =head1 SPORE MIDDLEWARES ENABLED
383              
384             The following modifications are applied to requests before they are
385             made, in order:
386              
387             =over 4
388              
389             =item serializing the body to JSON
390              
391             =item setting the C<X-Auth-Token> header to the authentication token,
392             if available
393              
394             =item setting the C<Date> header to the current date in RFC 1123
395             format
396              
397             =item setting the C<Client-ID> header to the value of the
398             C<client_uuid> attribute
399              
400             =item if the C<__url__> parameter is provided to the method call,
401             replace the request path and querystring params with its value
402              
403             =back
404              
405             The following modifications are applied to responses before they are
406             returned, in order:
407              
408             =over 4
409              
410             =item deserializing the body from JSON, except for 401 and 403
411             responses, which are likely to come from Keystone instead and are
412             plain text.
413              
414             =back
415              
416             =head1 SEE ALSO
417              
418             L<Net::HTTP::Spore>
419              
420             =head1 AUTHOR
421              
422             Fabrice Gabolde <fgabolde@weborama.com>
423              
424             =head1 COPYRIGHT AND LICENSE
425              
426             Copyright (C) 2014 Weborama
427              
428             This program is free software; you can redistribute it and/or modify
429             it under the terms of the GNU General Public License as published by
430             the Free Software Foundation; either version 2 of the License, or (at
431             your option) any later version.
432              
433             This program is distributed in the hope that it will be useful, but
434             WITHOUT ANY WARRANTY; without even the implied warranty of
435             MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
436             General Public License for more details.
437              
438             You should have received a copy of the GNU General Public License
439             along with this program; if not, write to the Free Software
440             Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
441             02110-1301, USA.
442              
443             =cut