From 72a7f8f8f565ca69334285d47c07b22bb0a6dbfe Mon Sep 17 00:00:00 2001 From: Lee Sharma Date: Thu, 26 Oct 2017 12:38:49 -0400 Subject: [PATCH] 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? --- lib/amazon_product_api/endpoint.rb | 102 +++++++++++++++++ .../item_lookup_endpoint.rb | 95 +++------------- .../item_search_endpoint.rb | 107 ++++-------------- 3 files changed, 142 insertions(+), 162 deletions(-) create mode 100644 lib/amazon_product_api/endpoint.rb diff --git a/lib/amazon_product_api/endpoint.rb b/lib/amazon_product_api/endpoint.rb new file mode 100644 index 0000000..aa07af9 --- /dev/null +++ b/lib/amazon_product_api/endpoint.rb @@ -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 diff --git a/lib/amazon_product_api/item_lookup_endpoint.rb b/lib/amazon_product_api/item_lookup_endpoint.rb index 3059bc8..f9f41e9 100644 --- a/lib/amazon_product_api/item_lookup_endpoint.rb +++ b/lib/amazon_product_api/item_lookup_endpoint.rb @@ -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 diff --git a/lib/amazon_product_api/item_search_endpoint.rb b/lib/amazon_product_api/item_search_endpoint.rb index 3cc1797..5f705d2 100644 --- a/lib/amazon_product_api/item_search_endpoint.rb +++ b/lib/amazon_product_api/item_search_endpoint.rb @@ -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