File Coverage

blib/lib/Clustericious/Client.pm
Criterion Covered Total %
statement 194 368 52.7
branch 46 168 27.3
condition 24 99 24.2
subroutine 33 54 61.1
pod 15 15 100.0
total 312 704 44.3


line stmt bran cond sub pod time code
1             package Clustericious::Client;
2              
3 3     3   43466 use strict; no strict 'refs';
  3     3   8  
  3         122  
  3         23  
  3         6  
  3         72  
4 3     3   15 use warnings;
  3         6  
  3         138  
5              
6             # ABSTRACT: Construct command line and perl clients for RESTful services.
7             our $VERSION = '0.85'; # VERSION
8              
9              
10 3     3   2702 use Mojo::Base qw/-base/;
  3         37313  
  3         24  
11              
12 3     3   4107 use Mojo::UserAgent;
  3         1161711  
  3         46  
13 3     3   125 use Mojo::ByteStream qw/b/;
  3         8  
  3         204  
14 3     3   17 use Mojo::Parameters;
  3         8  
  3         22  
15 3     3   4649 use JSON::XS;
  3         19301  
  3         281  
16 3     3   2818 use Clustericious::Config;
  3         623369  
  3         119  
17 3     3   1891 use Clustericious::Client::Object;
  3         14  
  3         93  
18 3     3   1637 use Clustericious::Client::Meta;
  3         10  
  3         88  
19 3     3   1789 use Clustericious::Client::Meta::Route;
  3         10  
  3         39  
20 3     3   113 use MojoX::Log::Log4perl;
  3         6  
  3         19  
21 3     3   83 use Log::Log4perl qw/:easy/;
  3         5  
  3         22  
22 3     3   14576 use File::Temp;
  3         29948  
  3         499  
