File Coverage

blib/lib/Restish/Client.pm
Criterion Covered Total %
statement 119 170 70.0
branch 61 88 69.3
condition 5 20 25.0
subroutine 22 31 70.9
pod 6 12 50.0
total 213 321 66.3


line stmt bran cond sub pod time code
1 4     4   913744 use strict;
  4         9  
  4         220  
2             package Restish::Client;
3              
4 4     4   4301 use Moo;
  4         28918  
  4         21  
5 4     4   5686 use Carp qw(croak);
  4         5  
  4         189  
6 4     4   1862 use Data::Validate::URI qw(is_http_uri is_https_uri);
  4         181611  
  4         248  
7 4     4   40 use HTTP::Headers;
  4         8  
  4         86  
8 4     4   13 use HTTP::Request;
  4         5  
  4         86  
9 4     4   14 use JSON qw(decode_json encode_json);
  4         6  
  4         57  
10 4     4   484 use LWP::UserAgent;
  4         18  
  4         103  
11 4     4   1815 use Text::Sprintf::Named qw(named_sprintf);
  4         4002  
  4         244  
12 4     4   23 use URI::Escape qw(uri_escape);
  4         5  
  4         154  
13 4     4   1554 use URI::Query;
  4         7053  
  4         212  
14 4     4   2985 use HTTP::Cookies;
  4         25569  
  4         1697  
