Skip to content

Commit d50f959

Browse files
authored
Add Amazon Product API "ItemLookup" endpoint (#173)
* Refactor AmazonProductAPI In preparation for #62, this commit pulls the endpoint-specific information out of the HTTPClient class and into and endpoint-specific class. Now HTTPClient is responsible only for managing the environment information and directing the user to the relevant endpoint. Adding a new endpoint is as simple as adding a method (ex. `item_search`) and corresponding class (ex. `ItemSearchEndpoint). The interface is *not* in its final form yet. This is just a good breaking point for a commit. * Add ItemSearch endpoint Building on the last commit, this adds a new ItemLookup endpoint. Now we can pull details on an individual item based on the ASIN. The endpoints and responses include a lot of duplication. Some refactoring will probably be needed. * Extract spec for ItemSearchEndpoint When the item search endpoint was extracted, the specs weren't extracted with it; this means that the HTTPClientSpec was testing everything relating to the item search endpoint. This commit extracts all specs relating to the endpoint into a new file. This can now be refactored and copied over for the `ItemLookupEndpointSpec`. * Add spec for ItemLookupEndpoint * Fix CodeClimate issue This is a bit of a hack solution, but I don't feel comfortable doing any major abstraction here yet; I don't think we have enough information. Hopefully this'll clear up the CodeClimate complaints about duplicated code! * Fix easy PR-related rubocop issues Two big points: 1. This only fixes easy rubocop issues related to this PR. This doesn't touch any of the new capistrano code; I want the diff for this PR to be fairly contained. 2. This includes two rubocop config changes: i. Exclude the vendor directory from linting ii. Allow multiline braces in tests (for exception checks, let, etc.) There are a few more risky/controversial rubocop changes I omitted from this commit. Those are coming next. * Fix all Lint/UriEscapeUnescape violations Fixes #56 This commit changes all the `URI.escape` calls to `CGI.escape`. All tests pass and the app still works. As a bonus, this seems to resolve issue #56 too-apostrophes are now properly escaped. This resolves all rubocop issues relating to this PR. * Give better name to AWS test credentials in specs Following the review suggestions. This renames `env` to `aws_credentials`. The latter is a better name because it represents a credentials object (built from env vars), not the ENV object itself. Hopefully this will be clearer! * Fix item lookup hash When I fixed the code style for Code Climate in #173, I used the wrong hash leading to no update attributes being found. This fixes the bug, adds a test, and renames some of the variables.
1 parent fdd92e4 commit d50f959

14 files changed

Lines changed: 416 additions & 142 deletions

.rubocop.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ AllCops:
55
# directories or files.
66
Exclude:
77
- db/**/*
8+
- vendor/**/*
89
TargetRubyVersion: 2.4.1
910
# Cop names are not displayed in offense messages by default. Change behavior
1011
# by overriding DisplayCopNames, or by giving the `-D/--display-cop-names`
@@ -16,4 +17,8 @@ AllCops:
1617
DisplayStyleGuide: true
1718

1819
Style/NumericLiterals:
19-
Enabled: false
20+
Enabled: false
21+
22+
Style/BlockDelimiters:
23+
Exclude:
24+
- spec/**/*

app/controllers/amazon_search_controller.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class AmazonSearchController < ApplicationController
99

1010
def show
1111
authorize :amazon_search, :show?
12-
@response = amazon_client.search_response
12+
@response = amazon_search_response
1313
end
1414

1515
def new
@@ -18,9 +18,11 @@ def new
1818

1919
private
2020

21-
def amazon_client
22-
AmazonProductAPI::HTTPClient.new(query: params[:query],
23-
page_num: params[:page_num] || 1)
21+
def amazon_search_response
22+
client = AmazonProductAPI::HTTPClient.new
23+
query = client.item_search(query: params[:query],
24+
page: params[:page_num] || 1)
25+
query.response
2426
end
2527

2628
def set_wishlist

lib/amazon_product_api/http_client.rb

Lines changed: 11 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,31 @@
11
# frozen_string_literal: true
22

3-
require 'amazon_product_api/search_response'
3+
require 'amazon_product_api/item_search_endpoint'
4+
require 'amazon_product_api/item_lookup_endpoint'
45

56
module AmazonProductAPI
6-
# Responsible for building and executing the query to the Amazon Product API.
7+
# Responsible for managing all Amazon Product API queries.
78
#
8-
# Any logic relating to endpoints, building the query string, authentication
9-
# signatures, etc. should live in this class.
9+
# All endpoints (returning query objects) should live in this class.
1010
class HTTPClient
11-
require 'httparty'
12-
require 'time'
13-
require 'uri'
14-
require 'openssl'
15-
require 'base64'
11+
attr_reader :env # injectable credentials
1612

17-
# The region you are interested in
18-
ENDPOINT = 'webservices.amazon.com'
19-
REQUEST_URI = '/onca/xml'
20-
21-
attr_reader :env
22-
attr_writer :query, :page_num
23-
24-
def initialize(query:, page_num: 1, env: ENV)
25-
@query = query
26-
@page_num = page_num
13+
def initialize(env: ENV)
2714
@env = env
2815
assign_env_vars
2916
end
3017

31-
# Generate the signed URL
32-
def url
33-
raise InvalidQueryError unless query && page_num
34-
35-
"http://#{ENDPOINT}#{REQUEST_URI}" + # base
36-
"?#{canonical_query_string}" + # query
37-
"&Signature=#{uri_escape(signature)}" # signature
38-
end
39-
40-
# Performs the search query and returns the resulting SearchResponse
41-
def search_response(http: HTTParty)
42-
response = get(http: http)
43-
SearchResponse.new parse_response(response)
18+
def item_search(query:, page: 1)
19+
ItemSearchEndpoint.new(query, page, aws_credentials)
4420
end
4521

46-
# Send the HTTP request
47-
def get(http: HTTParty)
48-
http.get(url)
22+
def item_lookup(asin)
23+
ItemLookupEndpoint.new(asin, aws_credentials)
4924
end
5025

5126
private
5227

53-
attr_reader :query, :page_num, :aws_credentials
28+
attr_reader :aws_credentials
5429

5530
def assign_env_vars
5631
@aws_credentials = AWSCredentials.new(env['AWS_ACCESS_KEY'],
@@ -61,54 +36,6 @@ def assign_env_vars
6136
"they're set."
6237
raise InvalidQueryError, msg unless @aws_credentials.present?
6338
end
64-
65-
def parse_response(response)
66-
Hash.from_xml(response.body)
67-
end
68-
69-
def uri_escape(phrase)
70-
URI.encode_www_form_component(phrase.to_s)
71-
end
72-
73-
def params
74-
params = {
75-
'Service' => 'AWSECommerceService',
76-
'Operation' => 'ItemSearch',
77-
'AWSAccessKeyId' => aws_credentials.access_key,
78-
'AssociateTag' => aws_credentials.associate_tag,
79-
'SearchIndex' => 'All',
80-
'Keywords' => query.to_s,
81-
'ResponseGroup' => 'ItemAttributes,Offers,Images',
82-
'ItemPage' => page_num.to_s
83-
}
84-
85-
# Set current timestamp if not set
86-
params['Timestamp'] ||= Time.now.gmtime.iso8601
87-
params
88-
end
89-
90-
# Generate the canonical query
91-
def canonical_query_string
92-
params.sort
93-
.map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" }
94-
.join('&')
95-
end
96-
97-
# Generate the string to be signed
98-
def string_to_sign
99-
"GET\n#{ENDPOINT}\n#{REQUEST_URI}\n#{canonical_query_string}"
100-
end
101-
102-
# Generate the signature required by the Product Advertising API
103-
def signature
104-
Base64.encode64(digest_with_key(string_to_sign)).strip
105-
end
106-
107-
def digest_with_key(string)
108-
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'),
109-
aws_credentials.secret_key,
110-
string)
111-
end
11239
end
11340

11441
# Wrapper object to store/verify AWS credentials
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# frozen_string_literal: true
2+
3+
require 'amazon_product_api/lookup_response'
4+
5+
module AmazonProductAPI
6+
# Responsible for looking up an item listing on Amazon
7+
#
8+
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/ItemLookup.html
9+
#
10+
# Any logic relating to lookup, building the query string, authentication
11+
# signatures, etc. should live in this class.
12+
class ItemLookupEndpoint
13+
require 'httparty'
14+
require 'time'
15+
require 'uri'
16+
require 'openssl'
17+
require 'base64'
18+
19+
# The region you are interested in
20+
ENDPOINT = 'webservices.amazon.com'
21+
REQUEST_URI = '/onca/xml'
22+
23+
attr_accessor :asin, :aws_credentials
24+
25+
def initialize(asin, aws_credentials)
26+
@asin = asin
27+
@aws_credentials = aws_credentials
28+
end
29+
30+
# Generate the signed URL
31+
def url
32+
"http://#{ENDPOINT}#{REQUEST_URI}" + # base
33+
"?#{canonical_query_string}" + # query
34+
"&Signature=#{uri_escape(signature)}" # signature
35+
end
36+
37+
# Send the HTTP request
38+
def get(http: HTTParty)
39+
http.get(url)
40+
end
41+
42+
# Performs the search query and returns the resulting SearchResponse
43+
def response(http: HTTParty, logger: Rails.logger)
44+
response = parse_response get(http: http)
45+
logger.debug(response)
46+
LookupResponse.new(response).item
47+
end
48+
49+
private
50+
51+
def parse_response(response)
52+
Hash.from_xml(response.body)
53+
end
54+
55+
# Generate the signature required by the Product Advertising API
56+
def signature
57+
Base64.encode64(digest_with_key(string_to_sign)).strip
58+
end
59+
60+
# Generate the string to be signed
61+
def string_to_sign
62+
"GET\n#{ENDPOINT}\n#{REQUEST_URI}\n#{canonical_query_string}"
63+
end
64+
65+
# Generate the canonical query
66+
def canonical_query_string
67+
params.sort
68+
.map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" }
69+
.join('&')
70+
end
71+
72+
def params
73+
params = {
74+
'Service' => 'AWSECommerceService',
75+
'AWSAccessKeyId' => aws_credentials.access_key,
76+
'AssociateTag' => aws_credentials.associate_tag,
77+
# endpoint-specific
78+
'Operation' => 'ItemLookup',
79+
'ResponseGroup' => 'ItemAttributes,Offers,Images',
80+
'ItemId' => asin.to_s
81+
}
82+
83+
# Set current timestamp if not set
84+
params['Timestamp'] ||= Time.now.gmtime.iso8601
85+
params
86+
end
87+
88+
def digest_with_key(string)
89+
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'),
90+
aws_credentials.secret_key,
91+
string)
92+
end
93+
94+
def uri_escape(phrase)
95+
CGI.escape(phrase.to_s)
96+
end
97+
end
98+
end
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# frozen_string_literal: true
2+
3+
require 'amazon_product_api/search_response'
4+
5+
module AmazonProductAPI
6+
# Responsible for building and executing an Amazon Product API search query.
7+
#
8+
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/ItemSearch.html
9+
#
10+
# Any logic relating to searching, building the query string, authentication
11+
# signatures, etc. should live in this class.
12+
class ItemSearchEndpoint
13+
require 'httparty'
14+
require 'time'
15+
require 'uri'
16+
require 'openssl'
17+
require 'base64'
18+
19+
# The region you are interested in
20+
ENDPOINT = 'webservices.amazon.com'
21+
REQUEST_URI = '/onca/xml'
22+
23+
attr_accessor :query, :page, :aws_credentials
24+
25+
def initialize(query, page, aws_credentials)
26+
@query = query
27+
@page = page
28+
@aws_credentials = aws_credentials
29+
end
30+
31+
# Generate the signed URL
32+
def url
33+
raise InvalidQueryError unless query && page
34+
35+
"http://#{ENDPOINT}#{REQUEST_URI}" + # base
36+
"?#{canonical_query_string}" + # query
37+
"&Signature=#{uri_escape(signature)}" # signature
38+
end
39+
40+
# Send the HTTP request
41+
def get(http: HTTParty)
42+
http.get(url)
43+
end
44+
45+
# Performs the search query and returns the resulting SearchResponse
46+
def response(http: HTTParty, logger: Rails.logger)
47+
response = parse_response get(http: http)
48+
logger.debug response
49+
SearchResponse.new response
50+
end
51+
52+
private
53+
54+
def parse_response(response)
55+
Hash.from_xml(response.body)
56+
end
57+
58+
# Generate the signature required by the Product Advertising API
59+
def signature
60+
Base64.encode64(digest_with_key(string_to_sign)).strip
61+
end
62+
63+
# Generate the string to be signed
64+
def string_to_sign
65+
"GET\n#{ENDPOINT}\n#{REQUEST_URI}\n#{canonical_query_string}"
66+
end
67+
68+
# Generate the canonical query
69+
def canonical_query_string
70+
params.sort
71+
.map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" }
72+
.join('&')
73+
end
74+
75+
def params
76+
params = {
77+
'Service' => 'AWSECommerceService',
78+
'AWSAccessKeyId' => aws_credentials.access_key,
79+
'AssociateTag' => aws_credentials.associate_tag,
80+
# endpoint-specific
81+
'Operation' => 'ItemSearch',
82+
'ResponseGroup' => 'ItemAttributes,Offers,Images',
83+
'SearchIndex' => 'All',
84+
'Keywords' => query.to_s,
85+
'ItemPage' => page.to_s
86+
}
87+
88+
# Set current timestamp if not set
89+
params['Timestamp'] ||= Time.now.gmtime.iso8601
90+
params
91+
end
92+
93+
def digest_with_key(string)
94+
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'),
95+
aws_credentials.secret_key,
96+
string)
97+
end
98+
99+
def uri_escape(phrase)
100+
CGI.escape(phrase.to_s)
101+
end
102+
end
103+
end

0 commit comments

Comments
 (0)