Skip to content

Commit a20bbfc

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 1c65d43 commit a20bbfc

3 files changed

Lines changed: 240 additions & 241 deletions

File tree

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

Lines changed: 70 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -13,121 +13,96 @@
1313
# limitations under the License.
1414
#
1515

16-
from typing import Optional
17-
from urllib.parse import urlparse, urlunparse
16+
"""Helpers for client setup and configuration."""
1817

19-
from google.auth.exceptions import MutualTLSChannelError # type: ignore
18+
import os
19+
from typing import Callable, Optional, Tuple
2020

21+
from google.auth.exceptions import MutualTLSChannelError # type: ignore
22+
from google.auth.transport import mtls # type: ignore
2123

22-
def get_default_mtls_endpoint(api_endpoint: Optional[str]) -> Optional[str]:
23-
"""Converts api endpoint to mTLS endpoint.
2424

25-
Convert "*.sandbox.googleapis.com" and "*.googleapis.com" to
26-
"*.mtls.sandbox.googleapis.com" and "*.mtls.googleapis.com" respectively.
27-
Other URLs (including those that do not match these domain suffixes or
28-
already contain '.mtls.') are passed through as-is.
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.
2929
30-
Args:
31-
api_endpoint (Optional[str]): the api endpoint to convert.
30+
Alternatively, read from the GOOGLE_API_USE_CLIENT_CERTIFICATE env var.
3231
3332
Returns:
34-
Optional[str]: converted mTLS api endpoint.
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.)
3538
"""
36-
if not api_endpoint or ".mtls." in api_endpoint.lower():
37-
return api_endpoint
38-
39-
has_scheme = "://" in api_endpoint
40-
if not has_scheme:
41-
parsed = urlparse("//" + api_endpoint)
42-
else:
43-
parsed = urlparse(api_endpoint)
44-
45-
host = parsed.hostname
46-
if not host:
47-
return api_endpoint
48-
49-
port = f":{parsed.port}" if parsed.port else ""
50-
51-
lowered_host = host.lower()
52-
if lowered_host.endswith(".sandbox.googleapis.com"):
53-
new_host = host[:-23] + ".mtls.sandbox.googleapis.com"
54-
elif lowered_host.endswith(".googleapis.com"):
55-
new_host = host[:-15] + ".mtls.googleapis.com"
56-
else:
57-
return api_endpoint
58-
59-
netloc = new_host + port
60-
new_parsed = parsed._replace(netloc=netloc)
61-
62-
if not has_scheme:
63-
return urlunparse(new_parsed)[2:]
64-
else:
65-
return urlunparse(new_parsed)
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"
6654

6755

68-
def get_api_endpoint(
69-
api_override: Optional[str],
70-
universe_domain: str,
71-
default_universe: str,
72-
default_mtls_endpoint: Optional[str],
73-
default_endpoint_template: str,
74-
use_mtls: bool,
75-
) -> str:
76-
"""Return the API endpoint used by the client.
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.
7761
7862
Args:
79-
api_override (Optional[str]): The API endpoint override. If specified,
80-
this is always returned.
81-
universe_domain (str): The universe domain used by the client.
82-
default_universe (str): The default universe domain.
83-
default_mtls_endpoint (Optional[str]): The default mTLS endpoint.
84-
default_endpoint_template (str): The default endpoint template containing
85-
a placeholder `{UNIVERSE_DOMAIN}`.
86-
use_mtls (bool): Whether to use the mTLS endpoint.
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.
8766
8867
Returns:
89-
str: The API endpoint to be used by the client.
90-
91-
Raises:
92-
google.auth.exceptions.MutualTLSChannelError: If mTLS is requested but
93-
not supported in the configured universe domain.
94-
ValueError: If mTLS is requested but no mTLS endpoint is available.
68+
Callable[[], Tuple[bytes, bytes]] or None: The client cert source to be used by the client.
9569
"""
96-
if api_override is not None:
97-
return api_override
98-
99-
if use_mtls:
100-
if universe_domain.lower() != default_universe.lower():
101-
raise MutualTLSChannelError(
102-
f"mTLS is not supported in any universe other than {default_universe}."
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."
10381
)
104-
if not default_mtls_endpoint:
105-
raise ValueError("mTLS endpoint is not available.")
106-
return default_mtls_endpoint
107-
else:
108-
return default_endpoint_template.format(UNIVERSE_DOMAIN=universe_domain)
109-
82+
return None
11083

111-
def get_universe_domain(
112-
universe_domain: Optional[str],
113-
default_universe: str = "googleapis.com",
114-
) -> str:
115-
"""Return the universe domain used by the client.
11684

117-
Args:
118-
universe_domain (Optional[str]): The configured universe domain.
119-
default_universe (str): The default universe domain.
85+
def read_environment_variables() -> Tuple[bool, str, Optional[str]]:
86+
"""Returns the environment variables used by the client.
12087
12188
Returns:
122-
str: The universe domain to be used by the client.
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.
12392
12493
Raises:
125-
ValueError: If the resolved universe domain is an empty string.
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"].
12699
"""
127-
resolved = (
128-
universe_domain.strip() if universe_domain is not None else default_universe
129-
)
130-
131-
if not resolved:
132-
raise ValueError("Universe Domain cannot be an empty string.")
133-
return resolved
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
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)