1# Copyright 2014 Google LLC
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"""Shared implementation of connections to API servers."""
16
17import collections
18import collections.abc
19import json
20import os
21import platform
22from typing import Optional
23from urllib.parse import urlencode
24import warnings
25
26from google.api_core.client_info import ClientInfo
27from google.cloud import exceptions
28from google.cloud import version
29
30
31API_BASE_URL = "https://www.googleapis.com"
32"""The base of the API call URL."""
33
34DEFAULT_USER_AGENT = "gcloud-python/{0}".format(version.__version__)
35"""The user agent for google-cloud-python requests."""
36
37CLIENT_INFO_HEADER = "X-Goog-API-Client"
38CLIENT_INFO_TEMPLATE = "gl-python/" + platform.python_version() + " gccl/{}"
39
40_USER_AGENT_ALL_CAPS_DEPRECATED = """\
41The 'USER_AGENT' class-level attribute is deprecated. Please use
42'user_agent' instead.
43"""
44
45_EXTRA_HEADERS_ALL_CAPS_DEPRECATED = """\
46The '_EXTRA_HEADERS' class-level attribute is deprecated. Please use
47'extra_headers' instead.
48"""
49
50_DEFAULT_TIMEOUT = 60 # in seconds
51
52
53class Connection(object):
54 """A generic connection to Google Cloud Platform.
55
56 :type client: :class:`~google.cloud.client.Client`
57 :param client: The client that owns the current connection.
58
59 :type client_info: :class:`~google.api_core.client_info.ClientInfo`
60 :param client_info: (Optional) instance used to generate user agent.
61 """
62
63 _user_agent = DEFAULT_USER_AGENT
64
65 def __init__(self, client, client_info=None):
66 self._client = client
67
68 if client_info is None:
69 client_info = ClientInfo()
70
71 self._client_info = client_info
72 self._extra_headers = {}
73
74 @property
75 def USER_AGENT(self):
76 """Deprecated: get / set user agent sent by connection.
77
78 :rtype: str
79 :returns: user agent
80 """
81 warnings.warn(_USER_AGENT_ALL_CAPS_DEPRECATED, DeprecationWarning, stacklevel=2)
82 return self.user_agent
83
84 @USER_AGENT.setter
85 def USER_AGENT(self, value):
86 warnings.warn(_USER_AGENT_ALL_CAPS_DEPRECATED, DeprecationWarning, stacklevel=2)
87 self.user_agent = value
88
89 @property
90 def user_agent(self):
91 """Get / set user agent sent by connection.
92
93 :rtype: str
94 :returns: user agent
95 """
96 return self._client_info.to_user_agent()
97
98 @user_agent.setter
99 def user_agent(self, value):
100 self._client_info.user_agent = value
101
102 @property
103 def _EXTRA_HEADERS(self):
104 """Deprecated: get / set extra headers sent by connection.
105
106 :rtype: dict
107 :returns: header keys / values
108 """
109 warnings.warn(
110 _EXTRA_HEADERS_ALL_CAPS_DEPRECATED, DeprecationWarning, stacklevel=2
111 )
112 return self.extra_headers
113
114 @_EXTRA_HEADERS.setter
115 def _EXTRA_HEADERS(self, value):
116 warnings.warn(
117 _EXTRA_HEADERS_ALL_CAPS_DEPRECATED, DeprecationWarning, stacklevel=2
118 )
119 self.extra_headers = value
120
121 @property
122 def extra_headers(self):
123 """Get / set extra headers sent by connection.
124
125 :rtype: dict
126 :returns: header keys / values
127 """
128 return self._extra_headers
129
130 @extra_headers.setter
131 def extra_headers(self, value):
132 self._extra_headers = value
133
134 @property
135 def credentials(self):
136 """Getter for current credentials.
137
138 :rtype: :class:`google.auth.credentials.Credentials` or
139 :class:`NoneType`
140 :returns: The credentials object associated with this connection.
141 """
142 return self._client._credentials
143
144 @property
145 def http(self):
146 """A getter for the HTTP transport used in talking to the API.
147
148 Returns:
149 google.auth.transport.requests.AuthorizedSession:
150 A :class:`requests.Session` instance.
151 """
152 return self._client._http
153
154
155class JSONConnection(Connection):
156 """A connection to a Google JSON-based API.
157
158 These APIs are discovery based. For reference:
159
160 https://developers.google.com/discovery/
161
162 This defines :meth:`api_request` for making a generic JSON
163 API request and API requests are created elsewhere.
164
165 * :attr:`API_BASE_URL`
166 * :attr:`API_VERSION`
167 * :attr:`API_URL_TEMPLATE`
168
169 must be updated by subclasses.
170 """
171
172 API_BASE_URL: Optional[str] = None
173 """The base of the API call URL."""
174
175 API_BASE_MTLS_URL: Optional[str] = None
176 """The base of the API call URL for mutual TLS."""
177
178 ALLOW_AUTO_SWITCH_TO_MTLS_URL = False
179 """Indicates if auto switch to mTLS url is allowed."""
180
181 API_VERSION: Optional[str] = None
182 """The version of the API, used in building the API call's URL."""
183
184 API_URL_TEMPLATE: Optional[str] = None
185 """A template for the URL of a particular API call."""
186
187 def get_api_base_url_for_mtls(self, api_base_url=None):
188 """Return the api base url for mutual TLS.
189
190 Typically, you shouldn't need to use this method.
191
192 The logic is as follows:
193
194 If `api_base_url` is provided, just return this value; otherwise, the
195 return value depends `GOOGLE_API_USE_MTLS_ENDPOINT` environment variable
196 value.
197
198 If the environment variable value is "always", return `API_BASE_MTLS_URL`.
199 If the environment variable value is "never", return `API_BASE_URL`.
200 Otherwise, if `ALLOW_AUTO_SWITCH_TO_MTLS_URL` is True and the underlying
201 http is mTLS, then return `API_BASE_MTLS_URL`; otherwise return `API_BASE_URL`.
202
203 :type api_base_url: str
204 :param api_base_url: User provided api base url. It takes precedence over
205 `API_BASE_URL` and `API_BASE_MTLS_URL`.
206
207 :rtype: str
208 :returns: The api base url used for mTLS.
209 """
210 if api_base_url:
211 return api_base_url
212
213 env = os.getenv("GOOGLE_API_USE_MTLS_ENDPOINT", "auto")
214 if env == "always":
215 url_to_use = self.API_BASE_MTLS_URL
216 elif env == "never":
217 url_to_use = self.API_BASE_URL
218 else:
219 if self.ALLOW_AUTO_SWITCH_TO_MTLS_URL:
220 url_to_use = (
221 self.API_BASE_MTLS_URL if self.http.is_mtls else self.API_BASE_URL
222 )
223 else:
224 url_to_use = self.API_BASE_URL
225 return url_to_use
226
227 def build_api_url(
228 self, path, query_params=None, api_base_url=None, api_version=None
229 ):
230 """Construct an API url given a few components, some optional.
231
232 Typically, you shouldn't need to use this method.
233
234 :type path: str
235 :param path: The path to the resource (ie, ``'/b/bucket-name'``).
236
237 :type query_params: dict or list
238 :param query_params: A dictionary of keys and values (or list of
239 key-value pairs) to insert into the query
240 string of the URL.
241
242 :type api_base_url: str
243 :param api_base_url: The base URL for the API endpoint.
244 Typically you won't have to provide this.
245
246 :type api_version: str
247 :param api_version: The version of the API to call.
248 Typically you shouldn't provide this and instead
249 use the default for the library.
250
251 :rtype: str
252 :returns: The URL assembled from the pieces provided.
253 """
254 url = self.API_URL_TEMPLATE.format(
255 api_base_url=self.get_api_base_url_for_mtls(api_base_url),
256 api_version=(api_version or self.API_VERSION),
257 path=path,
258 )
259
260 query_params = query_params or {}
261
262 if isinstance(query_params, collections.abc.Mapping):
263 query_params = query_params.copy()
264 else:
265 query_params_dict = collections.defaultdict(list)
266 for key, value in query_params:
267 query_params_dict[key].append(value)
268 query_params = query_params_dict
269
270 query_params.setdefault("prettyPrint", "false")
271
272 url += "?" + urlencode(query_params, doseq=True)
273
274 return url
275
276 def _make_request(
277 self,
278 method,
279 url,
280 data=None,
281 content_type=None,
282 headers=None,
283 target_object=None,
284 timeout=_DEFAULT_TIMEOUT,
285 extra_api_info=None,
286 ):
287 """A low level method to send a request to the API.
288
289 Typically, you shouldn't need to use this method.
290
291 :type method: str
292 :param method: The HTTP method to use in the request.
293
294 :type url: str
295 :param url: The URL to send the request to.
296
297 :type data: str
298 :param data: The data to send as the body of the request.
299
300 :type content_type: str
301 :param content_type: The proper MIME type of the data provided.
302
303 :type headers: dict
304 :param headers: (Optional) A dictionary of HTTP headers to send with
305 the request. If passed, will be modified directly
306 here with added headers.
307
308 :type target_object: object
309 :param target_object:
310 (Optional) Argument to be used by library callers. This can allow
311 custom behavior, for example, to defer an HTTP request and complete
312 initialization of the object at a later time.
313
314 :type timeout: float or tuple
315 :param timeout: (optional) The amount of time, in seconds, to wait
316 for the server response.
317
318 Can also be passed as a tuple (connect_timeout, read_timeout).
319 See :meth:`requests.Session.request` documentation for details.
320
321 :type extra_api_info: string
322 :param extra_api_info: (optional) Extra api info to be appended to
323 the X-Goog-API-Client header
324
325 :rtype: :class:`requests.Response`
326 :returns: The HTTP response.
327 """
328 headers = headers or {}
329 headers.update(self.extra_headers)
330 headers["Accept-Encoding"] = "gzip"
331
332 if content_type:
333 headers["Content-Type"] = content_type
334
335 if extra_api_info:
336 headers[CLIENT_INFO_HEADER] = f"{self.user_agent} {extra_api_info}"
337 else:
338 headers[CLIENT_INFO_HEADER] = self.user_agent
339 headers["User-Agent"] = self.user_agent
340
341 return self._do_request(
342 method, url, headers, data, target_object, timeout=timeout
343 )
344
345 def _do_request(
346 self, method, url, headers, data, target_object, timeout=_DEFAULT_TIMEOUT
347 ): # pylint: disable=unused-argument
348 """Low-level helper: perform the actual API request over HTTP.
349
350 Allows batch context managers to override and defer a request.
351
352 :type method: str
353 :param method: The HTTP method to use in the request.
354
355 :type url: str
356 :param url: The URL to send the request to.
357
358 :type headers: dict
359 :param headers: A dictionary of HTTP headers to send with the request.
360
361 :type data: str
362 :param data: The data to send as the body of the request.
363
364 :type target_object: object
365 :param target_object:
366 (Optional) Unused ``target_object`` here but may be used by a
367 superclass.
368
369 :type timeout: float or tuple
370 :param timeout: (optional) The amount of time, in seconds, to wait
371 for the server response.
372
373 Can also be passed as a tuple (connect_timeout, read_timeout).
374 See :meth:`requests.Session.request` documentation for details.
375
376 :rtype: :class:`requests.Response`
377 :returns: The HTTP response.
378 """
379 return self.http.request(
380 url=url, method=method, headers=headers, data=data, timeout=timeout
381 )
382
383 def api_request(
384 self,
385 method,
386 path,
387 query_params=None,
388 data=None,
389 content_type=None,
390 headers=None,
391 api_base_url=None,
392 api_version=None,
393 expect_json=True,
394 _target_object=None,
395 timeout=_DEFAULT_TIMEOUT,
396 extra_api_info=None,
397 ):
398 """Make a request over the HTTP transport to the API.
399
400 You shouldn't need to use this method, but if you plan to
401 interact with the API using these primitives, this is the
402 correct one to use.
403
404 :type method: str
405 :param method: The HTTP method name (ie, ``GET``, ``POST``, etc).
406 Required.
407
408 :type path: str
409 :param path: The path to the resource (ie, ``'/b/bucket-name'``).
410 Required.
411
412 :type query_params: dict or list
413 :param query_params: A dictionary of keys and values (or list of
414 key-value pairs) to insert into the query
415 string of the URL.
416
417 :type data: str
418 :param data: The data to send as the body of the request. Default is
419 the empty string.
420
421 :type content_type: str
422 :param content_type: The proper MIME type of the data provided. Default
423 is None.
424
425 :type headers: dict
426 :param headers: extra HTTP headers to be sent with the request.
427
428 :type api_base_url: str
429 :param api_base_url: The base URL for the API endpoint.
430 Typically you won't have to provide this.
431 Default is the standard API base URL.
432
433 :type api_version: str
434 :param api_version: The version of the API to call. Typically
435 you shouldn't provide this and instead use
436 the default for the library. Default is the
437 latest API version supported by
438 google-cloud-python.
439
440 :type expect_json: bool
441 :param expect_json: If True, this method will try to parse the
442 response as JSON and raise an exception if
443 that cannot be done. Default is True.
444
445 :type _target_object: :class:`object`
446 :param _target_object:
447 (Optional) Protected argument to be used by library callers. This
448 can allow custom behavior, for example, to defer an HTTP request
449 and complete initialization of the object at a later time.
450
451 :type timeout: float or tuple
452 :param timeout: (optional) The amount of time, in seconds, to wait
453 for the server response.
454
455 Can also be passed as a tuple (connect_timeout, read_timeout).
456 See :meth:`requests.Session.request` documentation for details.
457
458 :type extra_api_info: string
459 :param extra_api_info: (optional) Extra api info to be appended to
460 the X-Goog-API-Client header
461
462 :raises ~google.cloud.exceptions.GoogleCloudError: if the response code
463 is not 200 OK.
464 :raises ValueError: if the response content type is not JSON.
465 :rtype: dict or str
466 :returns: The API response payload, either as a raw string or
467 a dictionary if the response is valid JSON.
468 """
469 url = self.build_api_url(
470 path=path,
471 query_params=query_params,
472 api_base_url=api_base_url,
473 api_version=api_version,
474 )
475
476 # Making the executive decision that any dictionary
477 # data will be sent properly as JSON.
478 if data and isinstance(data, dict):
479 data = json.dumps(data)
480 content_type = "application/json"
481
482 response = self._make_request(
483 method=method,
484 url=url,
485 data=data,
486 content_type=content_type,
487 headers=headers,
488 target_object=_target_object,
489 timeout=timeout,
490 extra_api_info=extra_api_info,
491 )
492
493 if not 200 <= response.status_code < 300:
494 raise exceptions.from_http_response(response)
495
496 if expect_json and response.content:
497 return response.json()
498 else:
499 return response.content