From 7450a607293b9634359d8eeab7bc44e39dc7a9a2 Mon Sep 17 00:00:00 2001 From: Lee Sharma Date: Thu, 26 Oct 2017 12:38:49 -0400 Subject: [PATCH 1/3] 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 | 104 ++++++++++++++++++ .../item_lookup_endpoint.rb | 85 +++----------- .../item_search_endpoint.rb | 89 +++------------ 3 files changed, 132 insertions(+), 146 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..76bbb5d --- /dev/null +++ b/lib/amazon_product_api/endpoint.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +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' + REQUEST_URI = '/onca/xml' + + # 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) + CGI.escape(phrase.to_s) + end + end +end diff --git a/lib/amazon_product_api/item_lookup_endpoint.rb b/lib/amazon_product_api/item_lookup_endpoint.rb index 087d597..7f026f5 100644 --- a/lib/amazon_product_api/item_lookup_endpoint.rb +++ b/lib/amazon_product_api/item_lookup_endpoint.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'amazon_product_api/endpoint' require 'amazon_product_api/lookup_response' module AmazonProductAPI @@ -7,92 +8,32 @@ module AmazonProductAPI # # 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 - def parse_response(response) - Hash.from_xml(response.body) - end + attr_reader :asin, :aws_credentials - # Generate the signature required by the Product Advertising API - def signature - Base64.encode64(digest_with_key(string_to_sign)).strip + def process_response(response_hash) + LookupResponse.new(response_hash).item 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 + # 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) - CGI.escape(phrase.to_s) end end end diff --git a/lib/amazon_product_api/item_search_endpoint.rb b/lib/amazon_product_api/item_search_endpoint.rb index 7230cfa..271d287 100644 --- a/lib/amazon_product_api/item_search_endpoint.rb +++ b/lib/amazon_product_api/item_search_endpoint.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'amazon_product_api/endpoint' require 'amazon_product_api/search_response' module AmazonProductAPI @@ -7,97 +8,37 @@ module AmazonProductAPI # # 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 - 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 + attr_accessor :query, :page, :aws_credentials - # Generate the canonical query - def canonical_query_string - params.sort - .map { |key, value| "#{uri_escape(key)}=#{uri_escape(value)}" } - .join('&') + def process_response(response_hash) + SearchResponse.new response_hash end - def params - params = { - 'Service' => 'AWSECommerceService', - 'AWSAccessKeyId' => aws_credentials.access_key, - 'AssociateTag' => aws_credentials.associate_tag, - # endpoint-specific + # 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) - CGI.escape(phrase.to_s) end end end From 4cd254a1cbe8ac24e6f296611c19695ab52907c8 Mon Sep 17 00:00:00 2001 From: Lee Sharma Date: Thu, 26 Oct 2017 13:05:53 -0400 Subject: [PATCH 2/3] Add Amazon Product API README Since the instructions are getting more complicated, this commit adds a README (with new endpoint instructions) to the Amazon Product API. This has three benefits: * It keeps the root README clean and relevant to most people. * It allows us to go into more documentation detail. * It facilitates an eventual extraction of the Amazon Product API lib. It might be worth extracting more from the "Getting Amazon Product Advertising API Working Locally" section into the lib README, but for now there's just endpoint information. --- README.md | 5 ++- lib/amazon_product_api/README.md | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 lib/amazon_product_api/README.md diff --git a/README.md b/README.md index fcbe52b..e82847c 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,10 @@ account or login locally: By the end of this section, you should be able to search Amazon products and add items to wishlists on your local machine (when your logged-in user is an -admin or site manager). +admin or site manager). *Note: if you're adding a new API endpoint, read more +[here][API client README].* + +[API client README]: lib/amazon_product_api/README.md **This step is only required for site managers and admins searching/adding Amazon products.** If your issue doesn't involve working with the Amazon diff --git a/lib/amazon_product_api/README.md b/lib/amazon_product_api/README.md new file mode 100644 index 0000000..1fa9cc8 --- /dev/null +++ b/lib/amazon_product_api/README.md @@ -0,0 +1,68 @@ +# Amazon Product Advertising API Client + +This folder contains the wrapper code for the Amazon Product Advertising API. +For more details on the API, check the [Amazon documentation]. + +[Amazon documentation]: http://docs.aws.amazon.com/AWSECommerceService/latest/DG/Welcome.html + +## Adding an Endpoint + +All endpoints should be subclassed from `AmazonProductAPI::Endpoint`. In order +to add a new endpoint, you'll need to modify the template below in a few ways: + + * Prepend any new attributes to the `#initialize` parameter list. Any + validations or processing can be done in `#initialize` as shown. Note: + setting `aws_credentials` is **required**! + + * Add endpoint-specific request parameters to `#request_params`. These can + be found in the Amazon API documentation. + + * Add any post-processing of the API response in `#process_response`. + + * Update the class name and comments. + +### Endpoint Template + +```ruby +require 'amazon_product_api/endpoint' + +module AmazonProductAPI + # Responsible for building and executing <...> + # + # + # + # Contains all specialization logic for this endpoint including request + # parameters, parameter validation, and response parsing. + class TemplateEndpoint < Endpoint + # Add any parameters you need for the specific endpoint. + # + # Make sure you set `@aws_credentials`-the query won't work without it! + def initialize(aws_credentials) + # Attribute validations + # raise InvalidQueryError, 'reason' if ... + + # Initialize attributes + @aws_credentials = aws_credentials + end + + private + + attr_accessor :aws_credentials # any other attrs + + # Add any post-processing of the response hash. + def process_response(response_hash) + ExampleResponse.new(response_hash).item + end + + # Include request parameters unique to this endpoint. + def request_params + { + # 'Operation' => 'ItemLookup', + # 'IdType' => 'ASIN', + # 'ItemId' => 'the item asin', + # ... + } + end + end +end +``` From 3b0bb6a629c0c16a15b4b403c224f737ba31b9e5 Mon Sep 17 00:00:00 2001 From: Lee Sharma Date: Sun, 26 Nov 2017 00:42:59 -0500 Subject: [PATCH 3/3] Only load AWS test credentials once Whenever the tests run, the following error appeared: warning: already initialized constant AWSTestCredentials This moves the AWSTestCredentials init from the individual specs to a spec helper class: `spec/support/helpers/amazon_helpers.rb`. --- spec/lib/amazon_product_api/item_lookup_endpoint_spec.rb | 3 +-- spec/lib/amazon_product_api/item_search_endpoint_spec.rb | 3 +-- spec/support/helpers/amazon_helpers.rb | 6 ++++++ 3 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 spec/support/helpers/amazon_helpers.rb diff --git a/spec/lib/amazon_product_api/item_lookup_endpoint_spec.rb b/spec/lib/amazon_product_api/item_lookup_endpoint_spec.rb index c8c9288..97bcc85 100644 --- a/spec/lib/amazon_product_api/item_lookup_endpoint_spec.rb +++ b/spec/lib/amazon_product_api/item_lookup_endpoint_spec.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true require 'amazon_product_api/item_lookup_endpoint' +require 'support/helpers/amazon_helpers' describe AmazonProductAPI::ItemLookupEndpoint do - AWSTestCredentials = Struct.new(:access_key, :secret_key, :associate_tag) - let(:aws_credentials) { AWSTestCredentials.new('aws_access_key', 'aws_secret_key', diff --git a/spec/lib/amazon_product_api/item_search_endpoint_spec.rb b/spec/lib/amazon_product_api/item_search_endpoint_spec.rb index 16ea7c9..190169d 100644 --- a/spec/lib/amazon_product_api/item_search_endpoint_spec.rb +++ b/spec/lib/amazon_product_api/item_search_endpoint_spec.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true require 'amazon_product_api/item_search_endpoint' +require 'support/helpers/amazon_helpers' describe AmazonProductAPI::ItemSearchEndpoint do - AWSTestCredentials = Struct.new(:access_key, :secret_key, :associate_tag) - let(:aws_credentials) { AWSTestCredentials.new('aws_access_key', 'aws_secret_key', diff --git a/spec/support/helpers/amazon_helpers.rb b/spec/support/helpers/amazon_helpers.rb new file mode 100644 index 0000000..8d6b22c --- /dev/null +++ b/spec/support/helpers/amazon_helpers.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Mocks an AWS Credentials object +AWSTestCredentials = Struct.new(:access_key, + :secret_key, + :associate_tag)