blob: 12668832da40d12368f2a1a0b2c7f5911b7a2d41 [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
Craig Citroae83efb2014-06-06 09:45:57 -070075from oauth2client.client import GoogleCredentials
Jon Wayne Parrott6755f612016-08-15 10:52:26 -070076
77# Oauth2client < 3 has the positional helper in 'util', >= 3 has it
78# in '_helpers'.
79try:
80 from oauth2client.util import _add_query_parameter
81 from oauth2client.util import positional
82except ImportError:
83 from oauth2client._helpers import _add_query_parameter
84 from oauth2client._helpers import positional
John Asmuth864311d2014-04-24 15:46:08 -040085
86
87# The client library requires a version of httplib2 that supports RETRIES.
88httplib2.RETRIES = 1
89
90logger = logging.getLogger(__name__)
91
92URITEMPLATE = re.compile('{[^}]*}')
93VARNAME = re.compile('[a-zA-Z0-9_-]+')
94DISCOVERY_URI = ('https://www.googleapis.com/discovery/v1/apis/'
95 '{api}/{apiVersion}/rest')
Ethan Bao12b7cd32016-03-14 14:25:10 -070096V1_DISCOVERY_URI = DISCOVERY_URI
97V2_DISCOVERY_URI = ('https://{api}.googleapis.com/$discovery/rest?'
98 'version={apiVersion}')
John Asmuth864311d2014-04-24 15:46:08 -040099DEFAULT_METHOD_DOC = 'A description of how to use this function'
100HTTP_PAYLOAD_METHODS = frozenset(['PUT', 'POST', 'PATCH'])
Igor Maravić22435292017-01-19 22:28:22 +0100101
John Asmuth864311d2014-04-24 15:46:08 -0400102_MEDIA_SIZE_BIT_SHIFTS = {'KB': 10, 'MB': 20, 'GB': 30, 'TB': 40}
103BODY_PARAMETER_DEFAULT_VALUE = {
104 'description': 'The request body.',
105 'type': 'object',
106 'required': True,
107}
108MEDIA_BODY_PARAMETER_DEFAULT_VALUE = {
109 'description': ('The filename of the media request body, or an instance '
110 'of a MediaUpload object.'),
111 'type': 'string',
112 'required': False,
113}
Brian J. Watson38051ac2016-10-25 07:53:08 -0700114MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE = {
115 'description': ('The MIME type of the media request body, or an instance '
116 'of a MediaUpload object.'),
117 'type': 'string',
118 'required': False,
119}
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:
727 raise TypeError('Missing required parameter "%s"' % name)
728
INADA Naokie4ea1a92015-03-04 03:45:42 +0900729 for name, regex in six.iteritems(parameters.pattern_params):
John Asmuth864311d2014-04-24 15:46:08 -0400730 if name in kwargs:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900731 if isinstance(kwargs[name], six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400732 pvalues = [kwargs[name]]
733 else:
734 pvalues = kwargs[name]
735 for pvalue in pvalues:
736 if re.match(regex, pvalue) is None:
737 raise TypeError(
738 'Parameter "%s" value "%s" does not match the pattern "%s"' %
739 (name, pvalue, regex))
740
INADA Naokie4ea1a92015-03-04 03:45:42 +0900741 for name, enums in six.iteritems(parameters.enum_params):
John Asmuth864311d2014-04-24 15:46:08 -0400742 if name in kwargs:
743 # We need to handle the case of a repeated enum
744 # name differently, since we want to handle both
745 # arg='value' and arg=['value1', 'value2']
746 if (name in parameters.repeated_params and
INADA Naokie4ea1a92015-03-04 03:45:42 +0900747 not isinstance(kwargs[name], six.string_types)):
John Asmuth864311d2014-04-24 15:46:08 -0400748 values = kwargs[name]
749 else:
750 values = [kwargs[name]]
751 for value in values:
752 if value not in enums:
753 raise TypeError(
754 'Parameter "%s" value "%s" is not an allowed value in "%s"' %
755 (name, value, str(enums)))
756
757 actual_query_params = {}
758 actual_path_params = {}
INADA Naokie4ea1a92015-03-04 03:45:42 +0900759 for key, value in six.iteritems(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400760 to_type = parameters.param_types.get(key, 'string')
761 # For repeated parameters we cast each member of the list.
762 if key in parameters.repeated_params and type(value) == type([]):
763 cast_value = [_cast(x, to_type) for x in value]
764 else:
765 cast_value = _cast(value, to_type)
766 if key in parameters.query_params:
767 actual_query_params[parameters.argmap[key]] = cast_value
768 if key in parameters.path_params:
769 actual_path_params[parameters.argmap[key]] = cast_value
770 body_value = kwargs.get('body', None)
771 media_filename = kwargs.get('media_body', None)
Brian J. Watson38051ac2016-10-25 07:53:08 -0700772 media_mime_type = kwargs.get('media_mime_type', None)
John Asmuth864311d2014-04-24 15:46:08 -0400773
774 if self._developerKey:
775 actual_query_params['key'] = self._developerKey
776
777 model = self._model
778 if methodName.endswith('_media'):
779 model = MediaModel()
780 elif 'response' not in methodDesc:
781 model = RawModel()
782
783 headers = {}
784 headers, params, query, body = model.request(headers,
785 actual_path_params, actual_query_params, body_value)
786
787 expanded_url = uritemplate.expand(pathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800788 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400789
790 resumable = None
791 multipart_boundary = ''
792
793 if media_filename:
794 # Ensure we end up with a valid MediaUpload object.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900795 if isinstance(media_filename, six.string_types):
Brian J. Watson38051ac2016-10-25 07:53:08 -0700796 if media_mime_type is None:
797 logger.warning(
798 'media_mime_type argument not specified: trying to auto-detect for %s',
799 media_filename)
800 media_mime_type, _ = mimetypes.guess_type(media_filename)
John Asmuth864311d2014-04-24 15:46:08 -0400801 if media_mime_type is None:
802 raise UnknownFileType(media_filename)
803 if not mimeparse.best_match([media_mime_type], ','.join(accept)):
804 raise UnacceptableMimeTypeError(media_mime_type)
805 media_upload = MediaFileUpload(media_filename,
806 mimetype=media_mime_type)
807 elif isinstance(media_filename, MediaUpload):
808 media_upload = media_filename
809 else:
810 raise TypeError('media_filename must be str or MediaUpload.')
811
812 # Check the maxSize
Pat Feratec46e9052015-03-03 17:59:17 -0800813 if media_upload.size() is not None and media_upload.size() > maxSize > 0:
John Asmuth864311d2014-04-24 15:46:08 -0400814 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
815
816 # Use the media path uri for media uploads
817 expanded_url = uritemplate.expand(mediaPathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800818 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400819 if media_upload.resumable():
820 url = _add_query_parameter(url, 'uploadType', 'resumable')
821
822 if media_upload.resumable():
823 # This is all we need to do for resumable, if the body exists it gets
824 # sent in the first request, otherwise an empty body is sent.
825 resumable = media_upload
826 else:
827 # A non-resumable upload
828 if body is None:
829 # This is a simple media upload
830 headers['content-type'] = media_upload.mimetype()
831 body = media_upload.getbytes(0, media_upload.size())
832 url = _add_query_parameter(url, 'uploadType', 'media')
833 else:
834 # This is a multipart/related upload.
835 msgRoot = MIMEMultipart('related')
836 # msgRoot should not write out it's own headers
837 setattr(msgRoot, '_write_headers', lambda self: None)
838
839 # attach the body as one part
840 msg = MIMENonMultipart(*headers['content-type'].split('/'))
841 msg.set_payload(body)
842 msgRoot.attach(msg)
843
844 # attach the media as the second part
845 msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
846 msg['Content-Transfer-Encoding'] = 'binary'
847
848 payload = media_upload.getbytes(0, media_upload.size())
849 msg.set_payload(payload)
850 msgRoot.attach(msg)
Craig Citro72389b72014-07-15 17:12:50 -0700851 # encode the body: note that we can't use `as_string`, because
852 # it plays games with `From ` lines.
Phil Ruffwind26178fc2015-10-13 19:00:33 -0400853 fp = BytesIO()
854 g = _BytesGenerator(fp, mangle_from_=False)
Craig Citro72389b72014-07-15 17:12:50 -0700855 g.flatten(msgRoot, unixfrom=False)
856 body = fp.getvalue()
John Asmuth864311d2014-04-24 15:46:08 -0400857
858 multipart_boundary = msgRoot.get_boundary()
859 headers['content-type'] = ('multipart/related; '
860 'boundary="%s"') % multipart_boundary
861 url = _add_query_parameter(url, 'uploadType', 'multipart')
862
Eric Gjertsen87553e42014-05-13 15:49:50 -0400863 logger.info('URL being requested: %s %s' % (httpMethod,url))
John Asmuth864311d2014-04-24 15:46:08 -0400864 return self._requestBuilder(self._http,
865 model.response,
866 url,
867 method=httpMethod,
868 body=body,
869 headers=headers,
870 methodId=methodId,
871 resumable=resumable)
872
873 docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
874 if len(parameters.argmap) > 0:
875 docs.append('Args:\n')
876
877 # Skip undocumented params and params common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900878 skip_parameters = list(rootDesc.get('parameters', {}).keys())
John Asmuth864311d2014-04-24 15:46:08 -0400879 skip_parameters.extend(STACK_QUERY_PARAMETERS)
880
INADA Naokie4ea1a92015-03-04 03:45:42 +0900881 all_args = list(parameters.argmap.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400882 args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
883
884 # Move body to the front of the line.
885 if 'body' in all_args:
886 args_ordered.append('body')
887
888 for name in all_args:
889 if name not in args_ordered:
890 args_ordered.append(name)
891
892 for arg in args_ordered:
893 if arg in skip_parameters:
894 continue
895
896 repeated = ''
897 if arg in parameters.repeated_params:
898 repeated = ' (repeated)'
899 required = ''
900 if arg in parameters.required_params:
901 required = ' (required)'
902 paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
903 paramdoc = paramdesc.get('description', 'A parameter')
904 if '$ref' in paramdesc:
905 docs.append(
906 (' %s: object, %s%s%s\n The object takes the'
907 ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
908 schema.prettyPrintByName(paramdesc['$ref'])))
909 else:
910 paramtype = paramdesc.get('type', 'string')
911 docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
912 repeated))
913 enum = paramdesc.get('enum', [])
914 enumDesc = paramdesc.get('enumDescriptions', [])
915 if enum and enumDesc:
916 docs.append(' Allowed values\n')
917 for (name, desc) in zip(enum, enumDesc):
918 docs.append(' %s - %s\n' % (name, desc))
919 if 'response' in methodDesc:
920 if methodName.endswith('_media'):
921 docs.append('\nReturns:\n The media object as a string.\n\n ')
922 else:
923 docs.append('\nReturns:\n An object of the form:\n\n ')
924 docs.append(schema.prettyPrintSchema(methodDesc['response']))
925
926 setattr(method, '__doc__', ''.join(docs))
927 return (methodName, method)
928
929
930def createNextMethod(methodName):
931 """Creates any _next methods for attaching to a Resource.
932
933 The _next methods allow for easy iteration through list() responses.
934
935 Args:
936 methodName: string, name of the method to use.
937 """
938 methodName = fix_method_name(methodName)
939
940 def methodNext(self, previous_request, previous_response):
941 """Retrieves the next page of results.
942
943Args:
944 previous_request: The request for the previous page. (required)
945 previous_response: The response from the request for the previous page. (required)
946
947Returns:
948 A request object that you can call 'execute()' on to request the next
949 page. Returns None if there are no more items in the collection.
950 """
951 # Retrieve nextPageToken from previous_response
952 # Use as pageToken in previous_request to create new request.
953
Son Dinh2a9a2132015-07-23 16:30:56 +0000954 if 'nextPageToken' not in previous_response or not previous_response['nextPageToken']:
John Asmuth864311d2014-04-24 15:46:08 -0400955 return None
956
957 request = copy.copy(previous_request)
958
959 pageToken = previous_response['nextPageToken']
Pat Ferated5b61bd2015-03-03 16:04:11 -0800960 parsed = list(urlparse(request.uri))
John Asmuth864311d2014-04-24 15:46:08 -0400961 q = parse_qsl(parsed[4])
962
963 # Find and remove old 'pageToken' value from URI
964 newq = [(key, value) for (key, value) in q if key != 'pageToken']
965 newq.append(('pageToken', pageToken))
Pat Ferated5b61bd2015-03-03 16:04:11 -0800966 parsed[4] = urlencode(newq)
967 uri = urlunparse(parsed)
John Asmuth864311d2014-04-24 15:46:08 -0400968
969 request.uri = uri
970
Eric Gjertsen87553e42014-05-13 15:49:50 -0400971 logger.info('URL being requested: %s %s' % (methodName,uri))
John Asmuth864311d2014-04-24 15:46:08 -0400972
973 return request
974
975 return (methodName, methodNext)
976
977
978class Resource(object):
979 """A class for interacting with a resource."""
980
981 def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
982 resourceDesc, rootDesc, schema):
983 """Build a Resource from the API description.
984
985 Args:
986 http: httplib2.Http, Object to make http requests with.
987 baseUrl: string, base URL for the API. All requests are relative to this
988 URI.
989 model: googleapiclient.Model, converts to and from the wire format.
990 requestBuilder: class or callable that instantiates an
991 googleapiclient.HttpRequest object.
992 developerKey: string, key obtained from
993 https://code.google.com/apis/console
994 resourceDesc: object, section of deserialized discovery document that
995 describes a resource. Note that the top level discovery document
996 is considered a resource.
997 rootDesc: object, the entire deserialized discovery document.
998 schema: object, mapping of schema names to schema descriptions.
999 """
1000 self._dynamic_attrs = []
1001
1002 self._http = http
1003 self._baseUrl = baseUrl
1004 self._model = model
1005 self._developerKey = developerKey
1006 self._requestBuilder = requestBuilder
1007 self._resourceDesc = resourceDesc
1008 self._rootDesc = rootDesc
1009 self._schema = schema
1010
1011 self._set_service_methods()
1012
1013 def _set_dynamic_attr(self, attr_name, value):
1014 """Sets an instance attribute and tracks it in a list of dynamic attributes.
1015
1016 Args:
1017 attr_name: string; The name of the attribute to be set
1018 value: The value being set on the object and tracked in the dynamic cache.
1019 """
1020 self._dynamic_attrs.append(attr_name)
1021 self.__dict__[attr_name] = value
1022
1023 def __getstate__(self):
1024 """Trim the state down to something that can be pickled.
1025
1026 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1027 will be wiped and restored on pickle serialization.
1028 """
1029 state_dict = copy.copy(self.__dict__)
1030 for dynamic_attr in self._dynamic_attrs:
1031 del state_dict[dynamic_attr]
1032 del state_dict['_dynamic_attrs']
1033 return state_dict
1034
1035 def __setstate__(self, state):
1036 """Reconstitute the state of the object from being pickled.
1037
1038 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1039 will be wiped and restored on pickle serialization.
1040 """
1041 self.__dict__.update(state)
1042 self._dynamic_attrs = []
1043 self._set_service_methods()
1044
1045 def _set_service_methods(self):
1046 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
1047 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
1048 self._add_next_methods(self._resourceDesc, self._schema)
1049
1050 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001051 # If this is the root Resource, add a new_batch_http_request() method.
1052 if resourceDesc == rootDesc:
1053 batch_uri = '%s%s' % (
1054 rootDesc['rootUrl'], rootDesc.get('batchPath', 'batch'))
1055 def new_batch_http_request(callback=None):
Pepper Lebeck-Jobe56052ec2015-06-13 14:07:14 -04001056 """Create a BatchHttpRequest object based on the discovery document.
1057
1058 Args:
1059 callback: callable, A callback to be called for each response, of the
1060 form callback(id, response, exception). The first parameter is the
1061 request id, and the second is the deserialized response object. The
1062 third is an apiclient.errors.HttpError exception object if an HTTP
1063 error occurred while processing the request, or None if no error
1064 occurred.
1065
1066 Returns:
1067 A BatchHttpRequest object based on the discovery document.
1068 """
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001069 return BatchHttpRequest(callback=callback, batch_uri=batch_uri)
1070 self._set_dynamic_attr('new_batch_http_request', new_batch_http_request)
1071
John Asmuth864311d2014-04-24 15:46:08 -04001072 # Add basic methods to Resource
1073 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001074 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001075 fixedMethodName, method = createMethod(
1076 methodName, methodDesc, rootDesc, schema)
1077 self._set_dynamic_attr(fixedMethodName,
1078 method.__get__(self, self.__class__))
1079 # Add in _media methods. The functionality of the attached method will
1080 # change when it sees that the method name ends in _media.
1081 if methodDesc.get('supportsMediaDownload', False):
1082 fixedMethodName, method = createMethod(
1083 methodName + '_media', methodDesc, rootDesc, schema)
1084 self._set_dynamic_attr(fixedMethodName,
1085 method.__get__(self, self.__class__))
1086
1087 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
1088 # Add in nested resources
1089 if 'resources' in resourceDesc:
1090
1091 def createResourceMethod(methodName, methodDesc):
1092 """Create a method on the Resource to access a nested Resource.
1093
1094 Args:
1095 methodName: string, name of the method to use.
1096 methodDesc: object, fragment of deserialized discovery document that
1097 describes the method.
1098 """
1099 methodName = fix_method_name(methodName)
1100
1101 def methodResource(self):
1102 return Resource(http=self._http, baseUrl=self._baseUrl,
1103 model=self._model, developerKey=self._developerKey,
1104 requestBuilder=self._requestBuilder,
1105 resourceDesc=methodDesc, rootDesc=rootDesc,
1106 schema=schema)
1107
1108 setattr(methodResource, '__doc__', 'A collection resource.')
1109 setattr(methodResource, '__is_resource__', True)
1110
1111 return (methodName, methodResource)
1112
INADA Naokie4ea1a92015-03-04 03:45:42 +09001113 for methodName, methodDesc in six.iteritems(resourceDesc['resources']):
John Asmuth864311d2014-04-24 15:46:08 -04001114 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
1115 self._set_dynamic_attr(fixedMethodName,
1116 method.__get__(self, self.__class__))
1117
1118 def _add_next_methods(self, resourceDesc, schema):
1119 # Add _next() methods
1120 # Look for response bodies in schema that contain nextPageToken, and methods
1121 # that take a pageToken parameter.
1122 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001123 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001124 if 'response' in methodDesc:
1125 responseSchema = methodDesc['response']
1126 if '$ref' in responseSchema:
1127 responseSchema = schema.get(responseSchema['$ref'])
1128 hasNextPageToken = 'nextPageToken' in responseSchema.get('properties',
1129 {})
1130 hasPageToken = 'pageToken' in methodDesc.get('parameters', {})
1131 if hasNextPageToken and hasPageToken:
1132 fixedMethodName, method = createNextMethod(methodName + '_next')
1133 self._set_dynamic_attr(fixedMethodName,
1134 method.__get__(self, self.__class__))