blob: cee56284c0724a549157630c07153f86cb7ae9c1 [file] [log] [blame]
Craig Citro751b7fb2014-09-23 11:20:38 -07001# Copyright 2014 Google Inc. All Rights Reserved.
John Asmuth864311d2014-04-24 15:46:08 -04002#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Client for discovery based APIs.
16
17A client library for Google's discovery based APIs.
18"""
INADA Naoki0bceb332014-08-20 15:27:52 +090019from __future__ import absolute_import
INADA Naokie4ea1a92015-03-04 03:45:42 +090020import six
21from six.moves import zip
John Asmuth864311d2014-04-24 15:46:08 -040022
23__author__ = 'jcgregorio@google.com (Joe Gregorio)'
24__all__ = [
25 'build',
26 'build_from_document',
27 'fix_method_name',
28 'key2param',
29 ]
30
Phil Ruffwind26178fc2015-10-13 19:00:33 -040031from six import BytesIO
Takashi Matsuo3772f9d2015-09-04 12:25:55 -070032from six.moves import http_client
Pat Ferated5b61bd2015-03-03 16:04:11 -080033from six.moves.urllib.parse import urlencode, urlparse, urljoin, \
34 urlunparse, parse_qsl
John Asmuth864311d2014-04-24 15:46:08 -040035
36# Standard library imports
37import copy
Phil Ruffwind26178fc2015-10-13 19:00:33 -040038try:
39 from email.generator import BytesGenerator
40except ImportError:
41 from email.generator import Generator as BytesGenerator
John Asmuth864311d2014-04-24 15:46:08 -040042from email.mime.multipart import MIMEMultipart
43from email.mime.nonmultipart import MIMENonMultipart
Craig Citro6ae34d72014-08-18 23:10:09 -070044import json
John Asmuth864311d2014-04-24 15:46:08 -040045import keyword
46import logging
47import mimetypes
48import os
49import re
John Asmuth864311d2014-04-24 15:46:08 -040050
51# Third-party imports
52import httplib2
John Asmuth864311d2014-04-24 15:46:08 -040053import uritemplate
54
55# Local imports
Pat Ferateb240c172015-03-03 16:23:51 -080056from googleapiclient import mimeparse
John Asmuth864311d2014-04-24 15:46:08 -040057from googleapiclient.errors import HttpError
58from googleapiclient.errors import InvalidJsonError
59from googleapiclient.errors import MediaUploadSizeError
60from googleapiclient.errors import UnacceptableMimeTypeError
61from googleapiclient.errors import UnknownApiNameOrVersion
62from googleapiclient.errors import UnknownFileType
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -040063from googleapiclient.http import BatchHttpRequest
John Asmuth864311d2014-04-24 15:46:08 -040064from googleapiclient.http import HttpRequest
65from googleapiclient.http import MediaFileUpload
66from googleapiclient.http import MediaUpload
67from googleapiclient.model import JsonModel
68from googleapiclient.model import MediaModel
69from googleapiclient.model import RawModel
70from googleapiclient.schema import Schemas
Craig Citroae83efb2014-06-06 09:45:57 -070071from oauth2client.client import GoogleCredentials
John Asmuth864311d2014-04-24 15:46:08 -040072from oauth2client.util import _add_query_parameter
73from oauth2client.util import positional
74
75
76# The client library requires a version of httplib2 that supports RETRIES.
77httplib2.RETRIES = 1
78
79logger = logging.getLogger(__name__)
80
81URITEMPLATE = re.compile('{[^}]*}')
82VARNAME = re.compile('[a-zA-Z0-9_-]+')
83DISCOVERY_URI = ('https://www.googleapis.com/discovery/v1/apis/'
84 '{api}/{apiVersion}/rest')
85DEFAULT_METHOD_DOC = 'A description of how to use this function'
86HTTP_PAYLOAD_METHODS = frozenset(['PUT', 'POST', 'PATCH'])
87_MEDIA_SIZE_BIT_SHIFTS = {'KB': 10, 'MB': 20, 'GB': 30, 'TB': 40}
88BODY_PARAMETER_DEFAULT_VALUE = {
89 'description': 'The request body.',
90 'type': 'object',
91 'required': True,
92}
93MEDIA_BODY_PARAMETER_DEFAULT_VALUE = {
94 'description': ('The filename of the media request body, or an instance '
95 'of a MediaUpload object.'),
96 'type': 'string',
97 'required': False,
98}
99
100# Parameters accepted by the stack, but not visible via discovery.
101# TODO(dhermes): Remove 'userip' in 'v2'.
102STACK_QUERY_PARAMETERS = frozenset(['trace', 'pp', 'userip', 'strict'])
103STACK_QUERY_PARAMETER_DEFAULT_VALUE = {'type': 'string', 'location': 'query'}
104
105# Library-specific reserved words beyond Python keywords.
106RESERVED_WORDS = frozenset(['body'])
107
Phil Ruffwind26178fc2015-10-13 19:00:33 -0400108# patch _write_lines to avoid munging '\r' into '\n'
109# ( https://bugs.python.org/issue18886 https://bugs.python.org/issue19003 )
110class _BytesGenerator(BytesGenerator):
111 _write_lines = BytesGenerator.write
John Asmuth864311d2014-04-24 15:46:08 -0400112
113def fix_method_name(name):
114 """Fix method names to avoid reserved word conflicts.
115
116 Args:
117 name: string, method name.
118
119 Returns:
120 The name with a '_' prefixed if the name is a reserved word.
121 """
122 if keyword.iskeyword(name) or name in RESERVED_WORDS:
123 return name + '_'
124 else:
125 return name
126
127
128def key2param(key):
129 """Converts key names into parameter names.
130
131 For example, converting "max-results" -> "max_results"
132
133 Args:
134 key: string, the method key name.
135
136 Returns:
137 A safe method name based on the key name.
138 """
139 result = []
140 key = list(key)
141 if not key[0].isalpha():
142 result.append('x')
143 for c in key:
144 if c.isalnum():
145 result.append(c)
146 else:
147 result.append('_')
148
149 return ''.join(result)
150
151
152@positional(2)
153def build(serviceName,
154 version,
155 http=None,
156 discoveryServiceUrl=DISCOVERY_URI,
157 developerKey=None,
158 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700159 requestBuilder=HttpRequest,
Takashi Matsuo30125122015-08-19 11:42:32 -0700160 credentials=None,
161 cache_discovery=True,
162 cache=None):
John Asmuth864311d2014-04-24 15:46:08 -0400163 """Construct a Resource for interacting with an API.
164
165 Construct a Resource object for interacting with an API. The serviceName and
166 version are the names from the Discovery service.
167
168 Args:
169 serviceName: string, name of the service.
170 version: string, the version of the service.
171 http: httplib2.Http, An instance of httplib2.Http or something that acts
172 like it that HTTP requests will be made through.
173 discoveryServiceUrl: string, a URI Template that points to the location of
174 the discovery service. It should have two parameters {api} and
175 {apiVersion} that when filled in produce an absolute URI to the discovery
176 document for that service.
177 developerKey: string, key obtained from
178 https://code.google.com/apis/console.
179 model: googleapiclient.Model, converts to and from the wire format.
180 requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
181 request.
Orest Bolohane92c9002014-05-30 11:15:43 -0700182 credentials: oauth2client.Credentials, credentials to be used for
183 authentication.
Takashi Matsuo30125122015-08-19 11:42:32 -0700184 cache_discovery: Boolean, whether or not to cache the discovery doc.
185 cache: googleapiclient.discovery_cache.base.CacheBase, an optional
186 cache object for the discovery documents.
John Asmuth864311d2014-04-24 15:46:08 -0400187
188 Returns:
189 A Resource object with methods for interacting with the service.
190 """
191 params = {
192 'api': serviceName,
193 'apiVersion': version
194 }
195
196 if http is None:
197 http = httplib2.Http()
198
199 requested_url = uritemplate.expand(discoveryServiceUrl, params)
200
Takashi Matsuo3772f9d2015-09-04 12:25:55 -0700201 try:
202 content = _retrieve_discovery_doc(requested_url, http, cache_discovery,
203 cache)
204 except HttpError as e:
205 if e.resp.status == http_client.NOT_FOUND:
206 raise UnknownApiNameOrVersion("name: %s version: %s" % (serviceName,
207 version))
208 else:
209 raise e
Takashi Matsuo30125122015-08-19 11:42:32 -0700210
211 return build_from_document(content, base=discoveryServiceUrl, http=http,
212 developerKey=developerKey, model=model, requestBuilder=requestBuilder,
213 credentials=credentials)
214
215
216def _retrieve_discovery_doc(url, http, cache_discovery, cache=None):
217 """Retrieves the discovery_doc from cache or the internet.
218
219 Args:
220 url: string, the URL of the discovery document.
221 http: httplib2.Http, An instance of httplib2.Http or something that acts
222 like it through which HTTP requests will be made.
223 cache_discovery: Boolean, whether or not to cache the discovery doc.
224 cache: googleapiclient.discovery_cache.base.Cache, an optional cache
225 object for the discovery documents.
226
227 Returns:
228 A unicode string representation of the discovery document.
229 """
230 if cache_discovery:
231 from . import discovery_cache
232 from .discovery_cache import base
233 if cache is None:
234 cache = discovery_cache.autodetect()
235 if cache:
236 content = cache.get(url)
237 if content:
238 return content
239
240 actual_url = url
John Asmuth864311d2014-04-24 15:46:08 -0400241 # REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
242 # variable that contains the network address of the client sending the
243 # request. If it exists then add that to the request for the discovery
244 # document to avoid exceeding the quota on discovery requests.
245 if 'REMOTE_ADDR' in os.environ:
Takashi Matsuo30125122015-08-19 11:42:32 -0700246 actual_url = _add_query_parameter(url, 'userIp', os.environ['REMOTE_ADDR'])
247 logger.info('URL being requested: GET %s', actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400248
Takashi Matsuo30125122015-08-19 11:42:32 -0700249 resp, content = http.request(actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400250
John Asmuth864311d2014-04-24 15:46:08 -0400251 if resp.status >= 400:
Takashi Matsuo30125122015-08-19 11:42:32 -0700252 raise HttpError(resp, content, uri=actual_url)
John Asmuth864311d2014-04-24 15:46:08 -0400253
254 try:
Pat Ferate9b0452c2015-03-03 17:59:56 -0800255 content = content.decode('utf-8')
256 except AttributeError:
257 pass
258
259 try:
Craig Citro6ae34d72014-08-18 23:10:09 -0700260 service = json.loads(content)
INADA Naokic1505df2014-08-20 15:19:53 +0900261 except ValueError as e:
John Asmuth864311d2014-04-24 15:46:08 -0400262 logger.error('Failed to parse as JSON: ' + content)
263 raise InvalidJsonError()
Takashi Matsuo30125122015-08-19 11:42:32 -0700264 if cache_discovery and cache:
265 cache.set(url, content)
266 return content
John Asmuth864311d2014-04-24 15:46:08 -0400267
268
269@positional(1)
270def build_from_document(
271 service,
272 base=None,
273 future=None,
274 http=None,
275 developerKey=None,
276 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700277 requestBuilder=HttpRequest,
278 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400279 """Create a Resource for interacting with an API.
280
281 Same as `build()`, but constructs the Resource object from a discovery
282 document that is it given, as opposed to retrieving one over HTTP.
283
284 Args:
285 service: string or object, the JSON discovery document describing the API.
286 The value passed in may either be the JSON string or the deserialized
287 JSON.
288 base: string, base URI for all HTTP requests, usually the discovery URI.
289 This parameter is no longer used as rootUrl and servicePath are included
290 within the discovery document. (deprecated)
291 future: string, discovery document with future capabilities (deprecated).
292 http: httplib2.Http, An instance of httplib2.Http or something that acts
293 like it that HTTP requests will be made through.
294 developerKey: string, Key for controlling API usage, generated
295 from the API Console.
296 model: Model class instance that serializes and de-serializes requests and
297 responses.
298 requestBuilder: Takes an http request and packages it up to be executed.
Orest Bolohane92c9002014-05-30 11:15:43 -0700299 credentials: object, credentials to be used for authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400300
301 Returns:
302 A Resource object with methods for interacting with the service.
303 """
304
Jonathan Wayne Parrotta6e6fbd2015-07-16 15:33:57 -0700305 if http is None:
306 http = httplib2.Http()
307
John Asmuth864311d2014-04-24 15:46:08 -0400308 # future is no longer used.
309 future = {}
310
INADA Naokie4ea1a92015-03-04 03:45:42 +0900311 if isinstance(service, six.string_types):
Craig Citro6ae34d72014-08-18 23:10:09 -0700312 service = json.loads(service)
Pat Ferated5b61bd2015-03-03 16:04:11 -0800313 base = urljoin(service['rootUrl'], service['servicePath'])
John Asmuth864311d2014-04-24 15:46:08 -0400314 schema = Schemas(service)
315
Orest Bolohane92c9002014-05-30 11:15:43 -0700316 if credentials:
317 # If credentials were passed in, we could have two cases:
318 # 1. the scopes were specified, in which case the given credentials
319 # are used for authorizing the http;
oresticaaff4e1f2014-07-08 11:28:45 -0700320 # 2. the scopes were not provided (meaning the Application Default
321 # Credentials are to be used). In this case, the Application Default
322 # Credentials are built and used instead of the original credentials.
323 # If there are no scopes found (meaning the given service requires no
324 # authentication), there is no authorization of the http.
Craig Citroae83efb2014-06-06 09:45:57 -0700325 if (isinstance(credentials, GoogleCredentials) and
326 credentials.create_scoped_required()):
Orest Bolohane92c9002014-05-30 11:15:43 -0700327 scopes = service.get('auth', {}).get('oauth2', {}).get('scopes', {})
328 if scopes:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900329 credentials = credentials.create_scoped(list(scopes.keys()))
Orest Bolohane92c9002014-05-30 11:15:43 -0700330 else:
331 # No need to authorize the http object
332 # if the service does not require authentication.
333 credentials = None
334
335 if credentials:
336 http = credentials.authorize(http)
337
John Asmuth864311d2014-04-24 15:46:08 -0400338 if model is None:
339 features = service.get('features', [])
340 model = JsonModel('dataWrapper' in features)
341 return Resource(http=http, baseUrl=base, model=model,
342 developerKey=developerKey, requestBuilder=requestBuilder,
343 resourceDesc=service, rootDesc=service, schema=schema)
344
345
346def _cast(value, schema_type):
347 """Convert value to a string based on JSON Schema type.
348
349 See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
350 JSON Schema.
351
352 Args:
353 value: any, the value to convert
354 schema_type: string, the type that value should be interpreted as
355
356 Returns:
357 A string representation of 'value' based on the schema_type.
358 """
359 if schema_type == 'string':
360 if type(value) == type('') or type(value) == type(u''):
361 return value
362 else:
363 return str(value)
364 elif schema_type == 'integer':
365 return str(int(value))
366 elif schema_type == 'number':
367 return str(float(value))
368 elif schema_type == 'boolean':
369 return str(bool(value)).lower()
370 else:
371 if type(value) == type('') or type(value) == type(u''):
372 return value
373 else:
374 return str(value)
375
376
377def _media_size_to_long(maxSize):
378 """Convert a string media size, such as 10GB or 3TB into an integer.
379
380 Args:
381 maxSize: string, size as a string, such as 2MB or 7GB.
382
383 Returns:
384 The size as an integer value.
385 """
386 if len(maxSize) < 2:
INADA Naoki0bceb332014-08-20 15:27:52 +0900387 return 0
John Asmuth864311d2014-04-24 15:46:08 -0400388 units = maxSize[-2:].upper()
389 bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
390 if bit_shift is not None:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900391 return int(maxSize[:-2]) << bit_shift
John Asmuth864311d2014-04-24 15:46:08 -0400392 else:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900393 return int(maxSize)
John Asmuth864311d2014-04-24 15:46:08 -0400394
395
396def _media_path_url_from_info(root_desc, path_url):
397 """Creates an absolute media path URL.
398
399 Constructed using the API root URI and service path from the discovery
400 document and the relative path for the API method.
401
402 Args:
403 root_desc: Dictionary; the entire original deserialized discovery document.
404 path_url: String; the relative URL for the API method. Relative to the API
405 root, which is specified in the discovery document.
406
407 Returns:
408 String; the absolute URI for media upload for the API method.
409 """
410 return '%(root)supload/%(service_path)s%(path)s' % {
411 'root': root_desc['rootUrl'],
412 'service_path': root_desc['servicePath'],
413 'path': path_url,
414 }
415
416
417def _fix_up_parameters(method_desc, root_desc, http_method):
418 """Updates parameters of an API method with values specific to this library.
419
420 Specifically, adds whatever global parameters are specified by the API to the
421 parameters for the individual method. Also adds parameters which don't
422 appear in the discovery document, but are available to all discovery based
423 APIs (these are listed in STACK_QUERY_PARAMETERS).
424
425 SIDE EFFECTS: This updates the parameters dictionary object in the method
426 description.
427
428 Args:
429 method_desc: Dictionary with metadata describing an API method. Value comes
430 from the dictionary of methods stored in the 'methods' key in the
431 deserialized discovery document.
432 root_desc: Dictionary; the entire original deserialized discovery document.
433 http_method: String; the HTTP method used to call the API method described
434 in method_desc.
435
436 Returns:
437 The updated Dictionary stored in the 'parameters' key of the method
438 description dictionary.
439 """
440 parameters = method_desc.setdefault('parameters', {})
441
442 # Add in the parameters common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900443 for name, description in six.iteritems(root_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400444 parameters[name] = description
445
446 # Add in undocumented query parameters.
447 for name in STACK_QUERY_PARAMETERS:
448 parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
449
450 # Add 'body' (our own reserved word) to parameters if the method supports
451 # a request payload.
452 if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
453 body = BODY_PARAMETER_DEFAULT_VALUE.copy()
454 body.update(method_desc['request'])
455 parameters['body'] = body
456
457 return parameters
458
459
460def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
461 """Updates parameters of API by adding 'media_body' if supported by method.
462
463 SIDE EFFECTS: If the method supports media upload and has a required body,
464 sets body to be optional (required=False) instead. Also, if there is a
465 'mediaUpload' in the method description, adds 'media_upload' key to
466 parameters.
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 path_url: String; the relative URL for the API method. Relative to the API
474 root, which is specified in the discovery document.
475 parameters: A dictionary describing method parameters for method described
476 in method_desc.
477
478 Returns:
479 Triple (accept, max_size, media_path_url) where:
480 - accept is a list of strings representing what content types are
481 accepted for media upload. Defaults to empty list if not in the
482 discovery document.
483 - max_size is a long representing the max size in bytes allowed for a
484 media upload. Defaults to 0L if not in the discovery document.
485 - media_path_url is a String; the absolute URI for media upload for the
486 API method. Constructed using the API root URI and service path from
487 the discovery document and the relative path for the API method. If
488 media upload is not supported, this is None.
489 """
490 media_upload = method_desc.get('mediaUpload', {})
491 accept = media_upload.get('accept', [])
492 max_size = _media_size_to_long(media_upload.get('maxSize', ''))
493 media_path_url = None
494
495 if media_upload:
496 media_path_url = _media_path_url_from_info(root_desc, path_url)
497 parameters['media_body'] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
498 if 'body' in parameters:
499 parameters['body']['required'] = False
500
501 return accept, max_size, media_path_url
502
503
504def _fix_up_method_description(method_desc, root_desc):
505 """Updates a method description in a discovery document.
506
507 SIDE EFFECTS: Changes the parameters dictionary in the method description with
508 extra parameters which are used locally.
509
510 Args:
511 method_desc: Dictionary with metadata describing an API method. Value comes
512 from the dictionary of methods stored in the 'methods' key in the
513 deserialized discovery document.
514 root_desc: Dictionary; the entire original deserialized discovery document.
515
516 Returns:
517 Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
518 where:
519 - path_url is a String; the relative URL for the API method. Relative to
520 the API root, which is specified in the discovery document.
521 - http_method is a String; the HTTP method used to call the API method
522 described in the method description.
523 - method_id is a String; the name of the RPC method associated with the
524 API method, and is in the method description in the 'id' key.
525 - accept is a list of strings representing what content types are
526 accepted for media upload. Defaults to empty list if not in the
527 discovery document.
528 - max_size is a long representing the max size in bytes allowed for a
529 media upload. Defaults to 0L if not in the discovery document.
530 - media_path_url is a String; the absolute URI for media upload for the
531 API method. Constructed using the API root URI and service path from
532 the discovery document and the relative path for the API method. If
533 media upload is not supported, this is None.
534 """
535 path_url = method_desc['path']
536 http_method = method_desc['httpMethod']
537 method_id = method_desc['id']
538
539 parameters = _fix_up_parameters(method_desc, root_desc, http_method)
540 # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
541 # 'parameters' key and needs to know if there is a 'body' parameter because it
542 # also sets a 'media_body' parameter.
543 accept, max_size, media_path_url = _fix_up_media_upload(
544 method_desc, root_desc, path_url, parameters)
545
546 return path_url, http_method, method_id, accept, max_size, media_path_url
547
548
Craig Citro7ee535d2015-02-23 10:11:14 -0800549def _urljoin(base, url):
550 """Custom urljoin replacement supporting : before / in url."""
551 # In general, it's unsafe to simply join base and url. However, for
552 # the case of discovery documents, we know:
553 # * base will never contain params, query, or fragment
554 # * url will never contain a scheme or net_loc.
555 # In general, this means we can safely join on /; we just need to
556 # ensure we end up with precisely one / joining base and url. The
557 # exception here is the case of media uploads, where url will be an
558 # absolute url.
559 if url.startswith('http://') or url.startswith('https://'):
Pat Ferated5b61bd2015-03-03 16:04:11 -0800560 return urljoin(base, url)
Craig Citro7ee535d2015-02-23 10:11:14 -0800561 new_base = base if base.endswith('/') else base + '/'
562 new_url = url[1:] if url.startswith('/') else url
563 return new_base + new_url
564
565
John Asmuth864311d2014-04-24 15:46:08 -0400566# TODO(dhermes): Convert this class to ResourceMethod and make it callable
567class ResourceMethodParameters(object):
568 """Represents the parameters associated with a method.
569
570 Attributes:
571 argmap: Map from method parameter name (string) to query parameter name
572 (string).
573 required_params: List of required parameters (represented by parameter
574 name as string).
575 repeated_params: List of repeated parameters (represented by parameter
576 name as string).
577 pattern_params: Map from method parameter name (string) to regular
578 expression (as a string). If the pattern is set for a parameter, the
579 value for that parameter must match the regular expression.
580 query_params: List of parameters (represented by parameter name as string)
581 that will be used in the query string.
582 path_params: Set of parameters (represented by parameter name as string)
583 that will be used in the base URL path.
584 param_types: Map from method parameter name (string) to parameter type. Type
585 can be any valid JSON schema type; valid values are 'any', 'array',
586 'boolean', 'integer', 'number', 'object', or 'string'. Reference:
587 http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
588 enum_params: Map from method parameter name (string) to list of strings,
589 where each list of strings is the list of acceptable enum values.
590 """
591
592 def __init__(self, method_desc):
593 """Constructor for ResourceMethodParameters.
594
595 Sets default values and defers to set_parameters to populate.
596
597 Args:
598 method_desc: Dictionary with metadata describing an API method. Value
599 comes from the dictionary of methods stored in the 'methods' key in
600 the deserialized discovery document.
601 """
602 self.argmap = {}
603 self.required_params = []
604 self.repeated_params = []
605 self.pattern_params = {}
606 self.query_params = []
607 # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
608 # parsing is gotten rid of.
609 self.path_params = set()
610 self.param_types = {}
611 self.enum_params = {}
612
613 self.set_parameters(method_desc)
614
615 def set_parameters(self, method_desc):
616 """Populates maps and lists based on method description.
617
618 Iterates through each parameter for the method and parses the values from
619 the parameter dictionary.
620
621 Args:
622 method_desc: Dictionary with metadata describing an API method. Value
623 comes from the dictionary of methods stored in the 'methods' key in
624 the deserialized discovery document.
625 """
INADA Naokie4ea1a92015-03-04 03:45:42 +0900626 for arg, desc in six.iteritems(method_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400627 param = key2param(arg)
628 self.argmap[param] = arg
629
630 if desc.get('pattern'):
631 self.pattern_params[param] = desc['pattern']
632 if desc.get('enum'):
633 self.enum_params[param] = desc['enum']
634 if desc.get('required'):
635 self.required_params.append(param)
636 if desc.get('repeated'):
637 self.repeated_params.append(param)
638 if desc.get('location') == 'query':
639 self.query_params.append(param)
640 if desc.get('location') == 'path':
641 self.path_params.add(param)
642 self.param_types[param] = desc.get('type', 'string')
643
644 # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
645 # should have all path parameters already marked with
646 # 'location: path'.
647 for match in URITEMPLATE.finditer(method_desc['path']):
648 for namematch in VARNAME.finditer(match.group(0)):
649 name = key2param(namematch.group(0))
650 self.path_params.add(name)
651 if name in self.query_params:
652 self.query_params.remove(name)
653
654
655def createMethod(methodName, methodDesc, rootDesc, schema):
656 """Creates a method for attaching to a Resource.
657
658 Args:
659 methodName: string, name of the method to use.
660 methodDesc: object, fragment of deserialized discovery document that
661 describes the method.
662 rootDesc: object, the entire deserialized discovery document.
663 schema: object, mapping of schema names to schema descriptions.
664 """
665 methodName = fix_method_name(methodName)
666 (pathUrl, httpMethod, methodId, accept,
667 maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc)
668
669 parameters = ResourceMethodParameters(methodDesc)
670
671 def method(self, **kwargs):
672 # Don't bother with doc string, it will be over-written by createMethod.
673
INADA Naokie4ea1a92015-03-04 03:45:42 +0900674 for name in six.iterkeys(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400675 if name not in parameters.argmap:
676 raise TypeError('Got an unexpected keyword argument "%s"' % name)
677
678 # Remove args that have a value of None.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900679 keys = list(kwargs.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400680 for name in keys:
681 if kwargs[name] is None:
682 del kwargs[name]
683
684 for name in parameters.required_params:
685 if name not in kwargs:
686 raise TypeError('Missing required parameter "%s"' % name)
687
INADA Naokie4ea1a92015-03-04 03:45:42 +0900688 for name, regex in six.iteritems(parameters.pattern_params):
John Asmuth864311d2014-04-24 15:46:08 -0400689 if name in kwargs:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900690 if isinstance(kwargs[name], six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400691 pvalues = [kwargs[name]]
692 else:
693 pvalues = kwargs[name]
694 for pvalue in pvalues:
695 if re.match(regex, pvalue) is None:
696 raise TypeError(
697 'Parameter "%s" value "%s" does not match the pattern "%s"' %
698 (name, pvalue, regex))
699
INADA Naokie4ea1a92015-03-04 03:45:42 +0900700 for name, enums in six.iteritems(parameters.enum_params):
John Asmuth864311d2014-04-24 15:46:08 -0400701 if name in kwargs:
702 # We need to handle the case of a repeated enum
703 # name differently, since we want to handle both
704 # arg='value' and arg=['value1', 'value2']
705 if (name in parameters.repeated_params and
INADA Naokie4ea1a92015-03-04 03:45:42 +0900706 not isinstance(kwargs[name], six.string_types)):
John Asmuth864311d2014-04-24 15:46:08 -0400707 values = kwargs[name]
708 else:
709 values = [kwargs[name]]
710 for value in values:
711 if value not in enums:
712 raise TypeError(
713 'Parameter "%s" value "%s" is not an allowed value in "%s"' %
714 (name, value, str(enums)))
715
716 actual_query_params = {}
717 actual_path_params = {}
INADA Naokie4ea1a92015-03-04 03:45:42 +0900718 for key, value in six.iteritems(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400719 to_type = parameters.param_types.get(key, 'string')
720 # For repeated parameters we cast each member of the list.
721 if key in parameters.repeated_params and type(value) == type([]):
722 cast_value = [_cast(x, to_type) for x in value]
723 else:
724 cast_value = _cast(value, to_type)
725 if key in parameters.query_params:
726 actual_query_params[parameters.argmap[key]] = cast_value
727 if key in parameters.path_params:
728 actual_path_params[parameters.argmap[key]] = cast_value
729 body_value = kwargs.get('body', None)
730 media_filename = kwargs.get('media_body', None)
731
732 if self._developerKey:
733 actual_query_params['key'] = self._developerKey
734
735 model = self._model
736 if methodName.endswith('_media'):
737 model = MediaModel()
738 elif 'response' not in methodDesc:
739 model = RawModel()
740
741 headers = {}
742 headers, params, query, body = model.request(headers,
743 actual_path_params, actual_query_params, body_value)
744
745 expanded_url = uritemplate.expand(pathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800746 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400747
748 resumable = None
749 multipart_boundary = ''
750
751 if media_filename:
752 # Ensure we end up with a valid MediaUpload object.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900753 if isinstance(media_filename, six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400754 (media_mime_type, encoding) = mimetypes.guess_type(media_filename)
755 if media_mime_type is None:
756 raise UnknownFileType(media_filename)
757 if not mimeparse.best_match([media_mime_type], ','.join(accept)):
758 raise UnacceptableMimeTypeError(media_mime_type)
759 media_upload = MediaFileUpload(media_filename,
760 mimetype=media_mime_type)
761 elif isinstance(media_filename, MediaUpload):
762 media_upload = media_filename
763 else:
764 raise TypeError('media_filename must be str or MediaUpload.')
765
766 # Check the maxSize
Pat Feratec46e9052015-03-03 17:59:17 -0800767 if media_upload.size() is not None and media_upload.size() > maxSize > 0:
John Asmuth864311d2014-04-24 15:46:08 -0400768 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
769
770 # Use the media path uri for media uploads
771 expanded_url = uritemplate.expand(mediaPathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800772 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400773 if media_upload.resumable():
774 url = _add_query_parameter(url, 'uploadType', 'resumable')
775
776 if media_upload.resumable():
777 # This is all we need to do for resumable, if the body exists it gets
778 # sent in the first request, otherwise an empty body is sent.
779 resumable = media_upload
780 else:
781 # A non-resumable upload
782 if body is None:
783 # This is a simple media upload
784 headers['content-type'] = media_upload.mimetype()
785 body = media_upload.getbytes(0, media_upload.size())
786 url = _add_query_parameter(url, 'uploadType', 'media')
787 else:
788 # This is a multipart/related upload.
789 msgRoot = MIMEMultipart('related')
790 # msgRoot should not write out it's own headers
791 setattr(msgRoot, '_write_headers', lambda self: None)
792
793 # attach the body as one part
794 msg = MIMENonMultipart(*headers['content-type'].split('/'))
795 msg.set_payload(body)
796 msgRoot.attach(msg)
797
798 # attach the media as the second part
799 msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
800 msg['Content-Transfer-Encoding'] = 'binary'
801
802 payload = media_upload.getbytes(0, media_upload.size())
803 msg.set_payload(payload)
804 msgRoot.attach(msg)
Craig Citro72389b72014-07-15 17:12:50 -0700805 # encode the body: note that we can't use `as_string`, because
806 # it plays games with `From ` lines.
Phil Ruffwind26178fc2015-10-13 19:00:33 -0400807 fp = BytesIO()
808 g = _BytesGenerator(fp, mangle_from_=False)
Craig Citro72389b72014-07-15 17:12:50 -0700809 g.flatten(msgRoot, unixfrom=False)
810 body = fp.getvalue()
John Asmuth864311d2014-04-24 15:46:08 -0400811
812 multipart_boundary = msgRoot.get_boundary()
813 headers['content-type'] = ('multipart/related; '
814 'boundary="%s"') % multipart_boundary
815 url = _add_query_parameter(url, 'uploadType', 'multipart')
816
Eric Gjertsen87553e42014-05-13 15:49:50 -0400817 logger.info('URL being requested: %s %s' % (httpMethod,url))
John Asmuth864311d2014-04-24 15:46:08 -0400818 return self._requestBuilder(self._http,
819 model.response,
820 url,
821 method=httpMethod,
822 body=body,
823 headers=headers,
824 methodId=methodId,
825 resumable=resumable)
826
827 docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
828 if len(parameters.argmap) > 0:
829 docs.append('Args:\n')
830
831 # Skip undocumented params and params common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900832 skip_parameters = list(rootDesc.get('parameters', {}).keys())
John Asmuth864311d2014-04-24 15:46:08 -0400833 skip_parameters.extend(STACK_QUERY_PARAMETERS)
834
INADA Naokie4ea1a92015-03-04 03:45:42 +0900835 all_args = list(parameters.argmap.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400836 args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
837
838 # Move body to the front of the line.
839 if 'body' in all_args:
840 args_ordered.append('body')
841
842 for name in all_args:
843 if name not in args_ordered:
844 args_ordered.append(name)
845
846 for arg in args_ordered:
847 if arg in skip_parameters:
848 continue
849
850 repeated = ''
851 if arg in parameters.repeated_params:
852 repeated = ' (repeated)'
853 required = ''
854 if arg in parameters.required_params:
855 required = ' (required)'
856 paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
857 paramdoc = paramdesc.get('description', 'A parameter')
858 if '$ref' in paramdesc:
859 docs.append(
860 (' %s: object, %s%s%s\n The object takes the'
861 ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
862 schema.prettyPrintByName(paramdesc['$ref'])))
863 else:
864 paramtype = paramdesc.get('type', 'string')
865 docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
866 repeated))
867 enum = paramdesc.get('enum', [])
868 enumDesc = paramdesc.get('enumDescriptions', [])
869 if enum and enumDesc:
870 docs.append(' Allowed values\n')
871 for (name, desc) in zip(enum, enumDesc):
872 docs.append(' %s - %s\n' % (name, desc))
873 if 'response' in methodDesc:
874 if methodName.endswith('_media'):
875 docs.append('\nReturns:\n The media object as a string.\n\n ')
876 else:
877 docs.append('\nReturns:\n An object of the form:\n\n ')
878 docs.append(schema.prettyPrintSchema(methodDesc['response']))
879
880 setattr(method, '__doc__', ''.join(docs))
881 return (methodName, method)
882
883
884def createNextMethod(methodName):
885 """Creates any _next methods for attaching to a Resource.
886
887 The _next methods allow for easy iteration through list() responses.
888
889 Args:
890 methodName: string, name of the method to use.
891 """
892 methodName = fix_method_name(methodName)
893
894 def methodNext(self, previous_request, previous_response):
895 """Retrieves the next page of results.
896
897Args:
898 previous_request: The request for the previous page. (required)
899 previous_response: The response from the request for the previous page. (required)
900
901Returns:
902 A request object that you can call 'execute()' on to request the next
903 page. Returns None if there are no more items in the collection.
904 """
905 # Retrieve nextPageToken from previous_response
906 # Use as pageToken in previous_request to create new request.
907
Son Dinh2a9a2132015-07-23 16:30:56 +0000908 if 'nextPageToken' not in previous_response or not previous_response['nextPageToken']:
John Asmuth864311d2014-04-24 15:46:08 -0400909 return None
910
911 request = copy.copy(previous_request)
912
913 pageToken = previous_response['nextPageToken']
Pat Ferated5b61bd2015-03-03 16:04:11 -0800914 parsed = list(urlparse(request.uri))
John Asmuth864311d2014-04-24 15:46:08 -0400915 q = parse_qsl(parsed[4])
916
917 # Find and remove old 'pageToken' value from URI
918 newq = [(key, value) for (key, value) in q if key != 'pageToken']
919 newq.append(('pageToken', pageToken))
Pat Ferated5b61bd2015-03-03 16:04:11 -0800920 parsed[4] = urlencode(newq)
921 uri = urlunparse(parsed)
John Asmuth864311d2014-04-24 15:46:08 -0400922
923 request.uri = uri
924
Eric Gjertsen87553e42014-05-13 15:49:50 -0400925 logger.info('URL being requested: %s %s' % (methodName,uri))
John Asmuth864311d2014-04-24 15:46:08 -0400926
927 return request
928
929 return (methodName, methodNext)
930
931
932class Resource(object):
933 """A class for interacting with a resource."""
934
935 def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
936 resourceDesc, rootDesc, schema):
937 """Build a Resource from the API description.
938
939 Args:
940 http: httplib2.Http, Object to make http requests with.
941 baseUrl: string, base URL for the API. All requests are relative to this
942 URI.
943 model: googleapiclient.Model, converts to and from the wire format.
944 requestBuilder: class or callable that instantiates an
945 googleapiclient.HttpRequest object.
946 developerKey: string, key obtained from
947 https://code.google.com/apis/console
948 resourceDesc: object, section of deserialized discovery document that
949 describes a resource. Note that the top level discovery document
950 is considered a resource.
951 rootDesc: object, the entire deserialized discovery document.
952 schema: object, mapping of schema names to schema descriptions.
953 """
954 self._dynamic_attrs = []
955
956 self._http = http
957 self._baseUrl = baseUrl
958 self._model = model
959 self._developerKey = developerKey
960 self._requestBuilder = requestBuilder
961 self._resourceDesc = resourceDesc
962 self._rootDesc = rootDesc
963 self._schema = schema
964
965 self._set_service_methods()
966
967 def _set_dynamic_attr(self, attr_name, value):
968 """Sets an instance attribute and tracks it in a list of dynamic attributes.
969
970 Args:
971 attr_name: string; The name of the attribute to be set
972 value: The value being set on the object and tracked in the dynamic cache.
973 """
974 self._dynamic_attrs.append(attr_name)
975 self.__dict__[attr_name] = value
976
977 def __getstate__(self):
978 """Trim the state down to something that can be pickled.
979
980 Uses the fact that the instance variable _dynamic_attrs holds attrs that
981 will be wiped and restored on pickle serialization.
982 """
983 state_dict = copy.copy(self.__dict__)
984 for dynamic_attr in self._dynamic_attrs:
985 del state_dict[dynamic_attr]
986 del state_dict['_dynamic_attrs']
987 return state_dict
988
989 def __setstate__(self, state):
990 """Reconstitute the state of the object from being pickled.
991
992 Uses the fact that the instance variable _dynamic_attrs holds attrs that
993 will be wiped and restored on pickle serialization.
994 """
995 self.__dict__.update(state)
996 self._dynamic_attrs = []
997 self._set_service_methods()
998
999 def _set_service_methods(self):
1000 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
1001 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
1002 self._add_next_methods(self._resourceDesc, self._schema)
1003
1004 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001005 # If this is the root Resource, add a new_batch_http_request() method.
1006 if resourceDesc == rootDesc:
1007 batch_uri = '%s%s' % (
1008 rootDesc['rootUrl'], rootDesc.get('batchPath', 'batch'))
1009 def new_batch_http_request(callback=None):
Pepper Lebeck-Jobe56052ec2015-06-13 14:07:14 -04001010 """Create a BatchHttpRequest object based on the discovery document.
1011
1012 Args:
1013 callback: callable, A callback to be called for each response, of the
1014 form callback(id, response, exception). The first parameter is the
1015 request id, and the second is the deserialized response object. The
1016 third is an apiclient.errors.HttpError exception object if an HTTP
1017 error occurred while processing the request, or None if no error
1018 occurred.
1019
1020 Returns:
1021 A BatchHttpRequest object based on the discovery document.
1022 """
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -04001023 return BatchHttpRequest(callback=callback, batch_uri=batch_uri)
1024 self._set_dynamic_attr('new_batch_http_request', new_batch_http_request)
1025
John Asmuth864311d2014-04-24 15:46:08 -04001026 # Add basic methods to Resource
1027 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001028 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001029 fixedMethodName, method = createMethod(
1030 methodName, methodDesc, rootDesc, schema)
1031 self._set_dynamic_attr(fixedMethodName,
1032 method.__get__(self, self.__class__))
1033 # Add in _media methods. The functionality of the attached method will
1034 # change when it sees that the method name ends in _media.
1035 if methodDesc.get('supportsMediaDownload', False):
1036 fixedMethodName, method = createMethod(
1037 methodName + '_media', methodDesc, rootDesc, schema)
1038 self._set_dynamic_attr(fixedMethodName,
1039 method.__get__(self, self.__class__))
1040
1041 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
1042 # Add in nested resources
1043 if 'resources' in resourceDesc:
1044
1045 def createResourceMethod(methodName, methodDesc):
1046 """Create a method on the Resource to access a nested Resource.
1047
1048 Args:
1049 methodName: string, name of the method to use.
1050 methodDesc: object, fragment of deserialized discovery document that
1051 describes the method.
1052 """
1053 methodName = fix_method_name(methodName)
1054
1055 def methodResource(self):
1056 return Resource(http=self._http, baseUrl=self._baseUrl,
1057 model=self._model, developerKey=self._developerKey,
1058 requestBuilder=self._requestBuilder,
1059 resourceDesc=methodDesc, rootDesc=rootDesc,
1060 schema=schema)
1061
1062 setattr(methodResource, '__doc__', 'A collection resource.')
1063 setattr(methodResource, '__is_resource__', True)
1064
1065 return (methodName, methodResource)
1066
INADA Naokie4ea1a92015-03-04 03:45:42 +09001067 for methodName, methodDesc in six.iteritems(resourceDesc['resources']):
John Asmuth864311d2014-04-24 15:46:08 -04001068 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
1069 self._set_dynamic_attr(fixedMethodName,
1070 method.__get__(self, self.__class__))
1071
1072 def _add_next_methods(self, resourceDesc, schema):
1073 # Add _next() methods
1074 # Look for response bodies in schema that contain nextPageToken, and methods
1075 # that take a pageToken parameter.
1076 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001077 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001078 if 'response' in methodDesc:
1079 responseSchema = methodDesc['response']
1080 if '$ref' in responseSchema:
1081 responseSchema = schema.get(responseSchema['$ref'])
1082 hasNextPageToken = 'nextPageToken' in responseSchema.get('properties',
1083 {})
1084 hasPageToken = 'pageToken' in methodDesc.get('parameters', {})
1085 if hasNextPageToken and hasPageToken:
1086 fixedMethodName, method = createNextMethod(methodName + '_next')
1087 self._set_dynamic_attr(fixedMethodName,
1088 method.__get__(self, self.__class__))