diff --git a/app/controllers/amazon_search_controller.rb b/app/controllers/amazon_search_controller.rb index 0c09fc6..3c8aa23 100644 --- a/app/controllers/amazon_search_controller.rb +++ b/app/controllers/amazon_search_controller.rb @@ -7,7 +7,7 @@ class AmazonSearchController < ApplicationController def show authorize :amazon_search, :show? - @response = amazon_client.search_response + @response = amazon_search_response end def new @@ -15,9 +15,10 @@ def 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 diff --git a/lib/amazon_product_api/http_client.rb b/lib/amazon_product_api/http_client.rb index b02c0e7..c5ba48e 100644 --- a/lib/amazon_product_api/http_client.rb +++ b/lib/amazon_product_api/http_client.rb @@ -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"], @@ -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 diff --git a/lib/amazon_product_api/item_search_endpoint.rb b/lib/amazon_product_api/item_search_endpoint.rb new file mode 100644 index 0000000..eaa7087 --- /dev/null +++ b/lib/amazon_product_api/item_search_endpoint.rb @@ -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 diff --git a/spec/lib/amazon_product_api/http_client_spec.rb b/spec/lib/amazon_product_api/http_client_spec.rb index bef9264..4b7285d 100644 --- a/spec/lib/amazon_product_api/http_client_spec.rb +++ b/spec/lib/amazon_product_api/http_client_spec.rb @@ -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." @@ -26,7 +27,7 @@ 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 @@ -34,7 +35,7 @@ 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" } @@ -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 @@ -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