Skip to content

Commit 228da75

Browse files
committed
Extract endpoint superclass
I'm pretty torn on this commit-an abstract superclass doesn't seem like a very ruby thing to do. Maybe I've been doing too much Java? There are two main goals of this commit: 1. It should be trivial to add new endpoints. We're going to have to add a few more, and being able to customize only the specialized bits will save a ton of time and prevent bugs. 2. Shared code should exist in one location. Already, we've got a bug in our signing process, and keeping this all in a superclass means we only need to fix it in one place. I don't like how I'm handling `@aws_credentials` at the moment (requiring each subclass to initialize it); any ideas of a better way to handle it?
1 parent cd044b2 commit 228da75

File tree

3 files changed

+132
-146
lines changed

3 files changed

+132
-146
lines changed

lib/amazon_product_api/endpoint.rb

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# frozen_string_literal: true
2+
3+
module AmazonProductAPI
4+
# Base representation of all Amazon Product Advertising API endpoints.
5+
#
6+
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/\
7+
# CHAP_OperationListAlphabetical.html
8+
#
9+
# Any general logic relating to lookup, building the query string,
10+
# authentication signatures, etc. should live in this class. Specializations
11+
# (including specific request parameters and response parsing) should live in
12+
# endpoint subclasses.
13+
class Endpoint
14+
require 'httparty'
15+
require 'time'
16+
require 'uri'
17+
require 'openssl'
18+
require 'base64'
19+
20+
# The region you are interested in
21+
ENDPOINT = 'webservices.amazon.com'
22+
REQUEST_URI = '/onca/xml'
23+
24+
# Generates the signed URL
25+
def url
26+
raise InvalidQueryError, 'Missing AWS credentials' unless aws_credentials
27+
28+
"http://#{ENDPOINT}#{REQUEST_URI}" + # base
29+
"?#{canonical_query_string}" + # query
30+
"&Signature=#{uri_escape(signature)}" # signature
31+
end
32+
33+
# Sends the HTTP request
34+
def get(http: HTTParty)
35+
http.get(url)
36+
end
37+
38+
# Performs the search query and returns the processed response
39+
def response(http: HTTParty, logger: Rails.logger)
40+
response = parse_response get(http: http)
41+
logger.debug response
42+
process_response(response)
43+
end
44+
45+
private
46+
47+
attr_reader :aws_credentials
48+
49+
# Takes the response hash and returns the processed API response
50+
#
51+
# This must be implemented for each individual endpoint.
52+
def process_response(_response_hash)
53+
raise NotImplementedError, 'Implement this method in your subclass.'
54+
end
55+
56+
# Returns a hash of request parameters unique to the endpoint
57+
#
58+
# This must be implemented for each individual endpoint.
59+
def request_params
60+
raise NotImplementedError, 'Implement this method in your subclass.'
61+
end
62+
63+
def params
64+
params = request_params.merge(
65+
'Service' => 'AWSECommerceService',
66+
'AWSAccessKeyId' => aws_credentials.access_key,
67+
'AssociateTag' => aws_credentials.associate_tag
68+
)
69+
70+
# Set current timestamp if not set
71+
params['Timestamp'] ||= Time.now.gmtime.iso8601
72+
params
73+
end
74+
75+
def parse_response(response)
76+
Hash.from_xml(response.body)
77+
end
78+
79+
# Generates the signature required by the Product Advertising API
80+
def signature
81+
Base64.encode64(digest_with_key(string_to_sign)).strip
82+
end
83+
84+
def string_to_sign
85+
"GET\n#{ENDPOINT}\n#{REQUEST_URI}\n#{canonical_query_string}"
86+
end
87+
88+
def canonical_query_string
89+
params.sort
90+
.map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" }
91+
.join('&')
92+
end
93+
94+
def digest_with_key(string)
95+
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'),
96+
aws_credentials.secret_key,
97+
string)
98+
end
99+
100+
def uri_escape(phrase)
101+
CGI.escape(phrase.to_s)
102+
end
103+
end
104+
end
Lines changed: 13 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,39 @@
11
# frozen_string_literal: true
22

3+
require 'amazon_product_api/endpoint'
34
require 'amazon_product_api/lookup_response'
45

56
module AmazonProductAPI
67
# Responsible for looking up an item listing on Amazon
78
#
89
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/ItemLookup.html
910
#
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-
11+
# Contains all specialization logic for this endpoint including request
12+
# parameters, parameter validation, and response parsing.
13+
class ItemLookupEndpoint < Endpoint
2514
def initialize(asin, aws_credentials)
2615
@asin = asin
2716
@aws_credentials = aws_credentials
2817
end
2918

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-
4919
private
5020

51-
def parse_response(response)
52-
Hash.from_xml(response.body)
53-
end
21+
attr_reader :asin, :aws_credentials
5422

55-
# Generate the signature required by the Product Advertising API
56-
def signature
57-
Base64.encode64(digest_with_key(string_to_sign)).strip
23+
def process_response(response_hash)
24+
LookupResponse.new(response_hash).item
5825
end
5926

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
27+
# Other request parameters for ItemLookup can be found here:
28+
#
29+
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/\
30+
# ItemLookup.html#ItemLookup-rp
31+
def request_params
32+
{
7833
'Operation' => 'ItemLookup',
7934
'ResponseGroup' => 'ItemAttributes,Offers,Images',
8035
'ItemId' => asin.to_s
8136
}
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)
9637
end
9738
end
9839
end
Lines changed: 15 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,44 @@
11
# frozen_string_literal: true
22

3+
require 'amazon_product_api/endpoint'
34
require 'amazon_product_api/search_response'
45

56
module AmazonProductAPI
67
# Responsible for building and executing an Amazon Product API search query.
78
#
89
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/ItemSearch.html
910
#
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-
11+
# Contains all specialization logic for this endpoint including request
12+
# parameters, parameter validation, and response parsing.
13+
class ItemSearchEndpoint < Endpoint
2514
def initialize(query, page, aws_credentials)
15+
raise InvalidQueryError, "Page can't be nil." if page.nil?
16+
2617
@query = query
2718
@page = page
2819
@aws_credentials = aws_credentials
2920
end
3021

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-
5222
private
5323

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
24+
attr_accessor :query, :page, :aws_credentials
6725

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('&')
26+
def process_response(response_hash)
27+
SearchResponse.new response_hash
7328
end
7429

75-
def params
76-
params = {
77-
'Service' => 'AWSECommerceService',
78-
'AWSAccessKeyId' => aws_credentials.access_key,
79-
'AssociateTag' => aws_credentials.associate_tag,
80-
# endpoint-specific
30+
# Other request parameters for ItemLookup can be found here:
31+
#
32+
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/\
33+
# ItemSearch.html#ItemSearch-rp
34+
def request_params
35+
{
8136
'Operation' => 'ItemSearch',
8237
'ResponseGroup' => 'ItemAttributes,Offers,Images',
8338
'SearchIndex' => 'All',
8439
'Keywords' => query.to_s,
8540
'ItemPage' => page.to_s
8641
}
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)
10142
end
10243
end
10344
end

0 commit comments

Comments
 (0)