blob: cdbf1400582b549982b3664e00e061d6ff926e84 [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
31
32# Standard library imports
Craig Citro72389b72014-07-15 17:12:50 -070033import StringIO
John Asmuth864311d2014-04-24 15:46:08 -040034import copy
Craig Citro72389b72014-07-15 17:12:50 -070035from email.generator import Generator
John Asmuth864311d2014-04-24 15:46:08 -040036from email.mime.multipart import MIMEMultipart
37from email.mime.nonmultipart import MIMENonMultipart
Craig Citro6ae34d72014-08-18 23:10:09 -070038import json
John Asmuth864311d2014-04-24 15:46:08 -040039import keyword
40import logging
41import mimetypes
42import os
43import re
44import urllib
45import urlparse
46
47try:
48 from urlparse import parse_qsl
49except ImportError:
50 from cgi import parse_qsl
51
52# Third-party imports
53import httplib2
INADA Naoki0bceb332014-08-20 15:27:52 +090054from . import mimeparse
John Asmuth864311d2014-04-24 15:46:08 -040055import uritemplate
56
57# Local imports
58from googleapiclient.errors import HttpError
59from googleapiclient.errors import InvalidJsonError
60from googleapiclient.errors import MediaUploadSizeError
61from googleapiclient.errors import UnacceptableMimeTypeError
62from googleapiclient.errors import UnknownApiNameOrVersion
63from googleapiclient.errors import UnknownFileType
64from 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
108
109def fix_method_name(name):
110 """Fix method names to avoid reserved word conflicts.
111
112 Args:
113 name: string, method name.
114
115 Returns:
116 The name with a '_' prefixed if the name is a reserved word.
117 """
118 if keyword.iskeyword(name) or name in RESERVED_WORDS:
119 return name + '_'
120 else:
121 return name
122
123
124def key2param(key):
125 """Converts key names into parameter names.
126
127 For example, converting "max-results" -> "max_results"
128
129 Args:
130 key: string, the method key name.
131
132 Returns:
133 A safe method name based on the key name.
134 """
135 result = []
136 key = list(key)
137 if not key[0].isalpha():
138 result.append('x')
139 for c in key:
140 if c.isalnum():
141 result.append(c)
142 else:
143 result.append('_')
144
145 return ''.join(result)
146
147
148@positional(2)
149def build(serviceName,
150 version,
151 http=None,
152 discoveryServiceUrl=DISCOVERY_URI,
153 developerKey=None,
154 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700155 requestBuilder=HttpRequest,
156 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400157 """Construct a Resource for interacting with an API.
158
159 Construct a Resource object for interacting with an API. The serviceName and
160 version are the names from the Discovery service.
161
162 Args:
163 serviceName: string, name of the service.
164 version: string, the version of the service.
165 http: httplib2.Http, An instance of httplib2.Http or something that acts
166 like it that HTTP requests will be made through.
167 discoveryServiceUrl: string, a URI Template that points to the location of
168 the discovery service. It should have two parameters {api} and
169 {apiVersion} that when filled in produce an absolute URI to the discovery
170 document for that service.
171 developerKey: string, key obtained from
172 https://code.google.com/apis/console.
173 model: googleapiclient.Model, converts to and from the wire format.
174 requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
175 request.
Orest Bolohane92c9002014-05-30 11:15:43 -0700176 credentials: oauth2client.Credentials, credentials to be used for
177 authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400178
179 Returns:
180 A Resource object with methods for interacting with the service.
181 """
182 params = {
183 'api': serviceName,
184 'apiVersion': version
185 }
186
187 if http is None:
188 http = httplib2.Http()
189
190 requested_url = uritemplate.expand(discoveryServiceUrl, params)
191
192 # REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
193 # variable that contains the network address of the client sending the
194 # request. If it exists then add that to the request for the discovery
195 # document to avoid exceeding the quota on discovery requests.
196 if 'REMOTE_ADDR' in os.environ:
197 requested_url = _add_query_parameter(requested_url, 'userIp',
198 os.environ['REMOTE_ADDR'])
Eric Gjertsen87553e42014-05-13 15:49:50 -0400199 logger.info('URL being requested: GET %s' % requested_url)
John Asmuth864311d2014-04-24 15:46:08 -0400200
201 resp, content = http.request(requested_url)
202
203 if resp.status == 404:
204 raise UnknownApiNameOrVersion("name: %s version: %s" % (serviceName,
205 version))
206 if resp.status >= 400:
207 raise HttpError(resp, content, uri=requested_url)
208
209 try:
Craig Citro6ae34d72014-08-18 23:10:09 -0700210 service = json.loads(content)
INADA Naokic1505df2014-08-20 15:19:53 +0900211 except ValueError as e:
John Asmuth864311d2014-04-24 15:46:08 -0400212 logger.error('Failed to parse as JSON: ' + content)
213 raise InvalidJsonError()
214
215 return build_from_document(content, base=discoveryServiceUrl, http=http,
Orest Bolohane92c9002014-05-30 11:15:43 -0700216 developerKey=developerKey, model=model, requestBuilder=requestBuilder,
217 credentials=credentials)
John Asmuth864311d2014-04-24 15:46:08 -0400218
219
220@positional(1)
221def build_from_document(
222 service,
223 base=None,
224 future=None,
225 http=None,
226 developerKey=None,
227 model=None,
Orest Bolohane92c9002014-05-30 11:15:43 -0700228 requestBuilder=HttpRequest,
229 credentials=None):
John Asmuth864311d2014-04-24 15:46:08 -0400230 """Create a Resource for interacting with an API.
231
232 Same as `build()`, but constructs the Resource object from a discovery
233 document that is it given, as opposed to retrieving one over HTTP.
234
235 Args:
236 service: string or object, the JSON discovery document describing the API.
237 The value passed in may either be the JSON string or the deserialized
238 JSON.
239 base: string, base URI for all HTTP requests, usually the discovery URI.
240 This parameter is no longer used as rootUrl and servicePath are included
241 within the discovery document. (deprecated)
242 future: string, discovery document with future capabilities (deprecated).
243 http: httplib2.Http, An instance of httplib2.Http or something that acts
244 like it that HTTP requests will be made through.
245 developerKey: string, Key for controlling API usage, generated
246 from the API Console.
247 model: Model class instance that serializes and de-serializes requests and
248 responses.
249 requestBuilder: Takes an http request and packages it up to be executed.
Orest Bolohane92c9002014-05-30 11:15:43 -0700250 credentials: object, credentials to be used for authentication.
John Asmuth864311d2014-04-24 15:46:08 -0400251
252 Returns:
253 A Resource object with methods for interacting with the service.
254 """
255
256 # future is no longer used.
257 future = {}
258
INADA Naokie4ea1a92015-03-04 03:45:42 +0900259 if isinstance(service, six.string_types):
Craig Citro6ae34d72014-08-18 23:10:09 -0700260 service = json.loads(service)
John Asmuth864311d2014-04-24 15:46:08 -0400261 base = urlparse.urljoin(service['rootUrl'], service['servicePath'])
262 schema = Schemas(service)
263
Orest Bolohane92c9002014-05-30 11:15:43 -0700264 if credentials:
265 # If credentials were passed in, we could have two cases:
266 # 1. the scopes were specified, in which case the given credentials
267 # are used for authorizing the http;
oresticaaff4e1f2014-07-08 11:28:45 -0700268 # 2. the scopes were not provided (meaning the Application Default
269 # Credentials are to be used). In this case, the Application Default
270 # Credentials are built and used instead of the original credentials.
271 # If there are no scopes found (meaning the given service requires no
272 # authentication), there is no authorization of the http.
Craig Citroae83efb2014-06-06 09:45:57 -0700273 if (isinstance(credentials, GoogleCredentials) and
274 credentials.create_scoped_required()):
Orest Bolohane92c9002014-05-30 11:15:43 -0700275 scopes = service.get('auth', {}).get('oauth2', {}).get('scopes', {})
276 if scopes:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900277 credentials = credentials.create_scoped(list(scopes.keys()))
Orest Bolohane92c9002014-05-30 11:15:43 -0700278 else:
279 # No need to authorize the http object
280 # if the service does not require authentication.
281 credentials = None
282
283 if credentials:
284 http = credentials.authorize(http)
285
John Asmuth864311d2014-04-24 15:46:08 -0400286 if model is None:
287 features = service.get('features', [])
288 model = JsonModel('dataWrapper' in features)
289 return Resource(http=http, baseUrl=base, model=model,
290 developerKey=developerKey, requestBuilder=requestBuilder,
291 resourceDesc=service, rootDesc=service, schema=schema)
292
293
294def _cast(value, schema_type):
295 """Convert value to a string based on JSON Schema type.
296
297 See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
298 JSON Schema.
299
300 Args:
301 value: any, the value to convert
302 schema_type: string, the type that value should be interpreted as
303
304 Returns:
305 A string representation of 'value' based on the schema_type.
306 """
307 if schema_type == 'string':
308 if type(value) == type('') or type(value) == type(u''):
309 return value
310 else:
311 return str(value)
312 elif schema_type == 'integer':
313 return str(int(value))
314 elif schema_type == 'number':
315 return str(float(value))
316 elif schema_type == 'boolean':
317 return str(bool(value)).lower()
318 else:
319 if type(value) == type('') or type(value) == type(u''):
320 return value
321 else:
322 return str(value)
323
324
325def _media_size_to_long(maxSize):
326 """Convert a string media size, such as 10GB or 3TB into an integer.
327
328 Args:
329 maxSize: string, size as a string, such as 2MB or 7GB.
330
331 Returns:
332 The size as an integer value.
333 """
334 if len(maxSize) < 2:
INADA Naoki0bceb332014-08-20 15:27:52 +0900335 return 0
John Asmuth864311d2014-04-24 15:46:08 -0400336 units = maxSize[-2:].upper()
337 bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
338 if bit_shift is not None:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900339 return int(maxSize[:-2]) << bit_shift
John Asmuth864311d2014-04-24 15:46:08 -0400340 else:
INADA Naoki1a91f7f2014-08-20 15:18:16 +0900341 return int(maxSize)
John Asmuth864311d2014-04-24 15:46:08 -0400342
343
344def _media_path_url_from_info(root_desc, path_url):
345 """Creates an absolute media path URL.
346
347 Constructed using the API root URI and service path from the discovery
348 document and the relative path for the API method.
349
350 Args:
351 root_desc: Dictionary; the entire original deserialized discovery document.
352 path_url: String; the relative URL for the API method. Relative to the API
353 root, which is specified in the discovery document.
354
355 Returns:
356 String; the absolute URI for media upload for the API method.
357 """
358 return '%(root)supload/%(service_path)s%(path)s' % {
359 'root': root_desc['rootUrl'],
360 'service_path': root_desc['servicePath'],
361 'path': path_url,
362 }
363
364
365def _fix_up_parameters(method_desc, root_desc, http_method):
366 """Updates parameters of an API method with values specific to this library.
367
368 Specifically, adds whatever global parameters are specified by the API to the
369 parameters for the individual method. Also adds parameters which don't
370 appear in the discovery document, but are available to all discovery based
371 APIs (these are listed in STACK_QUERY_PARAMETERS).
372
373 SIDE EFFECTS: This updates the parameters dictionary object in the method
374 description.
375
376 Args:
377 method_desc: Dictionary with metadata describing an API method. Value comes
378 from the dictionary of methods stored in the 'methods' key in the
379 deserialized discovery document.
380 root_desc: Dictionary; the entire original deserialized discovery document.
381 http_method: String; the HTTP method used to call the API method described
382 in method_desc.
383
384 Returns:
385 The updated Dictionary stored in the 'parameters' key of the method
386 description dictionary.
387 """
388 parameters = method_desc.setdefault('parameters', {})
389
390 # Add in the parameters common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900391 for name, description in six.iteritems(root_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400392 parameters[name] = description
393
394 # Add in undocumented query parameters.
395 for name in STACK_QUERY_PARAMETERS:
396 parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
397
398 # Add 'body' (our own reserved word) to parameters if the method supports
399 # a request payload.
400 if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
401 body = BODY_PARAMETER_DEFAULT_VALUE.copy()
402 body.update(method_desc['request'])
403 parameters['body'] = body
404
405 return parameters
406
407
408def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
409 """Updates parameters of API by adding 'media_body' if supported by method.
410
411 SIDE EFFECTS: If the method supports media upload and has a required body,
412 sets body to be optional (required=False) instead. Also, if there is a
413 'mediaUpload' in the method description, adds 'media_upload' key to
414 parameters.
415
416 Args:
417 method_desc: Dictionary with metadata describing an API method. Value comes
418 from the dictionary of methods stored in the 'methods' key in the
419 deserialized discovery document.
420 root_desc: Dictionary; the entire original deserialized discovery document.
421 path_url: String; the relative URL for the API method. Relative to the API
422 root, which is specified in the discovery document.
423 parameters: A dictionary describing method parameters for method described
424 in method_desc.
425
426 Returns:
427 Triple (accept, max_size, media_path_url) where:
428 - accept is a list of strings representing what content types are
429 accepted for media upload. Defaults to empty list if not in the
430 discovery document.
431 - max_size is a long representing the max size in bytes allowed for a
432 media upload. Defaults to 0L if not in the discovery document.
433 - media_path_url is a String; the absolute URI for media upload for the
434 API method. Constructed using the API root URI and service path from
435 the discovery document and the relative path for the API method. If
436 media upload is not supported, this is None.
437 """
438 media_upload = method_desc.get('mediaUpload', {})
439 accept = media_upload.get('accept', [])
440 max_size = _media_size_to_long(media_upload.get('maxSize', ''))
441 media_path_url = None
442
443 if media_upload:
444 media_path_url = _media_path_url_from_info(root_desc, path_url)
445 parameters['media_body'] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
446 if 'body' in parameters:
447 parameters['body']['required'] = False
448
449 return accept, max_size, media_path_url
450
451
452def _fix_up_method_description(method_desc, root_desc):
453 """Updates a method description in a discovery document.
454
455 SIDE EFFECTS: Changes the parameters dictionary in the method description with
456 extra parameters which are used locally.
457
458 Args:
459 method_desc: Dictionary with metadata describing an API method. Value comes
460 from the dictionary of methods stored in the 'methods' key in the
461 deserialized discovery document.
462 root_desc: Dictionary; the entire original deserialized discovery document.
463
464 Returns:
465 Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
466 where:
467 - path_url is a String; the relative URL for the API method. Relative to
468 the API root, which is specified in the discovery document.
469 - http_method is a String; the HTTP method used to call the API method
470 described in the method description.
471 - method_id is a String; the name of the RPC method associated with the
472 API method, and is in the method description in the 'id' key.
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 path_url = method_desc['path']
484 http_method = method_desc['httpMethod']
485 method_id = method_desc['id']
486
487 parameters = _fix_up_parameters(method_desc, root_desc, http_method)
488 # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
489 # 'parameters' key and needs to know if there is a 'body' parameter because it
490 # also sets a 'media_body' parameter.
491 accept, max_size, media_path_url = _fix_up_media_upload(
492 method_desc, root_desc, path_url, parameters)
493
494 return path_url, http_method, method_id, accept, max_size, media_path_url
495
496
Craig Citro7ee535d2015-02-23 10:11:14 -0800497def _urljoin(base, url):
498 """Custom urljoin replacement supporting : before / in url."""
499 # In general, it's unsafe to simply join base and url. However, for
500 # the case of discovery documents, we know:
501 # * base will never contain params, query, or fragment
502 # * url will never contain a scheme or net_loc.
503 # In general, this means we can safely join on /; we just need to
504 # ensure we end up with precisely one / joining base and url. The
505 # exception here is the case of media uploads, where url will be an
506 # absolute url.
507 if url.startswith('http://') or url.startswith('https://'):
508 return urlparse.urljoin(base, url)
509 new_base = base if base.endswith('/') else base + '/'
510 new_url = url[1:] if url.startswith('/') else url
511 return new_base + new_url
512
513
John Asmuth864311d2014-04-24 15:46:08 -0400514# TODO(dhermes): Convert this class to ResourceMethod and make it callable
515class ResourceMethodParameters(object):
516 """Represents the parameters associated with a method.
517
518 Attributes:
519 argmap: Map from method parameter name (string) to query parameter name
520 (string).
521 required_params: List of required parameters (represented by parameter
522 name as string).
523 repeated_params: List of repeated parameters (represented by parameter
524 name as string).
525 pattern_params: Map from method parameter name (string) to regular
526 expression (as a string). If the pattern is set for a parameter, the
527 value for that parameter must match the regular expression.
528 query_params: List of parameters (represented by parameter name as string)
529 that will be used in the query string.
530 path_params: Set of parameters (represented by parameter name as string)
531 that will be used in the base URL path.
532 param_types: Map from method parameter name (string) to parameter type. Type
533 can be any valid JSON schema type; valid values are 'any', 'array',
534 'boolean', 'integer', 'number', 'object', or 'string'. Reference:
535 http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
536 enum_params: Map from method parameter name (string) to list of strings,
537 where each list of strings is the list of acceptable enum values.
538 """
539
540 def __init__(self, method_desc):
541 """Constructor for ResourceMethodParameters.
542
543 Sets default values and defers to set_parameters to populate.
544
545 Args:
546 method_desc: Dictionary with metadata describing an API method. Value
547 comes from the dictionary of methods stored in the 'methods' key in
548 the deserialized discovery document.
549 """
550 self.argmap = {}
551 self.required_params = []
552 self.repeated_params = []
553 self.pattern_params = {}
554 self.query_params = []
555 # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
556 # parsing is gotten rid of.
557 self.path_params = set()
558 self.param_types = {}
559 self.enum_params = {}
560
561 self.set_parameters(method_desc)
562
563 def set_parameters(self, method_desc):
564 """Populates maps and lists based on method description.
565
566 Iterates through each parameter for the method and parses the values from
567 the parameter dictionary.
568
569 Args:
570 method_desc: Dictionary with metadata describing an API method. Value
571 comes from the dictionary of methods stored in the 'methods' key in
572 the deserialized discovery document.
573 """
INADA Naokie4ea1a92015-03-04 03:45:42 +0900574 for arg, desc in six.iteritems(method_desc.get('parameters', {})):
John Asmuth864311d2014-04-24 15:46:08 -0400575 param = key2param(arg)
576 self.argmap[param] = arg
577
578 if desc.get('pattern'):
579 self.pattern_params[param] = desc['pattern']
580 if desc.get('enum'):
581 self.enum_params[param] = desc['enum']
582 if desc.get('required'):
583 self.required_params.append(param)
584 if desc.get('repeated'):
585 self.repeated_params.append(param)
586 if desc.get('location') == 'query':
587 self.query_params.append(param)
588 if desc.get('location') == 'path':
589 self.path_params.add(param)
590 self.param_types[param] = desc.get('type', 'string')
591
592 # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
593 # should have all path parameters already marked with
594 # 'location: path'.
595 for match in URITEMPLATE.finditer(method_desc['path']):
596 for namematch in VARNAME.finditer(match.group(0)):
597 name = key2param(namematch.group(0))
598 self.path_params.add(name)
599 if name in self.query_params:
600 self.query_params.remove(name)
601
602
603def createMethod(methodName, methodDesc, rootDesc, schema):
604 """Creates a method for attaching to a Resource.
605
606 Args:
607 methodName: string, name of the method to use.
608 methodDesc: object, fragment of deserialized discovery document that
609 describes the method.
610 rootDesc: object, the entire deserialized discovery document.
611 schema: object, mapping of schema names to schema descriptions.
612 """
613 methodName = fix_method_name(methodName)
614 (pathUrl, httpMethod, methodId, accept,
615 maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc)
616
617 parameters = ResourceMethodParameters(methodDesc)
618
619 def method(self, **kwargs):
620 # Don't bother with doc string, it will be over-written by createMethod.
621
INADA Naokie4ea1a92015-03-04 03:45:42 +0900622 for name in six.iterkeys(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400623 if name not in parameters.argmap:
624 raise TypeError('Got an unexpected keyword argument "%s"' % name)
625
626 # Remove args that have a value of None.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900627 keys = list(kwargs.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400628 for name in keys:
629 if kwargs[name] is None:
630 del kwargs[name]
631
632 for name in parameters.required_params:
633 if name not in kwargs:
634 raise TypeError('Missing required parameter "%s"' % name)
635
INADA Naokie4ea1a92015-03-04 03:45:42 +0900636 for name, regex in six.iteritems(parameters.pattern_params):
John Asmuth864311d2014-04-24 15:46:08 -0400637 if name in kwargs:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900638 if isinstance(kwargs[name], six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400639 pvalues = [kwargs[name]]
640 else:
641 pvalues = kwargs[name]
642 for pvalue in pvalues:
643 if re.match(regex, pvalue) is None:
644 raise TypeError(
645 'Parameter "%s" value "%s" does not match the pattern "%s"' %
646 (name, pvalue, regex))
647
INADA Naokie4ea1a92015-03-04 03:45:42 +0900648 for name, enums in six.iteritems(parameters.enum_params):
John Asmuth864311d2014-04-24 15:46:08 -0400649 if name in kwargs:
650 # We need to handle the case of a repeated enum
651 # name differently, since we want to handle both
652 # arg='value' and arg=['value1', 'value2']
653 if (name in parameters.repeated_params and
INADA Naokie4ea1a92015-03-04 03:45:42 +0900654 not isinstance(kwargs[name], six.string_types)):
John Asmuth864311d2014-04-24 15:46:08 -0400655 values = kwargs[name]
656 else:
657 values = [kwargs[name]]
658 for value in values:
659 if value not in enums:
660 raise TypeError(
661 'Parameter "%s" value "%s" is not an allowed value in "%s"' %
662 (name, value, str(enums)))
663
664 actual_query_params = {}
665 actual_path_params = {}
INADA Naokie4ea1a92015-03-04 03:45:42 +0900666 for key, value in six.iteritems(kwargs):
John Asmuth864311d2014-04-24 15:46:08 -0400667 to_type = parameters.param_types.get(key, 'string')
668 # For repeated parameters we cast each member of the list.
669 if key in parameters.repeated_params and type(value) == type([]):
670 cast_value = [_cast(x, to_type) for x in value]
671 else:
672 cast_value = _cast(value, to_type)
673 if key in parameters.query_params:
674 actual_query_params[parameters.argmap[key]] = cast_value
675 if key in parameters.path_params:
676 actual_path_params[parameters.argmap[key]] = cast_value
677 body_value = kwargs.get('body', None)
678 media_filename = kwargs.get('media_body', None)
679
680 if self._developerKey:
681 actual_query_params['key'] = self._developerKey
682
683 model = self._model
684 if methodName.endswith('_media'):
685 model = MediaModel()
686 elif 'response' not in methodDesc:
687 model = RawModel()
688
689 headers = {}
690 headers, params, query, body = model.request(headers,
691 actual_path_params, actual_query_params, body_value)
692
693 expanded_url = uritemplate.expand(pathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800694 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400695
696 resumable = None
697 multipart_boundary = ''
698
699 if media_filename:
700 # Ensure we end up with a valid MediaUpload object.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900701 if isinstance(media_filename, six.string_types):
John Asmuth864311d2014-04-24 15:46:08 -0400702 (media_mime_type, encoding) = mimetypes.guess_type(media_filename)
703 if media_mime_type is None:
704 raise UnknownFileType(media_filename)
705 if not mimeparse.best_match([media_mime_type], ','.join(accept)):
706 raise UnacceptableMimeTypeError(media_mime_type)
707 media_upload = MediaFileUpload(media_filename,
708 mimetype=media_mime_type)
709 elif isinstance(media_filename, MediaUpload):
710 media_upload = media_filename
711 else:
712 raise TypeError('media_filename must be str or MediaUpload.')
713
714 # Check the maxSize
715 if maxSize > 0 and media_upload.size() > maxSize:
716 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
717
718 # Use the media path uri for media uploads
719 expanded_url = uritemplate.expand(mediaPathUrl, params)
Craig Citro7ee535d2015-02-23 10:11:14 -0800720 url = _urljoin(self._baseUrl, expanded_url + query)
John Asmuth864311d2014-04-24 15:46:08 -0400721 if media_upload.resumable():
722 url = _add_query_parameter(url, 'uploadType', 'resumable')
723
724 if media_upload.resumable():
725 # This is all we need to do for resumable, if the body exists it gets
726 # sent in the first request, otherwise an empty body is sent.
727 resumable = media_upload
728 else:
729 # A non-resumable upload
730 if body is None:
731 # This is a simple media upload
732 headers['content-type'] = media_upload.mimetype()
733 body = media_upload.getbytes(0, media_upload.size())
734 url = _add_query_parameter(url, 'uploadType', 'media')
735 else:
736 # This is a multipart/related upload.
737 msgRoot = MIMEMultipart('related')
738 # msgRoot should not write out it's own headers
739 setattr(msgRoot, '_write_headers', lambda self: None)
740
741 # attach the body as one part
742 msg = MIMENonMultipart(*headers['content-type'].split('/'))
743 msg.set_payload(body)
744 msgRoot.attach(msg)
745
746 # attach the media as the second part
747 msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
748 msg['Content-Transfer-Encoding'] = 'binary'
749
750 payload = media_upload.getbytes(0, media_upload.size())
751 msg.set_payload(payload)
752 msgRoot.attach(msg)
Craig Citro72389b72014-07-15 17:12:50 -0700753 # encode the body: note that we can't use `as_string`, because
754 # it plays games with `From ` lines.
755 fp = StringIO.StringIO()
756 g = Generator(fp, mangle_from_=False)
757 g.flatten(msgRoot, unixfrom=False)
758 body = fp.getvalue()
John Asmuth864311d2014-04-24 15:46:08 -0400759
760 multipart_boundary = msgRoot.get_boundary()
761 headers['content-type'] = ('multipart/related; '
762 'boundary="%s"') % multipart_boundary
763 url = _add_query_parameter(url, 'uploadType', 'multipart')
764
Eric Gjertsen87553e42014-05-13 15:49:50 -0400765 logger.info('URL being requested: %s %s' % (httpMethod,url))
John Asmuth864311d2014-04-24 15:46:08 -0400766 return self._requestBuilder(self._http,
767 model.response,
768 url,
769 method=httpMethod,
770 body=body,
771 headers=headers,
772 methodId=methodId,
773 resumable=resumable)
774
775 docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
776 if len(parameters.argmap) > 0:
777 docs.append('Args:\n')
778
779 # Skip undocumented params and params common to all methods.
INADA Naokie4ea1a92015-03-04 03:45:42 +0900780 skip_parameters = list(rootDesc.get('parameters', {}).keys())
John Asmuth864311d2014-04-24 15:46:08 -0400781 skip_parameters.extend(STACK_QUERY_PARAMETERS)
782
INADA Naokie4ea1a92015-03-04 03:45:42 +0900783 all_args = list(parameters.argmap.keys())
John Asmuth864311d2014-04-24 15:46:08 -0400784 args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
785
786 # Move body to the front of the line.
787 if 'body' in all_args:
788 args_ordered.append('body')
789
790 for name in all_args:
791 if name not in args_ordered:
792 args_ordered.append(name)
793
794 for arg in args_ordered:
795 if arg in skip_parameters:
796 continue
797
798 repeated = ''
799 if arg in parameters.repeated_params:
800 repeated = ' (repeated)'
801 required = ''
802 if arg in parameters.required_params:
803 required = ' (required)'
804 paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
805 paramdoc = paramdesc.get('description', 'A parameter')
806 if '$ref' in paramdesc:
807 docs.append(
808 (' %s: object, %s%s%s\n The object takes the'
809 ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
810 schema.prettyPrintByName(paramdesc['$ref'])))
811 else:
812 paramtype = paramdesc.get('type', 'string')
813 docs.append(' %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
814 repeated))
815 enum = paramdesc.get('enum', [])
816 enumDesc = paramdesc.get('enumDescriptions', [])
817 if enum and enumDesc:
818 docs.append(' Allowed values\n')
819 for (name, desc) in zip(enum, enumDesc):
820 docs.append(' %s - %s\n' % (name, desc))
821 if 'response' in methodDesc:
822 if methodName.endswith('_media'):
823 docs.append('\nReturns:\n The media object as a string.\n\n ')
824 else:
825 docs.append('\nReturns:\n An object of the form:\n\n ')
826 docs.append(schema.prettyPrintSchema(methodDesc['response']))
827
828 setattr(method, '__doc__', ''.join(docs))
829 return (methodName, method)
830
831
832def createNextMethod(methodName):
833 """Creates any _next methods for attaching to a Resource.
834
835 The _next methods allow for easy iteration through list() responses.
836
837 Args:
838 methodName: string, name of the method to use.
839 """
840 methodName = fix_method_name(methodName)
841
842 def methodNext(self, previous_request, previous_response):
843 """Retrieves the next page of results.
844
845Args:
846 previous_request: The request for the previous page. (required)
847 previous_response: The response from the request for the previous page. (required)
848
849Returns:
850 A request object that you can call 'execute()' on to request the next
851 page. Returns None if there are no more items in the collection.
852 """
853 # Retrieve nextPageToken from previous_response
854 # Use as pageToken in previous_request to create new request.
855
856 if 'nextPageToken' not in previous_response:
857 return None
858
859 request = copy.copy(previous_request)
860
861 pageToken = previous_response['nextPageToken']
862 parsed = list(urlparse.urlparse(request.uri))
863 q = parse_qsl(parsed[4])
864
865 # Find and remove old 'pageToken' value from URI
866 newq = [(key, value) for (key, value) in q if key != 'pageToken']
867 newq.append(('pageToken', pageToken))
868 parsed[4] = urllib.urlencode(newq)
869 uri = urlparse.urlunparse(parsed)
870
871 request.uri = uri
872
Eric Gjertsen87553e42014-05-13 15:49:50 -0400873 logger.info('URL being requested: %s %s' % (methodName,uri))
John Asmuth864311d2014-04-24 15:46:08 -0400874
875 return request
876
877 return (methodName, methodNext)
878
879
880class Resource(object):
881 """A class for interacting with a resource."""
882
883 def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
884 resourceDesc, rootDesc, schema):
885 """Build a Resource from the API description.
886
887 Args:
888 http: httplib2.Http, Object to make http requests with.
889 baseUrl: string, base URL for the API. All requests are relative to this
890 URI.
891 model: googleapiclient.Model, converts to and from the wire format.
892 requestBuilder: class or callable that instantiates an
893 googleapiclient.HttpRequest object.
894 developerKey: string, key obtained from
895 https://code.google.com/apis/console
896 resourceDesc: object, section of deserialized discovery document that
897 describes a resource. Note that the top level discovery document
898 is considered a resource.
899 rootDesc: object, the entire deserialized discovery document.
900 schema: object, mapping of schema names to schema descriptions.
901 """
902 self._dynamic_attrs = []
903
904 self._http = http
905 self._baseUrl = baseUrl
906 self._model = model
907 self._developerKey = developerKey
908 self._requestBuilder = requestBuilder
909 self._resourceDesc = resourceDesc
910 self._rootDesc = rootDesc
911 self._schema = schema
912
913 self._set_service_methods()
914
915 def _set_dynamic_attr(self, attr_name, value):
916 """Sets an instance attribute and tracks it in a list of dynamic attributes.
917
918 Args:
919 attr_name: string; The name of the attribute to be set
920 value: The value being set on the object and tracked in the dynamic cache.
921 """
922 self._dynamic_attrs.append(attr_name)
923 self.__dict__[attr_name] = value
924
925 def __getstate__(self):
926 """Trim the state down to something that can be pickled.
927
928 Uses the fact that the instance variable _dynamic_attrs holds attrs that
929 will be wiped and restored on pickle serialization.
930 """
931 state_dict = copy.copy(self.__dict__)
932 for dynamic_attr in self._dynamic_attrs:
933 del state_dict[dynamic_attr]
934 del state_dict['_dynamic_attrs']
935 return state_dict
936
937 def __setstate__(self, state):
938 """Reconstitute the state of the object from being pickled.
939
940 Uses the fact that the instance variable _dynamic_attrs holds attrs that
941 will be wiped and restored on pickle serialization.
942 """
943 self.__dict__.update(state)
944 self._dynamic_attrs = []
945 self._set_service_methods()
946
947 def _set_service_methods(self):
948 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
949 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
950 self._add_next_methods(self._resourceDesc, self._schema)
951
952 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
953 # Add basic methods to Resource
954 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +0900955 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -0400956 fixedMethodName, method = createMethod(
957 methodName, methodDesc, rootDesc, schema)
958 self._set_dynamic_attr(fixedMethodName,
959 method.__get__(self, self.__class__))
960 # Add in _media methods. The functionality of the attached method will
961 # change when it sees that the method name ends in _media.
962 if methodDesc.get('supportsMediaDownload', False):
963 fixedMethodName, method = createMethod(
964 methodName + '_media', methodDesc, rootDesc, schema)
965 self._set_dynamic_attr(fixedMethodName,
966 method.__get__(self, self.__class__))
967
968 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
969 # Add in nested resources
970 if 'resources' in resourceDesc:
971
972 def createResourceMethod(methodName, methodDesc):
973 """Create a method on the Resource to access a nested Resource.
974
975 Args:
976 methodName: string, name of the method to use.
977 methodDesc: object, fragment of deserialized discovery document that
978 describes the method.
979 """
980 methodName = fix_method_name(methodName)
981
982 def methodResource(self):
983 return Resource(http=self._http, baseUrl=self._baseUrl,
984 model=self._model, developerKey=self._developerKey,
985 requestBuilder=self._requestBuilder,
986 resourceDesc=methodDesc, rootDesc=rootDesc,
987 schema=schema)
988
989 setattr(methodResource, '__doc__', 'A collection resource.')
990 setattr(methodResource, '__is_resource__', True)
991
992 return (methodName, methodResource)
993
INADA Naokie4ea1a92015-03-04 03:45:42 +0900994 for methodName, methodDesc in six.iteritems(resourceDesc['resources']):
John Asmuth864311d2014-04-24 15:46:08 -0400995 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
996 self._set_dynamic_attr(fixedMethodName,
997 method.__get__(self, self.__class__))
998
999 def _add_next_methods(self, resourceDesc, schema):
1000 # Add _next() methods
1001 # Look for response bodies in schema that contain nextPageToken, and methods
1002 # that take a pageToken parameter.
1003 if 'methods' in resourceDesc:
INADA Naokie4ea1a92015-03-04 03:45:42 +09001004 for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
John Asmuth864311d2014-04-24 15:46:08 -04001005 if 'response' in methodDesc:
1006 responseSchema = methodDesc['response']
1007 if '$ref' in responseSchema:
1008 responseSchema = schema.get(responseSchema['$ref'])
1009 hasNextPageToken = 'nextPageToken' in responseSchema.get('properties',
1010 {})
1011 hasPageToken = 'pageToken' in methodDesc.get('parameters', {})
1012 if hasNextPageToken and hasPageToken:
1013 fixedMethodName, method = createNextMethod(methodName + '_next')
1014 self._set_dynamic_attr(fixedMethodName,
1015 method.__get__(self, self.__class__))