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