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 use_client_cert = os.getenv(GOOGLE_API_USE_CLIENT_CERTIFICATE, "false")
653 if not use_client_cert in ("true", "false"):
654 raise MutualTLSChannelError(
655 "Unsupported GOOGLE_API_USE_CLIENT_CERTIFICATE value. Accepted values: true, false"
656 )
657 if client_options and client_options.client_cert_source:
658 raise MutualTLSChannelError(
659 "ClientOptions.client_cert_source is not supported, please use ClientOptions.client_encrypted_cert_source."
660 )
661 if use_client_cert == "true":
662 if (
663 client_options
664 and hasattr(client_options, "client_encrypted_cert_source")
665 and client_options.client_encrypted_cert_source
666 ):
667 client_cert_to_use = client_options.client_encrypted_cert_source
668 elif (
669 adc_cert_path and adc_key_path and mtls.has_default_client_cert_source()
670 ):
671 client_cert_to_use = mtls.default_client_encrypted_cert_source(
672 adc_cert_path, adc_key_path
673 )
674 if client_cert_to_use:
675 cert_path, key_path, passphrase = client_cert_to_use()
676
677 # The http object we built could be google_auth_httplib2.AuthorizedHttp
678 # or httplib2.Http. In the first case we need to extract the wrapped
679 # httplib2.Http object from google_auth_httplib2.AuthorizedHttp.
680 http_channel = (
681 http.http
682 if google_auth_httplib2
683 and isinstance(http, google_auth_httplib2.AuthorizedHttp)
684 else http
685 )
686 http_channel.add_certificate(key_path, cert_path, "", passphrase)
687
688 # If user doesn't provide api endpoint via client options, decide which
689 # api endpoint to use.
690 if "mtlsRootUrl" in service and (
691 not client_options or not client_options.api_endpoint
692 ):
693 mtls_endpoint = urllib.parse.urljoin(
694 service["mtlsRootUrl"], service["servicePath"]
695 )
696 use_mtls_endpoint = os.getenv(GOOGLE_API_USE_MTLS_ENDPOINT, "auto")
697
698 if not use_mtls_endpoint in ("never", "auto", "always"):
699 raise MutualTLSChannelError(
700 "Unsupported GOOGLE_API_USE_MTLS_ENDPOINT value. Accepted values: never, auto, always"
701 )
702
703 # Switch to mTLS endpoint, if environment variable is "always", or
704 # environment varibable is "auto" and client cert exists.
705 if use_mtls_endpoint == "always" or (
706 use_mtls_endpoint == "auto" and client_cert_to_use
707 ):
708 if HAS_UNIVERSE and universe_domain != universe.DEFAULT_UNIVERSE:
709 raise MutualTLSChannelError(
710 f"mTLS is not supported in any universe other than {universe.DEFAULT_UNIVERSE}."
711 )
712 base = mtls_endpoint
713 else:
714 # Check google-api-core >= 2.18.0 if credentials' universe != "googleapis.com".
715 http_credentials = getattr(http, "credentials", None)
716 _check_api_core_compatible_with_credentials_universe(http_credentials)
717
718 if model is None:
719 features = service.get("features", [])
720 model = JsonModel("dataWrapper" in features)
721
722 return Resource(
723 http=http,
724 baseUrl=base,
725 model=model,
726 developerKey=developerKey,
727 requestBuilder=requestBuilder,
728 resourceDesc=service,
729 rootDesc=service,
730 schema=schema,
731 universe_domain=universe_domain,
732 )
733
734
735def _cast(value, schema_type):
736 """Convert value to a string based on JSON Schema type.
737
738 See http://tools.ietf.org/html/draft-zyp-json-schema-03 for more details on
739 JSON Schema.
740
741 Args:
742 value: any, the value to convert
743 schema_type: string, the type that value should be interpreted as
744
745 Returns:
746 A string representation of 'value' based on the schema_type.
747 """
748 if schema_type == "string":
749 if type(value) == type("") or type(value) == type(""):
750 return value
751 else:
752 return str(value)
753 elif schema_type == "integer":
754 return str(int(value))
755 elif schema_type == "number":
756 return str(float(value))
757 elif schema_type == "boolean":
758 return str(bool(value)).lower()
759 else:
760 if type(value) == type("") or type(value) == type(""):
761 return value
762 else:
763 return str(value)
764
765
766def _media_size_to_long(maxSize):
767 """Convert a string media size, such as 10GB or 3TB into an integer.
768
769 Args:
770 maxSize: string, size as a string, such as 2MB or 7GB.
771
772 Returns:
773 The size as an integer value.
774 """
775 if len(maxSize) < 2:
776 return 0
777 units = maxSize[-2:].upper()
778 bit_shift = _MEDIA_SIZE_BIT_SHIFTS.get(units)
779 if bit_shift is not None:
780 return int(maxSize[:-2]) << bit_shift
781 else:
782 return int(maxSize)
783
784
785def _media_path_url_from_info(root_desc, path_url):
786 """Creates an absolute media path URL.
787
788 Constructed using the API root URI and service path from the discovery
789 document and the relative path for the API method.
790
791 Args:
792 root_desc: Dictionary; the entire original deserialized discovery document.
793 path_url: String; the relative URL for the API method. Relative to the API
794 root, which is specified in the discovery document.
795
796 Returns:
797 String; the absolute URI for media upload for the API method.
798 """
799 return "%(root)supload/%(service_path)s%(path)s" % {
800 "root": root_desc["rootUrl"],
801 "service_path": root_desc["servicePath"],
802 "path": path_url,
803 }
804
805
806def _fix_up_parameters(method_desc, root_desc, http_method, schema):
807 """Updates parameters of an API method with values specific to this library.
808
809 Specifically, adds whatever global parameters are specified by the API to the
810 parameters for the individual method. Also adds parameters which don't
811 appear in the discovery document, but are available to all discovery based
812 APIs (these are listed in STACK_QUERY_PARAMETERS).
813
814 SIDE EFFECTS: This updates the parameters dictionary object in the method
815 description.
816
817 Args:
818 method_desc: Dictionary with metadata describing an API method. Value comes
819 from the dictionary of methods stored in the 'methods' key in the
820 deserialized discovery document.
821 root_desc: Dictionary; the entire original deserialized discovery document.
822 http_method: String; the HTTP method used to call the API method described
823 in method_desc.
824 schema: Object, mapping of schema names to schema descriptions.
825
826 Returns:
827 The updated Dictionary stored in the 'parameters' key of the method
828 description dictionary.
829 """
830 parameters = method_desc.setdefault("parameters", {})
831
832 # Add in the parameters common to all methods.
833 for name, description in root_desc.get("parameters", {}).items():
834 parameters[name] = description
835
836 # Add in undocumented query parameters.
837 for name in STACK_QUERY_PARAMETERS:
838 parameters[name] = STACK_QUERY_PARAMETER_DEFAULT_VALUE.copy()
839
840 # Add 'body' (our own reserved word) to parameters if the method supports
841 # a request payload.
842 if http_method in HTTP_PAYLOAD_METHODS and "request" in method_desc:
843 body = BODY_PARAMETER_DEFAULT_VALUE.copy()
844 body.update(method_desc["request"])
845 parameters["body"] = body
846
847 return parameters
848
849
850def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
851 """Adds 'media_body' and 'media_mime_type' parameters if supported by method.
852
853 SIDE EFFECTS: If there is a 'mediaUpload' in the method description, adds
854 'media_upload' key to parameters.
855
856 Args:
857 method_desc: Dictionary with metadata describing an API method. Value comes
858 from the dictionary of methods stored in the 'methods' key in the
859 deserialized discovery document.
860 root_desc: Dictionary; the entire original deserialized discovery document.
861 path_url: String; the relative URL for the API method. Relative to the API
862 root, which is specified in the discovery document.
863 parameters: A dictionary describing method parameters for method described
864 in method_desc.
865
866 Returns:
867 Triple (accept, max_size, media_path_url) where:
868 - accept is a list of strings representing what content types are
869 accepted for media upload. Defaults to empty list if not in the
870 discovery document.
871 - max_size is a long representing the max size in bytes allowed for a
872 media upload. Defaults to 0L if not in the discovery document.
873 - media_path_url is a String; the absolute URI for media upload for the
874 API method. Constructed using the API root URI and service path from
875 the discovery document and the relative path for the API method. If
876 media upload is not supported, this is None.
877 """
878 media_upload = method_desc.get("mediaUpload", {})
879 accept = media_upload.get("accept", [])
880 max_size = _media_size_to_long(media_upload.get("maxSize", ""))
881 media_path_url = None
882
883 if media_upload:
884 media_path_url = _media_path_url_from_info(root_desc, path_url)
885 parameters["media_body"] = MEDIA_BODY_PARAMETER_DEFAULT_VALUE.copy()
886 parameters["media_mime_type"] = MEDIA_MIME_TYPE_PARAMETER_DEFAULT_VALUE.copy()
887
888 return accept, max_size, media_path_url
889
890
891def _fix_up_method_description(method_desc, root_desc, schema):
892 """Updates a method description in a discovery document.
893
894 SIDE EFFECTS: Changes the parameters dictionary in the method description with
895 extra parameters which are used locally.
896
897 Args:
898 method_desc: Dictionary with metadata describing an API method. Value comes
899 from the dictionary of methods stored in the 'methods' key in the
900 deserialized discovery document.
901 root_desc: Dictionary; the entire original deserialized discovery document.
902 schema: Object, mapping of schema names to schema descriptions.
903
904 Returns:
905 Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
906 where:
907 - path_url is a String; the relative URL for the API method. Relative to
908 the API root, which is specified in the discovery document.
909 - http_method is a String; the HTTP method used to call the API method
910 described in the method description.
911 - method_id is a String; the name of the RPC method associated with the
912 API method, and is in the method description in the 'id' key.
913 - accept is a list of strings representing what content types are
914 accepted for media upload. Defaults to empty list if not in the
915 discovery document.
916 - max_size is a long representing the max size in bytes allowed for a
917 media upload. Defaults to 0L if not in the discovery document.
918 - media_path_url is a String; the absolute URI for media upload for the
919 API method. Constructed using the API root URI and service path from
920 the discovery document and the relative path for the API method. If
921 media upload is not supported, this is None.
922 """
923 path_url = method_desc["path"]
924 http_method = method_desc["httpMethod"]
925 method_id = method_desc["id"]
926
927 parameters = _fix_up_parameters(method_desc, root_desc, http_method, schema)
928 # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
929 # 'parameters' key and needs to know if there is a 'body' parameter because it
930 # also sets a 'media_body' parameter.
931 accept, max_size, media_path_url = _fix_up_media_upload(
932 method_desc, root_desc, path_url, parameters
933 )
934
935 return path_url, http_method, method_id, accept, max_size, media_path_url
936
937
938def _fix_up_media_path_base_url(media_path_url, base_url):
939 """
940 Update the media upload base url if its netloc doesn't match base url netloc.
941
942 This can happen in case the base url was overridden by
943 client_options.api_endpoint.
944
945 Args:
946 media_path_url: String; the absolute URI for media upload.
947 base_url: string, base URL for the API. All requests are relative to this URI.
948
949 Returns:
950 String; the absolute URI for media upload.
951 """
952 parsed_media_url = urllib.parse.urlparse(media_path_url)
953 parsed_base_url = urllib.parse.urlparse(base_url)
954 if parsed_media_url.netloc == parsed_base_url.netloc:
955 return media_path_url
956 return urllib.parse.urlunparse(
957 parsed_media_url._replace(netloc=parsed_base_url.netloc)
958 )
959
960
961def _urljoin(base, url):
962 """Custom urljoin replacement supporting : before / in url."""
963 # In general, it's unsafe to simply join base and url. However, for
964 # the case of discovery documents, we know:
965 # * base will never contain params, query, or fragment
966 # * url will never contain a scheme or net_loc.
967 # In general, this means we can safely join on /; we just need to
968 # ensure we end up with precisely one / joining base and url. The
969 # exception here is the case of media uploads, where url will be an
970 # absolute url.
971 if url.startswith("http://") or url.startswith("https://"):
972 return urllib.parse.urljoin(base, url)
973 new_base = base if base.endswith("/") else base + "/"
974 new_url = url[1:] if url.startswith("/") else url
975 return new_base + new_url
976
977
978# TODO(dhermes): Convert this class to ResourceMethod and make it callable
979class ResourceMethodParameters(object):
980 """Represents the parameters associated with a method.
981
982 Attributes:
983 argmap: Map from method parameter name (string) to query parameter name
984 (string).
985 required_params: List of required parameters (represented by parameter
986 name as string).
987 repeated_params: List of repeated parameters (represented by parameter
988 name as string).
989 pattern_params: Map from method parameter name (string) to regular
990 expression (as a string). If the pattern is set for a parameter, the
991 value for that parameter must match the regular expression.
992 query_params: List of parameters (represented by parameter name as string)
993 that will be used in the query string.
994 path_params: Set of parameters (represented by parameter name as string)
995 that will be used in the base URL path.
996 param_types: Map from method parameter name (string) to parameter type. Type
997 can be any valid JSON schema type; valid values are 'any', 'array',
998 'boolean', 'integer', 'number', 'object', or 'string'. Reference:
999 http://tools.ietf.org/html/draft-zyp-json-schema-03#section-5.1
1000 enum_params: Map from method parameter name (string) to list of strings,
1001 where each list of strings is the list of acceptable enum values.
1002 """
1003
1004 def __init__(self, method_desc):
1005 """Constructor for ResourceMethodParameters.
1006
1007 Sets default values and defers to set_parameters to populate.
1008
1009 Args:
1010 method_desc: Dictionary with metadata describing an API method. Value
1011 comes from the dictionary of methods stored in the 'methods' key in
1012 the deserialized discovery document.
1013 """
1014 self.argmap = {}
1015 self.required_params = []
1016 self.repeated_params = []
1017 self.pattern_params = {}
1018 self.query_params = []
1019 # TODO(dhermes): Change path_params to a list if the extra URITEMPLATE
1020 # parsing is gotten rid of.
1021 self.path_params = set()
1022 self.param_types = {}
1023 self.enum_params = {}
1024
1025 self.set_parameters(method_desc)
1026
1027 def set_parameters(self, method_desc):
1028 """Populates maps and lists based on method description.
1029
1030 Iterates through each parameter for the method and parses the values from
1031 the parameter dictionary.
1032
1033 Args:
1034 method_desc: Dictionary with metadata describing an API method. Value
1035 comes from the dictionary of methods stored in the 'methods' key in
1036 the deserialized discovery document.
1037 """
1038 parameters = method_desc.get("parameters", {})
1039 sorted_parameters = OrderedDict(sorted(parameters.items()))
1040 for arg, desc in sorted_parameters.items():
1041 param = key2param(arg)
1042 self.argmap[param] = arg
1043
1044 if desc.get("pattern"):
1045 self.pattern_params[param] = desc["pattern"]
1046 if desc.get("enum"):
1047 self.enum_params[param] = desc["enum"]
1048 if desc.get("required"):
1049 self.required_params.append(param)
1050 if desc.get("repeated"):
1051 self.repeated_params.append(param)
1052 if desc.get("location") == "query":
1053 self.query_params.append(param)
1054 if desc.get("location") == "path":
1055 self.path_params.add(param)
1056 self.param_types[param] = desc.get("type", "string")
1057
1058 # TODO(dhermes): Determine if this is still necessary. Discovery based APIs
1059 # should have all path parameters already marked with
1060 # 'location: path'.
1061 for match in URITEMPLATE.finditer(method_desc["path"]):
1062 for namematch in VARNAME.finditer(match.group(0)):
1063 name = key2param(namematch.group(0))
1064 self.path_params.add(name)
1065 if name in self.query_params:
1066 self.query_params.remove(name)
1067
1068
1069def createMethod(methodName, methodDesc, rootDesc, schema):
1070 """Creates a method for attaching to a Resource.
1071
1072 Args:
1073 methodName: string, name of the method to use.
1074 methodDesc: object, fragment of deserialized discovery document that
1075 describes the method.
1076 rootDesc: object, the entire deserialized discovery document.
1077 schema: object, mapping of schema names to schema descriptions.
1078 """
1079 methodName = fix_method_name(methodName)
1080 (
1081 pathUrl,
1082 httpMethod,
1083 methodId,
1084 accept,
1085 maxSize,
1086 mediaPathUrl,
1087 ) = _fix_up_method_description(methodDesc, rootDesc, schema)
1088
1089 parameters = ResourceMethodParameters(methodDesc)
1090
1091 def method(self, **kwargs):
1092 # Don't bother with doc string, it will be over-written by createMethod.
1093
1094 # Validate credentials for the configured universe.
1095 self._validate_credentials()
1096
1097 for name in kwargs:
1098 if name not in parameters.argmap:
1099 raise TypeError("Got an unexpected keyword argument {}".format(name))
1100
1101 # Remove args that have a value of None.
1102 keys = list(kwargs.keys())
1103 for name in keys:
1104 if kwargs[name] is None:
1105 del kwargs[name]
1106
1107 for name in parameters.required_params:
1108 if name not in kwargs:
1109 # temporary workaround for non-paging methods incorrectly requiring
1110 # page token parameter (cf. drive.changes.watch vs. drive.changes.list)
1111 if name not in _PAGE_TOKEN_NAMES or _findPageTokenName(
1112 _methodProperties(methodDesc, schema, "response")
1113 ):
1114 raise TypeError('Missing required parameter "%s"' % name)
1115
1116 for name, regex in parameters.pattern_params.items():
1117 if name in kwargs:
1118 if isinstance(kwargs[name], str):
1119 pvalues = [kwargs[name]]
1120 else:
1121 pvalues = kwargs[name]
1122 for pvalue in pvalues:
1123 if re.match(regex, pvalue) is None:
1124 raise TypeError(
1125 'Parameter "%s" value "%s" does not match the pattern "%s"'
1126 % (name, pvalue, regex)
1127 )
1128
1129 for name, enums in parameters.enum_params.items():
1130 if name in kwargs:
1131 # We need to handle the case of a repeated enum
1132 # name differently, since we want to handle both
1133 # arg='value' and arg=['value1', 'value2']
1134 if name in parameters.repeated_params and not isinstance(
1135 kwargs[name], str
1136 ):
1137 values = kwargs[name]
1138 else:
1139 values = [kwargs[name]]
1140 for value in values:
1141 if value not in enums:
1142 raise TypeError(
1143 'Parameter "%s" value "%s" is not an allowed value in "%s"'
1144 % (name, value, str(enums))
1145 )
1146
1147 actual_query_params = {}
1148 actual_path_params = {}
1149 for key, value in kwargs.items():
1150 to_type = parameters.param_types.get(key, "string")
1151 # For repeated parameters we cast each member of the list.
1152 if key in parameters.repeated_params and type(value) == type([]):
1153 cast_value = [_cast(x, to_type) for x in value]
1154 else:
1155 cast_value = _cast(value, to_type)
1156 if key in parameters.query_params:
1157 actual_query_params[parameters.argmap[key]] = cast_value
1158 if key in parameters.path_params:
1159 actual_path_params[parameters.argmap[key]] = cast_value
1160 body_value = kwargs.get("body", None)
1161 media_filename = kwargs.get("media_body", None)
1162 media_mime_type = kwargs.get("media_mime_type", None)
1163
1164 if self._developerKey:
1165 actual_query_params["key"] = self._developerKey
1166
1167 model = self._model
1168 if methodName.endswith("_media"):
1169 model = MediaModel()
1170 elif "response" not in methodDesc:
1171 model = RawModel()
1172
1173 api_version = methodDesc.get("apiVersion", None)
1174
1175 headers = {}
1176 headers, params, query, body = model.request(
1177 headers, actual_path_params, actual_query_params, body_value, api_version
1178 )
1179
1180 expanded_url = uritemplate.expand(pathUrl, params)
1181 url = _urljoin(self._baseUrl, expanded_url + query)
1182
1183 resumable = None
1184 multipart_boundary = ""
1185
1186 if media_filename:
1187 # Ensure we end up with a valid MediaUpload object.
1188 if isinstance(media_filename, str):
1189 if media_mime_type is None:
1190 logger.warning(
1191 "media_mime_type argument not specified: trying to auto-detect for %s",
1192 media_filename,
1193 )
1194 media_mime_type, _ = mimetypes.guess_type(media_filename)
1195 if media_mime_type is None:
1196 raise UnknownFileType(media_filename)
1197 if not mimeparse.best_match([media_mime_type], ",".join(accept)):
1198 raise UnacceptableMimeTypeError(media_mime_type)
1199 media_upload = MediaFileUpload(media_filename, mimetype=media_mime_type)
1200 elif isinstance(media_filename, MediaUpload):
1201 media_upload = media_filename
1202 else:
1203 raise TypeError("media_filename must be str or MediaUpload.")
1204
1205 # Check the maxSize
1206 if media_upload.size() is not None and media_upload.size() > maxSize > 0:
1207 raise MediaUploadSizeError("Media larger than: %s" % maxSize)
1208
1209 # Use the media path uri for media uploads
1210 expanded_url = uritemplate.expand(mediaPathUrl, params)
1211 url = _urljoin(self._baseUrl, expanded_url + query)
1212 url = _fix_up_media_path_base_url(url, self._baseUrl)
1213 if media_upload.resumable():
1214 url = _add_query_parameter(url, "uploadType", "resumable")
1215
1216 if media_upload.resumable():
1217 # This is all we need to do for resumable, if the body exists it gets
1218 # sent in the first request, otherwise an empty body is sent.
1219 resumable = media_upload
1220 else:
1221 # A non-resumable upload
1222 if body is None:
1223 # This is a simple media upload
1224 headers["content-type"] = media_upload.mimetype()
1225 body = media_upload.getbytes(0, media_upload.size())
1226 url = _add_query_parameter(url, "uploadType", "media")
1227 else:
1228 # This is a multipart/related upload.
1229 msgRoot = MIMEMultipart("related")
1230 # msgRoot should not write out it's own headers
1231 setattr(msgRoot, "_write_headers", lambda self: None)
1232
1233 # attach the body as one part
1234 msg = MIMENonMultipart(*headers["content-type"].split("/"))
1235 msg.set_payload(body)
1236 msgRoot.attach(msg)
1237
1238 # attach the media as the second part
1239 msg = MIMENonMultipart(*media_upload.mimetype().split("/"))
1240 msg["Content-Transfer-Encoding"] = "binary"
1241
1242 payload = media_upload.getbytes(0, media_upload.size())
1243 msg.set_payload(payload)
1244 msgRoot.attach(msg)
1245 # encode the body: note that we can't use `as_string`, because
1246 # it plays games with `From ` lines.
1247 fp = io.BytesIO()
1248 g = _BytesGenerator(fp, mangle_from_=False)
1249 g.flatten(msgRoot, unixfrom=False)
1250 body = fp.getvalue()
1251
1252 multipart_boundary = msgRoot.get_boundary()
1253 headers["content-type"] = (
1254 "multipart/related; " 'boundary="%s"'
1255 ) % multipart_boundary
1256 url = _add_query_parameter(url, "uploadType", "multipart")
1257
1258 logger.debug("URL being requested: %s %s" % (httpMethod, url))
1259 return self._requestBuilder(
1260 self._http,
1261 model.response,
1262 url,
1263 method=httpMethod,
1264 body=body,
1265 headers=headers,
1266 methodId=methodId,
1267 resumable=resumable,
1268 )
1269
1270 docs = [methodDesc.get("description", DEFAULT_METHOD_DOC), "\n\n"]
1271 if len(parameters.argmap) > 0:
1272 docs.append("Args:\n")
1273
1274 # Skip undocumented params and params common to all methods.
1275 skip_parameters = list(rootDesc.get("parameters", {}).keys())
1276 skip_parameters.extend(STACK_QUERY_PARAMETERS)
1277
1278 all_args = list(parameters.argmap.keys())
1279 args_ordered = [key2param(s) for s in methodDesc.get("parameterOrder", [])]
1280
1281 # Move body to the front of the line.
1282 if "body" in all_args:
1283 args_ordered.append("body")
1284
1285 for name in sorted(all_args):
1286 if name not in args_ordered:
1287 args_ordered.append(name)
1288
1289 for arg in args_ordered:
1290 if arg in skip_parameters:
1291 continue
1292
1293 repeated = ""
1294 if arg in parameters.repeated_params:
1295 repeated = " (repeated)"
1296 required = ""
1297 if arg in parameters.required_params:
1298 required = " (required)"
1299 paramdesc = methodDesc["parameters"][parameters.argmap[arg]]
1300 paramdoc = paramdesc.get("description", "A parameter")
1301 if "$ref" in paramdesc:
1302 docs.append(
1303 (" %s: object, %s%s%s\n The object takes the form of:\n\n%s\n\n")
1304 % (
1305 arg,
1306 paramdoc,
1307 required,
1308 repeated,
1309 schema.prettyPrintByName(paramdesc["$ref"]),
1310 )
1311 )
1312 else:
1313 paramtype = paramdesc.get("type", "string")
1314 docs.append(
1315 " %s: %s, %s%s%s\n" % (arg, paramtype, paramdoc, required, repeated)
1316 )
1317 enum = paramdesc.get("enum", [])
1318 enumDesc = paramdesc.get("enumDescriptions", [])
1319 if enum and enumDesc:
1320 docs.append(" Allowed values\n")
1321 for (name, desc) in zip(enum, enumDesc):
1322 docs.append(" %s - %s\n" % (name, desc))
1323 if "response" in methodDesc:
1324 if methodName.endswith("_media"):
1325 docs.append("\nReturns:\n The media object as a string.\n\n ")
1326 else:
1327 docs.append("\nReturns:\n An object of the form:\n\n ")
1328 docs.append(schema.prettyPrintSchema(methodDesc["response"]))
1329
1330 setattr(method, "__doc__", "".join(docs))
1331 return (methodName, method)
1332
1333
1334def createNextMethod(
1335 methodName,
1336 pageTokenName="pageToken",
1337 nextPageTokenName="nextPageToken",
1338 isPageTokenParameter=True,
1339):
1340 """Creates any _next methods for attaching to a Resource.
1341
1342 The _next methods allow for easy iteration through list() responses.
1343
1344 Args:
1345 methodName: string, name of the method to use.
1346 pageTokenName: string, name of request page token field.
1347 nextPageTokenName: string, name of response page token field.
1348 isPageTokenParameter: Boolean, True if request page token is a query
1349 parameter, False if request page token is a field of the request body.
1350 """
1351 methodName = fix_method_name(methodName)
1352
1353 def methodNext(self, previous_request, previous_response):
1354 """Retrieves the next page of results.
1355
1356 Args:
1357 previous_request: The request for the previous page. (required)
1358 previous_response: The response from the request for the previous page. (required)
1359
1360 Returns:
1361 A request object that you can call 'execute()' on to request the next
1362 page. Returns None if there are no more items in the collection.
1363 """
1364 # Retrieve nextPageToken from previous_response
1365 # Use as pageToken in previous_request to create new request.
1366
1367 nextPageToken = previous_response.get(nextPageTokenName, None)
1368 if not nextPageToken:
1369 return None
1370
1371 request = copy.copy(previous_request)
1372
1373 if isPageTokenParameter:
1374 # Replace pageToken value in URI
1375 request.uri = _add_query_parameter(
1376 request.uri, pageTokenName, nextPageToken
1377 )
1378 logger.debug("Next page request URL: %s %s" % (methodName, request.uri))
1379 else:
1380 # Replace pageToken value in request body
1381 model = self._model
1382 body = model.deserialize(request.body)
1383 body[pageTokenName] = nextPageToken
1384 request.body = model.serialize(body)
1385 request.body_size = len(request.body)
1386 if "content-length" in request.headers:
1387 del request.headers["content-length"]
1388 logger.debug("Next page request body: %s %s" % (methodName, body))
1389
1390 return request
1391
1392 return (methodName, methodNext)
1393
1394
1395class Resource(object):
1396 """A class for interacting with a resource."""
1397
1398 def __init__(
1399 self,
1400 http,
1401 baseUrl,
1402 model,
1403 requestBuilder,
1404 developerKey,
1405 resourceDesc,
1406 rootDesc,
1407 schema,
1408 universe_domain=universe.DEFAULT_UNIVERSE if HAS_UNIVERSE else "",
1409 ):
1410 """Build a Resource from the API description.
1411
1412 Args:
1413 http: httplib2.Http, Object to make http requests with.
1414 baseUrl: string, base URL for the API. All requests are relative to this
1415 URI.
1416 model: googleapiclient.Model, converts to and from the wire format.
1417 requestBuilder: class or callable that instantiates an
1418 googleapiclient.HttpRequest object.
1419 developerKey: string, key obtained from
1420 https://code.google.com/apis/console
1421 resourceDesc: object, section of deserialized discovery document that
1422 describes a resource. Note that the top level discovery document
1423 is considered a resource.
1424 rootDesc: object, the entire deserialized discovery document.
1425 schema: object, mapping of schema names to schema descriptions.
1426 universe_domain: string, the universe for the API. The default universe
1427 is "googleapis.com".
1428 """
1429 self._dynamic_attrs = []
1430
1431 self._http = http
1432 self._baseUrl = baseUrl
1433 self._model = model
1434 self._developerKey = developerKey
1435 self._requestBuilder = requestBuilder
1436 self._resourceDesc = resourceDesc
1437 self._rootDesc = rootDesc
1438 self._schema = schema
1439 self._universe_domain = universe_domain
1440 self._credentials_validated = False
1441
1442 self._set_service_methods()
1443
1444 def _set_dynamic_attr(self, attr_name, value):
1445 """Sets an instance attribute and tracks it in a list of dynamic attributes.
1446
1447 Args:
1448 attr_name: string; The name of the attribute to be set
1449 value: The value being set on the object and tracked in the dynamic cache.
1450 """
1451 self._dynamic_attrs.append(attr_name)
1452 self.__dict__[attr_name] = value
1453
1454 def __getstate__(self):
1455 """Trim the state down to something that can be pickled.
1456
1457 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1458 will be wiped and restored on pickle serialization.
1459 """
1460 state_dict = copy.copy(self.__dict__)
1461 for dynamic_attr in self._dynamic_attrs:
1462 del state_dict[dynamic_attr]
1463 del state_dict["_dynamic_attrs"]
1464 return state_dict
1465
1466 def __setstate__(self, state):
1467 """Reconstitute the state of the object from being pickled.
1468
1469 Uses the fact that the instance variable _dynamic_attrs holds attrs that
1470 will be wiped and restored on pickle serialization.
1471 """
1472 self.__dict__.update(state)
1473 self._dynamic_attrs = []
1474 self._set_service_methods()
1475
1476 def __enter__(self):
1477 return self
1478
1479 def __exit__(self, exc_type, exc, exc_tb):
1480 self.close()
1481
1482 def close(self):
1483 """Close httplib2 connections."""
1484 # httplib2 leaves sockets open by default.
1485 # Cleanup using the `close` method.
1486 # https://github.com/httplib2/httplib2/issues/148
1487 self._http.close()
1488
1489 def _set_service_methods(self):
1490 self._add_basic_methods(self._resourceDesc, self._rootDesc, self._schema)
1491 self._add_nested_resources(self._resourceDesc, self._rootDesc, self._schema)
1492 self._add_next_methods(self._resourceDesc, self._schema)
1493
1494 def _add_basic_methods(self, resourceDesc, rootDesc, schema):
1495 # If this is the root Resource, add a new_batch_http_request() method.
1496 if resourceDesc == rootDesc:
1497 batch_uri = "%s%s" % (
1498 rootDesc["rootUrl"],
1499 rootDesc.get("batchPath", "batch"),
1500 )
1501
1502 def new_batch_http_request(callback=None):
1503 """Create a BatchHttpRequest object based on the discovery document.
1504
1505 Args:
1506 callback: callable, A callback to be called for each response, of the
1507 form callback(id, response, exception). The first parameter is the
1508 request id, and the second is the deserialized response object. The
1509 third is an apiclient.errors.HttpError exception object if an HTTP
1510 error occurred while processing the request, or None if no error
1511 occurred.
1512
1513 Returns:
1514 A BatchHttpRequest object based on the discovery document.
1515 """
1516 return BatchHttpRequest(callback=callback, batch_uri=batch_uri)
1517
1518 self._set_dynamic_attr("new_batch_http_request", new_batch_http_request)
1519
1520 # Add basic methods to Resource
1521 if "methods" in resourceDesc:
1522 for methodName, methodDesc in resourceDesc["methods"].items():
1523 fixedMethodName, method = createMethod(
1524 methodName, methodDesc, rootDesc, schema
1525 )
1526 self._set_dynamic_attr(
1527 fixedMethodName, method.__get__(self, self.__class__)
1528 )
1529 # Add in _media methods. The functionality of the attached method will
1530 # change when it sees that the method name ends in _media.
1531 if methodDesc.get("supportsMediaDownload", False):
1532 fixedMethodName, method = createMethod(
1533 methodName + "_media", methodDesc, rootDesc, schema
1534 )
1535 self._set_dynamic_attr(
1536 fixedMethodName, method.__get__(self, self.__class__)
1537 )
1538
1539 def _add_nested_resources(self, resourceDesc, rootDesc, schema):
1540 # Add in nested resources
1541 if "resources" in resourceDesc:
1542
1543 def createResourceMethod(methodName, methodDesc):
1544 """Create a method on the Resource to access a nested Resource.
1545
1546 Args:
1547 methodName: string, name of the method to use.
1548 methodDesc: object, fragment of deserialized discovery document that
1549 describes the method.
1550 """
1551 methodName = fix_method_name(methodName)
1552
1553 def methodResource(self):
1554 return Resource(
1555 http=self._http,
1556 baseUrl=self._baseUrl,
1557 model=self._model,
1558 developerKey=self._developerKey,
1559 requestBuilder=self._requestBuilder,
1560 resourceDesc=methodDesc,
1561 rootDesc=rootDesc,
1562 schema=schema,
1563 universe_domain=self._universe_domain,
1564 )
1565
1566 setattr(methodResource, "__doc__", "A collection resource.")
1567 setattr(methodResource, "__is_resource__", True)
1568
1569 return (methodName, methodResource)
1570
1571 for methodName, methodDesc in resourceDesc["resources"].items():
1572 fixedMethodName, method = createResourceMethod(methodName, methodDesc)
1573 self._set_dynamic_attr(
1574 fixedMethodName, method.__get__(self, self.__class__)
1575 )
1576
1577 def _add_next_methods(self, resourceDesc, schema):
1578 # Add _next() methods if and only if one of the names 'pageToken' or
1579 # 'nextPageToken' occurs among the fields of both the method's response
1580 # type either the method's request (query parameters) or request body.
1581 if "methods" not in resourceDesc:
1582 return
1583 for methodName, methodDesc in resourceDesc["methods"].items():
1584 nextPageTokenName = _findPageTokenName(
1585 _methodProperties(methodDesc, schema, "response")
1586 )
1587 if not nextPageTokenName:
1588 continue
1589 isPageTokenParameter = True
1590 pageTokenName = _findPageTokenName(methodDesc.get("parameters", {}))
1591 if not pageTokenName:
1592 isPageTokenParameter = False
1593 pageTokenName = _findPageTokenName(
1594 _methodProperties(methodDesc, schema, "request")
1595 )
1596 if not pageTokenName:
1597 continue
1598 fixedMethodName, method = createNextMethod(
1599 methodName + "_next",
1600 pageTokenName,
1601 nextPageTokenName,
1602 isPageTokenParameter,
1603 )
1604 self._set_dynamic_attr(
1605 fixedMethodName, method.__get__(self, self.__class__)
1606 )
1607
1608 def _validate_credentials(self):
1609 """Validates client's and credentials' universe domains are consistent.
1610
1611 Returns:
1612 bool: True iff the configured universe domain is valid.
1613
1614 Raises:
1615 UniverseMismatchError: If the configured universe domain is not valid.
1616 """
1617 credentials = getattr(self._http, "credentials", None)
1618
1619 self._credentials_validated = (
1620 (
1621 self._credentials_validated
1622 or universe.compare_domains(self._universe_domain, credentials)
1623 )
1624 if HAS_UNIVERSE
1625 else True
1626 )
1627 return self._credentials_validated
1628
1629
1630def _findPageTokenName(fields):
1631 """Search field names for one like a page token.
1632
1633 Args:
1634 fields: container of string, names of fields.
1635
1636 Returns:
1637 First name that is either 'pageToken' or 'nextPageToken' if one exists,
1638 otherwise None.
1639 """
1640 return next(
1641 (tokenName for tokenName in _PAGE_TOKEN_NAMES if tokenName in fields), None
1642 )
1643
1644
1645def _methodProperties(methodDesc, schema, name):
1646 """Get properties of a field in a method description.
1647
1648 Args:
1649 methodDesc: object, fragment of deserialized discovery document that
1650 describes the method.
1651 schema: object, mapping of schema names to schema descriptions.
1652 name: string, name of top-level field in method description.
1653
1654 Returns:
1655 Object representing fragment of deserialized discovery document
1656 corresponding to 'properties' field of object corresponding to named field
1657 in method description, if it exists, otherwise empty dict.
1658 """
1659 desc = methodDesc.get(name, {})
1660 if "$ref" in desc:
1661 desc = schema.get(desc["$ref"], {})
1662 return desc.get("properties", {})