Skip to content

Commit

Permalink
Extract endpoint superclass
Browse files Browse the repository at this point in the history
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?
  • Loading branch information
leesharma committed Oct 26, 2017
1 parent fa6c620 commit 72a7f8f
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 162 deletions.
102 changes: 102 additions & 0 deletions lib/amazon_product_api/endpoint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
module AmazonProductAPI
# Base representation of all Amazon Product Advertising API endpoints.
#
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/\
# CHAP_OperationListAlphabetical.html
#
# Any general logic relating to lookup, building the query string,
# authentication signatures, etc. should live in this class. Specializations
# (including specific request parameters and response parsing) should live in
# endpoint subclasses.
class Endpoint
require 'httparty'
require 'time'
require 'uri'
require 'openssl'
require 'base64'

# The region you are interested in
ENDPOINT = 'webservices.amazon.com'.freeze
REQUEST_URI = '/onca/xml'.freeze

# Generates the signed URL
def url
raise InvalidQueryError, 'Missing AWS credentials' unless aws_credentials

"http://#{ENDPOINT}#{REQUEST_URI}" + # base
"?#{canonical_query_string}" + # query
"&Signature=#{uri_escape(signature)}" # signature
end

# Sends the HTTP request
def get(http: HTTParty)
http.get(url)
end

# Performs the search query and returns the processed response
def response(http: HTTParty, logger: Rails.logger)
response = parse_response get(http: http)
logger.debug response
process_response(response)
end

private

attr_reader :aws_credentials

# Takes the response hash and returns the processed API response
#
# This must be implemented for each individual endpoint.
def process_response(_response_hash)
raise NotImplementedError, 'Implement this method in your subclass.'
end

# Returns a hash of request parameters unique to the endpoint
#
# This must be implemented for each individual endpoint.
def request_params
raise NotImplementedError, 'Implement this method in your subclass.'
end

def params
params = request_params.merge(
'Service' => 'AWSECommerceService',
'AWSAccessKeyId' => aws_credentials.access_key,
'AssociateTag' => aws_credentials.associate_tag,
)

# Set current timestamp if not set
params['Timestamp'] ||= Time.now.gmtime.iso8601
params
end

def parse_response(response)
Hash.from_xml(response.body)
end

# Generates the signature required by the Product Advertising API
def signature
Base64.encode64(digest_with_key(string_to_sign)).strip
end

def string_to_sign
"GET\n#{ENDPOINT}\n#{REQUEST_URI}\n#{canonical_query_string}"
end

def canonical_query_string
params.sort
.map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" }
.join('&')
end

def digest_with_key(string)
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'),
aws_credentials.secret_key,
string)
end

