amazon-associates 0.6.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. data/.gitignore +3 -0
  2. data/CHANGELOG +29 -0
  3. data/LICENSE +21 -0
  4. data/README.rdoc +84 -0
  5. data/Rakefile +62 -0
  6. data/VERSION.yml +5 -0
  7. data/amazon-associates.gemspec +118 -0
  8. data/lib/amazon-associates.rb +90 -0
  9. data/lib/amazon-associates/caching/filesystem_cache.rb +121 -0
  10. data/lib/amazon-associates/errors.rb +23 -0
  11. data/lib/amazon-associates/extensions/core.rb +31 -0
  12. data/lib/amazon-associates/extensions/hpricot.rb +115 -0
  13. data/lib/amazon-associates/request.rb +143 -0
  14. data/lib/amazon-associates/requests/browse_node.rb +10 -0
  15. data/lib/amazon-associates/requests/cart.rb +81 -0
  16. data/lib/amazon-associates/requests/item.rb +13 -0
  17. data/lib/amazon-associates/responses/browse_node_lookup_response.rb +10 -0
  18. data/lib/amazon-associates/responses/cart_responses.rb +26 -0
  19. data/lib/amazon-associates/responses/item_lookup_response.rb +16 -0
  20. data/lib/amazon-associates/responses/item_search_response.rb +20 -0
  21. data/lib/amazon-associates/responses/response.rb +27 -0
  22. data/lib/amazon-associates/responses/similarity_lookup_response.rb +9 -0
  23. data/lib/amazon-associates/types/api_result.rb +8 -0
  24. data/lib/amazon-associates/types/browse_node.rb +48 -0
  25. data/lib/amazon-associates/types/cart.rb +87 -0
  26. data/lib/amazon-associates/types/customer_review.rb +15 -0
  27. data/lib/amazon-associates/types/editorial_review.rb +8 -0
  28. data/lib/amazon-associates/types/error.rb +8 -0
  29. data/lib/amazon-associates/types/image.rb +37 -0
  30. data/lib/amazon-associates/types/image_set.rb +11 -0
  31. data/lib/amazon-associates/types/item.rb +156 -0
  32. data/lib/amazon-associates/types/listmania_list.rb +9 -0
  33. data/lib/amazon-associates/types/measurement.rb +47 -0
  34. data/lib/amazon-associates/types/offer.rb +10 -0
  35. data/lib/amazon-associates/types/ordinal.rb +24 -0
  36. data/lib/amazon-associates/types/price.rb +29 -0
  37. data/lib/amazon-associates/types/requests.rb +50 -0
  38. data/spec/requests/browse_node_lookup_spec.rb +41 -0
  39. data/spec/requests/item_search_spec.rb +27 -0
  40. data/spec/spec_helper.rb +8 -0
  41. data/spec/types/cart_spec.rb +294 -0
  42. data/spec/types/item_spec.rb +55 -0
  43. data/spec/types/measurement_spec.rb +43 -0
  44. data/test/amazon/browse_node_test.rb +34 -0
  45. data/test/amazon/cache_test.rb +33 -0
  46. data/test/amazon/caching/filesystem_cache_test.rb +198 -0
  47. data/test/amazon/item_test.rb +397 -0
  48. data/test/test_helper.rb +9 -0
  49. data/test/utilities/filesystem_test_helper.rb +35 -0
  50. metadata +216 -0
