File Coverage

blib/lib/REST/Consumer.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package REST::Consumer;
2             # a generic client for talking to restful web services
3              
4 1     1   30318 use strict;
  1         2  
  1         33  
5 1     1   4 use warnings;
  1         1  
  1         27  
6              
7 1     1   4 use Carp qw(cluck);
  1         6  
  1         66  
8 1     1   435 use LWP::UserAgent::Paranoid;
  0            
  0            
9             use URI;
10             use JSON::XS;
11             use HTTP::Request;
12             use HTTP::Headers;
13             use File::Path qw( mkpath );
14             use REST::Consumer::Dispatch;
15             use REST::Consumer::RequestException;
16             use REST::Consumer::PermissiveResolver;
17             use Time::HiRes qw(usleep);
18              
19             our $VERSION = '1.02';
20              
21             my $global_configuration = {};
22             my %service_clients;
23             my $data_path = $ENV{DATA_PATH} || $ENV{TMPDIR} || '/tmp';
24             my $throw_exceptions = 1;
25              
26             # make sure config gets loaded from url every 5 minutes
27             my $config_reload_interval = 60 * 5;
28              
29             sub throw_exceptions {
30             my ($class, $value) = @_;
31             $throw_exceptions = $value if defined $value;
32             return $throw_exceptions;
33             }
34              
35             sub configure {
36             my ($class, $config, @args) = @_;
37             if (!ref $config) {
38             if ($config =~ /^https?:/) {
39             # if the config is a scalar that starts with http:, assume it's a url to fetch configuration from
40             my $uri = URI->new($config);
41             my ($dir, $filename) = _config_file_path($uri);
42              
43             my @stat = stat("$dir/$filename");
44             my $age_in_seconds = time - $stat[9];
45              
46             $config = load_config_from_file("$dir/$filename", \@stat);
47              
48             # reload config from url if it's older than 10 minutes
49             if (!$config || ($age_in_seconds && $age_in_seconds > $config_reload_interval)) {
50             my $client = $class->new( host => $uri->host, port => $uri->port );
51             $config = $client->get( path => $uri->path );
52              
53             # try to cache config loaded from a url to a file for fault tolerance
54             write_config_to_file($uri, $config);
55             }
56             } else {
57             # otherwise it's a filename
58             my $path = $config;
59             $config = load_config_from_file($path);
60             }
61             }
62              
63             if (ref $config ne 'HASH') {
64             die "Invalid configuration. It should either be a hashref or a url or filename to get config data from";
65             }
66              
67             for my $key (keys %$config) {
68             $global_configuration->{$key} = _validate_client_config($config->{$key});
69             }
70              
71             return 1;
72             }
73              
74             sub _config_file_path {
75             my ($uri) = @_;
76             my $cache_filename = $uri->host . '-' . $uri->port . $uri->path . '.json';
77             # $cache_filename =~ s/\//-/g;
78             my ($dir, $filename) = $cache_filename =~ /(.*)\/([^\/]*)/i;
79             return ("$data_path/rest-consumer/config/$dir", $filename);
80             }
81              
82             sub load_config_from_file {
83             my ($path, $stat) = @_;
84             my @stat = $stat || stat($path);
85             return if !-e _ || !-r _;
86              
87             undef $/;
88             open my $config_fh, $path or die "Couldn't open config file '$path': $!";
89              
90             my $data = <$config_fh>;
91             my $decoded_data = JSON::XS::decode_json($data);
92             close $config_fh;
93             return $decoded_data;
94             }
95              
96             sub write_config_to_file {
97             my ($url, $config) = @_;
98             my ($dir, $filename) = _config_file_path($url);
99              
100             eval { mkpath($dir) };
101             if ($@) {
102             warn "Couldn’t create make directory for rest consumer config $dir: $@";
103             return;
104             }
105              
106             # if (!-w "$dir/$filename") {
107             # warn "Can't write config data to: $dir/$filename - not caching rest consumer config data";
108             # return;
109             # }
110              
111             open my $cache_file, '>', "$dir/$filename"
112             or die "Couldn't open config file for write '$dir/$filename': $!";
113              
114             print $cache_file JSON::XS::encode_json($config);
115             close $cache_file;
116             }
117              
118             sub service {
119             my ($class, $name) = @_;
120             return $service_clients{$name} if defined $service_clients{$name};
121              
122             die "No service configured with name: $name"
123             if !exists $global_configuration->{$name};
124              
125             $service_clients{$name} = $class->new(%{$global_configuration->{$name}});
126             return $service_clients{$name};
127             }
128              
129             sub _validate_client_config {
130             my ($config) = @_;
131             my $valid = {
132             host => $config->{host},
133             url => $config->{url},
134             port => $config->{port},
135             (defined $config->{ua} ? (ua => $config->{ua}) : ()),
136              
137             # timeout on requests to the service
138             timeout => $config->{timeout} || 10,
139              
140             # retry this many times if we don't get a 200 response from the service
141             retry => exists $config->{retry} ? $config->{retry} : exists $config->{retries} ? $config->{retries} : 0,
142              
143             # delay by this many ms before every retry
144             retry_delay => $config->{retry_delay} || 0,
145              
146             # print some extra debugging messages
147             verbose => $config->{verbose} || 0,
148              
149             # enable persistent connection
150             keep_alive => $config->{keep_alive} || 1,
151              
152             agent => $config->{user_agent} || "REST-Consumer/$VERSION",
153              
154             auth => $config->{auth} || {},
155             };
156              
157             if (!$valid->{host} and !$valid->{url}) {
158             die "Either host or url is required";
159             }
160              
161             return $valid;
162             }
163              
164             sub new {
165             my ($class, @args) = @_;
166             my $args = {};
167             if (scalar @args == 1 && !ref $args[0]) {
168             $args->{url} = $args[0];
169             } else {
170             $args = { @args };
171             }
172             my $self = _validate_client_config($args);
173             bless $self, $class;
174             return $self;
175             }
176              
177             sub host {
178             my ($self, $host) = @_;
179             $self->{host} = $host if defined $host;
180             return $self->{host};
181             }
182              
183             sub port {
184             my ($self, $port) = @_;
185             $self->{port} = $port if defined $port;
186             return $self->{port};
187             }
188              
189             sub timeout {
190             my ($self, $timeout) = @_;
191             if (defined $timeout) {
192             $self->{timeout} = $timeout;
193             $self->apply_timeout($self->{_user_agent}) if $self->{_user_agent};
194             }
195             return $self->{timeout};
196             }
197              
198             sub retry {
199             my ($self, $retry) = @_;
200             $self->{retry} = $retry if defined $retry;
201             return $self->{retry};
202             }
203              
204             sub retry_delay {
205             my ($self, $retry_delay) = @_;
206             $self->{retry_delay} = $retry_delay if defined $retry_delay;
207             return $self->{retry_delay};
208             }
209              
210             sub keep_alive {
211             my ($self, $keep_alive) = @_;
212             if (defined $keep_alive) {
213             $self->{keep_alive} = $keep_alive;
214             $self->apply_keep_alive($self->{_user_agent}) if $self->{_user_agent};
215             }
216             return $self->{keep_alive};
217             }
218              
219             sub agent {
220             my ($self, $agent) = @_;
221             if (defined $agent) {
222             $self->{agent} = $agent;
223             $self->apply_agent($self->{_user_agent}) if $self->{_user_agent};
224             }
225             return $self->{agent};
226             }
227              
228             sub last_request {
229             my ($self) = @_;
230             return $self->{_last_request};
231             }
232              
233             sub last_response {
234             my ($self) = @_;
235             return $self->{_last_response};
236             }
237              
238             sub get_user_agent { user_agent(@_) }
239              
240             sub apply_timeout {
241             my ($self, $ua) = @_;
242             if ($ua->can('request_timeout')) {
243             # $ua is a LWP::UserAgent::Paranoid or some other thing that honors request_timeout
244             $ua->request_timeout($self->timeout);
245             } else {
246             # $ua is vanilla LWP or a subclass, use the inferior timeout method
247             $ua->timeout($self->timeout);
248             }
249             }
250              
251             sub apply_agent {
252             my ($self, $ua) = @_;
253             $ua->agent($self->agent);
254             }
255              
256             sub apply_keep_alive {
257             my ($self, $ua) = @_;
258             if ($ua->can('keep_alive')) {
259             # $ua is some well-behaved user-provided object
260             $ua->keep_alive($self->keep_alive);
261             } elsif ($ua->can('conn_cache')) {
262             # $ua is an LWP::UserAgent - unfortunately there's no ->keep_alive method exposed by LWP::UserAgent,
263             # so we just do what its constructor does to set up keep-alive.
264             if ($self->keep_alive) {
265             $ua->conn_cache({total_capacity => $self->keep_alive});
266             } else {
267             $ua->conn_cache(undef);
268             }
269             } else {
270             # no clue how to handle things; sorry charlie.
271             cluck "Don't know how to make a user-specified user agent object of type ${\ref($ua)} honor your keep_alive request.\n";
272             }
273             }
274              
275              
276             sub user_agent {
277             my $self = shift;
278             return $self->{_user_agent} if defined $self->{_user_agent};
279              
280             my $user_agent = delete $self->{ua};
281             unless ($user_agent) {
282             # Paranoid's default resolver blocks access to private IP addresses.
283             # We don't want to do any such thing by default, so provide a more permissive one.
284             $user_agent = LWP::UserAgent::Paranoid->new(
285             resolver => REST::Consumer::PermissiveResolver->new
286             );
287             }
288              
289             # bubble our ->timeout, ->agent, and ->keep_alive args into this $user_agent object.
290             # if keep alive is enabled, we create a connection that persists globally
291             $self->apply_timeout($user_agent);
292             $self->apply_agent($user_agent);
293             $self->apply_keep_alive($user_agent);
294              
295             # handle auth headers
296             my $default_headers = $user_agent->default_headers;
297             $default_headers->header( 'accept' => 'application/json' );
298              
299             if (exists $self->{auth} && $self->{auth}{type} && $self->{auth}{type} eq 'basic') {
300             $default_headers->authorization_basic($self->{auth}{username}, $self->{auth}{password});
301             }
302              
303             $self->{_user_agent} = $user_agent;
304             return $user_agent;
305             }
306              
307              
308             # create the base url for the request composed of the host and port
309             # add http if it hasn't already been prepended
310             sub get_service_base_url {
311             my $self = shift;
312             return $self->{url} if $self->{url};
313              
314             my $host = $self->{host};
315             my $port = $self->{port};
316             $host =~ s|/$||;
317              
318             return ( ($host =~ m|^https?://| ? '' : 'http://' ) . $host . ($port ? ":$port" : '') );
319             }
320              
321             # return a URI object containing the url and any query parameters
322             # path: the url
323             # params: an array ref or hash ref containing key/value pairs to add to the URI
324             sub get_uri {
325             my $self = shift;
326             my %args = @_;
327             my $path = $args{path};
328             my $params = $args{params};
329             $path =~ s|^/||;
330              
331             # replace any sinatra-like url tokens with their param value
332             if (ref $params eq 'HASH') {
333             $path =~ s/\:(\w+)/exists $params->{$1} ? URI::Escape::uri_escape(delete $params->{$1}) : $1/eg;
334             }
335              
336             my $uri = URI->new( $self->get_service_base_url() . "/$path" );
337             # accept key / values in hash or array format
338             my @params = ref($params) eq 'HASH' ? %$params : ref($params) eq 'ARRAY' ? @$params : ();
339             $uri->query_form( @params );
340             return $uri;
341             }
342              
343             # get an http request object for the given input
344             sub get_http_request {
345             my $self = shift;
346             my %args = @_;
347             my $path = $args{path} or die 'path is a required argument. e.g. "/" ';
348             my $content = $args{content};
349             my $headers = $args{headers};
350             my $params = $args{params};
351             my $method = $args{method} or die 'method is a required argument';
352             my $content_type = $args{content_type};
353              
354             # build the uri from path and params
355             my $uri = $self->get_uri(path => $path, params => $params);
356              
357             $self->debug( sprintf('Creating request: %s %s', $method, $uri->as_string() ));
358              
359             # add headers if present
360             my $full_headers = $self->user_agent->default_headers || HTTP::Headers->new;
361             if ($headers) {
362             my @header_params = ref($headers) eq 'HASH' ? %$headers : ref($headers) eq 'ARRAY' ? @$headers : ();
363             $full_headers->header(@header_params);
364             }
365              
366             # assemble request
367             my $req = HTTP::Request->new($method => $uri, $full_headers);
368              
369             $self->add_content_to_request(
370             request => $req,
371             content_type => $content_type,
372             content => $content,
373             );
374              
375              
376             return $req;
377             }
378              
379              
380             # add content to the request
381             # by default, serialize to json
382             # otherwise use content type to determine any action if needed
383             # content type defaults to application/json
384             sub add_content_to_request {
385             my $self = shift;
386             my %args = @_;
387             my $request = $args{request} or die 'request is required';
388             my $content_type = $args{content_type} || 'application/x-www-form-urlencoded';
389             my $content = $args{content};
390              
391             return unless defined($content) && length($content);
392              
393             $request->content_type($content_type);
394             if ($content_type eq 'application/x-www-form-urlencoded') {
395             # We use a temporary URI object to format
396             # the application/x-www-form-urlencoded content.
397             my $url = URI->new('http:');
398             if (ref $content eq 'HASH') {
399             $url->query_form(%$content);
400             } elsif (ref $content eq 'ARRAY') {
401             $url->query_form(@$content);
402             } else {
403             $url->query($content);
404             }
405             $content = $url->query;
406              
407             # HTML/4.01 says that line breaks are represented as "CR LF" pairs (i.e., `%0D%0A')
408             $content =~ s/(?
409             $request->content($content);
410             } elsif ($content_type eq 'application/json') {
411             my $json = ref($content) ? JSON::XS::encode_json($content) : $content;
412             $request->content($json);
413             } elsif ($content_type eq 'multipart/form-data') {
414             $request->content($content);
415             } else {
416             # if content type is something else, just include the raw data here
417             # modify this code if we need to process other content types differently
418             $request->content($content);
419             }
420             }
421              
422             # send a request to the given path with the given method, params, and content body
423             # and get back a response object
424             #
425             # path: the location of the resource on the given hostname. e.g. '/path/to/resource'
426             # content: optional content body to send in a post. e.g. a json document
427             # params: an arrayref or hashref of key/value pairs to include in the request
428             # headers: a list of key value pairs to add to the header
429             # method: get,post,delete,put,head
430             #
431             # depending on the value of $self->retry this function will retry a request if it receives an error.
432             # In the future we may want to consider managing this based on the specific error code received.
433             sub get_response {
434             my $self = shift;
435             my %args = @_;
436             my $path = $args{path} or die 'path is a required argument. e.g. "/" ';
437             my $content = $args{content} || $args{body};
438             my $headers = $args{headers};
439             my $params = $args{params};
440             my $method = $args{method} or die 'method is a required argument';
441             my $content_type = $args{content_type};
442             my $retry_count = defined $args{retry} ? $args{retry} : $self->{retry};
443             my $process_response = $args{process_response} || 0;
444            
445             my $req = $self->get_http_request(
446             path => $path,
447             content => $content,
448             headers => $headers,
449             params => $params,
450             method => $method,
451             content_type => $content_type,
452             );
453              
454             my ($result, $flow_control);
455             # run the request
456             my $try = 0;
457             while ($try <= $retry_count) {
458             $try++;
459             my $response = $self->get_response_for_request(http_request => $req);
460              
461             # Okay, now do processing like retries, handlers, and whatnot.
462             my $dispatch = REST::Consumer::Dispatch->new(
463             handlers => $args{handlers},
464             default_is_raw => ($process_response ? 0 : 1),
465             debugger => $self->debugger,
466             );
467              
468             ($flow_control, $result) = $dispatch->handle_response(
469             request => $req,
470             response => $response,
471             attempt => $try,
472             );
473            
474             last unless $flow_control eq 'retry';
475             if ($self->retry_delay) {
476             $self->debug(sprintf ("Sleeping %d ms before retrying...", $self->retry_delay));
477             usleep (1000 * $self->retry_delay);
478             }
479             }
480              
481             if ($flow_control eq 'succeed') {
482             # $result is an arrayref of the value(s) returned
483             # by a handler (possibly the default handler).
484             if (scalar @$result == 1) {
485             return $result->[0];
486             } else {
487             return @$result;
488             }
489             } else {
490             # $flow_control could be 'succeed' or 'retry'
491             # (but we ran out of retries)
492             # $result = a failure object
493             if ($self->throw_exceptions) {
494             $result->throw;
495             } else {
496             return $result;
497             }
498             }
499             }
500              
501             # do everything that get_response does, but return the deserialized content in the response instead of the response object
502             sub get_processed_response {
503             my $self = shift;
504             my $response = $self->get_response(
505             process_response => 1,
506             @_,
507             );
508             return $response;
509             }
510              
511              
512             #
513             # http_request => an HTTP Request object
514             # _retries => how many times we've already tried to get a valid response for this request
515             sub get_response_for_request {
516             my ($self, %args) = @_;
517             my $http_request = $args{http_request};
518             $self->{_last_request} = $http_request;
519             $self->{_last_response} = undef;
520              
521             my $user_agent = $self->user_agent;
522             my $response = $user_agent->request($http_request);
523              
524             $self->{_last_response} = $response;
525             $self->debug( sprintf('Got response: %s', $response->code()));
526              
527             return $response;
528             }
529              
530             sub head {
531             my $self = shift;
532             return $self->get_response(@_, method => 'HEAD');
533             }
534              
535             sub get {
536             my $self = shift;
537             return $self->get_processed_response(@_, method => 'GET');
538             }
539              
540             sub post {
541             my $self = shift;
542             return $self->get_processed_response(@_, method => 'POST');
543             }
544              
545             sub delete {
546             my $self = shift;
547             return $self->get_processed_response(@_, method => 'DELETE');
548             }
549              
550             sub put {
551             my $self = shift;
552             return $self->get_processed_response(@_, method => 'PUT');
553             }
554              
555              
556             sub debugger {
557             my $self = shift;
558             return sub {} unless $self->{verbose};
559             return sub {
560             local $\ = "\n";
561             print STDERR @_;
562             }
563             }
564              
565             # print status messages to stderr if running in verbose mode
566             sub debug {
567             shift->debugger->(@_);
568             }
569              
570              
571             1;
572             __END__