File Coverage

blib/lib/Respite/Client.pm
Criterion Covered Total %
statement 96 161 59.6
branch 31 108 28.7
condition 20 82 24.3
subroutine 21 35 60.0
pod 1 5 20.0
total 169 391 43.2


line stmt bran cond sub pod time code
1             package Respite::Client;
2              
3             # Respite::Client - Generic class for running remote services
4              
5 4     4   1511 use strict;
  4         6  
  4         133  
6 4     4   15 use warnings;
  4         7  
  4         250  
7 4     4   17 use base 'Respite::Common'; # Default _configs
  4         4  
  4         496  
8 4     4   8533 use IO::Socket::SSL ();
  4         355499  
  4         213  
9 4     4   40 use Time::HiRes qw(sleep);
  4         11  
  4         58  
10 4     4   440 use Digest::MD5 qw(md5_hex);
  4         11  
  4         1797  
11              
12             BEGIN {
13 4 50   4   25 if (! eval { require Throw }) {
  4         34  
14 0         0 *Throw_::TO_JSON = sub { +{%{$_[0]}} };
  0         0  
  0         0  
15 0 0 0     0 *Throw_::_str = sub { my ($s) = @_; my ($e,$p) = delete(@$s{qw(error _pretty)}); $e||="throw"; $e .= ': '.($p||$Throw::pretty?jsop():json())->encode($s) if %$s; "$e\n" };
  0 0 0     0  
  0         0  
  0         0  
  0         0  
  0         0  
16 0 0       0 *throw = *Throw_::throw = sub { my ($m,$a,$l)=@_; $a=ref($m) ? $m : {%{$a||{}}, error => $m};
  0 0       0  
  0         0  
17 0 0 0     0 do {my$i=$l||0;$i++while __PACKAGE__ eq caller$i; $a->{'trace'}=sprintf "%s at %s line %s\n",(caller$i)[3,1,2]} if $a->{'trace'}||$l; die bless $a, 'Throw_' };
  0   0     0  
  0         0  
  0         0  
  0         0  
  0         0  
18 0         0 overload::OVERLOAD('Throw_', '""' => \&Throw_::_str, fallback => 1);
19 4         51 } else { Throw->import('throw') }
20             }
21              
22 10 50 33 10 1 89 sub service_name { $_[0]->{'service_name'} || $_[0]->{'service'} || throw "Missing service_name" }
23              
24             sub run_method {
25 5     5 0 14 my $self = shift;
26 5         24 my $name = $self->service_name;
27 5   33     34 my $method = shift || throw "Missing $name service method", undef, 1;
28 5   50     23 my $args = shift || {};
29 5 50       25 throw "Invalid $name service args", {method => $method, args => $args}, 1 if ref($args) ne 'HASH';
30 5   33     190 local $args->{'_i'} = $self->{'remote_ip'} || $ENV{'REMOTE_ADDR'} || (($ENV{'REALUSER'} || $ENV{'SUDO_USER'}) ? 'sudo' : 'cmdline');
31 5   50     1122 local $args->{'_w'} = $self->{'remote_user'} || $ENV{'REALUSER'} || $ENV{'SUDO_USER'} || $ENV{'REMOTE_USER'} || $ENV{'USER'} || (getpwuid($<))[0] || '-unknown-';
32 5 50 33     62 local $args->{'_t'} = $self->{'token'} if !$args->{'_t'} && $self->{'token'};
33 5 50 100     48 local $args->{'_c'} = do {my $i = my $c = 0; $c = [(caller $i++)[0..3]] while !$i || $c->[0]->isa(__PACKAGE__); join '; ', @$c} if ! $self->config(no_trace => undef, $name);
  5         11  
  5         255  
  5         39  
34 5 50       24 local $self->{'flat'} = exists($args->{'_flat'}) ? delete($args->{'_flat'}) : $self->config(flat => undef, $name);
35 5 50       16 return $self->_remote_call($method, $args) if $self->_needs_remote($method);
36 0         0 return $self->_local_call( $method, $args);
37             }
38              
39             sub _needs_remote {
40 10     10   92 my ($self, $method) = @_;
41 10         71 return $method !~ /(^local_|_local$)/;
42             }
43              
44             sub _local_call {
45 0     0   0 my ($self, $method, $args) = @_;
46 0         0 my $name = $self->service_name;
47 0   0     0 local $self->{'brand'} ||= $self->api_brand($name);
48             my $hash = eval {
49             my $code = $self->can("__$method") || throw "Invalid $name service method", {method => $method}, 1;
50             return $code->($self, $args);
51 0   0     0 } || (ref($@) eq 'HASH' && $@->{'error'} ? $@ : {error => "Trouble running $name service method", service => $name});
52 0         0 return $self->_result({method => $method, args => $args, data => $hash, service => $name, url => 'local'});
53             }
54              
55             sub config {
56 78     78 0 223 my ($self, $key, $def, $name) = @_;
57 78   33     160 $name ||= $self->service_name;
58 78         221 my $c = $self->_configs($name);
59             return exists($self->{$key}) ? $self->{$key}
60             : exists($c->{"${name}_service_${key}"}) ? $c->{"${name}_service_${key}"}
61             : (ref($c->{"${name}_service"}) && exists $c->{"${name}_service"}->{$key}) ? $c->{"${name}_service"}->{$key}
62             : exists($c->{"${name}_${key}"}) ? $c->{"${name}_${key}"}
63 78 100 33     620 : (ref($c->{$name}) && exists $c->{$name}->{$key}) ? $c->{$name}->{$key}
    50 33        
    50          
    50          
    50          
    100          
64             : ref($def) eq 'CODE' ? $def->($self) : $def;
65             }
66              
67             sub api_brand {
68 5     5 0 15 my ($self, $name) = @_;
69 5   33     19 $name ||= $self->service_name;
70 5 50       16 return undef if $self->config(no_brand => undef, $name); ## no critic (ProhibitExplicitReturnUndef)
71 5 50 33 5   68 $self->config(brand => sub { eval { config::provider() } || $self->_configs->{'provider'} || do { warn "Missing $name brand"; '-' } }, $name);
  5         12  
  5         155  
  5         123  
  5         28  
72             }
73              
74             sub _remote_call {
75 5     5   15 my ($self, $method, $args) = @_;
76 5         18 my $begin = Time::HiRes::time();
77 5         52 my $name = $self->service_name;
78 5         17 my $brand = $self->api_brand($name);
79 5     58   48 my $val = sub { my ($key, $def) = @_; $self->config($key, $def, $name) };
  58         202  
  58         114  
80 5         16 my $no_ssl = $val->(no_ssl => undef);
81 5     0   56 my $host = $val->(host => sub {throw "Missing $name service host",undef,1});
  0         0  
82 5 50       27 my $port = $val->(port => ($no_ssl ? 80 : 443));
83 5 50   5   40 my $path = $val->(path => sub { $name =~ /^(\w+)_service/ ? $1 : $name });
  5         21  
84 5 50       21 my $pass = $val->(no_sign => undef) ? undef : $val->(pass => undef); # rely on the server to tell us if a password is necessary
85 5 50       22 my $utf8 = exists($args->{'_utf8_encoded'}) ? delete($args->{'_utf8_encoded'}) : $val->(utf8_encoded => undef);
86 5   33     14 my $enc = $utf8 && (!ref($utf8) || $utf8->{$method});
87 5         22 my $retry = $val->(retry => undef);
88 5         12 my $ns = $val->(ns => undef);
89 5 50       15 $method = "${ns}_${method}" if $ns;
90 5 50       23 my $url = "/$path/$method".($brand ? "/$brand" : '');
91 5         89 my $cookie = $val->(cookie => undef);
92              
93 5         10 my $req;
94 5     0   119 local $SIG{'ALRM'} = sub { die "Timeout on $name\n" };
  0         0  
95 5   50     37 my $old = alarm($args->{'_timeout'} || $val->(timeout => 120)) || 0;
96 5         23 my %head;
97             my $hash = eval {
98             _decode_utf8_recurse($args) if $enc;
99             $req = eval { $self->json->encode($args) } || throw "Trouble encoding $name service json", {msg => $@}, 1;
100             my $sign = defined($pass) ? do { my $t = int $begin; "X-Respite-Auth: ".($val->('md5_pass') ? md5_hex($pass) : md5_hex("$pass:$t:$url:".md5_hex($req)).":$t")."\r\n" } : '';
101             $cookie = $cookie ? "Cookie: $cookie\r\n" : '';
102              
103             my $sock;
104             my $i = 0;
105             while (++$i) {
106             # Note SSL verify may not work as expected on IO::Socket::SSL versions below v1.46
107             $sock = $no_ssl ? IO::Socket::INET->new("$host:$port")
108             : IO::Socket::SSL->new(PeerAddr => $host, PeerPort => $port, SSL_verify_mode => $val->(ssl_verify_mode => 0));
109             last if $sock || !$retry || (Time::HiRes::time() - $begin > 3);
110             sleep 0.5;
111             }
112             if (!$sock) {
113             throw "Could not connect to $name service", {
114             host => $host, port => $port, url => $url,
115             msg => (!$no_ssl && ($IO::Socket::SSL::SSL_ERROR || $!)), detail => "$@", ssl => !$no_ssl, tries => $i,
116             };
117             }
118              
119             my $out = "POST $url HTTP/1.0\r\n${cookie}${sign}Host: $host\r\nContent-length: ".length($req)."\r\nContent-type: application/json\r\n\r\n$req";
120             warn "DEBUG_Respite: Connected to http".($no_ssl?'':'s')."://$host:$port/\n$out\n" if $ENV{'DEBUG_Respite'};
121             print $sock $out;
122             my ($len, $type, $line);
123             throw "Got non-200 status from $name service", {status => $line, url => $url} if !($line = <$sock>) || $line !~ m{^HTTP/\S+ 200\b};
124             while (defined($line = <$sock>)) {
125             $line =~ s/\r?\n$// || throw "Failed to find line termination", {line => $line};
126             last if $line eq "";
127             my ($key, $val) = split /\s*:\s*/, $line, 2;
128             $head{$key} = $head{$key} ? ref($head{$key}) ? [@{$head{$key}}, $val] : [$head{$key}, $val] : $val;
129             $len = ($val =~ /^\d+$/) ? $val : throw "Invalid content length", {h => \%head} if lc($key) eq 'content-length';
130             }
131             throw "Failed to find content length in $name service response" if ! $len;
132             throw "Content too large in $name service", {length => $len} if $len > 100_000_000;
133             my $data = '';
134             while (1) {
135             read($sock, $data, $len, length $data) || throw "Failed to read bytes", {needed => $len, got => length($data)};
136             last if length $data >= $len;
137             }
138             close $sock;
139             alarm($old);
140              
141             throw "Invalid $name service json object string" if $data !~ /^\s*\{/;
142             my $resp = eval { $self->json->decode($data) } || throw "Failed to decode $name service json response data", {msg => $@};
143             _encode_utf8_recurse($resp) if $enc;
144             $resp;
145 5   33     12 } || do { alarm($old); {error => "Failed to get valid $name service response: $@"} };
146              
147             return $self->_result({
148             service => $name,
149             method => $method,
150             args => $args,
151             data => $hash,
152             headers => \%head,
153             url => $url, host => $host, port => $port,
154             brand => $brand,
155             elapsed => sprintf('%.05f', Time::HiRes::time() - $begin),
156 5 50       236 ($self->{'pretty'} ? (pretty => 1) : ()),
157             });
158             }
159              
160             sub _result {
161 5     5   27 my ($self, $args) = @_;
162 5 50       33 if ($self->{'flat'}) {
163 5         14 my $data = $args->{'data'};
164 5 50 33     75 throw {_service => $args->{'service'} || $self->service_name, %$data, ($args->{'pretty'} ? (_pretty => 1) : ())} if $data->{'error'};
    100          
165 3         144 return $data;
166             }
167 0         0 return bless $args, $self->_result_class;
168             }
169              
170 0 0   0   0 sub _result_class { shift->{'result_class'} || 'Respite::Client::Result' }
171              
172             sub _encode_utf8_recurse {
173 0     0   0 my $d = shift;
174 0 0       0 if (UNIVERSAL::isa($d, 'HASH')) {
    0          
175 0 0 0     0 for my $k (keys %$d) { my $v = $d->{$k}; (ref $v) ? _encode_utf8_recurse($v) : $v and utf8::is_utf8($v) and utf8::encode($d->{$k}) }
  0 0       0  
  0         0  
176             } elsif (UNIVERSAL::isa($d, 'ARRAY')) {
177 0 0 0     0 for my $v (@$d) { (ref $v) ? _encode_utf8_recurse($v) : $v and utf8::is_utf8($v) and utf8::encode($v) }
  0 0       0  
178             }
179             }
180              
181             sub _decode_utf8_recurse {
182 0     0   0 my $d = shift;
183 0   0     0 my $seen = shift || {};
184 0 0       0 return if $seen->{$d}++;
185 0 0       0 if (UNIVERSAL::isa($d, 'HASH')) {
    0          
186 0 0 0     0 for my $k (keys %$d) { my $v = $d->{$k}; (ref $v) ? _decode_utf8_recurse($v, $seen) : $v and !utf8::is_utf8($v) and utf8::decode($d->{$k}) }
  0 0       0  
  0         0  
187             } elsif (UNIVERSAL::isa($d, 'ARRAY')) {
188 0 0 0     0 for my $v (@$d) { (ref $v) ? _decode_utf8_recurse($v, $seen) : $v and !utf8::is_utf8($v) and utf8::decode($v) }
  0 0       0  
189             }
190             }
191              
192             sub AUTOLOAD {
193 5     5   9005 my $self = shift;
194 5   50     56 my $args = shift || {};
195 5 50       150 my $meth = $Respite::Client::AUTOLOAD =~ /::(\w+)$/ ? $1 : throw "Invalid method\n";
196 5 50       28 throw "Self was not passed while looking up method", {method => $meth, trace => 1} if ! ref $self;
197 5 0 33     50 throw "Invalid ".$self->service_name." method \"$meth\"", {trace => 1} if !$self->_needs_remote($meth) && ! $self->can("__${meth}");
198 5     5   47 my $code = sub { $_[0]->run_method($meth => $_[1]) };
  5         31  
199 4     4   12397 no strict 'refs'; ## no critic
  4         9  
  4         957  
200 5 50       35 *{ref($self)."::$meth"} = $code if __PACKAGE__ ne ref($self);
  0         0  
201 5         22 return $self->$code($args);
202             }
203              
204       0     sub DESTROY {}
205              
206             sub run_commandline {
207 0     0 0   my $class = shift;
208 0 0         my $args = ref($_[0]) ? shift : {@_};
209 0 0         my $self = ref($class) ? $class : $class->new({%$args});
210 0           require Respite::CommandLine;
211 0     0     Respite::CommandLine->run({dispatch_factory => sub { $self }});
  0            
212             }
213              
214             ###----------------------------------------------------------------###
215              
216             {
217             package Respite::Client::Result;
218 4     4   31 use overload 'bool' => sub { ! shift->error }, '""' => \&as_string, fallback => 1;
  4     0   5  
  4         53  
  0         0  
219 0     0     sub error { shift->data->{'error'} }
220 0     0     sub TO_JSON { return {%{$_[0]}} }
  0            
221             sub as_string {
222 0     0     my $self = shift;
223 0 0         if (my $err = $self->error) {
224 0           my $data = $self->data;
225 0 0         my $p = defined($Respite::Client::pretty) ? $Respite::Client::pretty : $self->{'pretty'};
226 0           local $data->{'error'}; delete $data->{'error'};
  0            
227 0 0         return !scalar keys %$self ? $err : "$err: ".($p ? Respite::Client::jsop():Respite::Client::json())->encode({%$data});
    0          
228             }
229 0           return "Called $self->{'service'} service method $self->{'method'}";
230             }
231 0   0 0     sub data { shift->{'data'} ||= {} }
232             }
233              
234             1;