File Coverage

blib/lib/API/Pingboard.pm
Criterion Covered Total %
statement 27 134 20.1
branch 0 44 0.0
condition 0 15 0.0
subroutine 9 21 42.8
pod 8 8 100.0
total 44 222 19.8


line stmt bran cond sub pod time code
1             package API::Pingboard;
2             # ABSTRACT: API interface to Pingboard
3 2     2   4421 use Moose;
  2         2225119  
  2         14  
4 2     2   16163 use MooseX::Params::Validate;
  2         163698  
  2         15  
5 2     2   2443 use MooseX::WithCache;
  2         245364  
  2         17  
6 2     2   5921 use LWP::UserAgent;
  2         166878  
  2         93  
7 2     2   28 use HTTP::Request;
  2         4  
  2         78  
8 2     2   14 use HTTP::Headers;
  2         5  
  2         59  
9 2     2   1769 use JSON::MaybeXS;
  2         55907  
  2         193  
10 2     2   2206 use YAML;
  2         18621  
  2         154  
11 2     2   2163 use Encode;
  2         22772  
  2         4138  
12              
13             our $VERSION = 0.002;
14              
15             =head1 NAME
16              
17             API::Pingboard
18              
19             =head1 DESCRIPTION
20              
21             Interaction with Pingboard
22              
23             This module uses MooseX::Log::Log4perl for logging - be sure to initialize!
24              
25             =cut
26              
27              
28             =head1 ATTRIBUTES
29              
30             =over 4
31              
32             =item cache
33              
34             Optional.
35              
36             Provided by MooseX::WithX - optionally pass a Cache::FileCache object to cache and avoid unnecessary requests
37              
38             =cut
39              
40             with "MooseX::Log::Log4perl";
41              
42             # Unfortunately it is necessary to define the cache type to be expected here with 'backend'
43             # TODO a way to be more generic with cache backend would be better
44             with 'MooseX::WithCache' => {
45             backend => 'Cache::FileCache',
46             };
47              
48             =item access_token
49              
50             Required.
51              
52             =cut
53             has 'access_token' => (
54             is => 'ro',
55             isa => 'Str',
56             required => 1,
57             );
58              
59             # TODO Username and password login not working yet
60             =item password
61              
62              
63             =cut
64             has 'password' => (
65             is => 'ro',
66             isa => 'Str',
67             required => 0,
68             );
69              
70             =item username
71              
72              
73             =cut
74             has 'username' => (
75             is => 'ro',
76             isa => 'Str',
77             required => 0,
78             );
79              
80             =item timeout
81              
82             Timeout when communicating with Pingboard in seconds. Optional. Default: 10
83             Will only be in effect if you allow the useragent to be built in this module.
84              
85             =cut
86             has 'timeout' => (
87             is => 'ro',
88             isa => 'Int',
89             required => 1,
90             default => 10,
91             );
92              
93             =item default_backoff
94             Optional. Default: 10
95             Time in seconds to back off before retrying request.
96             If a 429 response is given and the Retry-Time header is provided by the api this will be overridden.
97             =cut
98             has 'default_backoff' => (
99             is => 'ro',
100             isa => 'Int',
101             required => 1,
102             default => 10,
103             );
104              
105             =item retry_on_status
106             Optional. Default: [ 429, 500, 502, 503, 504 ]
107             Which http response codes should we retry on?
108             =cut
109             has 'retry_on_status' => (
110             is => 'ro',
111             isa => 'ArrayRef',
112             required => 1,
113             default => sub{ [ 429, 500, 502, 503, 504 ] },
114             );
115              
116             =item max_tries
117             Optional. Default: undef
118             Limit maximum number of times a query should be attempted before failing. If undefined then unlimited retries
119             =cut
120             has 'max_tries' => (
121             is => 'ro',
122             isa => 'Int',
123             );
124              
125             =item pingboard_api_url
126              
127             Required.
128              
129             =cut
130             has 'pingboard_api_url' => (
131             is => 'ro',
132             isa => 'Str',
133             required => 1,
134             default => 'https://app.pingboard.com/api/v2/',
135             );
136              
137             =item user_agent
138              
139             Optional. A new LWP::UserAgent will be created for you if you don't already have one you'd like to reuse.
140              
141             =cut
142              
143             has 'user_agent' => (
144             is => 'ro',
145             isa => 'LWP::UserAgent',
146             required => 1,
147             lazy => 1,
148             builder => '_build_user_agent',
149              
150             );
151              
152             has 'default_headers' => (
153             is => 'ro',
154             isa => 'HTTP::Headers',
155             required => 1,
156             lazy => 1,
157             builder => '_build_default_headers',
158             );
159              
160             sub _build_user_agent {
161 0     0     my $self = shift;
162 0           $self->log->debug( "Building useragent" );
163 0           my $ua = LWP::UserAgent->new(
164             keep_alive => 1,
165             timeout => $self->timeout,
166             );
167 0           return $ua;
168             }
169              
170             sub _build_default_headers {
171 0     0     my $self = shift;
172 0           my $h = HTTP::Headers->new();
173 0           $h->header( 'Content-Type' => "application/json" );
174 0           $h->header( 'Accept' => "application/json" );
175             # Only oauth works for now
176 0           $h->header( 'Authorization' => "Bearer " . $self->access_token );
177 0           return $h;
178             }
179              
180              
181             =back
182              
183             =head1 METHODS
184              
185             =over 4
186              
187             =item init
188              
189             Create the user agent. As these are built lazily, initialising manually can avoid
190             errors thrown when building them later being silently swallowed in try/catch blocks.
191              
192             =cut
193              
194             sub init {
195 0     0 1   my $self = shift;
196 0           my $ua = $self->user_agent;
197             }
198              
199             =item get_users
200              
201             =over 4
202              
203             =item id
204              
205             The user id to get
206              
207             =cut
208              
209             sub get_users {
210 0     0 1   my ( $self, %params ) = validated_hash(
211             \@_,
212             id => { isa => 'Int', optional => 1 },
213             size => { isa => 'Int', optional => 1 },
214             );
215 0           $params{field} = 'users';
216 0 0         $params{path} = 'users' . ( $params{id} ? '/' . $params{id} : '' );
217 0           delete( $params{id} );
218 0           return $self->_paged_request_from_api( %params );
219             }
220              
221             =item get_groups
222              
223             =over 4
224              
225             =item id (optional)
226              
227             The group id to get
228              
229             =cut
230              
231             sub get_groups {
232 0     0 1   my ( $self, %params ) = validated_hash(
233             \@_,
234             id => { isa => 'Int', optional => 1 },
235             size => { isa => 'Int', optional => 1 },
236             );
237 0           $params{field} = 'groups';
238 0 0         $params{path} = 'groups' . ( $params{id} ? '/' . $params{id} : '' );
239 0           delete( $params{id} );
240 0           return $self->_paged_request_from_api( %params );
241             }
242              
243             =item get_custom_fields
244              
245             =over 4
246              
247             =item id (optional)
248              
249             The resource id to get
250              
251             =cut
252              
253             sub get_custom_fields {
254 0     0 1   my ( $self, %params ) = validated_hash(
255             \@_,
256             id => { isa => 'Int', optional => 1 },
257             size => { isa => 'Int', optional => 1 },
258             );
259 0           $params{field} = 'custom_fields';
260 0 0         $params{path} = 'custom_fields' . ( $params{id} ? '/' . $params{id} : '' );
261 0           delete( $params{id} );
262 0           return $self->_paged_request_from_api( %params );
263             }
264              
265             =item get_linked_accounts
266              
267             =over 4
268              
269             =item id
270              
271             The resource id to get
272              
273             =cut
274              
275             sub get_linked_accounts {
276 0     0 1   my ( $self, %params ) = validated_hash(
277             \@_,
278             id => { isa => 'Int'},
279             );
280 0           $params{field} = 'linked_accounts';
281 0           $params{path} = 'linked_accounts/' . $params{id};
282 0           delete( $params{id} );
283 0           return $self->_paged_request_from_api( %params );
284             }
285              
286             =item get_linked_account_providers
287              
288             =over 4
289              
290             =item id (optional)
291              
292             The resource id to get
293              
294             =cut
295              
296             sub get_linked_account_providers {
297 0     0 1   my ( $self, %params ) = validated_hash(
298             \@_,
299             id => { isa => 'Int', optional => 1 },
300             size => { isa => 'Int', optional => 1 },
301             );
302 0           $params{field} = 'linked_account_providers';
303 0 0         $params{path} = 'linked_account_providers' . ( $params{id} ? '/' . $params{id} : '' );
304 0           delete( $params{id} );
305 0           return $self->_paged_request_from_api( %params );
306             }
307              
308             =item get_statuses
309              
310             =over 4
311              
312             =item id (optional)
313              
314             The resource id to get
315              
316             =cut
317              
318             sub get_statuses {
319 0     0 1   my ( $self, %params ) = validated_hash(
320             \@_,
321             id => { isa => 'Int', optional => 1 },
322             size => { isa => 'Int', optional => 1 },
323             );
324 0           $params{field} = 'statuses';
325 0 0         $params{path} = 'statuses' . ( $params{id} ? '/' . $params{id} : '' );
326 0           delete( $params{id} );
327 0           return $self->_paged_request_from_api( %params );
328             }
329              
330              
331             =item clear_cache_object_id
332              
333             Clears an object from the cache.
334              
335             =over 4
336              
337             =item user_id
338              
339             Required. Object id to clear from the cache.
340              
341             =back
342              
343             Returns whether cache_del was successful or not
344              
345             =cut
346             sub clear_cache_object_id {
347 0     0 1   my ( $self, %params ) = validated_hash(
348             \@_,
349             object_id => { isa => 'Str' }
350             );
351              
352 0           $self->log->debug( "Clearing cache id: $params{object_id}" );
353 0           my $foo = $self->cache_del( $params{object_id} );
354              
355 0           return $foo;
356             }
357              
358             sub _paged_request_from_api {
359 0     0     my ( $self, %params ) = validated_hash(
360             \@_,
361             method => { isa => 'Str', optional => 1, default => 'GET' },
362             path => { isa => 'Str' },
363             field => { isa => 'Str' },
364             size => { isa => 'Int', optional => 1 },
365             body => { isa => 'Str', optional => 1 },
366             );
367 0           my @results;
368 0           my $page = 1;
369 0           my $response = undef;
370             do{
371             $response = $self->_request_from_api(
372             method => $params{method},
373 0 0         path => $params{path} . ( $page > 1 ? ( $params{path} =~ m/\?/ ? '&' : '?' ) . 'page=' . $page : '' ),
    0          
374             );
375 0           push( @results, @{ $response->{$params{field} } } );
  0            
376 0           $page++;
377 0   0       }while( $response->{meta}{$params{field}}{page} < $response->{meta}{$params{field}}{page_count} and ( not $params{size} or scalar( @results ) < $params{size} ) );
      0        
378 0           return @results;
379             }
380              
381              
382             sub _request_from_api {
383 0     0     my ( $self, %params ) = validated_hash(
384             \@_,
385             method => { isa => 'Str' },
386             path => { isa => 'Str', optional => 1 },
387             uri => { isa => 'Str', optional => 1 },
388             body => { isa => 'Str', optional => 1 },
389             headers => { isa => 'HTTP::Headers', optional => 1 },
390             fields => { isa => 'HashRef', optional => 1 },
391             );
392 0           my $url;
393 0 0         if( $params{uri} ){
    0          
394 0           $url = $params{uri};
395             }elsif( $params{path} ){
396 0           $url = $self->pingboard_api_url . $params{path};
397             }else{
398 0           $self->log->logdie( "Cannot request without either a path or uri" );
399             }
400              
401             my $request = HTTP::Request->new(
402             $params{method},
403             $url,
404 0   0       $params{headers} || $self->default_headers,
405             );
406 0 0         $request->content( $params{body} ) if( $params{body} );
407              
408 0           $self->log->debug( "Requesting: " . $request->uri );
409 0 0         $self->log->trace( "Request:\n" . Dump( $request ) ) if $self->log->is_trace;
410              
411 0           my $response;
412 0           my $retry = 1;
413 0           my $try_count = 0;
414 0           do{
415 0           my $retry_delay = $self->default_backoff;
416 0           $try_count++;
417             # Fields are a special use-case for GET requests:
418             # https://metacpan.org/pod/LWP::UserAgent#ua-get-url-field_name-value
419 0 0         if( $params{fields} ){
420 0 0         if( $request->method ne 'GET' ){
421 0           $self->log->logdie( 'Cannot use fields unless the request method is GET' );
422             }
423 0           my %fields = %{ $params{fields} };
  0            
424 0           my $headers = $request->headers();
425 0           foreach( keys( %{ $headers } ) ){
  0            
426 0           $fields{$_} = $headers->{$_};
427             }
428 0           $self->log->trace( "Fields:\n" . Dump( \%fields ) );
429 0           $response = $self->user_agent->get(
430             $request->uri(),
431             %fields,
432             );
433             }else{
434 0           $response = $self->user_agent->request( $request );
435             }
436 0 0         if( $response->is_success ){
437 0           $retry = 0;
438             }else{
439 0 0         if( grep{ $_ == $response->code } @{ $self->retry_on_status } ){
  0            
  0            
440 0 0         if( $response->code == 429 ){
441             # if retry-after header exists and has valid data use this for backoff time
442 0 0 0       if( $response->header( 'Retry-After' ) and $response->header('Retry-After') =~ /^\d+$/ ) {
443 0           $retry_delay = $response->header('Retry-After');
444             }
445 0           $self->log->warn( sprintf( "Received a %u (Too Many Requests) response with 'Retry-After' header... going to backoff and retry in %u seconds!",
446             $response->code,
447             $retry_delay,
448             ) );
449             }else{
450 0           $self->log->warn( sprintf( "Received a %u: %s ... going to backoff and retry in %u seconds!",
451             $response->code,
452             $response->decoded_content,
453             $retry_delay
454             ) );
455             }
456             }else{
457 0           $retry = 0;
458             }
459              
460 0 0         if( $retry == 1 ){
461 0 0 0       if( not $self->max_tries or $self->max_tries > $try_count ){
462 0           $self->log->debug( sprintf( "Try %u failed... sleeping %u before next attempt", $try_count, $retry_delay ) );
463 0           sleep( $retry_delay );
464             }else{
465 0           $self->log->debug( sprintf( "Try %u failed... exceeded max_tries (%u) so not going to retry", $try_count, $self->max_tries ) );
466 0           $retry = 0;
467             }
468             }
469             }
470             }while( $retry );
471              
472 0 0         $self->log->trace( "Last response:\n", Dump( $response ) ) if $self->log->is_trace;
473 0 0         if( not $response->is_success ){
474 0           $self->log->logdie( "API Error: http status:". $response->code .' '. $response->message . ' Content: ' . $response->content);
475             }
476 0 0         if( $response->decoded_content ){
477 0           return decode_json( encode( 'utf8', $response->decoded_content ) );
478             }
479 0           return;
480             }
481              
482              
483             1;
484              
485             =back
486              
487             =head1 COPYRIGHT
488              
489             Copyright 2015, Robin Clarke
490              
491             =head1 AUTHOR
492              
493             Robin Clarke <robin@robinclarke.net>
494              
495             Jeremy Falling <projects@falling.se>
496