blob: 5c8624902da02bff8409a9a529a5d7ea98dde49f [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
76# Oauth2client < 3 has the positional helper in 'util', >= 3 has it
77# in '_helpers'.
78try:
79 from oauth2client.util import _add_query_parameter
80 from oauth2client.util import positional
81except ImportError:
82 from oauth2client._helpers import _add_query_parameter
83 from oauth2client._helpers import positional
John Asmuth864311d2014-04-24 15:46:08 -040084
85
86# The client library requires a version of httplib2 that supports RETRIES.
87httplib2.RETRIES = 1
88
89logger = logging.getLogger(__name__)
90
91URITEMPLATE = re.compile('{[^}]*}')
92VARNAME = re.compile('[a-zA-Z0-9_-]+')
93DISCOVERY_URI = ('https://www.googleapis.com/discovery/v1/apis/'
94 '{api}/{apiVersion}/rest')
Ethan Bao12b7cd32016-03-14 14:25:10 -070095V1_DISCOVERY_URI = DISCOVERY_URI
96V2_DISCOVERY_URI = ('https://{api}.googleapis.com/$discovery/rest?'
97 'version={apiVersion}')
John Asmuth864311d2014-04-24 15:46:08 -040098DEFAULT_METHOD_DOC = 'A description of how to use this function'
99HTTP_PAYLOAD_METHODS = frozenset(['PUT', 'POST', 'PATCH'])
Igor Maravić22435292017-01-19 22:28:22 +0100100
John Asmuth864311d2014-04-24 15:46:08 -0400101_MEDIA_SIZE_BIT_SHIFTS = {'KB': 10, 'MB': 20, 'GB': 30, 'TB': 40}
102BODY_PARAMETER_DEFAULT_VALUE = {
103 'description': 'The request body.',
104 'type': 'object',
105 'required': True,
106}
107MEDIA_BODY_PARAMETER_DEFAULT_VALUE = {
108 'description': ('The filename of the media request body, or an instance '
109 'of a MediaUpload object.'),
110 'type': 'string',
111 'required': False,
112}
Brian J. Watson38051ac2016-10-25 07:53:08 -0700113MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE = {
114 'description': ('The MIME type of the media request body, or an instance '
115 'of a MediaUpload object.'),
116 'type': 'string',
117 'required': False,
118}
Thomas Coffee20af04d2017-02-10 15:24:44 -0800119_PAGE_TOKEN_NAMES = ('pageToken', 'nextPageToken')
John Asmuth864311d2014-04-24 15:46:08 -0400120
121# Parameters accepted by the stack, but not visible via discovery.
122# TODO(dhermes): Remove 'userip' in 'v2'.
123STACK_QUERY_PARAMETERS = frozenset(['trace', 'pp', 'userip', 'strict'])
124STACK_QUERY_PARAMETER_DEFAULT_VALUE = {'type': 'string', 'location': 'query'}
125
126# Library-specific reserved words beyond Python keywords.
127RESERVED_WORDS = frozenset(['body'])
128
Phil Ruffwind26178fc2015-10-13 19:00:33 -0400129# patch _write_lines to avoid munging '\r' into '\n'
130# ( https://bugs.python.org/issue18886 https://bugs.python.org/issue19003 )
131class _BytesGenerator(BytesGenerator):
132 _write_lines = BytesGenerator.write
John Asmuth864311d2014-04-24 15:46:08 -0400133
134def fix_method_name(name):
135 """Fix method names to avoid reserved word conflicts.
136
137 Args:
138 name: string, method name.
139
140 Returns:
141 The name with a '_' prefixed if the name is a reserved word.
142 """
143 if keyword.iskeyword(name) or name in RESERVED_WORDS:
144 return name + '_'
145 else:
146 return name
147
148
149def key2param(key):
150 """Converts key names into parameter names.
151
152 For example, converting "max-results" -> "max_results"
153
154 Args:
155 key: string, the method key name.
156
157 Returns:
158 A safe method name based on the key name.
159 """
160 result = []
161 key = list(key)
162 if not key[0].isalpha():
163 result.append('x')
164 for c in key:
165 if c.isalnum():
166 result.append(c)
167 else:
168 result.append('_')
169
170 return ''.join(result)
171
172
173@positional(2)
174def build(serviceName,
175 version,
176 http=None,
177 discoveryServiceUrl=DISCOVERY_URI,
178 developerKey=None,
179 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700180 requestBuilder=HttpRequest,
Takashi Matsuo30125122015-08-19 11:42:32 -0700181 credentials=None,
182 cache_discovery=True,
183 cache=None):
John Asmuth864311d2014-04-24 15:46:08 -0400184 """Construct a Resource for interacting with an API.
185
186 Construct a Resource object for interacting with an API. The serviceName and
187 version are the names from the Discovery service.
188
189 Args:
190 serviceName: string, name of the service.
191 version: string, the version of the service.
192 http: httplib2.Http, An instance of httplib2.Http or something that acts
193 like it that HTTP requests will be made through.
194 discoveryServiceUrl: string, a URI Template that points to the location of
195 the discovery service. It should have two parameters {api} and
196 {apiVersion} that when filled in produce an absolute URI to the discovery
197 document for that service.
198 developerKey: string, key obtained from
199 https://code.google.com/apis/console.
200 model: googleapiclient.Model, converts to and from the wire format.
201 requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
202 request.
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800203 credentials: oauth2client.Credentials or
204 google.auth.credentials.Credentials, credentials to be used for
Orest Bolohane92c9002014-05-30 11:15:43 -0700205 authentication.
Takashi Matsuo30125122015-08-19 11:42:32 -0700206 cache_discovery: Boolean, whether or not to cache the discovery doc.
207 cache: googleapiclient.discovery_cache.base.CacheBase, an optional
208 cache object for the discovery documents.
John Asmuth864311d2014-04-24 15:46:08 -0400209
210 Returns:
211 A Resource object with methods for interacting with the service.
212 """
213 params = {
214 'api': serviceName,
215 'apiVersion': version
216 }
217
Igor Maravić22435292017-01-19 22:28:22 +0100218 if http is None:
219 discovery_http = build_http()
220 else:
221 discovery_http = http
John Asmuth864311d2014-04-24 15:46:08 -0400222
Ethan Bao12b7cd32016-03-14 14:25:10 -0700223 for discovery_url in (discoveryServiceUrl, V2_DISCOVERY_URI,):
224 requested_url = uritemplate.expand(discovery_url, params)
John Asmuth864311d2014-04-24 15:46:08 -0400225
Ethan Bao12b7cd32016-03-14 14:25:10 -0700226 try:
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800227 content = _retrieve_discovery_doc(
228 requested_url, discovery_http, cache_discovery, cache)
Ethan Bao12b7cd32016-03-14 14:25:10 -0700229 return build_from_document(content, base=discovery_url, http=http,
230 developerKey=developerKey, model=model, requestBuilder=requestBuilder,
231 credentials=credentials)
232 except HttpError as e:
233 if e.resp.status == http_client.NOT_FOUND:
234 continue
235 else:
236 raise e
Takashi Matsuo30125122015-08-19 11:42:32 -0700237
Ethan Bao12b7cd32016-03-14 14:25:10 -0700238 raise UnknownApiNameOrVersion(
239 "name: %s version: %s" % (serviceName, version))
Takashi Matsuo30125122015-08-19 11:42:32 -0700240
241
242def _retrieve_discovery_doc(url, http, cache_discovery, cache=None):
243 """Retrieves the discovery_doc from cache or the internet.
244
245 Args:
246 url: string, the URL of the discovery document.
247 http: httplib2.Http, An instance of httplib2.Http or something that acts
248 like it through which HTTP requests will be made.
249 cache_discovery: Boolean, whether or not to cache the discovery doc.
250 cache: googleapiclient.discovery_cache.base.Cache, an optional cache
251 object for the discovery documents.
252
253 Returns:
254 A unicode string representation of the discovery document.
255 """
256 if cache_discovery:
257 from . import discovery_cache
258 from .discovery_cache import base
259 if cache is None:
260 cache = discovery_cache.autodetect()
261 if cache:
262 content = cache.get(url)
263 if content:
264 return content
265
266 actual_url = url
John Asmuth864311d2014-04-24 15:46:08 -0400267 # REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
268 # variable that contains the network address of the client sending the
269 # request. If it exists then add that to the request for the discovery
270 # document to avoid exceeding the quota on discovery requests.
271 if 'REMOTE_ADDR' in os.environ:
Takashi Matsuo30125122015-08-19 11:42:32 -0700272 actual_url = _add_query_parameter(url, 'userIp', os.environ['REMOTE_ADDR'])
273 logger.info('URL being requested: GET %s', actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400274
Takashi Matsuo30125122015-08-19 11:42:32 -0700275 resp, content = http.request(actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400276
John Asmuth864311d2014-04-24 15:46:08 -0400277 if resp.status >= 400:
Takashi Matsuo30125122015-08-19 11:42:32 -0700278 raise HttpError(resp, content, uri=actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400279
280 try:
Pat Ferate9b0452c2015-03-03 17:59:56 -0800281 content = content.decode('utf-8')
282 except AttributeError:
283 pass
284
285 try:
Craig Citro6ae34d72014-08-18 23:10:09 -0700286 service = json.loads(content)
INADA Naokic1505df2014-08-20 15:19:53 +0900287 except ValueError as e:
John Asmuth864311d2014-04-24 15:46:08 -0400288 logger.error('Failed to parse as JSON: ' + content)
289 raise InvalidJsonError()
Takashi Matsuo30125122015-08-19 11:42:32 -0700290 if cache_discovery and cache:
291 cache.set(url, content)
292 return content
John Asmuth864311d2014-04-24 15:46:08 -0400293
294
295@positional(1)
296def build_from_document(
297 service,
298 base=None,
299 future=None,
300 http=None,
301 developerKey=None,
302 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700303 requestBuilder=HttpRequest,
304 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400305 """Create a Resource for interacting with an API.
306
307 Same as `build()`, but constructs the Resource object from a discovery
308 document that is it given, as opposed to retrieving one over HTTP.
309
310 Args:
311 service: string or object, the JSON discovery document describing the API.
312 The value passed in may either be the JSON string or the deserialized
313 JSON.
314 base: string, base URI for all HTTP requests, usually the discovery URI.
315 This parameter is no longer used as rootUrl and servicePath are included
316 within the discovery document. (deprecated)
317 future: string, discovery document with future capabilities (deprecated).
318 http: httplib2.Http, An instance of httplib2.Http or something that acts
319 like it that HTTP requests will be made through.
320 developerKey: string, Key for controlling API usage, generated
321 from the API Console.
322 model: Model class instance that serializes and de-serializes requests and
323 responses.
324 requestBuilder: Takes an http request and packages it up to be executed.
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800325 credentials: oauth2client.Credentials or
326 google.auth.credentials.Credentials, credentials to be used for
327 authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400328
329 Returns:
330 A Resource object with methods for interacting with the service.
331 """
332
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800333 if http is not None and credentials is not None:
334 raise ValueError('Arguments http and credentials are mutually exclusive.')
John Asmuth864311d2014-04-24 15:46:08 -0400335
INADA Naokie4ea1a92015-03-04 03:45:42 +0900336 if isinstance(service, six.string_types):
Craig Citro6ae34d72014-08-18 23:10:09 -0700337 service = json.loads(service)
Christian Ternuse469a9f2016-08-16 12:44:03 -0400338
339 if 'rootUrl' not in service and (isinstance(http, (HttpMock,
340 HttpMockSequence))):
341 logger.error("You are using HttpMock or HttpMockSequence without" +
342 "having the service discovery doc in cache. Try calling " +
343 "build() without mocking once first to populate the " +
344 "cache.")
345 raise InvalidJsonError()
346
Pat Ferated5b61bd2015-03-03 16:04:11 -0800347 base = urljoin(service['rootUrl'], service['servicePath'])
John Asmuth864311d2014-04-24 15:46:08 -0400348 schema = Schemas(service)
349
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800350 # If the http client is not specified, then we must construct an http client
351 # to make requests. If the service has scopes, then we also need to setup
352 # authentication.
353 if http is None:
354 # Does the service require scopes?
355 scopes = list(
356 service.get('auth', {}).get('oauth2', {}).get('scopes', {}).keys())
Orest Bolohane92c9002014-05-30 11:15:43 -0700357
Jon Wayne Parrott068eb352017-02-08 10:13:06 -0800358 # If so, then the we need to setup authentication if no developerKey is
359 # specified.
360 if scopes and not developerKey:
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800361 # If the user didn't pass in credentials, attempt to acquire application
362 # default credentials.
363 if credentials is None:
364 credentials = _auth.default_credentials()
365
366 # The credentials need to be scoped.
367 credentials = _auth.with_scopes(credentials, scopes)
368
369 # Create an authorized http instance
370 http = _auth.authorized_http(credentials)
371
372 # If the service doesn't require scopes then there is no need for
373 # authentication.
374 else:
Igor Maravić22435292017-01-19 22:28:22 +0100375 http = build_http()
Orest Bolohane92c9002014-05-30 11:15:43 -0700376
John Asmuth864311d2014-04-24 15:46:08 -0400377 if model is None:
378 features = service.get('features', [])
379 model = JsonModel('dataWrapper' in features)
Jon Wayne Parrott85c2c6d2017-01-05 12:34:49 -0800380
John Asmuth864311d2014-04-24 15:46:08 -0400381 return Resource(http=http, baseUrl=base, model=model,
382 developerKey=developerKey, requestBuilder=requestBuilder,
383 resourceDesc=service, rootDesc=service, schema=schema)
384
385
386def _cast(value, schema_type):
387 """Convert value to a string based on JSON Schema type.
388
389 See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
390 JSON Schema.
391
392 Args:
393 value: any, the value to convert
394 schema_type: string, the type that value should be interpreted as
395
396 Returns:
397 A string representation of 'value' based on the schema_type.
398 """
399 if schema_type == 'string':
400 if type(value) == type('') or type(value) == type(u''):
401 return value
402 else:
403 return str(value)
404 elif schema_type == 'integer':
405 return str(int(value))
406 elif schema_type == 'number':
407 return str(float(value))
408 elif schema_type == 'boolean':
409 return str(bool(value)).lower()
410 else:
411 if type(value) == type('') or type(value) == type(u''):
412 return value
413 else:
414 return str(value)
415
416
417def _media_size_to_long(maxSize):
418 """Convert a string media size, such as 10GB or 3TB into an integer.
419
420 Args:
421 maxSize: string, size as a string, such as 2MB or 7GB.
422
423 Returns:
424 The size as an integer value.
425 """
426 if len(maxSize) < 2:
INADA Naoki0bceb332014-08-20 15:27:52 +0900427 return 0
John Asmuth864311d2014-04-24 15:46:08 -0400428 units = maxSize[-2:].upper()
429 bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
430 if bit_shift is not None:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900431 return int(maxSize[:-2]) << bit_shift
John Asmuth864311d2014-04-24 15:46:08 -0400432 else:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900433 return int(maxSize)
John Asmuth864311d2014-04-24 15:46:08 -0400434
435
436def _media_path_url_from_info(root_desc, path_url):
437 """Creates an absolute media path URL.
438
439 Constructed using the API root URI and service path from the discovery
440 document and the relative path for the API method.
441
442 Args:
443 root_desc: Dictionary; the entire original deserialized discovery document.
444 path_url: String; the relative URL for the API method. Relative to the API
445 root, which is specified in the discovery document.
446
447 Returns:
448 String; the absolute URI for media upload for the API method.
449 """
450 return '%(root)supload/%(service_path)s%(path)s' % {
451 'root': root_desc['rootUrl'],
452 'service_path': root_desc['servicePath'],
453 'path': path_url,
454 }
455
456
457def _fix_up_parameters(method_desc, root_desc, http_method):
458 """Updates parameters of an API method with values specific to this library.
459
460 Specifically, adds whatever global parameters are specified by the API to the
461 parameters for the individual method. Also adds parameters which don't
462 appear in the discovery document, but are available to all discovery based
463 APIs (these are listed in STACK_QUERY_PARAMETERS).
464
465 SIDE EFFECTS: This updates the parameters dictionary object in the method
466 description.
467
468 Args:
469 method_desc: Dictionary with metadata describing an API method. Value comes
470 from the dictionary of methods stored in the 'methods' key in the
471 deserialized discovery document.
472 root_desc: Dictionary; the entire original deserialized discovery document.
473 http_method: String; the HTTP method used to call the API method described
474 in method_desc.
475
476 Returns:
477 The updated Dictionary stored in the 'parameters' key of the method
478 description dictionary.
479 """
480 parameters = method_desc.setdefault('parameters', {})
481
482 # Add in the parameters common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900483 for name, description in six.iteritems(root_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400484 parameters[name] = description
485
486 # Add in undocumented query parameters.
487 for name in STACK_QUERY_PARAMETERS:
488 parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
489
490 # Add 'body' (our own reserved word) to parameters if the method supports
491 # a request payload.
492 if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
493 body = BODY_PARAMETER_DEFAULT_VALUE.copy()
494 body.update(method_desc['request'])
495 parameters['body'] = body
496
497 return parameters
498
499
500def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
Brian J. Watson38051ac2016-10-25 07:53:08 -0700501 """Adds 'media_body' and 'media_mime_type' parameters if supported by method.
John Asmuth864311d2014-04-24 15:46:08 -0400502
503 SIDE EFFECTS: If the method supports media upload and has a required body,
504 sets body to be optional (required=False) instead. Also, if there is a
505 'mediaUpload' in the method description, adds 'media_upload' key to
506 parameters.
507
508 Args:
509 method_desc: Dictionary with metadata describing an API method. Value comes
510 from the dictionary of methods stored in the 'methods' key in the
511 deserialized discovery document.
512 root_desc: Dictionary; the entire original deserialized discovery document.
513 path_url: String; the relative URL for the API method. Relative to the API
514 root, which is specified in the discovery document.
515 parameters: A dictionary describing method parameters for method described
516 in method_desc.
517
518 Returns:
519 Triple (accept, max_size, media_path_url) where:
520 - accept is a list of strings representing what content types are
521 accepted for media upload. Defaults to empty list if not in the
522 discovery document.
523 - max_size is a long representing the max size in bytes allowed for a
524 media upload. Defaults to 0L if not in the discovery document.
525 - media_path_url is a String; the absolute URI for media upload for the
526 API method. Constructed using the API root URI and service path from
527 the discovery document and the relative path for the API method. If
528 media upload is not supported, this is None.
529 """
530 media_upload = method_desc.get('mediaUpload', {})
531 accept = media_upload.get('accept', [])
532 max_size = _media_size_to_long(media_upload.get('maxSize', ''))
533 media_path_url = None
534
535 if media_upload:
536 media_path_url = _media_path_url_from_info(root_desc, path_url)
537 parameters['media_body'] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
Brian J. Watson38051ac2016-10-25 07:53:08 -0700538 parameters['media_mime_type'] = MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE.copy()
John Asmuth864311d2014-04-24 15:46:08 -0400539 if 'body' in parameters:
540 parameters['body']['required'] = False
541
542 return accept, max_size, media_path_url
543
544
545def _fix_up_method_description(method_desc, root_desc):
546 """Updates a method description in a discovery document.
547
548 SIDE EFFECTS: Changes the parameters dictionary in the method description with
549 extra parameters which are used locally.
550
551 Args:
552 method_desc: Dictionary with metadata describing an API method. Value comes
553 from the dictionary of methods stored in the 'methods' key in the
554 deserialized discovery document.
555 root_desc: Dictionary; the entire original deserialized discovery document.
556
557 Returns:
558 Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
559 where:
560 - path_url is a String; the relative URL for the API method. Relative to
561 the API root, which is specified in the discovery document.
562 - http_method is a String; the HTTP method used to call the API method
563 described in the method description.
564 - method_id is a String; the name of the RPC method associated with the
565 API method, and is in the method description in the 'id' key.
566 - accept is a list of strings representing what content types are
567 accepted for media upload. Defaults to empty list if not in the
568 discovery document.
569 - max_size is a long representing the max size in bytes allowed for a
570 media upload. Defaults to 0L if not in the discovery document.
571 - media_path_url is a String; the absolute URI for media upload for the
572 API method. Constructed using the API root URI and service path from
573 the discovery document and the relative path for the API method. If
574 media upload is not supported, this is None.
575 """
576 path_url = method_desc['path']
577 http_method = method_desc['httpMethod']
578 method_id = method_desc['id']
579
580 parameters = _fix_up_parameters(method_desc, root_desc, http_method)
581 # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
582 # 'parameters' key and needs to know if there is a 'body' parameter because it
583 # also sets a 'media_body' parameter.
584 accept, max_size, media_path_url = _fix_up_media_upload(
585 method_desc, root_desc, path_url, parameters)
586
587 return path_url, http_method, method_id, accept, max_size, media_path_url
588
589
Craig Citro7ee535d2015-02-23 10:11:14 -0800590def _urljoin(base, url):
591 """Custom urljoin replacement supporting : before / in url."""
592 # In general, it's unsafe to simply join base and url. However, for
593 # the case of discovery documents, we know:
594 # * base will never contain params, query, or fragment
595 # * url will never contain a scheme or net_loc.
596 # In general, this means we can safely join on /; we just need to
597 # ensure we end up with precisely one / joining base and url. The
598 # exception here is the case of media uploads, where url will be an
599 # absolute url.
600 if url.startswith('http://') or url.startswith('https://'):
Pat Ferated5b61bd2015-03-03 16:04:11 -0800601 return urljoin(base, url)
Craig Citro7ee535d2015-02-23 10:11:14 -0800602 new_base = base if base.endswith('/') else base + '/'
603 new_url = url[1:] if url.startswith('/') else url
604 return new_base + new_url
605
606
John Asmuth864311d2014-04-24 15:46:08 -0400607# TODO(dhermes): Convert this class to ResourceMethod and make it callable
608class ResourceMethodParameters(object):
609 """Represents the parameters associated with a method.
610
611 Attributes:
612 argmap: Map from method parameter name (string) to query parameter name
613 (string).
614 required_params: List of required parameters (represented by parameter
615 name as string).
616 repeated_params: List of repeated parameters (represented by parameter
617 name as string).
618 pattern_params: Map from method parameter name (string) to regular
619 expression (as a string). If the pattern is set for a parameter, the
620 value for that parameter must match the regular expression.
621 query_params: List of parameters (represented by parameter name as string)
622 that will be used in the query string.
623 path_params: Set of parameters (represented by parameter name as string)
624 that will be used in the base URL path.
625 param_types: Map from method parameter name (string) to parameter type. Type
626 can be any valid JSON schema type; valid values are 'any', 'array',
627 'boolean', 'integer', 'number', 'object', or 'string'. Reference:
628 http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
629 enum_params: Map from method parameter name (string) to list of strings,
630 where each list of strings is the list of acceptable enum values.
631 """
632
633 def __init__(self, method_desc):
634 """Constructor for ResourceMethodParameters.
635
636 Sets default values and defers to set_parameters to populate.
637
638 Args:
639 method_desc: Dictionary with metadata describing an API method. Value
640 comes from the dictionary of methods stored in the 'methods' key in
641 the deserialized discovery document.
642 """
643 self.argmap = {}
644 self.required_params = []
645 self.repeated_params = []
646 self.pattern_params = {}
647 self.query_params = []
648 # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
649 # parsing is gotten rid of.
650 self.path_params = set()
651 self.param_types = {}
652 self.enum_params = {}
653
654 self.set_parameters(method_desc)
655
656 def set_parameters(self, method_desc):
657 """Populates maps and lists based on method description.
658
659 Iterates through each parameter for the method and parses the values from
660 the parameter dictionary.
661
662 Args:
663 method_desc: Dictionary with metadata describing an API method. Value
664 comes from the dictionary of methods stored in the 'methods' key in
665 the deserialized discovery document.
666 """
INADA Naokie4ea1a92015-03-04 03:45:42 +0900667 for arg, desc in six.iteritems(method_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400668 param = key2param(arg)
669 self.argmap[param] = arg
670
671 if desc.get('pattern'):
672 self.pattern_params[param] = desc['pattern']
673 if desc.get('enum'):
674 self.enum_params[param] = desc['enum']
675 if desc.get('required'):
676 self.required_params.append(param)
677 if desc.get('repeated'):
678 self.repeated_params.append(param)
679 if desc.get('location') == 'query':
680 self.query_params.append(param)
681 if desc.get('location') == 'path':
682 self.path_params.add(param)
683 self.param_types[param] = desc.get('type', 'string')
684
685 # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
686 # should have all path parameters already marked with
687 # 'location: path'.
688 for match in URITEMPLATE.finditer(method_desc['path']):
689 for namematch in VARNAME.finditer(match.group(0)):
690 name = key2param(namematch.group(0))
691 self.path_params.add(name)
692 if name in self.query_params:
693 self.query_params.remove(name)
694
695
696def createMethod(methodName, methodDesc, rootDesc, schema):
697 """Creates a method for attaching to a Resource.
698
699 Args:
700 methodName: string, name of the method to use.
701 methodDesc: object, fragment of deserialized discovery document that
702 describes the method.
703 rootDesc: object, the entire deserialized discovery document.
704 schema: object, mapping of schema names to schema descriptions.
705 """
706 methodName = fix_method_name(methodName)
707 (pathUrl, httpMethod, methodId, accept,
708 maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc)
709
710 parameters = ResourceMethodParameters(methodDesc)
711
712 def method(self, **kwargs):
713 # Don't bother with doc string, it will be over-written by createMethod.
714
INADA Naokie4ea1a92015-03-04 03:45:42 +0900715 for name in six.iterkeys(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400716 if name not in parameters.argmap:
717 raise TypeError('Got an unexpected keyword argument "%s"' % name)
718
719 # Remove args that have a value of None.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900720 keys = list(kwargs.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400721 for name in keys:
722 if kwargs[name] is None:
723 del kwargs[name]
724
725 for name in parameters.required_params:
726 if name not in kwargs:
Thomas Coffee20af04d2017-02-10 15:24:44 -0800727 # temporary workaround for non-paging methods incorrectly requiring
728 # page token parameter (cf. drive.changes.watch vs. drive.changes.list)
729 if name not in _PAGE_TOKEN_NAMES or _findPageTokenName(
730 _methodProperties(methodDesc, schema, 'response')):
731 raise TypeError('Missing required parameter "%s"' % name)
John Asmuth864311d2014-04-24 15:46:08 -0400732
INADA Naokie4ea1a92015-03-04 03:45:42 +0900733 for name, regex in six.iteritems(parameters.pattern_params):
John Asmuth864311d2014-04-24 15:46:08 -0400734 if name in kwargs:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900735 if isinstance(kwargs[name], six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400736 pvalues = [kwargs[name]]
737 else:
738 pvalues = kwargs[name]
739 for pvalue in pvalues:
740 if re.match(regex, pvalue) is None:
741 raise TypeError(
742 'Parameter "%s" value "%s" does not match the pattern "%s"' %
743 (name, pvalue, regex))
744
INADA Naokie4ea1a92015-03-04 03:45:42 +0900745 for name, enums in six.iteritems(parameters.enum_params):
John Asmuth864311d2014-04-24 15:46:08 -0400746 if name in kwargs:
747 # We need to handle the case of a repeated enum
748 # name differently, since we want to handle both
749 # arg='value' and arg=['value1', 'value2']
750 if (name in parameters.repeated_params and
INADA Naokie4ea1a92015-03-04 03:45:42 +0900751 not isinstance(kwargs[name], six.string_types)):
John Asmuth864311d2014-04-24 15:46:08 -0400752 values = kwargs[name]
753 else:
754 values = [kwargs[name]]
755 for value in values:
756 if value not in enums:
757 raise TypeError(
758 'Parameter "%s" value "%s" is not an allowed value in "%s"' %
759 (name, value, str(enums)))
760
761 actual_query_params = {}
762 actual_path_params = {}
INADA Naokie4ea1a92015-03-04 03:45:42 +0900763 for key, value in six.iteritems(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400764 to_type = parameters.param_types.get(key, 'string')
765 # For repeated parameters we cast each member of the list.
766 if key in parameters.repeated_params and type(value) == type([]):
767 cast_value = [_cast(x, to_type) for x in value]
768 else:
769 cast_value = _cast(value, to_type)
770 if key in parameters.query_params:
771 actual_query_params[parameters.argmap[key]] = cast_value
772 if key in parameters.path_params:
773 actual_path_params[parameters.argmap[key]] = cast_value
774 body_value = kwargs.get('body', None)
775 media_filename = kwargs.get('media_body', None)
Brian J. Watson38051ac2016-10-25 07:53:08 -0700776 media_mime_type = kwargs.get('media_mime_type', None)
John Asmuth864311d2014-04-24 15:46:08 -0400777
778 if self._developerKey:
779 actual_query_params['key'] = self._developerKey
780
781 model = self._model
782 if methodName.endswith('_media'):
783 model = MediaModel()
784 elif 'response' not in methodDesc:
785 model = RawModel()
786
787 headers = {}
788 headers, params, query, body = model.request(headers,
789 actual_path_params, actual_query_params, body_value)
790
791 expanded_url = uritemplate.expand(pathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800792 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400793
794 resumable = None
795 multipart_boundary = ''
796
797 if media_filename:
798 # Ensure we end up with a valid MediaUpload object.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900799 if isinstance(media_filename, six.string_types):
Brian J. Watson38051ac2016-10-25 07:53:08 -0700800 if media_mime_type is None:
801 logger.warning(
802 'media_mime_type argument not specified: trying to auto-detect for %s',
803 media_filename)
804 media_mime_type, _ = mimetypes.guess_type(media_filename)
John Asmuth864311d2014-04-24 15:46:08 -0400805 if media_mime_type is None:
806 raise UnknownFileType(media_filename)
807 if not mimeparse.best_match([media_mime_type], ','.join(accept)):
808 raise UnacceptableMimeTypeError(media_mime_type)
809 media_upload = MediaFileUpload(media_filename,
810 mimetype=media_mime_type)
811 elif isinstance(media_filename, MediaUpload):
812 media_upload = media_filename
813 else:
814 raise TypeError('media_filename must be str or MediaUpload.')
815
816 # Check the maxSize
Pat Feratec46e9052015-03-03 17:59:17 -0800817 if media_upload.size() is not None and media_upload.size() > maxSize > 0:
John Asmuth864311d2014-04-24 15:46:08 -0400818 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
819
820 # Use the media path uri for media uploads
821 expanded_url = uritemplate.expand(mediaPathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800822 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400823 if media_upload.resumable():
824 url = _add_query_parameter(url, 'uploadType', 'resumable')
825
826 if media_upload.resumable():
827 # This is all we need to do for resumable, if the body exists it gets
828 # sent in the first request, otherwise an empty body is sent.
829 resumable = media_upload
830 else:
831 # A non-resumable upload
832 if body is None:
833 # This is a simple media upload
834 headers['content-type'] = media_upload.mimetype()
835 body = media_upload.getbytes(0, media_upload.size())
836 url = _add_query_parameter(url, 'uploadType', 'media')
837 else:
838 # This is a multipart/related upload.
839 msgRoot = MIMEMultipart('related')
840 # msgRoot should not write out it's own headers
841 setattr(msgRoot, '_write_headers', lambda self: None)
842
843 # attach the body as one part
844 msg = MIMENonMultipart(*headers['content-type'].split('/'))
845 msg.set_payload(body)
846 msgRoot.attach(msg)
847
848 # attach the media as the second part
849 msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
850 msg['Content-Transfer-Encoding'] = 'binary'
851
852 payload = media_upload.getbytes(0, media_upload.size())
853 msg.set_payload(payload)
854 msgRoot.attach(msg)
Craig Citro72389b72014-07-15 17:12:50 -0700855 # encode the body: note that we can't use `as_string`, because
856 # it plays games with `From ` lines.
Phil Ruffwind26178fc2015-10-13 19:00:33 -0400857 fp = BytesIO()
858 g = _BytesGenerator(fp, mangle_from_=False)
Craig Citro72389b72014-07-15 17:12:50 -0700859 g.flatten(msgRoot, unixfrom=False)
860 body = fp.getvalue()
John Asmuth864311d2014-04-24 15:46:08 -0400861
862 multipart_boundary = msgRoot.get_boundary()
863 headers['content-type'] = ('multipart/related; '
864 'boundary="%s"') % multipart_boundary
865 url = _add_query_parameter(url, 'uploadType', 'multipart')
866
Eric Gjertsen87553e42014-05-13 15:49:50 -0400867 logger.info('URL being requested: %s %s' % (httpMethod,url))
John Asmuth864311d2014-04-24 15:46:08 -0400868 return self._requestBuilder(self._http,
869 model.response,
870 url,
871 method=httpMethod,
872 body=body,
873 headers=headers,
874 methodId=methodId,
875 resumable=resumable)
876
877 docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
878 if len(parameters.argmap) > 0:
879 docs.append('Args:\n')
880
881 # Skip undocumented params and params common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900882 skip_parameters = list(rootDesc.get('parameters', {}).keys())
John Asmuth864311d2014-04-24 15:46:08 -0400883 skip_parameters.extend(STACK_QUERY_PARAMETERS)
884
INADA Naokie4ea1a92015-03-04 03:45:42 +0900885 all_args = list(parameters.argmap.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400886 args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
887
888 # Move body to the front of the line.
889 if 'body' in all_args:
890 args_ordered.append('body')
891
892 for name in all_args:
893 if name not in args_ordered:
894 args_ordered.append(name)
895
896 for arg in args_ordered:
897 if arg in skip_parameters:
898 continue
899
900 repeated = ''
901 if arg in parameters.repeated_params:
902 repeated = ' (repeated)'
903 required = ''
904 if arg in parameters.required_params:
905 required = ' (required)'
906 paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
907 paramdoc = paramdesc.get('description', 'A parameter')
908 if '$ref' in paramdesc:
909 docs.append(
910 (' %s: object, %s%s%s\n The object takes the'
911 ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
912 schema.prettyPrintByName(paramdesc['$ref'])))
913 else:
914 paramtype = paramdesc.get('type', 'string')
915 docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
916 repeated))
917 enum = paramdesc.get('enum', [])
918 enumDesc = paramdesc.get('enumDescriptions', [])
919 if enum and enumDesc:
920 docs.append(' Allowed values\n')
921 for (name, desc) in zip(enum, enumDesc):
922 docs.append(' %s - %s\n' % (name, desc))
923 if 'response' in methodDesc:
924 if methodName.endswith('_media'):
925 docs.append('\nReturns:\n The media object as a string.\n\n ')
926 else:
927 docs.append('\nReturns:\n An object of the form:\n\n ')
928 docs.append(schema.prettyPrintSchema(methodDesc['response']))
929
930 setattr(method, '__doc__', ''.join(docs))
931 return (methodName, method)
932
933
Thomas Coffee20af04d2017-02-10 15:24:44 -0800934def createNextMethod(methodName,
935 pageTokenName='pageToken',
936 nextPageTokenName='nextPageToken',
937 isPageTokenParameter=True):
John Asmuth864311d2014-04-24 15:46:08 -0400938 """Creates any _next methods for attaching to a Resource.
939
940 The _next methods allow for easy iteration through list() responses.
941
942 Args:
943 methodName: string, name of the method to use.
Thomas Coffee20af04d2017-02-10 15:24:44 -0800944 pageTokenName: string, name of request page token field.
945 nextPageTokenName: string, name of response page token field.
946 isPageTokenParameter: Boolean, True if request page token is a query
947 parameter, False if request page token is a field of the request body.
John Asmuth864311d2014-04-24 15:46:08 -0400948 """
949 methodName = fix_method_name(methodName)
950
951 def methodNext(self, previous_request, previous_response):
952 """Retrieves the next page of results.
953
954Args:
955 previous_request: The request for the previous page. (required)
956 previous_response: The response from the request for the previous page. (required)
957
958Returns:
959 A request object that you can call 'execute()' on to request the next
960 page. Returns None if there are no more items in the collection.
961 """
962 # Retrieve nextPageToken from previous_response
963 # Use as pageToken in previous_request to create new request.
964
Thomas Coffee20af04d2017-02-10 15:24:44 -0800965 nextPageToken = previous_response.get(nextPageTokenName, None)
966 if not nextPageToken:
John Asmuth864311d2014-04-24 15:46:08 -0400967 return None
968
969 request = copy.copy(previous_request)
970
Thomas Coffee20af04d2017-02-10 15:24:44 -0800971 if isPageTokenParameter:
972 # Replace pageToken value in URI
973 request.uri = _add_query_parameter(
974 request.uri, pageTokenName, nextPageToken)
975 logger.info('Next page request URL: %s %s' % (methodName, request.uri))
976 else:
977 # Replace pageToken value in request body
978 model = self._model
979 body = model.deserialize(request.body)
980 body[pageTokenName] = nextPageToken
981 request.body = model.serialize(body)
982 logger.info('Next page request body: %s %s' % (methodName, body))
John Asmuth864311d2014-04-24 15:46:08 -0400983
984 return request
985
986 return (methodName, methodNext)
987
988
989class Resource(object):
990 """A class for interacting with a resource."""
991
992 def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
993 resourceDesc, rootDesc, schema):
994 """Build a Resource from the API description.
995
996 Args:
997 http: httplib2.Http, Object to make http requests with.
998 baseUrl: string, base URL for the API. All requests are relative to this
999 URI.
1000 model: googleapiclient.Model, converts to and from the wire format.
1001 requestBuilder: class or callable that instantiates an
1002 googleapiclient.HttpRequest object.
1003 developerKey: string, key obtained from
1004 https://code.google.com/apis/console
1005 resourceDesc: object, section of deserialized discovery document that
1006 describes a resource. Note that the top level discovery document
1007 is considered a resource.
1008 rootDesc: object, the entire deserialized discovery document.
1009 schema: object, mapping of schema names to schema descriptions.
1010 """
1011 self._dynamic_attrs = []
1012
1013 self._http = http
1014 self._baseUrl = baseUrl
1015 self._model = model
1016 self._developerKey = developerKey
1017 self._requestBuilder = requestBuilder
1018 self._resourceDesc = resourceDesc
1019 self._rootDesc = rootDesc
1020 self._schema = schema
1021
1022 self._set_service_methods()
1023
1024 def _set_dynamic_attr(self, attr_name, value):
1025 """Sets an instance attribute and tracks it in a list of dynamic attributes.
1026
1027 Args:
1028 attr_name: string; The name of the attribute to be set
1029 value: The value being set on the object and tracked in the dynamic cache.
1030 """
1031 self._dynamic_attrs.append(attr_name)
1032 self.__dict__[attr_name] = value
1033
1034 def __getstate__(self):
1035 """Trim the state down to something that can be pickled.
1036
1037 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1038 will be wiped and restored on pickle serialization.
1039 """
1040 state_dict = copy.copy(self.__dict__)
1041 for dynamic_attr in self._dynamic_attrs:
1042 del state_dict[dynamic_attr]
1043 del state_dict['_dynamic_attrs']
1044 return state_dict
1045
1046 def __setstate__(self, state):
1047 """Reconstitute the state of the object from being pickled.
1048
1049 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1050 will be wiped and restored on pickle serialization.
1051 """
1052 self.__dict__.update(state)
1053 self._dynamic_attrs = []
1054 self._set_service_methods()
1055
1056 def _set_service_methods(self):
1057 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
1058 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
1059 self._add_next_methods(self._resourceDesc, self._schema)
1060
1061 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001062 # If this is the root Resource, add a new_batch_http_request() method.
1063 if resourceDesc == rootDesc:
1064 batch_uri = '%s%s' % (
1065 rootDesc['rootUrl'], rootDesc.get('batchPath', 'batch'))
1066 def new_batch_http_request(callback=None):
Pepper Lebeck-Jobe56052ec2015-06-13 14:07:14 -04001067 """Create a BatchHttpRequest object based on the discovery document.
1068
1069 Args:
1070 callback: callable, A callback to be called for each response, of the
1071 form callback(id, response, exception). The first parameter is the
1072 request id, and the second is the deserialized response object. The
1073 third is an apiclient.errors.HttpError exception object if an HTTP
1074 error occurred while processing the request, or None if no error
1075 occurred.
1076
1077 Returns:
1078 A BatchHttpRequest object based on the discovery document.
1079 """
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001080 return BatchHttpRequest(callback=callback, batch_uri=batch_uri)
1081 self._set_dynamic_attr('new_batch_http_request', new_batch_http_request)
1082
John Asmuth864311d2014-04-24 15:46:08 -04001083 # Add basic methods to Resource
1084 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001085 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001086 fixedMethodName, method = createMethod(
1087 methodName, methodDesc, rootDesc, schema)
1088 self._set_dynamic_attr(fixedMethodName,
1089 method.__get__(self, self.__class__))
1090 # Add in _media methods. The functionality of the attached method will
1091 # change when it sees that the method name ends in _media.
1092 if methodDesc.get('supportsMediaDownload', False):
1093 fixedMethodName, method = createMethod(
1094 methodName + '_media', methodDesc, rootDesc, schema)
1095 self._set_dynamic_attr(fixedMethodName,
1096 method.__get__(self, self.__class__))
1097
1098 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
1099 # Add in nested resources
1100 if 'resources' in resourceDesc:
1101
1102 def createResourceMethod(methodName, methodDesc):
1103 """Create a method on the Resource to access a nested Resource.
1104
1105 Args:
1106 methodName: string, name of the method to use.
1107 methodDesc: object, fragment of deserialized discovery document that
1108 describes the method.
1109 """
1110 methodName = fix_method_name(methodName)
1111
1112 def methodResource(self):
1113 return Resource(http=self._http, baseUrl=self._baseUrl,
1114 model=self._model, developerKey=self._developerKey,
1115 requestBuilder=self._requestBuilder,
1116 resourceDesc=methodDesc, rootDesc=rootDesc,
1117 schema=schema)
1118
1119 setattr(methodResource, '__doc__', 'A collection resource.')
1120 setattr(methodResource, '__is_resource__', True)
1121
1122 return (methodName, methodResource)
1123
INADA Naokie4ea1a92015-03-04 03:45:42 +09001124 for methodName, methodDesc in six.iteritems(resourceDesc['resources']):
John Asmuth864311d2014-04-24 15:46:08 -04001125 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
1126 self._set_dynamic_attr(fixedMethodName,
1127 method.__get__(self, self.__class__))
1128
1129 def _add_next_methods(self, resourceDesc, schema):
Thomas Coffee20af04d2017-02-10 15:24:44 -08001130 # Add _next() methods if and only if one of the names 'pageToken' or
1131 # 'nextPageToken' occurs among the fields of both the method's response
1132 # type either the method's request (query parameters) or request body.
1133 if 'methods' not in resourceDesc:
1134 return
1135 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
1136 nextPageTokenName = _findPageTokenName(
1137 _methodProperties(methodDesc, schema, 'response'))
1138 if not nextPageTokenName:
1139 continue
1140 isPageTokenParameter = True
1141 pageTokenName = _findPageTokenName(methodDesc.get('parameters', {}))
1142 if not pageTokenName:
1143 isPageTokenParameter = False
1144 pageTokenName = _findPageTokenName(
1145 _methodProperties(methodDesc, schema, 'request'))
1146 if not pageTokenName:
1147 continue
1148 fixedMethodName, method = createNextMethod(
1149 methodName + '_next', pageTokenName, nextPageTokenName,
1150 isPageTokenParameter)
1151 self._set_dynamic_attr(fixedMethodName,
1152 method.__get__(self, self.__class__))
1153
1154
1155def _findPageTokenName(fields):
1156 """Search field names for one like a page token.
1157
1158 Args:
1159 fields: container of string, names of fields.
1160
1161 Returns:
1162 First name that is either 'pageToken' or 'nextPageToken' if one exists,
1163 otherwise None.
1164 """
1165 return next((tokenName for tokenName in _PAGE_TOKEN_NAMES
1166 if tokenName in fields), None)
1167
1168def _methodProperties(methodDesc, schema, name):
1169 """Get properties of a field in a method description.
1170
1171 Args:
1172 methodDesc: object, fragment of deserialized discovery document that
1173 describes the method.
1174 schema: object, mapping of schema names to schema descriptions.
1175 name: string, name of top-level field in method description.
1176
1177 Returns:
1178 Object representing fragment of deserialized discovery document
1179 corresponding to 'properties' field of object corresponding to named field
1180 in method description, if it exists, otherwise empty dict.
1181 """
1182 desc = methodDesc.get(name, {})
1183 if '$ref' in desc:
1184 desc = schema.get(desc['$ref'], {})
1185 return desc.get('properties', {})