More carefully resolving the discovery-of-discovery issue.
[arvados.git] / lib / google / api_client.rb
1 # Copyright 2010 Google Inc.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 #      http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15
16 gem 'faraday', '~> 0.7.0'
17 require 'faraday'
18 require 'faraday/utils'
19 require 'multi_json'
20 require 'stringio'
21
22 require 'google/api_client/version'
23 require 'google/api_client/errors'
24 require 'google/api_client/environment'
25 require 'google/api_client/discovery'
26 require 'google/api_client/reference'
27 require 'google/api_client/result'
28
29
30 module Google
31   # TODO(bobaman): Document all this stuff.
32
33
34   ##
35   # This class manages APIs communication.
36   class APIClient
37     ##
38     # Creates a new Google API client.
39     #
40     # @param [Hash] options The configuration parameters for the client.
41     # @option options [Symbol, #generate_authenticated_request] :authorization
42     #   (:oauth_1)
43     #   The authorization mechanism used by the client.  The following
44     #   mechanisms are supported out-of-the-box:
45     #   <ul>
46     #     <li><code>:two_legged_oauth_1</code></li>
47     #     <li><code>:oauth_1</code></li>
48     #     <li><code>:oauth_2</code></li>
49     #   </ul>
50     # @option options [String] :application_name
51     #   The name of the application using the client.
52     # @option options [String] :application_version
53     #   The version number of the application using the client.
54     # @option options [String] :user_agent
55     #   ("{app_name} google-api-ruby-client/{version} {os_name}/{os_version}")
56     #   The user agent used by the client.  Most developers will want to
57     #   leave this value alone and use the `:application_name` option instead.
58     # @option options [String] :host ("www.googleapis.com")
59     #   The API hostname used by the client. This rarely needs to be changed.
60     # @option options [String] :port (443)
61     #   The port number used by the client. This rarely needs to be changed.
62     # @option options [String] :discovery_path ("/discovery/v1")
63     #   The discovery base path. This rarely needs to be changed.
64     def initialize(options={})
65       # Normalize key to String to allow indifferent access.
66       options = options.inject({}) do |accu, (key, value)|
67         accu[key.to_s] = value
68         accu
69       end
70       # Almost all API usage will have a host of 'www.googleapis.com'.
71       self.host = options["host"] || 'www.googleapis.com'
72       self.port = options["port"] || 443
73       self.discovery_path = options["discovery_path"] || '/discovery/v1'
74
75       # Most developers will want to leave this value alone and use the
76       # application_name option.
77       application_string = (
78         options["application_name"] ? (
79           "#{options["application_name"]}/" +
80           "#{options["application_version"] || '0.0.0'}"
81         ) : ""
82       )
83       self.user_agent = options["user_agent"] || (
84         "#{application_string} " +
85         "google-api-ruby-client/#{VERSION::STRING} " +
86          ENV::OS_VERSION
87       ).strip
88       # The writer method understands a few Symbols and will generate useful
89       # default authentication mechanisms.
90       self.authorization = options.key?("authorization") ? options["authorization"] : :oauth_2
91       self.key = options["key"]
92       self.user_ip = options["user_ip"]
93       @discovery_uris = {}
94       @discovery_documents = {}
95       @discovered_apis = {}
96       return self
97     end
98
99     ##
100     # Returns the authorization mechanism used by the client.
101     #
102     # @return [#generate_authenticated_request] The authorization mechanism.
103     attr_reader :authorization
104
105     ##
106     # Sets the authorization mechanism used by the client.
107     #
108     # @param [#generate_authenticated_request] new_authorization
109     #   The new authorization mechanism.
110     def authorization=(new_authorization)
111       case new_authorization
112       when :oauth_1, :oauth
113         gem 'signet', '~> 0.3.0'
114         require 'signet/oauth_1/client'
115         # NOTE: Do not rely on this default value, as it may change
116         new_authorization = Signet::OAuth1::Client.new(
117           :temporary_credential_uri =>
118             'https://www.google.com/accounts/OAuthGetRequestToken',
119           :authorization_uri =>
120             'https://www.google.com/accounts/OAuthAuthorizeToken',
121           :token_credential_uri =>
122             'https://www.google.com/accounts/OAuthGetAccessToken',
123           :client_credential_key => 'anonymous',
124           :client_credential_secret => 'anonymous'
125         )
126       when :two_legged_oauth_1, :two_legged_oauth
127         gem 'signet', '~> 0.3.0'
128         require 'signet/oauth_1/client'
129         # NOTE: Do not rely on this default value, as it may change
130         new_authorization = Signet::OAuth1::Client.new(
131           :client_credential_key => nil,
132           :client_credential_secret => nil,
133           :two_legged => true
134         )
135       when :oauth_2
136         gem 'signet', '~> 0.3.0'
137         require 'signet/oauth_2/client'
138         # NOTE: Do not rely on this default value, as it may change
139         new_authorization = Signet::OAuth2::Client.new(
140           :authorization_uri =>
141             'https://accounts.google.com/o/oauth2/auth',
142           :token_credential_uri =>
143             'https://accounts.google.com/o/oauth2/token'
144         )
145       when nil
146         # No authorization mechanism
147       else
148         if !new_authorization.respond_to?(:generate_authenticated_request)
149           raise TypeError,
150             'Expected authorization mechanism to respond to ' +
151             '#generate_authenticated_request.'
152         end
153       end
154       @authorization = new_authorization
155       return @authorization
156     end
157
158     ##
159     # The application's API key issued by the API console.
160     #
161     # @return [String] The API key.
162     attr_accessor :key
163
164     ##
165     # The IP address of the user this request is being performed on behalf of.
166     #
167     # @return [String] The user's IP address.
168     attr_accessor :user_ip
169
170     ##
171     # The user agent used by the client.
172     #
173     # @return [String]
174     #   The user agent string used in the User-Agent header.
175     attr_accessor :user_agent
176
177     ##
178     # The API hostname used by the client.
179     #
180     # @return [String]
181     #   The API hostname. Should almost always be 'www.googleapis.com'.
182     attr_accessor :host
183
184     ##
185     # The port number used by the client.
186     #
187     # @return [String]
188     #   The port number. Should almost always be 443.
189     attr_accessor :port
190
191     ##
192     # The base path used by the client for discovery.
193     #
194     # @return [String]
195     #   The base path. Should almost always be '/discovery/v1'.
196     attr_accessor :discovery_path
197
198     ##
199     # Resolves a URI template against the client's configured base.
200     #
201     # @param [String, Addressable::URI, Addressable::Template] template
202     #   The template to resolve.
203     # @param [Hash] mapping The mapping that corresponds to the template.
204     # @return [Addressable::URI] The expanded URI.
205     def resolve_uri(template, mapping={})
206       @base_uri ||= Addressable::URI.new(
207         :scheme => 'https',
208         :host => self.host,
209         :port => self.port
210       ).normalize
211       template = if template.kind_of?(Addressable::Template)
212         template.pattern
213       elsif template.respond_to?(:to_str)
214         template.to_str
215       else
216         raise TypeError,
217           "Expected String, Addressable::URI, or Addressable::Template, " +
218           "got #{template.class}."
219       end
220       return Addressable::Template.new(@base_uri + template).expand(mapping)
221     end
222
223     ##
224     # Returns the URI for the directory document.
225     #
226     # @return [Addressable::URI] The URI of the directory document.
227     def directory_uri
228       return resolve_uri(self.discovery_path + '/apis')
229     end
230
231     ##
232     # Manually registers a URI as a discovery document for a specific version
233     # of an API.
234     #
235     # @param [String, Symbol] api The API name.
236     # @param [String] version The desired version of the API.
237     # @param [Addressable::URI] uri The URI of the discovery document.
238     def register_discovery_uri(api, version, uri)
239       api = api.to_s
240       version = version || 'v1'
241       @discovery_uris["#{api}:#{version}"] = uri
242     end
243
244     ##
245     # Returns the URI for the discovery document.
246     #
247     # @param [String, Symbol] api The API name.
248     # @param [String] version The desired version of the API.
249     # @return [Addressable::URI] The URI of the discovery document.
250     def discovery_uri(api, version=nil)
251       api = api.to_s
252       version = version || 'v1'
253       return @discovery_uris["#{api}:#{version}"] ||= (
254         resolve_uri(
255           self.discovery_path + '/apis/{api}/{version}/rest',
256           'api' => api,
257           'version' => version
258         )
259       )
260     end
261
262     ##
263     # Manually registers a pre-loaded discovery document for a specific version
264     # of an API.
265     #
266     # @param [String, Symbol] api The API name.
267     # @param [String] version The desired version of the API.
268     # @param [String, StringIO] discovery_document
269     #   The contents of the discovery document.
270     def register_discovery_document(api, version, discovery_document)
271       api = api.to_s
272       version = version || 'v1'
273       if discovery_document.kind_of?(StringIO)
274         discovery_document.rewind
275         discovery_document = discovery_document.string
276       elsif discovery_document.respond_to?(:to_str)
277         discovery_document = discovery_document.to_str
278       else
279         raise TypeError,
280           "Expected String or StringIO, got #{discovery_document.class}."
281       end
282       @discovery_documents["#{api}:#{version}"] =
283         MultiJson.decode(discovery_document)
284     end
285
286     ##
287     # Returns the parsed directory document.
288     #
289     # @return [Hash] The parsed JSON from the directory document.
290     def directory_document
291       return @directory_document ||= (begin
292         request = self.generate_request(
293           :http_method => :get,
294           :uri => self.directory_uri,
295           :authenticated => false
296         )
297         response = self.transmit(:request => request)
298         if response.status >= 200 && response.status < 300
299           MultiJson.decode(response.body)
300         elsif response.status >= 400
301           case response.status
302           when 400...500
303             exception_type = ClientError
304           when 500...600
305             exception_type = ServerError
306           else
307             exception_type = TransmissionError
308           end
309           url = request.to_env(Faraday.default_connection)[:url]
310           raise exception_type,
311             "Could not retrieve directory document at: #{url}"
312         end
313       end)
314     end
315
316     ##
317     # Returns the parsed discovery document.
318     #
319     # @param [String, Symbol] api The API name.
320     # @param [String] version The desired version of the API.
321     # @return [Hash] The parsed JSON from the discovery document.
322     def discovery_document(api, version=nil)
323       api = api.to_s
324       version = version || 'v1'
325       return @discovery_documents["#{api}:#{version}"] ||= (begin
326         request = self.generate_request(
327           :http_method => :get,
328           :uri => self.discovery_uri(api, version),
329           :authenticated => false
330         )
331         response = self.transmit(:request => request)
332         if response.status >= 200 && response.status < 300
333           MultiJson.decode(response.body)
334         elsif response.status >= 400
335           case response.status
336           when 400...500
337             exception_type = ClientError
338           when 500...600
339             exception_type = ServerError
340           else
341             exception_type = TransmissionError
342           end
343           url = request.to_env(Faraday.default_connection)[:url]
344           raise exception_type,
345             "Could not retrieve discovery document at: #{url}"
346         end
347       end)
348     end
349
350     ##
351     # Returns all APIs published in the directory document.
352     #
353     # @return [Array] The list of available APIs.
354     def discovered_apis
355       @directory_apis ||= (begin
356         document_base = self.directory_uri
357         if self.directory_document && self.directory_document['items']
358           self.directory_document['items'].map do |discovery_document|
359             Google::APIClient::API.new(
360               document_base,
361               discovery_document
362             )
363           end
364         else
365           []
366         end
367       end)
368     end
369
370     ##
371     # Returns the service object for a given service name and service version.
372     #
373     # @param [String, Symbol] api The API name.
374     # @param [String] version The desired version of the API.
375     #
376     # @return [Google::APIClient::API] The service object.
377     def discovered_api(api, version=nil)
378       if !api.kind_of?(String) && !api.kind_of?(Symbol)
379         raise TypeError,
380           "Expected String or Symbol, got #{api.class}."
381       end
382       api = api.to_s
383       version = version || 'v1'
384       return @discovered_apis["#{api}:#{version}"] ||= begin
385         document_base = self.discovery_uri(api, version)
386         discovery_document = self.discovery_document(api, version)
387         if document_base && discovery_document
388           Google::APIClient::API.new(
389             document_base,
390             discovery_document
391           )
392         else
393           nil
394         end
395       end
396     end
397
398     ##
399     # Returns the method object for a given RPC name and service version.
400     #
401     # @param [String, Symbol] rpc_name The RPC name of the desired method.
402     # @param [String, Symbol] rpc_name The API the method is within.
403     # @param [String] version The desired version of the API.
404     #
405     # @return [Google::APIClient::Method] The method object.
406     def discovered_method(rpc_name, api, version=nil)
407       if !rpc_name.kind_of?(String) && !rpc_name.kind_of?(Symbol)
408         raise TypeError,
409           "Expected String or Symbol, got #{rpc_name.class}."
410       end
411       rpc_name = rpc_name.to_s
412       api = api.to_s
413       version = version || 'v1'
414       service = self.discovered_api(api, version)
415       if service.to_h[rpc_name]
416         return service.to_h[rpc_name]
417       else
418         return nil
419       end
420     end
421
422     ##
423     # Returns the service object with the highest version number.
424     #
425     # @note <em>Warning</em>: This method should be used with great care.
426     # As APIs are updated, minor differences between versions may cause
427     # incompatibilities. Requesting a specific version will avoid this issue.
428     #
429     # @param [String, Symbol] api The name of the service.
430     #
431     # @return [Google::APIClient::API] The service object.
432     def preferred_version(api)
433       if !api.kind_of?(String) && !api.kind_of?(Symbol)
434         raise TypeError,
435           "Expected String or Symbol, got #{api.class}."
436       end
437       api = api.to_s
438       return self.discovered_apis.detect do |a|
439         a.name == api && a.preferred == true
440       end
441     end
442
443     ##
444     # Verifies an ID token against a server certificate. Used to ensure that
445     # an ID token supplied by an untrusted client-side mechanism is valid.
446     # Raises an error if the token is invalid or missing.
447     def verify_id_token!
448       gem 'jwt', '~> 0.1.4'
449       require 'jwt'
450       require 'openssl'
451       @certificates ||= {}
452       if !self.authorization.respond_to?(:id_token)
453         raise ArgumentError, (
454           "Current authorization mechanism does not support ID tokens: " +
455           "#{self.authorization.class.to_s}"
456         )
457       elsif !self.authorization.id_token
458         raise ArgumentError, (
459           "Could not verify ID token, ID token missing. " +
460           "Scopes were: #{self.authorization.scope.inspect}"
461         )
462       else
463         check_cached_certs = lambda do
464           valid = false
465           for key, cert in @certificates
466             begin
467               self.authorization.decoded_id_token(cert.public_key)
468               valid = true
469             rescue JWT::DecodeError, Signet::UnsafeOperationError
470               # Expected exception. Ignore, ID token has not been validated.
471             end
472           end
473           valid
474         end
475         if check_cached_certs.call()
476           return true
477         end
478         request = self.generate_request(
479           :http_method => :get,
480           :uri => 'https://www.googleapis.com/oauth2/v1/certs',
481           :authenticated => false
482         )
483         response = self.transmit(:request => request)
484         if response.status >= 200 && response.status < 300
485           @certificates.merge!(
486             Hash[MultiJson.decode(response.body).map do |key, cert|
487               [key, OpenSSL::X509::Certificate.new(cert)]
488             end]
489           )
490         elsif response.status >= 400
491           case response.status
492           when 400...500
493             exception_type = ClientError
494           when 500...600
495             exception_type = ServerError
496           else
497             exception_type = TransmissionError
498           end
499           url = request.to_env(Faraday.default_connection)[:url]
500           raise exception_type,
501             "Could not retrieve certificates from: #{url}"
502         end
503         if check_cached_certs.call()
504           return true
505         else
506           raise InvalidIDTokenError,
507             "Could not verify ID token against any available certificate."
508         end
509       end
510       return nil
511     end
512
513     ##
514     # Generates a request.
515     #
516     # @option options [Google::APIClient::Method, String] :api_method
517     #   The method object or the RPC name of the method being executed.
518     # @option options [Hash, Array] :parameters
519     #   The parameters to send to the method.
520     # @option options [Hash, Array] :headers The HTTP headers for the request.
521     # @option options [String] :body The body of the request.
522     # @option options [String] :version ("v1")
523     #   The service version. Only used if `api_method` is a `String`.
524     # @option options [#generate_authenticated_request] :authorization
525     #   The authorization mechanism for the response. Used only if
526     #   `:authenticated` is `true`.
527     # @option options [TrueClass, FalseClass] :authenticated (true)
528     #   `true` if the request must be signed or somehow
529     #   authenticated, `false` otherwise.
530     #
531     # @return [Faraday::Request] The generated request.
532     #
533     # @example
534     #   request = client.generate_request(
535     #     :api_method => 'plus.activities.list',
536     #     :parameters =>
537     #       {'collection' => 'public', 'userId' => 'me'}
538     #   )
539     def generate_request(options={})
540       # Note: The merge method on a Hash object will coerce an API Reference
541       # object into a Hash and merge with the default options.
542       options={
543         :version => 'v1',
544         :authorization => self.authorization,
545         :key => self.key,
546         :user_ip => self.user_ip,
547         :connection => Faraday.default_connection
548       }.merge(options)
549       # The Reference object is going to need this to do method ID lookups.
550       options[:client] = self
551       # The default value for the :authenticated option depends on whether an
552       # authorization mechanism has been set.
553       if options[:authorization]
554         options = {:authenticated => true}.merge(options)
555       else
556         options = {:authenticated => false}.merge(options)
557       end
558       reference = Google::APIClient::Reference.new(options)
559       request = reference.to_request
560       if options[:authenticated]
561         request = self.generate_authenticated_request(
562           :request => request,
563           :connection => options[:connection]
564         )
565       end
566       return request
567     end
568
569     ##
570     # Signs a request using the current authorization mechanism.
571     #
572     # @param [Hash] options a customizable set of options
573     #
574     # @return [Faraday::Request] The signed or otherwise authenticated request.
575     def generate_authenticated_request(options={})
576       return authorization.generate_authenticated_request(options)
577     end
578
579     ##
580     # Transmits the request using the current HTTP adapter.
581     #
582     # @option options [Array, Faraday::Request] :request
583     #   The HTTP request to transmit.
584     # @option options [String, Symbol] :method
585     #   The method for the HTTP request.
586     # @option options [String, Addressable::URI] :uri
587     #   The URI for the HTTP request.
588     # @option options [Array, Hash] :headers
589     #   The headers for the HTTP request.
590     # @option options [String] :body
591     #   The body for the HTTP request.
592     # @option options [Faraday::Connection] :connection
593     #   The HTTP connection to use.
594     #
595     # @return [Faraday::Response] The response from the server.
596     def transmit(options={})
597       options[:connection] ||= Faraday.default_connection
598       if options[:request]
599         if options[:request].kind_of?(Array)
600           method, uri, headers, body = options[:request]
601         elsif options[:request].kind_of?(Faraday::Request)
602           unless options[:connection]
603             raise ArgumentError,
604               "Faraday::Request used, requires a connection to be provided."
605           end
606           method = options[:request].method.to_s.downcase.to_sym
607           uri = options[:connection].build_url(
608             options[:request].path, options[:request].params
609           )
610           headers = options[:request].headers || {}
611           body = options[:request].body || ''
612         end
613       else
614         method = options[:method] || :get
615         uri = options[:uri]
616         headers = options[:headers] || []
617         body = options[:body] || ''
618       end
619       headers = headers.to_a if headers.kind_of?(Hash)
620       request_components = {
621         :method => method,
622         :uri => uri,
623         :headers => headers,
624         :body => body
625       }
626       # Verify that we have all pieces required to transmit an HTTP request
627       request_components.each do |(key, value)|
628         unless value
629           raise ArgumentError, "Missing :#{key} parameter."
630         end
631       end
632
633       if self.user_agent != nil
634         # If there's no User-Agent header, set one.
635         unless headers.kind_of?(Enumerable)
636           # We need to use some Enumerable methods, relying on the presence of
637           # the #each method.
638           class << headers
639             include Enumerable
640           end
641         end
642         if self.user_agent.kind_of?(String)
643           unless headers.any? { |k, v| k.downcase == 'User-Agent'.downcase }
644             headers = headers.to_a.insert(0, ['User-Agent', self.user_agent])
645           end
646         elsif self.user_agent != nil
647           raise TypeError,
648             "Expected User-Agent to be String, got #{self.user_agent.class}"
649         end
650       end
651
652       request = Faraday::Request.create(method.to_s.downcase.to_sym) do |req|
653         req.url(Addressable::URI.parse(uri))
654         req.headers = Faraday::Utils::Headers.new(headers)
655         req.body = body
656       end
657       request_env = request.to_env(options[:connection])
658       response = options[:connection].app.call(request_env)
659       return response
660     end
661
662     ##
663     # Executes a request, wrapping it in a Result object.
664     #
665     # @param [Google::APIClient::Method, String] api_method
666     #   The method object or the RPC name of the method being executed.
667     # @param [Hash, Array] parameters
668     #   The parameters to send to the method.
669     # @param [String] body The body of the request.
670     # @param [Hash, Array] headers The HTTP headers for the request.
671     # @option options [String] :version ("v1")
672     #   The service version. Only used if `api_method` is a `String`.
673     # @option options [#generate_authenticated_request] :authorization
674     #   The authorization mechanism for the response. Used only if
675     #   `:authenticated` is `true`.
676     # @option options [TrueClass, FalseClass] :authenticated (true)
677     #   `true` if the request must be signed or somehow
678     #   authenticated, `false` otherwise.
679     #
680     # @return [Google::APIClient::Result] The result from the API.
681     #
682     # @example
683     #   result = client.execute(
684     #     :api_method => 'plus.activities.list',
685     #     :parameters => {'collection' => 'public', 'userId' => 'me'}
686     #   )
687     #
688     # @see Google::APIClient#generate_request
689     def execute(*params)
690       # This block of code allows us to accept multiple parameter passing
691       # styles, and maintaining some backwards compatibility.
692       #
693       # Note: I'm extremely tempted to deprecate this style of execute call.
694       if params.last.respond_to?(:to_hash) && params.size == 1
695         options = params.pop
696       else
697         options = {}
698       end
699       options[:api_method] = params.shift if params.size > 0
700       options[:parameters] = params.shift if params.size > 0
701       options[:body] = params.shift if params.size > 0
702       options[:headers] = params.shift if params.size > 0
703       options[:client] = self
704
705       reference = Google::APIClient::Reference.new(options)
706       request = self.generate_request(reference)
707       response = self.transmit(
708         :request => request,
709         :connection => options[:connection]
710       )
711       return Google::APIClient::Result.new(reference, request, response)
712     end
713
714     ##
715     # Same as Google::APIClient#execute, but raises an exception if there was
716     # an error.
717     #
718     # @see Google::APIClient#execute
719     def execute!(*params)
720       result = self.execute(*params)
721       if result.data.respond_to?(:error) &&
722           result.data.error.respond_to?(:message)
723         # You're going to get a terrible error message if the response isn't
724         # parsed successfully as an error.
725         error_message = result.data.error.message
726       elsif result.data['error'] && result.data['error']['message']
727         error_message = result.data['error']['message']
728       end
729       if result.response.status >= 400
730         case result.response.status
731         when 400...500
732           exception_type = ClientError
733           error_message ||= "A client error has occurred."
734         when 500...600
735           exception_type = ServerError
736           error_message ||= "A server error has occurred."
737         else
738           exception_type = TransmissionError
739           error_message ||= "A transmission error has occurred."
740         end
741         raise exception_type, error_message
742       end
743       return result
744     end
745   end
746 end
747
748 require 'google/api_client/version'