15              
16             our $VERSION = '1.0';
17              
18             our %VALID_METHOD = (
19             GET => 1,
20             PUT => 1,
21             POST => 1,
22             DELETE => 1,
23             PATCH => 1,
24             LIST => 1,
25             );
26              
27             # Set this to enable the canonical encoding of json, for facilitating string
28             # comparisons. Only to be used when testing. https://metacpan.org/pod/JSON#canonical
29             our $CANONICAL = 0;
30              
31             =head1 NAME
32              
33             Restish::Client - A RESTish client...in perl!
34              
35             =head1 SYNOPSIS
36              
37             use Restish::Client;
38              
39             my $client = Restish::Client->new(
40             uri_host => 'https://api.example.com/',
41             head_params_default => { 'Authorization' => 'Bearer mytoken' },
42             );
43              
44             # GET request
45             my $data = $client->GET( uri => '/v1/users' );
46              
47             # POST with body parameters
48             my $result = $client->POST(
49             uri => '/v1/users',
50             body_params => { name => 'Alice', email => 'alice@example.com' },
51             );
52              
53             # GET with URI template and query parameters
54             my $user = $client->GET(
55             uri => '/v1/users/%(user_id)s',
56             template_params => { user_id => '42' },
57             query_params => { format => 'json' },
58             );
59              
60             if ($client->response_code == 200) {
61             print $user->{name};
62             }
63              
64             =head1 DESCRIPTION
65              
66             This module provides a Perl wrapper for the REST-like API's.
67              
68             =head2 METHODS
69              
70             =over 12
71              
72             =item C
73              
74             my $client = Restish::Client->new(
75             uri_host => 'https://vault.example.com/',
76             head_params_default => { 'X-Vault-Token' => $a_token },
77             agent_options => { timeout => 5 },
78             require_https => 1,
79             ssl_opts => {
80             SSL_use_cert => 1,
81             SSL_cert_file => "/etc/ssl/certs/cert.pem",
82             SSL_key_file => "/etc/ssl/private_keys/key.pem",
83             },
84             cookie_jar => 1,
85             );
86              
87             Construct a new Restish::Client object. The uri_host is used as the base
88             uri for each API call, and serves as a template if string interpolation is used
89             (see below).
90              
91             Optionally provide any data that can be set via a mutator, such as
92             head_params_default or the ssl_opts.
93              
94             Options can be passed to the user agent (currently LWP) via agent_options.
95              
96             If require_https is set, new() will die if uri_host is not an https uri.
97              
98             =item C
99              
100             $client->head_params_default({ 'X-Vault-Token' => $auth_token });
101              
102             Supply a hashref specifying default header parameters to be sent with every
103             request using this object.
104              
105             =cut
106              
107             has head_params_default => (
108             is => 'rw',
109             default => sub { {} },
110             isa => sub {
111             __PACKAGE__->error("Invalid parameter $_[0]; supply a hashref")
112             unless ref $_[0] eq 'HASH'
113             }
114             );
115              
116             =item C
117              
118             $client->ssl_opts({ SSL_use_cert => 1 });
119              
120             Supply a hashref specifying default LWP UserAgent SSL options to be sent with every
121             request using this object.
122              
123             =cut
124              
125             has ssl_opts => (
126             is => 'rw',
127             default => sub { {} },
128             isa => sub {
129             __PACKAGE__->error("Invalid parameter $_[0]; supply a hashref")
130             unless ref $_[0] eq 'HASH'
131             }
132             );
133              
134             =item C
135              
136             $client->cookie_jar(1);
137             $client->cookie_jar(/path/to/cookiejar)
138              
139             Enable LWP UserAgent's cookie_jar. Optionally store the cookie jar to disk.
140              
141             =cut
142              
143             has cookie_jar => (
144             is => 'rw',
145             default => undef,
146             );
147              
148              
149             =item C
150              
151             $client->request( method => 'POST',
152             uri => 'already/escaped/path',
153             query_params => { param1 => value1, param2 => value2 },
154             body_params => { body_param1 => bvalue1, body_param2 => bvalue2 },
155             head_params => { X-Subject-Token => $subject_token } );
156              
157             Send a request based off of the object's base uri_host, returning a Perl data
158             structure of the parsed JSON response in the event of a 2xx series response
159             code. c and c are required.
160              
161             If the request returns a 4xx or 5xx response status code, the return value will
162             be 0.
163              
164             The c, c, and c methods can be
165             used to retrieve more information about the previous request.
166              
167             The URI is specified as a string that supports Text::Sprintf::Named compatible
168             string interpretation. Interpolated values will be escaped, but the
169             non-interpolated section will not be escaped. The URI can begin with a slash or
170             the slash can be omitted.
171              
172             my $res = $client->request(
173             method => 'GET',
174             uri => '/%(tenant_id)s/%(other)s',
175             template_params => { tenant_id => 'cde381ab', other => 'blah' }
176             );
177              
178             Optionally specify parameters. URI parameters will be escaped in the query
179             string. Body parameters will be encoded as JSON. Head parameters will be sent
180             in addition to any default parameters specified using the
181             c method.
182              
183             Invalid parameters, such as an invalid uri or not supplying a hashref to
184             query_params, will result in an exception.
185              
186             Instead of body_params, you can use raw_body to upload a file.
187             Use content_type to specify the Content-Type.
188              
189             my $res = $client->request(
190             method => 'POST',
191             uri => 'uploads.json',
192             query_params => { filename => 'important-doc.pdf' },
193             raw_body => $file_data,
194             content_type => 'application/pdf',
195             );
196              
197             =cut
198              
199             sub request {
200 20     20 1 585 my ($self, %params) = @_;
201              
202             # Check params
203             # should probably use Params::Validate here
204              
205             # check to make sure all named params are valid to avoid cases like using
206             # uri_params instead of query_params, which could potentially have bad
207             # effects such as when deleting after using a filtered request where the
208             # filter didn't actually apply
209 20         126 my %valid_req_params = (
210             method => 1,
211             uri => 1,
212             template_params => 1,
213             query_params => 1,
214             body_params => 1,
215             head_params => 1,
216              
217             # to pass in a file
218             raw_body => 1,
219             content_type => 1,
220             );
221              
222 20         64 foreach (keys %params) {
223             $self->error("Invalid named parameter supplied to Restish::Client->request: $_")
224 48 100       118 unless defined $valid_req_params{$_};
225             }
226              
227 19 100       91 if ($params{query_params}) {
228             $self->error("query_params must be a hashref")
229 2 100       9 unless ref($params{query_params}) eq 'HASH';
230             }
231              
232             $VALID_METHOD{$params{method}} or
233 18 100       69 $self->error("Invalid value for parameter $params{method}");
234              
235             $self->error("Missing value for parameter URI")
236 17 100       44 unless defined $params{uri};
237              
238             # End param checking
239              
240              
241             my $joined_uri = $self->_assemble_uri(
242 16         85 $params{uri}, $params{query_params}, $params{template_params});
243             # It's ok if query_params and/or template_params are nonexistent
244              
245 12         66 $self->_set__response(undef);
246              
247 12         79 my $header = HTTP::Headers->new();
248 1         5 $header->header(%{$params{head_params}})
249 12 100       133 if $params{head_params};
250             $header->header('Content-Type' => 'application/json')
251 12 100       112 if $params{body_params};
252             $header->header('Content-Type' => $params{content_type})
253 12 50       183 if $params{content_type};
254              
255             my $req = HTTP::Request->new(
256             $params{method},
257 12         63 $joined_uri,
258             $header
259             );
260              
261 12 100       32095 if ($params{body_params}) {
262             $CANONICAL ? $req->content(JSON->new->utf8->canonical->encode($params{body_params}))
263 3 100       107 : $req->content(encode_json($params{body_params}));
264             }
265              
266 12 50       115 if ($params{raw_body}) {
267 0         0 $req->content($params{raw_body});
268             }
269              
270 12         69 my $agent = $self->_get_agent();
271              
272 12         20230 my $res;
273 12 50       55 if ($self->debug) {
274 4     4   29 use Data::Dumper;
  4         5  
  4         6765  
275             local $Data::Dumper::Sortkeys = sub {
276             return [
277 0     0   0 grep {$_ !~ /x-auth-token|x-subject-token|x-vault-token/} keys %{$_[0]}
  0         0  
  0         0  
278             ];
279 0 0       0 } if $self->debug->{trim_tokens};
280              
281 0         0 warn "*** LWP DEFAULT HEADERS: ". Dumper($agent->default_headers);
282 0         0 warn "*** REQUEST: " . Dumper($req);
283              
284 0         0 $res = $agent->request($req);
285 0         0 warn "*** RESPONSE: " . Dumper($res);
286             } else {
287 12         51 $res = $agent->request($req);
288             }
289              
290 12         5795 $self->_set__response($res);
291              
292 12 100       43 if ($res->is_success) {
293             # This is a bad hack, but some Compute calls return non-json response
294             # bodies and decode_json will throw an exception on them.
295             # Alternatively, could use a JSON::allow_ method all the time, but I
296             # prefer to have validation when actual JSON is returned
297 11 100       130 if ($res->decoded_content) {
298 10 100       1714 return decode_json $res->decoded_content
299             if substr($res->decoded_content, 0, 1) =~ /[\{\[]/;
300 1         130 return $res->decoded_content;
301             }
302              
303             # request succeeded, but response had no content
304 1         171 return 1;
305             }
306              
307             # request failed
308 1         60 return 0;
309             }
310              
311             =item C
312              
313             $client->METHOD(params) will ship the METHOD as method=>$method to the request
314              
315             =cut
316             sub GET {
317 0     0 0 0 my ($self, %params) = @_;
318 0         0 $params{method} = 'GET';
319 0         0 return $self->request(%params);
320             }
321             sub POST {
322 0     0 0 0 my ($self, %params) = @_;
323 0         0 $params{method} = 'POST';
324 0         0 return $self->request(%params);
325             }
326             sub LIST {
327 0     0 0 0 my ($self, %params) = @_;
328 0         0 $params{method} = 'LIST';
329 0         0 return $self->request(%params);
330             }
331             sub DELETE {
332 0     0   0 my ($self, %params) = @_;
333 0         0 $params{method} = 'DELETE';
334 0         0 return $self->request(%params);
335             }
336             sub PATCH {
337 0     0 0 0 my ($self, %params) = @_;
338 0         0 $params{method} = 'PATCH';
339 0         0 return $self->request(%params);
340             }
341              
342              
343             =item C
344              
345             Send a request directly to a LWP::UserAgent request method. These arguments of the
346             requst may be in the form of key=>value, or multiples of k1=>v1, k2=>v2. Complex
347             structures are not supported.
348              
349             Usage:
350              
351             # For GET/DELETE supply each k=>v pair as a new array element
352             $client->thin_request('GET', $URI, key1=> val1, key2 => val2);
353              
354             # For POST/PUT if you wrap the k=>v pairs into a structure they will be sent as form data
355             $client->thin_request('PUT', $URI, {key1 => val1, key2 => val2});
356              
357             Example:
358              
359             my $res = $client->thin_request('POST', "public/auth", { user => $user, pass => $pass });
360              
361             =cut
362             sub thin_request {
363 0     0 1 0 my ($self, $method, $uri, $query_params, @data) = @_;
364              
365 0         0 my $agent = $self->_get_agent();
366              
367 0 0       0 die("invalid method") unless $VALID_METHOD{$method};
368              
369             # Don't support LIST
370 0 0       0 if ($method eq 'LIST') {
371 0         0 __PACKAGE__->error("thin_request does not support LIST");
372             }
373              
374             # lc for function call
375 0         0 $method = lc($method);
376              
377             # POST will require a structure
378 0 0 0     0 if ($method eq 'post' && !@data) {
379 0         0 @data = [];
380             }
381              
382 0         0 my $joined_uri = $self->_assemble_uri($uri,$query_params);
383              
384 0         0 my $res = $agent->$method($joined_uri, @data);
385              
386 0         0 $self->_set__response($res);
387              
388 0 0       0 if ($res->is_success) {
389 0 0       0 if (defined $res->decoded_content) {
390 0 0       0 if ($res->header("Content-Type") =~ /^application\/json\b/i) {
391 0   0     0 my $json = eval {return decode_json $res->decoded_content} || return 0;
392 0         0 return $json;
393             } else {
394 0         0 return $res->decoded_content;
395             }
396             }
397              
398             # request succeeded, but response had no content
399 0         0 return 1;
400             }
401             # request failed
402 0         0 return 0;
403             }
404              
405             =item C
406              
407             Shortcut to the whether the last response succeeded
408              
409             =cut
410              
411             sub is_success {
412 0     0 1 0 my ($self) = @_;
413 0 0       0 return $self->_response->is_success
414             if defined $self->_response;
415 0         0 return undef;
416             }
417              
418             =item C
419              
420             Returns the response code of the last request.
421              
422             =cut
423              
424             sub response_code {
425 9     9 1 5459 my ($self) = @_;
426 9 100       94 return $self->_response->code
427             if defined $self->_response;
428 1         7 return undef;
429             }
430              
431              
432             =item C
433              
434             my $ctype = $client->response_header('Content-Type');
435              
436             Returns the value of a selected response header of the last request.
437              
438             =cut
439              
440             sub response_header {
441 2     2 1 1033 my ($self, $desired_header) = @_;
442 2 100       19 return $self->_response->header($desired_header)
443             if defined $self->_response;
444 1         5 return undef;
445             }
446              
447              
448             =item C
449              
450             Returns a string of the response body of the last request.
451              
452             =cut
453              
454             sub response_body {
455 2     2 1 1850 my ($self) = @_;
456 2 100       21 return $self->_response->decoded_content
457             if defined $self->_response;
458 1         5 return undef;
459             }
460              
461              
462             =item C
463              
464             Dump information on every request(). Set to undef, {}, or a hashref of
465             configuration flags.
466              
467             =over 12
468              
469             =item C
470              
471             The default level: don't dump anything.
472              
473             =item C<{}>
474              
475             Dump the LWP object's default header object, request object, and response
476             object.
477              
478             =item C<{trim_tokens => 0}>
479              
480             Whether to trim tokens.
481              
482             =back
483              
484             =cut
485              
486             has debug => (
487             is => 'rw',
488             default => sub { undef }
489             );
490              
491             =back
492              
493             =head2 PRIVATE METHODS
494              
495             The following private methods are documented in case a subclass should need to
496             override them.
497              
498             =over 12
499              
500             =cut
501              
502             # If a user wants to use a new root path, safest route is a new obj
503             has _uri_host => (
504             is => 'ro',
505             required => 1,
506             init_arg => 'uri_host',
507             isa => sub {
508             my $uri = $_[0];
509             __PACKAGE__->error("Invalid value for parameter $uri; must specify http(s)://")
510             unless $uri =~ qr{^https?://\S*};
511             }
512             );
513              
514             has _require_https => (
515             is => 'ro',
516             init_arg => 'require_https',
517             isa => sub {
518             my $option = $_[0];
519             __PACKAGE__->error("Invalid value for parameter require_https: $option;"
520             . " Must be either 0 or 1.")
521             unless $option =~ /^[01]$/;
522             },
523             default => 0
524             );
525              
526             sub BUILD {
527 29     29 0 261 my ($self) = @_;
528 29 100 66     231 __PACKAGE__->error("Invalid value for uri_host: $self->uri_host; "
529             . " require_https specified but not a https uri")
530             if $self->_require_https && !($self->_uri_host =~ /^https/);
531             }
532              
533             # _agent_options($options_hashref)
534             # Hashref containing the constructor options for the user agent.
535             has _agent_options => (
536             is => 'ro',
537             init_arg => 'agent_options',
538             default => sub {
539             return { agent => __PACKAGE__ . "/$VERSION" };
540             },
541             trigger => sub {
542             my ($self, $options) = @_;
543             return if defined $options->{'agent'};
544              
545             $options->{agent} = __PACKAGE__ . "/$VERSION";
546             return $options;
547             }
548             );
549              
550             # _response stores the HTTP::Response object from the most recent request
551             has _response => (is => 'rwp');
552              
553              
554             =item C<_get_agent>
555              
556             Returns a new user agent object for use in requests with this client.
557             C<_get_agent> uses C<_agent_options> to get the constructor options for the
558             agent.
559              
560             =cut
561              
562             sub _get_agent {
563 16     16   281 my ($self) = @_;
564              
565 16 50       26 my %options = %{$self->_agent_options || {}};
  16         119  
566              
567 16         94 my $headers = HTTP::Headers->new(Accept => 'application/json');
568            
569 0         0 $headers->header(%{$self->head_params_default})
570 16 50       873 if %{$self->head_params_default};
  16         435  
571              
572 16         160 $options{default_headers} = $headers;
573              
574 16 50       275 $options{ssl_opts} = $self->ssl_opts if $self->ssl_opts;
575              
576 16 50       410 $options{cookie_jar} = $self->_get_cookie_jar() if $self->cookie_jar;
577              
578 16         47 $options{env_proxy} = 1;
579              
580 16         121 return LWP::UserAgent->new(%options);
581             }
582              
583             sub _get_cookie_jar {
584 0     0   0 my ($self) = @_;
585              
586 0 0       0 if($self->cookie_jar eq 1) {
587 0   0     0 $self->{_cookie_jar} ||= HTTP::Cookies->new();
588             } else {
589 0   0     0 $self->{_cookie_jar} ||= HTTP::Cookies->new(file => $self->cookie_jar, autosave => 1, ignore_discard => 1);
590             }
591             }
592              
593             # _assemble_uri($uri_arrayref_or_string, $query_params_hashref, $template_params_hashref)
594             # Joins the base uri, uri_host, with the desired path
595             sub _assemble_uri {
596 17     17   70 my ($self, $path, $query_params, $template_params) = @_;
597              
598 17 100       57 if (ref $path) {
599 1         6 $self->error("Invalid value for parameter $path; must be a string");
600             }
601              
602 16 100       35 if (defined $query_params) {
603 1 50 33     3 $self->error("Invalid value for parameter $query_params; must be a HASH or ARRAY ref")
604             unless (ref($query_params) eq 'HASH' or ref($query_params) eq 'ARRAY');
605             }
606              
607 16         24 my $uri;
608 16 100       53 if ($path eq '/') {
    100          
609             # Remove trailing / from base uri if joining to a path of /
610 1         3 my $uri_host = $self->_uri_host;
611              
612 1 50       7 $uri_host = $1
613             if $uri_host =~ qr{(\S*)/$};
614              
615 1         3 $uri = $uri_host . '/';
616              
617             } elsif ($path) {
618             # Remove trailing / from base uri and beginning / from path
619             # so as not to construct a uri with // in the path
620              
621 12         31 my $uri_host = $self->_uri_host;
622              
623 12 100       81 $uri_host = $1
624             if $uri_host =~ qr{(\S*)/$};
625              
626 12 100       95 $path = $1
627             if $path =~ qr{^/(\S*)};
628              
629 12         33 $uri = $uri_host . '/' . $path;
630              
631             } else {
632             # No path to append to uri_host, so don't modify uri_host in case user
633             # wanted trailing slash
634 3         14 $uri = $self->_uri_host;
635             }
636              
637 16 100       42 $uri = $self->_interpolate_uri($uri, $template_params)
638             if defined $template_params;
639              
640 15 100       59 $uri .= '?' . URI::Query->new($query_params)->stringify
641             if defined $query_params;
642              
643 15 100 66     634 $self->error("Invalid value $uri; does not form a valid uri")
644             unless(is_http_uri($uri) or is_https_uri($uri));
645              
646 13         15233 return $uri;
647             }
648              
649              
650             # _interpolate_uri($uri_string, $template_params_hashref)
651             # Interpolates named values into the uri, escaping each
652             sub _interpolate_uri {
653 4     4   11 my ($self, $uri, $template_params) = @_;
654              
655 4 100       18 $self->error("Invalid value for parameter $template_params: "
656             . "interpolated values must be supplied in a hashref\n")
657             unless ref($template_params) eq 'HASH';
658              
659 3         7 my %escaped_tparams;
660 3         8 foreach my $key (keys %$template_params) {
661 6         143 $escaped_tparams{$key} = uri_escape $template_params->{$key};
662             }
663              
664 3         90 $uri = named_sprintf($uri, %escaped_tparams);
665              
666 3         394 return $uri;
667             }
668              
669             # error handling
670             sub error {
671 12     12 0 426 my ($self, $error) = @_;
672              
673 12         200 croak($error);
674             }
675              
676             =back
677              
678             =cut
679              
680             1;