diff --git a/lib/httparty.rb b/lib/httparty.rb index 6f9ec57a..aef4ee63 100644 --- a/lib/httparty.rb +++ b/lib/httparty.rb @@ -10,6 +10,7 @@ require 'httparty/cookie_hash' require 'httparty/net_digest_auth' require 'httparty/version' +require 'httparty/connection_adapter' # @see HTTParty::ClassMethods module HTTParty @@ -57,9 +58,11 @@ def self.included(base) # * :+maintain_method_across_redirects+: see HTTParty::ClassMethods.maintain_method_across_redirects. # * :+no_follow+: see HTTParty::ClassMethods.no_follow. # * :+parser+: see HTTParty::ClassMethods.parser. + # * :+connection_adapter+: see HTTParty::ClassMethods.connection_adapter. # * :+pem+: see HTTParty::ClassMethods.pem. # * :+query_string_normalizer+: see HTTParty::ClassMethods.query_string_normalizer # * :+ssl_ca_file+: see HTTParty::ClassMethods.ssl_ca_file. + # * :+ssl_ca_path+: see HTTParty::ClassMethods.ssl_ca_path. module ClassMethods @@ -331,6 +334,30 @@ def parser(custom_parser = nil) end end + # Allows setting a custom connection_adapter for the http connections + # + # @example + # class Foo + # include HTTParty + # connection_adapter Proc.new {|uri, options| ... } + # end + # + # @example provide optional configuration for your connection_adapter + # class Foo + # include HTTParty + # connection_adapter Proc.new {|uri, options| ... }, {:foo => :bar} + # end + # + # @see HTTParty::ConnectionAdapter + def connection_adapter(custom_adapter = nil, options = nil) + if custom_adapter.nil? + default_options[:connection_adapter] + else + default_options[:connection_adapter] = custom_adapter + default_options[:connection_adapter_options] = options + end + end + # Allows making a get request to a url. # # class Foo diff --git a/lib/httparty/connection_adapter.rb b/lib/httparty/connection_adapter.rb new file mode 100644 index 00000000..9c542b60 --- /dev/null +++ b/lib/httparty/connection_adapter.rb @@ -0,0 +1,111 @@ +module HTTParty + # Default connection adapter that returns a new Net::HTTP each time + # + # == Custom Connection Factories + # + # If you like to implement your own connection adapter, subclassing + # HTTPParty::ConnectionAdapter will make it easier. Just override + # the #connection method. The uri and options attributes will have + # all the info you need to construct your http connection. Whatever + # you return from your connection method needs to adhere to the + # Net::HTTP interface as this is what HTTParty expects. + # + # @example log the uri and options + # class LoggingConnectionAdapter < HTTParty::ConnectionAdapter + # def connection + # puts uri + # puts options + # Net::HTTP.new(uri) + # end + # end + # + # @example count number of http calls + # class CountingConnectionAdapter < HTTParty::ConnectionAdapter + # @@count = 0 + # + # self.count + # @@count + # end + # + # def connection + # self.count += 1 + # super + # end + # end + # + # === Configuration + # There is lots of configuration data available for your connection adapter + # in the #options attribute. It is up to you to interpret them within your + # connection adapter. Take a look at the implementation of + # HTTParty::ConnectionAdapter#connection for examples of how they are used. + # Something are probably interesting are as follows: + # * :+timeout+: timeout in seconds + # * :+debug_output+: see HTTParty::ClassMethods.debug_output. + # * :+pem+: contains pem data. see HTTParty::ClassMethods.pem. + # * :+ssl_ca_file+: see HTTParty::ClassMethods.ssl_ca_file. + # * :+ssl_ca_path+: see HTTParty::ClassMethods.ssl_ca_path. + # * :+connection_adapter_options+: contains the hash your passed to HTTParty.connection_adapter when you configured your connection adapter + class ConnectionAdapter + + def self.call(uri, options) + new(uri, options).connection + end + + attr_reader :uri, :options + + def initialize(uri, options={}) + raise ArgumentError, "uri must be a URI, not a #{uri.class}" unless uri.kind_of? URI + + @uri = uri + @options = options + end + + def connection + http = Net::HTTP.new(uri.host, uri.port, options[:http_proxyaddr], options[:http_proxyport], options[:http_proxyuser], options[:http_proxypass]) + + http.use_ssl = ssl_implied?(uri) + + attach_ssl_certificates(http, options) + + if options[:timeout] && (options[:timeout].is_a?(Integer) || options[:timeout].is_a?(Float)) + http.open_timeout = options[:timeout] + http.read_timeout = options[:timeout] + end + + if options[:debug_output] + http.set_debug_output(options[:debug_output]) + end + + return http + end + + private + def ssl_implied?(uri) + uri.port == 443 || uri.instance_of?(URI::HTTPS) + end + + def attach_ssl_certificates(http, options) + if http.use_ssl? + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + + # Client certificate authentication + if options[:pem] + http.cert = OpenSSL::X509::Certificate.new(options[:pem]) + http.key = OpenSSL::PKey::RSA.new(options[:pem], options[:pem_password]) + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + + # SSL certificate authority file and/or directory + if options[:ssl_ca_file] + http.ca_file = options[:ssl_ca_file] + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + + if options[:ssl_ca_path] + http.ca_path = options[:ssl_ca_path] + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + end + end + end +end diff --git a/lib/httparty/request.rb b/lib/httparty/request.rb index db556a08..032de69c 100644 --- a/lib/httparty/request.rb +++ b/lib/httparty/request.rb @@ -33,7 +33,8 @@ def initialize(http_method, path, o={}) :limit => o.delete(:no_follow) ? 1 : 5, :default_params => {}, :follow_redirects => true, - :parser => Parser + :parser => Parser, + :connection_adapter => ConnectionAdapter }.merge(o) end @@ -68,6 +69,10 @@ def parser options[:parser] end + def connection_adapter + options[:connection_adapter] + end + def perform(&block) validate setup_raw_request @@ -92,50 +97,8 @@ def perform(&block) private - def attach_ssl_certificates(http) - if http.use_ssl? - http.verify_mode = OpenSSL::SSL::VERIFY_NONE - - # Client certificate authentication - if options[:pem] - http.cert = OpenSSL::X509::Certificate.new(options[:pem]) - http.key = OpenSSL::PKey::RSA.new(options[:pem], options[:pem_password]) - http.verify_mode = OpenSSL::SSL::VERIFY_PEER - end - - # SSL certificate authority file and/or directory - if options[:ssl_ca_file] - http.ca_file = options[:ssl_ca_file] - http.verify_mode = OpenSSL::SSL::VERIFY_PEER - end - - if options[:ssl_ca_path] - http.ca_path = options[:ssl_ca_path] - http.verify_mode = OpenSSL::SSL::VERIFY_PEER - end - end - end - def http - http = Net::HTTP.new(uri.host, uri.port, options[:http_proxyaddr], options[:http_proxyport], options[:http_proxyuser], options[:http_proxypass]) - http.use_ssl = ssl_implied? - - if options[:timeout] && (options[:timeout].is_a?(Integer) || options[:timeout].is_a?(Float)) - http.open_timeout = options[:timeout] - http.read_timeout = options[:timeout] - end - - attach_ssl_certificates(http) - - if options[:debug_output] - http.set_debug_output(options[:debug_output]) - end - - http - end - - def ssl_implied? - uri.port == 443 || uri.instance_of?(URI::HTTPS) + connection_adapter.call(uri, options) end def body diff --git a/spec/httparty/connection_adapter_spec.rb b/spec/httparty/connection_adapter_spec.rb new file mode 100644 index 00000000..4273efe8 --- /dev/null +++ b/spec/httparty/connection_adapter_spec.rb @@ -0,0 +1,204 @@ +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'spec_helper')) + +describe HTTParty::ConnectionAdapter do + + describe "initialization" do + let(:uri) { URI 'http://www.google.com' } + it "takes a URI as input" do + HTTParty::ConnectionAdapter.new(uri) + end + + it "raises an ArgumentError if the uri is nil" do + expect { HTTParty::ConnectionAdapter.new(nil) }.to raise_error ArgumentError + end + + it "raises an ArgumentError if the uri is a String" do + expect { HTTParty::ConnectionAdapter.new('http://www.google.com') }.to raise_error ArgumentError + end + + it "sets the uri" do + adapter = HTTParty::ConnectionAdapter.new(uri) + adapter.uri.should be uri + end + + it "also accepts an optional options hash" do + HTTParty::ConnectionAdapter.new(uri, {}) + end + + it "sets the options" do + options = {:foo => :bar} + adapter = HTTParty::ConnectionAdapter.new(uri, options) + adapter.options.should be options + end + end + + describe ".call" do + it "generates an HTTParty::ConnectionAdapter instance with the given uri and options" do + HTTParty::ConnectionAdapter.should_receive(:new).with(@uri, @options).and_return(stub(:connection => nil)) + HTTParty::ConnectionAdapter.call(@uri, @options) + end + + it "calls #connection on the connection adapter" do + adapter = mock('Adapter') + connection = mock('Connection') + adapter.should_receive(:connection).and_return(connection) + HTTParty::ConnectionAdapter.stub(:new => adapter) + HTTParty::ConnectionAdapter.call(@uri, @options).should be connection + end + end + + describe '#connection' do + let(:uri) { URI 'http://www.google.com' } + let(:options) { Hash.new } + let(:adapter) { HTTParty::ConnectionAdapter.new(uri, options) } + + describe "the resulting connection" do + subject { adapter.connection } + it { should be_an_instance_of Net::HTTP } + + context "when dealing with ssl" do + Spec::Matchers.define :use_ssl do + match do |connection| + connection.use_ssl? + end + end + + context "using port 443 for ssl" do + let(:uri) { URI 'https://api.foo.com/v1:443' } + it { should use_ssl } + end + + context "using port 80" do + let(:uri) { URI 'http://foobar.com' } + it { should_not use_ssl } + end + + context "https scheme with default port" do + let(:uri) { URI 'https://foobar.com' } + it { should use_ssl } + end + + context "https scheme with non-standard port" do + let(:uri) { URI 'https://foobar.com:123456' } + it { should use_ssl } + end + end + + context "when timeout is not set" do + it "doesn't set the timeout" do + http = mock("http", :null_object => true) + http.should_not_receive(:open_timeout=) + http.should_not_receive(:read_timeout=) + Net::HTTP.stub(:new => http) + + adapter.connection + end + end + + context "when setting timeout" do + context "to 5 seconds" do + let(:options) { {:timeout => 5} } + + its(:open_timeout) { should == 5 } + its(:read_timeout) { should == 5 } + end + + context "and timeout is a string" do + let(:options) { {:timeout => "five seconds"} } + + it "doesn't set the timeout" do + http = mock("http", :null_object => true) + http.should_not_receive(:open_timeout=) + http.should_not_receive(:read_timeout=) + Net::HTTP.stub(:new => http) + + adapter.connection + end + end + end + + context "when debug_output" do + let(:http) { Net::HTTP.new(uri) } + before do + Net::HTTP.stub(:new => http) + end + + context "is set to $stderr" do + let(:options) { {:debug_output => $stderr} } + it "has debug output set" do + http.should_receive(:set_debug_output).with($stderr) + adapter.connection + end + end + + context "is not provided" do + it "does not set_debug_output" do + http.should_not_receive(:set_debug_output) + adapter.connection + end + end + end + + context 'when providing proxy address and port' do + let(:options) { {:http_proxyaddr => '1.2.3.4', :http_proxyport => 8080} } + + it { should be_a_proxy } + its(:proxy_address) { should == '1.2.3.4' } + its(:proxy_port) { should == 8080 } + + context 'as well as proxy user and password' do + let(:options) do + {:http_proxyaddr => '1.2.3.4', :http_proxyport => 8080, + :http_proxyuser => 'user', :http_proxypass => 'pass'} + end + its(:proxy_user) { should == 'user' } + its(:proxy_pass) { should == 'pass' } + end + end + + context "when providing PEM certificates" do + let(:pem) { :pem_contents } + let(:options) { {:pem => pem, :pem_password => "password"} } + + context "when scheme is https" do + let(:uri) { URI 'https://google.com' } + let(:cert) { mock("OpenSSL::X509::Certificate") } + let(:key) { mock("OpenSSL::PKey::RSA") } + + before do + OpenSSL::X509::Certificate.should_receive(:new).with(pem).and_return(cert) + OpenSSL::PKey::RSA.should_receive(:new).with(pem, "password").and_return(key) + end + + it "uses the provided PEM certificate " do + subject.cert.should == cert + subject.key.should == key + end + + it "will verify the certificate" do + subject.verify_mode.should == OpenSSL::SSL::VERIFY_PEER + end + end + + context "when scheme is not https" do + let(:uri) { URI 'http://google.com' } + let(:http) { Net::HTTP.new(uri) } + + before do + Net::HTTP.stub(:new => http) + OpenSSL::X509::Certificate.should_not_receive(:new).with(pem) + OpenSSL::PKey::RSA.should_not_receive(:new).with(pem, "password") + http.should_not_receive(:cert=) + http.should_not_receive(:key=) + end + + it "has no PEM certificate " do + subject.cert.should be_nil + subject.key.should be_nil + end + end + end + end + + end +end diff --git a/spec/httparty/request_spec.rb b/spec/httparty/request_spec.rb index f233dd12..9db7b72d 100644 --- a/spec/httparty/request_spec.rb +++ b/spec/httparty/request_spec.rb @@ -45,6 +45,17 @@ request = HTTParty::Request.new(Net::HTTP::Get, 'http://google.com', :parser => my_parser) request.parser.should == my_parser end + + it "sets connection_adapter to HTTPParty::ConnectionAdapter" do + request = HTTParty::Request.new(Net::HTTP::Get, 'http://google.com') + request.connection_adapter.should == HTTParty::ConnectionAdapter + end + + it "sets connection_adapter to the optional connection_adapter" do + my_adapter = lambda {} + request = HTTParty::Request.new(Net::HTTP::Get, 'http://google.com', :connection_adapter => my_adapter) + request.connection_adapter.should == my_adapter + end end describe "#format" do @@ -145,125 +156,12 @@ end describe 'http' do - it "should use ssl for port 443" do - request = HTTParty::Request.new(Net::HTTP::Get, 'https://api.foo.com/v1:443') - request.send(:http).use_ssl?.should == true - end - - it 'should not use ssl for port 80' do - request = HTTParty::Request.new(Net::HTTP::Get, 'http://foobar.com') - request.send(:http).use_ssl?.should == false - end - - it "uses ssl for https scheme with default port" do - request = HTTParty::Request.new(Net::HTTP::Get, 'https://foobar.com') - request.send(:http).use_ssl?.should == true - end - - it "uses ssl for https scheme regardless of port" do - request = HTTParty::Request.new(Net::HTTP::Get, 'https://foobar.com:123456') - request.send(:http).use_ssl?.should == true - end - - context "PEM certificates" do - before do - OpenSSL::X509::Certificate.stub(:new) - OpenSSL::PKey::RSA.stub(:new) - end - - context "when scheme is https" do - before do - @request.stub!(:uri).and_return(URI.parse("https://google.com")) - pem = :pem_contents - @cert = mock("OpenSSL::X509::Certificate") - @key = mock("OpenSSL::PKey::RSA") - OpenSSL::X509::Certificate.should_receive(:new).with(pem).and_return(@cert) - OpenSSL::PKey::RSA.should_receive(:new).with(pem, "password").and_return(@key) - - @request.options[:pem] = pem - @request.options[:pem_password] = "password" - @pem_http = @request.send(:http) - end - - it "should use a PEM certificate when provided" do - @pem_http.cert.should == @cert - @pem_http.key.should == @key - end - - it "should verify the certificate when provided" do - @pem_http = @request.send(:http) - @pem_http.verify_mode.should == OpenSSL::SSL::VERIFY_PEER - end - end - - context "when scheme is not https" do - it "does not assign a PEM" do - http = Net::HTTP.new('google.com') - http.should_not_receive(:cert=) - http.should_not_receive(:key=) - Net::HTTP.stub(:new => http) - - request = HTTParty::Request.new(Net::HTTP::Get, 'http://google.com') - request.options[:pem] = :pem_contents - request.send(:http) - end - end - - context "debugging" do - before do - @http = Net::HTTP.new('google.com') - Net::HTTP.stub(:new => @http) - @request = HTTParty::Request.new(Net::HTTP::Get, 'http://google.com') - end - - it "calls #set_debug_output when the option is provided" do - @request.options[:debug_output] = $stderr - @http.should_receive(:set_debug_output).with($stderr) - @request.send(:http) - end - - it "does not set_debug_output when the option is not provided" do - @http.should_not_receive(:set_debug_output) - @request.send(:http) - end - end - - context 'with a proxy' do - it 'should use a proxy address and port' do - request = HTTParty::Request.new(Net::HTTP::Get, 'https://foobar.com', - :http_proxyaddr => '1.2.3.4', :http_proxyport => 8080) - http = request.send(:http) - http.proxy_address.should == '1.2.3.4' - http.proxy_port.should == 8080 - end - - it 'should use a proxy user and password when provided' do - request = HTTParty::Request.new(Net::HTTP::Get, 'https://foobar.com', - :http_proxyaddr => '1.2.3.4', :http_proxyport => 8080, - :http_proxyuser => 'user', :http_proxypass => 'pass') - http = request.send(:http) - http.proxy_user.should == 'user' - http.proxy_pass.should == 'pass' - end - end - end - - context "when setting timeout" do - it "does nothing if the timeout option is a string" do - http = mock("http", :null_object => true) - http.should_not_receive(:open_timeout=) - http.should_not_receive(:read_timeout=) - Net::HTTP.stub(:new => http) - - request = HTTParty::Request.new(Net::HTTP::Get, 'https://foobar.com', {:timeout => "five seconds"}) - request.send(:http) - end - - it "sets the timeout to 5 seconds" do - @request.options[:timeout] = 5 - @request.send(:http).open_timeout.should == 5 - @request.send(:http).read_timeout.should == 5 - end + it "should get a connection from the connection_adapter" do + http = Net::HTTP.new('google.com') + adapter = mock('adapter') + request = HTTParty::Request.new(Net::HTTP::Get, 'https://api.foo.com/v1:443', :connection_adapter => adapter) + adapter.should_receive(:call).with(request.uri, request.options).and_return(http) + request.send(:http).should be http end end diff --git a/spec/httparty_spec.rb b/spec/httparty_spec.rb index 5650b4be..a5516fb9 100644 --- a/spec/httparty_spec.rb +++ b/spec/httparty_spec.rb @@ -326,6 +326,38 @@ class MyParser < HTTParty::Parser end end + describe "connection_adapter" do + let(:uri) { 'http://google.com/api.json' } + let(:connection_adapter) { mock('CustomConnectionAdapter') } + + it "should set the connection_adapter" do + @klass.connection_adapter connection_adapter + @klass.default_options[:connection_adapter].should be connection_adapter + end + + it "should set the connection_adapter_options when provided" do + options = {:foo => :bar} + @klass.connection_adapter connection_adapter, options + @klass.default_options[:connection_adapter_options].should be options + end + + it "should not set the connection_adapter_options when not provided" do + @klass.connection_adapter connection_adapter + @klass.default_options[:connection_adapter_options].should be_nil + end + + it "should process a request with a connection from the adapter" do + connection_adapter_options = {:foo => :bar} + connection_adapter.should_receive(:call) do |u,o| + o[:connection_adapter_options].should == connection_adapter_options + HTTParty::ConnectionAdapter.call(u,o) + end.with(URI.parse(uri), kind_of(Hash)) + FakeWeb.register_uri(:get, uri, :body => 'stuff') + @klass.connection_adapter connection_adapter, connection_adapter_options + @klass.get(uri).should == 'stuff' + end + end + describe "format" do it "should allow xml" do @klass.format :xml