@@ -0,0 +1,23 @@
1
+ module Amazon
2
+ module Associates
3
+ class RequestError < StandardError; end
4
+ class InvalidParameterValue < ArgumentError; end
5
+ class ParameterOutOfRange < InvalidParameterValue; end
6
+ class RequiredParameterMissing < ArgumentError; end
7
+ class ItemNotFound < StandardError; end
8
+
9
+ # Map AWS error types to ruby exceptions
10
+ ERROR = {
11
+ 'AWS.InvalidParameterValue' => InvalidParameterValue,
12
+ 'AWS.MissingParameters' => RequiredParameterMissing,
13
+ 'AWS.MinimumParameterRequirement' => RequiredParameterMissing,
14
+ 'AWS.ECommerceService.NoExactMatches' => ItemNotFound,
15
+ 'AWS.ParameterOutOfRange' => ParameterOutOfRange,
16
+ 'AWS.InvalidOperationParameter'=> InvalidParameterValue,
17
+ 'AWS.InvalidResponseGroup' => InvalidParameterValue,
18
+ 'AWS.RestrictedParameterValueCombination' => InvalidParameterValue
19
+ }
20
+
21
+ IGNORE_ERRORS = ['AWS.ECommerceService.NoSimilarities']
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ require 'rubygems'
2
+ require 'active_support'
3
+
4
+ class Object # http://whytheluckystiff.net/articles/seeingMetaclassesClearly.html
5
+ def meta_def name, &blk
6
+ (class << self; self; end).instance_eval { define_method name, &blk }
7
+ end
8
+ end
9
+
10
+ class OpenHash < Hash
11
+ def method_missing_with_attributes_query(meth, *args)
12
+ fetch(meth) do
13
+ method_missing_without_attributes_query(meth)
14
+ end
15
+ end
16
+ alias_method_chain :method_missing, :attributes_query
17
+ end
18
+
19
+ class Float
20
+ def whole?
21
+ (self % 1) < 0.0001
22
+ end
23
+ end
24
+
25
+ class Hash
26
+ def rekey!(keys)
27
+ keys.each_pair do |old, new|
28
+ store(new, delete(old)) if has_key?(old)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,115 @@
1
+ require 'rubygems'
2
+ require 'active_support'
3
+ require 'roxml'
4
+
5
+ # TODO: Untange this inter-dependency...
6
+ %w{ browse_node measurement image ordinal price }.each do |type|
7
+ require File.join(File.dirname(__FILE__), '..', 'types', type)
8
+ end
9
+
10
+ module Hpricot
11
+ class Element < OpenHash
12
+ def initialize(value, attributes = {})
13
+ merge! :value => value,
14
+ :attributes => attributes
15
+ end
16
+ end
17
+
18
+ # Extend with some convenience methods
19
+ module Traverse
20
+ def self.induce(type, &block)
21
+ raise ArgumentError, "block missing" unless block_given?
22
+
23
+ type_at, to_type, types_at = "#{type}_at", "to_#{type}", "#{type.to_s.pluralize}_at"
24
+ if [type_at, to_type, types_at].any? {|m| method_defined?(m) }
25
+ raise ArgumentError, "some methods already defined"
26
+ end
27
+
28
+ define_method type_at do |path|
29
+ result = at(path) and yield result
30
+ end
31
+ define_method to_type do
32
+ method(type_at).call('')
33
+ end
34
+ define_method types_at do |path|
35
+ results = search(path) and results.collect {|r| yield r }
36
+ end
37
+ end
38
+
39
+ # Get the text value of the given path, leave empty to retrieve current element value.
40
+ induce :text do |result|
41
+ CGI::unescapeHTML(result.inner_html)
42
+ end
43
+
44
+ induce :int do |result|
45
+ Integer(result.inner_html)
46
+ end
47
+
48
+ induce :bool do |result|
49
+ case result.inner_html
50
+ when '0': false
51
+ when 'False': false
52
+ when '1': true
53
+ when 'True': true
54
+ else
55
+ raise TypeError, "String #{result.inspect} is not convertible to bool"
56
+ end
57
+ end
58
+
59
+ induce :element do |result|
60
+ # TODO: Use to_h here?
61
+ attrs = result.attributes.inject({}) do |hash, attr|
62
+ hash[attr[0].to_sym] = attr[1].to_s; hash
63
+ end
64
+
65
+ children = result.children
66
+ if children.size == 1 and children.first.is_a? Text
67
+ value = children.first.to_s
68
+ else
69
+ result = children.inject({}) do |hash, item|
70
+ name = item.name.to_sym
71
+ hash[name] ||= []
72
+ hash[name] << item.to_hash
73
+ hash
74
+ end
75
+
76
+ value = result.each_pair {|key, value| result[key] = value[0] if value.size == 1 }
77
+ end
78
+
79
+ (attrs.empty?) ? value : Element.new(value, attrs)
80
+ end
81
+
82
+ # TODO: This probably doesn't belong here... References to Amazon:: types indicate as much anyway
83
+ # Get the children element text values in hash format with the element names as the hash keys.
84
+ induce :hash do |result|
85
+ # TODO: date?, image? &c
86
+ # TODO: This is super-ugly... is there a better way to map?
87
+ if ['width', 'height', 'length', 'weight'].include? result.name
88
+ Amazon::Measurement.from_xml(result.to_s)
89
+ elsif ['batteriesincluded', 'iseligibleforsupersavershipping', 'isautographed', 'ismemorabilia', 'isvalid'].include? result.name
90
+ result.to_bool
91
+ elsif result.name == 'browsenode'
92
+ Amazon::BrowseNode.from_xml(result.to_s)
93
+ elsif result.name == 'edition'
94
+ begin
95
+ Amazon::Ordinal.from_xml(result.to_s)
96
+ rescue TypeError
97
+ # a few edition types aren't ordinals, but strings (e.g., "First American Edition")
98
+ result.to_text
99
+ end
100
+ elsif result.name.starts_with? 'total' or result.name.starts_with? 'number' or ['quantity'].include? result.name
101
+ result.to_int
102
+ elsif result.name.ends_with? 'price' or result.name.ends_with? 'total'
103
+ Amazon::Price.from_xml(result.to_s)
104
+ elsif result.name.ends_with? 'image'
105
+ Amazon::Image.from_xml(result.to_s)
106
+ else
107
+ if (result.children.size > 1 or !result.children.first.is_a? Text) and names = result.children.collect {|c| c.name }.uniq and names.size == 1 and names[0].pluralize == result.name
108
+ result.children.map(&:to_hash)
109
+ else
110
+ result.to_element
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,143 @@
1
+ %w{errors extensions/core types/api_result
2
+ types/error types/customer_review types/editorial_review types/ordinal types/listmania_list types/browse_node types/measurement types/image types/image_set types/price types/offer types/item types/requests types/cart
3
+ responses/response responses/item_search_response responses/item_lookup_response responses/similarity_lookup_response responses/browse_node_lookup_response responses/cart_responses }.each do |file|
4
+ require File.join(File.dirname(__FILE__), file)
5
+ end
6
+
7
+ require 'net/http'
8
+ require 'cgi'
9
+ require 'hmac'
10
+ require 'hmac-sha2'
11
+ require 'base64'
12
+
13
+ module Amazon
14
+ module Associates
15
+ def self.request(actions, &block)
16
+ actions.each_pair do |action, main_arg|
17
+ meta_def(action) do |*args|
18
+ opts = args.extract_options!
19
+ opts[main_arg] = args.first unless args.empty?
20
+ opts[:operation] = action.to_s.camelize
21
+
22
+ opts = yield opts if block_given?
23
+ send_request(opts)
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+ # Generic send request to ECS REST service. You have to specify the :operation parameter.
30
+ def self.send_request(opts)
31
+ opts.to_options!
32
+ opts.reverse_merge! options.except(:caching_options, :caching_strategy)
33
+ if opts[:aWS_access_key_id].blank?
34
+ raise ArgumentError, "amazon-associates requires the :aws_access_key_id option"
35
+ end
36
+
37
+ request_url = prepare_url(opts)
38
+ response = nil
39
+
40
+ if cacheable?(opts['Operation'])
41
+ FilesystemCache.sweep
42
+
43
+ response = FilesystemCache.get(request_url)
44
+ end
45
+
46
+ if response.nil?
47
+ log "Request URL: #{request_url}"
48
+
49
+ response = Net::HTTP.get_response(URI::parse(request_url))
50
+ unless response.kind_of? Net::HTTPSuccess
51
+ raise RequestError, "HTTP Response: #{response.inspect}"
52
+ end
53
+ cache_response(request_url, response) if cacheable?(opts['Operation'])
54
+ end
55
+
56
+ doc = ROXML::XML::Parser.parse(response.body).root
57
+ eval(doc.name).from_xml(doc, request_url)
58
+ end
59
+
60
+ BASE_ARGS = [:aWS_access_key_id, :operation, :associate_tag, :response_group]
61
+ CART_ARGS = [:cart_id, :hMAC]
62
+ ITEM_ARGS = (0..99).inject([:items]) do |all, i|
63
+ all << :"Item.#{i}.ASIN"
64
+ all << :"Item.#{i}.OfferListingId"
65
+ all << :"Item.#{i}.CartItemId"
66
+ all << :"Item.#{i}.Quantity"
67
+ all
68
+ end
69
+ OTHER_ARGS = [
70
+ :item_page, :item_id, :country, :type, :item_type,
71
+ :browse_node_id, :actor, :artist, :audience_rating, :author,
72
+ :availability, :brand, :browse_node, :city, :composer,
73
+ :condition, :conductor, :director, :page, :keywords,
74
+ :manufacturer, :maximum_price, :merchant_id,
75
+ :minimum_price, :neighborhood, :orchestra,
76
+ :postal_code, :power, :publisher, :search_index, :sort,
77
+ :tag_page, :tags_per_page, :tag_sort, :text_stream,
78
+ :title, :variation_page
79
+ ]
80
+ VALID_ARGS = {
81
+ 'CartCreate' => ITEM_ARGS,
82
+ 'CartAdd' => ITEM_ARGS + CART_ARGS,
83
+ 'CartModify' => ITEM_ARGS + CART_ARGS,
84
+ 'CartGet' => CART_ARGS,
85
+ 'CartClear' => CART_ARGS
86
+ }
87
+
88
+ def self.valid_arguments(operation)
89
+ BASE_ARGS + VALID_ARGS.fetch(operation, OTHER_ARGS)
90
+ end
91
+
92
+ TLDS = HashWithIndifferentAccess.new(
93
+ 'us' => 'com',
94
+ 'uk' => 'co.uk',
95
+ 'ca' => 'ca',
96
+ 'de' => 'de',
97
+ 'jp' => 'co.jp',
98
+ 'fr' => 'fr'
99
+ )
100
+ def self.tld(country)
101
+ TLDS.fetch(country || 'us') do
102
+ raise RequestError, "Invalid country '#{country}'"
103
+ end
104
+ end
105
+
106
+ def self.prepare_url(opts)
107
+ opts = opts.to_hash.to_options!
108
+ raise opts.inspect if opts.has_key?(:cart)
109
+ opts.assert_valid_keys(*valid_arguments(opts[:operation]))
110
+
111
+ params = opts.each_pair do |k, v|
112
+ opts.delete(k)
113
+ v *= ',' if v.is_a? Array
114
+ opts[k.to_s.camelize] = v.to_s
115
+ params
116
+ end
117
+
118
+ params.merge!(
119
+ 'Service' => 'AWSECommerceService',
120
+ 'Timestamp' => Time.now.gmtime.iso8601,
121
+ 'SignatureVersion' => '2',
122
+ 'SignatureMethod' => "HmacSHA256"
123
+ )
124
+
125
+ unsigned_uri = URI.parse("http://webservices.amazon.#{tld(opts.delete("Country"))}/onca/xml?#{params.sort { |a, b| a[0] <=> b[0] }.map { |key, val| "#{key}=#{CGI::escape(val).gsub('+', '%20')}" }.join("&")}")
126
+ hmac = HMAC::SHA256.new(ENV['AMAZON_SECRET_ACCESS_KEY'])
127
+ hmac.update("GET\n#{unsigned_uri.host}\n#{unsigned_uri.path}\n#{unsigned_uri.query}")
128
+ "#{unsigned_uri}&Signature=#{CGI::escape(Base64.encode64(hmac.digest).chomp)}"
129
+ end
130
+
131
+ def self.cacheable?(operation)
132
+ caching_enabled? && !operation.starts_with?('Cart')
133
+ end
134
+
135
+ def self.caching_enabled?
136
+ !options[:caching_strategy].blank?
137
+ end
138
+
139
+ def self.cache_response(request, response)
140
+ FilesystemCache.cache(request, response)
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,10 @@
1
+ require File.join(File.dirname(__FILE__), '../request')
2
+
3
+ module Amazon
4
+ module Associates
5
+ request :browse_node_lookup => :browse_node_id do |opts|
6
+ opts[:response_group] ||= 'TopSellers'
7
+ opts
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,81 @@
1
+ require File.join(File.dirname(__FILE__), '../request')
2
+
3
+ module Amazon
4
+ module Associates
5
+ private
6
+ def self.unpack_item(opts, index, item, count = 1)
7
+ case item
8
+ when CartItem
9
+ opts[:"Item.#{index}.CartItemId"] = item.cart_item_id
10
+ when String
11
+ opts["Item.#{index}.ASIN"] = item
12
+ when Item
13
+ opts["Item.#{index}.ASIN"] = item.asin
14
+ else
15
+ item = item.to_hash.dup
16
+ unless [:offer_listing_id, :asin, :list_item_id, :cart_item_id].any?{|id| item.has_key?(id)}
17
+ raise ArgumentError, "item needs an OfferListingId, ASIN, or ListItemId"
18
+ end
19
+
20
+ if id = item[:cart_item_id]
21
+ opts[:"Item.#{index}.CartItemId"] = id
22
+ elsif id = item[:offer_listing_id]
23
+ opts[:"Item.#{index}.OfferListingId"] = id
24
+ elsif id = item.delete(:asin)
25
+ opts["Item.#{index}.ASIN"] = id
26
+ elsif id = item.delete(:list_item_id)
27
+ opts["Item.#{index}.ListItemId"] = id
28
+ end
29
+ end
30
+ opts[:"Item.#{index}.Quantity"] = count
31
+ end
32
+
33
+ def self.unpack_items(opts)
34
+ raise ArgumentError, "items are required" if opts[:items].blank?
35
+
36
+ opts.delete(:items).each_with_index do |(item, count), index|
37
+ unpack_item(opts, index, item, count || item[:quantity] || 1)
38
+ end
39
+ opts
40
+ end
41
+
42
+ def self.unpack_cart(opts)
43
+ opts.merge!(opts.delete(:cart).to_hash) if opts[:cart]
44
+ opts[:cart_id] ||= opts.delete(:id)
45
+ opts[:hMAC] ||= opts.delete(:hmac)
46
+ opts
47
+ end
48
+
49
+ public
50
+ # Cart operations build the Item tags from the ASIN
51
+
52
+ # Creates remote shopping cart containing _asin_
53
+ request :cart_create => :items do |opts|
54
+ unpack_items(opts)
55
+ end
56
+
57
+ # Adds item to remote shopping cart
58
+ request :cart_add => :cart do |opts|
59
+ opts = unpack_items(opts)
60
+ unpack_cart(opts)
61
+ end
62
+
63
+ # Adds item to remote shopping cart
64
+ request :cart_get => :cart do |opts|
65
+ unpack_cart(opts)
66
+ end
67
+
68
+ # modifies _cart_item_id_ in remote shopping cart
69
+ # _quantity_ defaults to 0 to remove the given _cart_item_id_
70
+ # specify _quantity_ to update cart contents
71
+ request :cart_modify => :cart do |opts|
72
+ opts = unpack_items(opts)
73
+ unpack_cart(opts)
74
+ end
75
+
76
+ # clears contents of remote shopping cart
77
+ request :cart_clear => :cart do |opts|
78
+ unpack_cart(opts)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,13 @@
1
+ require File.join(File.dirname(__FILE__), '../request')
2
+
3
+ module Amazon
4
+ module Associates
5
+ request :item_search => :keywords do |opts|
6
+ # TODO: Default to blended? Don't show others except on refined search page?
7
+ opts[:search_index] ||= 'Books'
8
+ opts
9
+ end
10
+ request :similarity_lookup => :item_id,
11
+ :item_lookup => :item_id
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ module Amazon
2
+ module Associates
3
+ class BrowseNodeLookupResponse < Response
4
+ xml_name 'BrowseNodeLookupResponse'
5
+ xml_reader :browse_nodes, :as => [BrowseNode]
6
+ xml_reader :request, :as => Request, :in => 'xmlns:BrowseNodes'
7
+ xml_reader :request_query, :as => BrowseNodeLookupRequest, :in => 'xmlns:BrowseNodes/xmlns:Request'
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,26 @@
1
+ module Amazon
2
+ module Associates
3
+ class CartResponse < Response
4
+ xml_reader :cart, :as => Cart, :required => true
5
+ xml_reader :request, :as => Request, :in => 'xmlns:Cart'
6
+ xml_reader :query_request, :as => CartRequest, :in => 'xmlns:Cart/xmlns:Request'
7
+ end
8
+
9
+ class CartCreateResponse < CartResponse
10
+ xml_name 'CartCreateResponse'
11
+ end
12
+
13
+ class CartGetResponse < CartResponse
14
+ end
15
+
16
+ class CartAddResponse < CartResponse
17
+ end
18
+
19
+ class CartModifyResponse < CartResponse
20
+ end
21
+
22
+ class CartClearResponse < CartResponse
23
+ xml_name 'CartClearResponse'
24
+ end
25
+ end
26
+ end