Skip to content

Commit

Permalink
Add Amazon Product API "ItemLookup" endpoint (#173)
Browse files Browse the repository at this point in the history
* Refactor AmazonProductAPI

In preparation for #62, this commit pulls the endpoint-specific
information out of the HTTPClient class and into and endpoint-specific
class.

Now HTTPClient is responsible only for managing the environment
information and directing the user to the relevant endpoint. Adding a
new endpoint is as simple as adding a method (ex. `item_search`) and
corresponding class (ex. `ItemSearchEndpoint).

The interface is *not* in its final form yet. This is just a good
breaking point for a commit.

* Add ItemSearch endpoint

Building on the last commit, this adds a new ItemLookup endpoint. Now we
can pull details on an individual item based on the ASIN.

The endpoints and responses include a lot of duplication. Some
refactoring will probably be needed.

* Extract spec for ItemSearchEndpoint

When the item search endpoint was extracted, the specs weren't extracted
with it; this means that the HTTPClientSpec was testing everything
relating to the item search endpoint.

This commit extracts all specs relating to the endpoint into a new file.
This can now be refactored and copied over for the
`ItemLookupEndpointSpec`.

* Add spec for ItemLookupEndpoint

* Fix CodeClimate issue

This is a bit of a hack solution, but I don't feel comfortable doing any
major abstraction here yet; I don't think we have enough information.

Hopefully this'll clear up the CodeClimate complaints about duplicated
code!

* Fix easy PR-related rubocop issues

Two big points:

  1. This only fixes easy rubocop issues related to this PR. This
     doesn't touch any of the new capistrano code; I want the diff for
     this PR to be fairly contained.

  2. This includes two rubocop config changes:
      i.  Exclude the vendor directory from linting
      ii. Allow multiline braces in tests (for exception checks, let, etc.)

There are a few more risky/controversial rubocop changes I omitted from
this commit. Those are coming next.

* Fix all Lint/UriEscapeUnescape violations

Fixes #56

This commit changes all the `URI.escape` calls to `CGI.escape`. All
tests pass and the app still works. As a bonus, this seems to resolve
issue #56 too-apostrophes are now properly escaped.

This resolves all rubocop issues relating to this PR.

* Give better name to AWS test credentials in specs

Following the review suggestions. This renames `env` to
`aws_credentials`. The latter is a better name because it represents a
credentials object (built from env vars), not the ENV object itself.

Hopefully this will be clearer!

* Fix item lookup hash

When I fixed the code style for Code Climate in #173, I used the wrong
hash leading to no update attributes being found. This fixes the bug,
adds a test, and renames some of the variables.
  • Loading branch information
leesharma committed Nov 29, 2017
1 parent fdd92e4 commit d50f959
Show file tree
Hide file tree
Showing 14 changed files with 416 additions and 142 deletions.
7 changes: 6 additions & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ AllCops:
# directories or files.
Exclude:
- db/**/*
- vendor/**/*
TargetRubyVersion: 2.4.1
# Cop names are not displayed in offense messages by default. Change behavior
# by overriding DisplayCopNames, or by giving the `-D/--display-cop-names`
Expand All @@ -16,4 +17,8 @@ AllCops:
DisplayStyleGuide: true

Style/NumericLiterals:
Enabled: false
Enabled: false

Style/BlockDelimiters:
Exclude:
- spec/**/*
10 changes: 6 additions & 4 deletions app/controllers/amazon_search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class AmazonSearchController < ApplicationController

def show
authorize :amazon_search, :show?
@response = amazon_client.search_response
@response = amazon_search_response
end

def new
Expand All @@ -18,9 +18,11 @@ def new

private

def amazon_client
AmazonProductAPI::HTTPClient.new(query: params[:query],
page_num: params[:page_num] || 1)
def amazon_search_response
client = AmazonProductAPI::HTTPClient.new
query = client.item_search(query: params[:query],
page: params[:page_num] || 1)
query.response
end

def set_wishlist
Expand Down
95 changes: 11 additions & 84 deletions lib/amazon_product_api/http_client.rb
Original file line number Diff line number Diff line change
@@ -1,56 +1,31 @@
# frozen_string_literal: true

require 'amazon_product_api/search_response'
require 'amazon_product_api/item_search_endpoint'
require 'amazon_product_api/item_lookup_endpoint'

module AmazonProductAPI
# Responsible for building and executing the query to the Amazon Product API.
# Responsible for managing all Amazon Product API queries.
#
# Any logic relating to endpoints, building the query string, authentication
# signatures, etc. should live in this class.
# All endpoints (returning query objects) should live in this class.
class HTTPClient
require 'httparty'
require 'time'
require 'uri'
require 'openssl'
require 'base64'
attr_reader :env # injectable credentials

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

attr_reader :env
attr_writer :query, :page_num

def initialize(query:, page_num: 1, env: ENV)
@query = query
@page_num = page_num
def initialize(env: ENV)
@env = env
assign_env_vars
end

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

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

# Performs the search query and returns the resulting SearchResponse
def search_response(http: HTTParty)
response = get(http: http)
SearchResponse.new parse_response(response)
def item_search(query:, page: 1)
ItemSearchEndpoint.new(query, page, aws_credentials)
end

# Send the HTTP request
def get(http: HTTParty)
http.get(url)
def item_lookup(asin)
ItemLookupEndpoint.new(asin, aws_credentials)
end

private

attr_reader :query, :page_num, :aws_credentials
attr_reader :aws_credentials

def assign_env_vars
@aws_credentials = AWSCredentials.new(env['AWS_ACCESS_KEY'],
Expand All @@ -61,54 +36,6 @@ def assign_env_vars
"they're set."
raise InvalidQueryError, msg unless @aws_credentials.present?
end

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

def uri_escape(phrase)
URI.encode_www_form_component(phrase.to_s)
end

def params
params = {
'Service' => 'AWSECommerceService',
'Operation' => 'ItemSearch',
'AWSAccessKeyId' => aws_credentials.access_key,
'AssociateTag' => aws_credentials.associate_tag,
'SearchIndex' => 'All',
'Keywords' => query.to_s,
'ResponseGroup' => 'ItemAttributes,Offers,Images',
'ItemPage' => page_num.to_s
}

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

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

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

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

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

# Wrapper object to store/verify AWS credentials
Expand Down
98 changes: 98 additions & 0 deletions lib/amazon_product_api/item_lookup_endpoint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

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

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

# 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
}

# 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
103 changes: 103 additions & 0 deletions lib/amazon_product_api/item_search_endpoint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# frozen_string_literal: true

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

def initialize(query, page, aws_credentials)
@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

# 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
}

# 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
Loading

0 comments on commit d50f959

Please sign in to comment.