File Coverage

blib/lib/WebService/Pingboard.pm
Criterion Covered Total %
statement 86 171 50.2
branch 14 60 23.3
condition 11 45 24.4
subroutine 19 24 79.1
pod 10 10 100.0
total 140 310 45.1


line stmt bran cond sub pod time code
1             package WebService::Pingboard;
2             # ABSTRACT: Interface to Pingboard API
3 2     2   3140 use Moose;
  2         939973  
  2         12  
4 2     2   15812 use MooseX::Params::Validate;
  2         159418  
  2         15  
5 2     2   2334 use MooseX::WithCache;
  2         231511  
  2         16  
6 2     2   3322 use LWP::UserAgent;
  2         106536  
  2         70  
7 2     2   17 use HTTP::Request;
  2         5  
  2         56  
8 2     2   10 use HTTP::Headers;
  2         4  
  2         47  
9 2     2   1462 use JSON::MaybeXS;
  2         12573  
  2         122  
10 2     2   1354 use YAML;
  2         14767  
  2         128  
11 2     2   1788 use Encode;
  2         21528  
  2         181  
12 2     2   1441 use URI::Encode qw/uri_encode/;
  2         2519  
  2         5443  
13              
14             our $VERSION = 0.005;
15              
16             =head1 NAME
17              
18             WebService::Pingboard
19              
20             =head1 DESCRIPTION
21              
22             Interaction with Pingboard
23              
24             This module uses MooseX::Log::Log4perl for logging - be sure to initialize!
25              
26             =cut
27              
28              
29             =head1 ATTRIBUTES
30              
31             =over 4
32              
33             =item cache
34              
35             Optional.
36              
37             Provided by MooseX::WithX - optionally pass a Cache::FileCache object to cache and avoid unnecessary requests
38              
39             =cut
40              
41             with "MooseX::Log::Log4perl";
42              
43             # Unfortunately it is necessary to define the cache type to be expected here with 'backend'
44             # TODO a way to be more generic with cache backend would be better
45             with 'MooseX::WithCache' => {
46             backend => 'Cache::FileCache',
47             };
48              
49             =item refresh_token
50              
51              
52             =cut
53             has 'refresh_token' => (
54             is => 'ro',
55             isa => 'Str',
56             required => 0,
57             writer => '_set_refresh_token',
58             );
59              
60             =item password
61              
62              
63             =cut
64             has 'password' => (
65             is => 'ro',
66             isa => 'Str',
67             required => 0,
68             writer => '_set_password',
69             );
70              
71             =item username
72              
73              
74             =cut
75             has 'username' => (
76             is => 'ro',
77             isa => 'Str',
78             required => 0,
79             writer => '_set_username',
80             );
81              
82             =item timeout
83              
84             Timeout when communicating with Pingboard in seconds. Optional. Default: 10
85             Will only be in effect if you allow the useragent to be built in this module.
86              
87             =cut
88             has 'timeout' => (
89             is => 'ro',
90             isa => 'Int',
91             required => 1,
92             default => 10,
93             );
94              
95             =item default_backoff
96              
97             Optional. Default: 10
98             Time in seconds to back off before retrying request.
99             If a 429 response is given and the Retry-Time header is provided by the api this will be overridden.
100              
101             =cut
102             has 'default_backoff' => (
103             is => 'ro',
104             isa => 'Int',
105             required => 1,
106             default => 10,
107             );
108              
109             =item default_page_size
110              
111             Optional. Default: 100
112              
113             =cut
114             has 'default_page_size' => (
115             is => 'rw',
116             isa => 'Int',
117             required => 1,
118             default => 100,
119             );
120              
121             =item retry_on_status
122              
123             Optional. Default: [ 429, 500, 502, 503, 504 ]
124             Which http response codes should we retry on?
125              
126             =cut
127             has 'retry_on_status' => (
128             is => 'ro',
129             isa => 'ArrayRef',
130             required => 1,
131             default => sub{ [ 429, 500, 502, 503, 504 ] },
132             );
133              
134             =item max_tries
135              
136             Optional. Default: undef
137              
138             Limit maximum number of times a query should be attempted before failing. If undefined then unlimited retries
139              
140             =cut
141             has 'max_tries' => (
142             is => 'ro',
143             isa => 'Int',
144             );
145              
146             =item api_url
147              
148             Default: https://app.pingboard.com/api/v2/
149              
150             =cut
151             has 'api_url' => (
152             is => 'ro',
153             isa => 'Str',
154             required => 1,
155             default => 'https://app.pingboard.com/api/v2',
156             );
157              
158             =item user_agent
159              
160             Optional. A new LWP::UserAgent will be created for you if you don't already have one you'd like to reuse.
161              
162             =cut
163              
164             has 'user_agent' => (
165             is => 'ro',
166             isa => 'LWP::UserAgent',
167             required => 1,
168             lazy => 1,
169             builder => '_build_user_agent',
170              
171             );
172              
173             =item loglevel
174              
175             Optionally override the global loglevel for this module
176              
177             =cut
178              
179             has 'loglevel' => (
180             is => 'rw',
181             isa => 'Str',
182             trigger => \&_set_loglevel,
183             );
184              
185             has '_access_token' => (
186             is => 'ro',
187             isa => 'Str',
188             required => 0,
189             writer => '_set_access_token',
190             );
191              
192             has '_headers' => (
193             is => 'ro',
194             isa => 'HTTP::Headers',
195             writer => '_set_headers',
196             );
197              
198             has '_access_token_expires' => (
199             is => 'ro',
200             isa => 'Int',
201             required => 0,
202             writer => '_set_access_token_expires',
203             );
204              
205             sub _set_loglevel {
206 0     0   0 my( $self, $level ) = @_;
207 0         0 $self->log->warn( "Setting new loglevel: $level" );
208 0         0 $self->log->level( $level );
209             }
210              
211             sub _build_user_agent {
212 0     0   0 my $self = shift;
213 0         0 $self->log->debug( "Building useragent" );
214 0         0 my $ua = LWP::UserAgent->new(
215             keep_alive => 1,
216             timeout => $self->timeout,
217             );
218 0         0 return $ua;
219             }
220              
221             =back
222              
223             =head1 METHODS
224              
225             =over 4
226              
227             =item valid_access_token
228              
229             Will return a valid access token.
230              
231             =cut
232              
233             sub valid_access_token {
234 4     4 1 24 my ( $self, %params ) = validated_hash(
235             \@_,
236             username => { isa => 'Str', optional => 1 },
237             password => { isa => 'Str', optional => 1 },
238             refresh_token => { isa => 'Str', optional => 1 },
239             );
240              
241             # If we still have a valid access token, use this
242             #if( $self->_access_token and $self->_access_token_expires > ( time() + 5 ) ){
243 4 50       870 if( $self->access_token_is_valid ){
244 0         0 return $self->_access_token;
245             }
246              
247 4   33     152 $params{username} ||= $self->username;
248 4   33     174 $params{password} ||= $self->password;
249 4   33     159 $params{refresh_token} ||= $self->refresh_token;
250              
251 4         13 my $h = HTTP::Headers->new();
252 4         31 $h->header( 'Content-Type' => "application/json" );
253 4         141 $h->header( 'Accept' => "application/json" );
254              
255 4         124 my $data;
256 4 50 33     21 if( $params{username} and $params{refresh_token} ){
    50 33        
257 0         0 $self->log->debug( "Requesting fresh access_token with refresh_token: $params{refresh_token}" );
258             $data = $self->_request_from_api(
259             method => 'POST',
260             headers => $h,
261             uri => 'https://app.pingboard.com/oauth/token',
262 0         0 options => sprintf( 'username=%s&refresh_token=%s&grant_type=refresh_token', $params{username}, $params{refresh_token} ),
263             );
264             }elsif( $params{username} and $params{password} ){
265 0         0 $self->log->debug( "Requesting fresh access_token with username and password for: $params{username}" );
266             $data = $self->_request_from_api(
267             method => 'POST',
268             headers => $h,
269             uri => 'https://app.pingboard.com/oauth/token',
270 0         0 options => sprintf( 'username=%s&password=%s&grant_type=password', $params{username}, uri_encode( $params{password} ) ),
271             );
272             }else{
273 4         69 die( "Cannot create valid access_token without a refresh_token or username and password" );
274             }
275 0         0 $self->log->debug( "Response from getting access_token:\n" . Dump( $data ) );
276 0         0 my $expire_time = time() + $data->{expires_in};
277 0         0 $self->log->debug( "Got new access_token: $data->{access_token} which expires at " . localtime( $expire_time ) );
278 0 0       0 if( $data->{refresh_token} ){
279 0         0 $self->log->debug( "Got new refresh_token: $data->{refresh_token}" );
280 0         0 $self->_set_refresh_token( $data->{refresh_token} );
281             }
282 0         0 $self->_set_access_token( $data->{access_token} );
283 0         0 $self->_set_access_token_expires( $expire_time );
284 0         0 return $data->{access_token};
285             }
286              
287             =item access_token_is_valid
288              
289             Returns true if a valid access token exists (with at least 5 seconds validity remaining).
290              
291             =cut
292              
293             sub access_token_is_valid {
294 8     8 1 11 my $self = shift;
295 8 50 33     302 return 1 if( $self->_access_token and $self->_access_token_expires > ( time() + 5 ) );
296 8         26 return 0;
297             }
298              
299             =item headers
300              
301             Returns a HTTP::Headers object with the Authorization header set with a valid access token
302              
303             =cut
304             sub headers {
305 4     4 1 8 my $self = shift;
306 4 50 33     10 if( not $self->access_token_is_valid or not $self->_headers ){
307 4         20 my $h = HTTP::Headers->new();
308 4         36 $h->header( 'Content-Type' => "application/json" );
309 4         197 $h->header( 'Accept' => "application/json" );
310 4         137 $h->header( 'Authorization' => "Bearer " . $self->valid_access_token );
311 0         0 $self->_set_headers( $h );
312             }
313 0         0 return $self->_headers;
314             }
315              
316             =item get_users
317              
318             =over 4
319              
320             =item id
321              
322             Optional. The user id to get
323              
324             =item limit
325              
326             Optional. Maximum number of entries to fetch.
327              
328             =item page_size
329              
330             Optional. Page size to use when fetching.
331              
332             =back
333              
334             =cut
335              
336             sub get_users {
337 1     1 1 3845 my ( $self, %params ) = validated_hash(
338             \@_,
339             id => { isa => 'Int', optional => 1 },
340             limit => { isa => 'Int', optional => 1 },
341             page_size => { isa => 'Int', optional => 1 },
342             options => { isa => 'Str', optional => 1 },
343             );
344 1         1104 $params{field} = 'users';
345 1 50       5 $params{path} = '/users' . ( $params{id} ? '/' . $params{id} : '' );
346 1         2 delete( $params{id} );
347 1         5 return $self->_paged_request_from_api( %params );
348             }
349              
350             =item get_groups
351              
352             =over 4
353              
354             =item id (optional)
355              
356             The group id to get
357              
358             =item limit
359              
360             Optional. Maximum number of entries to fetch.
361              
362             =item page_size
363              
364             Optional. Page size to use when fetching.
365              
366             =back
367              
368             =cut
369              
370             sub get_groups {
371 1     1 1 817 my ( $self, %params ) = validated_hash(
372             \@_,
373             id => { isa => 'Int', optional => 1 },
374             limit => { isa => 'Int', optional => 1 },
375             page_size => { isa => 'Int', optional => 1 },
376             options => { isa => 'Str', optional => 1 },
377             );
378 1         989 $params{field} = 'groups';
379 1 50       6 $params{path} = '/groups' . ( $params{id} ? '/' . $params{id} : '' );
380 1         1 delete( $params{id} );
381 1         5 return $self->_paged_request_from_api( %params );
382             }
383              
384             =item get_custom_fields
385              
386             =over 4
387              
388             =item id (optional)
389              
390             The resource id to get
391              
392             =item limit
393              
394             Optional. Maximum number of entries to fetch.
395              
396             =item page_size
397              
398             Optional. Page size to use when fetching.
399              
400             =back
401              
402             =cut
403              
404             sub get_custom_fields {
405 0     0 1 0 my ( $self, %params ) = validated_hash(
406             \@_,
407             id => { isa => 'Int', optional => 1 },
408             limit => { isa => 'Int', optional => 1 },
409             page_size => { isa => 'Int', optional => 1 },
410             options => { isa => 'Str', optional => 1 },
411             );
412 0         0 $params{field} = 'custom_fields';
413 0 0       0 $params{path} = '/custom_fields' . ( $params{id} ? '/' . $params{id} : '' );
414 0         0 delete( $params{id} );
415 0         0 return $self->_paged_request_from_api( %params );
416             }
417              
418             =item get_linked_accounts
419              
420             =over 4
421              
422             =item id
423              
424             The resource id to get
425              
426             =back
427              
428             =cut
429              
430             sub get_linked_accounts {
431 0     0 1 0 my ( $self, %params ) = validated_hash(
432             \@_,
433             id => { isa => 'Int'},
434             options => { isa => 'Str', optional => 1 },
435             );
436 0         0 $params{field} = 'linked_accounts';
437 0         0 $params{path} = '/linked_accounts/' . $params{id};
438 0         0 delete( $params{id} );
439 0         0 return $self->_paged_request_from_api( %params );
440             }
441              
442             =item get_linked_account_providers
443              
444             =over 4
445              
446             =item id (optional)
447              
448             The resource id to get
449              
450             =item limit
451              
452             Optional. Maximum number of entries to fetch.
453              
454             =item page_size
455              
456             Optional. Page size to use when fetching.
457              
458             =back
459              
460             =cut
461              
462             sub get_linked_account_providers {
463 1     1 1 1514 my ( $self, %params ) = validated_hash(
464             \@_,
465             id => { isa => 'Int', optional => 1 },
466             limit => { isa => 'Int', optional => 1 },
467             page_size => { isa => 'Int', optional => 1 },
468             options => { isa => 'Str', optional => 1 },
469             );
470 1         988 $params{field} = 'linked_account_providers';
471 1 50       5 $params{path} = '/linked_account_providers' . ( $params{id} ? '/' . $params{id} : '' );
472 1         2 delete( $params{id} );
473 1         4 return $self->_paged_request_from_api( %params );
474             }
475              
476             =item get_statuses
477              
478             =over 4
479              
480             =item id (optional)
481              
482             The resource id to get
483              
484             =item limit
485              
486             Optional. Maximum number of entries to fetch.
487              
488             =item page_size
489              
490             Optional. Page size to use when fetching.
491              
492             =back
493              
494             =cut
495              
496             sub get_statuses {
497 1     1 1 1172 my ( $self, %params ) = validated_hash(
498             \@_,
499             id => { isa => 'Int', optional => 1 },
500             limit => { isa => 'Int', optional => 1 },
501             page_size => { isa => 'Int', optional => 1 },
502             options => { isa => 'Str', optional => 1 },
503             );
504 1         1019 $params{field} = 'statuses';
505 1 50       7 $params{path} = '/statuses' . ( $params{id} ? '/' . $params{id} : '' );
506 1         2 delete( $params{id} );
507 1         4 return $self->_paged_request_from_api( %params );
508             }
509              
510              
511             =item clear_cache_object_id
512              
513             Clears an object from the cache.
514              
515             =over 4
516              
517             =item object_id
518              
519             Required. Object id to clear from the cache.
520              
521             =back
522              
523             Returns whether cache_del was successful or not
524              
525             =cut
526             sub clear_cache_object_id {
527 0     0 1 0 my ( $self, %params ) = validated_hash(
528             \@_,
529             object_id => { isa => 'Str' }
530             );
531              
532 0         0 $self->log->debug( "Clearing cache id: $params{object_id}" );
533 0         0 my $foo = $self->cache_del( $params{object_id} );
534              
535 0         0 return $foo;
536             }
537              
538             sub _paged_request_from_api {
539 4     4   40 my ( $self, %params ) = validated_hash(
540             \@_,
541             method => { isa => 'Str', optional => 1, default => 'GET' },
542             path => { isa => 'Str' },
543             field => { isa => 'Str' },
544             limit => { isa => 'Int', optional => 1 },
545             page_size => { isa => 'Int', optional => 1 },
546             options => { isa => 'Str', optional => 1 },
547             body => { isa => 'Str', optional => 1 },
548             );
549 4         2824 my @results;
550 4         8 my $page = 1;
551              
552 4   33     179 $params{page_size} ||= $self->default_page_size;
553 4 50 33     26 if( $params{limit} and $params{limit} < $params{page_size} ){
554 4         7 $params{page_size} = $params{limit};
555             }
556              
557 4         6 my $response = undef;
558             do{
559             my %request_params = (
560             method => $params{method},
561             path => $params{path} . ( $params{path} =~ m/\?/ ? '&' : '?' ) . 'page=' . $page . '&page_size=' . $params{page_size},
562 4 50       29 );
563 4 50       9 $request_params{options} = $params{options} if( $params{options} );
564 4         15 $response = $self->_request_from_api( %request_params );
565 0         0 push( @results, @{ $response->{$params{field} } } );
  0         0  
566 0         0 $page++;
567 4   0     7 }while( $response->{meta}{$params{field}}{page} < $response->{meta}{$params{field}}{page_count} and ( not $params{limit} or scalar( @results ) < $params{limit} ) );
      0        
568 0         0 return @results;
569             }
570              
571              
572             sub _request_from_api {
573 4     4   39 my ( $self, %params ) = validated_hash(
574             \@_,
575             method => { isa => 'Str' },
576             path => { isa => 'Str', optional => 1 },
577             uri => { isa => 'Str', optional => 1 },
578             body => { isa => 'Str', optional => 1 },
579             headers => { isa => 'HTTP::Headers', optional => 1 },
580             options => { isa => 'Str', optional => 1 },
581             fields => { isa => 'HashRef', optional => 1 },
582             );
583 4   33     2625 my $url = $params{uri} || $self->api_url;
584 4 50       15 $url .= $params{path} if( $params{path} );
585 4 0       17 $url .= ( $url =~ m/\?/ ? '&' : '?' ) . $params{options} if( $params{options} );
    50          
586              
587             my $request = HTTP::Request->new(
588             $params{method},
589             $url,
590 4   33     20 $params{headers} || $self->headers,
591             );
592 0 0         $request->content( $params{body} ) if( $params{body} );
593              
594 0           $self->log->debug( "Requesting: " . $request->uri );
595 0 0         $self->log->trace( "Request:\n" . Dump( $request ) ) if $self->log->is_trace;
596              
597 0           my $response;
598 0           my $retry = 1;
599 0           my $try_count = 0;
600 0           do{
601 0           my $retry_delay = $self->default_backoff;
602 0           $try_count++;
603             # Fields are a special use-case for GET requests:
604             # https://metacpan.org/pod/LWP::UserAgent#ua-get-url-field_name-value
605 0 0         if( $params{fields} ){
606 0 0         if( $request->method ne 'GET' ){
607 0           $self->log->logdie( 'Cannot use fields unless the request method is GET' );
608             }
609 0           my %fields = %{ $params{fields} };
  0            
610 0           my $headers = $request->headers();
611 0           foreach( keys( %{ $headers } ) ){
  0            
612 0           $fields{$_} = $headers->{$_};
613             }
614 0           $self->log->trace( "Fields:\n" . Dump( \%fields ) );
615 0           $response = $self->user_agent->get(
616             $request->uri(),
617             %fields,
618             );
619             }else{
620 0           $response = $self->user_agent->request( $request );
621             }
622 0 0         if( $response->is_success ){
623 0           $retry = 0;
624             }else{
625 0 0         if( grep{ $_ == $response->code } @{ $self->retry_on_status } ){
  0            
  0            
626 0 0         if( $response->code == 429 ){
627             # if retry-after header exists and has valid data use this for backoff time
628 0 0 0       if( $response->header( 'Retry-After' ) and $response->header('Retry-After') =~ /^\d+$/ ) {
629 0           $retry_delay = $response->header('Retry-After');
630             }
631 0           $self->log->warn( sprintf( "Received a %u (Too Many Requests) response with 'Retry-After' header... going to backoff and retry in %u seconds!",
632             $response->code,
633             $retry_delay,
634             ) );
635             }else{
636 0           $self->log->warn( sprintf( "Received a %u: %s ... going to backoff and retry in %u seconds!",
637             $response->code,
638             $response->decoded_content,
639             $retry_delay
640             ) );
641             }
642             }else{
643 0           $retry = 0;
644             }
645              
646 0 0         if( $retry == 1 ){
647 0 0 0       if( not $self->max_tries or $self->max_tries > $try_count ){
648 0           $self->log->debug( sprintf( "Try %u failed... sleeping %u before next attempt", $try_count, $retry_delay ) );
649 0           sleep( $retry_delay );
650             }else{
651 0           $self->log->debug( sprintf( "Try %u failed... exceeded max_tries (%u) so not going to retry", $try_count, $self->max_tries ) );
652 0           $retry = 0;
653             }
654             }
655             }
656             }while( $retry );
657              
658 0 0         $self->log->trace( "Last response:\n", Dump( $response ) ) if $self->log->is_trace;
659 0 0         if( not $response->is_success ){
660 0           $self->log->logdie( "API Error: http status:". $response->code .' '. $response->message . ' Content: ' . $response->content);
661             }
662 0 0         if( $response->decoded_content ){
663 0           return decode_json( encode( 'utf8', $response->decoded_content ) );
664             }
665 0           return;
666             }
667              
668              
669             1;
670              
671             =back
672              
673             =head1 COPYRIGHT
674              
675             Copyright 2015, Robin Clarke
676              
677             =head1 AUTHOR
678              
679             Robin Clarke <robin@robinclarke.net>
680              
681             Jeremy Falling <projects@falling.se>
682