blob: be62cf73e13a744e46f71f489c113f6b7e4ec48e [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
Pat Ferateed9affd2015-03-03 16:03:15 -080031from six import StringIO
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
Craig Citro72389b72014-07-15 17:12:50 -070038from email.generator import Generator
John Asmuth864311d2014-04-24 15:46:08 -040039from email.mime.multipart import MIMEMultipart
40from email.mime.nonmultipart import MIMENonMultipart
Craig Citro6ae34d72014-08-18 23:10:09 -070041import json
John Asmuth864311d2014-04-24 15:46:08 -040042import keyword
43import logging
44import mimetypes
45import os
46import re
John Asmuth864311d2014-04-24 15:46:08 -040047
48# Third-party imports
49import httplib2
John Asmuth864311d2014-04-24 15:46:08 -040050import uritemplate
51
52# Local imports
Pat Ferateb240c172015-03-03 16:23:51 -080053from googleapiclient import mimeparse
John Asmuth864311d2014-04-24 15:46:08 -040054from googleapiclient.errors import HttpError
55from googleapiclient.errors import InvalidJsonError
56from googleapiclient.errors import MediaUploadSizeError
57from googleapiclient.errors import UnacceptableMimeTypeError
58from googleapiclient.errors import UnknownApiNameOrVersion
59from googleapiclient.errors import UnknownFileType
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -040060from googleapiclient.http import BatchHttpRequest
John Asmuth864311d2014-04-24 15:46:08 -040061from googleapiclient.http import HttpRequest
62from googleapiclient.http import MediaFileUpload
63from googleapiclient.http import MediaUpload
64from googleapiclient.model import JsonModel
65from googleapiclient.model import MediaModel
66from googleapiclient.model import RawModel
67from googleapiclient.schema import Schemas
Craig Citroae83efb2014-06-06 09:45:57 -070068from oauth2client.client import GoogleCredentials
John Asmuth864311d2014-04-24 15:46:08 -040069from oauth2client.util import _add_query_parameter
70from oauth2client.util import positional
71
72
73# The client library requires a version of httplib2 that supports RETRIES.
74httplib2.RETRIES = 1
75
76logger = logging.getLogger(__name__)
77
78URITEMPLATE = re.compile('{[^}]*}')
79VARNAME = re.compile('[a-zA-Z0-9_-]+')
80DISCOVERY_URI = ('https://www.googleapis.com/discovery/v1/apis/'
81 '{api}/{apiVersion}/rest')
82DEFAULT_METHOD_DOC = 'A description of how to use this function'
83HTTP_PAYLOAD_METHODS = frozenset(['PUT', 'POST', 'PATCH'])
84_MEDIA_SIZE_BIT_SHIFTS = {'KB': 10, 'MB': 20, 'GB': 30, 'TB': 40}
85BODY_PARAMETER_DEFAULT_VALUE = {
86 'description': 'The request body.',
87 'type': 'object',
88 'required': True,
89}
90MEDIA_BODY_PARAMETER_DEFAULT_VALUE = {
91 'description': ('The filename of the media request body, or an instance '
92 'of a MediaUpload object.'),
93 'type': 'string',
94 'required': False,
95}
96
97# Parameters accepted by the stack, but not visible via discovery.
98# TODO(dhermes): Remove 'userip' in 'v2'.
99STACK_QUERY_PARAMETERS = frozenset(['trace', 'pp', 'userip', 'strict'])
100STACK_QUERY_PARAMETER_DEFAULT_VALUE = {'type': 'string', 'location': 'query'}
101
102# Library-specific reserved words beyond Python keywords.
103RESERVED_WORDS = frozenset(['body'])
104
105
106def fix_method_name(name):
107 """Fix method names to avoid reserved word conflicts.
108
109 Args:
110 name: string, method name.
111
112 Returns:
113 The name with a '_' prefixed if the name is a reserved word.
114 """
115 if keyword.iskeyword(name) or name in RESERVED_WORDS:
116 return name + '_'
117 else:
118 return name
119
120
121def key2param(key):
122 """Converts key names into parameter names.
123
124 For example, converting "max-results" -> "max_results"
125
126 Args:
127 key: string, the method key name.
128
129 Returns:
130 A safe method name based on the key name.
131 """
132 result = []
133 key = list(key)
134 if not key[0].isalpha():
135 result.append('x')
136 for c in key:
137 if c.isalnum():
138 result.append(c)
139 else:
140 result.append('_')
141
142 return ''.join(result)
143
144
145@positional(2)
146def build(serviceName,
147 version,
148 http=None,
149 discoveryServiceUrl=DISCOVERY_URI,
150 developerKey=None,
151 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700152 requestBuilder=HttpRequest,
Takashi Matsuo30125122015-08-19 11:42:32 -0700153 credentials=None,
154 cache_discovery=True,
155 cache=None):
John Asmuth864311d2014-04-24 15:46:08 -0400156 """Construct a Resource for interacting with an API.
157
158 Construct a Resource object for interacting with an API. The serviceName and
159 version are the names from the Discovery service.
160
161 Args:
162 serviceName: string, name of the service.
163 version: string, the version of the service.
164 http: httplib2.Http, An instance of httplib2.Http or something that acts
165 like it that HTTP requests will be made through.
166 discoveryServiceUrl: string, a URI Template that points to the location of
167 the discovery service. It should have two parameters {api} and
168 {apiVersion} that when filled in produce an absolute URI to the discovery
169 document for that service.
170 developerKey: string, key obtained from
171 https://code.google.com/apis/console.
172 model: googleapiclient.Model, converts to and from the wire format.
173 requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
174 request.
Orest Bolohane92c9002014-05-30 11:15:43 -0700175 credentials: oauth2client.Credentials, credentials to be used for
176 authentication.
Takashi Matsuo30125122015-08-19 11:42:32 -0700177 cache_discovery: Boolean, whether or not to cache the discovery doc.
178 cache: googleapiclient.discovery_cache.base.CacheBase, an optional
179 cache object for the discovery documents.
John Asmuth864311d2014-04-24 15:46:08 -0400180
181 Returns:
182 A Resource object with methods for interacting with the service.
183 """
184 params = {
185 'api': serviceName,
186 'apiVersion': version
187 }
188
189 if http is None:
190 http = httplib2.Http()
191
192 requested_url = uritemplate.expand(discoveryServiceUrl, params)
193
Takashi Matsuo3772f9d2015-09-04 12:25:55 -0700194 try:
195 content = _retrieve_discovery_doc(requested_url, http, cache_discovery,
196 cache)
197 except HttpError as e:
198 if e.resp.status == http_client.NOT_FOUND:
199 raise UnknownApiNameOrVersion("name: %s version: %s" % (serviceName,
200 version))
201 else:
202 raise e
Takashi Matsuo30125122015-08-19 11:42:32 -0700203
204 return build_from_document(content, base=discoveryServiceUrl, http=http,
205 developerKey=developerKey, model=model, requestBuilder=requestBuilder,
206 credentials=credentials)
207
208
209def _retrieve_discovery_doc(url, http, cache_discovery, cache=None):
210 """Retrieves the discovery_doc from cache or the internet.
211
212 Args:
213 url: string, the URL of the discovery document.
214 http: httplib2.Http, An instance of httplib2.Http or something that acts
215 like it through which HTTP requests will be made.
216 cache_discovery: Boolean, whether or not to cache the discovery doc.
217 cache: googleapiclient.discovery_cache.base.Cache, an optional cache
218 object for the discovery documents.
219
220 Returns:
221 A unicode string representation of the discovery document.
222 """
223 if cache_discovery:
224 from . import discovery_cache
225 from .discovery_cache import base
226 if cache is None:
227 cache = discovery_cache.autodetect()
228 if cache:
229 content = cache.get(url)
230 if content:
231 return content
232
233 actual_url = url
John Asmuth864311d2014-04-24 15:46:08 -0400234 # REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
235 # variable that contains the network address of the client sending the
236 # request. If it exists then add that to the request for the discovery
237 # document to avoid exceeding the quota on discovery requests.
238 if 'REMOTE_ADDR' in os.environ:
Takashi Matsuo30125122015-08-19 11:42:32 -0700239 actual_url = _add_query_parameter(url, 'userIp', os.environ['REMOTE_ADDR'])
240 logger.info('URL being requested: GET %s', actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400241
Takashi Matsuo30125122015-08-19 11:42:32 -0700242 resp, content = http.request(actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400243
John Asmuth864311d2014-04-24 15:46:08 -0400244 if resp.status >= 400:
Takashi Matsuo30125122015-08-19 11:42:32 -0700245 raise HttpError(resp, content, uri=actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400246
247 try:
Pat Ferate9b0452c2015-03-03 17:59:56 -0800248 content = content.decode('utf-8')
249 except AttributeError:
250 pass
251
252 try:
Craig Citro6ae34d72014-08-18 23:10:09 -0700253 service = json.loads(content)
INADA Naokic1505df2014-08-20 15:19:53 +0900254 except ValueError as e:
John Asmuth864311d2014-04-24 15:46:08 -0400255 logger.error('Failed to parse as JSON: ' + content)
256 raise InvalidJsonError()
Takashi Matsuo30125122015-08-19 11:42:32 -0700257 if cache_discovery and cache:
258 cache.set(url, content)
259 return content
John Asmuth864311d2014-04-24 15:46:08 -0400260
261
262@positional(1)
263def build_from_document(
264 service,
265 base=None,
266 future=None,
267 http=None,
268 developerKey=None,
269 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700270 requestBuilder=HttpRequest,
271 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400272 """Create a Resource for interacting with an API.
273
274 Same as `build()`, but constructs the Resource object from a discovery
275 document that is it given, as opposed to retrieving one over HTTP.
276
277 Args:
278 service: string or object, the JSON discovery document describing the API.
279 The value passed in may either be the JSON string or the deserialized
280 JSON.
281 base: string, base URI for all HTTP requests, usually the discovery URI.
282 This parameter is no longer used as rootUrl and servicePath are included
283 within the discovery document. (deprecated)
284 future: string, discovery document with future capabilities (deprecated).
285 http: httplib2.Http, An instance of httplib2.Http or something that acts
286 like it that HTTP requests will be made through.
287 developerKey: string, Key for controlling API usage, generated
288 from the API Console.
289 model: Model class instance that serializes and de-serializes requests and
290 responses.
291 requestBuilder: Takes an http request and packages it up to be executed.
Orest Bolohane92c9002014-05-30 11:15:43 -0700292 credentials: object, credentials to be used for authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400293
294 Returns:
295 A Resource object with methods for interacting with the service.
296 """
297
Jonathan Wayne Parrotta6e6fbd2015-07-16 15:33:57 -0700298 if http is None:
299 http = httplib2.Http()
300
John Asmuth864311d2014-04-24 15:46:08 -0400301 # future is no longer used.
302 future = {}
303
INADA Naokie4ea1a92015-03-04 03:45:42 +0900304 if isinstance(service, six.string_types):
Craig Citro6ae34d72014-08-18 23:10:09 -0700305 service = json.loads(service)
Pat Ferated5b61bd2015-03-03 16:04:11 -0800306 base = urljoin(service['rootUrl'], service['servicePath'])
John Asmuth864311d2014-04-24 15:46:08 -0400307 schema = Schemas(service)
308
Orest Bolohane92c9002014-05-30 11:15:43 -0700309 if credentials:
310 # If credentials were passed in, we could have two cases:
311 # 1. the scopes were specified, in which case the given credentials
312 # are used for authorizing the http;
oresticaaff4e1f2014-07-08 11:28:45 -0700313 # 2. the scopes were not provided (meaning the Application Default
314 # Credentials are to be used). In this case, the Application Default
315 # Credentials are built and used instead of the original credentials.
316 # If there are no scopes found (meaning the given service requires no
317 # authentication), there is no authorization of the http.
Craig Citroae83efb2014-06-06 09:45:57 -0700318 if (isinstance(credentials, GoogleCredentials) and
319 credentials.create_scoped_required()):
Orest Bolohane92c9002014-05-30 11:15:43 -0700320 scopes = service.get('auth', {}).get('oauth2', {}).get('scopes', {})
321 if scopes:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900322 credentials = credentials.create_scoped(list(scopes.keys()))
Orest Bolohane92c9002014-05-30 11:15:43 -0700323 else:
324 # No need to authorize the http object
325 # if the service does not require authentication.
326 credentials = None
327
328 if credentials:
329 http = credentials.authorize(http)
330
John Asmuth864311d2014-04-24 15:46:08 -0400331 if model is None:
332 features = service.get('features', [])
333 model = JsonModel('dataWrapper' in features)
334 return Resource(http=http, baseUrl=base, model=model,
335 developerKey=developerKey, requestBuilder=requestBuilder,
336 resourceDesc=service, rootDesc=service, schema=schema)
337
338
339def _cast(value, schema_type):
340 """Convert value to a string based on JSON Schema type.
341
342 See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
343 JSON Schema.
344
345 Args:
346 value: any, the value to convert
347 schema_type: string, the type that value should be interpreted as
348
349 Returns:
350 A string representation of 'value' based on the schema_type.
351 """
352 if schema_type == 'string':
353 if type(value) == type('') or type(value) == type(u''):
354 return value
355 else:
356 return str(value)
357 elif schema_type == 'integer':
358 return str(int(value))
359 elif schema_type == 'number':
360 return str(float(value))
361 elif schema_type == 'boolean':
362 return str(bool(value)).lower()
363 else:
364 if type(value) == type('') or type(value) == type(u''):
365 return value
366 else:
367 return str(value)
368
369
370def _media_size_to_long(maxSize):
371 """Convert a string media size, such as 10GB or 3TB into an integer.
372
373 Args:
374 maxSize: string, size as a string, such as 2MB or 7GB.
375
376 Returns:
377 The size as an integer value.
378 """
379 if len(maxSize) < 2:
INADA Naoki0bceb332014-08-20 15:27:52 +0900380 return 0
John Asmuth864311d2014-04-24 15:46:08 -0400381 units = maxSize[-2:].upper()
382 bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
383 if bit_shift is not None:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900384 return int(maxSize[:-2]) << bit_shift
John Asmuth864311d2014-04-24 15:46:08 -0400385 else:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900386 return int(maxSize)
John Asmuth864311d2014-04-24 15:46:08 -0400387
388
389def _media_path_url_from_info(root_desc, path_url):
390 """Creates an absolute media path URL.
391
392 Constructed using the API root URI and service path from the discovery
393 document and the relative path for the API method.
394
395 Args:
396 root_desc: Dictionary; the entire original deserialized discovery document.
397 path_url: String; the relative URL for the API method. Relative to the API
398 root, which is specified in the discovery document.
399
400 Returns:
401 String; the absolute URI for media upload for the API method.
402 """
403 return '%(root)supload/%(service_path)s%(path)s' % {
404 'root': root_desc['rootUrl'],
405 'service_path': root_desc['servicePath'],
406 'path': path_url,
407 }
408
409
410def _fix_up_parameters(method_desc, root_desc, http_method):
411 """Updates parameters of an API method with values specific to this library.
412
413 Specifically, adds whatever global parameters are specified by the API to the
414 parameters for the individual method. Also adds parameters which don't
415 appear in the discovery document, but are available to all discovery based
416 APIs (these are listed in STACK_QUERY_PARAMETERS).
417
418 SIDE EFFECTS: This updates the parameters dictionary object in the method
419 description.
420
421 Args:
422 method_desc: Dictionary with metadata describing an API method. Value comes
423 from the dictionary of methods stored in the 'methods' key in the
424 deserialized discovery document.
425 root_desc: Dictionary; the entire original deserialized discovery document.
426 http_method: String; the HTTP method used to call the API method described
427 in method_desc.
428
429 Returns:
430 The updated Dictionary stored in the 'parameters' key of the method
431 description dictionary.
432 """
433 parameters = method_desc.setdefault('parameters', {})
434
435 # Add in the parameters common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900436 for name, description in six.iteritems(root_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400437 parameters[name] = description
438
439 # Add in undocumented query parameters.
440 for name in STACK_QUERY_PARAMETERS:
441 parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
442
443 # Add 'body' (our own reserved word) to parameters if the method supports
444 # a request payload.
445 if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
446 body = BODY_PARAMETER_DEFAULT_VALUE.copy()
447 body.update(method_desc['request'])
448 parameters['body'] = body
449
450 return parameters
451
452
453def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
454 """Updates parameters of API by adding 'media_body' if supported by method.
455
456 SIDE EFFECTS: If the method supports media upload and has a required body,
457 sets body to be optional (required=False) instead. Also, if there is a
458 'mediaUpload' in the method description, adds 'media_upload' key to
459 parameters.
460
461 Args:
462 method_desc: Dictionary with metadata describing an API method. Value comes
463 from the dictionary of methods stored in the 'methods' key in the
464 deserialized discovery document.
465 root_desc: Dictionary; the entire original deserialized discovery document.
466 path_url: String; the relative URL for the API method. Relative to the API
467 root, which is specified in the discovery document.
468 parameters: A dictionary describing method parameters for method described
469 in method_desc.
470
471 Returns:
472 Triple (accept, max_size, media_path_url) where:
473 - accept is a list of strings representing what content types are
474 accepted for media upload. Defaults to empty list if not in the
475 discovery document.
476 - max_size is a long representing the max size in bytes allowed for a
477 media upload. Defaults to 0L if not in the discovery document.
478 - media_path_url is a String; the absolute URI for media upload for the
479 API method. Constructed using the API root URI and service path from
480 the discovery document and the relative path for the API method. If
481 media upload is not supported, this is None.
482 """
483 media_upload = method_desc.get('mediaUpload', {})
484 accept = media_upload.get('accept', [])
485 max_size = _media_size_to_long(media_upload.get('maxSize', ''))
486 media_path_url = None
487
488 if media_upload:
489 media_path_url = _media_path_url_from_info(root_desc, path_url)
490 parameters['media_body'] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
491 if 'body' in parameters:
492 parameters['body']['required'] = False
493
494 return accept, max_size, media_path_url
495
496
497def _fix_up_method_description(method_desc, root_desc):
498 """Updates a method description in a discovery document.
499
500 SIDE EFFECTS: Changes the parameters dictionary in the method description with
501 extra parameters which are used locally.
502
503 Args:
504 method_desc: Dictionary with metadata describing an API method. Value comes
505 from the dictionary of methods stored in the 'methods' key in the
506 deserialized discovery document.
507 root_desc: Dictionary; the entire original deserialized discovery document.
508
509 Returns:
510 Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
511 where:
512 - path_url is a String; the relative URL for the API method. Relative to
513 the API root, which is specified in the discovery document.
514 - http_method is a String; the HTTP method used to call the API method
515 described in the method description.
516 - method_id is a String; the name of the RPC method associated with the
517 API method, and is in the method description in the 'id' key.
518 - accept is a list of strings representing what content types are
519 accepted for media upload. Defaults to empty list if not in the
520 discovery document.
521 - max_size is a long representing the max size in bytes allowed for a
522 media upload. Defaults to 0L if not in the discovery document.
523 - media_path_url is a String; the absolute URI for media upload for the
524 API method. Constructed using the API root URI and service path from
525 the discovery document and the relative path for the API method. If
526 media upload is not supported, this is None.
527 """
528 path_url = method_desc['path']
529 http_method = method_desc['httpMethod']
530 method_id = method_desc['id']
531
532 parameters = _fix_up_parameters(method_desc, root_desc, http_method)
533 # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
534 # 'parameters' key and needs to know if there is a 'body' parameter because it
535 # also sets a 'media_body' parameter.
536 accept, max_size, media_path_url = _fix_up_media_upload(
537 method_desc, root_desc, path_url, parameters)
538
539 return path_url, http_method, method_id, accept, max_size, media_path_url
540
541
Craig Citro7ee535d2015-02-23 10:11:14 -0800542def _urljoin(base, url):
543 """Custom urljoin replacement supporting : before / in url."""
544 # In general, it's unsafe to simply join base and url. However, for
545 # the case of discovery documents, we know:
546 # * base will never contain params, query, or fragment
547 # * url will never contain a scheme or net_loc.
548 # In general, this means we can safely join on /; we just need to
549 # ensure we end up with precisely one / joining base and url. The
550 # exception here is the case of media uploads, where url will be an
551 # absolute url.
552 if url.startswith('http://') or url.startswith('https://'):
Pat Ferated5b61bd2015-03-03 16:04:11 -0800553 return urljoin(base, url)
Craig Citro7ee535d2015-02-23 10:11:14 -0800554 new_base = base if base.endswith('/') else base + '/'
555 new_url = url[1:] if url.startswith('/') else url
556 return new_base + new_url
557
558
John Asmuth864311d2014-04-24 15:46:08 -0400559# TODO(dhermes): Convert this class to ResourceMethod and make it callable
560class ResourceMethodParameters(object):
561 """Represents the parameters associated with a method.
562
563 Attributes:
564 argmap: Map from method parameter name (string) to query parameter name
565 (string).
566 required_params: List of required parameters (represented by parameter
567 name as string).
568 repeated_params: List of repeated parameters (represented by parameter
569 name as string).
570 pattern_params: Map from method parameter name (string) to regular
571 expression (as a string). If the pattern is set for a parameter, the
572 value for that parameter must match the regular expression.
573 query_params: List of parameters (represented by parameter name as string)
574 that will be used in the query string.
575 path_params: Set of parameters (represented by parameter name as string)
576 that will be used in the base URL path.
577 param_types: Map from method parameter name (string) to parameter type. Type
578 can be any valid JSON schema type; valid values are 'any', 'array',
579 'boolean', 'integer', 'number', 'object', or 'string'. Reference:
580 http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
581 enum_params: Map from method parameter name (string) to list of strings,
582 where each list of strings is the list of acceptable enum values.
583 """
584
585 def __init__(self, method_desc):
586 """Constructor for ResourceMethodParameters.
587
588 Sets default values and defers to set_parameters to populate.
589
590 Args:
591 method_desc: Dictionary with metadata describing an API method. Value
592 comes from the dictionary of methods stored in the 'methods' key in
593 the deserialized discovery document.
594 """
595 self.argmap = {}
596 self.required_params = []
597 self.repeated_params = []
598 self.pattern_params = {}
599 self.query_params = []
600 # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
601 # parsing is gotten rid of.
602 self.path_params = set()
603 self.param_types = {}
604 self.enum_params = {}
605
606 self.set_parameters(method_desc)
607
608 def set_parameters(self, method_desc):
609 """Populates maps and lists based on method description.
610
611 Iterates through each parameter for the method and parses the values from
612 the parameter dictionary.
613
614 Args:
615 method_desc: Dictionary with metadata describing an API method. Value
616 comes from the dictionary of methods stored in the 'methods' key in
617 the deserialized discovery document.
618 """
INADA Naokie4ea1a92015-03-04 03:45:42 +0900619 for arg, desc in six.iteritems(method_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400620 param = key2param(arg)
621 self.argmap[param] = arg
622
623 if desc.get('pattern'):
624 self.pattern_params[param] = desc['pattern']
625 if desc.get('enum'):
626 self.enum_params[param] = desc['enum']
627 if desc.get('required'):
628 self.required_params.append(param)
629 if desc.get('repeated'):
630 self.repeated_params.append(param)
631 if desc.get('location') == 'query':
632 self.query_params.append(param)
633 if desc.get('location') == 'path':
634 self.path_params.add(param)
635 self.param_types[param] = desc.get('type', 'string')
636
637 # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
638 # should have all path parameters already marked with
639 # 'location: path'.
640 for match in URITEMPLATE.finditer(method_desc['path']):
641 for namematch in VARNAME.finditer(match.group(0)):
642 name = key2param(namematch.group(0))
643 self.path_params.add(name)
644 if name in self.query_params:
645 self.query_params.remove(name)
646
647
648def createMethod(methodName, methodDesc, rootDesc, schema):
649 """Creates a method for attaching to a Resource.
650
651 Args:
652 methodName: string, name of the method to use.
653 methodDesc: object, fragment of deserialized discovery document that
654 describes the method.
655 rootDesc: object, the entire deserialized discovery document.
656 schema: object, mapping of schema names to schema descriptions.
657 """
658 methodName = fix_method_name(methodName)
659 (pathUrl, httpMethod, methodId, accept,
660 maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc)
661
662 parameters = ResourceMethodParameters(methodDesc)
663
664 def method(self, **kwargs):
665 # Don't bother with doc string, it will be over-written by createMethod.
666
INADA Naokie4ea1a92015-03-04 03:45:42 +0900667 for name in six.iterkeys(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400668 if name not in parameters.argmap:
669 raise TypeError('Got an unexpected keyword argument "%s"' % name)
670
671 # Remove args that have a value of None.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900672 keys = list(kwargs.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400673 for name in keys:
674 if kwargs[name] is None:
675 del kwargs[name]
676
677 for name in parameters.required_params:
678 if name not in kwargs:
679 raise TypeError('Missing required parameter "%s"' % name)
680
INADA Naokie4ea1a92015-03-04 03:45:42 +0900681 for name, regex in six.iteritems(parameters.pattern_params):
John Asmuth864311d2014-04-24 15:46:08 -0400682 if name in kwargs:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900683 if isinstance(kwargs[name], six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400684 pvalues = [kwargs[name]]
685 else:
686 pvalues = kwargs[name]
687 for pvalue in pvalues:
688 if re.match(regex, pvalue) is None:
689 raise TypeError(
690 'Parameter "%s" value "%s" does not match the pattern "%s"' %
691 (name, pvalue, regex))
692
INADA Naokie4ea1a92015-03-04 03:45:42 +0900693 for name, enums in six.iteritems(parameters.enum_params):
John Asmuth864311d2014-04-24 15:46:08 -0400694 if name in kwargs:
695 # We need to handle the case of a repeated enum
696 # name differently, since we want to handle both
697 # arg='value' and arg=['value1', 'value2']
698 if (name in parameters.repeated_params and
INADA Naokie4ea1a92015-03-04 03:45:42 +0900699 not isinstance(kwargs[name], six.string_types)):
John Asmuth864311d2014-04-24 15:46:08 -0400700 values = kwargs[name]
701 else:
702 values = [kwargs[name]]
703 for value in values:
704 if value not in enums:
705 raise TypeError(
706 'Parameter "%s" value "%s" is not an allowed value in "%s"' %
707 (name, value, str(enums)))
708
709 actual_query_params = {}
710 actual_path_params = {}
INADA Naokie4ea1a92015-03-04 03:45:42 +0900711 for key, value in six.iteritems(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400712 to_type = parameters.param_types.get(key, 'string')
713 # For repeated parameters we cast each member of the list.
714 if key in parameters.repeated_params and type(value) == type([]):
715 cast_value = [_cast(x, to_type) for x in value]
716 else:
717 cast_value = _cast(value, to_type)
718 if key in parameters.query_params:
719 actual_query_params[parameters.argmap[key]] = cast_value
720 if key in parameters.path_params:
721 actual_path_params[parameters.argmap[key]] = cast_value
722 body_value = kwargs.get('body', None)
723 media_filename = kwargs.get('media_body', None)
724
725 if self._developerKey:
726 actual_query_params['key'] = self._developerKey
727
728 model = self._model
729 if methodName.endswith('_media'):
730 model = MediaModel()
731 elif 'response' not in methodDesc:
732 model = RawModel()
733
734 headers = {}
735 headers, params, query, body = model.request(headers,
736 actual_path_params, actual_query_params, body_value)
737
738 expanded_url = uritemplate.expand(pathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800739 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400740
741 resumable = None
742 multipart_boundary = ''
743
744 if media_filename:
745 # Ensure we end up with a valid MediaUpload object.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900746 if isinstance(media_filename, six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400747 (media_mime_type, encoding) = mimetypes.guess_type(media_filename)
748 if media_mime_type is None:
749 raise UnknownFileType(media_filename)
750 if not mimeparse.best_match([media_mime_type], ','.join(accept)):
751 raise UnacceptableMimeTypeError(media_mime_type)
752 media_upload = MediaFileUpload(media_filename,
753 mimetype=media_mime_type)
754 elif isinstance(media_filename, MediaUpload):
755 media_upload = media_filename
756 else:
757 raise TypeError('media_filename must be str or MediaUpload.')
758
759 # Check the maxSize
Pat Feratec46e9052015-03-03 17:59:17 -0800760 if media_upload.size() is not None and media_upload.size() > maxSize > 0:
John Asmuth864311d2014-04-24 15:46:08 -0400761 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
762
763 # Use the media path uri for media uploads
764 expanded_url = uritemplate.expand(mediaPathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800765 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400766 if media_upload.resumable():
767 url = _add_query_parameter(url, 'uploadType', 'resumable')
768
769 if media_upload.resumable():
770 # This is all we need to do for resumable, if the body exists it gets
771 # sent in the first request, otherwise an empty body is sent.
772 resumable = media_upload
773 else:
774 # A non-resumable upload
775 if body is None:
776 # This is a simple media upload
777 headers['content-type'] = media_upload.mimetype()
778 body = media_upload.getbytes(0, media_upload.size())
779 url = _add_query_parameter(url, 'uploadType', 'media')
780 else:
781 # This is a multipart/related upload.
782 msgRoot = MIMEMultipart('related')
783 # msgRoot should not write out it's own headers
784 setattr(msgRoot, '_write_headers', lambda self: None)
785
786 # attach the body as one part
787 msg = MIMENonMultipart(*headers['content-type'].split('/'))
788 msg.set_payload(body)
789 msgRoot.attach(msg)
790
791 # attach the media as the second part
792 msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
793 msg['Content-Transfer-Encoding'] = 'binary'
794
795 payload = media_upload.getbytes(0, media_upload.size())
796 msg.set_payload(payload)
797 msgRoot.attach(msg)
Craig Citro72389b72014-07-15 17:12:50 -0700798 # encode the body: note that we can't use `as_string`, because
799 # it plays games with `From ` lines.
Pat Ferateed9affd2015-03-03 16:03:15 -0800800 fp = StringIO()
Craig Citro72389b72014-07-15 17:12:50 -0700801 g = Generator(fp, mangle_from_=False)
802 g.flatten(msgRoot, unixfrom=False)
803 body = fp.getvalue()
John Asmuth864311d2014-04-24 15:46:08 -0400804
805 multipart_boundary = msgRoot.get_boundary()
806 headers['content-type'] = ('multipart/related; '
807 'boundary="%s"') % multipart_boundary
808 url = _add_query_parameter(url, 'uploadType', 'multipart')
809
Eric Gjertsen87553e42014-05-13 15:49:50 -0400810 logger.info('URL being requested: %s %s' % (httpMethod,url))
John Asmuth864311d2014-04-24 15:46:08 -0400811 return self._requestBuilder(self._http,
812 model.response,
813 url,
814 method=httpMethod,
815 body=body,
816 headers=headers,
817 methodId=methodId,
818 resumable=resumable)
819
820 docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
821 if len(parameters.argmap) > 0:
822 docs.append('Args:\n')
823
824 # Skip undocumented params and params common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900825 skip_parameters = list(rootDesc.get('parameters', {}).keys())
John Asmuth864311d2014-04-24 15:46:08 -0400826 skip_parameters.extend(STACK_QUERY_PARAMETERS)
827
INADA Naokie4ea1a92015-03-04 03:45:42 +0900828 all_args = list(parameters.argmap.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400829 args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
830
831 # Move body to the front of the line.
832 if 'body' in all_args:
833 args_ordered.append('body')
834
835 for name in all_args:
836 if name not in args_ordered:
837 args_ordered.append(name)
838
839 for arg in args_ordered:
840 if arg in skip_parameters:
841 continue
842
843 repeated = ''
844 if arg in parameters.repeated_params:
845 repeated = ' (repeated)'
846 required = ''
847 if arg in parameters.required_params:
848 required = ' (required)'
849 paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
850 paramdoc = paramdesc.get('description', 'A parameter')
851 if '$ref' in paramdesc:
852 docs.append(
853 (' %s: object, %s%s%s\n The object takes the'
854 ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
855 schema.prettyPrintByName(paramdesc['$ref'])))
856 else:
857 paramtype = paramdesc.get('type', 'string')
858 docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
859 repeated))
860 enum = paramdesc.get('enum', [])
861 enumDesc = paramdesc.get('enumDescriptions', [])
862 if enum and enumDesc:
863 docs.append(' Allowed values\n')
864 for (name, desc) in zip(enum, enumDesc):
865 docs.append(' %s - %s\n' % (name, desc))
866 if 'response' in methodDesc:
867 if methodName.endswith('_media'):
868 docs.append('\nReturns:\n The media object as a string.\n\n ')
869 else:
870 docs.append('\nReturns:\n An object of the form:\n\n ')
871 docs.append(schema.prettyPrintSchema(methodDesc['response']))
872
873 setattr(method, '__doc__', ''.join(docs))
874 return (methodName, method)
875
876
877def createNextMethod(methodName):
878 """Creates any _next methods for attaching to a Resource.
879
880 The _next methods allow for easy iteration through list() responses.
881
882 Args:
883 methodName: string, name of the method to use.
884 """
885 methodName = fix_method_name(methodName)
886
887 def methodNext(self, previous_request, previous_response):
888 """Retrieves the next page of results.
889
890Args:
891 previous_request: The request for the previous page. (required)
892 previous_response: The response from the request for the previous page. (required)
893
894Returns:
895 A request object that you can call 'execute()' on to request the next
896 page. Returns None if there are no more items in the collection.
897 """
898 # Retrieve nextPageToken from previous_response
899 # Use as pageToken in previous_request to create new request.
900
Son Dinh2a9a2132015-07-23 16:30:56 +0000901 if 'nextPageToken' not in previous_response or not previous_response['nextPageToken']:
John Asmuth864311d2014-04-24 15:46:08 -0400902 return None
903
904 request = copy.copy(previous_request)
905
906 pageToken = previous_response['nextPageToken']
Pat Ferated5b61bd2015-03-03 16:04:11 -0800907 parsed = list(urlparse(request.uri))
John Asmuth864311d2014-04-24 15:46:08 -0400908 q = parse_qsl(parsed[4])
909
910 # Find and remove old 'pageToken' value from URI
911 newq = [(key, value) for (key, value) in q if key != 'pageToken']
912 newq.append(('pageToken', pageToken))
Pat Ferated5b61bd2015-03-03 16:04:11 -0800913 parsed[4] = urlencode(newq)
914 uri = urlunparse(parsed)
John Asmuth864311d2014-04-24 15:46:08 -0400915
916 request.uri = uri
917
Eric Gjertsen87553e42014-05-13 15:49:50 -0400918 logger.info('URL being requested: %s %s' % (methodName,uri))
John Asmuth864311d2014-04-24 15:46:08 -0400919
920 return request
921
922 return (methodName, methodNext)
923
924
925class Resource(object):
926 """A class for interacting with a resource."""
927
928 def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
929 resourceDesc, rootDesc, schema):
930 """Build a Resource from the API description.
931
932 Args:
933 http: httplib2.Http, Object to make http requests with.
934 baseUrl: string, base URL for the API. All requests are relative to this
935 URI.
936 model: googleapiclient.Model, converts to and from the wire format.
937 requestBuilder: class or callable that instantiates an
938 googleapiclient.HttpRequest object.
939 developerKey: string, key obtained from
940 https://code.google.com/apis/console
941 resourceDesc: object, section of deserialized discovery document that
942 describes a resource. Note that the top level discovery document
943 is considered a resource.
944 rootDesc: object, the entire deserialized discovery document.
945 schema: object, mapping of schema names to schema descriptions.
946 """
947 self._dynamic_attrs = []
948
949 self._http = http
950 self._baseUrl = baseUrl
951 self._model = model
952 self._developerKey = developerKey
953 self._requestBuilder = requestBuilder
954 self._resourceDesc = resourceDesc
955 self._rootDesc = rootDesc
956 self._schema = schema
957
958 self._set_service_methods()
959
960 def _set_dynamic_attr(self, attr_name, value):
961 """Sets an instance attribute and tracks it in a list of dynamic attributes.
962
963 Args:
964 attr_name: string; The name of the attribute to be set
965 value: The value being set on the object and tracked in the dynamic cache.
966 """
967 self._dynamic_attrs.append(attr_name)
968 self.__dict__[attr_name] = value
969
970 def __getstate__(self):
971 """Trim the state down to something that can be pickled.
972
973 Uses the fact that the instance variable _dynamic_attrs holds attrs that
974 will be wiped and restored on pickle serialization.
975 """
976 state_dict = copy.copy(self.__dict__)
977 for dynamic_attr in self._dynamic_attrs:
978 del state_dict[dynamic_attr]
979 del state_dict['_dynamic_attrs']
980 return state_dict
981
982 def __setstate__(self, state):
983 """Reconstitute the state of the object from being pickled.
984
985 Uses the fact that the instance variable _dynamic_attrs holds attrs that
986 will be wiped and restored on pickle serialization.
987 """
988 self.__dict__.update(state)
989 self._dynamic_attrs = []
990 self._set_service_methods()
991
992 def _set_service_methods(self):
993 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
994 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
995 self._add_next_methods(self._resourceDesc, self._schema)
996
997 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -0400998 # If this is the root Resource, add a new_batch_http_request() method.
999 if resourceDesc == rootDesc:
1000 batch_uri = '%s%s' % (
1001 rootDesc['rootUrl'], rootDesc.get('batchPath', 'batch'))
1002 def new_batch_http_request(callback=None):
Pepper Lebeck-Jobe56052ec2015-06-13 14:07:14 -04001003 """Create a BatchHttpRequest object based on the discovery document.
1004
1005 Args:
1006 callback: callable, A callback to be called for each response, of the
1007 form callback(id, response, exception). The first parameter is the
1008 request id, and the second is the deserialized response object. The
1009 third is an apiclient.errors.HttpError exception object if an HTTP
1010 error occurred while processing the request, or None if no error
1011 occurred.
1012
1013 Returns:
1014 A BatchHttpRequest object based on the discovery document.
1015 """
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001016 return BatchHttpRequest(callback=callback, batch_uri=batch_uri)
1017 self._set_dynamic_attr('new_batch_http_request', new_batch_http_request)
1018
John Asmuth864311d2014-04-24 15:46:08 -04001019 # Add basic methods to Resource
1020 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001021 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001022 fixedMethodName, method = createMethod(
1023 methodName, methodDesc, rootDesc, schema)
1024 self._set_dynamic_attr(fixedMethodName,
1025 method.__get__(self, self.__class__))
1026 # Add in _media methods. The functionality of the attached method will
1027 # change when it sees that the method name ends in _media.
1028 if methodDesc.get('supportsMediaDownload', False):
1029 fixedMethodName, method = createMethod(
1030 methodName + '_media', methodDesc, rootDesc, schema)
1031 self._set_dynamic_attr(fixedMethodName,
1032 method.__get__(self, self.__class__))
1033
1034 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
1035 # Add in nested resources
1036 if 'resources' in resourceDesc:
1037
1038 def createResourceMethod(methodName, methodDesc):
1039 """Create a method on the Resource to access a nested Resource.
1040
1041 Args:
1042 methodName: string, name of the method to use.
1043 methodDesc: object, fragment of deserialized discovery document that
1044 describes the method.
1045 """
1046 methodName = fix_method_name(methodName)
1047
1048 def methodResource(self):
1049 return Resource(http=self._http, baseUrl=self._baseUrl,
1050 model=self._model, developerKey=self._developerKey,
1051 requestBuilder=self._requestBuilder,
1052 resourceDesc=methodDesc, rootDesc=rootDesc,
1053 schema=schema)
1054
1055 setattr(methodResource, '__doc__', 'A collection resource.')
1056 setattr(methodResource, '__is_resource__', True)
1057
1058 return (methodName, methodResource)
1059
INADA Naokie4ea1a92015-03-04 03:45:42 +09001060 for methodName, methodDesc in six.iteritems(resourceDesc['resources']):
John Asmuth864311d2014-04-24 15:46:08 -04001061 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
1062 self._set_dynamic_attr(fixedMethodName,
1063 method.__get__(self, self.__class__))
1064
1065 def _add_next_methods(self, resourceDesc, schema):
1066 # Add _next() methods
1067 # Look for response bodies in schema that contain nextPageToken, and methods
1068 # that take a pageToken parameter.
1069 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001070 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001071 if 'response' in methodDesc:
1072 responseSchema = methodDesc['response']
1073 if '$ref' in responseSchema:
1074 responseSchema = schema.get(responseSchema['$ref'])
1075 hasNextPageToken = 'nextPageToken' in responseSchema.get('properties',
1076 {})
1077 hasPageToken = 'pageToken' in methodDesc.get('parameters', {})
1078 if hasNextPageToken and hasPageToken:
1079 fixedMethodName, method = createNextMethod(methodName + '_next')
1080 self._set_dynamic_attr(fixedMethodName,
1081 method.__get__(self, self.__class__))