• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2014 Google Inc. All Rights Reserved.
2#
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"""
19from __future__ import absolute_import
20import six
21from six.moves import zip
22
23__author__ = 'jcgregorio@google.com (Joe Gregorio)'
24__all__ = [
25    'build',
26    'build_from_document',
27    'fix_method_name',
28    'key2param',
29    ]
30
31from six import BytesIO
32from six.moves import http_client
33from six.moves.urllib.parse import urlencode, urlparse, urljoin, \
34  urlunparse, parse_qsl
35
36# Standard library imports
37import copy
38try:
39  from email.generator import BytesGenerator
40except ImportError:
41  from email.generator import Generator as BytesGenerator
42from email.mime.multipart import MIMEMultipart
43from email.mime.nonmultipart import MIMENonMultipart
44import json
45import keyword
46import logging
47import mimetypes
48import os
49import re
50
51# Third-party imports
52import httplib2
53import uritemplate
54
55# Local imports
56from googleapiclient import _auth
57from googleapiclient import mimeparse
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 build_http
65from googleapiclient.http import BatchHttpRequest
66from googleapiclient.http import HttpMock
67from googleapiclient.http import HttpMockSequence
68from googleapiclient.http import HttpRequest
69from googleapiclient.http import MediaFileUpload
70from googleapiclient.http import MediaUpload
71from googleapiclient.model import JsonModel
72from googleapiclient.model import MediaModel
73from googleapiclient.model import RawModel
74from googleapiclient.schema import Schemas
75
76from googleapiclient._helpers import _add_query_parameter
77from googleapiclient._helpers import positional
78
79
80# The client library requires a version of httplib2 that supports RETRIES.
81httplib2.RETRIES = 1
82
83logger = logging.getLogger(__name__)
84
85URITEMPLATE = re.compile('{[^}]*}')
86VARNAME = re.compile('[a-zA-Z0-9_-]+')
87DISCOVERY_URI = ('https://www.googleapis.com/discovery/v1/apis/'
88                 '{api}/{apiVersion}/rest')
89V1_DISCOVERY_URI = DISCOVERY_URI
90V2_DISCOVERY_URI = ('https://{api}.googleapis.com/$discovery/rest?'
91                    'version={apiVersion}')
92DEFAULT_METHOD_DOC = 'A description of how to use this function'
93HTTP_PAYLOAD_METHODS = frozenset(['PUT', 'POST', 'PATCH'])
94
95_MEDIA_SIZE_BIT_SHIFTS = {'KB': 10, 'MB': 20, 'GB': 30, 'TB': 40}
96BODY_PARAMETER_DEFAULT_VALUE = {
97    'description': 'The request body.',
98    'type': 'object',
99}
100MEDIA_BODY_PARAMETER_DEFAULT_VALUE = {
101    'description': ('The filename of the media request body, or an instance '
102                    'of a MediaUpload object.'),
103    'type': 'string',
104    'required': False,
105}
106MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE = {
107    'description': ('The MIME type of the media request body, or an instance '
108                    'of a MediaUpload object.'),
109    'type': 'string',
110    'required': False,
111}
112_PAGE_TOKEN_NAMES = ('pageToken', 'nextPageToken')
113
114# Parameters accepted by the stack, but not visible via discovery.
115# TODO(dhermes): Remove 'userip' in 'v2'.
116STACK_QUERY_PARAMETERS = frozenset(['trace', 'pp', 'userip', 'strict'])
117STACK_QUERY_PARAMETER_DEFAULT_VALUE = {'type': 'string', 'location': 'query'}
118
119# Library-specific reserved words beyond Python keywords.
120RESERVED_WORDS = frozenset(['body'])
121
122# patch _write_lines to avoid munging '\r' into '\n'
123# ( https://bugs.python.org/issue18886 https://bugs.python.org/issue19003 )
124class _BytesGenerator(BytesGenerator):
125  _write_lines = BytesGenerator.write
126
127def fix_method_name(name):
128  """Fix method names to avoid '$' characters and reserved word conflicts.
129
130  Args:
131    name: string, method name.
132
133  Returns:
134    The name with '_' appended if the name is a reserved word and '$'
135    replaced with '_'.
136  """
137  name = name.replace('$', '_')
138  if keyword.iskeyword(name) or name in RESERVED_WORDS:
139    return name + '_'
140  else:
141    return name
142
143
144def key2param(key):
145  """Converts key names into parameter names.
146
147  For example, converting "max-results" -> "max_results"
148
149  Args:
150    key: string, the method key name.
151
152  Returns:
153    A safe method name based on the key name.
154  """
155  result = []
156  key = list(key)
157  if not key[0].isalpha():
158    result.append('x')
159  for c in key:
160    if c.isalnum():
161      result.append(c)
162    else:
163      result.append('_')
164
165  return ''.join(result)
166
167
168@positional(2)
169def build(serviceName,
170          version,
171          http=None,
172          discoveryServiceUrl=DISCOVERY_URI,
173          developerKey=None,
174          model=None,
175          requestBuilder=HttpRequest,
176          credentials=None,
177          cache_discovery=True,
178          cache=None):
179  """Construct a Resource for interacting with an API.
180
181  Construct a Resource object for interacting with an API. The serviceName and
182  version are the names from the Discovery service.
183
184  Args:
185    serviceName: string, name of the service.
186    version: string, the version of the service.
187    http: httplib2.Http, An instance of httplib2.Http or something that acts
188      like it that HTTP requests will be made through.
189    discoveryServiceUrl: string, a URI Template that points to the location of
190      the discovery service. It should have two parameters {api} and
191      {apiVersion} that when filled in produce an absolute URI to the discovery
192      document for that service.
193    developerKey: string, key obtained from
194      https://code.google.com/apis/console.
195    model: googleapiclient.Model, converts to and from the wire format.
196    requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
197      request.
198    credentials: oauth2client.Credentials or
199      google.auth.credentials.Credentials, credentials to be used for
200      authentication.
201    cache_discovery: Boolean, whether or not to cache the discovery doc.
202    cache: googleapiclient.discovery_cache.base.CacheBase, an optional
203      cache object for the discovery documents.
204
205  Returns:
206    A Resource object with methods for interacting with the service.
207  """
208  params = {
209      'api': serviceName,
210      'apiVersion': version
211      }
212
213  if http is None:
214    discovery_http = build_http()
215  else:
216    discovery_http = http
217
218  for discovery_url in (discoveryServiceUrl, V2_DISCOVERY_URI,):
219    requested_url = uritemplate.expand(discovery_url, params)
220
221    try:
222      content = _retrieve_discovery_doc(
223        requested_url, discovery_http, cache_discovery, cache, developerKey)
224      return build_from_document(content, base=discovery_url, http=http,
225          developerKey=developerKey, model=model, requestBuilder=requestBuilder,
226          credentials=credentials)
227    except HttpError as e:
228      if e.resp.status == http_client.NOT_FOUND:
229        continue
230      else:
231        raise e
232
233  raise UnknownApiNameOrVersion(
234        "name: %s  version: %s" % (serviceName, version))
235
236
237def _retrieve_discovery_doc(url, http, cache_discovery, cache=None,
238                            developerKey=None):
239  """Retrieves the discovery_doc from cache or the internet.
240
241  Args:
242    url: string, the URL of the discovery document.
243    http: httplib2.Http, An instance of httplib2.Http or something that acts
244      like it through which HTTP requests will be made.
245    cache_discovery: Boolean, whether or not to cache the discovery doc.
246    cache: googleapiclient.discovery_cache.base.Cache, an optional cache
247      object for the discovery documents.
248
249  Returns:
250    A unicode string representation of the discovery document.
251  """
252  if cache_discovery:
253    from . import discovery_cache
254    from .discovery_cache import base
255    if cache is None:
256      cache = discovery_cache.autodetect()
257    if cache:
258      content = cache.get(url)
259      if content:
260        return content
261
262  actual_url = url
263  # REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
264  # variable that contains the network address of the client sending the
265  # request. If it exists then add that to the request for the discovery
266  # document to avoid exceeding the quota on discovery requests.
267  if 'REMOTE_ADDR' in os.environ:
268    actual_url = _add_query_parameter(url, 'userIp', os.environ['REMOTE_ADDR'])
269  if developerKey:
270    actual_url = _add_query_parameter(url, 'key', developerKey)
271  logger.info('URL being requested: GET %s', actual_url)
272
273  resp, content = http.request(actual_url)
274
275  if resp.status >= 400:
276    raise HttpError(resp, content, uri=actual_url)
277
278  try:
279    content = content.decode('utf-8')
280  except AttributeError:
281    pass
282
283  try:
284    service = json.loads(content)
285  except ValueError as e:
286    logger.error('Failed to parse as JSON: ' + content)
287    raise InvalidJsonError()
288  if cache_discovery and cache:
289    cache.set(url, content)
290  return content
291
292
293@positional(1)
294def build_from_document(
295    service,
296    base=None,
297    future=None,
298    http=None,
299    developerKey=None,
300    model=None,
301    requestBuilder=HttpRequest,
302    credentials=None):
303  """Create a Resource for interacting with an API.
304
305  Same as `build()`, but constructs the Resource object from a discovery
306  document that is it given, as opposed to retrieving one over HTTP.
307
308  Args:
309    service: string or object, the JSON discovery document describing the API.
310      The value passed in may either be the JSON string or the deserialized
311      JSON.
312    base: string, base URI for all HTTP requests, usually the discovery URI.
313      This parameter is no longer used as rootUrl and servicePath are included
314      within the discovery document. (deprecated)
315    future: string, discovery document with future capabilities (deprecated).
316    http: httplib2.Http, An instance of httplib2.Http or something that acts
317      like it that HTTP requests will be made through.
318    developerKey: string, Key for controlling API usage, generated
319      from the API Console.
320    model: Model class instance that serializes and de-serializes requests and
321      responses.
322    requestBuilder: Takes an http request and packages it up to be executed.
323    credentials: oauth2client.Credentials or
324      google.auth.credentials.Credentials, credentials to be used for
325      authentication.
326
327  Returns:
328    A Resource object with methods for interacting with the service.
329  """
330
331  if http is not None and credentials is not None:
332    raise ValueError('Arguments http and credentials are mutually exclusive.')
333
334  if isinstance(service, six.string_types):
335    service = json.loads(service)
336  elif isinstance(service, six.binary_type):
337    service = json.loads(service.decode('utf-8'))
338
339  if  'rootUrl' not in service and (isinstance(http, (HttpMock,
340                                                      HttpMockSequence))):
341      logger.error("You are using HttpMock or HttpMockSequence without" +
342                   "having the service discovery doc in cache. Try calling " +
343                   "build() without mocking once first to populate the " +
344                   "cache.")
345      raise InvalidJsonError()
346
347  base = urljoin(service['rootUrl'], service['servicePath'])
348  schema = Schemas(service)
349
350  # If the http client is not specified, then we must construct an http client
351  # to make requests. If the service has scopes, then we also need to setup
352  # authentication.
353  if http is None:
354    # Does the service require scopes?
355    scopes = list(
356      service.get('auth', {}).get('oauth2', {}).get('scopes', {}).keys())
357
358    # If so, then the we need to setup authentication if no developerKey is
359    # specified.
360    if scopes and not developerKey:
361      # If the user didn't pass in credentials, attempt to acquire application
362      # default credentials.
363      if credentials is None:
364        credentials = _auth.default_credentials()
365
366      # The credentials need to be scoped.
367      credentials = _auth.with_scopes(credentials, scopes)
368
369    # If credentials are provided, create an authorized http instance;
370    # otherwise, skip authentication.
371    if credentials:
372      http = _auth.authorized_http(credentials)
373
374    # If the service doesn't require scopes then there is no need for
375    # authentication.
376    else:
377      http = build_http()
378
379  if model is None:
380    features = service.get('features', [])
381    model = JsonModel('dataWrapper' in features)
382
383  return Resource(http=http, baseUrl=base, model=model,
384                  developerKey=developerKey, requestBuilder=requestBuilder,
385                  resourceDesc=service, rootDesc=service, schema=schema)
386
387
388def _cast(value, schema_type):
389  """Convert value to a string based on JSON Schema type.
390
391  See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
392  JSON Schema.
393
394  Args:
395    value: any, the value to convert
396    schema_type: string, the type that value should be interpreted as
397
398  Returns:
399    A string representation of 'value' based on the schema_type.
400  """
401  if schema_type == 'string':
402    if type(value) == type('') or type(value) == type(u''):
403      return value
404    else:
405      return str(value)
406  elif schema_type == 'integer':
407    return str(int(value))
408  elif schema_type == 'number':
409    return str(float(value))
410  elif schema_type == 'boolean':
411    return str(bool(value)).lower()
412  else:
413    if type(value) == type('') or type(value) == type(u''):
414      return value
415    else:
416      return str(value)
417
418
419def _media_size_to_long(maxSize):
420  """Convert a string media size, such as 10GB or 3TB into an integer.
421
422  Args:
423    maxSize: string, size as a string, such as 2MB or 7GB.
424
425  Returns:
426    The size as an integer value.
427  """
428  if len(maxSize) < 2:
429    return 0
430  units = maxSize[-2:].upper()
431  bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
432  if bit_shift is not None:
433    return int(maxSize[:-2]) << bit_shift
434  else:
435    return int(maxSize)
436
437
438def _media_path_url_from_info(root_desc, path_url):
439  """Creates an absolute media path URL.
440
441  Constructed using the API root URI and service path from the discovery
442  document and the relative path for the API method.
443
444  Args:
445    root_desc: Dictionary; the entire original deserialized discovery document.
446    path_url: String; the relative URL for the API method. Relative to the API
447        root, which is specified in the discovery document.
448
449  Returns:
450    String; the absolute URI for media upload for the API method.
451  """
452  return '%(root)supload/%(service_path)s%(path)s' % {
453      'root': root_desc['rootUrl'],
454      'service_path': root_desc['servicePath'],
455      'path': path_url,
456  }
457
458
459def _fix_up_parameters(method_desc, root_desc, http_method, schema):
460  """Updates parameters of an API method with values specific to this library.
461
462  Specifically, adds whatever global parameters are specified by the API to the
463  parameters for the individual method. Also adds parameters which don't
464  appear in the discovery document, but are available to all discovery based
465  APIs (these are listed in STACK_QUERY_PARAMETERS).
466
467  SIDE EFFECTS: This updates the parameters dictionary object in the method
468  description.
469
470  Args:
471    method_desc: Dictionary with metadata describing an API method. Value comes
472        from the dictionary of methods stored in the 'methods' key in the
473        deserialized discovery document.
474    root_desc: Dictionary; the entire original deserialized discovery document.
475    http_method: String; the HTTP method used to call the API method described
476        in method_desc.
477    schema: Object, mapping of schema names to schema descriptions.
478
479  Returns:
480    The updated Dictionary stored in the 'parameters' key of the method
481        description dictionary.
482  """
483  parameters = method_desc.setdefault('parameters', {})
484
485  # Add in the parameters common to all methods.
486  for name, description in six.iteritems(root_desc.get('parameters', {})):
487    parameters[name] = description
488
489  # Add in undocumented query parameters.
490  for name in STACK_QUERY_PARAMETERS:
491    parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
492
493  # Add 'body' (our own reserved word) to parameters if the method supports
494  # a request payload.
495  if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
496    body = BODY_PARAMETER_DEFAULT_VALUE.copy()
497    body.update(method_desc['request'])
498    parameters['body'] = body
499
500  return parameters
501
502
503def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
504  """Adds 'media_body' and 'media_mime_type' parameters if supported by method.
505
506  SIDE EFFECTS: If there is a 'mediaUpload' in the method description, adds
507  'media_upload' key to parameters.
508
509  Args:
510    method_desc: Dictionary with metadata describing an API method. Value comes
511        from the dictionary of methods stored in the 'methods' key in the
512        deserialized discovery document.
513    root_desc: Dictionary; the entire original deserialized discovery document.
514    path_url: String; the relative URL for the API method. Relative to the API
515        root, which is specified in the discovery document.
516    parameters: A dictionary describing method parameters for method described
517        in method_desc.
518
519  Returns:
520    Triple (accept, max_size, media_path_url) where:
521      - accept is a list of strings representing what content types are
522        accepted for media upload. Defaults to empty list if not in the
523        discovery document.
524      - max_size is a long representing the max size in bytes allowed for a
525        media upload. Defaults to 0L if not in the discovery document.
526      - media_path_url is a String; the absolute URI for media upload for the
527        API method. Constructed using the API root URI and service path from
528        the discovery document and the relative path for the API method. If
529        media upload is not supported, this is None.
530  """
531  media_upload = method_desc.get('mediaUpload', {})
532  accept = media_upload.get('accept', [])
533  max_size = _media_size_to_long(media_upload.get('maxSize', ''))
534  media_path_url = None
535
536  if media_upload:
537    media_path_url = _media_path_url_from_info(root_desc, path_url)
538    parameters['media_body'] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
539    parameters['media_mime_type'] = MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE.copy()
540
541  return accept, max_size, media_path_url
542
543
544def _fix_up_method_description(method_desc, root_desc, schema):
545  """Updates a method description in a discovery document.
546
547  SIDE EFFECTS: Changes the parameters dictionary in the method description with
548  extra parameters which are used locally.
549
550  Args:
551    method_desc: Dictionary with metadata describing an API method. Value comes
552        from the dictionary of methods stored in the 'methods' key in the
553        deserialized discovery document.
554    root_desc: Dictionary; the entire original deserialized discovery document.
555    schema: Object, mapping of schema names to schema descriptions.
556
557  Returns:
558    Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
559    where:
560      - path_url is a String; the relative URL for the API method. Relative to
561        the API root, which is specified in the discovery document.
562      - http_method is a String; the HTTP method used to call the API method
563        described in the method description.
564      - method_id is a String; the name of the RPC method associated with the
565        API method, and is in the method description in the 'id' key.
566      - accept is a list of strings representing what content types are
567        accepted for media upload. Defaults to empty list if not in the
568        discovery document.
569      - max_size is a long representing the max size in bytes allowed for a
570        media upload. Defaults to 0L if not in the discovery document.
571      - media_path_url is a String; the absolute URI for media upload for the
572        API method. Constructed using the API root URI and service path from
573        the discovery document and the relative path for the API method. If
574        media upload is not supported, this is None.
575  """
576  path_url = method_desc['path']
577  http_method = method_desc['httpMethod']
578  method_id = method_desc['id']
579
580  parameters = _fix_up_parameters(method_desc, root_desc, http_method, schema)
581  # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
582  # 'parameters' key and needs to know if there is a 'body' parameter because it
583  # also sets a 'media_body' parameter.
584  accept, max_size, media_path_url = _fix_up_media_upload(
585      method_desc, root_desc, path_url, parameters)
586
587  return path_url, http_method, method_id, accept, max_size, media_path_url
588
589
590def _urljoin(base, url):
591  """Custom urljoin replacement supporting : before / in url."""
592  # In general, it's unsafe to simply join base and url. However, for
593  # the case of discovery documents, we know:
594  #  * base will never contain params, query, or fragment
595  #  * url will never contain a scheme or net_loc.
596  # In general, this means we can safely join on /; we just need to
597  # ensure we end up with precisely one / joining base and url. The
598  # exception here is the case of media uploads, where url will be an
599  # absolute url.
600  if url.startswith('http://') or url.startswith('https://'):
601    return urljoin(base, url)
602  new_base = base if base.endswith('/') else base + '/'
603  new_url = url[1:] if url.startswith('/') else url
604  return new_base + new_url
605
606
607# TODO(dhermes): Convert this class to ResourceMethod and make it callable
608class ResourceMethodParameters(object):
609  """Represents the parameters associated with a method.
610
611  Attributes:
612    argmap: Map from method parameter name (string) to query parameter name
613        (string).
614    required_params: List of required parameters (represented by parameter
615        name as string).
616    repeated_params: List of repeated parameters (represented by parameter
617        name as string).
618    pattern_params: Map from method parameter name (string) to regular
619        expression (as a string). If the pattern is set for a parameter, the
620        value for that parameter must match the regular expression.
621    query_params: List of parameters (represented by parameter name as string)
622        that will be used in the query string.
623    path_params: Set of parameters (represented by parameter name as string)
624        that will be used in the base URL path.
625    param_types: Map from method parameter name (string) to parameter type. Type
626        can be any valid JSON schema type; valid values are 'any', 'array',
627        'boolean', 'integer', 'number', 'object', or 'string'. Reference:
628        http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
629    enum_params: Map from method parameter name (string) to list of strings,
630       where each list of strings is the list of acceptable enum values.
631  """
632
633  def __init__(self, method_desc):
634    """Constructor for ResourceMethodParameters.
635
636    Sets default values and defers to set_parameters to populate.
637
638    Args:
639      method_desc: Dictionary with metadata describing an API method. Value
640          comes from the dictionary of methods stored in the 'methods' key in
641          the deserialized discovery document.
642    """
643    self.argmap = {}
644    self.required_params = []
645    self.repeated_params = []
646    self.pattern_params = {}
647    self.query_params = []
648    # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
649    #                parsing is gotten rid of.
650    self.path_params = set()
651    self.param_types = {}
652    self.enum_params = {}
653
654    self.set_parameters(method_desc)
655
656  def set_parameters(self, method_desc):
657    """Populates maps and lists based on method description.
658
659    Iterates through each parameter for the method and parses the values from
660    the parameter dictionary.
661
662    Args:
663      method_desc: Dictionary with metadata describing an API method. Value
664          comes from the dictionary of methods stored in the 'methods' key in
665          the deserialized discovery document.
666    """
667    for arg, desc in six.iteritems(method_desc.get('parameters', {})):
668      param = key2param(arg)
669      self.argmap[param] = arg
670
671      if desc.get('pattern'):
672        self.pattern_params[param] = desc['pattern']
673      if desc.get('enum'):
674        self.enum_params[param] = desc['enum']
675      if desc.get('required'):
676        self.required_params.append(param)
677      if desc.get('repeated'):
678        self.repeated_params.append(param)
679      if desc.get('location') == 'query':
680        self.query_params.append(param)
681      if desc.get('location') == 'path':
682        self.path_params.add(param)
683      self.param_types[param] = desc.get('type', 'string')
684
685    # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
686    #                should have all path parameters already marked with
687    #                'location: path'.
688    for match in URITEMPLATE.finditer(method_desc['path']):
689      for namematch in VARNAME.finditer(match.group(0)):
690        name = key2param(namematch.group(0))
691        self.path_params.add(name)
692        if name in self.query_params:
693          self.query_params.remove(name)
694
695
696def createMethod(methodName, methodDesc, rootDesc, schema):
697  """Creates a method for attaching to a Resource.
698
699  Args:
700    methodName: string, name of the method to use.
701    methodDesc: object, fragment of deserialized discovery document that
702      describes the method.
703    rootDesc: object, the entire deserialized discovery document.
704    schema: object, mapping of schema names to schema descriptions.
705  """
706  methodName = fix_method_name(methodName)
707  (pathUrl, httpMethod, methodId, accept,
708   maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc, schema)
709
710  parameters = ResourceMethodParameters(methodDesc)
711
712  def method(self, **kwargs):
713    # Don't bother with doc string, it will be over-written by createMethod.
714
715    for name in six.iterkeys(kwargs):
716      if name not in parameters.argmap:
717        raise TypeError('Got an unexpected keyword argument "%s"' % name)
718
719    # Remove args that have a value of None.
720    keys = list(kwargs.keys())
721    for name in keys:
722      if kwargs[name] is None:
723        del kwargs[name]
724
725    for name in parameters.required_params:
726      if name not in kwargs:
727        # temporary workaround for non-paging methods incorrectly requiring
728        # page token parameter (cf. drive.changes.watch vs. drive.changes.list)
729        if name not in _PAGE_TOKEN_NAMES or _findPageTokenName(
730            _methodProperties(methodDesc, schema, 'response')):
731          raise TypeError('Missing required parameter "%s"' % name)
732
733    for name, regex in six.iteritems(parameters.pattern_params):
734      if name in kwargs:
735        if isinstance(kwargs[name], six.string_types):
736          pvalues = [kwargs[name]]
737        else:
738          pvalues = kwargs[name]
739        for pvalue in pvalues:
740          if re.match(regex, pvalue) is None:
741            raise TypeError(
742                'Parameter "%s" value "%s" does not match the pattern "%s"' %
743                (name, pvalue, regex))
744
745    for name, enums in six.iteritems(parameters.enum_params):
746      if name in kwargs:
747        # We need to handle the case of a repeated enum
748        # name differently, since we want to handle both
749        # arg='value' and arg=['value1', 'value2']
750        if (name in parameters.repeated_params and
751            not isinstance(kwargs[name], six.string_types)):
752          values = kwargs[name]
753        else:
754          values = [kwargs[name]]
755        for value in values:
756          if value not in enums:
757            raise TypeError(
758                'Parameter "%s" value "%s" is not an allowed value in "%s"' %
759                (name, value, str(enums)))
760
761    actual_query_params = {}
762    actual_path_params = {}
763    for key, value in six.iteritems(kwargs):
764      to_type = parameters.param_types.get(key, 'string')
765      # For repeated parameters we cast each member of the list.
766      if key in parameters.repeated_params and type(value) == type([]):
767        cast_value = [_cast(x, to_type) for x in value]
768      else:
769        cast_value = _cast(value, to_type)
770      if key in parameters.query_params:
771        actual_query_params[parameters.argmap[key]] = cast_value
772      if key in parameters.path_params:
773        actual_path_params[parameters.argmap[key]] = cast_value
774    body_value = kwargs.get('body', None)
775    media_filename = kwargs.get('media_body', None)
776    media_mime_type = kwargs.get('media_mime_type', None)
777
778    if self._developerKey:
779      actual_query_params['key'] = self._developerKey
780
781    model = self._model
782    if methodName.endswith('_media'):
783      model = MediaModel()
784    elif 'response' not in methodDesc:
785      model = RawModel()
786
787    headers = {}
788    headers, params, query, body = model.request(headers,
789        actual_path_params, actual_query_params, body_value)
790
791    expanded_url = uritemplate.expand(pathUrl, params)
792    url = _urljoin(self._baseUrl, expanded_url + query)
793
794    resumable = None
795    multipart_boundary = ''
796
797    if media_filename:
798      # Ensure we end up with a valid MediaUpload object.
799      if isinstance(media_filename, six.string_types):
800        if media_mime_type is None:
801          logger.warning(
802              'media_mime_type argument not specified: trying to auto-detect for %s',
803              media_filename)
804          media_mime_type, _ = mimetypes.guess_type(media_filename)
805        if media_mime_type is None:
806          raise UnknownFileType(media_filename)
807        if not mimeparse.best_match([media_mime_type], ','.join(accept)):
808          raise UnacceptableMimeTypeError(media_mime_type)
809        media_upload = MediaFileUpload(media_filename,
810                                       mimetype=media_mime_type)
811      elif isinstance(media_filename, MediaUpload):
812        media_upload = media_filename
813      else:
814        raise TypeError('media_filename must be str or MediaUpload.')
815
816      # Check the maxSize
817      if media_upload.size() is not None and media_upload.size() > maxSize > 0:
818        raise MediaUploadSizeError("Media larger than: %s" % maxSize)
819
820      # Use the media path uri for media uploads
821      expanded_url = uritemplate.expand(mediaPathUrl, params)
822      url = _urljoin(self._baseUrl, expanded_url + query)
823      if media_upload.resumable():
824        url = _add_query_parameter(url, 'uploadType', 'resumable')
825
826      if media_upload.resumable():
827        # This is all we need to do for resumable, if the body exists it gets
828        # sent in the first request, otherwise an empty body is sent.
829        resumable = media_upload
830      else:
831        # A non-resumable upload
832        if body is None:
833          # This is a simple media upload
834          headers['content-type'] = media_upload.mimetype()
835          body = media_upload.getbytes(0, media_upload.size())
836          url = _add_query_parameter(url, 'uploadType', 'media')
837        else:
838          # This is a multipart/related upload.
839          msgRoot = MIMEMultipart('related')
840          # msgRoot should not write out it's own headers
841          setattr(msgRoot, '_write_headers', lambda self: None)
842
843          # attach the body as one part
844          msg = MIMENonMultipart(*headers['content-type'].split('/'))
845          msg.set_payload(body)
846          msgRoot.attach(msg)
847
848          # attach the media as the second part
849          msg = MIMENonMultipart(*media_upload.mimetype().split('/'))
850          msg['Content-Transfer-Encoding'] = 'binary'
851
852          payload = media_upload.getbytes(0, media_upload.size())
853          msg.set_payload(payload)
854          msgRoot.attach(msg)
855          # encode the body: note that we can't use `as_string`, because
856          # it plays games with `From ` lines.
857          fp = BytesIO()
858          g = _BytesGenerator(fp, mangle_from_=False)
859          g.flatten(msgRoot, unixfrom=False)
860          body = fp.getvalue()
861
862          multipart_boundary = msgRoot.get_boundary()
863          headers['content-type'] = ('multipart/related; '
864                                     'boundary="%s"') % multipart_boundary
865          url = _add_query_parameter(url, 'uploadType', 'multipart')
866
867    logger.info('URL being requested: %s %s' % (httpMethod,url))
868    return self._requestBuilder(self._http,
869                                model.response,
870                                url,
871                                method=httpMethod,
872                                body=body,
873                                headers=headers,
874                                methodId=methodId,
875                                resumable=resumable)
876
877  docs = [methodDesc.get('description', DEFAULT_METHOD_DOC), '\n\n']
878  if len(parameters.argmap) > 0:
879    docs.append('Args:\n')
880
881  # Skip undocumented params and params common to all methods.
882  skip_parameters = list(rootDesc.get('parameters', {}).keys())
883  skip_parameters.extend(STACK_QUERY_PARAMETERS)
884
885  all_args = list(parameters.argmap.keys())
886  args_ordered = [key2param(s) for s in methodDesc.get('parameterOrder', [])]
887
888  # Move body to the front of the line.
889  if 'body' in all_args:
890    args_ordered.append('body')
891
892  for name in all_args:
893    if name not in args_ordered:
894      args_ordered.append(name)
895
896  for arg in args_ordered:
897    if arg in skip_parameters:
898      continue
899
900    repeated = ''
901    if arg in parameters.repeated_params:
902      repeated = ' (repeated)'
903    required = ''
904    if arg in parameters.required_params:
905      required = ' (required)'
906    paramdesc = methodDesc['parameters'][parameters.argmap[arg]]
907    paramdoc = paramdesc.get('description', 'A parameter')
908    if '$ref' in paramdesc:
909      docs.append(
910          ('  %s: object, %s%s%s\n    The object takes the'
911          ' form of:\n\n%s\n\n') % (arg, paramdoc, required, repeated,
912            schema.prettyPrintByName(paramdesc['$ref'])))
913    else:
914      paramtype = paramdesc.get('type', 'string')
915      docs.append('  %s: %s, %s%s%s\n' % (arg, paramtype, paramdoc, required,
916                                          repeated))
917    enum = paramdesc.get('enum', [])
918    enumDesc = paramdesc.get('enumDescriptions', [])
919    if enum and enumDesc:
920      docs.append('    Allowed values\n')
921      for (name, desc) in zip(enum, enumDesc):
922        docs.append('      %s - %s\n' % (name, desc))
923  if 'response' in methodDesc:
924    if methodName.endswith('_media'):
925      docs.append('\nReturns:\n  The media object as a string.\n\n    ')
926    else:
927      docs.append('\nReturns:\n  An object of the form:\n\n    ')
928      docs.append(schema.prettyPrintSchema(methodDesc['response']))
929
930  setattr(method, '__doc__', ''.join(docs))
931  return (methodName, method)
932
933
934def createNextMethod(methodName,
935                     pageTokenName='pageToken',
936                     nextPageTokenName='nextPageToken',
937                     isPageTokenParameter=True):
938  """Creates any _next methods for attaching to a Resource.
939
940  The _next methods allow for easy iteration through list() responses.
941
942  Args:
943    methodName: string, name of the method to use.
944    pageTokenName: string, name of request page token field.
945    nextPageTokenName: string, name of response page token field.
946    isPageTokenParameter: Boolean, True if request page token is a query
947        parameter, False if request page token is a field of the request body.
948  """
949  methodName = fix_method_name(methodName)
950
951  def methodNext(self, previous_request, previous_response):
952    """Retrieves the next page of results.
953
954Args:
955  previous_request: The request for the previous page. (required)
956  previous_response: The response from the request for the previous page. (required)
957
958Returns:
959  A request object that you can call 'execute()' on to request the next
960  page. Returns None if there are no more items in the collection.
961    """
962    # Retrieve nextPageToken from previous_response
963    # Use as pageToken in previous_request to create new request.
964
965    nextPageToken = previous_response.get(nextPageTokenName, None)
966    if not nextPageToken:
967      return None
968
969    request = copy.copy(previous_request)
970
971    if isPageTokenParameter:
972        # Replace pageToken value in URI
973        request.uri = _add_query_parameter(
974            request.uri, pageTokenName, nextPageToken)
975        logger.info('Next page request URL: %s %s' % (methodName, request.uri))
976    else:
977        # Replace pageToken value in request body
978        model = self._model
979        body = model.deserialize(request.body)
980        body[pageTokenName] = nextPageToken
981        request.body = model.serialize(body)
982        logger.info('Next page request body: %s %s' % (methodName, body))
983
984    return request
985
986  return (methodName, methodNext)
987
988
989class Resource(object):
990  """A class for interacting with a resource."""
991
992  def __init__(self, http, baseUrl, model, requestBuilder, developerKey,
993               resourceDesc, rootDesc, schema):
994    """Build a Resource from the API description.
995
996    Args:
997      http: httplib2.Http, Object to make http requests with.
998      baseUrl: string, base URL for the API. All requests are relative to this
999          URI.
1000      model: googleapiclient.Model, converts to and from the wire format.
1001      requestBuilder: class or callable that instantiates an
1002          googleapiclient.HttpRequest object.
1003      developerKey: string, key obtained from
1004          https://code.google.com/apis/console
1005      resourceDesc: object, section of deserialized discovery document that
1006          describes a resource. Note that the top level discovery document
1007          is considered a resource.
1008      rootDesc: object, the entire deserialized discovery document.
1009      schema: object, mapping of schema names to schema descriptions.
1010    """
1011    self._dynamic_attrs = []
1012
1013    self._http = http
1014    self._baseUrl = baseUrl
1015    self._model = model
1016    self._developerKey = developerKey
1017    self._requestBuilder = requestBuilder
1018    self._resourceDesc = resourceDesc
1019    self._rootDesc = rootDesc
1020    self._schema = schema
1021
1022    self._set_service_methods()
1023
1024  def _set_dynamic_attr(self, attr_name, value):
1025    """Sets an instance attribute and tracks it in a list of dynamic attributes.
1026
1027    Args:
1028      attr_name: string; The name of the attribute to be set
1029      value: The value being set on the object and tracked in the dynamic cache.
1030    """
1031    self._dynamic_attrs.append(attr_name)
1032    self.__dict__[attr_name] = value
1033
1034  def __getstate__(self):
1035    """Trim the state down to something that can be pickled.
1036
1037    Uses the fact that the instance variable _dynamic_attrs holds attrs that
1038    will be wiped and restored on pickle serialization.
1039    """
1040    state_dict = copy.copy(self.__dict__)
1041    for dynamic_attr in self._dynamic_attrs:
1042      del state_dict[dynamic_attr]
1043    del state_dict['_dynamic_attrs']
1044    return state_dict
1045
1046  def __setstate__(self, state):
1047    """Reconstitute the state of the object from being pickled.
1048
1049    Uses the fact that the instance variable _dynamic_attrs holds attrs that
1050    will be wiped and restored on pickle serialization.
1051    """
1052    self.__dict__.update(state)
1053    self._dynamic_attrs = []
1054    self._set_service_methods()
1055
1056  def _set_service_methods(self):
1057    self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
1058    self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
1059    self._add_next_methods(self._resourceDesc, self._schema)
1060
1061  def _add_basic_methods(self, resourceDesc, rootDesc, schema):
1062    # If this is the root Resource, add a new_batch_http_request() method.
1063    if resourceDesc == rootDesc:
1064      batch_uri = '%s%s' % (
1065        rootDesc['rootUrl'], rootDesc.get('batchPath', 'batch'))
1066      def new_batch_http_request(callback=None):
1067        """Create a BatchHttpRequest object based on the discovery document.
1068
1069        Args:
1070          callback: callable, A callback to be called for each response, of the
1071            form callback(id, response, exception). The first parameter is the
1072            request id, and the second is the deserialized response object. The
1073            third is an apiclient.errors.HttpError exception object if an HTTP
1074            error occurred while processing the request, or None if no error
1075            occurred.
1076
1077        Returns:
1078          A BatchHttpRequest object based on the discovery document.
1079        """
1080        return BatchHttpRequest(callback=callback, batch_uri=batch_uri)
1081      self._set_dynamic_attr('new_batch_http_request', new_batch_http_request)
1082
1083    # Add basic methods to Resource
1084    if 'methods' in resourceDesc:
1085      for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
1086        fixedMethodName, method = createMethod(
1087            methodName, methodDesc, rootDesc, schema)
1088        self._set_dynamic_attr(fixedMethodName,
1089                               method.__get__(self, self.__class__))
1090        # Add in _media methods. The functionality of the attached method will
1091        # change when it sees that the method name ends in _media.
1092        if methodDesc.get('supportsMediaDownload', False):
1093          fixedMethodName, method = createMethod(
1094              methodName + '_media', methodDesc, rootDesc, schema)
1095          self._set_dynamic_attr(fixedMethodName,
1096                                 method.__get__(self, self.__class__))
1097
1098  def _add_nested_resources(self, resourceDesc, rootDesc, schema):
1099    # Add in nested resources
1100    if 'resources' in resourceDesc:
1101
1102      def createResourceMethod(methodName, methodDesc):
1103        """Create a method on the Resource to access a nested Resource.
1104
1105        Args:
1106          methodName: string, name of the method to use.
1107          methodDesc: object, fragment of deserialized discovery document that
1108            describes the method.
1109        """
1110        methodName = fix_method_name(methodName)
1111
1112        def methodResource(self):
1113          return Resource(http=self._http, baseUrl=self._baseUrl,
1114                          model=self._model, developerKey=self._developerKey,
1115                          requestBuilder=self._requestBuilder,
1116                          resourceDesc=methodDesc, rootDesc=rootDesc,
1117                          schema=schema)
1118
1119        setattr(methodResource, '__doc__', 'A collection resource.')
1120        setattr(methodResource, '__is_resource__', True)
1121
1122        return (methodName, methodResource)
1123
1124      for methodName, methodDesc in six.iteritems(resourceDesc['resources']):
1125        fixedMethodName, method = createResourceMethod(methodName, methodDesc)
1126        self._set_dynamic_attr(fixedMethodName,
1127                               method.__get__(self, self.__class__))
1128
1129  def _add_next_methods(self, resourceDesc, schema):
1130    # Add _next() methods if and only if one of the names 'pageToken' or
1131    # 'nextPageToken' occurs among the fields of both the method's response
1132    # type either the method's request (query parameters) or request body.
1133    if 'methods' not in resourceDesc:
1134      return
1135    for methodName, methodDesc in six.iteritems(resourceDesc['methods']):
1136      nextPageTokenName = _findPageTokenName(
1137          _methodProperties(methodDesc, schema, 'response'))
1138      if not nextPageTokenName:
1139        continue
1140      isPageTokenParameter = True
1141      pageTokenName = _findPageTokenName(methodDesc.get('parameters', {}))
1142      if not pageTokenName:
1143        isPageTokenParameter = False
1144        pageTokenName = _findPageTokenName(
1145            _methodProperties(methodDesc, schema, 'request'))
1146      if not pageTokenName:
1147        continue
1148      fixedMethodName, method = createNextMethod(
1149          methodName + '_next', pageTokenName, nextPageTokenName,
1150          isPageTokenParameter)
1151      self._set_dynamic_attr(fixedMethodName,
1152                             method.__get__(self, self.__class__))
1153
1154
1155def _findPageTokenName(fields):
1156  """Search field names for one like a page token.
1157
1158  Args:
1159    fields: container of string, names of fields.
1160
1161  Returns:
1162    First name that is either 'pageToken' or 'nextPageToken' if one exists,
1163    otherwise None.
1164  """
1165  return next((tokenName for tokenName in _PAGE_TOKEN_NAMES
1166              if tokenName in fields), None)
1167
1168def _methodProperties(methodDesc, schema, name):
1169  """Get properties of a field in a method description.
1170
1171  Args:
1172    methodDesc: object, fragment of deserialized discovery document that
1173      describes the method.
1174    schema: object, mapping of schema names to schema descriptions.
1175    name: string, name of top-level field in method description.
1176
1177  Returns:
1178    Object representing fragment of deserialized discovery document
1179    corresponding to 'properties' field of object corresponding to named field
1180    in method description, if it exists, otherwise empty dict.
1181  """
1182  desc = methodDesc.get(name, {})
1183  if '$ref' in desc:
1184    desc = schema.get(desc['$ref'], {})
1185  return desc.get('properties', {})
1186