File Coverage

blib/lib/Net/Amazon.pm
Criterion Covered Total %
statement 19 21 90.4
branch n/a
condition n/a
subroutine 7 7 100.0
pod n/a
total 26 28 92.8


line stmt bran cond sub pod time code
1             ###################################################################
2             package Net::Amazon;
3             ######################################################################
4             # Mike Schilli , 2003
5             ######################################################################
6              
7 26     26   1039718 use 5.006;
  26         102  
  26         1142  
8 26     26   164 use strict;
  26         48  
  26         1125  
9 26     26   142 use warnings;
  26         112  
  26         9646  
10              
11             our $VERSION = '0.62';
12             our $WSDL_DATE = '2011-08-01';
13             our $Locale = 'us';
14             our @CANNED_RESPONSES = ();
15             our $IS_CANNED = 0;
16              
17 26     26   75788 use LWP::UserAgent;
  26         2211330  
  26         1052  
18 26     26   310 use HTTP::Message;
  26         52  
  26         612  
19 26     26   47030 use HTTP::Request::Common;
  26         100367  
  26         2391  
20 26     26   27816 use XML::Simple;
  0            
  0            
21             use Data::Dumper;
22             use URI;
23             use Log::Log4perl qw(:easy get_logger);
24             use Time::HiRes qw(usleep gettimeofday tv_interval);
25             use Digest::SHA qw(hmac_sha256_base64);
26             use URI::Escape qw(uri_escape);
27              
28             # Each key represents a search() type, and each value indicates which
29             # Net::Amazon::Request:: class to use to handle it.
30             use constant SEARCH_TYPE_CLASS_MAP => {
31             actor => 'Actor',
32             artist => 'Artist',
33             all => 'All',
34             author => 'Author',
35             asin => 'ASIN',
36             blended => 'Blended',
37             browsenode => 'BrowseNode',
38             director => 'Director',
39             ean => 'EAN',
40             exchange => 'Exchange',
41             isbn => 'ISBN',
42             keyword => 'Keyword',
43             manufacturer => 'Manufacturer',
44             musiclabel => 'MusicLabel',
45             power => 'Power',
46             publisher => 'Publisher',
47             seller => 'Seller',
48             similar => 'Similar',
49             textstream => 'TextStream',
50             title => 'Title',
51             upc => 'UPC',
52             };
53              
54             ##################################################
55             sub new {
56             ##################################################
57             my($class, %options) = @_;
58              
59             if(! exists $options{token}) {
60             die "Mandatory paramter 'token' not defined";
61             }
62              
63             if(! exists $options{secret_key}) {
64             die "Mandatory paramter 'secret_key' not defined";
65             }
66              
67             if(! exists $options{associate_tag}) {
68             die "Mandatory paramter 'associate_tag' not defined";
69             }
70              
71             my $self = {
72             strict => 1,
73             response_dump => 0,
74             rate_limit => 1.0, # 1 req/sec
75             max_pages => 5,
76             ua => LWP::UserAgent->new(),
77             compress => 1,
78             %options,
79             };
80              
81             # XXX: this has to be set as soon as possible to ensure
82             # the validators pick up the correct locale. I don't
83             # like the way this works, and need to think of a better
84             # solution.
85             if (exists $self->{locale}) {
86             $Net::Amazon::Locale = $self->{locale};
87             }
88              
89             help_xml_simple_choose_a_parser();
90              
91             bless $self, $class;
92             }
93              
94             ##################################################
95             sub search {
96             ##################################################
97             my($self, %params) = @_;
98              
99             foreach my $key ( keys %params ) {
100             next unless ( my $class = SEARCH_TYPE_CLASS_MAP->{$key} );
101            
102             return $self->_make_request($class, \%params);
103             }
104              
105             # FIX?
106             # This seems like it really should be a die() instead...this is
107             # indicative of a programming problem. Generally speaking, it's
108             # best to issue warnings from a module--you can't be sure that the
109             # client has a stderr to begin with, or that he wants errors
110             # spewed to it.
111             warn "No Net::Amazon::Request type could be determined";
112              
113             return undef;
114             }
115              
116             ##################################################
117             sub intl_url {
118             ##################################################
119             my($self, $url) = @_;
120              
121             if(! exists $self->{locale}) {
122             return $url;
123             }
124              
125             if (0) {
126             } elsif ($self->{locale} eq "ca") {
127             $url =~ s/\.com/.ca/;
128             } elsif ($self->{locale} eq "de") {
129             $url =~ s/\.com/.de/;
130             } elsif ($self->{locale} eq "es") {
131             $url =~ s/\.com/.es/;
132             } elsif ($self->{locale} eq "fr") {
133             $url =~ s/\.com/.fr/;
134             } elsif ($self->{locale} eq "jp") {
135             $url =~ s/\.com/.co.jp/;
136             } elsif ($self->{locale} eq "it") {
137             $url =~ s/\.com/.it/;
138             } elsif ($self->{locale} eq "uk") {
139             $url =~ s/\.com/.co.uk/;
140             }
141              
142             return $url;
143             }
144              
145             ##################################################
146             sub request {
147             ##################################################
148             my($self, $request) = @_;
149              
150             my $resp_class = $request->response_class();
151              
152             eval "require $resp_class;" or
153             die "Cannot find '$resp_class'";
154              
155             my $res = $resp_class->new();
156              
157             my $url = URI->new($self->intl_url($request->amzn_xml_url()));
158             my $page = (defined $request->page()) ?
159             ($request->page() - 1) * $self->{max_pages} + 1 :
160             0;
161             my $ref;
162             my $max_pages_in_this_search = $self->{max_pages} + $page - 1;
163              
164             REQUEST: {
165             my %params = $request->params(page => $page);
166             $params{locale} = $self->{locale} if exists $self->{locale};
167              
168             $url->query_form(
169             'Service' => 'AWSECommerceService',
170             'AWSAccessKeyId' => $self->{token},
171             'Version' => $WSDL_DATE,
172             'AssociateTag' => $self->{associate_tag},
173             map { $_, $params{$_} } sort keys %params,
174             );
175            
176             # Signed requests will have different URLs, which breaks caching.
177             # Get a cachable URL before signing the request.
178             my $url_cachablestr = $url->as_string;
179              
180             # New signature for >=2009-03-31. Do not alter URL after this!
181             $url = $self->_sign_request($url) if exists $self->{secret_key};
182              
183             DEBUG(sub { "request: params = " . Dumper(\%params) . "\n"});
184              
185             my $urlstr = $url->as_string;
186              
187             DEBUG(sub { "urlstr=" . $urlstr });
188              
189             my $xml = fetch_url($self, $urlstr, $url_cachablestr, $res);
190              
191             if(!defined $xml) {
192             return $res;
193             }
194              
195             DEBUG(sub { "Received [ " . $xml . "]" });
196              
197             # Let the response class parse the XML
198             $ref = $res->xml_parse($xml);
199              
200             # DEBUG(sub { Data::Dumper::Dumper($ref) });
201              
202             if(! defined $ref) {
203             ERROR("Invalid XML");
204             $res->messages( [ "Invalid XML" ]);
205             $res->status("");
206             return $res;
207             }
208              
209             $res->current_page($ref, $page);
210             $res->set_total_results($ref);
211            
212             my $rc = $res->is_page_error($ref);
213             if ($rc == 0) {
214             return $res;
215             } elsif ($rc == -1) {
216             last;
217             }
218              
219             my $new_items = $res->xmlref_add($ref);
220              
221             DEBUG("Received valid XML ($new_items items)");
222              
223             # Stop if we've fetched max_pages already
224             if(defined $page && $max_pages_in_this_search <= $page) {
225             DEBUG("Fetched max_pages ($max_pages_in_this_search) -- stopping");
226             last;
227             }
228              
229             if($res->is_page_available($ref, $new_items, $page)) {
230             $page++;
231             redo REQUEST;
232             }
233              
234             # We're gonna fall out of this loop here.
235             }
236              
237             $res->status(1);
238             # We have a valid response, so if TotalResults isn't set,
239             # we most likely have a single response
240             $res->total_results(1) unless defined $res->total_results();
241             return $res;
242             }
243              
244             ##################################################
245             sub fetch_url {
246             ##################################################
247             my($self, $url, $url_cachablestr, $res) = @_;
248              
249             my $max_retries = 2;
250              
251             INFO("Fetching $url");
252              
253             if(@CANNED_RESPONSES) {
254             $IS_CANNED = 1;
255             INFO("Serving canned response (testing)");
256             return shift @CANNED_RESPONSES;
257             }
258              
259             if(exists $self->{cache}) {
260             my $resp = $self->{cache}->get($url_cachablestr);
261             if(defined $resp) {
262             INFO("Serving from cache");
263             return $resp;
264             }
265              
266             INFO("Cache miss");
267             }
268              
269             my $ua = $self->{ua};
270             $ua->env_proxy();
271              
272             my $resp;
273              
274             {
275             # wait up to a second before the next request so
276             # as to not violate Amazon's 1 query per second
277             # rule (or the configured rate_limit).
278             $self->pause() if $self->{strict};
279              
280             {
281             my $req = GET $url;
282              
283             $req->header("Accept-Encoding" => [ HTTP::Message::decodable() ])
284             if $self->{compress};
285              
286             $resp = $ua->request($req);
287             }
288              
289             $self->reset_timer() if $self->{strict};
290              
291             if($resp->is_error) {
292             # retry on 503 Service Unavailable errors
293             if ($resp->code == 503) {
294             if ($max_retries-- >= 0) {
295             INFO("Temporary Amazon error 503, retrying");
296             redo;
297             } else {
298             INFO("Out of retries, giving up");
299             $res->status("");
300             $res->messages( [ "Too many temporary Amazon errors" ] );
301             return undef;
302             }
303             } else {
304             $res->status("");
305             $res->messages( [ $resp->message ] );
306             return undef;
307             }
308             }
309              
310             if($self->{response_dump}) {
311             my $dumpfile = "response-$self->{response_dump}.txt";
312             open FILE, ">$dumpfile" or die "Cannot open $dumpfile";
313             print FILE $resp->decoded_content();
314             close FILE;
315             $self->{response_dump}++;
316             }
317              
318             if($resp->decoded_content =~ // &&
319             # Is this the same value of AWS4?
320             $resp->decoded_content =~ /Please retry/i) {
321             if($max_retries-- >= 0) {
322             INFO("Temporary Amazon error, retrying");
323             redo;
324             } else {
325             INFO("Out of retries, giving up");
326             $res->status("");
327             $res->messages( [ "Too many temporary Amazon errors" ] );
328             return undef;
329             }
330             }
331             }
332              
333             if(exists $self->{cache}) {
334             $self->{cache}->set($url_cachablestr, $resp->decoded_content());
335             }
336              
337             return $resp->decoded_content();
338             }
339              
340             ##################################################
341             # Poor man's Class::Struct
342             ##################################################
343             sub make_accessor {
344             ##################################################
345             my($package, $name) = @_;
346              
347             no strict qw(refs);
348              
349             my $code = <
350             *{"$package\\::$name"} = sub {
351             my(\$self, \$value) = \@_;
352              
353             if(defined \$value) {
354             \$self->{$name} = \$value;
355             }
356             if(exists \$self->{$name}) {
357             return (\$self->{$name});
358             } else {
359             return "";
360             }
361             }
362             EOT
363             if(! defined *{"$package\::$name"}) {
364             eval $code or die "$@";
365             }
366             }
367              
368             # An accessor for backward compatability with AWS3.
369             ##################################################
370             sub make_compatible_accessor{
371             ##################################################
372             my($package, $old_name, $new_name) = @_;
373              
374             no strict qw(refs);
375              
376             my $code = <
377             *{"$package\\::$old_name"} = sub {
378             my(\$self, \$value) = \@_;
379              
380             if(defined \$value) {
381             \$self->{$new_name} = \$value;
382             }
383             if(exists \$self->{$new_name}) {
384             return (\$self->{$new_name});
385             } else {
386             return "";
387             }
388             }
389             EOT
390             if(! defined *{"$package\::$old_name"}) {
391             eval $code or die "$@";
392             }
393             }
394              
395             ##################################################
396             # Make accessors for arrays
397             ##################################################
398             sub make_array_accessor {
399             ##################################################
400             my($package, $name) = @_;
401              
402             no strict qw(refs);
403              
404             my $code = <
405             *{"$package\\::$name"} = sub {
406             my(\$self, \$nameref) = \@_;
407             if(defined \$nameref) {
408             if(ref \$nameref eq "ARRAY") {
409             \$self->{$name} = \$nameref;
410             } else {
411             \$self->{$name} = [\$nameref];
412             }
413             }
414             # Return a list
415             if(exists \$self->{$name} and
416             ref \$self->{$name} eq "ARRAY") {
417             return \@{\$self->{$name}};
418             }
419              
420             return undef;
421             }
422             EOT
423              
424             if(! defined *{"$package\::$name"}) {
425             eval $code or die "$@";
426             }
427             }
428              
429             ##################################################
430             sub walk_hash_ref {
431             ##################################################
432             my ($package, $href, $aref) = @_;
433              
434             return $href if scalar(@$aref) == 0;
435              
436             my @a;
437             push @a, $_ for @$aref;
438              
439             my $tail = pop @a;
440             my $ref = $href;
441              
442             for my $part (@a) {
443             $ref = $ref->{$part};
444             }
445            
446             return $ref->{$tail};
447             }
448              
449              
450             ##################################################
451             sub artist {
452             ##################################################
453             my($self, $nameref) = @_;
454              
455             # Only return the first artist
456             return ($self->artists($nameref))[0];
457             }
458              
459             ##################################################
460             sub version {
461             ##################################################
462             my($self) = @_;
463             return $self->{Version};
464             }
465              
466             ##################################################
467             sub current_page {
468             ##################################################
469             my($self, $ref, $page) = @_;
470             if(exists $ref->{Items}->{TotalPages}) {
471             INFO("Page $page/$ref->{Items}->{TotalPages}");
472             }
473             }
474              
475             ##################################################
476             sub set_total_results {
477             ##################################################
478             my($self, $ref) = @_;
479             if(exists $ref->{Items}->{TotalResults}) {
480             $self->total_results( $ref->{Items}->{TotalResults} );
481             }
482             }
483              
484             ##################################################
485             sub is_page_error {
486             ##################################################
487             my($self, $ref) = @_;
488              
489             if(exists $ref->{Items}->{Request}->{Errors}) {
490             my $errref = $ref->{Items}->{Request}->{Errors};
491              
492             if (ref($errref->{Error}) eq "ARRAY") {
493             my @errors;
494             for my $e (@{$errref->{Error}}) {
495             push @errors, $e->{Message};
496             }
497             # multiple errors, set arrary ref
498             $self->messages( \@errors );
499             } else {
500             # single error, create array
501             $self->messages( [ $errref->{Error}->{Message} ] );
502             }
503              
504             ERROR("Fetch Error: " . $self->message );
505             $self->status("");
506             return 0;
507             }
508             return 1;
509             }
510              
511             ##################################################
512             sub is_page_available {
513             ##################################################
514             my($self, $ref, $new_items, $page) = @_;
515             if(exists $ref->{Items}->{TotalPages} and
516             $ref->{Items}->{TotalPages} > $page and
517             $IS_CANNED ne 1) {
518             DEBUG("Page $page of $ref->{Items}->{TotalPages} fetched - continuing");
519             return 1;
520             }
521             return 0;
522             }
523              
524             ##################################################
525             sub xmlref_add {
526             ##################################################
527             my($self, $xmlref) = @_;
528              
529             my $nof_items_added = 0;
530             return $nof_items_added unless defined $xmlref;
531              
532             # Push a nested hash structure, retrieved via XMLSimple, onto the
533             # object's internal 'xmlref' entry, which holds a ref to an array,
534             # whichs elements are refs to hashes holding an item's attributes
535             # (like OurPrice etc.)
536              
537             #DEBUG("xmlref_add ", Data::Dumper::Dumper($xmlref));
538              
539             unless(ref($self->{xmlref}) eq "HASH" &&
540             ref($self->{xmlref}->{Items}) eq "ARRAY") {
541             $self->{xmlref}->{Items} = [];
542             }
543              
544             if(ref($xmlref->{Items}->{Item}) eq "ARRAY") {
545             push @{$self->{xmlref}->{Items}}, @{$xmlref->{Items}->{Item}};
546             $nof_items_added = scalar @{$xmlref->{Items}->{Item}};
547             } else {
548             if (exists $xmlref->{Items}->{Item}->{ItemAttributes}) {
549             push @{$self->{xmlref}->{Items}}, $xmlref->{Items}->{Item};
550             $nof_items_added = 1;
551             }
552             }
553              
554             DEBUG("xmlref_add (after):", Data::Dumper::Dumper($self));
555             return $nof_items_added;
556             }
557              
558             ##################################################
559             sub help_xml_simple_choose_a_parser {
560             ##################################################
561            
562             eval "require XML::Parser";
563             unless($@) {
564             $XML::Simple::PREFERRED_PARSER = "XML::Parser";
565             return;
566             }
567              
568             eval "require XML::SAX::PurePerl";
569             unless($@) {
570             $XML::Simple::PREFERRED_PARSER = "XML::SAX::PurePerl";
571             return;
572             }
573             }
574              
575             ##################################################
576             # This timer makes sure we don't query Amazon more
577             # than once a second.
578             ##################################################
579             sub reset_timer {
580             ##################################################
581              
582             my $self = shift;
583             $self->{t0} = [gettimeofday];
584             }
585              
586             ##################################################
587             # Pause for up to a second if necessary.
588             ##################################################
589             sub pause {
590             ##################################################
591              
592             my $self = shift;
593             return unless ($self->{t0});
594              
595             my $t1 = [gettimeofday];
596             my $dur = (1.0/$self->{rate_limit} -
597             tv_interval($self->{t0}, $t1)) * 1000000;
598             if($dur > 0) {
599             # Use a pseudo subclass for the logger, since the app
600             # might not want to log that as 'ERROR'. Log4perl's
601             # inheritance mechanism makes sure it does the right
602             # thing for the current class.
603             my $logger = get_logger(__PACKAGE__ . "::RateLimit");
604             $logger->error("Ratelimiting: Sleeping $dur microseconds");
605             usleep($dur);
606             }
607             }
608              
609             ##
610             ## 'PRIVATE' METHODS
611             ##
612              
613             # $self->_make_request( TYPE, PARAMS )
614             #
615             # Takes a TYPE that corresponds to a Net::Amazon::Request
616             # class, require()s that class, instantiates it, and returns
617             # the result of that instance's request() method.
618             #
619             sub _make_request {
620             my ($self, $type, $params) = @_;
621              
622             my $class = "Net::Amazon::Request::$type";
623              
624             # XXX: change me back, this makes debugging a little difficult.
625             eval "require $class";
626              
627             my $req = $class->new(%{$params});
628            
629             return $self->request($req);
630             }
631              
632             # $self->_sign_request( URI )
633             #
634             # Takes a URI object that corresponds to a Net::Amazon::Request
635             # adds the required Timestamp and Signature parameters, and returns it
636             # See http://docs.amazonwebservices.com/AWSECommerceService/2009-03-31/DG/Query_QueryAuth.html
637             sub _sign_request {
638             my ($self,$uri) = @_;
639             return $uri unless exists $self->{secret_key};
640             # This assumes no duplicated keys. Safe assumption?
641             my %query = $uri->query_form;
642             my @now = gmtime;
643             $query{Timestamp} ||= sprintf('%04d-%02d-%02dT%02d:%02d:%02dZ',$now[5]+1900,$now[4]+1,@now[3,2,1,0]);
644             my $qstring = join '&', map {"$_=". uri_escape($query{$_},"^A-Za-z0-9\-_.~")} sort keys %query;
645             # Use chr(10), not "\n" which varies by platform
646             my $signme = join chr(10),"GET",$uri->host,$uri->path,$qstring;
647             my $sig = hmac_sha256_base64($signme, $self->{secret_key});
648             # Digest does not properly pad b64 strings
649             $sig .= '=' while length($sig) % 4;
650             $sig = uri_escape($sig,"^A-Za-z0-9\-_.~");
651             $qstring .= "&Signature=$sig";
652             $uri->query( $qstring );
653             return $uri;
654             }
655              
656             1;
657              
658             __END__