diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 5e4d36ff..37c0f632 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -61,6 +61,7 @@ Naming/AccessorMethodName: - 'lib/api_auth/request_drivers/net_http.rb' - 'lib/api_auth/request_drivers/rack.rb' - 'lib/api_auth/request_drivers/rest_client.rb' + - 'lib/api_auth/request_drivers/typhoeus_request.rb' # Offense count: 3 # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. diff --git a/api_auth.gemspec b/api_auth.gemspec index 9c9afea9..a97641c4 100644 --- a/api_auth.gemspec +++ b/api_auth.gemspec @@ -24,6 +24,7 @@ Gem::Specification.new do |s| s.add_development_dependency 'pry' s.add_development_dependency 'rake' s.add_development_dependency 'rest-client', '~> 2.0' + s.add_development_dependency 'typhoeus' s.add_development_dependency 'grape', '~> 1.1.0' s.add_development_dependency 'rspec', '~> 3.4' diff --git a/lib/api_auth.rb b/lib/api_auth.rb index a8a0e089..baec8d00 100644 --- a/lib/api_auth.rb +++ b/lib/api_auth.rb @@ -15,6 +15,7 @@ require 'api_auth/request_drivers/httpi' require 'api_auth/request_drivers/faraday' require 'api_auth/request_drivers/http' +require 'api_auth/request_drivers/typhoeus_request' require 'api_auth/headers' require 'api_auth/base' diff --git a/lib/api_auth/headers.rb b/lib/api_auth/headers.rb index 53cddbbd..e611aa05 100644 --- a/lib/api_auth/headers.rb +++ b/lib/api_auth/headers.rb @@ -36,6 +36,8 @@ def initialize_request_driver(request) HttpiRequest.new(request) when /Faraday::Request/ FaradayRequest.new(request) + when /Typhoeus::Request/ + TyphoeusRequest.new(request) when /HTTP::Request/ HttpRequest.new(request) end diff --git a/lib/api_auth/request_drivers/typhoeus_request.rb b/lib/api_auth/request_drivers/typhoeus_request.rb new file mode 100644 index 00000000..54bdfad8 --- /dev/null +++ b/lib/api_auth/request_drivers/typhoeus_request.rb @@ -0,0 +1,81 @@ +module ApiAuth + module RequestDrivers # :nodoc: + class TyphoeusRequest # :nodoc: + include ApiAuth::Helpers + + def initialize(request) + @request = request + fetch_headers + end + + def set_auth_header(header) + @request.options[:headers]['Authorization'] = header + fetch_headers + @request + end + + def calculated_md5 + body = @request.options[:body] || '' + md5_base64digest(body.to_s) + end + + def populate_content_md5 + return unless %w[POST PUT].include?(http_method.to_s.upcase) + + @request.options[:headers]['Content-MD5'] = calculated_md5 + fetch_headers + end + + def md5_mismatch? + if %w[POST PUT].include?(http_method.to_s.upcase) + calculated_md5 != content_md5 + else + false + end + end + + def fetch_headers + @headers = capitalize_keys(@request.options[:headers]) + end + + def http_method + @request.options[:method].to_s.upcase + end + + def content_type + find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) + end + + def content_md5 + find_header(%w[CONTENT-MD5 CONTENT_MD5 HTTP-CONTENT-MD5 HTTP_CONTENT_MD5]) + end + + def original_uri + find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) + end + + def request_uri + URI.parse(@request.url).request_uri + end + + def set_date + @request.options[:headers]['Date'] = Time.now.utc.httpdate + fetch_headers + end + + def timestamp + find_header(%w[DATE HTTP_DATE]) + end + + def authorization_header + find_header %w[Authorization AUTHORIZATION HTTP_AUTHORIZATION] + end + + private + + def find_header(keys) + keys.map { |key| @headers[key] }.compact.first + end + end + end +end diff --git a/spec/request_drivers/typhoeus_request_spec.rb b/spec/request_drivers/typhoeus_request_spec.rb new file mode 100644 index 00000000..1d6803c2 --- /dev/null +++ b/spec/request_drivers/typhoeus_request_spec.rb @@ -0,0 +1,196 @@ +require 'spec_helper' + +describe ApiAuth::RequestDrivers::TyphoeusRequest do + let(:timestamp) { Time.now.utc.httpdate } + + let(:request_url) { 'https://example.com/resource.xml?foo=bar&bar=foo' } + let(:upload_file) { File.open('spec/fixtures/upload.png', 'r') } + + let(:request_headers) do + { + 'Authorization' => 'APIAuth 1044:12345', + 'content-md5' => '1B2M2Y8AsgTpgAmY7PhCfg==', + 'content-type' => 'text/plain', + 'date' => timestamp + } + end + + let(:upload_request) do + Typhoeus::Request.new(request_url, method: :put, headers: request_headers, body: { file: upload_file }) + end + + let(:request) do + Typhoeus::Request.new(request_url, method: :put, headers: request_headers, body: "hello\nworld") + end + + subject(:driven_request) { ApiAuth::RequestDrivers::TyphoeusRequest.new(request) } + + describe 'getting headers correctly' do + describe '#content_type' do + it 'gets the content_type' do + expect(driven_request.content_type).to eq('text/plain') + end + end + + it 'gets the content_md5' do + expect(driven_request.content_md5).to eq('1B2M2Y8AsgTpgAmY7PhCfg==') + end + + it 'gets the request_uri' do + expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') + end + + it 'gets the timestamp' do + expect(driven_request.timestamp).to eq(timestamp) + end + + it 'gets the authorization_header' do + expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') + end + + describe '#calculated_md5' do + it 'calculates md5 from the body' do + expect(driven_request.calculated_md5).to eq('kZXQvrKoieG+Be1rsZVINw==') + end + + it 'treats no body as empty string' do + request.options[:body] = nil + expect(driven_request.calculated_md5).to eq('1B2M2Y8AsgTpgAmY7PhCfg==') + end + + context 'file upload' do + let(:request) { upload_request } + + it 'calculates correctly for multipart content' do + expect(driven_request.calculated_md5).to eq('3wNtEzQ9ZdXZnkyg/glH1g==') + end + end + end + + describe 'http_method' do + context 'when put request' do + let(:request) { Typhoeus::Request.new(request_url, method: :put, headers: request_headers) } + + it 'returns upcased put' do + expect(driven_request.http_method).to eq('PUT') + end + end + + context 'when get request' do + let(:request) { Typhoeus::Request.new(request_url, method: :get, headers: request_headers) } + + it 'returns upcased get' do + expect(driven_request.http_method).to eq('GET') + end + end + end + end + + describe 'setting headers correctly' do + let(:request_headers) do + { + 'content-type' => 'text/plain' + } + end + + let(:request) do + Typhoeus::Request.new(request_url, method: :put, headers: request_headers) + end + + describe '#populate_content_md5' do + context 'when request type has no body' do + let(:request) do + Typhoeus::Request.new(request_url, method: :get, headers: request_headers) + end + + it "doesn't populate content-md5" do + driven_request.populate_content_md5 + expect(request.options[:headers]['Content-MD5']).to be_nil + end + end + + context 'when request type has a body' do + let(:request) do + Typhoeus::Request.new(request_url, method: :put, headers: request_headers, body: "hello\nworld") + end + + it 'populates content-md5' do + driven_request.populate_content_md5 + expect(request.options[:headers]['Content-MD5']).to eq('kZXQvrKoieG+Be1rsZVINw==') + end + + it 'refreshes the cached headers' do + driven_request.populate_content_md5 + expect(driven_request.content_md5).to eq('kZXQvrKoieG+Be1rsZVINw==') + end + end + end + + describe '#set_date' do + before do + allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) + end + + it 'sets the date header of the request' do + driven_request.set_date + expect(request.options[:headers]['Date']).to eq(timestamp) + end + + it 'refreshes the cached headers' do + driven_request.set_date + expect(driven_request.timestamp).to eq(timestamp) + end + end + + describe '#set_auth_header' do + it 'sets the auth header' do + driven_request.set_auth_header('APIAuth 1044:54321') + expect(request.options[:headers]['Authorization']).to eq('APIAuth 1044:54321') + end + end + end + + describe 'md5_mismatch?' do + context 'when request type has no body' do + let(:request) do + Typhoeus::Request.new(request_url, method: :get, headers: request_headers) + end + + it 'is false' do + expect(driven_request.md5_mismatch?).to be false + end + end + + context 'when request type has a body' do + let(:request) do + Typhoeus::Request.new(request_url, method: :put, headers: request_headers, body: "hello\world") + end + + context 'when calculated matches sent' do + before do + request.options[:headers]['Content-MD5'] = '/F4DjTilcDIIVEHn/nAQsA==' + end + + it 'is false' do + expect(driven_request.md5_mismatch?).to be false + end + end + + context "when calculated doesn't match sent" do + before do + request.options[:headers]['Content-MD5'] = '3' + end + + it 'is true' do + expect(driven_request.md5_mismatch?).to be true + end + end + end + end + + describe 'fetch_headers' do + it 'returns request headers' do + expect(driven_request.fetch_headers).to include('CONTENT-TYPE' => 'text/plain') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3c14a2d7..ca62084c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -17,6 +17,7 @@ require 'httpi' require 'faraday' require 'grape' +require 'typhoeus' require 'net/http/post/multipart' # Requires supporting files with custom matchers and macros, etc,