diff --git a/lib/google/api_client.rb b/lib/google/api_client.rb index 3d2ec21d1..907170caa 100644 --- a/lib/google/api_client.rb +++ b/lib/google/api_client.rb @@ -12,16 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -require 'google/api_client/version' - module Google #:nodoc: ## # This class manages communication with a single API. class APIClient + def initialize(options={}) @options = { # TODO: What configuration options need to go here? }.merge(options) + unless @options[:parser] + require 'google/api_client/parser/json_parser' + # NOTE: Do not rely on this default value, as it may change + @options[:parser] = JSONParser.new + end unless @options[:authentication] require 'google/api_client/auth/oauth_1' # NOTE: Do not rely on this default value, as it may change @@ -32,5 +36,13 @@ module Google #:nodoc: @options[:transport] = HTTPTransport end end + + ## + # Returns the parser used by the client. + def parser + return @options[:parser] + end end end + +require 'google/api_client/version' diff --git a/lib/google/api_client/transport/http_transport.rb b/lib/google/api_client/transport/http_transport.rb index 270ce52b3..912d8265b 100644 --- a/lib/google/api_client/transport/http_transport.rb +++ b/lib/google/api_client/transport/http_transport.rb @@ -12,60 +12,173 @@ # See the License for the specific language governing permissions and # limitations under the License. -require 'google/api_client/parser/json_parser' +require 'net/http' +require 'net/https' +require 'addressable/uri' module Google #:nodoc: class APIClient #:nodoc: - ## - # Factory for HTTP backed client requests. + + ## + # Provides a consistent interface by which to make HTTP requests using the + # Net::HTTP class. class HTTPTransport - - ## - # The default transport configuration values. These may be overridden - # simply by passing in the same key to the constructor. - DEFAULTS = { - :parser => :json_parser + ALLOWED_SCHEMES = ["http", "https"] + METHOD_MAPPING = { + # RFC 2616 + :options => Net::HTTP::Options, + :get => Net::HTTP::Get, + :head => Net::HTTP::Head, + :post => Net::HTTP::Post, + :put => Net::HTTP::Put, + :delete => Net::HTTP::Delete, + :trace => Net::HTTP::Trace, + # Other standards supported by Net::HTTP + :copy => Net::HTTP::Copy, + :lock => Net::HTTP::Lock, + :mkcol => Net::HTTP::Mkcol, + :move => Net::HTTP::Move, + :propfind => Net::HTTP::Propfind, + :proppatch => Net::HTTP::Proppatch, + :unlock => Net::HTTP::Unlock } ## - # The default implementations of various parsers. These may be overriden - # simply by passing the same key to the constructor. - PARSERS = { - :json_parser => JSONParser.new - } - - ## - # Creates a new HTTP request factory. # - # @param [Hash] options - # @return [Google::APIClient::Discovery] The HTTP request factory. def initialize(options={}) - @options = DEFAULTS.clone - @options.merge!(options) + # A mapping from authorities to Net::HTTP objects. + @connection_pool = options[:connection_pool] || {} + if options[:cert_store] + @cert_store = options[:cert_store] + else + @cert_store = OpenSSL::X509::Store.new + @cert_store.set_default_paths + end + end + + attr_reader :connection_pool + attr_reader :cert_store - # first check if user passed a parser then fallback on appropriate default - @parser = @options[@options[:parser]] || PARSERS[@options[:parser]] - unless @parser - raise ArgumentError, - 'Invalid :parser configuration.' + def build_request(method, uri, options={}) + # No type-checking here, but OK because we check against a whitelist + method = method.to_s.downcase.to_sym + uri = Addressable::URI.parse(uri).normalize + if !METHOD_MAPPING.keys.include?(method) + raise ArgumentError, "Unsupported HTTP method: #{method}" + end + headers = { + "Accept" => "application/json;q=1.0, */*;q=0.5" + }.merge(options[:headers] || {}) + + # TODO(bobaman) More stuff here to handle optional parameters like + # form data. + + body = options[:body] || "" + if body != "" + entity_body_defaults = { + "Content-Length" => body.size.to_s, + "Content-Type" => "application/json" + } + headers = entity_body_defaults.merge(headers) + end + return [method.to_s.upcase, uri.to_s, headers, [body]] + end + + def send_request(request) + retried = false + begin + method, uri, headers, body_wrapper = request + body = "" + body_wrapper.each do |chunk| + body += chunk + end + + uri = Addressable::URI.parse(uri).normalize + connection = self.connect_to(uri) + + # Translate to Net::HTTP request + request_class = METHOD_MAPPING[method.to_s.downcase.to_sym] + if !request_class + raise ArgumentError, + "Unsupported HTTP method: #{method.to_s.downcase.to_sym}" + end + net_http_request = request_class.new(uri.request_uri) + for key, value in headers + net_http_request[key] = value + end + net_http_request.body = body + response = connection.request(net_http_request) + + response_headers = {} + # We want the canonical header name. + # Note that Net::HTTP is lossy in that it downcases header names and + # then capitalizes them afterwards. + # This results in less-than-ideal behavior for headers like 'ETag'. + # Not much we can do about it. + response.canonical_each do |header, value| + response_headers[header] = value + end + # We use the Rack spec to trivially abstract the response format + return [response.code.to_i, response_headers, [response.body]] + rescue Errno::EPIPE, IOError, EOFError => e + # If there's a problem with the connection, finish and restart + if !retried && connection.started? + retried = true + connection.finish + connection.start + retry + else + raise e + end end end ## - # Returns configuration of the transport. + # Builds a connection to the authority given in the URI using the + # appropriate protocol. # - # @return [Hash] The configuration options. - def options - return @options - end - - ## - # Returns the parser used by the transport. - # - # @return The handle to the parser. - def parser - return @parser + # @param [Addressable::URI, #to_str] uri The URI to connect to. + def connect_to(uri) + uri = Addressable::URI.parse(uri).normalize + if !ALLOWED_SCHEMES.include?(uri.scheme) + raise ArgumentError, "Unsupported protocol: #{uri.scheme}" + end + connection = @connection_pool[uri.site] + unless connection + connection = Net::HTTP.new(uri.host, uri.inferred_port) + end + retried = false + begin + if uri.scheme == 'https' && !connection.started? + connection.use_ssl = true + if connection.respond_to?(:enable_post_connection_check=) + # Deals with a security vulnerability + connection.enable_post_connection_check = true + end + connection.verify_mode = OpenSSL::SSL::VERIFY_PEER + connection.cert_store = @cert_store + end + unless connection.started? + # Since we allow a connection pool to be passed in, we don't + # actually know this connection has been started yet. + connection.start + end + rescue Errno::EPIPE, IOError, EOFError => e + # If there's a problem with the connection, finish and restart + if !retried && connection.started? + retried = true + connection.finish + connection.start + retry + else + raise e + end + end + # Keep a reference to the connection around + @connection_pool[uri.site] = connection + return connection end + protected :connect_to end end end diff --git a/lib/google/api_client/version.rb b/lib/google/api_client/version.rb index 6b93ae8be..9efddc15c 100644 --- a/lib/google/api_client/version.rb +++ b/lib/google/api_client/version.rb @@ -12,17 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Used to prevent the class/module from being loaded more than once -unless defined? Google::APIClient::VERSION - module Google #:nodoc: - class APIClient #:nodoc: - module VERSION #:nodoc: - MAJOR = 0 - MINOR = 1 - TINY = 0 +module Google #:nodoc: + class APIClient #:nodoc: + module VERSION #:nodoc: + MAJOR = 0 + MINOR = 1 + TINY = 0 - STRING = [MAJOR, MINOR, TINY].join('.') - end + STRING = [MAJOR, MINOR, TINY].join('.') end end end diff --git a/spec/google/api_client/transport/http_transport_slow_spec.rb b/spec/google/api_client/transport/http_transport_slow_spec.rb new file mode 100644 index 000000000..5d48c2ae4 --- /dev/null +++ b/spec/google/api_client/transport/http_transport_slow_spec.rb @@ -0,0 +1,104 @@ +# Copyright 2010 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'spec_helper' + +require 'net/http' +require 'net/https' +require 'google/api_client/transport/http_transport' + +class AlwaysFail + def initialize(*args) + raise IOError, "This would never work." + end +end + +Google::APIClient::HTTPTransport::METHOD_MAPPING[:fail] = AlwaysFail + +describe Google::APIClient::HTTPTransport, 'in the default configuration' do + before do + @http = Google::APIClient::HTTPTransport.new + end + + it 'should send a GET request' do + request = @http.build_request(:get, "http://www.google.com/") + response = @http.send_request(request) + status, headers, body = response + status.should >= 100 + body.size.should > 0 + headers.size.should > 0 + end + + it 'should send a GET request using SSL' do + request = @http.build_request(:get, "https://www.google.com/") + response = @http.send_request(request) + status, headers, body = response + status.should >= 100 + body.size.should > 0 + headers.size.should > 0 + end + + it 'should send a POST request' do + request = @http.build_request( + :post, "http://www.google.com/", :body => "A Body." + ) + response = @http.send_request(request) + status, headers, body = response + status.should >= 100 + body.size.should > 0 + headers.size.should > 0 + end + + it 'should send a PUT request' do + request = @http.build_request( + :put, "http://www.google.com/", :body => "A Body." + ) + response = @http.send_request(request) + status, headers, body = response + status.should >= 100 + body.size.should > 0 + headers.size.should > 0 + end + + it 'should send a DELETE request' do + request = @http.build_request(:delete, "http://www.google.com/") + response = @http.send_request(request) + status, headers, body = response + status.should >= 100 + body.size.should > 0 + headers.size.should > 0 + end + + it 'should fail to send a FAIL request' do + (lambda do + request = @http.build_request(:fail, "http://www.google.com/") + response = @http.send_request(request) + end).should raise_error(IOError) + end + + it 'should fail to send a BOGUS request' do + (lambda do + response = @http.send_request( + ["BOGUS", "http://www.google.com/", {}, [""]] + ) + end).should raise_error(ArgumentError) + end + + it 'should fail to connect to a non-addressable URI' do + (lambda do + request = @http.build_request(:get, "bogus://www.google.com/") + response = @http.send_request(request) + end).should raise_error(ArgumentError) + end +end diff --git a/spec/google/api_client/transport/http_transport_spec.rb b/spec/google/api_client/transport/http_transport_spec.rb index 699d1c885..e970e8196 100644 --- a/spec/google/api_client/transport/http_transport_spec.rb +++ b/spec/google/api_client/transport/http_transport_spec.rb @@ -14,50 +14,89 @@ require 'spec_helper' +require 'net/http' +require 'net/https' require 'google/api_client/transport/http_transport' -require 'google/api_client/parser/json_parser' -describe Google::APIClient::HTTPTransport, 'with default configuration' do +def assemble_body_string(body) + body_string = "" + body.each do |chunk| + body_string += chunk + end + return body_string +end + +describe Google::APIClient::HTTPTransport, 'in the default configuration' do before do - @transport = Google::APIClient::HTTPTransport.new + @http = Google::APIClient::HTTPTransport.new end - it 'should use the default json parser' do - @transport.parser.should be_instance_of Google::APIClient::JSONParser + it 'should build a valid GET request' do + method, uri, headers, body = + @http.build_request(:get, "http://www.example.com/") + body_string = assemble_body_string(body) + method.should == "GET" + uri.should === "http://www.example.com/" + headers.keys.should_not include("Content-Length") + body_string.should == "" + end + + it 'should build a valid POST request' do + method, uri, headers, body = @http.build_request( + :post, "http://www.example.com/", :body => "A body." + ) + body_string = assemble_body_string(body) + method.should == "POST" + uri.should === "http://www.example.com/" + headers["Content-Length"].should == "7" + body_string.should == "A body." + end + + it 'should build a valid PUT request' do + method, uri, headers, body = @http.build_request( + :put, "http://www.example.com/", :body => "A body." + ) + body_string = assemble_body_string(body) + method.should == "PUT" + uri.should === "http://www.example.com/" + headers["Content-Length"].should == "7" + body_string.should == "A body." + end + + it 'should build a valid DELETE request' do + method, uri, headers, body = + @http.build_request(:delete, "http://www.example.com/") + body_string = assemble_body_string(body) + method.should == "DELETE" + uri.should === "http://www.example.com/" + headers.keys.should_not include("Content-Length") + body_string.should == "" + end + + it 'should not build a BOGUS request' do + (lambda do + @http.build_request(:bogus, "http://www.example.com/") + end).should raise_error(ArgumentError) end end -describe Google::APIClient::HTTPTransport, 'with custom pluggable parser' do +describe Google::APIClient::HTTPTransport, + 'with a certificate store and connection pool' do before do - class FakeJsonParser - end - - @transport = Google::APIClient::HTTPTransport.new(:json_parser => FakeJsonParser.new) - end - - it 'should use the custom parser' do - @transport.parser.should be_instance_of FakeJsonParser - end -end - -describe Google::APIClient::HTTPTransport, 'with new parser type' do - before do - class FakeNewParser - end - - @transport = Google::APIClient::HTTPTransport.new( - :parser => :new_parser, - :new_parser => FakeNewParser.new + @http = Google::APIClient::HTTPTransport.new( + :cert_store => OpenSSL::X509::Store.new, + :connection_pool => { + "http://www.example.com" => Net::HTTP.new("www.example.com", 80) + } ) end - it 'should use new parser type' do - @transport.parser.should be_instance_of FakeNewParser + it 'should have the correct certificate store' do + # TODO(bobaman) Write a real test + @http.cert_store.should_not == nil end -end -describe Google::APIClient::HTTPTransport, 'with illegal parser config' do - it 'should raise ArgumentError' do - lambda { Google::APIClient::HTTPTransport.new(:parser => :fakeclass) }.should raise_exception(ArgumentError) + it 'should have the correct connection pool' do + @http.connection_pool.keys.should include("http://www.example.com") end end diff --git a/spec/google/api_client_spec.rb b/spec/google/api_client_spec.rb new file mode 100644 index 000000000..7c7e29881 --- /dev/null +++ b/spec/google/api_client_spec.rb @@ -0,0 +1,49 @@ +# Copyright 2010 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'spec_helper' + +require 'google/api_client' +require 'google/api_client/version' +require 'google/api_client/parser/json_parser' +require 'google/api_client/auth/oauth_1' +require 'google/api_client/transport/http_transport' + + +describe Google::APIClient, 'with default configuration' do + before do + @client = Google::APIClient.new + end + + it 'should make its version number available' do + ::Google::APIClient::VERSION::STRING.should be_instance_of(String) + end + + it 'should use the default JSON parser' do + @client.parser.should be_instance_of(Google::APIClient::JSONParser) + end +end + +describe Google::APIClient, 'with custom pluggable parser' do + before do + class FakeJsonParser + end + + @client = Google::APIClient.new(:parser => FakeJsonParser.new) + end + + it 'should use the custom parser' do + @client.parser.should be_instance_of(FakeJsonParser) + end +end