Skip to content

Commit

Permalink
Refactor AmazonProductAPI
Browse files Browse the repository at this point in the history
In preparation for rubyforgood#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.
  • Loading branch information
leesharma committed Oct 25, 2017
1 parent bfb9c88 commit ffed141
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 104 deletions.
9 changes: 5 additions & 4 deletions app/controllers/amazon_search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@ class AmazonSearchController < ApplicationController

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

def new
authorize :amazon_search, :new?
end

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
96 changes: 8 additions & 88 deletions lib/amazon_product_api/http_client.rb
Original file line number Diff line number Diff line change
@@ -1,56 +1,24 @@
require "amazon_product_api/search_response"
require "amazon_product_api/item_search_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)
end

# Send the HTTP request
def get(http: HTTParty)
http.get(url)
def item_search(query:, page: 1)
ItemSearchEndpoint.new(query, page, 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 @@ -63,54 +31,6 @@ def assign_env_vars
fail InvalidQueryError, msg
end
end

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

def uri_escape(phrase)
URI.escape(phrase.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
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
99 changes: 99 additions & 0 deletions lib/amazon_product_api/item_search_endpoint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
require "amazon_product_api/search_response"

module AmazonProductAPI
# Responsible for building and executing an Amazon Product API search query.
#
# 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)
response = get(http: http)
SearchResponse.new parse_response(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",
"Operation" => "ItemSearch",
"AWSAccessKeyId" => aws_credentials.access_key,
"AssociateTag" => aws_credentials.associate_tag,
"SearchIndex" => "All",
"Keywords" => query.to_s,
"ResponseGroup" => "ItemAttributes,Offers,Images",
"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
28 changes: 16 additions & 12 deletions spec/lib/amazon_product_api/http_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
"AWS_SECRET_KEY" => "aws_secret_key",
"AWS_ASSOCIATES_TAG" => "aws_associates_tag",
}
AmazonProductAPI::HTTPClient.new(query: "corgi", page_num: 5, env: env)
# AmazonProductAPI::HTTPClient.new(query: "corgi", page_num: 5, env: env)
AmazonProductAPI::HTTPClient.new(env: env)
}

context "when credentials are not present" do
it "throws an error" do
expect { AmazonProductAPI::HTTPClient.new(query: "anything", env: {}) }
expect { AmazonProductAPI::HTTPClient.new(env: {}) }
.to raise_error(AmazonProductAPI::InvalidQueryError,
"Environment variables AWS_ACCESS_KEY, AWS_SECRET_KEY, and " +
"AWS_ASSOCIATES_TAG are required values. Please make sure they're set."
Expand All @@ -26,15 +27,15 @@
allow(ENV).to receive(:[]).with("AWS_SECRET_KEY") { "" }
allow(ENV).to receive(:[]).with("AWS_ASSOCIATES_TAG") { "" }
}
subject { AmazonProductAPI::HTTPClient.new(query: "anything").env }
subject { AmazonProductAPI::HTTPClient.new.env }

it "defaults to the ENV object" do
expect(subject).to be ENV
end
end

describe "#url" do
subject(:url) { client.url }
subject(:url) { client.item_search(query: "corgi", page: 5).url }

it { should start_with "http://webservices.amazon.com/onca/xml" }
it { should include "AWSAccessKeyId=aws_access_key" }
Expand All @@ -52,22 +53,21 @@

context "when no query term was provided" do
it "should raise an InvalidQueryError" do
client.query = nil
expect { client.url }.to raise_error AmazonProductAPI::InvalidQueryError
expect { client.item_search }.to raise_error ArgumentError
end
end

context "when no page number was provided" do
it "should default to page 1" do
client = AmazonProductAPI::HTTPClient.new(query: "corgi")
expect(client.url).to include "ItemPage=1"
expect(client.item_search(query: "anything").url).to include "ItemPage=1"
end
end

context "when the page number is set to nil" do
it "should raise an InvalidQueryError" do
client.page_num = nil
expect { client.url }.to raise_error AmazonProductAPI::InvalidQueryError
expect {
client.item_search(query: "anything", page: nil).url
}.to raise_error AmazonProductAPI::InvalidQueryError
end
end
end
Expand All @@ -77,12 +77,16 @@

it "should make a `get` request to the specified http library" do
expect(http_double).to receive(:get).with(String)
client.get(http: http_double)
client.item_search(query: "corgi").get(http: http_double)
end
end

describe "#search_response", :external do
subject { AmazonProductAPI::HTTPClient.new(query: "corgi").search_response }
subject {
client = AmazonProductAPI::HTTPClient.new
query = client.item_search(query: "corgi")
query.response
}
it { should be_a AmazonProductAPI::SearchResponse }
it { should respond_to :items }
end
Expand Down

0 comments on commit ffed141

Please sign in to comment.