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 +``` 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 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)