blob: c451b473a4daeca2b0a75eef8343a06ae1f62373 [file] [log] [blame]
Craig Citro751b7fb2014-09-23 11:20:38 -07001# Copyright 2014 Google Inc. All Rights Reserved.
John Asmuth864311d2014-04-24 15:46:08 -04002#
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"""Client for discovery based APIs.
16
17A client library for Google's discovery based APIs.
18"""
INADA Naoki0bceb332014-08-20 15:27:52 +090019from __future__ import absolute_import
INADA Naokie4ea1a92015-03-04 03:45:42 +090020import six
21from six.moves import zip
John Asmuth864311d2014-04-24 15:46:08 -040022
23__author__ = 'jcgregorio@google.com (Joe Gregorio)'
24__all__ = [
25 'build',
26 'build_from_document',
27 'fix_method_name',
28 'key2param',
29 ]
30
Phil Ruffwind26178fc2015-10-13 19:00:33 -040031from six import BytesIO
Takashi Matsuo3772f9d2015-09-04 12:25:55 -070032from six.moves import http_client
Pat Ferated5b61bd2015-03-03 16:04:11 -080033from six.moves.urllib.parse import urlencode, urlparse, urljoin, \
34 urlunparse, parse_qsl
John Asmuth864311d2014-04-24 15:46:08 -040035
36# Standard library imports
37import copy
Phil Ruffwind26178fc2015-10-13 19:00:33 -040038try:
39 from email.generator import BytesGenerator
40except ImportError:
41 from email.generator import Generator as BytesGenerator
John Asmuth864311d2014-04-24 15:46:08 -040042from email.mime.multipart import MIMEMultipart
43from email.mime.nonmultipart import MIMENonMultipart
Craig Citro6ae34d72014-08-18 23:10:09 -070044import json
John Asmuth864311d2014-04-24 15:46:08 -040045import keyword
46import logging
47import mimetypes
48import os
49import re
John Asmuth864311d2014-04-24 15:46:08 -040050
51# Third-party imports
52import httplib2
John Asmuth864311d2014-04-24 15:46:08 -040053import uritemplate
54
55# Local imports
Pat Ferateb240c172015-03-03 16:23:51 -080056from googleapiclient import mimeparse
John Asmuth864311d2014-04-24 15:46:08 -040057from googleapiclient.errors import HttpError
58from googleapiclient.errors import InvalidJsonError
59from googleapiclient.errors import MediaUploadSizeError
60from googleapiclient.errors import UnacceptableMimeTypeError
61from googleapiclient.errors import UnknownApiNameOrVersion
62from googleapiclient.errors import UnknownFileType
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -040063from googleapiclient.http import BatchHttpRequest
Kostyantyn Leschenkobe8b1cb2016-10-17 12:57:21 +030064from googleapiclient.http import HttpMock
65from googleapiclient.http import HttpMockSequence
John Asmuth864311d2014-04-24 15:46:08 -040066from googleapiclient.http import HttpRequest
67from googleapiclient.http import MediaFileUpload
68from googleapiclient.http import MediaUpload
69from googleapiclient.model import JsonModel
70from googleapiclient.model import MediaModel
71from googleapiclient.model import RawModel
72from googleapiclient.schema import Schemas
Craig Citroae83efb2014-06-06 09:45:57 -070073from oauth2client.client import GoogleCredentials
Jon Wayne Parrott6755f612016-08-15 10:52:26 -070074
75# Oauth2client < 3 has the positional helper in 'util', >= 3 has it
76# in '_helpers'.
77try:
78 from oauth2client.util import _add_query_parameter
79 from oauth2client.util import positional
80except ImportError:
81 from oauth2client._helpers import _add_query_parameter
82 from oauth2client._helpers import positional
John Asmuth864311d2014-04-24 15:46:08 -040083
84
85# The client library requires a version of httplib2 that supports RETRIES.
86httplib2.RETRIES = 1
87
88logger = logging.getLogger(__name__)
89
90URITEMPLATE = re.compile('{[^}]*}')
91VARNAME = re.compile('[a-zA-Z0-9_-]+')
92DISCOVERY_URI = ('https://www.googleapis.com/discovery/v1/apis/'
93 '{api}/{apiVersion}/rest')
Ethan Bao12b7cd32016-03-14 14:25:10 -070094V1_DISCOVERY_URI = DISCOVERY_URI
95V2_DISCOVERY_URI = ('https://{api}.googleapis.com/$discovery/rest?'
96 'version={apiVersion}')
John Asmuth864311d2014-04-24 15:46:08 -040097DEFAULT_METHOD_DOC = 'A description of how to use this function'
98HTTP_PAYLOAD_METHODS = frozenset(['PUT', 'POST', 'PATCH'])
99_MEDIA_SIZE_BIT_SHIFTS = {'KB': 10, 'MB': 20, 'GB': 30, 'TB': 40}
100BODY_PARAMETER_DEFAULT_VALUE = {
101 'description': 'The request body.',
102 'type': 'object',
103 'required': True,
104}
105MEDIA_BODY_PARAMETER_DEFAULT_VALUE = {
106 'description': ('The filename of the media request body, or an instance '
107 'of a MediaUpload object.'),
108 'type': 'string',
109 'required': False,
110}
111
112# Parameters accepted by the stack, but not visible via discovery.
113# TODO(dhermes): Remove 'userip' in 'v2'.
114STACK_QUERY_PARAMETERS = frozenset(['trace', 'pp', 'userip', 'strict'])
115STACK_QUERY_PARAMETER_DEFAULT_VALUE = {'type': 'string', 'location': 'query'}
116
117# Library-specific reserved words beyond Python keywords.
118RESERVED_WORDS = frozenset(['body'])
119
Phil Ruffwind26178fc2015-10-13 19:00:33 -0400120# patch _write_lines to avoid munging '\r' into '\n'
121# ( https://bugs.python.org/issue18886 https://bugs.python.org/issue19003 )
122class _BytesGenerator(BytesGenerator):
123 _write_lines = BytesGenerator.write
John Asmuth864311d2014-04-24 15:46:08 -0400124
125def fix_method_name(name):
126 """Fix method names to avoid reserved word conflicts.
127
128 Args:
129 name: string, method name.
130
131 Returns:
132 The name with a '_' prefixed if the name is a reserved word.
133 """
134 if keyword.iskeyword(name) or name in RESERVED_WORDS:
135 return name + '_'
136 else:
137 return name
138
139
140def key2param(key):
141 """Converts key names into parameter names.
142
143 For example, converting "max-results" -> "max_results"
144
145 Args:
146 key: string, the method key name.
147
148 Returns:
149 A safe method name based on the key name.
150 """
151 result = []
152 key = list(key)
153 if not key[0].isalpha():
154 result.append('x')
155 for c in key:
156 if c.isalnum():
157 result.append(c)
158 else:
159 result.append('_')
160
161 return ''.join(result)
162
163
164@positional(2)
165def build(serviceName,
166 version,
167 http=None,
168 discoveryServiceUrl=DISCOVERY_URI,
169 developerKey=None,
170 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700171 requestBuilder=HttpRequest,
Takashi Matsuo30125122015-08-19 11:42:32 -0700172 credentials=None,
173 cache_discovery=True,
174 cache=None):
John Asmuth864311d2014-04-24 15:46:08 -0400175 """Construct a Resource for interacting with an API.
176
177 Construct a Resource object for interacting with an API. The serviceName and
178 version are the names from the Discovery service.
179
180 Args:
181 serviceName: string, name of the service.
182 version: string, the version of the service.
183 http: httplib2.Http, An instance of httplib2.Http or something that acts
184 like it that HTTP requests will be made through.
185 discoveryServiceUrl: string, a URI Template that points to the location of
186 the discovery service. It should have two parameters {api} and
187 {apiVersion} that when filled in produce an absolute URI to the discovery
188 document for that service.
189 developerKey: string, key obtained from
190 https://code.google.com/apis/console.
191 model: googleapiclient.Model, converts to and from the wire format.
192 requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
193 request.
Orest Bolohane92c9002014-05-30 11:15:43 -0700194 credentials: oauth2client.Credentials, credentials to be used for
195 authentication.
Takashi Matsuo30125122015-08-19 11:42:32 -0700196 cache_discovery: Boolean, whether or not to cache the discovery doc.
197 cache: googleapiclient.discovery_cache.base.CacheBase, an optional
198 cache object for the discovery documents.
John Asmuth864311d2014-04-24 15:46:08 -0400199
200 Returns:
201 A Resource object with methods for interacting with the service.
202 """
203 params = {
204 'api': serviceName,
205 'apiVersion': version
206 }
207
208 if http is None:
209 http = httplib2.Http()
210
Ethan Bao12b7cd32016-03-14 14:25:10 -0700211 for discovery_url in (discoveryServiceUrl, V2_DISCOVERY_URI,):
212 requested_url = uritemplate.expand(discovery_url, params)
John Asmuth864311d2014-04-24 15:46:08 -0400213
Ethan Bao12b7cd32016-03-14 14:25:10 -0700214 try:
215 content = _retrieve_discovery_doc(requested_url, http, cache_discovery,
216 cache)
217 return build_from_document(content, base=discovery_url, http=http,
218 developerKey=developerKey, model=model, requestBuilder=requestBuilder,
219 credentials=credentials)
220 except HttpError as e:
221 if e.resp.status == http_client.NOT_FOUND:
222 continue
223 else:
224 raise e
Takashi Matsuo30125122015-08-19 11:42:32 -0700225
Ethan Bao12b7cd32016-03-14 14:25:10 -0700226 raise UnknownApiNameOrVersion(
227 "name: %s version: %s" % (serviceName, version))
Takashi Matsuo30125122015-08-19 11:42:32 -0700228
229
230def _retrieve_discovery_doc(url, http, cache_discovery, cache=None):
231 """Retrieves the discovery_doc from cache or the internet.
232
233 Args:
234 url: string, the URL of the discovery document.
235 http: httplib2.Http, An instance of httplib2.Http or something that acts
236 like it through which HTTP requests will be made.
237 cache_discovery: Boolean, whether or not to cache the discovery doc.
238 cache: googleapiclient.discovery_cache.base.Cache, an optional cache
239 object for the discovery documents.
240
241 Returns:
242 A unicode string representation of the discovery document.
243 """
244 if cache_discovery:
245 from . import discovery_cache
246 from .discovery_cache import base
247 if cache is None:
248 cache = discovery_cache.autodetect()
249 if cache:
250 content = cache.get(url)
251 if content:
252 return content
253
254 actual_url = url
John Asmuth864311d2014-04-24 15:46:08 -0400255 # REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
256 # variable that contains the network address of the client sending the
257 # request. If it exists then add that to the request for the discovery
258 # document to avoid exceeding the quota on discovery requests.
259 if 'REMOTE_ADDR' in os.environ:
Takashi Matsuo30125122015-08-19 11:42:32 -0700260 actual_url = _add_query_parameter(url, 'userIp', os.environ['REMOTE_ADDR'])
261 logger.info('URL being requested: GET %s', actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400262
Takashi Matsuo30125122015-08-19 11:42:32 -0700263 resp, content = http.request(actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400264
John Asmuth864311d2014-04-24 15:46:08 -0400265 if resp.status >= 400:
Takashi Matsuo30125122015-08-19 11:42:32 -0700266 raise HttpError(resp, content, uri=actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400267
268 try:
Pat Ferate9b0452c2015-03-03 17:59:56 -0800269 content = content.decode('utf-8')
270 except AttributeError:
271 pass
272
273 try:
Craig Citro6ae34d72014-08-18 23:10:09 -0700274 service = json.loads(content)
INADA Naokic1505df2014-08-20 15:19:53 +0900275 except ValueError as e:
John Asmuth864311d2014-04-24 15:46:08 -0400276 logger.error('Failed to parse as JSON: ' + content)
277 raise InvalidJsonError()
Takashi Matsuo30125122015-08-19 11:42:32 -0700278 if cache_discovery and cache:
279 cache.set(url, content)
280 return content
John Asmuth864311d2014-04-24 15:46:08 -0400281
282
283@positional(1)
284def build_from_document(
285 service,
286 base=None,
287 future=None,
288 http=None,
289 developerKey=None,
290 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700291 requestBuilder=HttpRequest,
292 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400293 """Create a Resource for interacting with an API.
294
295 Same as `build()`, but constructs the Resource object from a discovery
296 document that is it given, as opposed to retrieving one over HTTP.
297
298 Args:
299 service: string or object, the JSON discovery document describing the API.
300 The value passed in may either be the JSON string or the deserialized
301 JSON.
302 base: string, base URI for all HTTP requests, usually the discovery URI.
303 This parameter is no longer used as rootUrl and servicePath are included
304 within the discovery document. (deprecated)
305 future: string, discovery document with future capabilities (deprecated).
306 http: httplib2.Http, An instance of httplib2.Http or something that acts
307 like it that HTTP requests will be made through.
308 developerKey: string, Key for controlling API usage, generated
309 from the API Console.
310 model: Model class instance that serializes and de-serializes requests and
311 responses.
312 requestBuilder: Takes an http request and packages it up to be executed.
Orest Bolohane92c9002014-05-30 11:15:43 -0700313 credentials: object, credentials to be used for authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400314
315 Returns:
316 A Resource object with methods for interacting with the service.
317 """
318
Jonathan Wayne Parrotta6e6fbd2015-07-16 15:33:57 -0700319 if http is None:
320 http = httplib2.Http()
321
John Asmuth864311d2014-04-24 15:46:08 -0400322 # future is no longer used.
323 future = {}
324
INADA Naokie4ea1a92015-03-04 03:45:42 +0900325 if isinstance(service, six.string_types):
Craig Citro6ae34d72014-08-18 23:10:09 -0700326 service = json.loads(service)
Christian Ternuse469a9f2016-08-16 12:44:03 -0400327
328 if 'rootUrl' not in service and (isinstance(http, (HttpMock,
329 HttpMockSequence))):
330 logger.error("You are using HttpMock or HttpMockSequence without" +
331 "having the service discovery doc in cache. Try calling " +
332 "build() without mocking once first to populate the " +
333 "cache.")
334 raise InvalidJsonError()
335
Pat Ferated5b61bd2015-03-03 16:04:11 -0800336 base = urljoin(service['rootUrl'], service['servicePath'])
John Asmuth864311d2014-04-24 15:46:08 -0400337 schema = Schemas(service)
338
Orest Bolohane92c9002014-05-30 11:15:43 -0700339 if credentials:
340 # If credentials were passed in, we could have two cases:
341 # 1. the scopes were specified, in which case the given credentials
342 # are used for authorizing the http;
oresticaaff4e1f2014-07-08 11:28:45 -0700343 # 2. the scopes were not provided (meaning the Application Default
344 # Credentials are to be used). In this case, the Application Default
345 # Credentials are built and used instead of the original credentials.
346 # If there are no scopes found (meaning the given service requires no
347 # authentication), there is no authorization of the http.
Craig Citroae83efb2014-06-06 09:45:57 -0700348 if (isinstance(credentials, GoogleCredentials) and
349 credentials.create_scoped_required()):
Orest Bolohane92c9002014-05-30 11:15:43 -0700350 scopes = service.get('auth', {}).get('oauth2', {}).get('scopes', {})
351 if scopes:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900352 credentials = credentials.create_scoped(list(scopes.keys()))
Orest Bolohane92c9002014-05-30 11:15:43 -0700353 else:
354 # No need to authorize the http object
355 # if the service does not require authentication.
356 credentials = None
357
358 if credentials:
359 http = credentials.authorize(http)
360
John Asmuth864311d2014-04-24 15:46:08 -0400361 if model is None:
362 features = service.get('features', [])
363 model = JsonModel('dataWrapper' in features)
364 return Resource(http=http, baseUrl=base, model=model,
365 developerKey=developerKey, requestBuilder=requestBuilder,
366 resourceDesc=service, rootDesc=service, schema=schema)
367
368
369def _cast(value, schema_type):
370 """Convert value to a string based on JSON Schema type.
371
372 See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
373 JSON Schema.
374
375 Args:
376 value: any, the value to convert
377 schema_type: string, the type that value should be interpreted as
378
379 Returns:
380 A string representation of 'value' based on the schema_type.
381 """
382 if schema_type == 'string':
383 if type(value) == type('') or type(value) == type(u''):
384 return value
385 else:
386 return str(value)
387 elif schema_type == 'integer':
388 return str(int(value))
389 elif schema_type == 'number':
390 return str(float(value))
391 elif schema_type == 'boolean':
392 return str(bool(value)).lower()
393 else:
394 if type(value) == type('') or type(value) == type(u''):
395 return value
396 else:
397 return str(value)
398
399
400def _media_size_to_long(maxSize):
401 """Convert a string media size, such as 10GB or 3TB into an integer.
402
403 Args:
404 maxSize: string, size as a string, such as 2MB or 7GB.
405
406 Returns:
407 The size as an integer value.
408 """
409 if len(maxSize) < 2:
INADA Naoki0bceb332014-08-20 15:27:52 +0900410 return 0
John Asmuth864311d2014-04-24 15:46:08 -0400411 units = maxSize[-2:].upper()
412 bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
413 if bit_shift is not None:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900414 return int(maxSize[:-2]) << bit_shift
John Asmuth864311d2014-04-24 15:46:08 -0400415 else:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900416 return int(maxSize)
John Asmuth864311d2014-04-24 15:46:08 -0400417
418
419def _media_path_url_from_info(root_desc, path_url):
420 """Creates an absolute media path URL.
421
422 Constructed using the API root URI and service path from the discovery
423 document and the relative path for the API method.
424
425 Args:
426 root_desc: Dictionary; the entire original deserialized discovery document.
427 path_url: String; the relative URL for the API method. Relative to the API
428 root, which is specified in the discovery document.
429
430 Returns:
431 String; the absolute URI for media upload for the API method.
432 """
433 return '%(root)supload/%(service_path)s%(path)s' % {
434 'root': root_desc['rootUrl'],
435 'service_path': root_desc['servicePath'],
436 'path': path_url,
437 }
438
439
440def _fix_up_parameters(method_desc, root_desc, http_method):
441 """Updates parameters of an API method with values specific to this library.
442
443 Specifically, adds whatever global parameters are specified by the API to the
444 parameters for the individual method. Also adds parameters which don't
445 appear in the discovery document, but are available to all discovery based
446 APIs (these are listed in STACK_QUERY_PARAMETERS).
447
448 SIDE EFFECTS: This updates the parameters dictionary object in the method
449 description.
450
451 Args:
452 method_desc: Dictionary with metadata describing an API method. Value comes
453 from the dictionary of methods stored in the 'methods' key in the
454 deserialized discovery document.
455 root_desc: Dictionary; the entire original deserialized discovery document.
456 http_method: String; the HTTP method used to call the API method described
457 in method_desc.
458
459 Returns:
460 The updated Dictionary stored in the 'parameters' key of the method
461 description dictionary.
462 """
463 parameters = method_desc.setdefault('parameters', {})
464
465 # Add in the parameters common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900466 for name, description in six.iteritems(root_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400467 parameters[name] = description
468
469 # Add in undocumented query parameters.
470 for name in STACK_QUERY_PARAMETERS:
471 parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
472
473 # Add 'body' (our own reserved word) to parameters if the method supports
474 # a request payload.
475 if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
476 body = BODY_PARAMETER_DEFAULT_VALUE.copy()
477 body.update(method_desc['request'])
478 parameters['body'] = body
479
480 return parameters
481
482
483def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
484 """Updates parameters of API by adding 'media_body' if supported by method.
485
486 SIDE EFFECTS: If the method supports media upload and has a required body,
487 sets body to be optional (required=False) instead. Also, if there is a
488 'mediaUpload' in the method description, adds 'media_upload' key to
489 parameters.
490
491 Args:
492 method_desc: Dictionary with metadata describing an API method. Value comes
493 from the dictionary of methods stored in the 'methods' key in the
494 deserialized discovery document.
495 root_desc: Dictionary; the entire original deserialized discovery document.
496 path_url: String; the relative URL for the API method. Relative to the API
497 root, which is specified in the discovery document.
498 parameters: A dictionary describing method parameters for method described
499 in method_desc.
500
501 Returns:
502 Triple (accept, max_size, media_path_url) where:
503 - accept is a list of strings representing what content types are
504 accepted for media upload. Defaults to empty list if not in the
505 discovery document.
506 - max_size is a long representing the max size in bytes allowed for a
507 media upload. Defaults to 0L if not in the discovery document.
508 - media_path_url is a String; the absolute URI for media upload for the
509 API method. Constructed using the API root URI and service path from
510 the discovery document and the relative path for the API method. If
511 media upload is not supported, this is None.
512 """
513 media_upload = method_desc.get('mediaUpload', {})
514 accept = media_upload.get('accept', [])
515 max_size = _media_size_to_long(media_upload.get('maxSize', ''))
516 media_path_url = None
517
518 if media_upload:
519 media_path_url = _media_path_url_from_info(root_desc, path_url)
520 parameters['media_body'] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
521 if 'body' in parameters:
522 parameters['body']['required'] = False
523
524 return accept, max_size, media_path_url
525
526
527def _fix_up_method_description(method_desc, root_desc):
528 """Updates a method description in a discovery document.
529
530 SIDE EFFECTS: Changes the parameters dictionary in the method description with
531 extra parameters which are used locally.
532
533 Args:
534 method_desc: Dictionary with metadata describing an API method. Value comes
535 from the dictionary of methods stored in the 'methods' key in the
536 deserialized discovery document.
537 root_desc: Dictionary; the entire original deserialized discovery document.
538
539 Returns:
540 Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
541 where:
542 - path_url is a String; the relative URL for the API method. Relative to
543 the API root, which is specified in the discovery document.
544 - http_method is a String; the HTTP method used to call the API method
545 described in the method description.
546 - method_id is a String; the name of the RPC method associated with the
547 API method, and is in the method description in the 'id' key.
548 - accept is a list of strings representing what content types are
549 accepted for media upload. Defaults to empty list if not in the
550 discovery document.
551 - max_size is a long representing the max size in bytes allowed for a
552 media upload. Defaults to 0L if not in the discovery document.
553 - media_path_url is a String; the absolute URI for media upload for the
554 API method. Constructed using the API root URI and service path from
555 the discovery document and the relative path for the API method. If
556 media upload is not supported, this is None.
557 """
558 path_url = method_desc['path']
559 http_method = method_desc['httpMethod']
560 method_id = method_desc['id']
561
562 parameters = _fix_up_parameters(method_desc, root_desc, http_method)
563 # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
564 # 'parameters' key and needs to know if there is a 'body' parameter because it
565 # also sets a 'media_body' parameter.
566 accept, max_size, media_path_url = _fix_up_media_upload(
567 method_desc, root_desc, path_url, parameters)
568
569 return path_url, http_method, method_id, accept, max_size, media_path_url
570
571
Craig Citro7ee535d2015-02-23 10:11:14 -0800572def _urljoin(base, url):
573 """Custom urljoin replacement supporting : before / in url."""
574 # In general, it's unsafe to simply join base and url. However, for
575 # the case of discovery documents, we know:
576 # * base will never contain params, query, or fragment
577 # * url will never contain a scheme or net_loc.
578 # In general, this means we can safely join on /; we just need to
579 # ensure we end up with precisely one / joining base and url. The
580 # exception here is the case of media uploads, where url will be an
581 # absolute url.
582 if url.startswith('http://') or url.startswith('https://'):
Pat Ferated5b61bd2015-03-03 16:04:11 -0800583 return urljoin(base, url)
Craig Citro7ee535d2015-02-23 10:11:14 -0800584 new_base = base if base.endswith('/') else base + '/'
585 new_url = url[1:] if url.startswith('/') else url
586 return new_base + new_url
587
588
John Asmuth864311d2014-04-24 15:46:08 -0400589# TODO(dhermes): Convert this class to ResourceMethod and make it callable
590class ResourceMethodParameters(object):
591 """Represents the parameters associated with a method.
592
593 Attributes:
594 argmap: Map from method parameter name (string) to query parameter name
595 (string).
596 required_params: List of required parameters (represented by parameter
597 name as string).
598 repeated_params: List of repeated parameters (represented by parameter
599 name as string).
600 pattern_params: Map from method parameter name (string) to regular
601 expression (as a string). If the pattern is set for a parameter, the
602 value for that parameter must match the regular expression.
603 query_params: List of parameters (represented by parameter name as string)
604 that will be used in the query string.
605 path_params: Set of parameters (represented by parameter name as string)
606 that will be used in the base URL path.
607 param_types: Map from method parameter name (string) to parameter type. Type
608 can be any valid JSON schema type; valid values are 'any', 'array',
609 'boolean', 'integer', 'number', 'object', or 'string'. Reference:
610 http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
611 enum_params: Map from method parameter name (string) to list of strings,
612 where each list of strings is the list of acceptable enum values.
613 """
614
615 def __init__(self, method_desc):
616 """Constructor for ResourceMethodParameters.
617
618 Sets default values and defers to set_parameters to populate.
619
620 Args:
621 method_desc: Dictionary with metadata describing an API method. Value
622 comes from the dictionary of methods stored in the 'methods' key in
623 the deserialized discovery document.
624 """
625 self.argmap = {}
626 self.required_params = []
627 self.repeated_params = []
628 self.pattern_params = {}
629 self.query_params = []
630 # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
631 # parsing is gotten rid of.
632 self.path_params = set()
633 self.param_types = {}
634 self.enum_params = {}
635
636 self.set_parameters(method_desc)
637
638 def set_parameters(self, method_desc):
639 """Populates maps and lists based on method description.
640
641 Iterates through each parameter for the method and parses the values from
642 the parameter dictionary.
643
644 Args:
645 method_desc: Dictionary with metadata describing an API method. Value
646 comes from the dictionary of methods stored in the 'methods' key in
647 the deserialized discovery document.
648 """
INADA Naokie4ea1a92015-03-04 03:45:42 +0900649 for arg, desc in six.iteritems(method_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400650 param = key2param(arg)
651 self.argmap[param] = arg
652
653 if desc.get('pattern'):
654 self.pattern_params[param] = desc['pattern']
655 if desc.get('enum'):
656 self.enum_params[param] = desc['enum']
657 if desc.get('required'):
658 self.required_params.append(param)
659 if desc.get('repeated'):
660 self.repeated_params.append(param)
661 if desc.get('location') == 'query':
662 self.query_params.append(param)
663 if desc.get('location') == 'path':
664 self.path_params.add(param)
665 self.param_types[param] = desc.get('type', 'string')
666
667 # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
668 # should have all path parameters already marked with
669 # 'location: path'.
670 for match in URITEMPLATE.finditer(method_desc['path']):
671 for namematch in VARNAME.finditer(match.group(0)):
672 name = key2param(namematch.group(0))
673 self.path_params.add(name)
674 if name in self.query_params:
675 self.query_params.remove(name)
676
677
678def createMethod(methodName, methodDesc, rootDesc, schema):
679 """Creates a method for attaching to a Resource.
680
681 Args:
682 methodName: string, name of the method to use.
683 methodDesc: object, fragment of deserialized discovery document that
684 describes the method.
685 rootDesc: object, the entire deserialized discovery document.
686 schema: object, mapping of schema names to schema descriptions.
687 """
688 methodName = fix_method_name(methodName)
689 (pathUrl, httpMethod, methodId, accept,
690 maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc)
691
692 parameters = ResourceMethodParameters(methodDesc)
693
694 def method(self, **kwargs):
695 # Don't bother with doc string, it will be over-written by createMethod.
696
INADA Naokie4ea1a92015-03-04 03:45:42 +0900697 for name in six.iterkeys(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400698 if name not in parameters.argmap:
699 raise TypeError('Got an unexpected keyword argument "%s"' % name)
700
701 # Remove args that have a value of None.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900702 keys = list(kwargs.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400703 for name in keys:
704 if kwargs[name] is None:
705 del kwargs[name]
706
707 for name in parameters.required_params:
708 if name not in kwargs:
709 raise TypeError('Missing required parameter "%s"' % name)
710
INADA Naokie4ea1a92015-03-04 03:45:42 +0900711 for name, regex in six.iteritems(parameters.pattern_params):
John Asmuth864311d2014-04-24 15:46:08 -0400712 if name in kwargs:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900713 if isinstance(kwargs[name], six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400714 pvalues = [kwargs[name]]
715 else:
716 pvalues = kwargs[name]
717 for pvalue in pvalues:
718 if re.match(regex, pvalue) is None:
719 raise TypeError(
720 'Parameter "%s" value "%s" does not match the pattern "%s"' %
721 (name, pvalue, regex))
722
INADA Naokie4ea1a92015-03-04 03:45:42 +0900723 for name, enums in six.iteritems(parameters.enum_params):
John Asmuth864311d2014-04-24 15:46:08 -0400724 if name in kwargs:
725 # We need to handle the case of a repeated enum
726 # name differently, since we want to handle both
727 # arg='value' and arg=['value1', 'value2']
728 if (name in parameters.repeated_params and
INADA Naokie4ea1a92015-03-04 03:45:42 +0900729 not isinstance(kwargs[name], six.string_types)):
John Asmuth864311d2014-04-24 15:46:08 -0400730 values = kwargs[name]
731 else:
732 values = [kwargs[name]]
733 for value in values:
734 if value not in enums:
735 raise TypeError(
736 'Parameter "%s" value "%s" is not an allowed value in "%s"' %
737 (name, value, str(enums)))
738
739 actual_query_params = {}
740 actual_path_params = {}
INADA Naokie4ea1a92015-03-04 03:45:42 +0900741 for key, value in six.iteritems(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400742 to_type = parameters.param_types.get(key, 'string')
743 # For repeated parameters we cast each member of the list.
744 if key in parameters.repeated_params and type(value) == type([]):
745 cast_value = [_cast(x, to_type) for x in value]
746 else:
747 cast_value = _cast(value, to_type)
748 if key in parameters.query_params:
749 actual_query_params[parameters.argmap[key]] = cast_value
750 if key in parameters.path_params:
751 actual_path_params[parameters.argmap[key]] = cast_value
752 body_value = kwargs.get('body', None)
753 media_filename = kwargs.get('media_body', None)
754
755 if self._developerKey:
756 actual_query_params['key'] = self._developerKey
757
758 model = self._model
759 if methodName.endswith('_media'):
760 model = MediaModel()
761 elif 'response' not in methodDesc:
762 model = RawModel()
763
764 headers = {}
765 headers, params, query, body = model.request(headers,
766 actual_path_params, actual_query_params, body_value)
767
768 expanded_url = uritemplate.expand(pathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800769 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400770
771 resumable = None
772 multipart_boundary = ''
773
774 if media_filename:
775 # Ensure we end up with a valid MediaUpload object.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900776 if isinstance(media_filename, six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400777 (media_mime_type, encoding) = mimetypes.guess_type(media_filename)
778 if media_mime_type is None:
779 raise UnknownFileType(media_filename)
780 if not mimeparse.best_match([media_mime_type], ','.join(accept)):
781 raise UnacceptableMimeTypeError(media_mime_type)
782 media_upload = MediaFileUpload(media_filename,
783 mimetype=media_mime_type)
784 elif isinstance(media_filename, MediaUpload):
785 media_upload = media_filename
786 else:
787 raise TypeError('media_filename must be str or MediaUpload.')
788
789 # Check the maxSize
Pat Feratec46e9052015-03-03 17:59:17 -0800790 if media_upload.size() is not None and media_upload.size() > maxSize > 0:
John Asmuth864311d2014-04-24 15:46:08 -0400791 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
792
793 # Use the media path uri for media uploads
794 expanded_url = uritemplate.expand(mediaPathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800795 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400796 if media_upload.resumable():
797 url = _add_query_parameter(url, 'uploadType', 'resumable')
798
799 if media_upload.resumable():
800 # This is all we need to do for resumable, if the body exists it gets
801 # sent in the first request, otherwise an empty body is sent.
802 resumable = media_upload
803 else:
804 # A non-resumable upload
805 if body is None:
806 # This is a simple media upload
807 headers['content-type'] = media_upload.mimetype()
808 body = media_upload.getbytes(0, media_upload.size())
809 url = _add_query_parameter(url, 'uploadType', 'media')
810 else:
811 # This is a multipart/related upload.
812 msgRoot = MIMEMultipart('related')
813 # msgRoot should not write out it's own headers
814 setattr(msgRoot, '_write_headers', lambda self: None)
815
816 # attach the body as one part
817 msg = MIMENonMultipart(*headers['content-type'].split('/'))
818 msg.set_payload(body)
819 msgRoot.attach(msg)
820
821 # attach the media as the second part
822 msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
823 msg['Content-Transfer-Encoding'] = 'binary'
824
825 payload = media_upload.getbytes(0, media_upload.size())
826 msg.set_payload(payload)
827 msgRoot.attach(msg)
Craig Citro72389b72014-07-15 17:12:50 -0700828 # encode the body: note that we can't use `as_string`, because
829 # it plays games with `From ` lines.
Phil Ruffwind26178fc2015-10-13 19:00:33 -0400830 fp = BytesIO()
831 g = _BytesGenerator(fp, mangle_from_=False)
Craig Citro72389b72014-07-15 17:12:50 -0700832 g.flatten(msgRoot, unixfrom=False)
833 body = fp.getvalue()
John Asmuth864311d2014-04-24 15:46:08 -0400834
835 multipart_boundary = msgRoot.get_boundary()
836 headers['content-type'] = ('multipart/related; '
837 'boundary="%s"') % multipart_boundary
838 url = _add_query_parameter(url, 'uploadType', 'multipart')
839
Eric Gjertsen87553e42014-05-13 15:49:50 -0400840 logger.info('URL being requested: %s %s' % (httpMethod,url))
John Asmuth864311d2014-04-24 15:46:08 -0400841 return self._requestBuilder(self._http,
842 model.response,
843 url,
844 method=httpMethod,
845 body=body,
846 headers=headers,
847 methodId=methodId,
848 resumable=resumable)
849
850 docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
851 if len(parameters.argmap) > 0:
852 docs.append('Args:\n')
853
854 # Skip undocumented params and params common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900855 skip_parameters = list(rootDesc.get('parameters', {}).keys())
John Asmuth864311d2014-04-24 15:46:08 -0400856 skip_parameters.extend(STACK_QUERY_PARAMETERS)
857
INADA Naokie4ea1a92015-03-04 03:45:42 +0900858 all_args = list(parameters.argmap.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400859 args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
860
861 # Move body to the front of the line.
862 if 'body' in all_args:
863 args_ordered.append('body')
864
865 for name in all_args:
866 if name not in args_ordered:
867 args_ordered.append(name)
868
869 for arg in args_ordered:
870 if arg in skip_parameters:
871 continue
872
873 repeated = ''
874 if arg in parameters.repeated_params:
875 repeated = ' (repeated)'
876 required = ''
877 if arg in parameters.required_params:
878 required = ' (required)'
879 paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
880 paramdoc = paramdesc.get('description', 'A parameter')
881 if '$ref' in paramdesc:
882 docs.append(
883 (' %s: object, %s%s%s\n The object takes the'
884 ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
885 schema.prettyPrintByName(paramdesc['$ref'])))
886 else:
887 paramtype = paramdesc.get('type', 'string')
888 docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
889 repeated))
890 enum = paramdesc.get('enum', [])
891 enumDesc = paramdesc.get('enumDescriptions', [])
892 if enum and enumDesc:
893 docs.append(' Allowed values\n')
894 for (name, desc) in zip(enum, enumDesc):
895 docs.append(' %s - %s\n' % (name, desc))
896 if 'response' in methodDesc:
897 if methodName.endswith('_media'):
898 docs.append('\nReturns:\n The media object as a string.\n\n ')
899 else:
900 docs.append('\nReturns:\n An object of the form:\n\n ')
901 docs.append(schema.prettyPrintSchema(methodDesc['response']))
902
903 setattr(method, '__doc__', ''.join(docs))
904 return (methodName, method)
905
906
907def createNextMethod(methodName):
908 """Creates any _next methods for attaching to a Resource.
909
910 The _next methods allow for easy iteration through list() responses.
911
912 Args:
913 methodName: string, name of the method to use.
914 """
915 methodName = fix_method_name(methodName)
916
917 def methodNext(self, previous_request, previous_response):
918 """Retrieves the next page of results.
919
920Args:
921 previous_request: The request for the previous page. (required)
922 previous_response: The response from the request for the previous page. (required)
923
924Returns:
925 A request object that you can call 'execute()' on to request the next
926 page. Returns None if there are no more items in the collection.
927 """
928 # Retrieve nextPageToken from previous_response
929 # Use as pageToken in previous_request to create new request.
930
Son Dinh2a9a2132015-07-23 16:30:56 +0000931 if 'nextPageToken' not in previous_response or not previous_response['nextPageToken']:
John Asmuth864311d2014-04-24 15:46:08 -0400932 return None
933
934 request = copy.copy(previous_request)
935
936 pageToken = previous_response['nextPageToken']
Pat Ferated5b61bd2015-03-03 16:04:11 -0800937 parsed = list(urlparse(request.uri))
John Asmuth864311d2014-04-24 15:46:08 -0400938 q = parse_qsl(parsed[4])
939
940 # Find and remove old 'pageToken' value from URI
941 newq = [(key, value) for (key, value) in q if key != 'pageToken']
942 newq.append(('pageToken', pageToken))
Pat Ferated5b61bd2015-03-03 16:04:11 -0800943 parsed[4] = urlencode(newq)
944 uri = urlunparse(parsed)
John Asmuth864311d2014-04-24 15:46:08 -0400945
946 request.uri = uri
947
Eric Gjertsen87553e42014-05-13 15:49:50 -0400948 logger.info('URL being requested: %s %s' % (methodName,uri))
John Asmuth864311d2014-04-24 15:46:08 -0400949
950 return request
951
952 return (methodName, methodNext)
953
954
955class Resource(object):
956 """A class for interacting with a resource."""
957
958 def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
959 resourceDesc, rootDesc, schema):
960 """Build a Resource from the API description.
961
962 Args:
963 http: httplib2.Http, Object to make http requests with.
964 baseUrl: string, base URL for the API. All requests are relative to this
965 URI.
966 model: googleapiclient.Model, converts to and from the wire format.
967 requestBuilder: class or callable that instantiates an
968 googleapiclient.HttpRequest object.
969 developerKey: string, key obtained from
970 https://code.google.com/apis/console
971 resourceDesc: object, section of deserialized discovery document that
972 describes a resource. Note that the top level discovery document
973 is considered a resource.
974 rootDesc: object, the entire deserialized discovery document.
975 schema: object, mapping of schema names to schema descriptions.
976 """
977 self._dynamic_attrs = []
978
979 self._http = http
980 self._baseUrl = baseUrl
981 self._model = model
982 self._developerKey = developerKey
983 self._requestBuilder = requestBuilder
984 self._resourceDesc = resourceDesc
985 self._rootDesc = rootDesc
986 self._schema = schema
987
988 self._set_service_methods()
989
990 def _set_dynamic_attr(self, attr_name, value):
991 """Sets an instance attribute and tracks it in a list of dynamic attributes.
992
993 Args:
994 attr_name: string; The name of the attribute to be set
995 value: The value being set on the object and tracked in the dynamic cache.
996 """
997 self._dynamic_attrs.append(attr_name)
998 self.__dict__[attr_name] = value
999
1000 def __getstate__(self):
1001 """Trim the state down to something that can be pickled.
1002
1003 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1004 will be wiped and restored on pickle serialization.
1005 """
1006 state_dict = copy.copy(self.__dict__)
1007 for dynamic_attr in self._dynamic_attrs:
1008 del state_dict[dynamic_attr]
1009 del state_dict['_dynamic_attrs']
1010 return state_dict
1011
1012 def __setstate__(self, state):
1013 """Reconstitute the state of the object from being pickled.
1014
1015 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1016 will be wiped and restored on pickle serialization.
1017 """
1018 self.__dict__.update(state)
1019 self._dynamic_attrs = []
1020 self._set_service_methods()
1021
1022 def _set_service_methods(self):
1023 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
1024 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
1025 self._add_next_methods(self._resourceDesc, self._schema)
1026
1027 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001028 # If this is the root Resource, add a new_batch_http_request() method.
1029 if resourceDesc == rootDesc:
1030 batch_uri = '%s%s' % (
1031 rootDesc['rootUrl'], rootDesc.get('batchPath', 'batch'))
1032 def new_batch_http_request(callback=None):
Pepper Lebeck-Jobe56052ec2015-06-13 14:07:14 -04001033 """Create a BatchHttpRequest object based on the discovery document.
1034
1035 Args:
1036 callback: callable, A callback to be called for each response, of the
1037 form callback(id, response, exception). The first parameter is the
1038 request id, and the second is the deserialized response object. The
1039 third is an apiclient.errors.HttpError exception object if an HTTP
1040 error occurred while processing the request, or None if no error
1041 occurred.
1042
1043 Returns:
1044 A BatchHttpRequest object based on the discovery document.
1045 """
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001046 return BatchHttpRequest(callback=callback, batch_uri=batch_uri)
1047 self._set_dynamic_attr('new_batch_http_request', new_batch_http_request)
1048
John Asmuth864311d2014-04-24 15:46:08 -04001049 # Add basic methods to Resource
1050 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001051 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001052 fixedMethodName, method = createMethod(
1053 methodName, methodDesc, rootDesc, schema)
1054 self._set_dynamic_attr(fixedMethodName,
1055 method.__get__(self, self.__class__))
1056 # Add in _media methods. The functionality of the attached method will
1057 # change when it sees that the method name ends in _media.
1058 if methodDesc.get('supportsMediaDownload', False):
1059 fixedMethodName, method = createMethod(
1060 methodName + '_media', methodDesc, rootDesc, schema)
1061 self._set_dynamic_attr(fixedMethodName,
1062 method.__get__(self, self.__class__))
1063
1064 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
1065 # Add in nested resources
1066 if 'resources' in resourceDesc:
1067
1068 def createResourceMethod(methodName, methodDesc):
1069 """Create a method on the Resource to access a nested Resource.
1070
1071 Args:
1072 methodName: string, name of the method to use.
1073 methodDesc: object, fragment of deserialized discovery document that
1074 describes the method.
1075 """
1076 methodName = fix_method_name(methodName)
1077
1078 def methodResource(self):
1079 return Resource(http=self._http, baseUrl=self._baseUrl,
1080 model=self._model, developerKey=self._developerKey,
1081 requestBuilder=self._requestBuilder,
1082 resourceDesc=methodDesc, rootDesc=rootDesc,
1083 schema=schema)
1084
1085 setattr(methodResource, '__doc__', 'A collection resource.')
1086 setattr(methodResource, '__is_resource__', True)
1087
1088 return (methodName, methodResource)
1089
INADA Naokie4ea1a92015-03-04 03:45:42 +09001090 for methodName, methodDesc in six.iteritems(resourceDesc['resources']):
John Asmuth864311d2014-04-24 15:46:08 -04001091 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
1092 self._set_dynamic_attr(fixedMethodName,
1093 method.__get__(self, self.__class__))
1094
1095 def _add_next_methods(self, resourceDesc, schema):
1096 # Add _next() methods
1097 # Look for response bodies in schema that contain nextPageToken, and methods
1098 # that take a pageToken parameter.
1099 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001100 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001101 if 'response' in methodDesc:
1102 responseSchema = methodDesc['response']
1103 if '$ref' in responseSchema:
1104 responseSchema = schema.get(responseSchema['$ref'])
1105 hasNextPageToken = 'nextPageToken' in responseSchema.get('properties',
1106 {})
1107 hasPageToken = 'pageToken' in methodDesc.get('parameters', {})
1108 if hasNextPageToken and hasPageToken:
1109 fixedMethodName, method = createNextMethod(methodName + '_next')
1110 self._set_dynamic_attr(fixedMethodName,
1111 method.__get__(self, self.__class__))