amazon-associates 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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