Skip to content

Commit 58a8230

Browse files
committed
feat(api-core): introduce client_utils and tests for use_client_cert_effective, get_client_cert_source, and read_environment_variables
1 parent d461da7 commit 58a8230

6 files changed

Lines changed: 323 additions & 323 deletions

File tree

packages/google-api-core/google/api_core/gapic_v1/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@
2525
# Older Python versions safely ignore this variable.
2626
__lazy_modules__: Set[str] = {
2727
"google.api_core.gapic_v1.client_info",
28+
"google.api_core.gapic_v1.client_utils",
2829
"google.api_core.gapic_v1.requests",
2930
"google.api_core.gapic_v1.routing_header",
3031
}
31-
__all__ = ["client_info", "requests", "routing_header"]
32+
__all__ = ["client_info", "client_utils", "requests", "routing_header"]
33+
3234

3335
if _has_grpc:
3436
__lazy_modules__.update(
@@ -42,6 +44,7 @@
4244

4345
from google.api_core.gapic_v1 import ( # noqa: E402
4446
client_info,
47+
client_utils,
4548
requests,
4649
routing_header,
4750
)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Copyright 2026 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+
16+
"""Helpers for client setup and configuration."""
17+
18+
import os
19+
from typing import Callable, Optional, Tuple
20+
21+
from google.auth.exceptions import MutualTLSChannelError # type: ignore
22+
from google.auth.transport import mtls # type: ignore
23+
24+
25+
def use_client_cert_effective() -> bool:
26+
"""Returns whether client certificate should be used for mTLS if the
27+
google-auth version supports should_use_client_cert automatic mTLS
28+
enablement.
29+
30+
Alternatively, read from the GOOGLE_API_USE_CLIENT_CERTIFICATE env var.
31+
32+
Returns:
33+
bool: whether client certificate should be used for mTLS
34+
Raises:
35+
ValueError: (If using a version of google-auth without
36+
should_use_client_cert and GOOGLE_API_USE_CLIENT_CERTIFICATE is
37+
set to an unexpected value.)
38+
"""
39+
# check if google-auth version supports should_use_client_cert for
40+
# automatic mTLS enablement
41+
if hasattr(mtls, "should_use_client_cert"): # pragma: NO COVER
42+
return mtls.should_use_client_cert()
43+
else: # pragma: NO COVER
44+
# if unsupported, fallback to reading from env var
45+
use_client_cert_str = os.getenv(
46+
"GOOGLE_API_USE_CLIENT_CERTIFICATE", "false"
47+
).lower()
48+
if use_client_cert_str not in ("true", "false"):
49+
raise ValueError(
50+
"Environment variable `GOOGLE_API_USE_CLIENT_CERTIFICATE` "
51+
"must be either `true` or `false`"
52+
)
53+
return use_client_cert_str == "true"
54+
55+
56+
def get_client_cert_source(
57+
provided_cert_source: Optional[Callable[[], Tuple[bytes, bytes]]],
58+
use_cert_flag: bool,
59+
) -> Optional[Callable[[], Tuple[bytes, bytes]]]:
60+
"""Return the client cert source to be used by the client.
61+
62+
Args:
63+
provided_cert_source (Callable[[], Tuple[bytes, bytes]]): The client certificate source provided.
64+
use_cert_flag (bool): A flag indicating whether to use the
65+
client certificate.
66+
67+
Returns:
68+
Callable[[], Tuple[bytes, bytes]] or None: The client cert source to be used by the client.
69+
"""
70+
if use_cert_flag:
71+
if provided_cert_source:
72+
return provided_cert_source
73+
elif (
74+
hasattr(mtls, "has_default_client_cert_source")
75+
and mtls.has_default_client_cert_source()
76+
):
77+
return mtls.default_client_cert_source()
78+
else:
79+
raise ValueError(
80+
"Client certificate is required for mTLS, but no client certificate source was provided or found."
81+
)
82+
return None
83+
84+
85+
def read_environment_variables() -> Tuple[bool, str, Optional[str]]:
86+
"""Returns the environment variables used by the client.
87+
88+
Returns:
89+
Tuple[bool, str, Optional[str]]: returns the
90+
GOOGLE_API_USE_CLIENT_CERTIFICATE, GOOGLE_API_USE_MTLS_ENDPOINT,
91+
and GOOGLE_CLOUD_UNIVERSE_DOMAIN environment variables.
92+
93+
Raises:
94+
ValueError: If GOOGLE_API_USE_CLIENT_CERTIFICATE is not
95+
any of ["true", "false"].
96+
google.auth.exceptions.MutualTLSChannelError: If
97+
GOOGLE_API_USE_MTLS_ENDPOINT is not any of
98+
["auto", "never", "always"].
99+
"""
100+
use_client_cert = use_client_cert_effective()
101+
use_mtls_endpoint = os.getenv("GOOGLE_API_USE_MTLS_ENDPOINT", "auto").lower()
102+
universe_domain_env = os.getenv("GOOGLE_CLOUD_UNIVERSE_DOMAIN")
103+
if use_mtls_endpoint not in ("auto", "never", "always"):
104+
raise MutualTLSChannelError(
105+
"Environment variable `GOOGLE_API_USE_MTLS_ENDPOINT` "
106+
"must be `never`, `auto` or `always`"
107+
)
108+
return use_client_cert, use_mtls_endpoint, universe_domain_env

packages/google-api-core/google/api_core/universe.py

Lines changed: 8 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@
1515
"""Helpers for universe domain."""
1616

1717
from typing import Any, Optional
18-
from urllib.parse import urlparse, urlunparse
19-
20-
from google.auth.exceptions import MutualTLSChannelError # type: ignore
2118

2219
DEFAULT_UNIVERSE = "googleapis.com"
2320

@@ -39,32 +36,6 @@ def __init__(self, client_universe, credentials_universe):
3936
super().__init__(message)
4037

4138

42-
def get_universe_domain(
43-
*potential_universes: Optional[str],
44-
default_universe: str,
45-
) -> str:
46-
"""Return the universe domain used by the client.
47-
48-
Args:
49-
*potential_universes (Optional[str]): Potential universe domains in order of preference.
50-
default_universe (str): The default universe domain.
51-
52-
Returns:
53-
str: The universe domain to be used by the client.
54-
55-
Raises:
56-
EmptyUniverseError: If the resolved universe domain is an empty string.
57-
"""
58-
resolved = next(
59-
(x.strip() for x in potential_universes if x is not None),
60-
default_universe,
61-
)
62-
63-
if not resolved:
64-
raise EmptyUniverseError()
65-
return resolved
66-
67-
6839
def determine_domain(
6940
client_universe_domain: Optional[str], universe_domain_env: Optional[str]
7041
) -> str:
@@ -81,11 +52,14 @@ def determine_domain(
8152
Raises:
8253
ValueError: If the universe domain is an empty string.
8354
"""
84-
return get_universe_domain(
85-
client_universe_domain,
86-
universe_domain_env,
87-
default_universe=DEFAULT_UNIVERSE,
88-
)
55+
universe_domain = DEFAULT_UNIVERSE
56+
if client_universe_domain is not None:
57+
universe_domain = client_universe_domain
58+
elif universe_domain_env is not None:
59+
universe_domain = universe_domain_env
60+
if len(universe_domain.strip()) == 0:
61+
raise EmptyUniverseError
62+
return universe_domain
8963

9064

9165
def compare_domains(client_universe: str, credentials: Any) -> bool:
@@ -106,94 +80,3 @@ def compare_domains(client_universe: str, credentials: Any) -> bool:
10680
if client_universe != credentials_universe:
10781
raise UniverseMismatchError(client_universe, credentials_universe)
10882
return True
109-
110-
111-
def get_default_mtls_endpoint(api_endpoint: Optional[str]) -> Optional[str]:
112-
"""Converts api endpoint to mTLS endpoint.
113-
114-
Convert "*.sandbox.googleapis.com" and "*.googleapis.com" to
115-
"*.mtls.sandbox.googleapis.com" and "*.mtls.googleapis.com" respectively.
116-
Other URLs (including those that do not match these domain suffixes or
117-
already contain '.mtls.') are passed through as-is.
118-
119-
Args:
120-
api_endpoint (Optional[str]): the api endpoint to convert.
121-
122-
Returns:
123-
Optional[str]: converted mTLS api endpoint.
124-
"""
125-
if not api_endpoint or ".mtls." in api_endpoint.lower():
126-
return api_endpoint
127-
128-
has_scheme = "://" in api_endpoint
129-
if not has_scheme:
130-
parsed = urlparse("//" + api_endpoint)
131-
else:
132-
parsed = urlparse(api_endpoint)
133-
134-
host = parsed.hostname
135-
if not host:
136-
return api_endpoint
137-
138-
port = f":{parsed.port}" if parsed.port else ""
139-
140-
lowered_host = host.lower()
141-
suffix_sandbox = ".sandbox.googleapis.com"
142-
suffix_google = ".googleapis.com"
143-
if lowered_host.endswith(suffix_sandbox):
144-
new_host = host[: -len(suffix_sandbox)] + ".mtls.sandbox.googleapis.com"
145-
elif lowered_host.endswith(suffix_google):
146-
new_host = host[: -len(suffix_google)] + ".mtls.googleapis.com"
147-
else:
148-
return api_endpoint
149-
150-
netloc = new_host + port
151-
new_parsed = parsed._replace(netloc=netloc)
152-
153-
if not has_scheme:
154-
return urlunparse(new_parsed)[2:]
155-
else:
156-
return urlunparse(new_parsed)
157-
158-
159-
def get_api_endpoint(
160-
api_override: Optional[str],
161-
universe_domain: str,
162-
default_universe: str,
163-
default_mtls_endpoint: Optional[str],
164-
default_endpoint_template: str,
165-
use_mtls: bool,
166-
) -> str:
167-
"""Return the API endpoint used by the client.
168-
169-
Args:
170-
api_override (Optional[str]): The API endpoint override. If specified,
171-
this is always returned.
172-
universe_domain (str): The universe domain used by the client.
173-
default_universe (str): The default universe domain.
174-
default_mtls_endpoint (Optional[str]): The default mTLS endpoint.
175-
default_endpoint_template (str): The default endpoint template containing
176-
a placeholder `{UNIVERSE_DOMAIN}`.
177-
use_mtls (bool): Whether to use the mTLS endpoint.
178-
179-
Returns:
180-
str: The API endpoint to be used by the client.
181-
182-
Raises:
183-
google.auth.exceptions.MutualTLSChannelError: If mTLS is requested but
184-
not supported in the configured universe domain.
185-
ValueError: If mTLS is requested but no mTLS endpoint is available.
186-
"""
187-
if api_override is not None:
188-
return api_override
189-
190-
if use_mtls:
191-
if universe_domain.lower() != default_universe.lower():
192-
raise MutualTLSChannelError(
193-
f"mTLS is not supported in any universe other than {default_universe}."
194-
)
195-
if not default_mtls_endpoint:
196-
raise ValueError("mTLS endpoint is not available.")
197-
return default_mtls_endpoint
198-
else:
199-
return default_endpoint_template.format(UNIVERSE_DOMAIN=universe_domain)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright 2026 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+
import os
16+
from unittest import mock
17+
18+
import pytest
19+
20+
21+
@pytest.fixture(scope="session", autouse=True)
22+
def mock_mtls_env():
23+
"""Autouse session-scoped fixture to isolate unit tests from workstation mTLS environments."""
24+
with mock.patch.dict(
25+
os.environ,
26+
{
27+
"GOOGLE_API_USE_CLIENT_CERTIFICATE": "false",
28+
"CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE": "false",
29+
},
30+
):
31+
yield

0 commit comments

Comments
 (0)