blob: 4109865f448290ce2a25e47f353c6d7e6d7991c4 [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
Pat Ferated5b61bd2015-03-03 16:04:11 -080032from six.moves.urllib.parse import urlencode, urlparse, urljoin, \
33 urlunparse, parse_qsl
John Asmuth864311d2014-04-24 15:46:08 -040034
35# Standard library imports
36import copy
Craig Citro72389b72014-07-15 17:12:50 -070037from email.generator import Generator
John Asmuth864311d2014-04-24 15:46:08 -040038from email.mime.multipart import MIMEMultipart
39from email.mime.nonmultipart import MIMENonMultipart
Craig Citro6ae34d72014-08-18 23:10:09 -070040import json
John Asmuth864311d2014-04-24 15:46:08 -040041import keyword
42import logging
43import mimetypes
44import os
45import re
John Asmuth864311d2014-04-24 15:46:08 -040046
47# Third-party imports
48import httplib2
John Asmuth864311d2014-04-24 15:46:08 -040049import uritemplate
50
51# Local imports
Pat Ferateb240c172015-03-03 16:23:51 -080052from googleapiclient import mimeparse
John Asmuth864311d2014-04-24 15:46:08 -040053from googleapiclient.errors import HttpError
54from googleapiclient.errors import InvalidJsonError
55from googleapiclient.errors import MediaUploadSizeError
56from googleapiclient.errors import UnacceptableMimeTypeError
57from googleapiclient.errors import UnknownApiNameOrVersion
58from googleapiclient.errors import UnknownFileType
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -040059from googleapiclient.http import BatchHttpRequest
John Asmuth864311d2014-04-24 15:46:08 -040060from googleapiclient.http import HttpRequest
61from googleapiclient.http import MediaFileUpload
62from googleapiclient.http import MediaUpload
63from googleapiclient.model import JsonModel
64from googleapiclient.model import MediaModel
65from googleapiclient.model import RawModel
66from googleapiclient.schema import Schemas
Craig Citroae83efb2014-06-06 09:45:57 -070067from oauth2client.client import GoogleCredentials
John Asmuth864311d2014-04-24 15:46:08 -040068from oauth2client.util import _add_query_parameter
69from oauth2client.util import positional
70
71
72# The client library requires a version of httplib2 that supports RETRIES.
73httplib2.RETRIES = 1
74
75logger = logging.getLogger(__name__)
76
77URITEMPLATE = re.compile('{[^}]*}')
78VARNAME = re.compile('[a-zA-Z0-9_-]+')
79DISCOVERY_URI = ('https://www.googleapis.com/discovery/v1/apis/'
80 '{api}/{apiVersion}/rest')
81DEFAULT_METHOD_DOC = 'A description of how to use this function'
82HTTP_PAYLOAD_METHODS = frozenset(['PUT', 'POST', 'PATCH'])
83_MEDIA_SIZE_BIT_SHIFTS = {'KB': 10, 'MB': 20, 'GB': 30, 'TB': 40}
84BODY_PARAMETER_DEFAULT_VALUE = {
85 'description': 'The request body.',
86 'type': 'object',
87 'required': True,
88}
89MEDIA_BODY_PARAMETER_DEFAULT_VALUE = {
90 'description': ('The filename of the media request body, or an instance '
91 'of a MediaUpload object.'),
92 'type': 'string',
93 'required': False,
94}
95
96# Parameters accepted by the stack, but not visible via discovery.
97# TODO(dhermes): Remove 'userip' in 'v2'.
98STACK_QUERY_PARAMETERS = frozenset(['trace', 'pp', 'userip', 'strict'])
99STACK_QUERY_PARAMETER_DEFAULT_VALUE = {'type': 'string', 'location': 'query'}
100
101# Library-specific reserved words beyond Python keywords.
102RESERVED_WORDS = frozenset(['body'])
103
104
105def fix_method_name(name):
106 """Fix method names to avoid reserved word conflicts.
107
108 Args:
109 name: string, method name.
110
111 Returns:
112 The name with a '_' prefixed if the name is a reserved word.
113 """
114 if keyword.iskeyword(name) or name in RESERVED_WORDS:
115 return name + '_'
116 else:
117 return name
118
119
120def key2param(key):
121 """Converts key names into parameter names.
122
123 For example, converting "max-results" -> "max_results"
124
125 Args:
126 key: string, the method key name.
127
128 Returns:
129 A safe method name based on the key name.
130 """
131 result = []
132 key = list(key)
133 if not key[0].isalpha():
134 result.append('x')
135 for c in key:
136 if c.isalnum():
137 result.append(c)
138 else:
139 result.append('_')
140
141 return ''.join(result)
142
143
144@positional(2)
145def build(serviceName,
146 version,
147 http=None,
148 discoveryServiceUrl=DISCOVERY_URI,
149 developerKey=None,
150 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700151 requestBuilder=HttpRequest,
152 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400153 """Construct a Resource for interacting with an API.
154
155 Construct a Resource object for interacting with an API. The serviceName and
156 version are the names from the Discovery service.
157
158 Args:
159 serviceName: string, name of the service.
160 version: string, the version of the service.
161 http: httplib2.Http, An instance of httplib2.Http or something that acts
162 like it that HTTP requests will be made through.
163 discoveryServiceUrl: string, a URI Template that points to the location of
164 the discovery service. It should have two parameters {api} and
165 {apiVersion} that when filled in produce an absolute URI to the discovery
166 document for that service.
167 developerKey: string, key obtained from
168 https://code.google.com/apis/console.
169 model: googleapiclient.Model, converts to and from the wire format.
170 requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
171 request.
Orest Bolohane92c9002014-05-30 11:15:43 -0700172 credentials: oauth2client.Credentials, credentials to be used for
173 authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400174
175 Returns:
176 A Resource object with methods for interacting with the service.
177 """
178 params = {
179 'api': serviceName,
180 'apiVersion': version
181 }
182
183 if http is None:
184 http = httplib2.Http()
185
186 requested_url = uritemplate.expand(discoveryServiceUrl, params)
187
188 # REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
189 # variable that contains the network address of the client sending the
190 # request. If it exists then add that to the request for the discovery
191 # document to avoid exceeding the quota on discovery requests.
192 if 'REMOTE_ADDR' in os.environ:
193 requested_url = _add_query_parameter(requested_url, 'userIp',
194 os.environ['REMOTE_ADDR'])
Eric Gjertsen87553e42014-05-13 15:49:50 -0400195 logger.info('URL being requested: GET %s' % requested_url)
John Asmuth864311d2014-04-24 15:46:08 -0400196
197 resp, content = http.request(requested_url)
198
199 if resp.status == 404:
200 raise UnknownApiNameOrVersion("name: %s version: %s" % (serviceName,
201 version))
202 if resp.status >= 400:
203 raise HttpError(resp, content, uri=requested_url)
204
205 try:
Pat Ferate9b0452c2015-03-03 17:59:56 -0800206 content = content.decode('utf-8')
207 except AttributeError:
208 pass
209
210 try:
Craig Citro6ae34d72014-08-18 23:10:09 -0700211 service = json.loads(content)
INADA Naokic1505df2014-08-20 15:19:53 +0900212 except ValueError as e:
John Asmuth864311d2014-04-24 15:46:08 -0400213 logger.error('Failed to parse as JSON: ' + content)
214 raise InvalidJsonError()
215
216 return build_from_document(content, base=discoveryServiceUrl, http=http,
Orest Bolohane92c9002014-05-30 11:15:43 -0700217 developerKey=developerKey, model=model, requestBuilder=requestBuilder,
218 credentials=credentials)
John Asmuth864311d2014-04-24 15:46:08 -0400219
220
221@positional(1)
222def build_from_document(
223 service,
224 base=None,
225 future=None,
226 http=None,
227 developerKey=None,
228 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700229 requestBuilder=HttpRequest,
230 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400231 """Create a Resource for interacting with an API.
232
233 Same as `build()`, but constructs the Resource object from a discovery
234 document that is it given, as opposed to retrieving one over HTTP.
235
236 Args:
237 service: string or object, the JSON discovery document describing the API.
238 The value passed in may either be the JSON string or the deserialized
239 JSON.
240 base: string, base URI for all HTTP requests, usually the discovery URI.
241 This parameter is no longer used as rootUrl and servicePath are included
242 within the discovery document. (deprecated)
243 future: string, discovery document with future capabilities (deprecated).
244 http: httplib2.Http, An instance of httplib2.Http or something that acts
245 like it that HTTP requests will be made through.
246 developerKey: string, Key for controlling API usage, generated
247 from the API Console.
248 model: Model class instance that serializes and de-serializes requests and
249 responses.
250 requestBuilder: Takes an http request and packages it up to be executed.
Orest Bolohane92c9002014-05-30 11:15:43 -0700251 credentials: object, credentials to be used for authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400252
253 Returns:
254 A Resource object with methods for interacting with the service.
255 """
256
257 # future is no longer used.
258 future = {}
259
INADA Naokie4ea1a92015-03-04 03:45:42 +0900260 if isinstance(service, six.string_types):
Craig Citro6ae34d72014-08-18 23:10:09 -0700261 service = json.loads(service)
Pat Ferated5b61bd2015-03-03 16:04:11 -0800262 base = urljoin(service['rootUrl'], service['servicePath'])
John Asmuth864311d2014-04-24 15:46:08 -0400263 schema = Schemas(service)
264
Orest Bolohane92c9002014-05-30 11:15:43 -0700265 if credentials:
266 # If credentials were passed in, we could have two cases:
267 # 1. the scopes were specified, in which case the given credentials
268 # are used for authorizing the http;
oresticaaff4e1f2014-07-08 11:28:45 -0700269 # 2. the scopes were not provided (meaning the Application Default
270 # Credentials are to be used). In this case, the Application Default
271 # Credentials are built and used instead of the original credentials.
272 # If there are no scopes found (meaning the given service requires no
273 # authentication), there is no authorization of the http.
Craig Citroae83efb2014-06-06 09:45:57 -0700274 if (isinstance(credentials, GoogleCredentials) and
275 credentials.create_scoped_required()):
Orest Bolohane92c9002014-05-30 11:15:43 -0700276 scopes = service.get('auth', {}).get('oauth2', {}).get('scopes', {})
277 if scopes:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900278 credentials = credentials.create_scoped(list(scopes.keys()))
Orest Bolohane92c9002014-05-30 11:15:43 -0700279 else:
280 # No need to authorize the http object
281 # if the service does not require authentication.
282 credentials = None
283
284 if credentials:
285 http = credentials.authorize(http)
286
John Asmuth864311d2014-04-24 15:46:08 -0400287 if model is None:
288 features = service.get('features', [])
289 model = JsonModel('dataWrapper' in features)
290 return Resource(http=http, baseUrl=base, model=model,
291 developerKey=developerKey, requestBuilder=requestBuilder,
292 resourceDesc=service, rootDesc=service, schema=schema)
293
294
295def _cast(value, schema_type):
296 """Convert value to a string based on JSON Schema type.
297
298 See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
299 JSON Schema.
300
301 Args:
302 value: any, the value to convert
303 schema_type: string, the type that value should be interpreted as
304
305 Returns:
306 A string representation of 'value' based on the schema_type.
307 """
308 if schema_type == 'string':
309 if type(value) == type('') or type(value) == type(u''):
310 return value
311 else:
312 return str(value)
313 elif schema_type == 'integer':
314 return str(int(value))
315 elif schema_type == 'number':
316 return str(float(value))
317 elif schema_type == 'boolean':
318 return str(bool(value)).lower()
319 else:
320 if type(value) == type('') or type(value) == type(u''):
321 return value
322 else:
323 return str(value)
324
325
326def _media_size_to_long(maxSize):
327 """Convert a string media size, such as 10GB or 3TB into an integer.
328
329 Args:
330 maxSize: string, size as a string, such as 2MB or 7GB.
331
332 Returns:
333 The size as an integer value.
334 """
335 if len(maxSize) < 2:
INADA Naoki0bceb332014-08-20 15:27:52 +0900336 return 0
John Asmuth864311d2014-04-24 15:46:08 -0400337 units = maxSize[-2:].upper()
338 bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
339 if bit_shift is not None:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900340 return int(maxSize[:-2]) << bit_shift
John Asmuth864311d2014-04-24 15:46:08 -0400341 else:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900342 return int(maxSize)
John Asmuth864311d2014-04-24 15:46:08 -0400343
344
345def _media_path_url_from_info(root_desc, path_url):
346 """Creates an absolute media path URL.
347
348 Constructed using the API root URI and service path from the discovery
349 document and the relative path for the API method.
350
351 Args:
352 root_desc: Dictionary; the entire original deserialized discovery document.
353 path_url: String; the relative URL for the API method. Relative to the API
354 root, which is specified in the discovery document.
355
356 Returns:
357 String; the absolute URI for media upload for the API method.
358 """
359 return '%(root)supload/%(service_path)s%(path)s' % {
360 'root': root_desc['rootUrl'],
361 'service_path': root_desc['servicePath'],
362 'path': path_url,
363 }
364
365
366def _fix_up_parameters(method_desc, root_desc, http_method):
367 """Updates parameters of an API method with values specific to this library.
368
369 Specifically, adds whatever global parameters are specified by the API to the
370 parameters for the individual method. Also adds parameters which don't
371 appear in the discovery document, but are available to all discovery based
372 APIs (these are listed in STACK_QUERY_PARAMETERS).
373
374 SIDE EFFECTS: This updates the parameters dictionary object in the method
375 description.
376
377 Args:
378 method_desc: Dictionary with metadata describing an API method. Value comes
379 from the dictionary of methods stored in the 'methods' key in the
380 deserialized discovery document.
381 root_desc: Dictionary; the entire original deserialized discovery document.
382 http_method: String; the HTTP method used to call the API method described
383 in method_desc.
384
385 Returns:
386 The updated Dictionary stored in the 'parameters' key of the method
387 description dictionary.
388 """
389 parameters = method_desc.setdefault('parameters', {})
390
391 # Add in the parameters common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900392 for name, description in six.iteritems(root_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400393 parameters[name] = description
394
395 # Add in undocumented query parameters.
396 for name in STACK_QUERY_PARAMETERS:
397 parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
398
399 # Add 'body' (our own reserved word) to parameters if the method supports
400 # a request payload.
401 if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
402 body = BODY_PARAMETER_DEFAULT_VALUE.copy()
403 body.update(method_desc['request'])
404 parameters['body'] = body
405
406 return parameters
407
408
409def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
410 """Updates parameters of API by adding 'media_body' if supported by method.
411
412 SIDE EFFECTS: If the method supports media upload and has a required body,
413 sets body to be optional (required=False) instead. Also, if there is a
414 'mediaUpload' in the method description, adds 'media_upload' key to
415 parameters.
416
417 Args:
418 method_desc: Dictionary with metadata describing an API method. Value comes
419 from the dictionary of methods stored in the 'methods' key in the
420 deserialized discovery document.
421 root_desc: Dictionary; the entire original deserialized discovery document.
422 path_url: String; the relative URL for the API method. Relative to the API
423 root, which is specified in the discovery document.
424 parameters: A dictionary describing method parameters for method described
425 in method_desc.
426
427 Returns:
428 Triple (accept, max_size, media_path_url) where:
429 - accept is a list of strings representing what content types are
430 accepted for media upload. Defaults to empty list if not in the
431 discovery document.
432 - max_size is a long representing the max size in bytes allowed for a
433 media upload. Defaults to 0L if not in the discovery document.
434 - media_path_url is a String; the absolute URI for media upload for the
435 API method. Constructed using the API root URI and service path from
436 the discovery document and the relative path for the API method. If
437 media upload is not supported, this is None.
438 """
439 media_upload = method_desc.get('mediaUpload', {})
440 accept = media_upload.get('accept', [])
441 max_size = _media_size_to_long(media_upload.get('maxSize', ''))
442 media_path_url = None
443
444 if media_upload:
445 media_path_url = _media_path_url_from_info(root_desc, path_url)
446 parameters['media_body'] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
447 if 'body' in parameters:
448 parameters['body']['required'] = False
449
450 return accept, max_size, media_path_url
451
452
453def _fix_up_method_description(method_desc, root_desc):
454 """Updates a method description in a discovery document.
455
456 SIDE EFFECTS: Changes the parameters dictionary in the method description with
457 extra parameters which are used locally.
458
459 Args:
460 method_desc: Dictionary with metadata describing an API method. Value comes
461 from the dictionary of methods stored in the 'methods' key in the
462 deserialized discovery document.
463 root_desc: Dictionary; the entire original deserialized discovery document.
464
465 Returns:
466 Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
467 where:
468 - path_url is a String; the relative URL for the API method. Relative to
469 the API root, which is specified in the discovery document.
470 - http_method is a String; the HTTP method used to call the API method
471 described in the method description.
472 - method_id is a String; the name of the RPC method associated with the
473 API method, and is in the method description in the 'id' key.
474 - accept is a list of strings representing what content types are
475 accepted for media upload. Defaults to empty list if not in the
476 discovery document.
477 - max_size is a long representing the max size in bytes allowed for a
478 media upload. Defaults to 0L if not in the discovery document.
479 - media_path_url is a String; the absolute URI for media upload for the
480 API method. Constructed using the API root URI and service path from
481 the discovery document and the relative path for the API method. If
482 media upload is not supported, this is None.
483 """
484 path_url = method_desc['path']
485 http_method = method_desc['httpMethod']
486 method_id = method_desc['id']
487
488 parameters = _fix_up_parameters(method_desc, root_desc, http_method)
489 # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
490 # 'parameters' key and needs to know if there is a 'body' parameter because it
491 # also sets a 'media_body' parameter.
492 accept, max_size, media_path_url = _fix_up_media_upload(
493 method_desc, root_desc, path_url, parameters)
494
495 return path_url, http_method, method_id, accept, max_size, media_path_url
496
497
Craig Citro7ee535d2015-02-23 10:11:14 -0800498def _urljoin(base, url):
499 """Custom urljoin replacement supporting : before / in url."""
500 # In general, it's unsafe to simply join base and url. However, for
501 # the case of discovery documents, we know:
502 # * base will never contain params, query, or fragment
503 # * url will never contain a scheme or net_loc.
504 # In general, this means we can safely join on /; we just need to
505 # ensure we end up with precisely one / joining base and url. The
506 # exception here is the case of media uploads, where url will be an
507 # absolute url.
508 if url.startswith('http://') or url.startswith('https://'):
Pat Ferated5b61bd2015-03-03 16:04:11 -0800509 return urljoin(base, url)
Craig Citro7ee535d2015-02-23 10:11:14 -0800510 new_base = base if base.endswith('/') else base + '/'
511 new_url = url[1:] if url.startswith('/') else url
512 return new_base + new_url
513
514
John Asmuth864311d2014-04-24 15:46:08 -0400515# TODO(dhermes): Convert this class to ResourceMethod and make it callable
516class ResourceMethodParameters(object):
517 """Represents the parameters associated with a method.
518
519 Attributes:
520 argmap: Map from method parameter name (string) to query parameter name
521 (string).
522 required_params: List of required parameters (represented by parameter
523 name as string).
524 repeated_params: List of repeated parameters (represented by parameter
525 name as string).
526 pattern_params: Map from method parameter name (string) to regular
527 expression (as a string). If the pattern is set for a parameter, the
528 value for that parameter must match the regular expression.
529 query_params: List of parameters (represented by parameter name as string)
530 that will be used in the query string.
531 path_params: Set of parameters (represented by parameter name as string)
532 that will be used in the base URL path.
533 param_types: Map from method parameter name (string) to parameter type. Type
534 can be any valid JSON schema type; valid values are 'any', 'array',
535 'boolean', 'integer', 'number', 'object', or 'string'. Reference:
536 http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
537 enum_params: Map from method parameter name (string) to list of strings,
538 where each list of strings is the list of acceptable enum values.
539 """
540
541 def __init__(self, method_desc):
542 """Constructor for ResourceMethodParameters.
543
544 Sets default values and defers to set_parameters to populate.
545
546 Args:
547 method_desc: Dictionary with metadata describing an API method. Value
548 comes from the dictionary of methods stored in the 'methods' key in
549 the deserialized discovery document.
550 """
551 self.argmap = {}
552 self.required_params = []
553 self.repeated_params = []
554 self.pattern_params = {}
555 self.query_params = []
556 # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
557 # parsing is gotten rid of.
558 self.path_params = set()
559 self.param_types = {}
560 self.enum_params = {}
561
562 self.set_parameters(method_desc)
563
564 def set_parameters(self, method_desc):
565 """Populates maps and lists based on method description.
566
567 Iterates through each parameter for the method and parses the values from
568 the parameter dictionary.
569
570 Args:
571 method_desc: Dictionary with metadata describing an API method. Value
572 comes from the dictionary of methods stored in the 'methods' key in
573 the deserialized discovery document.
574 """
INADA Naokie4ea1a92015-03-04 03:45:42 +0900575 for arg, desc in six.iteritems(method_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400576 param = key2param(arg)
577 self.argmap[param] = arg
578
579 if desc.get('pattern'):
580 self.pattern_params[param] = desc['pattern']
581 if desc.get('enum'):
582 self.enum_params[param] = desc['enum']
583 if desc.get('required'):
584 self.required_params.append(param)
585 if desc.get('repeated'):
586 self.repeated_params.append(param)
587 if desc.get('location') == 'query':
588 self.query_params.append(param)
589 if desc.get('location') == 'path':
590 self.path_params.add(param)
591 self.param_types[param] = desc.get('type', 'string')
592
593 # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
594 # should have all path parameters already marked with
595 # 'location: path'.
596 for match in URITEMPLATE.finditer(method_desc['path']):
597 for namematch in VARNAME.finditer(match.group(0)):
598 name = key2param(namematch.group(0))
599 self.path_params.add(name)
600 if name in self.query_params:
601 self.query_params.remove(name)
602
603
604def createMethod(methodName, methodDesc, rootDesc, schema):
605 """Creates a method for attaching to a Resource.
606
607 Args:
608 methodName: string, name of the method to use.
609 methodDesc: object, fragment of deserialized discovery document that
610 describes the method.
611 rootDesc: object, the entire deserialized discovery document.
612 schema: object, mapping of schema names to schema descriptions.
613 """
614 methodName = fix_method_name(methodName)
615 (pathUrl, httpMethod, methodId, accept,
616 maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc)
617
618 parameters = ResourceMethodParameters(methodDesc)
619
620 def method(self, **kwargs):
621 # Don't bother with doc string, it will be over-written by createMethod.
622
INADA Naokie4ea1a92015-03-04 03:45:42 +0900623 for name in six.iterkeys(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400624 if name not in parameters.argmap:
625 raise TypeError('Got an unexpected keyword argument "%s"' % name)
626
627 # Remove args that have a value of None.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900628 keys = list(kwargs.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400629 for name in keys:
630 if kwargs[name] is None:
631 del kwargs[name]
632
633 for name in parameters.required_params:
634 if name not in kwargs:
635 raise TypeError('Missing required parameter "%s"' % name)
636
INADA Naokie4ea1a92015-03-04 03:45:42 +0900637 for name, regex in six.iteritems(parameters.pattern_params):
John Asmuth864311d2014-04-24 15:46:08 -0400638 if name in kwargs:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900639 if isinstance(kwargs[name], six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400640 pvalues = [kwargs[name]]
641 else:
642 pvalues = kwargs[name]
643 for pvalue in pvalues:
644 if re.match(regex, pvalue) is None:
645 raise TypeError(
646 'Parameter "%s" value "%s" does not match the pattern "%s"' %
647 (name, pvalue, regex))
648
INADA Naokie4ea1a92015-03-04 03:45:42 +0900649 for name, enums in six.iteritems(parameters.enum_params):
John Asmuth864311d2014-04-24 15:46:08 -0400650 if name in kwargs:
651 # We need to handle the case of a repeated enum
652 # name differently, since we want to handle both
653 # arg='value' and arg=['value1', 'value2']
654 if (name in parameters.repeated_params and
INADA Naokie4ea1a92015-03-04 03:45:42 +0900655 not isinstance(kwargs[name], six.string_types)):
John Asmuth864311d2014-04-24 15:46:08 -0400656 values = kwargs[name]
657 else:
658 values = [kwargs[name]]
659 for value in values:
660 if value not in enums:
661 raise TypeError(
662 'Parameter "%s" value "%s" is not an allowed value in "%s"' %
663 (name, value, str(enums)))
664
665 actual_query_params = {}
666 actual_path_params = {}
INADA Naokie4ea1a92015-03-04 03:45:42 +0900667 for key, value in six.iteritems(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400668 to_type = parameters.param_types.get(key, 'string')
669 # For repeated parameters we cast each member of the list.
670 if key in parameters.repeated_params and type(value) == type([]):
671 cast_value = [_cast(x, to_type) for x in value]
672 else:
673 cast_value = _cast(value, to_type)
674 if key in parameters.query_params:
675 actual_query_params[parameters.argmap[key]] = cast_value
676 if key in parameters.path_params:
677 actual_path_params[parameters.argmap[key]] = cast_value
678 body_value = kwargs.get('body', None)
679 media_filename = kwargs.get('media_body', None)
680
681 if self._developerKey:
682 actual_query_params['key'] = self._developerKey
683
684 model = self._model
685 if methodName.endswith('_media'):
686 model = MediaModel()
687 elif 'response' not in methodDesc:
688 model = RawModel()
689
690 headers = {}
691 headers, params, query, body = model.request(headers,
692 actual_path_params, actual_query_params, body_value)
693
694 expanded_url = uritemplate.expand(pathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800695 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400696
697 resumable = None
698 multipart_boundary = ''
699
700 if media_filename:
701 # Ensure we end up with a valid MediaUpload object.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900702 if isinstance(media_filename, six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400703 (media_mime_type, encoding) = mimetypes.guess_type(media_filename)
704 if media_mime_type is None:
705 raise UnknownFileType(media_filename)
706 if not mimeparse.best_match([media_mime_type], ','.join(accept)):
707 raise UnacceptableMimeTypeError(media_mime_type)
708 media_upload = MediaFileUpload(media_filename,
709 mimetype=media_mime_type)
710 elif isinstance(media_filename, MediaUpload):
711 media_upload = media_filename
712 else:
713 raise TypeError('media_filename must be str or MediaUpload.')
714
715 # Check the maxSize
Pat Feratec46e9052015-03-03 17:59:17 -0800716 if media_upload.size() is not None and media_upload.size() > maxSize > 0:
John Asmuth864311d2014-04-24 15:46:08 -0400717 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
718
719 # Use the media path uri for media uploads
720 expanded_url = uritemplate.expand(mediaPathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800721 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400722 if media_upload.resumable():
723 url = _add_query_parameter(url, 'uploadType', 'resumable')
724
725 if media_upload.resumable():
726 # This is all we need to do for resumable, if the body exists it gets
727 # sent in the first request, otherwise an empty body is sent.
728 resumable = media_upload
729 else:
730 # A non-resumable upload
731 if body is None:
732 # This is a simple media upload
733 headers['content-type'] = media_upload.mimetype()
734 body = media_upload.getbytes(0, media_upload.size())
735 url = _add_query_parameter(url, 'uploadType', 'media')
736 else:
737 # This is a multipart/related upload.
738 msgRoot = MIMEMultipart('related')
739 # msgRoot should not write out it's own headers
740 setattr(msgRoot, '_write_headers', lambda self: None)
741
742 # attach the body as one part
743 msg = MIMENonMultipart(*headers['content-type'].split('/'))
744 msg.set_payload(body)
745 msgRoot.attach(msg)
746
747 # attach the media as the second part
748 msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
749 msg['Content-Transfer-Encoding'] = 'binary'
750
751 payload = media_upload.getbytes(0, media_upload.size())
752 msg.set_payload(payload)
753 msgRoot.attach(msg)
Craig Citro72389b72014-07-15 17:12:50 -0700754 # encode the body: note that we can't use `as_string`, because
755 # it plays games with `From ` lines.
Pat Ferateed9affd2015-03-03 16:03:15 -0800756 fp = StringIO()
Craig Citro72389b72014-07-15 17:12:50 -0700757 g = Generator(fp, mangle_from_=False)
758 g.flatten(msgRoot, unixfrom=False)
759 body = fp.getvalue()
John Asmuth864311d2014-04-24 15:46:08 -0400760
761 multipart_boundary = msgRoot.get_boundary()
762 headers['content-type'] = ('multipart/related; '
763 'boundary="%s"') % multipart_boundary
764 url = _add_query_parameter(url, 'uploadType', 'multipart')
765
Eric Gjertsen87553e42014-05-13 15:49:50 -0400766 logger.info('URL being requested: %s %s' % (httpMethod,url))
John Asmuth864311d2014-04-24 15:46:08 -0400767 return self._requestBuilder(self._http,
768 model.response,
769 url,
770 method=httpMethod,
771 body=body,
772 headers=headers,
773 methodId=methodId,
774 resumable=resumable)
775
776 docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
777 if len(parameters.argmap) > 0:
778 docs.append('Args:\n')
779
780 # Skip undocumented params and params common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900781 skip_parameters = list(rootDesc.get('parameters', {}).keys())
John Asmuth864311d2014-04-24 15:46:08 -0400782 skip_parameters.extend(STACK_QUERY_PARAMETERS)
783
INADA Naokie4ea1a92015-03-04 03:45:42 +0900784 all_args = list(parameters.argmap.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400785 args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
786
787 # Move body to the front of the line.
788 if 'body' in all_args:
789 args_ordered.append('body')
790
791 for name in all_args:
792 if name not in args_ordered:
793 args_ordered.append(name)
794
795 for arg in args_ordered:
796 if arg in skip_parameters:
797 continue
798
799 repeated = ''
800 if arg in parameters.repeated_params:
801 repeated = ' (repeated)'
802 required = ''
803 if arg in parameters.required_params:
804 required = ' (required)'
805 paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
806 paramdoc = paramdesc.get('description', 'A parameter')
807 if '$ref' in paramdesc:
808 docs.append(
809 (' %s: object, %s%s%s\n The object takes the'
810 ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
811 schema.prettyPrintByName(paramdesc['$ref'])))
812 else:
813 paramtype = paramdesc.get('type', 'string')
814 docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
815 repeated))
816 enum = paramdesc.get('enum', [])
817 enumDesc = paramdesc.get('enumDescriptions', [])
818 if enum and enumDesc:
819 docs.append(' Allowed values\n')
820 for (name, desc) in zip(enum, enumDesc):
821 docs.append(' %s - %s\n' % (name, desc))
822 if 'response' in methodDesc:
823 if methodName.endswith('_media'):
824 docs.append('\nReturns:\n The media object as a string.\n\n ')
825 else:
826 docs.append('\nReturns:\n An object of the form:\n\n ')
827 docs.append(schema.prettyPrintSchema(methodDesc['response']))
828
829 setattr(method, '__doc__', ''.join(docs))
830 return (methodName, method)
831
832
833def createNextMethod(methodName):
834 """Creates any _next methods for attaching to a Resource.
835
836 The _next methods allow for easy iteration through list() responses.
837
838 Args:
839 methodName: string, name of the method to use.
840 """
841 methodName = fix_method_name(methodName)
842
843 def methodNext(self, previous_request, previous_response):
844 """Retrieves the next page of results.
845
846Args:
847 previous_request: The request for the previous page. (required)
848 previous_response: The response from the request for the previous page. (required)
849
850Returns:
851 A request object that you can call 'execute()' on to request the next
852 page. Returns None if there are no more items in the collection.
853 """
854 # Retrieve nextPageToken from previous_response
855 # Use as pageToken in previous_request to create new request.
856
857 if 'nextPageToken' not in previous_response:
858 return None
859
860 request = copy.copy(previous_request)
861
862 pageToken = previous_response['nextPageToken']
Pat Ferated5b61bd2015-03-03 16:04:11 -0800863 parsed = list(urlparse(request.uri))
John Asmuth864311d2014-04-24 15:46:08 -0400864 q = parse_qsl(parsed[4])
865
866 # Find and remove old 'pageToken' value from URI
867 newq = [(key, value) for (key, value) in q if key != 'pageToken']
868 newq.append(('pageToken', pageToken))
Pat Ferated5b61bd2015-03-03 16:04:11 -0800869 parsed[4] = urlencode(newq)
870 uri = urlunparse(parsed)
John Asmuth864311d2014-04-24 15:46:08 -0400871
872 request.uri = uri
873
Eric Gjertsen87553e42014-05-13 15:49:50 -0400874 logger.info('URL being requested: %s %s' % (methodName,uri))
John Asmuth864311d2014-04-24 15:46:08 -0400875
876 return request
877
878 return (methodName, methodNext)
879
880
881class Resource(object):
882 """A class for interacting with a resource."""
883
884 def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
885 resourceDesc, rootDesc, schema):
886 """Build a Resource from the API description.
887
888 Args:
889 http: httplib2.Http, Object to make http requests with.
890 baseUrl: string, base URL for the API. All requests are relative to this
891 URI.
892 model: googleapiclient.Model, converts to and from the wire format.
893 requestBuilder: class or callable that instantiates an
894 googleapiclient.HttpRequest object.
895 developerKey: string, key obtained from
896 https://code.google.com/apis/console
897 resourceDesc: object, section of deserialized discovery document that
898 describes a resource. Note that the top level discovery document
899 is considered a resource.
900 rootDesc: object, the entire deserialized discovery document.
901 schema: object, mapping of schema names to schema descriptions.
902 """
903 self._dynamic_attrs = []
904
905 self._http = http
906 self._baseUrl = baseUrl
907 self._model = model
908 self._developerKey = developerKey
909 self._requestBuilder = requestBuilder
910 self._resourceDesc = resourceDesc
911 self._rootDesc = rootDesc
912 self._schema = schema
913
914 self._set_service_methods()
915
916 def _set_dynamic_attr(self, attr_name, value):
917 """Sets an instance attribute and tracks it in a list of dynamic attributes.
918
919 Args:
920 attr_name: string; The name of the attribute to be set
921 value: The value being set on the object and tracked in the dynamic cache.
922 """
923 self._dynamic_attrs.append(attr_name)
924 self.__dict__[attr_name] = value
925
926 def __getstate__(self):
927 """Trim the state down to something that can be pickled.
928
929 Uses the fact that the instance variable _dynamic_attrs holds attrs that
930 will be wiped and restored on pickle serialization.
931 """
932 state_dict = copy.copy(self.__dict__)
933 for dynamic_attr in self._dynamic_attrs:
934 del state_dict[dynamic_attr]
935 del state_dict['_dynamic_attrs']
936 return state_dict
937
938 def __setstate__(self, state):
939 """Reconstitute the state of the object from being pickled.
940
941 Uses the fact that the instance variable _dynamic_attrs holds attrs that
942 will be wiped and restored on pickle serialization.
943 """
944 self.__dict__.update(state)
945 self._dynamic_attrs = []
946 self._set_service_methods()
947
948 def _set_service_methods(self):
949 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
950 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
951 self._add_next_methods(self._resourceDesc, self._schema)
952
953 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -0400954 # If this is the root Resource, add a new_batch_http_request() method.
955 if resourceDesc == rootDesc:
956 batch_uri = '%s%s' % (
957 rootDesc['rootUrl'], rootDesc.get('batchPath', 'batch'))
958 def new_batch_http_request(callback=None):
Pepper Lebeck-Jobe56052ec2015-06-13 14:07:14 -0400959 """Create a BatchHttpRequest object based on the discovery document.
960
961 Args:
962 callback: callable, A callback to be called for each response, of the
963 form callback(id, response, exception). The first parameter is the
964 request id, and the second is the deserialized response object. The
965 third is an apiclient.errors.HttpError exception object if an HTTP
966 error occurred while processing the request, or None if no error
967 occurred.
968
969 Returns:
970 A BatchHttpRequest object based on the discovery document.
971 """
Pepper Lebeck-Jobe860836f2015-06-12 20:42:23 -0400972 return BatchHttpRequest(callback=callback, batch_uri=batch_uri)
973 self._set_dynamic_attr('new_batch_http_request', new_batch_http_request)
974
John Asmuth864311d2014-04-24 15:46:08 -0400975 # Add basic methods to Resource
976 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900977 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -0400978 fixedMethodName, method = createMethod(
979 methodName, methodDesc, rootDesc, schema)
980 self._set_dynamic_attr(fixedMethodName,
981 method.__get__(self, self.__class__))
982 # Add in _media methods. The functionality of the attached method will
983 # change when it sees that the method name ends in _media.
984 if methodDesc.get('supportsMediaDownload', False):
985 fixedMethodName, method = createMethod(
986 methodName + '_media', methodDesc, rootDesc, schema)
987 self._set_dynamic_attr(fixedMethodName,
988 method.__get__(self, self.__class__))
989
990 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
991 # Add in nested resources
992 if 'resources' in resourceDesc:
993
994 def createResourceMethod(methodName, methodDesc):
995 """Create a method on the Resource to access a nested Resource.
996
997 Args:
998 methodName: string, name of the method to use.
999 methodDesc: object, fragment of deserialized discovery document that
1000 describes the method.
1001 """
1002 methodName = fix_method_name(methodName)
1003
1004 def methodResource(self):
1005 return Resource(http=self._http, baseUrl=self._baseUrl,
1006 model=self._model, developerKey=self._developerKey,
1007 requestBuilder=self._requestBuilder,
1008 resourceDesc=methodDesc, rootDesc=rootDesc,
1009 schema=schema)
1010
1011 setattr(methodResource, '__doc__', 'A collection resource.')
1012 setattr(methodResource, '__is_resource__', True)
1013
1014 return (methodName, methodResource)
1015
INADA Naokie4ea1a92015-03-04 03:45:42 +09001016 for methodName, methodDesc in six.iteritems(resourceDesc['resources']):
John Asmuth864311d2014-04-24 15:46:08 -04001017 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
1018 self._set_dynamic_attr(fixedMethodName,
1019 method.__get__(self, self.__class__))
1020
1021 def _add_next_methods(self, resourceDesc, schema):
1022 # Add _next() methods
1023 # Look for response bodies in schema that contain nextPageToken, and methods
1024 # that take a pageToken parameter.
1025 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001026 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001027 if 'response' in methodDesc:
1028 responseSchema = methodDesc['response']
1029 if '$ref' in responseSchema:
1030 responseSchema = schema.get(responseSchema['$ref'])
1031 hasNextPageToken = 'nextPageToken' in responseSchema.get('properties',
1032 {})
1033 hasPageToken = 'pageToken' in methodDesc.get('parameters', {})
1034 if hasNextPageToken and hasPageToken:
1035 fixedMethodName, method = createNextMethod(methodName + '_next')
1036 self._set_dynamic_attr(fixedMethodName,
1037 method.__get__(self, self.__class__))