From f336ab34a7e648c59c2725bed7959b3553529b7f Mon Sep 17 00:00:00 2001 From: Bob Aman Date: Fri, 29 Jul 2011 18:07:04 -0400 Subject: [PATCH] Major update, primarily to add pagination support. * Added Reference objects to encapsulate API calls. * Added Result objects to encapsulate API responses. * Changed the return value of APIClient#execute to Result. * Changed the method signature of APIClient#execute to support named params. * Added APIClient#execute! which throws exceptions on error. * Added automatic parsing code to better allow for complex nested structures. * Added error parser. * Added module for pagination in parsers. --- bin/google-api | 11 +- examples/buzz.rb | 8 +- lib/google/api_client.rb | 254 +++++++++--------- lib/google/api_client/discovery.rb | 18 +- lib/google/api_client/errors.rb | 10 + lib/google/api_client/parser.rb | 59 ++++ .../api_client/parsers/json/error_parser.rb | 34 +++ .../api_client/parsers/json/pagination.rb | 40 +++ lib/google/api_client/parsers/json_parser.rb | 102 ++++++- lib/google/api_client/reference.rb | 182 +++++++++++++ lib/google/api_client/result.rb | 116 ++++++++ lib/google/api_client/version.rb | 4 +- spec/google/api_client/discovery_spec.rb | 197 +++++++------- .../api_client/parsers/json_parser_spec.rb | 60 +++-- spec/google/api_client_spec.rb | 32 +-- 15 files changed, 821 insertions(+), 306 deletions(-) create mode 100644 lib/google/api_client/parser.rb create mode 100644 lib/google/api_client/parsers/json/error_parser.rb create mode 100644 lib/google/api_client/parsers/json/pagination.rb create mode 100644 lib/google/api_client/reference.rb create mode 100644 lib/google/api_client/result.rb diff --git a/bin/google-api b/bin/google-api index 150a6dd45..71d2811c7 100755 --- a/bin/google-api +++ b/bin/google-api @@ -447,7 +447,7 @@ HTML method.upcase! request = [method, uri.to_str, headers, [request_body]] request = client.generate_authenticated_request(:request => request) - response = client.transmit_request(request) + response = client.transmit(request) status, headers, body = response puts body exit(0) @@ -477,10 +477,13 @@ HTML parameters['xoauth_requestor_id'] = options[:requestor_id] end begin - response = client.execute( - method, parameters, request_body, headers + result = client.execute( + :api_method => method, + :parameters => parameters, + :merged_body => request_body, + :headers => headers ) - status, headers, body = response + status, headers, body = result.response puts body exit(0) rescue ArgumentError => e diff --git a/examples/buzz.rb b/examples/buzz.rb index 1f0a5da9d..631136a38 100644 --- a/examples/buzz.rb +++ b/examples/buzz.rb @@ -75,10 +75,10 @@ get '/oauth2callback' do end get '/' do - response = @client.execute( + result = @client.execute( @buzz.activities.list, - 'userId' => '@me', 'scope' => '@consumption', 'alt'=> 'json' + {'userId' => '@me', 'scope' => '@consumption', 'alt'=> 'json'} ) - status, headers, body = response - [status, {'Content-Type' => 'application/json'}, body] + status, _, _ = result.response + [status, {'Content-Type' => 'application/json'}, JSON.generate(result.data)] end diff --git a/lib/google/api_client.rb b/lib/google/api_client.rb index 2e062ac4d..725784cab 100644 --- a/lib/google/api_client.rb +++ b/lib/google/api_client.rb @@ -20,6 +20,8 @@ require 'stringio' require 'google/api_client/errors' require 'google/api_client/environment' require 'google/api_client/discovery' +require 'google/api_client/reference' +require 'google/api_client/result' module Google # TODO(bobaman): Document all this stuff. @@ -65,16 +67,6 @@ module Google 'google-api-ruby-client/' + VERSION::STRING + ' ' + ENV::OS_VERSION ).strip - # This is mostly a default for the sake of convenience. - # Unlike most other options, this one may be nil, so we check for - # the presence of the key rather than checking the value. - if options.has_key?("parser") - self.parser = options["parser"] - else - require 'google/api_client/parsers/json_parser' - # NOTE: Do not rely on this default value, as it may change - self.parser = Google::APIClient::JSONParser - end # The writer method understands a few Symbols and will generate useful # default authentication mechanisms. self.authorization = options["authorization"] || :oauth_2 @@ -94,33 +86,6 @@ module Google return self end - - ## - # Returns the parser used by the client. - # - # @return [#serialize, #parse] - # The parser used by the client. Any object that implements both a - # #serialize and a #parse method may be used. - # If nil, no parsing will be done. - attr_reader :parser - - ## - # Sets the parser used by the client. - # - # @param [#serialize, #parse] new_parser - # The parser used by the client. Any object that implements both a - # #serialize and a #parse method may be used. - # If nil, no parsing will be done. - def parser=(new_parser) - if new_parser && - !new_parser.respond_to?(:serialize) && - !new_parser.respond_to?(:parse) - raise TypeError, - 'Expected parser object to respond to #serialize and #parse.' - end - @parser = new_parser - end - ## # Returns the authorization mechanism used by the client. # @@ -280,7 +245,7 @@ module Google "Expected String or StringIO, got #{discovery_document.class}." end @discovery_documents["#{api}:#{version}"] = - JSON.parse(discovery_document) + ::JSON.parse(discovery_document) end ## @@ -291,16 +256,21 @@ module Google return @directory_document ||= (begin request_uri = self.directory_uri request = ['GET', request_uri, [], []] - response = self.transmit_request(request) + response = self.transmit(request) status, headers, body = response if status == 200 # TODO(bobaman) Better status code handling? - merged_body = StringIO.new - body.each do |chunk| - merged_body.write(chunk) + merged_body = body.inject(StringIO.new) do |accu, chunk| + accu.write(chunk) + accu end - merged_body.rewind - JSON.parse(merged_body.string) - else + ::JSON.parse(merged_body.string) + elsif status >= 400 && status < 500 + raise ClientError, + "Could not retrieve discovery document at: #{request_uri}" + elsif status >= 500 && status < 600 + raise ServerError, + "Could not retrieve discovery document at: #{request_uri}" + elsif status > 600 raise TransmissionError, "Could not retrieve discovery document at: #{request_uri}" end @@ -319,16 +289,21 @@ module Google return @discovery_documents["#{api}:#{version}"] ||= (begin request_uri = self.discovery_uri(api, version) request = ['GET', request_uri, [], []] - response = self.transmit_request(request) + response = self.transmit(request) status, headers, body = response if status == 200 # TODO(bobaman) Better status code handling? - merged_body = StringIO.new - body.each do |chunk| - merged_body.write(chunk) + merged_body = body.inject(StringIO.new) do |accu, chunk| + accu.write(chunk) + accu end - merged_body.rewind - JSON.parse(merged_body.string) - else + ::JSON.parse(merged_body.string) + elsif status >= 400 && status < 500 + raise ClientError, + "Could not retrieve discovery document at: #{request_uri}" + elsif status >= 500 && status < 600 + raise ServerError, + "Could not retrieve discovery document at: #{request_uri}" + elsif status > 600 raise TransmissionError, "Could not retrieve discovery document at: #{request_uri}" end @@ -344,7 +319,7 @@ module Google document_base = self.directory_uri if self.directory_document && self.directory_document['items'] self.directory_document['items'].map do |discovery_document| - ::Google::APIClient::API.new( + Google::APIClient::API.new( document_base, discovery_document ) @@ -373,7 +348,7 @@ module Google document_base = self.discovery_uri(api, version) discovery_document = self.discovery_document(api, version) if document_base && discovery_document - ::Google::APIClient::API.new( + Google::APIClient::API.new( document_base, discovery_document ) @@ -442,8 +417,6 @@ module Google # - :version — # The service version. Only used if api_method is a # String. Defaults to 'v1'. - # - :parser — - # The parser for the response. # - :authorization — # The authorization mechanism for the response. Used only if # :authenticated is true. @@ -457,17 +430,20 @@ module Google # # @example # request = client.generate_request( - # 'chili.activities.list', - # {'scope' => '@self', 'userId' => '@me', 'alt' => 'json'} + # :api_method => 'chili.activities.list', + # :parameters => + # {'scope' => '@self', 'userId' => '@me', 'alt' => 'json'} # ) # method, uri, headers, body = request - def generate_request( - api_method, parameters={}, body='', headers=[], options={}) + def generate_request(options={}) + # Note: The merge method on a Hash object will coerce an API Reference + # object into a Hash and merge with the default options. options={ - :parser => self.parser, :version => 'v1', :authorization => self.authorization }.merge(options) + # The Reference object is going to need this to do method ID lookups. + options[:client] = self # The default value for the :authenticated option depends on whether an # authorization mechanism has been set. if options[:authorization] @@ -475,27 +451,8 @@ module Google else options = {:authenticated => false}.merge(options) end - if api_method.kind_of?(String) || api_method.kind_of?(Symbol) - api_method = api_method.to_s - # This method of guessing the API is unreliable. This will fail for - # APIs where the first segment of the RPC name does not match the - # service name. However, this is a fallback mechanism anyway. - # Developers should be passing in a reference to the method, rather - # than passing in a string or symbol. This should raise an error - # in the case of a mismatch. - api = api_method[/^([^.]+)\./, 1] - api_method = self.discovered_method( - api_method, api, options[:version] - ) - elsif !api_method.kind_of?(::Google::APIClient::Method) - raise TypeError, - "Expected String, Symbol, or Google::APIClient::Method, " + - "got #{api_method.class}." - end - unless api_method - raise ArgumentError, "API method could not be found." - end - request = api_method.generate_request(parameters, body, headers) + reference = Google::APIClient::Reference.new(options) + request = reference.to_request if options[:authenticated] request = self.generate_authenticated_request(:request => request) end @@ -503,47 +460,13 @@ module Google end ## - # Generates a request and transmits it. + # Signs a request using the current authorization mechanism. # - # @param [Google::APIClient::Method, String] api_method - # The method object or the RPC name of the method being executed. - # @param [Hash, Array] parameters - # The parameters to send to the method. - # @param [String] body The body of the request. - # @param [Hash, Array] headers The HTTP headers for the request. - # @param [Hash] options - # The configuration parameters for the request. - # - :version — - # The service version. Only used if api_method is a - # String. Defaults to 'v1'. - # - :adapter — - # The HTTP adapter. - # - :parser — - # The parser for the response. - # - :authorization — - # The authorization mechanism for the response. Used only if - # :authenticated is true. - # - :authenticated — - # true if the request must be signed or otherwise - # authenticated, false - # otherwise. Defaults to true. + # @param [Hash] options The options to pass through. # - # @return [Array] The response from the API. - # - # @example - # response = client.execute( - # 'chili.activities.list', - # {'scope' => '@self', 'userId' => '@me', 'alt' => 'json'} - # ) - # status, headers, body = response - def execute(api_method, parameters={}, body='', headers=[], options={}) - request = self.generate_request( - api_method, parameters, body, headers, options - ) - return self.transmit_request( - request, - options[:adapter] || self.http_adapter - ) + # @return [Array] The signed or otherwise authenticated request. + def generate_authenticated_request(options={}) + return authorization.generate_authenticated_request(options) end ## @@ -553,7 +476,7 @@ module Google # @param [#transmit] adapter The HTTP adapter. # # @return [Array] The response from the server. - def transmit_request(request, adapter=self.http_adapter) + def transmit(request, adapter=self.http_adapter) if self.user_agent != nil # If there's no User-Agent header, set one. method, uri, headers, body = request @@ -577,13 +500,90 @@ module Google end ## - # Signs a request using the current authorization mechanism. + # Executes a request, wrapping it in a Result object. # - # @param [Hash] options The options to pass through. + # @param [Google::APIClient::Method, String] api_method + # The method object or the RPC name of the method being executed. + # @param [Hash, Array] parameters + # The parameters to send to the method. + # @param [String] body The body of the request. + # @param [Hash, Array] headers The HTTP headers for the request. + # @param [Hash] options + # The configuration parameters for the request. + # - :version — + # The service version. Only used if api_method is a + # String. Defaults to 'v1'. + # - :adapter — + # The HTTP adapter. + # - :authorization — + # The authorization mechanism for the response. Used only if + # :authenticated is true. + # - :authenticated — + # true if the request must be signed or otherwise + # authenticated, false + # otherwise. Defaults to true. # - # @return [Array] The signed or otherwise authenticated request. - def generate_authenticated_request(options={}) - return authorization.generate_authenticated_request(options) + # @return [Array] The response from the API. + # + # @example + # request = client.generate_request( + # :api_method => 'chili.activities.list', + # :parameters => + # {'scope' => '@self', 'userId' => '@me', 'alt' => 'json'} + # ) + def execute(*params) + # This block of code allows us to accept multiple parameter passing + # styles, and maintaining backwards compatibility. + if params.last.respond_to?(:to_hash) && params.size != 2 + # Hash options are tricky. If we get two arguments, it's ambiguous + # whether to treat them as API parameters or Hash options, but since + # it's rare to need to pass in options, we must assume that the + # developer wanted to pass API parameters. Prefer using named + # parameters to avoid this issue. Unnamed parameters should be + # considered syntactic sugar. + options = params.pop + else + options = {} + end + options[:api_method] = params.shift if params.size > 0 + options[:parameters] = params.shift if params.size > 0 + options[:merged_body] = params.shift if params.size > 0 + options[:headers] = params.shift if params.size > 0 + options[:client] = self + + reference = Google::APIClient::Reference.new(options) + request = self.generate_request(reference) + response = self.transmit( + request, + options[:adapter] || self.http_adapter + ) + return Google::APIClient::Result.new(reference, request, response) + end + + ## + # Same as Google::APIClient#execute, but raises an exception if there was + # an error. + # + # @see Google::APIClient#execute + def execute!(*params) + result = self.execute(*params) + status, _, _ = result.response + if result.data.respond_to?(:error) + # You're going to get a terrible error message if the response isn't + # parsed successfully as an error. + error_message = result.data.error + end + if status >= 400 && status < 500 + raise ClientError, + error_message || "A client error has occurred." + elsif status >= 500 && status < 600 + raise ServerError, + error_message || "A server error has occurred." + elsif status > 600 + raise TransmissionError, + error_message || "A transmission error has occurred." + end + return result end end end diff --git a/lib/google/api_client/discovery.rb b/lib/google/api_client/discovery.rb index 4e2f37096..45f348bbe 100644 --- a/lib/google/api_client/discovery.rb +++ b/lib/google/api_client/discovery.rb @@ -144,7 +144,7 @@ module Google def resources return @resources ||= ( (@discovery_document['resources'] || []).inject([]) do |accu, (k, v)| - accu << ::Google::APIClient::Resource.new(self.method_base, k, v) + accu << Google::APIClient::Resource.new(self.method_base, k, v) accu end ) @@ -158,7 +158,7 @@ module Google def methods return @methods ||= ( (@discovery_document['methods'] || []).inject([]) do |accu, (k, v)| - accu << ::Google::APIClient::Method.new(self.method_base, k, v) + accu << Google::APIClient::Method.new(self.method_base, k, v) accu end ) @@ -271,7 +271,7 @@ module Google def resources return @resources ||= ( (@discovery_document['resources'] || []).inject([]) do |accu, (k, v)| - accu << ::Google::APIClient::Resource.new(self.method_base, k, v) + accu << Google::APIClient::Resource.new(self.method_base, k, v) accu end ) @@ -284,7 +284,7 @@ module Google def methods return @methods ||= ( (@discovery_document['methods'] || []).inject([]) do |accu, (k, v)| - accu << ::Google::APIClient::Method.new(self.method_base, k, v) + accu << Google::APIClient::Method.new(self.method_base, k, v) accu end ) @@ -378,6 +378,14 @@ module Google return @discovery_document['id'] end + ## + # Returns the HTTP method or 'GET' if none is specified. + # + # @return [String] The HTTP method that will be used in the request. + def http_method + return @discovery_document['httpMethod'] || 'GET' + end + ## # Returns the URI template for the method. A parameter list can be # used to expand this into a URI. @@ -465,7 +473,7 @@ module Google if !headers.kind_of?(Array) && !headers.kind_of?(Hash) raise TypeError, "Expected Hash or Array, got #{headers.class}." end - method = @discovery_document['httpMethod'] || 'GET' + method = self.http_method uri = self.generate_uri(parameters) headers = headers.to_a if headers.kind_of?(Hash) return [method, uri.to_str, headers, [body]] diff --git a/lib/google/api_client/errors.rb b/lib/google/api_client/errors.rb index 00d1995e9..0c6e852b3 100644 --- a/lib/google/api_client/errors.rb +++ b/lib/google/api_client/errors.rb @@ -26,5 +26,15 @@ module Google # invalid parameter values. class ValidationError < StandardError end + + ## + # A 4xx class HTTP error occurred. + class ClientError < TransmissionError + end + + ## + # A 5xx class HTTP error occurred. + class ServerError < TransmissionError + end end end diff --git a/lib/google/api_client/parser.rb b/lib/google/api_client/parser.rb new file mode 100644 index 000000000..e91351673 --- /dev/null +++ b/lib/google/api_client/parser.rb @@ -0,0 +1,59 @@ +# 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 'json' + +module Google + class APIClient + module Parser + def content_type(content_type) + @@content_type_mapping ||= {} + @@content_type_mapping[content_type] = self + end + + def self.match_content_type(content_type) + # TODO(bobaman): Do this more efficiently. + mime_type_regexp = /^([^\/]+)(?:\/([^+]+\+)?([^;]+))?(?:;.*)?$/ + if @@content_type_mapping[content_type] + # Exact match + return @@content_type_mapping[content_type] + else + media_type, extension, sub_type = + content_type.scan(mime_type_regexp)[0] + for pattern, parser in @@content_type_mapping + # We want to match on subtype first + pattern_media_type, pattern_extension, pattern_sub_type = + pattern.scan(mime_type_regexp)[0] + next if pattern_extension != nil + if media_type == pattern_media_type && sub_type == pattern_sub_type + return parser + end + end + for pattern, parser in @@content_type_mapping + # We failed to match on the subtype + # Try to match only on the media type + pattern_media_type, pattern_extension, pattern_sub_type = + pattern.scan(mime_type_regexp)[0] + next if pattern_extension != nil || pattern_sub_type != nil + if media_type == pattern_media_type + return parser + end + end + end + return nil + end + end + end +end diff --git a/lib/google/api_client/parsers/json/error_parser.rb b/lib/google/api_client/parsers/json/error_parser.rb new file mode 100644 index 000000000..719317a39 --- /dev/null +++ b/lib/google/api_client/parsers/json/error_parser.rb @@ -0,0 +1,34 @@ +# 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 'google/api_client/parsers/json_parser' + +module Google + class APIClient + module JSON + ## + # A module which provides a parser for error responses. + class ErrorParser + include Google::APIClient::JSONParser + + matches_fields 'error' + + def error + return self['error']['message'] + end + end + end + end +end diff --git a/lib/google/api_client/parsers/json/pagination.rb b/lib/google/api_client/parsers/json/pagination.rb new file mode 100644 index 000000000..cc4e2aa5a --- /dev/null +++ b/lib/google/api_client/parsers/json/pagination.rb @@ -0,0 +1,40 @@ +# 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 'google/api_client/parsers/json_parser' + +module Google + class APIClient + module JSON + ## + # A module which provides a paginated parser. + module Pagination + def self.included(parser) + parser.class_eval do + include Google::APIClient::JSONParser + end + end + + def next_page_token + return self["nextPageToken"] + end + + def prev_page_token + return self["prevPageToken"] + end + end + end + end +end diff --git a/lib/google/api_client/parsers/json_parser.rb b/lib/google/api_client/parsers/json_parser.rb index f686ddfd5..ff828bf33 100644 --- a/lib/google/api_client/parsers/json_parser.rb +++ b/lib/google/api_client/parsers/json_parser.rb @@ -14,27 +14,105 @@ require 'json' +require 'google/api_client/parser' module Google class APIClient ## - # Provides a consistent interface by which to parse request and response - # content. - # TODO(mattpok): ensure floats, URLs, dates are parsed correctly + # Provides a module which all other parsers should include. module JSONParser + extend Parser + content_type 'application/json' - def self.serialize(hash) - # JSON parser used can accept arrays as well, but we will limit - # to only allow hash to JSON string parsing to keep a simple interface - unless hash.instance_of? Hash - raise ArgumentError, - "JSON generate expected a Hash but got a #{hash.class}." + module Matcher + def conditions + @conditions ||= [] + end + + def matches_kind(kind) + self.matches_field_value(:kind, kind) + end + + def matches_fields(fields) + self.conditions << [:fields, fields] + end + + def matches_field_value(field, value) + self.conditions << [:field_value, field, value] end - return JSON.generate(hash) end - def self.parse(json_string) - return JSON.parse(json_string) + def self.parsers + @parsers ||= [] + end + + ## + # This method ensures that all parsers auto-register themselves. + def self.included(parser) + self.parsers << parser + parser.extend(Matcher) + end + + def initialize(data) + @data = data.kind_of?(Hash) ? data : ::JSON.parse(data) + end + + def [](key) + return self.json[key] + end + + def json + if @data + data = @data + elsif self.respond_to?(:data) + data = self.data + else + raise TypeError, "Parser did not provide access to raw data." + end + return data + end + + ## + # Matches a parser to the data. + def self.match(data) + for parser in self.parsers + conditions_met = true + for condition in (parser.conditions.sort_by { |c| c.size }).reverse + condition_type, *params = condition + case condition_type + when :fields + for field in params + if !data.has_key?(field) + conditions_met = false + break + end + end + when :field_values + field, value = params + if data[field] != value + conditions_met = false + break + end + else + raise ArgumentError, "Unknown condition type: #{condition_type}" + end + break if !conditions_met + end + if conditions_met + return parser + end + end + return nil + end + + def self.parse(json) + data = json.kind_of?(Hash) ? json : ::JSON.parse(json) + parser = self.match(data) + if parser + return parser.new(data) + else + return data + end end end end diff --git a/lib/google/api_client/reference.rb b/lib/google/api_client/reference.rb new file mode 100644 index 000000000..97d686709 --- /dev/null +++ b/lib/google/api_client/reference.rb @@ -0,0 +1,182 @@ +# 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 'stringio' +require 'addressable/uri' +require 'google/api_client/discovery' + +module Google + class APIClient + class Reference + def initialize(options={}) + # We only need this to do lookups on method ID String values + # It's optional, but method ID lookups will fail if the client is + # omitted. + @client = options[:client] + @version = options[:version] || 'v1' + + self.api_method = options[:api_method] + self.parameters = options[:parameters] || {} + self.headers = options[:headers] || [] + if options[:body] + self.body = options[:body] + elsif options[:merged_body] + self.merged_body = options[:merged_body] + else + self.merged_body = '' + end + unless self.api_method + self.http_method = options[:http_method] || 'GET' + self.uri = options[:uri] + end + end + + def api_method + return @api_method + end + + def api_method=(new_api_method) + if new_api_method.kind_of?(Google::APIClient::Method) || + new_api_method == nil + @api_method = new_api_method + elsif new_api_method.respond_to?(:to_str) || + new_api_method.kind_of?(Symbol) + unless @client + raise ArgumentError, + "API method lookup impossible without client instance." + end + new_api_method = new_api_method.to_s + # This method of guessing the API is unreliable. This will fail for + # APIs where the first segment of the RPC name does not match the + # service name. However, this is a fallback mechanism anyway. + # Developers should be passing in a reference to the method, rather + # than passing in a string or symbol. This should raise an error + # in the case of a mismatch. + api = new_api_method[/^([^.]+)\./, 1] + @api_method = @client.discovered_method( + new_api_method, api, @version + ) + if @api_method + # Ditch the client reference, we won't need it again. + @client = nil + else + raise ArgumentError, "API method could not be found." + end + else + raise TypeError, + "Expected Google::APIClient::Method, got #{new_api_method.class}." + end + end + + def parameters + return @parameters + end + + def parameters=(new_parameters) + # No type-checking needed, the Method class handles this. + @parameters = new_parameters + end + + def body + return @body + end + + def body=(new_body) + if new_body.respond_to?(:each) + @body = new_body + else + raise TypeError, "Expected body to respond to :each." + end + end + + def merged_body + return (self.body.inject(StringIO.new) do |accu, chunk| + accu.write(chunk) + accu + end).string + end + + def merged_body=(new_merged_body) + if new_merged_body.respond_to?(:string) + new_merged_body = new_merged_body.string + elsif new_merged_body.respond_to?(:to_str) + new_merged_body = new_merged_body.to_str + else + raise TypeError, + "Expected String or StringIO, got #{new_merged_body.class}." + end + self.body = [new_merged_body] + end + + def headers + return @headers ||= [] + end + + def headers=(new_headers) + if new_headers.kind_of?(Array) || new_headers.kind_of?(Hash) + @headers = new_headers + else + raise TypeError, "Expected Hash or Array, got #{new_headers.class}." + end + end + + def http_method + return @http_method ||= self.api_method.http_method + end + + def http_method=(new_http_method) + if new_http_method.kind_of?(Symbol) + @http_method = new_http_method.to_s.upcase + elsif new_http_method.respond_to?(:to_str) + @http_method = new_http_method.to_str.upcase + else + raise TypeError, + "Expected String or Symbol, got #{new_http_method.class}." + end + end + + def uri + return @uri ||= self.api_method.generate_uri(self.parameters) + end + + def uri=(new_uri) + @uri = Addressable::URI.parse(new_uri) + end + + def to_request + if self.api_method + return self.api_method.generate_request( + self.parameters, self.merged_body, self.headers + ) + else + return [self.http_method, self.uri, self.headers, self.body] + end + end + + def to_hash + options = {} + if self.api_method + options[:api_method] = self.api_method + options[:parameters] = self.parameters + else + options[:http_method] = self.http_method + options[:uri] = self.uri + end + options[:headers] = self.headers + options[:body] = self.body + return options + end + end + end +end diff --git a/lib/google/api_client/result.rb b/lib/google/api_client/result.rb new file mode 100644 index 000000000..f78cb3241 --- /dev/null +++ b/lib/google/api_client/result.rb @@ -0,0 +1,116 @@ +# 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 'google/api_client/parsers/json_parser' + +module Google + class APIClient + ## + # This class wraps a result returned by an API call. + class Result + def initialize(reference, request, response) + @reference = reference + @request = request + @response = response + end + + attr_reader :reference + + attr_reader :request + + attr_reader :response + + def status + return @response[0] + end + + def headers + return @response[1] + end + + def body + return @body ||= (begin + response_body = @response[2] + merged_body = (response_body.inject(StringIO.new) do |accu, chunk| + accu.write(chunk) + accu + end).string + end) + end + + def data + return @data ||= (begin + _, content_type = self.headers.detect do |h, v| + h.downcase == 'Content-Type'.downcase + end + parser_type = + Google::APIClient::Parser.match_content_type(content_type) + parser_type.parse(self.body) + end) + end + + def pagination_type + return :token + end + + def page_token_param + return "pageToken" + end + + def next_page_token + if self.data.respond_to?(:next_page_token) + return self.data.next_page_token + elsif self.data.respond_to?(:[]) + return self.data["nextPageToken"] + else + raise TypeError, "Data object did not respond to #next_page_token." + end + end + + def next_page + merged_parameters = Hash[self.reference.parameters].merge({ + self.page_token_param => self.next_page_token + }) + # Because References can be coerced to Hashes, we can merge them, + # preserving all context except the API method parameters that we're + # using for pagination. + return Google::APIClient::Reference.new( + Hash[self.reference].merge(:parameters => merged_parameters) + ) + end + + def prev_page_token + if self.data.respond_to?(:prev_page_token) + return self.data.prev_page_token + elsif self.data.respond_to?(:[]) + return self.data["prevPageToken"] + else + raise TypeError, "Data object did not respond to #next_page_token." + end + end + + def prev_page + merged_parameters = Hash[self.reference.parameters].merge({ + self.page_token_param => self.prev_page_token + }) + # Because References can be coerced to Hashes, we can merge them, + # preserving all context except the API method parameters that we're + # using for pagination. + return Google::APIClient::Reference.new( + Hash[self.reference].merge(:parameters => merged_parameters) + ) + end + end + end +end diff --git a/lib/google/api_client/version.rb b/lib/google/api_client/version.rb index f42feedd0..8ac950717 100644 --- a/lib/google/api_client/version.rb +++ b/lib/google/api_client/version.rb @@ -16,8 +16,8 @@ module Google class APIClient module VERSION - MAJOR = 0 - MINOR = 2 + MAJOR = 1 + MINOR = 0 TINY = 0 STRING = [MAJOR, MINOR, TINY].join('.') diff --git a/spec/google/api_client/discovery_spec.rb b/spec/google/api_client/discovery_spec.rb index bff4685ce..cd4606807 100644 --- a/spec/google/api_client/discovery_spec.rb +++ b/spec/google/api_client/discovery_spec.rb @@ -66,6 +66,9 @@ describe Google::APIClient do describe 'with the prediction API' do before do @client.authorization = nil + # The prediction API no longer exposes a v1, so we have to be + # careful about looking up the wrong API version. + @prediction = @client.discovered_api('prediction', 'v1.2') end it 'should correctly determine the discovery URI' do @@ -74,45 +77,39 @@ describe Google::APIClient do end it 'should correctly generate API objects' do - @client.discovered_api('prediction').name.should == 'prediction' - @client.discovered_api('prediction').version.should == 'v1' - @client.discovered_api(:prediction).name.should == 'prediction' - @client.discovered_api(:prediction).version.should == 'v1' + @client.discovered_api('prediction', 'v1.2').name.should == 'prediction' + @client.discovered_api('prediction', 'v1.2').version.should == 'v1.2' + @client.discovered_api(:prediction, 'v1.2').name.should == 'prediction' + @client.discovered_api(:prediction, 'v1.2').version.should == 'v1.2' end it 'should discover methods' do @client.discovered_method( - 'prediction.training.insert', 'prediction' + 'prediction.training.insert', 'prediction', 'v1.2' ).name.should == 'insert' @client.discovered_method( - :'prediction.training.insert', :prediction + :'prediction.training.insert', :prediction, 'v1.2' ).name.should == 'insert' - end - - it 'should discover methods' do @client.discovered_method( - 'prediction.training.delete', 'prediction', 'v1.1' + 'prediction.training.delete', 'prediction', 'v1.2' ).name.should == 'delete' end it 'should not find methods that are not in the discovery document' do @client.discovered_method( - 'prediction.training.delete', 'prediction', 'v1' - ).should == nil - @client.discovered_method( - 'prediction.bogus', 'prediction', 'v1' + 'prediction.bogus', 'prediction', 'v1.2' ).should == nil end it 'should raise an error for bogus methods' do (lambda do - @client.discovered_method(42, 'prediction', 'v1') + @client.discovered_method(42, 'prediction', 'v1.2') end).should raise_error(TypeError) end it 'should raise an error for bogus methods' do (lambda do - @client.generate_request(@client.discovered_api('prediction')) + @client.generate_request(@client.discovered_api('prediction', 'v1.2')) end).should raise_error(TypeError) end @@ -123,49 +120,50 @@ describe Google::APIClient do it 'should generate valid requests' do request = @client.generate_request( - 'prediction.training.insert', - {'data' => '12345', } + :api_method => @prediction.training.insert, + :parameters => {'data' => '12345', } ) method, uri, headers, body = request method.should == 'POST' uri.should == - 'https://www.googleapis.com/prediction/v1/training?data=12345' + 'https://www.googleapis.com/prediction/v1.2/training?data=12345' (headers.inject({}) { |h,(k,v)| h[k]=v; h }).should == {} body.should respond_to(:each) end it 'should generate requests against the correct URIs' do request = @client.generate_request( - :'prediction.training.insert', - {'data' => '12345'} + :api_method => @prediction.training.insert, + :parameters => {'data' => '12345'} ) method, uri, headers, body = request uri.should == - 'https://www.googleapis.com/prediction/v1/training?data=12345' + 'https://www.googleapis.com/prediction/v1.2/training?data=12345' end it 'should generate requests against the correct URIs' do - prediction = @client.discovered_api('prediction', 'v1') request = @client.generate_request( - prediction.training.insert, - {'data' => '12345'} + :api_method => @prediction.training.insert, + :parameters => {'data' => '12345'} ) method, uri, headers, body = request uri.should == - 'https://www.googleapis.com/prediction/v1/training?data=12345' + 'https://www.googleapis.com/prediction/v1.2/training?data=12345' end it 'should allow modification to the base URIs for testing purposes' do - prediction = @client.discovered_api('prediction', 'v1') + prediction = @client.discovered_api('prediction', 'v1.2') prediction.method_base = - 'https://testing-domain.googleapis.com/prediction/v1/' + 'https://testing-domain.googleapis.com/prediction/v1.2/' request = @client.generate_request( - prediction.training.insert, - {'data' => '123'} + :api_method => prediction.training.insert, + :parameters => {'data' => '123'} ) method, uri, headers, body = request - uri.should == - 'https://testing-domain.googleapis.com/prediction/v1/training?data=123' + uri.should == ( + 'https://testing-domain.googleapis.com/' + + 'prediction/v1.2/training?data=123' + ) end it 'should generate OAuth 1 requests' do @@ -173,8 +171,8 @@ describe Google::APIClient do @client.authorization.token_credential_key = '12345' @client.authorization.token_credential_secret = '12345' request = @client.generate_request( - 'prediction.training.insert', - {'data' => '12345'} + :api_method => @prediction.training.insert, + :parameters => {'data' => '12345'} ) method, uri, headers, body = request headers = headers.inject({}) { |h,(k,v)| h[k]=v; h } @@ -186,8 +184,8 @@ describe Google::APIClient do @client.authorization = :oauth_2 @client.authorization.access_token = '12345' request = @client.generate_request( - 'prediction.training.insert', - {'data' => '12345'} + :api_method => @prediction.training.insert, + :parameters => {'data' => '12345'} ) method, uri, headers, body = request headers = headers.inject({}) { |h,(k,v)| h[k]=v; h } @@ -199,24 +197,47 @@ describe Google::APIClient do @client.authorization = :oauth_1 @client.authorization.token_credential_key = '12345' @client.authorization.token_credential_secret = '12345' - response = @client.execute( - 'prediction.training.insert', + result = @client.execute( + @prediction.training.insert, {'data' => '12345'} ) - status, headers, body = response + status, headers, body = result.response status.should == 401 end it 'should not be able to execute improperly authorized requests' do @client.authorization = :oauth_2 @client.authorization.access_token = '12345' - response = @client.execute( - 'prediction.training.insert', + result = @client.execute( + @prediction.training.insert, {'data' => '12345'} ) - status, headers, body = response + status, headers, body = result.response status.should == 401 end + + it 'should not be able to execute improperly authorized requests' do + (lambda do + @client.authorization = :oauth_1 + @client.authorization.token_credential_key = '12345' + @client.authorization.token_credential_secret = '12345' + result = @client.execute!( + @prediction.training.insert, + {'data' => '12345'} + ) + end).should raise_error(Google::APIClient::ClientError) + end + + it 'should not be able to execute improperly authorized requests' do + (lambda do + @client.authorization = :oauth_2 + @client.authorization.access_token = '12345' + result = @client.execute!( + @prediction.training.insert, + {'data' => '12345'} + ) + end).should raise_error(Google::APIClient::ClientError) + end end describe 'with the buzz API' do @@ -251,22 +272,18 @@ describe Google::APIClient do it 'should fail for string RPC names that do not match API name' do (lambda do @client.generate_request( - 'chili.activities.list', - {'alt' => 'json'}, - '', - [], - {:signed => false} + :api_method => 'chili.activities.list', + :parameters => {'alt' => 'json'}, + :authenticated => false ) end).should raise_error(Google::APIClient::TransmissionError) end it 'should generate requests against the correct URIs' do request = @client.generate_request( - @buzz.activities.list, - {'userId' => 'hikingfan', 'scope' => '@public'}, - '', - [], - {:signed => false} + :api_method => @buzz.activities.list, + :parameters => {'userId' => 'hikingfan', 'scope' => '@public'}, + :authenticated => false ) method, uri, headers, body = request uri.should == @@ -276,11 +293,9 @@ describe Google::APIClient do it 'should correctly validate parameters' do (lambda do @client.generate_request( - @buzz.activities.list, - {'alt' => 'json'}, - '', - [], - {:signed => false} + :api_method => @buzz.activities.list, + :parameters => {'alt' => 'json'}, + :authenticated => false ) end).should raise_error(ArgumentError) end @@ -288,26 +303,33 @@ describe Google::APIClient do it 'should correctly validate parameters' do (lambda do @client.generate_request( - @buzz.activities.list, - {'userId' => 'hikingfan', 'scope' => '@bogus'}, - '', - [], - {:signed => false} + :api_method => @buzz.activities.list, + :parameters => {'userId' => 'hikingfan', 'scope' => '@bogus'}, + :authenticated => false ) end).should raise_error(ArgumentError) end it 'should be able to execute requests without authorization' do - response = @client.execute( + result = @client.execute( @buzz.activities.list, {'alt' => 'json', 'userId' => 'hikingfan', 'scope' => '@public'}, '', [], - {:signed => false} + :authenticated => false ) - status, headers, body = response + status, headers, body = result.response status.should == 200 end + + it 'should not be able to execute requests without authorization' do + result = @client.execute( + @buzz.activities.list, + 'alt' => 'json', 'userId' => '@me', 'scope' => '@self' + ) + status, headers, body = result.response + status.should == 401 + end end describe 'with the latitude API' do @@ -338,11 +360,8 @@ describe Google::APIClient do it 'should generate requests against the correct URIs' do request = @client.generate_request( - 'latitude.currentLocation.get', - {}, - '', - [], - {:signed => false} + :api_method => 'latitude.currentLocation.get', + :authenticated => false ) method, uri, headers, body = request uri.should == @@ -351,11 +370,8 @@ describe Google::APIClient do it 'should generate requests against the correct URIs' do request = @client.generate_request( - @latitude.current_location.get, - {}, - '', - [], - {:signed => false} + :api_method => @latitude.current_location.get, + :authenticated => false ) method, uri, headers, body = request uri.should == @@ -363,14 +379,11 @@ describe Google::APIClient do end it 'should not be able to execute requests without authorization' do - response = @client.execute( - 'latitude.currentLocation.get', - {}, - '', - [], - {:signed => false} + result = @client.execute( + :api_method => 'latitude.currentLocation.get', + :authenticated => false ) - status, headers, body = response + status, headers, body = result.response status.should == 401 end end @@ -403,11 +416,8 @@ describe Google::APIClient do it 'should generate requests against the correct URIs' do request = @client.generate_request( - 'moderator.profiles.get', - {}, - '', - [], - {:signed => false} + :api_method => 'moderator.profiles.get', + :authenticated => false ) method, uri, headers, body = request uri.should == @@ -416,11 +426,8 @@ describe Google::APIClient do it 'should generate requests against the correct URIs' do request = @client.generate_request( - @moderator.profiles.get, - {}, - '', - [], - {:signed => false} + :api_method => @moderator.profiles.get, + :authenticated => false ) method, uri, headers, body = request uri.should == @@ -428,14 +435,14 @@ describe Google::APIClient do end it 'should not be able to execute requests without authorization' do - response = @client.execute( + result = @client.execute( 'moderator.profiles.get', {}, '', [], - {:signed => false} + {:authenticated => false} ) - status, headers, body = response + status, headers, body = result.response status.should == 401 end end diff --git a/spec/google/api_client/parsers/json_parser_spec.rb b/spec/google/api_client/parsers/json_parser_spec.rb index d30242233..c7361ccf6 100644 --- a/spec/google/api_client/parsers/json_parser_spec.rb +++ b/spec/google/api_client/parsers/json_parser_spec.rb @@ -16,36 +16,40 @@ require 'spec_helper' require 'json' require 'google/api_client/parsers/json_parser' +require 'google/api_client/parsers/json/error_parser' +require 'google/api_client/parsers/json/pagination' -describe Google::APIClient::JSONParser, 'generates json from hash' do +describe Google::APIClient::JSONParser, 'with error data' do before do - @parser = Google::APIClient::JSONParser - end - - it 'should translate simple hash to JSON string' do - @parser.serialize('test' => 23).should == '{"test":23}' - end - - it 'should translate simple nested into to nested JSON string' do - @parser.serialize({ - 'test' => 23, 'test2' => {'foo' => 'baz', 12 => 3.14 } - }).should == - '{"test2":{"12":3.14,"foo":"baz"},"test":23}' - end -end - -describe Google::APIClient::JSONParser, 'parses json string into hash' do - before do - @parser = Google::APIClient::JSONParser - end - - it 'should parse simple json string into hash' do - @parser.parse('{"test":23}').should == {'test' => 23} - end - - it 'should parse nested json object into hash' do - @parser.parse('{"test":23, "test2":{"bar":"baz", "foo":3.14}}').should == { - 'test' => 23, 'test2' => {'bar' => 'baz', 'foo' => 3.14} + @data = { + 'error' => { + 'code' => 401, + 'message' => 'Token invalid - Invalid AuthSub token.', + 'errors' => [ + { + 'location' => 'Authorization', + 'domain' => 'global', + 'locationType' => 'header', + 'reason' => 'authError', + 'message' => 'Token invalid - Invalid AuthSub token.' + } + ] + } } end + + it 'should correctly match as an error' do + parser = Google::APIClient::JSONParser.match(@data) + parser.should == Google::APIClient::JSON::ErrorParser + end + + it 'should be automatically handled as an error when parsed' do + data = Google::APIClient::JSONParser.parse(@data) + data.should be_kind_of(Google::APIClient::JSON::ErrorParser) + end + + it 'should correctly expose error message' do + data = Google::APIClient::JSONParser.parse(@data) + data.error.should == 'Token invalid - Invalid AuthSub token.' + end end diff --git a/spec/google/api_client_spec.rb b/spec/google/api_client_spec.rb index 30bfa1d10..78b36ba96 100644 --- a/spec/google/api_client_spec.rb +++ b/spec/google/api_client_spec.rb @@ -36,7 +36,7 @@ shared_examples_for 'configurable user agent' do it 'should not allow the user agent to be used with bogus values' do (lambda do @client.user_agent = 42 - @client.transmit_request( + @client.transmit( ['GET', 'http://www.google.com/', [], []] ) end).should raise_error(TypeError) @@ -53,7 +53,7 @@ shared_examples_for 'configurable user agent' do end [200, [], ['']] end - @client.transmit_request(request, adapter) + @client.transmit(request, adapter) end end @@ -63,11 +63,7 @@ describe Google::APIClient do 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(Google::APIClient::JSONParser) + Google::APIClient::VERSION::STRING.should be_instance_of(String) end it 'should default to OAuth 2' do @@ -104,26 +100,4 @@ describe Google::APIClient do # TODO it_should_behave_like 'configurable user agent' end - - describe 'with custom pluggable parser' do - before do - class FakeJsonParser - def serialize(value) - return "42" - end - - def parse(value) - return 42 - end - end - - @client.parser = FakeJsonParser.new - end - - it 'should use the custom parser' do - @client.parser.should be_instance_of(FakeJsonParser) - end - - it_should_behave_like 'configurable user agent' - end end