23              
24              
25             has server_url => '';
26             has [qw(tx res userinfo client)];
27             has _remote => ''; # Access via remote()
28             has _cache => sub { + {} }; # cache of credentials
29              
30             sub import
31             {
32 3     3   35 my $class = shift;
33 3         12 my $caller = caller;
34              
35             {
36 3     3   26 no strict 'refs';
  3         5  
  3         4193  
  3         8  
37 3 50       103 push @{"${caller}::ISA"}, $class unless $caller->isa($class);
  3         53  
38 3         16 *{"${caller}::route"} = \&route;
  3         21  
39 3         8 *{"${caller}::route_meta"} = \&route_meta;
  3         22  
40 3         10 *{"${caller}::route_args"} = \&route_args;
  3         17  
41 3         18 *{"${caller}::route_doc"} = sub {
42 3     3   19 Clustericious::Client::Meta->add_route( $caller, @_ )
43 3         14 };
44 3         10 *{"${caller}::object"} = \&object;
  3         20  
45 3     0   13 *{"${caller}::import"} = sub {};
  3         4338  
  0         0  
46             }
47             }
48              
49              
50             sub new
51             {
52 1     1 1 4851 my $self = shift->SUPER::new(@_);
53 1         11 my %args = @_;
54              
55 1 50       9 if ($self->{app})
56             {
57 0         0 my $app = $self->{app};
58 0 0       0 $app = $app->new() unless ref($app);
59 0         0 my $client = Mojo::UserAgent->new;
60 0 0       0 return undef unless $client;
61 0   0     0 eval { $client->server->app($app) } // $client->app($app);
  0         0  
62              
63 0         0 $self->client($client);
64             }
65             else
66             {
67 1         13 $self->client(Mojo::UserAgent->new);
68 1 50       59 if (not length $self->server_url)
69             {
70 0         0 my $url = $self->_config->url;
71 0         0 $url =~ s{/$}{};
72 0         0 $self->server_url($url);
73             }
74             }
75              
76 1   50     28 $self->client->inactivity_timeout($ENV{CLUSTERICIOUS_KEEP_ALIVE_TIMEOUT} || 300);
77              
78 1         42 return $self;
79             }
80              
81              
82              
83             sub remote {
84 0     0 1 0 my $self = shift;
85 0 0       0 return $self->_remote unless @_ > 0;
86 0         0 my $remote = shift;
87 0 0       0 unless ($remote) { # reset to default
88 0         0 $self->{_remote} = '';
89 0         0 $self->server_url($self->_config->url);
90 0         0 return;
91             }
92 0         0 my $info = $self->_base_config->remotes->$remote;
93 0         0 TRACE "Using remote url : ".$info->{url};
94 0         0 $self->server_url($info->{url});
95 0         0 $self->userinfo('');
96 0         0 $self->_remote($remote);
97             }
98              
99              
100             sub remotes {
101 0     0 1 0 my $self = shift;
102 0         0 my %found = $self->_base_config->remotes(default => {});
103 0         0 return keys %found;
104             }
105              
106              
107             sub login {
108 0     0 1 0 my $self = shift;
109 0         0 my %args = @_;
110 0 0       0 my ($user,$pw) =
    0          
111             @_==2 ? @_
112             : @_ ? @args{qw/username password/}
113             : map $self->_config->$_, qw/username password/;
114 0         0 $self->userinfo(join ':', $user,$pw);
115             }
116              
117              
118             sub errorstring {
119 1     1 1 2 my $self = shift;
120 1 50       26 WARN "Missing response in client object" unless $self->res;
121 1 50       43 return unless $self->res;
122 0 0 0     0 return if $self->res->code && $self->res->is_status_class(200);
123 0 0       0 $self->res->error
124             || sprintf( "(%d) %s", $self->res->code, $self->res->message );
125             }
126              
127              
128             sub has_error {
129 3     3 1 6 my $c = shift;
130 3 50 33     73 return unless $c->tx || $c->res;
131 0 0 0     0 return 1 if $c->tx && $c->tx->error;
132 0 0 0     0 return 1 if $c->res && !$c->res->is_status_class(200);
133 0         0 return 0;
134             }
135              
136              
137              
138             sub route {
139 6     6 1 35 my $subname = shift;
140 6 50       16 my $objclass = ref $_[0] eq 'ARRAY' ? shift->[0] : undef;
141 6 50       14 my $doc = ref $_[-1] eq 'SCALAR' ? ${ pop() } : "";
  0         0  
142 6   33     14 my $url = pop || "/$subname";
143 6   100     18 my $method = shift || 'GET';
144              
145 6         11 my $client_class = scalar caller();
146 6         47 my $meta = Clustericious::Client::Meta::Route->new(
147             client_class => scalar caller(),
148             route_name => $subname
149             );
150              
151 6         65 $meta->set(method => $method);
152 6         21 $meta->set(url => $url);
153 6         64 $meta->set_doc($doc);
154              
155 6 50       15 if ($objclass) {
156 0         0 eval "require $objclass";
157 0 0       0 if ($@) {
158 0 0       0 LOGDIE "Error loading $objclass : $@" unless $@ =~ /Can't locate/i;
159             }
160             }
161              
162             {
163 3     3   19 no strict 'refs';
  3         5  
  3         1715  
  6         6  
164 6         46 *{caller() . "::$subname"} = sub {
165 4     4   2756 my $self = shift;
166 4         17 my @args = $self->meta_for($subname)->process_args(@_);
167 4         38 my $got = $self->_doit($meta,$method,$url,@args);
168 4 50       15 return $objclass->new($got, $self) if $objclass;
169 4         25 return $got;
170 6         32 };
171             }
172              
173             }
174              
175              
176             sub route_meta {
177 1     1 1 8 my $name = shift;
178 1         3 my $attrs = shift;
179 1         5 my $meta = Clustericious::Client::Meta::Route->new(
180             client_class => scalar caller(),
181             route_name => $name
182             );
183              
184 1         13 $meta->set($_ => $attrs->{$_}) for keys %$attrs;
185             }
186              
187              
188             sub route_args {
189 9     9 1 112 my $name = shift;
190 9         10 my $args = shift;
191 9 50       32 die "args must be an array ref" unless ref $args eq 'ARRAY';
192 9         30 my $meta = Clustericious::Client::Meta::Route->new(
193             client_class => scalar caller(),
194             route_name => $name
195             );
196              
197 9         94 $meta->set(args => $args);
198             }
199              
200              
201             sub object {
202 0     0 1 0 my $objname = shift;
203 0   0     0 my $url = shift || "/$objname";
204 0 0       0 my $doc = ref $_[-1] eq 'SCALAR' ? ${ pop() } : '';
  0         0  
205 0         0 my $caller = caller;
206              
207 0         0 my $objclass = "${caller}::" .
208 0         0 join('', map { ucfirst } split('_', $objname)); # foo_bar => FooBar
209              
210 0         0 eval "require $objclass";
211 0 0       0 if ($@) {
212 0 0       0 LOGDIE "Error loading $objclass : $@" unless $@ =~ /Can't locate/i;
213             }
214              
215 0 0       0 $objclass = 'Clustericious::Client::Object' unless $objclass->can('new');
216              
217 0         0 Clustericious::Client::Meta->add_object(scalar caller(),$objname,$doc);
218              
219 3     3   16 no strict 'refs';
  3         8  
  3         18871  
220 0         0 *{"${caller}::$objname"} = sub {
221 0     0   0 my $self = shift;
222 0         0 my $meta = Clustericious::Client::Meta::Route->new(
223             client_class => $caller,
224             route_name => $objname
225             );
226 0         0 $meta->set( quiet_post => 1 );
227 0         0 my $data = $self->_doit( $meta, GET => $url, @_ );
228 0         0 $objclass->new( $data, $self );
229 0         0 };
230 0         0 *{"${caller}::${objname}_delete"} = sub {
231 0     0   0 my $meta = Clustericious::Client::Meta::Route->new(
232             client_class => $caller,
233             route_name => $objname.'_delete',
234             );
235 0         0 $meta->set(dont_read_files => 1);
236 0         0 shift->_doit( $meta, DELETE => $url, @_ );
237 0         0 };
238 0         0 *{"${caller}::${objname}_search"} = sub {
239 0     0   0 my $meta = Clustericious::Client::Meta::Route->new(
240             client_class => $caller,
241             route_name => $objname.'_search'
242             );
243 0         0 $meta->set(dont_read_files => 1);
244 0         0 shift->_doit( $meta, POST => "$url/search", @_ );
245 0         0 };
246             }
247              
248             sub _doit {
249 4     4   9 my $self = shift;
250 4         7 my $meta;
251 4 50       14 $meta = shift if ref $_[0] eq 'Clustericious::Client::Meta::Route';
252 4         14 my ($method, $url, @args) = @_;
253              
254 4         6 my $auto_failover;
255 4 50 33     19 $auto_failover = 1 if $meta && $meta->get('auto_failover');
256              
257 4 50 33     88 $url = $self->server_url . $url if $self->server_url && $url !~ /^http/;
258 4 50       231 return undef if $self->server_url eq 'http://0.0.0.0';
259              
260 4         27 my $cb;
261 4         6 my $body = '';
262 4         9 my $headers = {};
263              
264 4 50 33     28 if ($method eq 'POST' && grep /^--/, @args) {
265 0         0 s/^--// for @args;
266 0         0 @args = ( { @args } );
267             }
268              
269 4 50       35 $url = Mojo::URL->new($url) unless ref $url;
270 4         948 my $parameters = $url->query;
271              
272             # Set up mappings from parameter names to modifier callbacks.
273 4         40 my %url_modifier;
274             my %payload_modifer;
275             my %gen_url_modifier = (
276 0     0   0 query => sub { my $name = shift;
277 0         0 sub { my ($u,$v) = @_; $u->query({$name => $v}) } },
  0         0  
  0         0  
278 13     13   13 append => sub { my $name = shift;
279 13         63 sub { my ($u,$v) = @_; push @{ $u->path->parts } , $v; $u; } },
  13         19  
  13         14  
  13         49  
  13         649  
280 4         39 );
281             my %gen_payload_modifier = (
282             array => sub {
283 1     1   4 my ( $name, $key ) = @_;
284 1 50       19 LOGDIE "missing key for array payload modifier" unless $key;
285 1   50     2 sub { my $body = shift; $body ||= {}; push @{ $body->{$key} }, ( $name => shift ); $body; }
  1         4  
  1         2  
  1         5  
  1         6  
286 1         10 },
287             hash => sub {
288 1     1   3 my $name = shift;
289 1   50     2 sub { my $body = shift; $body ||= {}; $body->{$name} = shift; $body; }
  1         8  
  1         3  
  1         6  
290 1         12 },
291 4         38 );
292 4 50 33     22 if ($meta && (my $arg_spec = $meta->get('args'))) {
293 4         13 for (@$arg_spec) {
294 15         25 my $name = $_->{name};
295 15 100       40 if (my $modifies_url = $_->{modifies_url}) {
296 13 50       48 $url_modifier{$name} =
    50          
297             ref($modifies_url) eq 'CODE' ? $modifies_url
298             : ($a = $gen_url_modifier{$modifies_url}) ? $a->($name)
299             : die "don't understand how to interpret modifies_url=$modifies_url";
300             }
301 15 100       45 if (my $modifies_payload = $_->{modifies_payload}) {
302 2 50       18 $payload_modifer{$name} =
    50          
303             ref($modifies_payload) eq 'CODE' ? $modifies_payload
304             : ($a = $gen_payload_modifier{$modifies_payload}) ? $a->($name,$_->{key})
305             : LOGDIE "don't understand how to interpret modifies_payload=$modifies_payload";
306             }
307             }
308             }
309              
310 4         18 while (defined(my $arg = shift @args)) {
311 15 50 0     54 if (ref $arg eq 'HASH') {
    50 0        
    100 0        
    50          
    0          
    0          
    0          
312 0         0 $method = 'POST';
313 0 0 0     0 $parameters->append(skip_existing => 1) if $meta && $meta->get("skip_existing");
314 0         0 $body = encode_json $arg;
315 0         0 $headers = { 'Content-Type' => 'application/json' };
316             } elsif (ref $arg eq 'CODE') {
317 0         0 $cb = $self->_mycallback($arg);
318             } elsif (my $code = $url_modifier{$arg}) {
319 13         28 $url = $code->($url, shift @args);
320             } elsif (my $code2 = $payload_modifer{$arg}) {
321 2         7 $body = $code2->($body, shift @args);
322             } elsif ($method eq "GET" && $arg =~ s/^--//) {
323 0         0 my $value = shift @args;
324 0         0 $parameters->append($arg => $value);
325             } elsif ($method eq "GET" && $arg =~ s/^-//) {
326             # example: $client->esdt(-range => [1 => 100]);
327 0         0 my $value = shift @args;
328 0 0       0 if (ref $value eq 'ARRAY') {
329 0         0 $value = "items=$value->[0]-$value->[1]";
330             }
331 0         0 $headers->{$arg} = $value;
332             } elsif ($method eq "POST" && !ref $arg) {
333 0         0 $body = $arg;
334 0 0 0     0 $headers = shift @args if $args[0] && ref $args[0] eq 'HASH';
335             } else {
336 0         0 push @{ $url->path->parts }, $arg;
  0         0  
337             }
338             }
339 4 50       19 $url = $url->to_abs unless $url->is_abs;
340 4 50 33     131 WARN "url $url is not absolute" unless $url =~ /^http/i || $ENV{HARNESS_ACTIVE};
341              
342 4 50       1471 $url->userinfo($self->userinfo) if $self->userinfo;
343              
344 4         43 DEBUG ( (ref $self)." : $method " ._sanitize_url($url));
345 4   50     1364 $headers->{Connection} ||= 'Close';
346 4   50     24 $headers->{Accept} ||= 'application/json';
347              
348 4 100 66     32 if($body && ref $body eq 'HASH' || ref $body eq 'ARRAY')
      66        
349             {
350 1         3 $headers->{'Content-Type'} = 'application/json';
351 1         34 $body = encode_json $body;
352             }
353              
354 4 50       12 return $self->client->build_tx($method, $url, $headers, $body, $cb) if $cb;
355              
356 4         98 my $tx = $self->client->build_tx($method, $url, $headers, $body);
357              
358 4         3595 $tx = $self->client->start($tx);
359 4         17122 my $res = $tx->res;
360 4         114 $self->res($res);
361 4         109 $self->tx($tx);
362              
363 4         155 my $auth_header;
364 4 0 50     82 if (($tx->res->code||0) == 401 && ($auth_header = $tx->res->headers->www_authenticate)
      33        
      33        
      0        
      0        
365             && !$url->userinfo && ($self->_has_auth || $self->_can_auth)) {
366 0         0 DEBUG "received code 401, trying again with credentials";
367 0         0 my ($realm) = $auth_header =~ /realm=(.*)$/i;
368 0         0 my $host = $url->host;
369 0 0       0 $self->login( $self->_has_auth ? () : $self->_get_user_pw($host,$realm) );
370 0 0       0 return $self->_doit($meta ? $meta : (), @_);
371             }
372              
373 4 50       147 if ($res->is_status_class(200)) {
374 0         0 TRACE "Got response : ".$res->to_string;
375 0   0     0 my $content_type = $res->headers->content_type || do {
376             WARN "No content-type from "._sanitize_url($url);
377             "text/plain";
378             };
379 0 0       0 return $method =~ /HEAD|DELETE/ ? 1
    0          
380             : $content_type =~ qr[application/json] ? decode_json($res->body)
381             : $res->body;
382             }
383              
384             # Failed.
385 4         122 my ($msg,$code) = $tx->error;
386 4   50     271 $msg ||= 'unknown error';
387 4         17 my $s_url = _sanitize_url($url);
388              
389 4 50       41 if ($code) {
390 0 0       0 if ($code == 404) {
391 0 0 0     0 TRACE "$method $url : $code $msg"
392             unless $ENV{ACPS_SUPPRESS_404}
393             && $url =~ /$ENV{ACPS_SUPPRESS_404}/;
394             } else {
395 0         0 ERROR "Error trying to $method $s_url : $code $msg";
396 0 0       0 TRACE "Full error body : ".$res->body if $res->body;
397 0   0     0 my $brief = $res->body || '';
398 0         0 $brief =~ s/\n/ /g;
399 0 0       0 ERROR substr($brief,0,200) if $brief;
400             }
401             # No failover for legitimate status codes.
402 0         0 return undef;
403             }
404              
405 4 50       23 unless ($auto_failover) {
406 4         33 ERROR "Error trying to $method $s_url : $msg";
407 4 50       1503 ERROR $res->body if $res->body;
408 4         443 return undef;
409             }
410 0         0 my $failover_urls = $self->_config->failover_urls(default => []);
411 0 0       0 unless (@$failover_urls) {
412 0         0 ERROR $msg;
413 0         0 return undef;
414             }
415 0         0 INFO "$msg but will try up to ".@$failover_urls." failover urls";
416 0         0 TRACE "Failover urls : @$failover_urls";
417 0         0 for my $url (@$failover_urls) {
418 0         0 DEBUG "Trying $url";
419 0         0 $self->server_url($url);
420 0         0 my $got = $self->_doit(@_);
421 0 0       0 return $got if $got;
422             }
423              
424 0         0 return undef;
425             }
426              
427             sub _mycallback
428             {
429 0     0   0 my $self = shift;
430 0         0 my $cb = shift;
431             sub
432             {
433 0     0   0 my ($client, $tx) = @_;
434              
435 0         0 $self->res($tx->res);
436 0         0 $self->tx($tx);
437              
438 0 0       0 if ($tx->res->is_status_class(200))
439             {
440 0 0       0 my $body = $tx->res->headers->content_type =~ qr[application/json]
441             ? decode_json($tx->res->body) : $tx->res->body;
442              
443 0 0       0 $cb->($body ? $body : 1);
444             }
445             else
446             {
447 0         0 $cb->();
448             }
449             }
450 0         0 }
451              
452             sub _sanitize_url {
453             # Remove passwords from urls for displaying
454 8     8   19 my $url = shift;
455 8 50       30 $url = Mojo::URL->new($url) unless ref $url eq "Mojo::URL";
456 8 50       176 return $url unless $url->userinfo;
457 0         0 my $c = $url->clone;
458 0         0 $c->userinfo("user:*****");
459 0         0 return $c;
460             }
461              
462             sub _appname {
463 0     0   0 my $self = shift;
464 0         0 (my $appname = ref $self) =~ s/:.*$//;
465 0         0 return $appname;
466             }
467              
468             sub _config {
469 0     0   0 my $self = shift;
470 0         0 my $conf = $self->_base_config;
471 0 0       0 if (my $remote = $self->_remote) {
472 0         0 return $conf->remotes->$remote;
473             }
474 0         0 return $conf;
475             }
476              
477             sub _base_config {
478             # Independent of remotes
479 0     0   0 my $self = shift;
480 0 0       0 return $self->{_base_config} if defined($self->{_base_config});
481 0         0 $self->{_base_config} = Clustericious::Config->new($self->_appname);
482 0         0 return $self->{_base_config};
483             }
484              
485             sub _has_auth {
486 0     0   0 my $self = shift;
487 0 0       0 return 0 unless $self->_config->username(default => '');
488 0 0       0 return 0 unless $self->_config->password(default => '');
489 0         0 return 1;
490             }
491              
492             sub _can_auth {
493 0     0   0 my $self = shift;
494 0 0       0 return -t STDIN ? 1 : 0;
495             }
496              
497             sub _get_user_pw {
498 0     0   0 my $self = shift;
499 0         0 my $host = shift;
500 0         0 my $realm = shift;
501 0 0       0 $realm = '' unless defined $realm;
502 0 0       0 return @{ $self->_cache->{$host}{$realm} } if exists($self->_cache->{$host}{$realm});
  0         0  
503             # "use"ing causes too many warnings; load on demand.
504 0         0 require Term::Prompt;
505 0   0     0 my $user = Term::Prompt::prompt('x', "Username for $realm at $host : ", '', $ENV{USER} // $ENV{USERNAME});
506 0         0 my $pw = Term::Prompt::prompt('p', 'Password:', '', '');
507 0         0 $self->_cache->{$host}{$realm} = [ $user, $pw ];
508 0         0 return ($user,$pw);
509             }
510              
511              
512             sub meta_for {
513 16     16 1 3473 my $self = shift;
514 16   66     122 my $route_name = shift || [ caller 1 ]->[3];
515 16 100       143 if ( $route_name =~ /::([^:]+)$/ ){
516 10         22 $route_name = $1;
517             }
518 16         96 my $meta = Clustericious::Client::Meta::Route->new(
519             route_name => $route_name,
520             client_class => ref $self
521             );
522             }
523              
524              
525             sub version {
526 0     0 1   my $self = shift;
527 0           my $meta = $self->meta_for("version");
528 0           $meta->set(auto_failover => 1);
529 0           $self->_doit($meta, GET => '/version');
530             }
531              
532              
533             sub status {
534 0     0 1   my $self = shift;
535 0           my $meta = $self->meta_for("status");
536 0           $meta->set(auto_failover => 1);
537 0           $self->_doit($meta, GET => '/status');
538             }
539              
540              
541             sub api {
542 0     0 1   my $self = shift;
543 0           my $meta = $self->meta_for("api");
544 0           $meta->set( auto_failover => 1 );
545 0           $self->_doit( $meta, GET => '/api' );
546             }
547              
548              
549             sub logtail {
550 0     0 1   my $self = shift;
551 0           my $got = $self->_doit(GET => '/log', @_);
552 0           return { text => $got };
553             }
554              
555              
556             1;
557              
558             __END__