def uri_escape(phrase)
URI.escape(phrase.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
end
end
end
95 changes: 17 additions & 78 deletions lib/amazon_product_api/item_lookup_endpoint.rb
Original file line number Diff line number Diff line change
@@ -1,98 +1,37 @@
require "amazon_product_api/lookup_response"
require 'amazon_product_api/endpoint'
require 'amazon_product_api/lookup_response'

module AmazonProductAPI
# Responsible for looking up an item listing on Amazon
#
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/ItemLookup.html
#
# Any logic relating to lookup, building the query string, authentication
# signatures, etc. should live in this class.
class ItemLookupEndpoint
require "httparty"
require "time"
require "uri"
require "openssl"
require "base64"

# The region you are interested in
ENDPOINT = "webservices.amazon.com"
REQUEST_URI = "/onca/xml"

attr_accessor :asin, :aws_credentials

# Contains all specialization logic for this endpoint including request
# parameters, parameter validation, and response parsing.
class ItemLookupEndpoint < Endpoint
def initialize(asin, aws_credentials)
@asin = asin
@aws_credentials = aws_credentials
end

# Generate the signed URL
def url
"http://#{ENDPOINT}#{REQUEST_URI}" + # base
"?#{canonical_query_string}" + # query
"&Signature=#{uri_escape(signature)}" # signature
end

# Send the HTTP request
def get(http: HTTParty)
http.get(url)
end

# Performs the search query and returns the resulting SearchResponse
def response(http: HTTParty, logger: Rails.logger)
response = parse_response get(http: http)
logger.debug(response)
LookupResponse.new(response).item
end


private

attr_reader :asin, :aws_credentials

def parse_response(response)
Hash.from_xml(response.body)
def process_response(response_hash)
LookupResponse.new(response_hash).item
end

# Generate the signature required by the Product Advertising API
def signature
Base64.encode64(digest_with_key string_to_sign).strip
end

# Generate the string to be signed
def string_to_sign
"GET\n#{ENDPOINT}\n#{REQUEST_URI}\n#{canonical_query_string}"
end

# Generate the canonical query
def canonical_query_string
params.sort
.map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" }
.join("&")
end

def params
params = {
"Service" => "AWSECommerceService",
"AWSAccessKeyId" => aws_credentials.access_key,
"AssociateTag" => aws_credentials.associate_tag,
# endpoint-specific
"Operation" => "ItemLookup",
"ResponseGroup" => "ItemAttributes,Offers,Images",
"ItemId" => asin.to_s,
# Other request parameters for ItemLookup can be found here:
#
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/\
# ItemLookup.html#ItemLookup-rp
def request_params
{
'Operation' => 'ItemLookup',
'ResponseGroup' => 'ItemAttributes,Offers,Images',
'ItemId' => asin.to_s,
}

# Set current timestamp if not set
params["Timestamp"] ||= Time.now.gmtime.iso8601
params
end

def digest_with_key(string)
OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"),
aws_credentials.secret_key,
string)
end

def uri_escape(phrase)
URI.escape(phrase.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
end
end
end
107 changes: 23 additions & 84 deletions lib/amazon_product_api/item_search_endpoint.rb
Original file line number Diff line number Diff line change
@@ -1,103 +1,42 @@
require "amazon_product_api/search_response"
require 'amazon_product_api/endpoint'
require 'amazon_product_api/search_response'

module AmazonProductAPI
# Responsible for building and executing an Amazon Product API search query.
#
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/ItemSearch.html
#
# Any logic relating to searching, building the query string, authentication
# signatures, etc. should live in this class.
class ItemSearchEndpoint
require "httparty"
require "time"
require "uri"
require "openssl"
require "base64"

# The region you are interested in
ENDPOINT = "webservices.amazon.com"
REQUEST_URI = "/onca/xml"

attr_accessor :query, :page, :aws_credentials

# Contains all specialization logic for this endpoint including request
# parameters, parameter validation, and response parsing.
class ItemSearchEndpoint < Endpoint
def initialize(query, page, aws_credentials)
raise InvalidQueryError, "Page can't be nil." if page.nil?

@query = query
@page = page
@aws_credentials = aws_credentials
end

# Generate the signed URL
def url
raise InvalidQueryError unless query && page

"http://#{ENDPOINT}#{REQUEST_URI}" + # base
"?#{canonical_query_string}" + # query
"&Signature=#{uri_escape(signature)}" # signature
end

# Send the HTTP request
def get(http: HTTParty)
http.get(url)
end

# Performs the search query and returns the resulting SearchResponse
def response(http: HTTParty, logger: Rails.logger)
response = parse_response get(http: http)
logger.debug response
SearchResponse.new response
end


private

attr_accessor :query, :page, :aws_credentials

def parse_response(response)
Hash.from_xml(response.body)
end

# Generate the signature required by the Product Advertising API
def signature
Base64.encode64(digest_with_key string_to_sign).strip
end

# Generate the string to be signed
def string_to_sign
"GET\n#{ENDPOINT}\n#{REQUEST_URI}\n#{canonical_query_string}"
end

# Generate the canonical query
def canonical_query_string
params.sort
.map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" }
.join("&")
end

def params
params = {
"Service" => "AWSECommerceService",
"AWSAccessKeyId" => aws_credentials.access_key,
"AssociateTag" => aws_credentials.associate_tag,
# endpoint-specific
"Operation" => "ItemSearch",
"ResponseGroup" => "ItemAttributes,Offers,Images",
"SearchIndex" => "All",
"Keywords" => query.to_s,
"ItemPage" => page.to_s
def process_response(response_hash)
SearchResponse.new response_hash
end

# Other request parameters for ItemLookup can be found here:
#
# http://docs.aws.amazon.com/AWSECommerceService/latest/DG/\
# ItemSearch.html#ItemSearch-rp
def request_params
{
'Operation' => 'ItemSearch',
'ResponseGroup' => 'ItemAttributes,Offers,Images',
'SearchIndex' => 'All',
'Keywords' => query.to_s,
'ItemPage' => page.to_s,
}

# Set current timestamp if not set
params["Timestamp"] ||= Time.now.gmtime.iso8601
params
end

def digest_with_key(string)
OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"),
aws_credentials.secret_key,
string)
end

def uri_escape(phrase)
URI.escape(phrase.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
end
end
end

0 comments on commit 72a7f8f

Please sign in to comment.