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
20
21__author__ = "jcgregorio@google.com (Joe Gregorio)"
22__all__ = ["build", "build_from_document", "fix_method_name", "key2param"]
23
24from collections import OrderedDict
25import collections.abc
26
27# Standard library imports
28import copy
29from email.generator import BytesGenerator
30from email.mime.multipart import MIMEMultipart
31from email.mime.nonmultipart import MIMENonMultipart
32import http.client as http_client
33import io
34import json
35import keyword
36import logging
37import mimetypes
38import os
39import re
40import urllib
41
42import google.api_core.client_options
43from google.auth.exceptions import MutualTLSChannelError
44from google.auth.transport import mtls
45from google.oauth2 import service_account
46
47# Third-party imports
48import httplib2
49import uritemplate
50
51try:
52 import google_auth_httplib2
53except ImportError: # pragma: NO COVER
54 google_auth_httplib2 = None
55
56try:
57 from google.api_core import universe
58
59 HAS_UNIVERSE = True
60except ImportError:
61 HAS_UNIVERSE = False
62
63# Local imports
64from googleapiclient import _auth, mimeparse
65from googleapiclient._helpers import _add_query_parameter, positional
66from googleapiclient.errors import (
67 HttpError,
68 InvalidJsonError,
69 MediaUploadSizeError,
70 UnacceptableMimeTypeError,
71 UnknownApiNameOrVersion,
72 UnknownFileType,
73)
74from googleapiclient.http import (
75 BatchHttpRequest,
76 HttpMock,
77 HttpMockSequence,
78 HttpRequest,
79 MediaFileUpload,
80 MediaUpload,
81 build_http,
82)
83from googleapiclient.model import JsonModel, MediaModel, RawModel
84from googleapiclient.schema import Schemas
85
86# The client library requires a version of httplib2 that supports RETRIES.
87httplib2.RETRIES = 1
88
89logger = logging.getLogger(__name__)
90
91URITEMPLATE = re.compile("{[^}]*}")
92VARNAME = re.compile("[a-zA-Z0-9_-]+")
93DISCOVERY_URI = (
94 "https://www.googleapis.com/discovery/v1/apis/" "{api}/{apiVersion}/rest"
95)
96V1_DISCOVERY_URI = DISCOVERY_URI
97V2_DISCOVERY_URI = (
98 "https://{api}.googleapis.com/$discovery/rest?" "version={apiVersion}"
99)
100DEFAULT_METHOD_DOC = "A description of how to use this function"
101HTTP_PAYLOAD_METHODS = frozenset(["PUT", "POST", "PATCH"])
102
103_MEDIA_SIZE_BIT_SHIFTS = {"KB": 10, "MB": 20, "GB": 30, "TB": 40}
104BODY_PARAMETER_DEFAULT_VALUE = {"description": "The request body.", "type": "object"}
105MEDIA_BODY_PARAMETER_DEFAULT_VALUE = {
106 "description": (
107 "The filename of the media request body, or an instance "
108 "of a MediaUpload object."
109 ),
110 "type": "string",
111 "required": False,
112}
113MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE = {
114 "description": (
115 "The MIME type of the media request body, or an instance "
116 "of a MediaUpload object."
117 ),
118 "type": "string",
119 "required": False,
120}
121_PAGE_TOKEN_NAMES = ("pageToken", "nextPageToken")
122
123# Parameters controlling mTLS behavior. See https://google.aip.dev/auth/4114.
124GOOGLE_API_USE_CLIENT_CERTIFICATE = "GOOGLE_API_USE_CLIENT_CERTIFICATE"
125GOOGLE_API_USE_MTLS_ENDPOINT = "GOOGLE_API_USE_MTLS_ENDPOINT"
126GOOGLE_CLOUD_UNIVERSE_DOMAIN = "GOOGLE_CLOUD_UNIVERSE_DOMAIN"
127DEFAULT_UNIVERSE = "googleapis.com"
128# Parameters accepted by the stack, but not visible via discovery.
129# TODO(dhermes): Remove 'userip' in 'v2'.
130STACK_QUERY_PARAMETERS = frozenset(["trace", "pp", "userip", "strict"])
131STACK_QUERY_PARAMETER_DEFAULT_VALUE = {"type": "string", "location": "query"}
132
133
134class APICoreVersionError(ValueError):
135 def __init__(self):
136 message = (
137 "google-api-core >= 2.18.0 is required to use the universe domain feature."
138 )
139 super().__init__(message)
140
141
142# Library-specific reserved words beyond Python keywords.
143RESERVED_WORDS = frozenset(["body"])
144
145# patch _write_lines to avoid munging '\r' into '\n'
146# ( https://bugs.python.org/issue18886 https://bugs.python.org/issue19003 )
147class _BytesGenerator(BytesGenerator):
148 _write_lines = BytesGenerator.write
149
150
151def fix_method_name(name):
152 """Fix method names to avoid '$' characters and reserved word conflicts.
153
154 Args:
155 name: string, method name.
156
157 Returns:
158 The name with '_' appended if the name is a reserved word and '$' and '-'
159 replaced with '_'.
160 """
161 name = name.replace("$", "_").replace("-", "_")
162 if keyword.iskeyword(name) or name in RESERVED_WORDS:
163 return name + "_"
164 else:
165 return name
166
167
168def key2param(key):
169 """Converts key names into parameter names.
170
171 For example, converting "max-results" -> "max_results"
172
173 Args:
174 key: string, the method key name.
175
176 Returns:
177 A safe method name based on the key name.
178 """
179 result = []
180 key = list(key)
181 if not key[0].isalpha():
182 result.append("x")
183 for c in key:
184 if c.isalnum():
185 result.append(c)
186 else:
187 result.append("_")
188
189 return "".join(result)
190
191
192@positional(2)
193def build(
194 serviceName,
195 version,
196 http=None,
197 discoveryServiceUrl=None,
198 developerKey=None,
199 model=None,
200 requestBuilder=HttpRequest,
201 credentials=None,
202 cache_discovery=True,
203 cache=None,
204 client_options=None,
205 adc_cert_path=None,
206 adc_key_path=None,
207 num_retries=1,
208 static_discovery=None,
209 always_use_jwt_access=False,
210):
211 """Construct a Resource for interacting with an API.
212
213 Construct a Resource object for interacting with an API. The serviceName and
214 version are the names from the Discovery service.
215
216 Args:
217 serviceName: string, name of the service.
218 version: string, the version of the service.
219 http: httplib2.Http, An instance of httplib2.Http or something that acts
220 like it that HTTP requests will be made through.
221 discoveryServiceUrl: string, a URI Template that points to the location of
222 the discovery service. It should have two parameters {api} and
223 {apiVersion} that when filled in produce an absolute URI to the discovery
224 document for that service.
225 developerKey: string, key obtained from
226 https://code.google.com/apis/console.
227 model: googleapiclient.Model, converts to and from the wire format.
228 requestBuilder: googleapiclient.http.HttpRequest, encapsulator for an HTTP
229 request.
230 credentials: oauth2client.Credentials or
231 google.auth.credentials.Credentials, credentials to be used for
232 authentication.
233 cache_discovery: Boolean, whether or not to cache the discovery doc.
234 cache: googleapiclient.discovery_cache.base.CacheBase, an optional
235 cache object for the discovery documents.
236 client_options: Mapping object or google.api_core.client_options, client
237 options to set user options on the client.
238 (1) The API endpoint should be set through client_options. If API endpoint
239 is not set, `GOOGLE_API_USE_MTLS_ENDPOINT` environment variable can be used
240 to control which endpoint to use.
241 (2) client_cert_source is not supported, client cert should be provided using
242 client_encrypted_cert_source instead. In order to use the provided client
243 cert, `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be
244 set to `true`.
245 More details on the environment variables are here:
246 https://google.aip.dev/auth/4114
247 adc_cert_path: str, client certificate file path to save the application
248 default client certificate for mTLS. This field is required if you want to
249 use the default client certificate. `GOOGLE_API_USE_CLIENT_CERTIFICATE`
250 environment variable must be set to `true` in order to use this field,
251 otherwise this field doesn't nothing.
252 More details on the environment variables are here:
253 https://google.aip.dev/auth/4114
254 adc_key_path: str, client encrypted private key file path to save the
255 application default client encrypted private key for mTLS. This field is
256 required if you want to use the default client certificate.
257 `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be set to
258 `true` in order to use this field, otherwise this field doesn't nothing.
259 More details on the environment variables are here:
260 https://google.aip.dev/auth/4114
261 num_retries: Integer, number of times to retry discovery with
262 randomized exponential backoff in case of intermittent/connection issues.
263 static_discovery: Boolean, whether or not to use the static discovery docs
264 included in the library. The default value for `static_discovery` depends
265 on the value of `discoveryServiceUrl`. `static_discovery` will default to
266 `True` when `discoveryServiceUrl` is also not provided, otherwise it will
267 default to `False`.
268 always_use_jwt_access: Boolean, whether always use self signed JWT for service
269 account credentials. This only applies to
270 google.oauth2.service_account.Credentials.
271
272 Returns:
273 A Resource object with methods for interacting with the service.
274
275 Raises:
276 google.auth.exceptions.MutualTLSChannelError: if there are any problems
277 setting up mutual TLS channel.
278 """
279 params = {"api": serviceName, "apiVersion": version}
280
281 # The default value for `static_discovery` depends on the value of
282 # `discoveryServiceUrl`. `static_discovery` will default to `True` when
283 # `discoveryServiceUrl` is also not provided, otherwise it will default to
284 # `False`. This is added for backwards compatability with
285 # google-api-python-client 1.x which does not support the `static_discovery`
286 # parameter.
287 if static_discovery is None:
288 if discoveryServiceUrl is None:
289 static_discovery = True
290 else:
291 static_discovery = False
292
293 if http is None:
294 discovery_http = build_http()
295 else:
296 discovery_http = http
297
298 service = None
299
300 for discovery_url in _discovery_service_uri_options(discoveryServiceUrl, version):
301 requested_url = uritemplate.expand(discovery_url, params)
302
303 try:
304 content = _retrieve_discovery_doc(
305 requested_url,
306 discovery_http,
307 cache_discovery,
308 serviceName,
309 version,
310 cache,
311 developerKey,
312 num_retries=num_retries,
313 static_discovery=static_discovery,
314 )
315 service = build_from_document(
316 content,
317 base=discovery_url,
318 http=http,
319 developerKey=developerKey,
320 model=model,
321 requestBuilder=requestBuilder,
322 credentials=credentials,
323 client_options=client_options,
324 adc_cert_path=adc_cert_path,
325 adc_key_path=adc_key_path,
326 always_use_jwt_access=always_use_jwt_access,
327 )
328 break # exit if a service was created
329 except HttpError as e:
330 if e.resp.status == http_client.NOT_FOUND:
331 continue
332 else:
333 raise e
334
335 # If discovery_http was created by this function, we are done with it
336 # and can safely close it
337 if http is None:
338 discovery_http.close()
339
340 if service is None:
341 raise UnknownApiNameOrVersion("name: %s version: %s" % (serviceName, version))
342 else:
343 return service
344
345
346def _discovery_service_uri_options(discoveryServiceUrl, version):
347 """
348 Returns Discovery URIs to be used for attempting to build the API Resource.
349
350 Args:
351 discoveryServiceUrl:
352 string, the Original Discovery Service URL preferred by the customer.
353 version:
354 string, API Version requested
355
356 Returns:
357 A list of URIs to be tried for the Service Discovery, in order.
358 """
359
360 if discoveryServiceUrl is not None:
361 return [discoveryServiceUrl]
362 if version is None:
363 # V1 Discovery won't work if the requested version is None
364 logger.warning(
365 "Discovery V1 does not support empty versions. Defaulting to V2..."
366 )
367 return [V2_DISCOVERY_URI]
368 else:
369 return [DISCOVERY_URI, V2_DISCOVERY_URI]
370
371
372def _retrieve_discovery_doc(
373 url,
374 http,
375 cache_discovery,
376 serviceName,
377 version,
378 cache=None,
379 developerKey=None,
380 num_retries=1,
381 static_discovery=True,
382):
383 """Retrieves the discovery_doc from cache or the internet.
384
385 Args:
386 url: string, the URL of the discovery document.
387 http: httplib2.Http, An instance of httplib2.Http or something that acts
388 like it through which HTTP requests will be made.
389 cache_discovery: Boolean, whether or not to cache the discovery doc.
390 serviceName: string, name of the service.
391 version: string, the version of the service.
392 cache: googleapiclient.discovery_cache.base.Cache, an optional cache
393 object for the discovery documents.
394 developerKey: string, Key for controlling API usage, generated
395 from the API Console.
396 num_retries: Integer, number of times to retry discovery with
397 randomized exponential backoff in case of intermittent/connection issues.
398 static_discovery: Boolean, whether or not to use the static discovery docs
399 included in the library.
400
401 Returns:
402 A unicode string representation of the discovery document.
403 """
404 from . import discovery_cache
405
406 if cache_discovery:
407 if cache is None:
408 cache = discovery_cache.autodetect()
409 if cache:
410 content = cache.get(url)
411 if content:
412 return content
413
414 # When `static_discovery=True`, use static discovery artifacts included
415 # with the library
416 if static_discovery:
417 content = discovery_cache.get_static_doc(serviceName, version)
418 if content:
419 return content
420 else:
421 raise UnknownApiNameOrVersion(
422 "name: %s version: %s" % (serviceName, version)
423 )
424
425 actual_url = url
426 # REMOTE_ADDR is defined by the CGI spec [RFC3875] as the environment
427 # variable that contains the network address of the client sending the
428 # request. If it exists then add that to the request for the discovery
429 # document to avoid exceeding the quota on discovery requests.
430 if "REMOTE_ADDR" in os.environ:
431 actual_url = _add_query_parameter(url, "userIp", os.environ["REMOTE_ADDR"])
432 if developerKey:
433 actual_url = _add_query_parameter(url, "key", developerKey)
434 logger.debug("URL being requested: GET %s", actual_url)
435
436 # Execute this request with retries build into HttpRequest
437 # Note that it will already raise an error if we don't get a 2xx response
438 req = HttpRequest(http, HttpRequest.null_postproc, actual_url)
439 resp, content = req.execute(num_retries=num_retries)
440
441 try:
442 content = content.decode("utf-8")
443 except AttributeError:
444 pass
445
446 try:
447 service = json.loads(content)
448 except ValueError as e:
449 logger.error("Failed to parse as JSON: " + content)
450 raise InvalidJsonError()
451 if cache_discovery and cache:
452 cache.set(url, content)
453 return content
454
455
456def _check_api_core_compatible_with_credentials_universe(credentials):
457 if not HAS_UNIVERSE:
458 credentials_universe = getattr(credentials, "universe_domain", None)
459 if credentials_universe and credentials_universe != DEFAULT_UNIVERSE:
460 raise APICoreVersionError
461
462
463@positional(1)
464def build_from_document(
465 service,
466 base=None,
467 future=None,
468 http=None,
469 developerKey=None,
470 model=None,
471 requestBuilder=HttpRequest,
472 credentials=None,
473 client_options=None,
474 adc_cert_path=None,
475 adc_key_path=None,
476 always_use_jwt_access=False,
477):
478 """Create a Resource for interacting with an API.
479
480 Same as `build()`, but constructs the Resource object from a discovery
481 document that is it given, as opposed to retrieving one over HTTP.
482
483 Args:
484 service: string or object, the JSON discovery document describing the API.
485 The value passed in may either be the JSON string or the deserialized
486 JSON.
487 base: string, base URI for all HTTP requests, usually the discovery URI.
488 This parameter is no longer used as rootUrl and servicePath are included
489 within the discovery document. (deprecated)
490 future: string, discovery document with future capabilities (deprecated).
491 http: httplib2.Http, An instance of httplib2.Http or something that acts
492 like it that HTTP requests will be made through.
493 developerKey: string, Key for controlling API usage, generated
494 from the API Console.
495 model: Model class instance that serializes and de-serializes requests and
496 responses.
497 requestBuilder: Takes an http request and packages it up to be executed.
498 credentials: oauth2client.Credentials or
499 google.auth.credentials.Credentials, credentials to be used for
500 authentication.
501 client_options: Mapping object or google.api_core.client_options, client
502 options to set user options on the client.
503 (1) The API endpoint should be set through client_options. If API endpoint
504 is not set, `GOOGLE_API_USE_MTLS_ENDPOINT` environment variable can be used
505 to control which endpoint to use.
506 (2) client_cert_source is not supported, client cert should be provided using
507 client_encrypted_cert_source instead. In order to use the provided client
508 cert, `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be
509 set to `true`.
510 More details on the environment variables are here:
511 https://google.aip.dev/auth/4114
512 adc_cert_path: str, client certificate file path to save the application
513 default client certificate for mTLS. This field is required if you want to
514 use the default client certificate. `GOOGLE_API_USE_CLIENT_CERTIFICATE`
515 environment variable must be set to `true` in order to use this field,
516 otherwise this field doesn't nothing.
517 More details on the environment variables are here:
518 https://google.aip.dev/auth/4114
519 adc_key_path: str, client encrypted private key file path to save the
520 application default client encrypted private key for mTLS. This field is
521 required if you want to use the default client certificate.
522 `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be set to
523 `true` in order to use this field, otherwise this field doesn't nothing.
524 More details on the environment variables are here:
525 https://google.aip.dev/auth/4114
526 always_use_jwt_access: Boolean, whether always use self signed JWT for service
527 account credentials. This only applies to
528 google.oauth2.service_account.Credentials.
529
530 Returns:
531 A Resource object with methods for interacting with the service.
532
533 Raises:
534 google.auth.exceptions.MutualTLSChannelError: if there are any problems
535 setting up mutual TLS channel.
536 """
537
538 if client_options is None:
539 client_options = google.api_core.client_options.ClientOptions()
540 if isinstance(client_options, collections.abc.Mapping):
541 client_options = google.api_core.client_options.from_dict(client_options)
542
543 if http is not None:
544 # if http is passed, the user cannot provide credentials
545 banned_options = [
546 (credentials, "credentials"),
547 (client_options.credentials_file, "client_options.credentials_file"),
548 ]
549 for option, name in banned_options:
550 if option is not None:
551 raise ValueError(
552 "Arguments http and {} are mutually exclusive".format(name)
553 )
554
555 if isinstance(service, str):
556 service = json.loads(service)
557 elif isinstance(service, bytes):
558 service = json.loads(service.decode("utf-8"))
559
560 if "rootUrl" not in service and isinstance(http, (HttpMock, HttpMockSequence)):
561 logger.error(
562 "You are using HttpMock or HttpMockSequence without"
563 + "having the service discovery doc in cache. Try calling "
564 + "build() without mocking once first to populate the "
565 + "cache."
566 )
567 raise InvalidJsonError()
568
569 # If an API Endpoint is provided on client options, use that as the base URL
570 base = urllib.parse.urljoin(service["rootUrl"], service["servicePath"])
571 universe_domain = None
572 if HAS_UNIVERSE:
573 universe_domain_env = os.getenv(GOOGLE_CLOUD_UNIVERSE_DOMAIN, None)
574 universe_domain = universe.determine_domain(
575 client_options.universe_domain, universe_domain_env
576 )
577 base = base.replace(universe.DEFAULT_UNIVERSE, universe_domain)
578 else:
579 client_universe = getattr(client_options, "universe_domain", None)
580 if client_universe:
581 raise APICoreVersionError
582
583 audience_for_self_signed_jwt = base
584 if client_options.api_endpoint:
585 base = client_options.api_endpoint
586
587 schema = Schemas(service)
588
589 # If the http client is not specified, then we must construct an http client
590 # to make requests. If the service has scopes, then we also need to setup
591 # authentication.
592 if http is None:
593 # Does the service require scopes?
594 scopes = list(
595 service.get("auth", {}).get("oauth2", {}).get("scopes", {}).keys()
596 )
597
598 # If so, then the we need to setup authentication if no developerKey is
599 # specified.
600 if scopes and not developerKey:
601 # Make sure the user didn't pass multiple credentials
602 if client_options.credentials_file and credentials:
603 raise google.api_core.exceptions.DuplicateCredentialArgs(
604 "client_options.credentials_file and credentials are mutually exclusive."
605 )
606 # Check for credentials file via client options
607 if client_options.credentials_file:
608 credentials = _auth.credentials_from_file(
609 client_options.credentials_file,
610 scopes=client_options.scopes,
611 quota_project_id=client_options.quota_project_id,
612 )
613 # If the user didn't pass in credentials, attempt to acquire application
614 # default credentials.
615 if credentials is None:
616 credentials = _auth.default_credentials(
617 scopes=client_options.scopes,
618 quota_project_id=client_options.quota_project_id,
619 )
620
621 # Check google-api-core >= 2.18.0 if credentials' universe != "googleapis.com".
622 _check_api_core_compatible_with_credentials_universe(credentials)
623
624 # The credentials need to be scoped.
625 # If the user provided scopes via client_options don't override them
626 if not client_options.scopes:
627 credentials = _auth.with_scopes(credentials, scopes)
628
629 # For google-auth service account credentials, enable self signed JWT if
630 # always_use_jwt_access is true.
631 if (
632 credentials
633 and isinstance(credentials, service_account.Credentials)
634 and always_use_jwt_access
635 and hasattr(service_account.Credentials, "with_always_use_jwt_access")
636 ):
637 credentials = credentials.with_always_use_jwt_access(always_use_jwt_access)
638 credentials._create_self_signed_jwt(audience_for_self_signed_jwt)
639
640 # If credentials are provided, create an authorized http instance;
641 # otherwise, skip authentication.
642 if credentials:
643 http = _auth.authorized_http(credentials)
644
645 # If the service doesn't require scopes then there is no need for
646 # authentication.
647 else:
648 http = build_http()
649
650 # Obtain client cert and create mTLS http channel if cert exists.
651 client_cert_to_use = None
652 if hasattr(mtls, "should_use_client_cert"):
653 use_client_cert = mtls.should_use_client_cert()
654 else:
655 # if unsupported, fallback to reading from env var
656 use_client_cert_str = os.getenv(
657 "GOOGLE_API_USE_CLIENT_CERTIFICATE", "false"
658 ).lower()
659 use_client_cert = use_client_cert_str == "true"
660 if use_client_cert_str not in ("true", "false"):
661 raise MutualTLSChannelError(
662 "Unsupported GOOGLE_API_USE_CLIENT_CERTIFICATE value. Accepted values: true, false"
663 )
664 if client_options and client_options.client_cert_source:
665 raise MutualTLSChannelError(
666 "ClientOptions.client_cert_source is not supported, please use ClientOptions.client_encrypted_cert_source."
667 )
668 if use_client_cert:
669 if (
670 client_options
671 and hasattr(client_options, "client_encrypted_cert_source")
672 and client_options.client_encrypted_cert_source
673 ):
674 client_cert_to_use = client_options.client_encrypted_cert_source
675 elif (
676 adc_cert_path and adc_key_path and mtls.has_default_client_cert_source()
677 ):
678 client_cert_to_use = mtls.default_client_encrypted_cert_source(
679 adc_cert_path, adc_key_path
680 )
681 if client_cert_to_use:
682 cert_path, key_path, passphrase = client_cert_to_use()
683
684 # The http object we built could be google_auth_httplib2.AuthorizedHttp
685 # or httplib2.Http. In the first case we need to extract the wrapped
686 # httplib2.Http object from google_auth_httplib2.AuthorizedHttp.
687 http_channel = (
688 http.http
689 if google_auth_httplib2
690 and isinstance(http, google_auth_httplib2.AuthorizedHttp)
691 else http
692 )
693 http_channel.add_certificate(key_path, cert_path, "", passphrase)
694
695 # If user doesn't provide api endpoint via client options, decide which
696 # api endpoint to use.
697 if "mtlsRootUrl" in service and (
698 not client_options or not client_options.api_endpoint
699 ):
700 mtls_endpoint = urllib.parse.urljoin(
701 service["mtlsRootUrl"], service["servicePath"]
702 )
703 use_mtls_endpoint = os.getenv(GOOGLE_API_USE_MTLS_ENDPOINT, "auto")
704
705 if not use_mtls_endpoint in ("never", "auto", "always"):
706 raise MutualTLSChannelError(
707 "Unsupported GOOGLE_API_USE_MTLS_ENDPOINT value. Accepted values: never, auto, always"
708 )
709
710 # Switch to mTLS endpoint, if environment variable is "always", or
711 # environment varibable is "auto" and client cert exists.
712 if use_mtls_endpoint == "always" or (
713 use_mtls_endpoint == "auto" and client_cert_to_use
714 ):
715 if HAS_UNIVERSE and universe_domain != universe.DEFAULT_UNIVERSE:
716 raise MutualTLSChannelError(
717 f"mTLS is not supported in any universe other than {universe.DEFAULT_UNIVERSE}."
718 )
719 base = mtls_endpoint
720 else:
721 # Check google-api-core >= 2.18.0 if credentials' universe != "googleapis.com".
722 http_credentials = getattr(http, "credentials", None)
723 _check_api_core_compatible_with_credentials_universe(http_credentials)
724
725 if model is None:
726 features = service.get("features", [])
727 model = JsonModel("dataWrapper" in features)
728
729 return Resource(
730 http=http,
731 baseUrl=base,
732 model=model,
733 developerKey=developerKey,
734 requestBuilder=requestBuilder,
735 resourceDesc=service,
736 rootDesc=service,
737 schema=schema,
738 universe_domain=universe_domain,
739 )
740
741
742def _cast(value, schema_type):
743 """Convert value to a string based on JSON Schema type.
744
745 See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
746 JSON Schema.
747
748 Args:
749 value: any, the value to convert
750 schema_type: string, the type that value should be interpreted as
751
752 Returns:
753 A string representation of 'value' based on the schema_type.
754 """
755 if schema_type == "string":
756 if type(value) == type("") or type(value) == type(""):
757 return value
758 else:
759 return str(value)
760 elif schema_type == "integer":
761 return str(int(value))
762 elif schema_type == "number":
763 return str(float(value))
764 elif schema_type == "boolean":
765 return str(bool(value)).lower()
766 else:
767 if type(value) == type("") or type(value) == type(""):
768 return value
769 else:
770 return str(value)
771
772
773def _media_size_to_long(maxSize):
774 """Convert a string media size, such as 10GB or 3TB into an integer.
775
776 Args:
777 maxSize: string, size as a string, such as 2MB or 7GB.
778
779 Returns:
780 The size as an integer value.
781 """
782 if len(maxSize) < 2:
783 return 0
784 units = maxSize[-2:].upper()
785 bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
786 if bit_shift is not None:
787 return int(maxSize[:-2]) << bit_shift
788 else:
789 return int(maxSize)
790
791
792def _media_path_url_from_info(root_desc, path_url):
793 """Creates an absolute media path URL.
794
795 Constructed using the API root URI and service path from the discovery
796 document and the relative path for the API method.
797
798 Args:
799 root_desc: Dictionary; the entire original deserialized discovery document.
800 path_url: String; the relative URL for the API method. Relative to the API
801 root, which is specified in the discovery document.
802
803 Returns:
804 String; the absolute URI for media upload for the API method.
805 """
806 return "%(root)supload/%(service_path)s%(path)s" % {
807 "root": root_desc["rootUrl"],
808 "service_path": root_desc["servicePath"],
809 "path": path_url,
810 }
811
812
813def _fix_up_parameters(method_desc, root_desc, http_method, schema):
814 """Updates parameters of an API method with values specific to this library.
815
816 Specifically, adds whatever global parameters are specified by the API to the
817 parameters for the individual method. Also adds parameters which don't
818 appear in the discovery document, but are available to all discovery based
819 APIs (these are listed in STACK_QUERY_PARAMETERS).
820
821 SIDE EFFECTS: This updates the parameters dictionary object in the method
822 description.
823
824 Args:
825 method_desc: Dictionary with metadata describing an API method. Value comes
826 from the dictionary of methods stored in the 'methods' key in the
827 deserialized discovery document.
828 root_desc: Dictionary; the entire original deserialized discovery document.
829 http_method: String; the HTTP method used to call the API method described
830 in method_desc.
831 schema: Object, mapping of schema names to schema descriptions.
832
833 Returns:
834 The updated Dictionary stored in the 'parameters' key of the method
835 description dictionary.
836 """
837 parameters = method_desc.setdefault("parameters", {})
838
839 # Add in the parameters common to all methods.
840 for name, description in root_desc.get("parameters", {}).items():
841 parameters[name] = description
842
843 # Add in undocumented query parameters.
844 for name in STACK_QUERY_PARAMETERS:
845 parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
846
847 # Add 'body' (our own reserved word) to parameters if the method supports
848 # a request payload.
849 if http_method in HTTP_PAYLOAD_METHODS and "request" in method_desc:
850 body = BODY_PARAMETER_DEFAULT_VALUE.copy()
851 body.update(method_desc["request"])
852 parameters["body"] = body
853
854 return parameters
855
856
857def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
858 """Adds 'media_body' and 'media_mime_type' parameters if supported by method.
859
860 SIDE EFFECTS: If there is a 'mediaUpload' in the method description, adds
861 'media_upload' key to parameters.
862
863 Args:
864 method_desc: Dictionary with metadata describing an API method. Value comes
865 from the dictionary of methods stored in the 'methods' key in the
866 deserialized discovery document.
867 root_desc: Dictionary; the entire original deserialized discovery document.
868 path_url: String; the relative URL for the API method. Relative to the API
869 root, which is specified in the discovery document.
870 parameters: A dictionary describing method parameters for method described
871 in method_desc.
872
873 Returns:
874 Triple (accept, max_size, media_path_url) where:
875 - accept is a list of strings representing what content types are
876 accepted for media upload. Defaults to empty list if not in the
877 discovery document.
878 - max_size is a long representing the max size in bytes allowed for a
879 media upload. Defaults to 0L if not in the discovery document.
880 - media_path_url is a String; the absolute URI for media upload for the
881 API method. Constructed using the API root URI and service path from
882 the discovery document and the relative path for the API method. If
883 media upload is not supported, this is None.
884 """
885 media_upload = method_desc.get("mediaUpload", {})
886 accept = media_upload.get("accept", [])
887 max_size = _media_size_to_long(media_upload.get("maxSize", ""))
888 media_path_url = None
889
890 if media_upload:
891 media_path_url = _media_path_url_from_info(root_desc, path_url)
892 parameters["media_body"] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
893 parameters["media_mime_type"] = MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE.copy()
894
895 return accept, max_size, media_path_url
896
897
898def _fix_up_method_description(method_desc, root_desc, schema):
899 """Updates a method description in a discovery document.
900
901 SIDE EFFECTS: Changes the parameters dictionary in the method description with
902 extra parameters which are used locally.
903
904 Args:
905 method_desc: Dictionary with metadata describing an API method. Value comes
906 from the dictionary of methods stored in the 'methods' key in the
907 deserialized discovery document.
908 root_desc: Dictionary; the entire original deserialized discovery document.
909 schema: Object, mapping of schema names to schema descriptions.
910
911 Returns:
912 Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
913 where:
914 - path_url is a String; the relative URL for the API method. Relative to
915 the API root, which is specified in the discovery document.
916 - http_method is a String; the HTTP method used to call the API method
917 described in the method description.
918 - method_id is a String; the name of the RPC method associated with the
919 API method, and is in the method description in the 'id' key.
920 - accept is a list of strings representing what content types are
921 accepted for media upload. Defaults to empty list if not in the
922 discovery document.
923 - max_size is a long representing the max size in bytes allowed for a
924 media upload. Defaults to 0L if not in the discovery document.
925 - media_path_url is a String; the absolute URI for media upload for the
926 API method. Constructed using the API root URI and service path from
927 the discovery document and the relative path for the API method. If
928 media upload is not supported, this is None.
929 """
930 path_url = method_desc["path"]
931 http_method = method_desc["httpMethod"]
932 method_id = method_desc["id"]
933
934 parameters = _fix_up_parameters(method_desc, root_desc, http_method, schema)
935 # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
936 # 'parameters' key and needs to know if there is a 'body' parameter because it
937 # also sets a 'media_body' parameter.
938 accept, max_size, media_path_url = _fix_up_media_upload(
939 method_desc, root_desc, path_url, parameters
940 )
941
942 return path_url, http_method, method_id, accept, max_size, media_path_url
943
944
945def _fix_up_media_path_base_url(media_path_url, base_url):
946 """
947 Update the media upload base url if its netloc doesn't match base url netloc.
948
949 This can happen in case the base url was overridden by
950 client_options.api_endpoint.
951
952 Args:
953 media_path_url: String; the absolute URI for media upload.
954 base_url: string, base URL for the API. All requests are relative to this URI.
955
956 Returns:
957 String; the absolute URI for media upload.
958 """
959 parsed_media_url = urllib.parse.urlparse(media_path_url)
960 parsed_base_url = urllib.parse.urlparse(base_url)
961 if parsed_media_url.netloc == parsed_base_url.netloc:
962 return media_path_url
963 return urllib.parse.urlunparse(
964 parsed_media_url._replace(netloc=parsed_base_url.netloc)
965 )
966
967
968def _urljoin(base, url):
969 """Custom urljoin replacement supporting : before / in url."""
970 # In general, it's unsafe to simply join base and url. However, for
971 # the case of discovery documents, we know:
972 # * base will never contain params, query, or fragment
973 # * url will never contain a scheme or net_loc.
974 # In general, this means we can safely join on /; we just need to
975 # ensure we end up with precisely one / joining base and url. The
976 # exception here is the case of media uploads, where url will be an
977 # absolute url.
978 if url.startswith("http://") or url.startswith("https://"):
979 return urllib.parse.urljoin(base, url)
980 new_base = base if base.endswith("/") else base + "/"
981 new_url = url[1:] if url.startswith("/") else url
982 return new_base + new_url
983
984
985# TODO(dhermes): Convert this class to ResourceMethod and make it callable
986class ResourceMethodParameters(object):
987 """Represents the parameters associated with a method.
988
989 Attributes:
990 argmap: Map from method parameter name (string) to query parameter name
991 (string).
992 required_params: List of required parameters (represented by parameter
993 name as string).
994 repeated_params: List of repeated parameters (represented by parameter
995 name as string).
996 pattern_params: Map from method parameter name (string) to regular
997 expression (as a string). If the pattern is set for a parameter, the
998 value for that parameter must match the regular expression.
999 query_params: List of parameters (represented by parameter name as string)
1000 that will be used in the query string.
1001 path_params: Set of parameters (represented by parameter name as string)
1002 that will be used in the base URL path.
1003 param_types: Map from method parameter name (string) to parameter type. Type
1004 can be any valid JSON schema type; valid values are 'any', 'array',
1005 'boolean', 'integer', 'number', 'object', or 'string'. Reference:
1006 http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
1007 enum_params: Map from method parameter name (string) to list of strings,
1008 where each list of strings is the list of acceptable enum values.
1009 """
1010
1011 def __init__(self, method_desc):
1012 """Constructor for ResourceMethodParameters.
1013
1014 Sets default values and defers to set_parameters to populate.
1015
1016 Args:
1017 method_desc: Dictionary with metadata describing an API method. Value
1018 comes from the dictionary of methods stored in the 'methods' key in
1019 the deserialized discovery document.
1020 """
1021 self.argmap = {}
1022 self.required_params = []
1023 self.repeated_params = []
1024 self.pattern_params = {}
1025 self.query_params = []
1026 # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
1027 # parsing is gotten rid of.
1028 self.path_params = set()
1029 self.param_types = {}
1030 self.enum_params = {}
1031
1032 self.set_parameters(method_desc)
1033
1034 def set_parameters(self, method_desc):
1035 """Populates maps and lists based on method description.
1036
1037 Iterates through each parameter for the method and parses the values from
1038 the parameter dictionary.
1039
1040 Args:
1041 method_desc: Dictionary with metadata describing an API method. Value
1042 comes from the dictionary of methods stored in the 'methods' key in
1043 the deserialized discovery document.
1044 """
1045 parameters = method_desc.get("parameters", {})
1046 sorted_parameters = OrderedDict(sorted(parameters.items()))
1047 for arg, desc in sorted_parameters.items():
1048 param = key2param(arg)
1049 self.argmap[param] = arg
1050
1051 if desc.get("pattern"):
1052 self.pattern_params[param] = desc["pattern"]
1053 if desc.get("enum"):
1054 self.enum_params[param] = desc["enum"]
1055 if desc.get("required"):
1056 self.required_params.append(param)
1057 if desc.get("repeated"):
1058 self.repeated_params.append(param)
1059 if desc.get("location") == "query":
1060 self.query_params.append(param)
1061 if desc.get("location") == "path":
1062 self.path_params.add(param)
1063 self.param_types[param] = desc.get("type", "string")
1064
1065 # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
1066 # should have all path parameters already marked with
1067 # 'location: path'.
1068 for match in URITEMPLATE.finditer(method_desc["path"]):
1069 for namematch in VARNAME.finditer(match.group(0)):
1070 name = key2param(namematch.group(0))
1071 self.path_params.add(name)
1072 if name in self.query_params:
1073 self.query_params.remove(name)
1074
1075
1076def createMethod(methodName, methodDesc, rootDesc, schema):
1077 """Creates a method for attaching to a Resource.
1078
1079 Args:
1080 methodName: string, name of the method to use.
1081 methodDesc: object, fragment of deserialized discovery document that
1082 describes the method.
1083 rootDesc: object, the entire deserialized discovery document.
1084 schema: object, mapping of schema names to schema descriptions.
1085 """
1086 methodName = fix_method_name(methodName)
1087 (
1088 pathUrl,
1089 httpMethod,
1090 methodId,
1091 accept,
1092 maxSize,
1093 mediaPathUrl,
1094 ) = _fix_up_method_description(methodDesc, rootDesc, schema)
1095
1096 parameters = ResourceMethodParameters(methodDesc)
1097
1098 def method(self, **kwargs):
1099 # Don't bother with doc string, it will be over-written by createMethod.
1100
1101 # Validate credentials for the configured universe.
1102 self._validate_credentials()
1103
1104 for name in kwargs:
1105 if name not in parameters.argmap:
1106 raise TypeError("Got an unexpected keyword argument {}".format(name))
1107
1108 # Remove args that have a value of None.
1109 keys = list(kwargs.keys())
1110 for name in keys:
1111 if kwargs[name] is None:
1112 del kwargs[name]
1113
1114 for name in parameters.required_params:
1115 if name not in kwargs:
1116 # temporary workaround for non-paging methods incorrectly requiring
1117 # page token parameter (cf. drive.changes.watch vs. drive.changes.list)
1118 if name not in _PAGE_TOKEN_NAMES or _findPageTokenName(
1119 _methodProperties(methodDesc, schema, "response")
1120 ):
1121 raise TypeError('Missing required parameter "%s"' % name)
1122
1123 for name, regex in parameters.pattern_params.items():
1124 if name in kwargs:
1125 if isinstance(kwargs[name], str):
1126 pvalues = [kwargs[name]]
1127 else:
1128 pvalues = kwargs[name]
1129 for pvalue in pvalues:
1130 if re.match(regex, pvalue) is None:
1131 raise TypeError(
1132 'Parameter "%s" value "%s" does not match the pattern "%s"'
1133 % (name, pvalue, regex)
1134 )
1135
1136 for name, enums in parameters.enum_params.items():
1137 if name in kwargs:
1138 # We need to handle the case of a repeated enum
1139 # name differently, since we want to handle both
1140 # arg='value' and arg=['value1', 'value2']
1141 if name in parameters.repeated_params and not isinstance(
1142 kwargs[name], str
1143 ):
1144 values = kwargs[name]
1145 else:
1146 values = [kwargs[name]]
1147 for value in values:
1148 if value not in enums:
1149 raise TypeError(
1150 'Parameter "%s" value "%s" is not an allowed value in "%s"'
1151 % (name, value, str(enums))
1152 )
1153
1154 actual_query_params = {}
1155 actual_path_params = {}
1156 for key, value in kwargs.items():
1157 to_type = parameters.param_types.get(key, "string")
1158 # For repeated parameters we cast each member of the list.
1159 if key in parameters.repeated_params and type(value) == type([]):
1160 cast_value = [_cast(x, to_type) for x in value]
1161 else:
1162 cast_value = _cast(value, to_type)
1163 if key in parameters.query_params:
1164 actual_query_params[parameters.argmap[key]] = cast_value
1165 if key in parameters.path_params:
1166 actual_path_params[parameters.argmap[key]] = cast_value
1167 body_value = kwargs.get("body", None)
1168 media_filename = kwargs.get("media_body", None)
1169 media_mime_type = kwargs.get("media_mime_type", None)
1170
1171 if self._developerKey:
1172 actual_query_params["key"] = self._developerKey
1173
1174 model = self._model
1175 if methodName.endswith("_media"):
1176 model = MediaModel()
1177 elif "response" not in methodDesc:
1178 model = RawModel()
1179
1180 api_version = methodDesc.get("apiVersion", None)
1181
1182 headers = {}
1183 headers, params, query, body = model.request(
1184 headers, actual_path_params, actual_query_params, body_value, api_version
1185 )
1186
1187 expanded_url = uritemplate.expand(pathUrl, params)
1188 url = _urljoin(self._baseUrl, expanded_url + query)
1189
1190 resumable = None
1191 multipart_boundary = ""
1192
1193 if media_filename:
1194 # Ensure we end up with a valid MediaUpload object.
1195 if isinstance(media_filename, str):
1196 if media_mime_type is None:
1197 logger.warning(
1198 "media_mime_type argument not specified: trying to auto-detect for %s",
1199 media_filename,
1200 )
1201 media_mime_type, _ = mimetypes.guess_type(media_filename)
1202 if media_mime_type is None:
1203 raise UnknownFileType(media_filename)
1204 if not mimeparse.best_match([media_mime_type], ",".join(accept)):
1205 raise UnacceptableMimeTypeError(media_mime_type)
1206 media_upload = MediaFileUpload(media_filename, mimetype=media_mime_type)
1207 elif isinstance(media_filename, MediaUpload):
1208 media_upload = media_filename
1209 else:
1210 raise TypeError("media_filename must be str or MediaUpload.")
1211
1212 # Check the maxSize
1213 if media_upload.size() is not None and media_upload.size() > maxSize > 0:
1214 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
1215
1216 # Use the media path uri for media uploads
1217 expanded_url = uritemplate.expand(mediaPathUrl, params)
1218 url = _urljoin(self._baseUrl, expanded_url + query)
1219 url = _fix_up_media_path_base_url(url, self._baseUrl)
1220 if media_upload.resumable():
1221 url = _add_query_parameter(url, "uploadType", "resumable")
1222
1223 if media_upload.resumable():
1224 # This is all we need to do for resumable, if the body exists it gets
1225 # sent in the first request, otherwise an empty body is sent.
1226 resumable = media_upload
1227 else:
1228 # A non-resumable upload
1229 if body is None:
1230 # This is a simple media upload
1231 headers["content-type"] = media_upload.mimetype()
1232 body = media_upload.getbytes(0, media_upload.size())
1233 url = _add_query_parameter(url, "uploadType", "media")
1234 else:
1235 # This is a multipart/related upload.
1236 msgRoot = MIMEMultipart("related")
1237 # msgRoot should not write out it's own headers
1238 setattr(msgRoot, "_write_headers", lambda self: None)
1239
1240 # attach the body as one part
1241 msg = MIMENonMultipart(*headers["content-type"].split("/"))
1242 msg.set_payload(body)
1243 msgRoot.attach(msg)
1244
1245 # attach the media as the second part
1246 msg = MIMENonMultipart(*media_upload.mimetype().split("/"))
1247 msg["Content-Transfer-Encoding"] = "binary"
1248
1249 payload = media_upload.getbytes(0, media_upload.size())
1250 msg.set_payload(payload)
1251 msgRoot.attach(msg)
1252 # encode the body: note that we can't use `as_string`, because
1253 # it plays games with `From ` lines.
1254 fp = io.BytesIO()
1255 g = _BytesGenerator(fp, mangle_from_=False)
1256 g.flatten(msgRoot, unixfrom=False)
1257 body = fp.getvalue()
1258
1259 multipart_boundary = msgRoot.get_boundary()
1260 headers["content-type"] = (
1261 "multipart/related; " 'boundary="%s"'
1262 ) % multipart_boundary
1263 url = _add_query_parameter(url, "uploadType", "multipart")
1264
1265 logger.debug("URL being requested: %s %s" % (httpMethod, url))
1266 return self._requestBuilder(
1267 self._http,
1268 model.response,
1269 url,
1270 method=httpMethod,
1271 body=body,
1272 headers=headers,
1273 methodId=methodId,
1274 resumable=resumable,
1275 )
1276
1277 docs = [methodDesc.get("description", DEFAULT_METHOD_DOC), "\n\n"]
1278 if len(parameters.argmap) > 0:
1279 docs.append("Args:\n")
1280
1281 # Skip undocumented params and params common to all methods.
1282 skip_parameters = list(rootDesc.get("parameters", {}).keys())
1283 skip_parameters.extend(STACK_QUERY_PARAMETERS)
1284
1285 all_args = list(parameters.argmap.keys())
1286 args_ordered = [key2param(s) for s in methodDesc.get("parameterOrder", [])]
1287
1288 # Move body to the front of the line.
1289 if "body" in all_args:
1290 args_ordered.append("body")
1291
1292 for name in sorted(all_args):
1293 if name not in args_ordered:
1294 args_ordered.append(name)
1295
1296 for arg in args_ordered:
1297 if arg in skip_parameters:
1298 continue
1299
1300 repeated = ""
1301 if arg in parameters.repeated_params:
1302 repeated = " (repeated)"
1303 required = ""
1304 if arg in parameters.required_params:
1305 required = " (required)"
1306 paramdesc = methodDesc["parameters"][parameters.argmap[arg]]
1307 paramdoc = paramdesc.get("description", "A parameter")
1308 if "$ref" in paramdesc:
1309 docs.append(
1310 (" %s: object, %s%s%s\n The object takes the form of:\n\n%s\n\n")
1311 % (
1312 arg,
1313 paramdoc,
1314 required,
1315 repeated,
1316 schema.prettyPrintByName(paramdesc["$ref"]),
1317 )
1318 )
1319 else:
1320 paramtype = paramdesc.get("type", "string")
1321 docs.append(
1322 " %s: %s, %s%s%s\n" % (arg, paramtype, paramdoc, required, repeated)
1323 )
1324 enum = paramdesc.get("enum", [])
1325 enumDesc = paramdesc.get("enumDescriptions", [])
1326 if enum and enumDesc:
1327 docs.append(" Allowed values\n")
1328 for (name, desc) in zip(enum, enumDesc):
1329 docs.append(" %s - %s\n" % (name, desc))
1330 if "response" in methodDesc:
1331 if methodName.endswith("_media"):
1332 docs.append("\nReturns:\n The media object as a string.\n\n ")
1333 else:
1334 docs.append("\nReturns:\n An object of the form:\n\n ")
1335 docs.append(schema.prettyPrintSchema(methodDesc["response"]))
1336
1337 setattr(method, "__doc__", "".join(docs))
1338 return (methodName, method)
1339
1340
1341def createNextMethod(
1342 methodName,
1343 pageTokenName="pageToken",
1344 nextPageTokenName="nextPageToken",
1345 isPageTokenParameter=True,
1346):
1347 """Creates any _next methods for attaching to a Resource.
1348
1349 The _next methods allow for easy iteration through list() responses.
1350
1351 Args:
1352 methodName: string, name of the method to use.
1353 pageTokenName: string, name of request page token field.
1354 nextPageTokenName: string, name of response page token field.
1355 isPageTokenParameter: Boolean, True if request page token is a query
1356 parameter, False if request page token is a field of the request body.
1357 """
1358 methodName = fix_method_name(methodName)
1359
1360 def methodNext(self, previous_request, previous_response):
1361 """Retrieves the next page of results.
1362
1363 Args:
1364 previous_request: The request for the previous page. (required)
1365 previous_response: The response from the request for the previous page. (required)
1366
1367 Returns:
1368 A request object that you can call 'execute()' on to request the next
1369 page. Returns None if there are no more items in the collection.
1370 """
1371 # Retrieve nextPageToken from previous_response
1372 # Use as pageToken in previous_request to create new request.
1373
1374 nextPageToken = previous_response.get(nextPageTokenName, None)
1375 if not nextPageToken:
1376 return None
1377
1378 request = copy.copy(previous_request)
1379
1380 if isPageTokenParameter:
1381 # Replace pageToken value in URI
1382 request.uri = _add_query_parameter(
1383 request.uri, pageTokenName, nextPageToken
1384 )
1385 logger.debug("Next page request URL: %s %s" % (methodName, request.uri))
1386 else:
1387 # Replace pageToken value in request body
1388 model = self._model
1389 body = model.deserialize(request.body)
1390 body[pageTokenName] = nextPageToken
1391 request.body = model.serialize(body)
1392 request.body_size = len(request.body)
1393 if "content-length" in request.headers:
1394 del request.headers["content-length"]
1395 logger.debug("Next page request body: %s %s" % (methodName, body))
1396
1397 return request
1398
1399 return (methodName, methodNext)
1400
1401
1402class Resource(object):
1403 """A class for interacting with a resource."""
1404
1405 def __init__(
1406 self,
1407 http,
1408 baseUrl,
1409 model,
1410 requestBuilder,
1411 developerKey,
1412 resourceDesc,
1413 rootDesc,
1414 schema,
1415 universe_domain=universe.DEFAULT_UNIVERSE if HAS_UNIVERSE else "",
1416 ):
1417 """Build a Resource from the API description.
1418
1419 Args:
1420 http: httplib2.Http, Object to make http requests with.
1421 baseUrl: string, base URL for the API. All requests are relative to this
1422 URI.
1423 model: googleapiclient.Model, converts to and from the wire format.
1424 requestBuilder: class or callable that instantiates an
1425 googleapiclient.HttpRequest object.
1426 developerKey: string, key obtained from
1427 https://code.google.com/apis/console
1428 resourceDesc: object, section of deserialized discovery document that
1429 describes a resource. Note that the top level discovery document
1430 is considered a resource.
1431 rootDesc: object, the entire deserialized discovery document.
1432 schema: object, mapping of schema names to schema descriptions.
1433 universe_domain: string, the universe for the API. The default universe
1434 is "googleapis.com".
1435 """
1436 self._dynamic_attrs = []
1437
1438 self._http = http
1439 self._baseUrl = baseUrl
1440 self._model = model
1441 self._developerKey = developerKey
1442 self._requestBuilder = requestBuilder
1443 self._resourceDesc = resourceDesc
1444 self._rootDesc = rootDesc
1445 self._schema = schema
1446 self._universe_domain = universe_domain
1447 self._credentials_validated = False
1448
1449 self._set_service_methods()
1450
1451 def _set_dynamic_attr(self, attr_name, value):
1452 """Sets an instance attribute and tracks it in a list of dynamic attributes.
1453
1454 Args:
1455 attr_name: string; The name of the attribute to be set
1456 value: The value being set on the object and tracked in the dynamic cache.
1457 """
1458 self._dynamic_attrs.append(attr_name)
1459 self.__dict__[attr_name] = value
1460
1461 def __getstate__(self):
1462 """Trim the state down to something that can be pickled.
1463
1464 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1465 will be wiped and restored on pickle serialization.
1466 """
1467 state_dict = copy.copy(self.__dict__)
1468 for dynamic_attr in self._dynamic_attrs:
1469 del state_dict[dynamic_attr]
1470 del state_dict["_dynamic_attrs"]
1471 return state_dict
1472
1473 def __setstate__(self, state):
1474 """Reconstitute the state of the object from being pickled.
1475
1476 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1477 will be wiped and restored on pickle serialization.
1478 """
1479 self.__dict__.update(state)
1480 self._dynamic_attrs = []
1481 self._set_service_methods()
1482
1483 def __enter__(self):
1484 return self
1485
1486 def __exit__(self, exc_type, exc, exc_tb):
1487 self.close()
1488
1489 def close(self):
1490 """Close httplib2 connections."""
1491 # httplib2 leaves sockets open by default.
1492 # Cleanup using the `close` method.
1493 # https://github.com/httplib2/httplib2/issues/148
1494 self._http.close()
1495
1496 def _set_service_methods(self):
1497 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
1498 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
1499 self._add_next_methods(self._resourceDesc, self._schema)
1500
1501 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
1502 # If this is the root Resource, add a new_batch_http_request() method.
1503 if resourceDesc == rootDesc:
1504 batch_uri = "%s%s" % (
1505 rootDesc["rootUrl"],
1506 rootDesc.get("batchPath", "batch"),
1507 )
1508
1509 def new_batch_http_request(callback=None):
1510 """Create a BatchHttpRequest object based on the discovery document.
1511
1512 Args:
1513 callback: callable, A callback to be called for each response, of the
1514 form callback(id, response, exception). The first parameter is the
1515 request id, and the second is the deserialized response object. The
1516 third is an apiclient.errors.HttpError exception object if an HTTP
1517 error occurred while processing the request, or None if no error
1518 occurred.
1519
1520 Returns:
1521 A BatchHttpRequest object based on the discovery document.
1522 """
1523 return BatchHttpRequest(callback=callback, batch_uri=batch_uri)
1524
1525 self._set_dynamic_attr("new_batch_http_request", new_batch_http_request)
1526
1527 # Add basic methods to Resource
1528 if "methods" in resourceDesc:
1529 for methodName, methodDesc in resourceDesc["methods"].items():
1530 fixedMethodName, method = createMethod(
1531 methodName, methodDesc, rootDesc, schema
1532 )
1533 self._set_dynamic_attr(
1534 fixedMethodName, method.__get__(self, self.__class__)
1535 )
1536 # Add in _media methods. The functionality of the attached method will
1537 # change when it sees that the method name ends in _media.
1538 if methodDesc.get("supportsMediaDownload", False):
1539 fixedMethodName, method = createMethod(
1540 methodName + "_media", methodDesc, rootDesc, schema
1541 )
1542 self._set_dynamic_attr(
1543 fixedMethodName, method.__get__(self, self.__class__)
1544 )
1545
1546 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
1547 # Add in nested resources
1548 if "resources" in resourceDesc:
1549
1550 def createResourceMethod(methodName, methodDesc):
1551 """Create a method on the Resource to access a nested Resource.
1552
1553 Args:
1554 methodName: string, name of the method to use.
1555 methodDesc: object, fragment of deserialized discovery document that
1556 describes the method.
1557 """
1558 methodName = fix_method_name(methodName)
1559
1560 def methodResource(self):
1561 return Resource(
1562 http=self._http,
1563 baseUrl=self._baseUrl,
1564 model=self._model,
1565 developerKey=self._developerKey,
1566 requestBuilder=self._requestBuilder,
1567 resourceDesc=methodDesc,
1568 rootDesc=rootDesc,
1569 schema=schema,
1570 universe_domain=self._universe_domain,
1571 )
1572
1573 setattr(methodResource, "__doc__", "A collection resource.")
1574 setattr(methodResource, "__is_resource__", True)
1575
1576 return (methodName, methodResource)
1577
1578 for methodName, methodDesc in resourceDesc["resources"].items():
1579 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
1580 self._set_dynamic_attr(
1581 fixedMethodName, method.__get__(self, self.__class__)
1582 )
1583
1584 def _add_next_methods(self, resourceDesc, schema):
1585 # Add _next() methods if and only if one of the names 'pageToken' or
1586 # 'nextPageToken' occurs among the fields of both the method's response
1587 # type either the method's request (query parameters) or request body.
1588 if "methods" not in resourceDesc:
1589 return
1590 for methodName, methodDesc in resourceDesc["methods"].items():
1591 nextPageTokenName = _findPageTokenName(
1592 _methodProperties(methodDesc, schema, "response")
1593 )
1594 if not nextPageTokenName:
1595 continue
1596 isPageTokenParameter = True
1597 pageTokenName = _findPageTokenName(methodDesc.get("parameters", {}))
1598 if not pageTokenName:
1599 isPageTokenParameter = False
1600 pageTokenName = _findPageTokenName(
1601 _methodProperties(methodDesc, schema, "request")
1602 )
1603 if not pageTokenName:
1604 continue
1605 fixedMethodName, method = createNextMethod(
1606 methodName + "_next",
1607 pageTokenName,
1608 nextPageTokenName,
1609 isPageTokenParameter,
1610 )
1611 self._set_dynamic_attr(
1612 fixedMethodName, method.__get__(self, self.__class__)
1613 )
1614
1615 def _validate_credentials(self):
1616 """Validates client's and credentials' universe domains are consistent.
1617
1618 Returns:
1619 bool: True iff the configured universe domain is valid.
1620
1621 Raises:
1622 UniverseMismatchError: If the configured universe domain is not valid.
1623 """
1624 credentials = getattr(self._http, "credentials", None)
1625
1626 self._credentials_validated = (
1627 (
1628 self._credentials_validated
1629 or universe.compare_domains(self._universe_domain, credentials)
1630 )
1631 if HAS_UNIVERSE
1632 else True
1633 )
1634 return self._credentials_validated
1635
1636
1637def _findPageTokenName(fields):
1638 """Search field names for one like a page token.
1639
1640 Args:
1641 fields: container of string, names of fields.
1642
1643 Returns:
1644 First name that is either 'pageToken' or 'nextPageToken' if one exists,
1645 otherwise None.
1646 """
1647 return next(
1648 (tokenName for tokenName in _PAGE_TOKEN_NAMES if tokenName in fields), None
1649 )
1650
1651
1652def _methodProperties(methodDesc, schema, name):
1653 """Get properties of a field in a method description.
1654
1655 Args:
1656 methodDesc: object, fragment of deserialized discovery document that
1657 describes the method.
1658 schema: object, mapping of schema names to schema descriptions.
1659 name: string, name of top-level field in method description.
1660
1661 Returns:
1662 Object representing fragment of deserialized discovery document
1663 corresponding to 'properties' field of object corresponding to named field
1664 in method description, if it exists, otherwise empty dict.
1665 """
1666 desc = methodDesc.get(name, {})
1667 if "$ref" in desc:
1668 desc = schema.get(desc["$ref"], {})
1669 return desc.get("properties", {})