blob: 7762d84fa565e16fa8e2d9edfad8347e0d77d738 [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
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -080056from googleapiclient import _auth
Pat Ferateb240c172015-03-03 16:23:51 -080057from googleapiclient import mimeparse
John Asmuth864311d2014-04-24 15:46:08 -040058from googleapiclient.errors import HttpError
59from googleapiclient.errors import InvalidJsonError
60from googleapiclient.errors import MediaUploadSizeError
61from googleapiclient.errors import UnacceptableMimeTypeError
62from googleapiclient.errors import UnknownApiNameOrVersion
63from googleapiclient.errors import UnknownFileType
Igor Maravić22435292017-01-19 22:28:22 +010064from googleapiclient.http import build_http
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -040065from googleapiclient.http import BatchHttpRequest
Kostyantyn Leschenkobe8b1cb2016-10-17 12:57:21 +030066from googleapiclient.http import HttpMock
67from googleapiclient.http import HttpMockSequence
John Asmuth864311d2014-04-24 15:46:08 -040068from googleapiclient.http import HttpRequest
69from googleapiclient.http import MediaFileUpload
70from googleapiclient.http import MediaUpload
71from googleapiclient.model import JsonModel
72from googleapiclient.model import MediaModel
73from googleapiclient.model import RawModel
74from googleapiclient.schema import Schemas
Jon Wayne Parrott6755f612016-08-15 10:52:26 -070075
Helen Koikede13e3b2018-04-26 16:05:16 -030076from googleapiclient._helpers import _add_query_parameter
77from googleapiclient._helpers import positional
John Asmuth864311d2014-04-24 15:46:08 -040078
79
80# The client library requires a version of httplib2 that supports RETRIES.
81httplib2.RETRIES = 1
82
83logger = logging.getLogger(__name__)
84
85URITEMPLATE = re.compile('{[^}]*}')
86VARNAME = re.compile('[a-zA-Z0-9_-]+')
87DISCOVERY_URI = ('https://www.googleapis.com/discovery/v1/apis/'
88 '{api}/{apiVersion}/rest')
Ethan Bao12b7cd32016-03-14 14:25:10 -070089V1_DISCOVERY_URI = DISCOVERY_URI
90V2_DISCOVERY_URI = ('https://{api}.googleapis.com/$discovery/rest?'
91 'version={apiVersion}')
John Asmuth864311d2014-04-24 15:46:08 -040092DEFAULT_METHOD_DOC = 'A description of how to use this function'
93HTTP_PAYLOAD_METHODS = frozenset(['PUT', 'POST', 'PATCH'])
Igor Maravić22435292017-01-19 22:28:22 +010094
John Asmuth864311d2014-04-24 15:46:08 -040095_MEDIA_SIZE_BIT_SHIFTS = {'KB': 10, 'MB': 20, 'GB': 30, 'TB': 40}
96BODY_PARAMETER_DEFAULT_VALUE = {
97 'description': 'The request body.',
98 'type': 'object',
99 'required': True,
100}
101MEDIA_BODY_PARAMETER_DEFAULT_VALUE = {
102 'description': ('The filename of the media request body, or an instance '
103 'of a MediaUpload object.'),
104 'type': 'string',
105 'required': False,
106}
Brian J. Watson38051ac2016-10-25 07:53:08 -0700107MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE = {
108 'description': ('The MIME type of the media request body, or an instance '
109 'of a MediaUpload object.'),
110 'type': 'string',
111 'required': False,
112}
Thomas Coffee20af04d2017-02-10 15:24:44 -0800113_PAGE_TOKEN_NAMES = ('pageToken', 'nextPageToken')
John Asmuth864311d2014-04-24 15:46:08 -0400114
115# Parameters accepted by the stack, but not visible via discovery.
116# TODO(dhermes): Remove 'userip' in 'v2'.
117STACK_QUERY_PARAMETERS = frozenset(['trace', 'pp', 'userip', 'strict'])
118STACK_QUERY_PARAMETER_DEFAULT_VALUE = {'type': 'string', 'location': 'query'}
119
120# Library-specific reserved words beyond Python keywords.
121RESERVED_WORDS = frozenset(['body'])
122
Phil Ruffwind26178fc2015-10-13 19:00:33 -0400123# patch _write_lines to avoid munging '\r' into '\n'
124# ( https://bugs.python.org/issue18886 https://bugs.python.org/issue19003 )
125class _BytesGenerator(BytesGenerator):
126 _write_lines = BytesGenerator.write
John Asmuth864311d2014-04-24 15:46:08 -0400127
128def fix_method_name(name):
129 """Fix method names to avoid reserved word conflicts.
130
131 Args:
132 name: string, method name.
133
134 Returns:
Matthew Whisenhuntbc335952018-04-25 11:21:42 -0700135 The name with an '_' appended if the name is a reserved word.
John Asmuth864311d2014-04-24 15:46:08 -0400136 """
137 if keyword.iskeyword(name) or name in RESERVED_WORDS:
138 return name + '_'
139 else:
140 return name
141
142
143def key2param(key):
144 """Converts key names into parameter names.
145
146 For example, converting "max-results" -> "max_results"
147
148 Args:
149 key: string, the method key name.
150
151 Returns:
152 A safe method name based on the key name.
153 """
154 result = []
155 key = list(key)
156 if not key[0].isalpha():
157 result.append('x')
158 for c in key:
159 if c.isalnum():
160 result.append(c)
161 else:
162 result.append('_')
163
164 return ''.join(result)
165
166
167@positional(2)
168def build(serviceName,
169 version,
170 http=None,
171 discoveryServiceUrl=DISCOVERY_URI,
172 developerKey=None,
173 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700174 requestBuilder=HttpRequest,
Takashi Matsuo30125122015-08-19 11:42:32 -0700175 credentials=None,
176 cache_discovery=True,
177 cache=None):
John Asmuth864311d2014-04-24 15:46:08 -0400178 """Construct a Resource for interacting with an API.
179
180 Construct a Resource object for interacting with an API. The serviceName and
181 version are the names from the Discovery service.
182
183 Args:
184 serviceName: string, name of the service.
185 version: string, the version of the service.
186 http: httplib2.Http, An instance of httplib2.Http or something that acts
187 like it that HTTP requests will be made through.
188 discoveryServiceUrl: string, a URI Template that points to the location of
189 the discovery service. It should have two parameters {api} and
190 {apiVersion} that when filled in produce an absolute URI to the discovery
191 document for that service.
192 developerKey: string, key obtained from
193 https://code.google.com/apis/console.
194 model: googleapiclient.Model, converts to and from the wire format.
195 requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
196 request.
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800197 credentials: oauth2client.Credentials or
198 google.auth.credentials.Credentials, credentials to be used for
Orest Bolohane92c9002014-05-30 11:15:43 -0700199 authentication.
Takashi Matsuo30125122015-08-19 11:42:32 -0700200 cache_discovery: Boolean, whether or not to cache the discovery doc.
201 cache: googleapiclient.discovery_cache.base.CacheBase, an optional
202 cache object for the discovery documents.
John Asmuth864311d2014-04-24 15:46:08 -0400203
204 Returns:
205 A Resource object with methods for interacting with the service.
206 """
207 params = {
208 'api': serviceName,
209 'apiVersion': version
210 }
211
Igor Maravić22435292017-01-19 22:28:22 +0100212 if http is None:
213 discovery_http = build_http()
214 else:
215 discovery_http = http
John Asmuth864311d2014-04-24 15:46:08 -0400216
Ethan Bao12b7cd32016-03-14 14:25:10 -0700217 for discovery_url in (discoveryServiceUrl, V2_DISCOVERY_URI,):
218 requested_url = uritemplate.expand(discovery_url, params)
John Asmuth864311d2014-04-24 15:46:08 -0400219
Ethan Bao12b7cd32016-03-14 14:25:10 -0700220 try:
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800221 content = _retrieve_discovery_doc(
222 requested_url, discovery_http, cache_discovery, cache)
Ethan Bao12b7cd32016-03-14 14:25:10 -0700223 return build_from_document(content, base=discovery_url, http=http,
224 developerKey=developerKey, model=model, requestBuilder=requestBuilder,
225 credentials=credentials)
226 except HttpError as e:
227 if e.resp.status == http_client.NOT_FOUND:
228 continue
229 else:
230 raise e
Takashi Matsuo30125122015-08-19 11:42:32 -0700231
Ethan Bao12b7cd32016-03-14 14:25:10 -0700232 raise UnknownApiNameOrVersion(
233 "name: %s version: %s" % (serviceName, version))
Takashi Matsuo30125122015-08-19 11:42:32 -0700234
235
236def _retrieve_discovery_doc(url, http, cache_discovery, cache=None):
237 """Retrieves the discovery_doc from cache or the internet.
238
239 Args:
240 url: string, the URL of the discovery document.
241 http: httplib2.Http, An instance of httplib2.Http or something that acts
242 like it through which HTTP requests will be made.
243 cache_discovery: Boolean, whether or not to cache the discovery doc.
244 cache: googleapiclient.discovery_cache.base.Cache, an optional cache
245 object for the discovery documents.
246
247 Returns:
248 A unicode string representation of the discovery document.
249 """
250 if cache_discovery:
251 from . import discovery_cache
252 from .discovery_cache import base
253 if cache is None:
254 cache = discovery_cache.autodetect()
255 if cache:
256 content = cache.get(url)
257 if content:
258 return content
259
260 actual_url = url
John Asmuth864311d2014-04-24 15:46:08 -0400261 # REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
262 # variable that contains the network address of the client sending the
263 # request. If it exists then add that to the request for the discovery
264 # document to avoid exceeding the quota on discovery requests.
265 if 'REMOTE_ADDR' in os.environ:
Takashi Matsuo30125122015-08-19 11:42:32 -0700266 actual_url = _add_query_parameter(url, 'userIp', os.environ['REMOTE_ADDR'])
267 logger.info('URL being requested: GET %s', actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400268
Takashi Matsuo30125122015-08-19 11:42:32 -0700269 resp, content = http.request(actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400270
John Asmuth864311d2014-04-24 15:46:08 -0400271 if resp.status >= 400:
Takashi Matsuo30125122015-08-19 11:42:32 -0700272 raise HttpError(resp, content, uri=actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400273
274 try:
Pat Ferate9b0452c2015-03-03 17:59:56 -0800275 content = content.decode('utf-8')
276 except AttributeError:
277 pass
278
279 try:
Craig Citro6ae34d72014-08-18 23:10:09 -0700280 service = json.loads(content)
INADA Naokic1505df2014-08-20 15:19:53 +0900281 except ValueError as e:
John Asmuth864311d2014-04-24 15:46:08 -0400282 logger.error('Failed to parse as JSON: ' + content)
283 raise InvalidJsonError()
Takashi Matsuo30125122015-08-19 11:42:32 -0700284 if cache_discovery and cache:
285 cache.set(url, content)
286 return content
John Asmuth864311d2014-04-24 15:46:08 -0400287
288
289@positional(1)
290def build_from_document(
291 service,
292 base=None,
293 future=None,
294 http=None,
295 developerKey=None,
296 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700297 requestBuilder=HttpRequest,
298 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400299 """Create a Resource for interacting with an API.
300
301 Same as `build()`, but constructs the Resource object from a discovery
302 document that is it given, as opposed to retrieving one over HTTP.
303
304 Args:
305 service: string or object, the JSON discovery document describing the API.
306 The value passed in may either be the JSON string or the deserialized
307 JSON.
308 base: string, base URI for all HTTP requests, usually the discovery URI.
309 This parameter is no longer used as rootUrl and servicePath are included
310 within the discovery document. (deprecated)
311 future: string, discovery document with future capabilities (deprecated).
312 http: httplib2.Http, An instance of httplib2.Http or something that acts
313 like it that HTTP requests will be made through.
314 developerKey: string, Key for controlling API usage, generated
315 from the API Console.
316 model: Model class instance that serializes and de-serializes requests and
317 responses.
318 requestBuilder: Takes an http request and packages it up to be executed.
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800319 credentials: oauth2client.Credentials or
320 google.auth.credentials.Credentials, credentials to be used for
321 authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400322
323 Returns:
324 A Resource object with methods for interacting with the service.
325 """
326
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800327 if http is not None and credentials is not None:
328 raise ValueError('Arguments http and credentials are mutually exclusive.')
John Asmuth864311d2014-04-24 15:46:08 -0400329
INADA Naokie4ea1a92015-03-04 03:45:42 +0900330 if isinstance(service, six.string_types):
Craig Citro6ae34d72014-08-18 23:10:09 -0700331 service = json.loads(service)
Christian Ternuse469a9f2016-08-16 12:44:03 -0400332
333 if 'rootUrl' not in service and (isinstance(http, (HttpMock,
334 HttpMockSequence))):
335 logger.error("You are using HttpMock or HttpMockSequence without" +
336 "having the service discovery doc in cache. Try calling " +
337 "build() without mocking once first to populate the " +
338 "cache.")
339 raise InvalidJsonError()
340
Pat Ferated5b61bd2015-03-03 16:04:11 -0800341 base = urljoin(service['rootUrl'], service['servicePath'])
John Asmuth864311d2014-04-24 15:46:08 -0400342 schema = Schemas(service)
343
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800344 # If the http client is not specified, then we must construct an http client
345 # to make requests. If the service has scopes, then we also need to setup
346 # authentication.
347 if http is None:
348 # Does the service require scopes?
349 scopes = list(
350 service.get('auth', {}).get('oauth2', {}).get('scopes', {}).keys())
Orest Bolohane92c9002014-05-30 11:15:43 -0700351
Jon Wayne Parrott068eb352017-02-08 10:13:06 -0800352 # If so, then the we need to setup authentication if no developerKey is
353 # specified.
354 if scopes and not developerKey:
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800355 # If the user didn't pass in credentials, attempt to acquire application
356 # default credentials.
357 if credentials is None:
358 credentials = _auth.default_credentials()
359
360 # The credentials need to be scoped.
361 credentials = _auth.with_scopes(credentials, scopes)
362
363 # Create an authorized http instance
364 http = _auth.authorized_http(credentials)
365
366 # If the service doesn't require scopes then there is no need for
367 # authentication.
368 else:
Igor Maravić22435292017-01-19 22:28:22 +0100369 http = build_http()
Orest Bolohane92c9002014-05-30 11:15:43 -0700370
John Asmuth864311d2014-04-24 15:46:08 -0400371 if model is None:
372 features = service.get('features', [])
373 model = JsonModel('dataWrapper' in features)
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800374
John Asmuth864311d2014-04-24 15:46:08 -0400375 return Resource(http=http, baseUrl=base, model=model,
376 developerKey=developerKey, requestBuilder=requestBuilder,
377 resourceDesc=service, rootDesc=service, schema=schema)
378
379
380def _cast(value, schema_type):
381 """Convert value to a string based on JSON Schema type.
382
383 See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
384 JSON Schema.
385
386 Args:
387 value: any, the value to convert
388 schema_type: string, the type that value should be interpreted as
389
390 Returns:
391 A string representation of 'value' based on the schema_type.
392 """
393 if schema_type == 'string':
394 if type(value) == type('') or type(value) == type(u''):
395 return value
396 else:
397 return str(value)
398 elif schema_type == 'integer':
399 return str(int(value))
400 elif schema_type == 'number':
401 return str(float(value))
402 elif schema_type == 'boolean':
403 return str(bool(value)).lower()
404 else:
405 if type(value) == type('') or type(value) == type(u''):
406 return value
407 else:
408 return str(value)
409
410
411def _media_size_to_long(maxSize):
412 """Convert a string media size, such as 10GB or 3TB into an integer.
413
414 Args:
415 maxSize: string, size as a string, such as 2MB or 7GB.
416
417 Returns:
418 The size as an integer value.
419 """
420 if len(maxSize) < 2:
INADA Naoki0bceb332014-08-20 15:27:52 +0900421 return 0
John Asmuth864311d2014-04-24 15:46:08 -0400422 units = maxSize[-2:].upper()
423 bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
424 if bit_shift is not None:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900425 return int(maxSize[:-2]) << bit_shift
John Asmuth864311d2014-04-24 15:46:08 -0400426 else:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900427 return int(maxSize)
John Asmuth864311d2014-04-24 15:46:08 -0400428
429
430def _media_path_url_from_info(root_desc, path_url):
431 """Creates an absolute media path URL.
432
433 Constructed using the API root URI and service path from the discovery
434 document and the relative path for the API method.
435
436 Args:
437 root_desc: Dictionary; the entire original deserialized discovery document.
438 path_url: String; the relative URL for the API method. Relative to the API
439 root, which is specified in the discovery document.
440
441 Returns:
442 String; the absolute URI for media upload for the API method.
443 """
444 return '%(root)supload/%(service_path)s%(path)s' % {
445 'root': root_desc['rootUrl'],
446 'service_path': root_desc['servicePath'],
447 'path': path_url,
448 }
449
450
Jean-Loup Roussel-Clouet0c0c8972018-04-28 05:42:43 +0900451def _fix_up_parameters(method_desc, root_desc, http_method, schema):
John Asmuth864311d2014-04-24 15:46:08 -0400452 """Updates parameters of an API method with values specific to this library.
453
454 Specifically, adds whatever global parameters are specified by the API to the
455 parameters for the individual method. Also adds parameters which don't
456 appear in the discovery document, but are available to all discovery based
457 APIs (these are listed in STACK_QUERY_PARAMETERS).
458
459 SIDE EFFECTS: This updates the parameters dictionary object in the method
460 description.
461
462 Args:
463 method_desc: Dictionary with metadata describing an API method. Value comes
464 from the dictionary of methods stored in the 'methods' key in the
465 deserialized discovery document.
466 root_desc: Dictionary; the entire original deserialized discovery document.
467 http_method: String; the HTTP method used to call the API method described
468 in method_desc.
Jean-Loup Roussel-Clouet0c0c8972018-04-28 05:42:43 +0900469 schema: Object, mapping of schema names to schema descriptions.
John Asmuth864311d2014-04-24 15:46:08 -0400470
471 Returns:
472 The updated Dictionary stored in the 'parameters' key of the method
473 description dictionary.
474 """
475 parameters = method_desc.setdefault('parameters', {})
476
477 # Add in the parameters common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900478 for name, description in six.iteritems(root_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400479 parameters[name] = description
480
481 # Add in undocumented query parameters.
482 for name in STACK_QUERY_PARAMETERS:
483 parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
484
485 # Add 'body' (our own reserved word) to parameters if the method supports
486 # a request payload.
487 if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
488 body = BODY_PARAMETER_DEFAULT_VALUE.copy()
489 body.update(method_desc['request'])
Jean-Loup Roussel-Clouet0c0c8972018-04-28 05:42:43 +0900490 # Make body optional for requests with no parameters.
491 if not _methodProperties(method_desc, schema, 'request'):
492 body['required'] = False
John Asmuth864311d2014-04-24 15:46:08 -0400493 parameters['body'] = body
494
495 return parameters
496
497
498def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
Brian J. Watson38051ac2016-10-25 07:53:08 -0700499 """Adds 'media_body' and 'media_mime_type' parameters if supported by method.
John Asmuth864311d2014-04-24 15:46:08 -0400500
501 SIDE EFFECTS: If the method supports media upload and has a required body,
502 sets body to be optional (required=False) instead. Also, if there is a
503 'mediaUpload' in the method description, adds 'media_upload' key to
504 parameters.
505
506 Args:
507 method_desc: Dictionary with metadata describing an API method. Value comes
508 from the dictionary of methods stored in the 'methods' key in the
509 deserialized discovery document.
510 root_desc: Dictionary; the entire original deserialized discovery document.
511 path_url: String; the relative URL for the API method. Relative to the API
512 root, which is specified in the discovery document.
513 parameters: A dictionary describing method parameters for method described
514 in method_desc.
515
516 Returns:
517 Triple (accept, max_size, media_path_url) where:
518 - accept is a list of strings representing what content types are
519 accepted for media upload. Defaults to empty list if not in the
520 discovery document.
521 - max_size is a long representing the max size in bytes allowed for a
522 media upload. Defaults to 0L if not in the discovery document.
523 - media_path_url is a String; the absolute URI for media upload for the
524 API method. Constructed using the API root URI and service path from
525 the discovery document and the relative path for the API method. If
526 media upload is not supported, this is None.
527 """
528 media_upload = method_desc.get('mediaUpload', {})
529 accept = media_upload.get('accept', [])
530 max_size = _media_size_to_long(media_upload.get('maxSize', ''))
531 media_path_url = None
532
533 if media_upload:
534 media_path_url = _media_path_url_from_info(root_desc, path_url)
535 parameters['media_body'] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
Brian J. Watson38051ac2016-10-25 07:53:08 -0700536 parameters['media_mime_type'] = MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE.copy()
John Asmuth864311d2014-04-24 15:46:08 -0400537 if 'body' in parameters:
538 parameters['body']['required'] = False
539
540 return accept, max_size, media_path_url
541
542
Jean-Loup Roussel-Clouet0c0c8972018-04-28 05:42:43 +0900543def _fix_up_method_description(method_desc, root_desc, schema):
John Asmuth864311d2014-04-24 15:46:08 -0400544 """Updates a method description in a discovery document.
545
546 SIDE EFFECTS: Changes the parameters dictionary in the method description with
547 extra parameters which are used locally.
548
549 Args:
550 method_desc: Dictionary with metadata describing an API method. Value comes
551 from the dictionary of methods stored in the 'methods' key in the
552 deserialized discovery document.
553 root_desc: Dictionary; the entire original deserialized discovery document.
Jean-Loup Roussel-Clouet0c0c8972018-04-28 05:42:43 +0900554 schema: Object, mapping of schema names to schema descriptions.
John Asmuth864311d2014-04-24 15:46:08 -0400555
556 Returns:
557 Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
558 where:
559 - path_url is a String; the relative URL for the API method. Relative to
560 the API root, which is specified in the discovery document.
561 - http_method is a String; the HTTP method used to call the API method
562 described in the method description.
563 - method_id is a String; the name of the RPC method associated with the
564 API method, and is in the method description in the 'id' key.
565 - accept is a list of strings representing what content types are
566 accepted for media upload. Defaults to empty list if not in the
567 discovery document.
568 - max_size is a long representing the max size in bytes allowed for a
569 media upload. Defaults to 0L if not in the discovery document.
570 - media_path_url is a String; the absolute URI for media upload for the
571 API method. Constructed using the API root URI and service path from
572 the discovery document and the relative path for the API method. If
573 media upload is not supported, this is None.
574 """
575 path_url = method_desc['path']
576 http_method = method_desc['httpMethod']
577 method_id = method_desc['id']
578
Jean-Loup Roussel-Clouet0c0c8972018-04-28 05:42:43 +0900579 parameters = _fix_up_parameters(method_desc, root_desc, http_method, schema)
John Asmuth864311d2014-04-24 15:46:08 -0400580 # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
581 # 'parameters' key and needs to know if there is a 'body' parameter because it
582 # also sets a 'media_body' parameter.
583 accept, max_size, media_path_url = _fix_up_media_upload(
584 method_desc, root_desc, path_url, parameters)
585
586 return path_url, http_method, method_id, accept, max_size, media_path_url
587
588
Craig Citro7ee535d2015-02-23 10:11:14 -0800589def _urljoin(base, url):
590 """Custom urljoin replacement supporting : before / in url."""
591 # In general, it's unsafe to simply join base and url. However, for
592 # the case of discovery documents, we know:
593 # * base will never contain params, query, or fragment
594 # * url will never contain a scheme or net_loc.
595 # In general, this means we can safely join on /; we just need to
596 # ensure we end up with precisely one / joining base and url. The
597 # exception here is the case of media uploads, where url will be an
598 # absolute url.
599 if url.startswith('http://') or url.startswith('https://'):
Pat Ferated5b61bd2015-03-03 16:04:11 -0800600 return urljoin(base, url)
Craig Citro7ee535d2015-02-23 10:11:14 -0800601 new_base = base if base.endswith('/') else base + '/'
602 new_url = url[1:] if url.startswith('/') else url
603 return new_base + new_url
604
605
John Asmuth864311d2014-04-24 15:46:08 -0400606# TODO(dhermes): Convert this class to ResourceMethod and make it callable
607class ResourceMethodParameters(object):
608 """Represents the parameters associated with a method.
609
610 Attributes:
611 argmap: Map from method parameter name (string) to query parameter name
612 (string).
613 required_params: List of required parameters (represented by parameter
614 name as string).
615 repeated_params: List of repeated parameters (represented by parameter
616 name as string).
617 pattern_params: Map from method parameter name (string) to regular
618 expression (as a string). If the pattern is set for a parameter, the
619 value for that parameter must match the regular expression.
620 query_params: List of parameters (represented by parameter name as string)
621 that will be used in the query string.
622 path_params: Set of parameters (represented by parameter name as string)
623 that will be used in the base URL path.
624 param_types: Map from method parameter name (string) to parameter type. Type
625 can be any valid JSON schema type; valid values are 'any', 'array',
626 'boolean', 'integer', 'number', 'object', or 'string'. Reference:
627 http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
628 enum_params: Map from method parameter name (string) to list of strings,
629 where each list of strings is the list of acceptable enum values.
630 """
631
632 def __init__(self, method_desc):
633 """Constructor for ResourceMethodParameters.
634
635 Sets default values and defers to set_parameters to populate.
636
637 Args:
638 method_desc: Dictionary with metadata describing an API method. Value
639 comes from the dictionary of methods stored in the 'methods' key in
640 the deserialized discovery document.
641 """
642 self.argmap = {}
643 self.required_params = []
644 self.repeated_params = []
645 self.pattern_params = {}
646 self.query_params = []
647 # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
648 # parsing is gotten rid of.
649 self.path_params = set()
650 self.param_types = {}
651 self.enum_params = {}
652
653 self.set_parameters(method_desc)
654
655 def set_parameters(self, method_desc):
656 """Populates maps and lists based on method description.
657
658 Iterates through each parameter for the method and parses the values from
659 the parameter dictionary.
660
661 Args:
662 method_desc: Dictionary with metadata describing an API method. Value
663 comes from the dictionary of methods stored in the 'methods' key in
664 the deserialized discovery document.
665 """
INADA Naokie4ea1a92015-03-04 03:45:42 +0900666 for arg, desc in six.iteritems(method_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400667 param = key2param(arg)
668 self.argmap[param] = arg
669
670 if desc.get('pattern'):
671 self.pattern_params[param] = desc['pattern']
672 if desc.get('enum'):
673 self.enum_params[param] = desc['enum']
674 if desc.get('required'):
675 self.required_params.append(param)
676 if desc.get('repeated'):
677 self.repeated_params.append(param)
678 if desc.get('location') == 'query':
679 self.query_params.append(param)
680 if desc.get('location') == 'path':
681 self.path_params.add(param)
682 self.param_types[param] = desc.get('type', 'string')
683
684 # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
685 # should have all path parameters already marked with
686 # 'location: path'.
687 for match in URITEMPLATE.finditer(method_desc['path']):
688 for namematch in VARNAME.finditer(match.group(0)):
689 name = key2param(namematch.group(0))
690 self.path_params.add(name)
691 if name in self.query_params:
692 self.query_params.remove(name)
693
694
695def createMethod(methodName, methodDesc, rootDesc, schema):
696 """Creates a method for attaching to a Resource.
697
698 Args:
699 methodName: string, name of the method to use.
700 methodDesc: object, fragment of deserialized discovery document that
701 describes the method.
702 rootDesc: object, the entire deserialized discovery document.
703 schema: object, mapping of schema names to schema descriptions.
704 """
705 methodName = fix_method_name(methodName)
706 (pathUrl, httpMethod, methodId, accept,
Jean-Loup Roussel-Clouet0c0c8972018-04-28 05:42:43 +0900707 maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc, schema)
John Asmuth864311d2014-04-24 15:46:08 -0400708
709 parameters = ResourceMethodParameters(methodDesc)
710
711 def method(self, **kwargs):
712 # Don't bother with doc string, it will be over-written by createMethod.
713
INADA Naokie4ea1a92015-03-04 03:45:42 +0900714 for name in six.iterkeys(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400715 if name not in parameters.argmap:
716 raise TypeError('Got an unexpected keyword argument "%s"' % name)
717
718 # Remove args that have a value of None.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900719 keys = list(kwargs.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400720 for name in keys:
721 if kwargs[name] is None:
722 del kwargs[name]
723
724 for name in parameters.required_params:
725 if name not in kwargs:
Thomas Coffee20af04d2017-02-10 15:24:44 -0800726 # temporary workaround for non-paging methods incorrectly requiring
727 # page token parameter (cf. drive.changes.watch vs. drive.changes.list)
728 if name not in _PAGE_TOKEN_NAMES or _findPageTokenName(
729 _methodProperties(methodDesc, schema, 'response')):
730 raise TypeError('Missing required parameter "%s"' % name)
John Asmuth864311d2014-04-24 15:46:08 -0400731
INADA Naokie4ea1a92015-03-04 03:45:42 +0900732 for name, regex in six.iteritems(parameters.pattern_params):
John Asmuth864311d2014-04-24 15:46:08 -0400733 if name in kwargs:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900734 if isinstance(kwargs[name], six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400735 pvalues = [kwargs[name]]
736 else:
737 pvalues = kwargs[name]
738 for pvalue in pvalues:
739 if re.match(regex, pvalue) is None:
740 raise TypeError(
741 'Parameter "%s" value "%s" does not match the pattern "%s"' %
742 (name, pvalue, regex))
743
INADA Naokie4ea1a92015-03-04 03:45:42 +0900744 for name, enums in six.iteritems(parameters.enum_params):
John Asmuth864311d2014-04-24 15:46:08 -0400745 if name in kwargs:
746 # We need to handle the case of a repeated enum
747 # name differently, since we want to handle both
748 # arg='value' and arg=['value1', 'value2']
749 if (name in parameters.repeated_params and
INADA Naokie4ea1a92015-03-04 03:45:42 +0900750 not isinstance(kwargs[name], six.string_types)):
John Asmuth864311d2014-04-24 15:46:08 -0400751 values = kwargs[name]
752 else:
753 values = [kwargs[name]]
754 for value in values:
755 if value not in enums:
756 raise TypeError(
757 'Parameter "%s" value "%s" is not an allowed value in "%s"' %
758 (name, value, str(enums)))
759
760 actual_query_params = {}
761 actual_path_params = {}
INADA Naokie4ea1a92015-03-04 03:45:42 +0900762 for key, value in six.iteritems(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400763 to_type = parameters.param_types.get(key, 'string')
764 # For repeated parameters we cast each member of the list.
765 if key in parameters.repeated_params and type(value) == type([]):
766 cast_value = [_cast(x, to_type) for x in value]
767 else:
768 cast_value = _cast(value, to_type)
769 if key in parameters.query_params:
770 actual_query_params[parameters.argmap[key]] = cast_value
771 if key in parameters.path_params:
772 actual_path_params[parameters.argmap[key]] = cast_value
773 body_value = kwargs.get('body', None)
774 media_filename = kwargs.get('media_body', None)
Brian J. Watson38051ac2016-10-25 07:53:08 -0700775 media_mime_type = kwargs.get('media_mime_type', None)
John Asmuth864311d2014-04-24 15:46:08 -0400776
777 if self._developerKey:
778 actual_query_params['key'] = self._developerKey
779
780 model = self._model
781 if methodName.endswith('_media'):
782 model = MediaModel()
783 elif 'response' not in methodDesc:
784 model = RawModel()
785
786 headers = {}
787 headers, params, query, body = model.request(headers,
788 actual_path_params, actual_query_params, body_value)
789
790 expanded_url = uritemplate.expand(pathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800791 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400792
793 resumable = None
794 multipart_boundary = ''
795
796 if media_filename:
797 # Ensure we end up with a valid MediaUpload object.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900798 if isinstance(media_filename, six.string_types):
Brian J. Watson38051ac2016-10-25 07:53:08 -0700799 if media_mime_type is None:
800 logger.warning(
801 'media_mime_type argument not specified: trying to auto-detect for %s',
802 media_filename)
803 media_mime_type, _ = mimetypes.guess_type(media_filename)
John Asmuth864311d2014-04-24 15:46:08 -0400804 if media_mime_type is None:
805 raise UnknownFileType(media_filename)
806 if not mimeparse.best_match([media_mime_type], ','.join(accept)):
807 raise UnacceptableMimeTypeError(media_mime_type)
808 media_upload = MediaFileUpload(media_filename,
809 mimetype=media_mime_type)
810 elif isinstance(media_filename, MediaUpload):
811 media_upload = media_filename
812 else:
813 raise TypeError('media_filename must be str or MediaUpload.')
814
815 # Check the maxSize
Pat Feratec46e9052015-03-03 17:59:17 -0800816 if media_upload.size() is not None and media_upload.size() > maxSize > 0:
John Asmuth864311d2014-04-24 15:46:08 -0400817 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
818
819 # Use the media path uri for media uploads
820 expanded_url = uritemplate.expand(mediaPathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800821 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400822 if media_upload.resumable():
823 url = _add_query_parameter(url, 'uploadType', 'resumable')
824
825 if media_upload.resumable():
826 # This is all we need to do for resumable, if the body exists it gets
827 # sent in the first request, otherwise an empty body is sent.
828 resumable = media_upload
829 else:
830 # A non-resumable upload
831 if body is None:
832 # This is a simple media upload
833 headers['content-type'] = media_upload.mimetype()
834 body = media_upload.getbytes(0, media_upload.size())
835 url = _add_query_parameter(url, 'uploadType', 'media')
836 else:
837 # This is a multipart/related upload.
838 msgRoot = MIMEMultipart('related')
839 # msgRoot should not write out it's own headers
840 setattr(msgRoot, '_write_headers', lambda self: None)
841
842 # attach the body as one part
843 msg = MIMENonMultipart(*headers['content-type'].split('/'))
844 msg.set_payload(body)
845 msgRoot.attach(msg)
846
847 # attach the media as the second part
848 msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
849 msg['Content-Transfer-Encoding'] = 'binary'
850
851 payload = media_upload.getbytes(0, media_upload.size())
852 msg.set_payload(payload)
853 msgRoot.attach(msg)
Craig Citro72389b72014-07-15 17:12:50 -0700854 # encode the body: note that we can't use `as_string`, because
855 # it plays games with `From ` lines.
Phil Ruffwind26178fc2015-10-13 19:00:33 -0400856 fp = BytesIO()
857 g = _BytesGenerator(fp, mangle_from_=False)
Craig Citro72389b72014-07-15 17:12:50 -0700858 g.flatten(msgRoot, unixfrom=False)
859 body = fp.getvalue()
John Asmuth864311d2014-04-24 15:46:08 -0400860
861 multipart_boundary = msgRoot.get_boundary()
862 headers['content-type'] = ('multipart/related; '
863 'boundary="%s"') % multipart_boundary
864 url = _add_query_parameter(url, 'uploadType', 'multipart')
865
Eric Gjertsen87553e42014-05-13 15:49:50 -0400866 logger.info('URL being requested: %s %s' % (httpMethod,url))
John Asmuth864311d2014-04-24 15:46:08 -0400867 return self._requestBuilder(self._http,
868 model.response,
869 url,
870 method=httpMethod,
871 body=body,
872 headers=headers,
873 methodId=methodId,
874 resumable=resumable)
875
876 docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
877 if len(parameters.argmap) > 0:
878 docs.append('Args:\n')
879
880 # Skip undocumented params and params common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900881 skip_parameters = list(rootDesc.get('parameters', {}).keys())
John Asmuth864311d2014-04-24 15:46:08 -0400882 skip_parameters.extend(STACK_QUERY_PARAMETERS)
883
INADA Naokie4ea1a92015-03-04 03:45:42 +0900884 all_args = list(parameters.argmap.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400885 args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
886
887 # Move body to the front of the line.
888 if 'body' in all_args:
889 args_ordered.append('body')
890
891 for name in all_args:
892 if name not in args_ordered:
893 args_ordered.append(name)
894
895 for arg in args_ordered:
896 if arg in skip_parameters:
897 continue
898
899 repeated = ''
900 if arg in parameters.repeated_params:
901 repeated = ' (repeated)'
902 required = ''
903 if arg in parameters.required_params:
904 required = ' (required)'
905 paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
906 paramdoc = paramdesc.get('description', 'A parameter')
907 if '$ref' in paramdesc:
908 docs.append(
909 (' %s: object, %s%s%s\n The object takes the'
910 ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
911 schema.prettyPrintByName(paramdesc['$ref'])))
912 else:
913 paramtype = paramdesc.get('type', 'string')
914 docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
915 repeated))
916 enum = paramdesc.get('enum', [])
917 enumDesc = paramdesc.get('enumDescriptions', [])
918 if enum and enumDesc:
919 docs.append(' Allowed values\n')
920 for (name, desc) in zip(enum, enumDesc):
921 docs.append(' %s - %s\n' % (name, desc))
922 if 'response' in methodDesc:
923 if methodName.endswith('_media'):
924 docs.append('\nReturns:\n The media object as a string.\n\n ')
925 else:
926 docs.append('\nReturns:\n An object of the form:\n\n ')
927 docs.append(schema.prettyPrintSchema(methodDesc['response']))
928
929 setattr(method, '__doc__', ''.join(docs))
930 return (methodName, method)
931
932
Thomas Coffee20af04d2017-02-10 15:24:44 -0800933def createNextMethod(methodName,
934 pageTokenName='pageToken',
935 nextPageTokenName='nextPageToken',
936 isPageTokenParameter=True):
John Asmuth864311d2014-04-24 15:46:08 -0400937 """Creates any _next methods for attaching to a Resource.
938
939 The _next methods allow for easy iteration through list() responses.
940
941 Args:
942 methodName: string, name of the method to use.
Thomas Coffee20af04d2017-02-10 15:24:44 -0800943 pageTokenName: string, name of request page token field.
944 nextPageTokenName: string, name of response page token field.
945 isPageTokenParameter: Boolean, True if request page token is a query
946 parameter, False if request page token is a field of the request body.
John Asmuth864311d2014-04-24 15:46:08 -0400947 """
948 methodName = fix_method_name(methodName)
949
950 def methodNext(self, previous_request, previous_response):
951 """Retrieves the next page of results.
952
953Args:
954 previous_request: The request for the previous page. (required)
955 previous_response: The response from the request for the previous page. (required)
956
957Returns:
958 A request object that you can call 'execute()' on to request the next
959 page. Returns None if there are no more items in the collection.
960 """
961 # Retrieve nextPageToken from previous_response
962 # Use as pageToken in previous_request to create new request.
963
Thomas Coffee20af04d2017-02-10 15:24:44 -0800964 nextPageToken = previous_response.get(nextPageTokenName, None)
965 if not nextPageToken:
John Asmuth864311d2014-04-24 15:46:08 -0400966 return None
967
968 request = copy.copy(previous_request)
969
Thomas Coffee20af04d2017-02-10 15:24:44 -0800970 if isPageTokenParameter:
971 # Replace pageToken value in URI
972 request.uri = _add_query_parameter(
973 request.uri, pageTokenName, nextPageToken)
974 logger.info('Next page request URL: %s %s' % (methodName, request.uri))
975 else:
976 # Replace pageToken value in request body
977 model = self._model
978 body = model.deserialize(request.body)
979 body[pageTokenName] = nextPageToken
980 request.body = model.serialize(body)
981 logger.info('Next page request body: %s %s' % (methodName, body))
John Asmuth864311d2014-04-24 15:46:08 -0400982
983 return request
984
985 return (methodName, methodNext)
986
987
988class Resource(object):
989 """A class for interacting with a resource."""
990
991 def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
992 resourceDesc, rootDesc, schema):
993 """Build a Resource from the API description.
994
995 Args:
996 http: httplib2.Http, Object to make http requests with.
997 baseUrl: string, base URL for the API. All requests are relative to this
998 URI.
999 model: googleapiclient.Model, converts to and from the wire format.
1000 requestBuilder: class or callable that instantiates an
1001 googleapiclient.HttpRequest object.
1002 developerKey: string, key obtained from
1003 https://code.google.com/apis/console
1004 resourceDesc: object, section of deserialized discovery document that
1005 describes a resource. Note that the top level discovery document
1006 is considered a resource.
1007 rootDesc: object, the entire deserialized discovery document.
1008 schema: object, mapping of schema names to schema descriptions.
1009 """
1010 self._dynamic_attrs = []
1011
1012 self._http = http
1013 self._baseUrl = baseUrl
1014 self._model = model
1015 self._developerKey = developerKey
1016 self._requestBuilder = requestBuilder
1017 self._resourceDesc = resourceDesc
1018 self._rootDesc = rootDesc
1019 self._schema = schema
1020
1021 self._set_service_methods()
1022
1023 def _set_dynamic_attr(self, attr_name, value):
1024 """Sets an instance attribute and tracks it in a list of dynamic attributes.
1025
1026 Args:
1027 attr_name: string; The name of the attribute to be set
1028 value: The value being set on the object and tracked in the dynamic cache.
1029 """
1030 self._dynamic_attrs.append(attr_name)
1031 self.__dict__[attr_name] = value
1032
1033 def __getstate__(self):
1034 """Trim the state down to something that can be pickled.
1035
1036 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1037 will be wiped and restored on pickle serialization.
1038 """
1039 state_dict = copy.copy(self.__dict__)
1040 for dynamic_attr in self._dynamic_attrs:
1041 del state_dict[dynamic_attr]
1042 del state_dict['_dynamic_attrs']
1043 return state_dict
1044
1045 def __setstate__(self, state):
1046 """Reconstitute the state of the object from being pickled.
1047
1048 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1049 will be wiped and restored on pickle serialization.
1050 """
1051 self.__dict__.update(state)
1052 self._dynamic_attrs = []
1053 self._set_service_methods()
1054
1055 def _set_service_methods(self):
1056 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
1057 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
1058 self._add_next_methods(self._resourceDesc, self._schema)
1059
1060 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001061 # If this is the root Resource, add a new_batch_http_request() method.
1062 if resourceDesc == rootDesc:
1063 batch_uri = '%s%s' % (
1064 rootDesc['rootUrl'], rootDesc.get('batchPath', 'batch'))
1065 def new_batch_http_request(callback=None):
Pepper Lebeck-Jobe56052ec2015-06-13 14:07:14 -04001066 """Create a BatchHttpRequest object based on the discovery document.
1067
1068 Args:
1069 callback: callable, A callback to be called for each response, of the
1070 form callback(id, response, exception). The first parameter is the
1071 request id, and the second is the deserialized response object. The
1072 third is an apiclient.errors.HttpError exception object if an HTTP
1073 error occurred while processing the request, or None if no error
1074 occurred.
1075
1076 Returns:
1077 A BatchHttpRequest object based on the discovery document.
1078 """
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001079 return BatchHttpRequest(callback=callback, batch_uri=batch_uri)
1080 self._set_dynamic_attr('new_batch_http_request', new_batch_http_request)
1081
John Asmuth864311d2014-04-24 15:46:08 -04001082 # Add basic methods to Resource
1083 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001084 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001085 fixedMethodName, method = createMethod(
1086 methodName, methodDesc, rootDesc, schema)
1087 self._set_dynamic_attr(fixedMethodName,
1088 method.__get__(self, self.__class__))
1089 # Add in _media methods. The functionality of the attached method will
1090 # change when it sees that the method name ends in _media.
1091 if methodDesc.get('supportsMediaDownload', False):
1092 fixedMethodName, method = createMethod(
1093 methodName + '_media', methodDesc, rootDesc, schema)
1094 self._set_dynamic_attr(fixedMethodName,
1095 method.__get__(self, self.__class__))
1096
1097 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
1098 # Add in nested resources
1099 if 'resources' in resourceDesc:
1100
1101 def createResourceMethod(methodName, methodDesc):
1102 """Create a method on the Resource to access a nested Resource.
1103
1104 Args:
1105 methodName: string, name of the method to use.
1106 methodDesc: object, fragment of deserialized discovery document that
1107 describes the method.
1108 """
1109 methodName = fix_method_name(methodName)
1110
1111 def methodResource(self):
1112 return Resource(http=self._http, baseUrl=self._baseUrl,
1113 model=self._model, developerKey=self._developerKey,
1114 requestBuilder=self._requestBuilder,
1115 resourceDesc=methodDesc, rootDesc=rootDesc,
1116 schema=schema)
1117
1118 setattr(methodResource, '__doc__', 'A collection resource.')
1119 setattr(methodResource, '__is_resource__', True)
1120
1121 return (methodName, methodResource)
1122
INADA Naokie4ea1a92015-03-04 03:45:42 +09001123 for methodName, methodDesc in six.iteritems(resourceDesc['resources']):
John Asmuth864311d2014-04-24 15:46:08 -04001124 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
1125 self._set_dynamic_attr(fixedMethodName,
1126 method.__get__(self, self.__class__))
1127
1128 def _add_next_methods(self, resourceDesc, schema):
Thomas Coffee20af04d2017-02-10 15:24:44 -08001129 # Add _next() methods if and only if one of the names 'pageToken' or
1130 # 'nextPageToken' occurs among the fields of both the method's response
1131 # type either the method's request (query parameters) or request body.
1132 if 'methods' not in resourceDesc:
1133 return
1134 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
1135 nextPageTokenName = _findPageTokenName(
1136 _methodProperties(methodDesc, schema, 'response'))
1137 if not nextPageTokenName:
1138 continue
1139 isPageTokenParameter = True
1140 pageTokenName = _findPageTokenName(methodDesc.get('parameters', {}))
1141 if not pageTokenName:
1142 isPageTokenParameter = False
1143 pageTokenName = _findPageTokenName(
1144 _methodProperties(methodDesc, schema, 'request'))
1145 if not pageTokenName:
1146 continue
1147 fixedMethodName, method = createNextMethod(
1148 methodName + '_next', pageTokenName, nextPageTokenName,
1149 isPageTokenParameter)
1150 self._set_dynamic_attr(fixedMethodName,
1151 method.__get__(self, self.__class__))
1152
1153
1154def _findPageTokenName(fields):
1155 """Search field names for one like a page token.
1156
1157 Args:
1158 fields: container of string, names of fields.
1159
1160 Returns:
1161 First name that is either 'pageToken' or 'nextPageToken' if one exists,
1162 otherwise None.
1163 """
1164 return next((tokenName for tokenName in _PAGE_TOKEN_NAMES
1165 if tokenName in fields), None)
1166
1167def _methodProperties(methodDesc, schema, name):
1168 """Get properties of a field in a method description.
1169
1170 Args:
1171 methodDesc: object, fragment of deserialized discovery document that
1172 describes the method.
1173 schema: object, mapping of schema names to schema descriptions.
1174 name: string, name of top-level field in method description.
1175
1176 Returns:
1177 Object representing fragment of deserialized discovery document
1178 corresponding to 'properties' field of object corresponding to named field
1179 in method description, if it exists, otherwise empty dict.
1180 """
1181 desc = methodDesc.get(name, {})
1182 if '$ref' in desc:
1183 desc = schema.get(desc['$ref'], {})
1184 return desc.get('properties', {})