Skip to content

Commit d461da7

Browse files
feat(api-core): add get_universe_domain helper to universe.py (#17799)
## Description This PR consolidates and centralizes all universe-related configuration helpers and routing logic into `google/api_core/universe.py`. This avoids polluting `gapic_v1` with duplicate routing logic and keeps universe/endpoint utilities unified. ### Changes: 1. **get_universe_domain**: - Added a more general version of `get_universe_domain` that accepts `*potential_universes` in order of preference. - Made `default_universe` a required keyword-only argument to align with other client helpers. 2. **determine_domain**: - Refactored `determine_domain` to wrap `get_universe_domain` to preserve backward compatibility. 3. **Endpoint & mTLS Helpers**: - Moved `get_api_endpoint` and `get_default_mtls_endpoint` from the `gapic_v1.client_utils` namespace into `google/api_core/universe.py`. 4. **Unit Tests**: - Migrated all related test cases to `tests/unit/test_universe.py`. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 26d43c1 commit d461da7

2 files changed

Lines changed: 322 additions & 8 deletions

File tree

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

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
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
1821

1922
DEFAULT_UNIVERSE = "googleapis.com"
2023

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

3841

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+
3968
def determine_domain(
4069
client_universe_domain: Optional[str], universe_domain_env: Optional[str]
4170
) -> str:
@@ -52,14 +81,11 @@ def determine_domain(
5281
Raises:
5382
ValueError: If the universe domain is an empty string.
5483
"""
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
84+
return get_universe_domain(
85+
client_universe_domain,
86+
universe_domain_env,
87+
default_universe=DEFAULT_UNIVERSE,
88+
)
6389

6490

6591
def compare_domains(client_universe: str, credentials: Any) -> bool:
@@ -80,3 +106,94 @@ def compare_domains(client_universe: str, credentials: Any) -> bool:
80106
if client_universe != credentials_universe:
81107
raise UniverseMismatchError(client_universe, credentials_universe)
82108
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)

packages/google-api-core/tests/unit/test_universe.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import pytest
16+
from google.auth.exceptions import MutualTLSChannelError
1617

1718
from google.api_core import universe
1819

@@ -62,3 +63,199 @@ def test_compare_domains():
6263
universe.compare_domains(fake_domain, _Fake_Credentials(another_fake_domain))
6364
assert str(excinfo.value).find(fake_domain) >= 0
6465
assert str(excinfo.value).find(another_fake_domain) >= 0
66+
67+
68+
def test_get_universe_domain():
69+
# When universe_domain is provided
70+
assert (
71+
universe.get_universe_domain("foo.com", default_universe="default.com")
72+
== "foo.com"
73+
)
74+
assert (
75+
universe.get_universe_domain(" foo.com ", default_universe="default.com")
76+
== "foo.com"
77+
)
78+
79+
# When universe_domain is None, falls back to default_universe
80+
assert (
81+
universe.get_universe_domain(None, default_universe="default.com")
82+
== "default.com"
83+
)
84+
85+
# When multiple potential universes are provided, resolves in order of preference
86+
assert (
87+
universe.get_universe_domain(
88+
"foo.com", "bar.com", default_universe="default.com"
89+
)
90+
== "foo.com"
91+
)
92+
assert (
93+
universe.get_universe_domain(None, "bar.com", default_universe="default.com")
94+
== "bar.com"
95+
)
96+
assert (
97+
universe.get_universe_domain(None, None, default_universe="default.com")
98+
== "default.com"
99+
)
100+
101+
# EmptyUniverseError raised when resolved value is empty string
102+
with pytest.raises(universe.EmptyUniverseError) as excinfo:
103+
universe.get_universe_domain("", default_universe="default.com")
104+
assert str(excinfo.value) == "Universe Domain cannot be an empty string."
105+
106+
with pytest.raises(universe.EmptyUniverseError) as excinfo:
107+
universe.get_universe_domain(" ", default_universe="default.com")
108+
assert str(excinfo.value) == "Universe Domain cannot be an empty string."
109+
110+
with pytest.raises(universe.EmptyUniverseError) as excinfo:
111+
universe.get_universe_domain(None, "", default_universe="default.com")
112+
assert str(excinfo.value) == "Universe Domain cannot be an empty string."
113+
114+
115+
def test_get_default_mtls_endpoint():
116+
# Test valid API endpoints
117+
assert (
118+
universe.get_default_mtls_endpoint("foo.googleapis.com")
119+
== "foo.mtls.googleapis.com"
120+
)
121+
assert (
122+
universe.get_default_mtls_endpoint("foo.sandbox.googleapis.com")
123+
== "foo.mtls.sandbox.googleapis.com"
124+
)
125+
# Test case-insensitivity
126+
assert (
127+
universe.get_default_mtls_endpoint("foo.GoogleAPIs.com")
128+
== "foo.mtls.googleapis.com"
129+
)
130+
assert (
131+
universe.get_default_mtls_endpoint("foo.Sandbox.GoogleAPIs.com")
132+
== "foo.mtls.sandbox.googleapis.com"
133+
)
134+
135+
# Test valid API endpoints with schemes
136+
assert (
137+
universe.get_default_mtls_endpoint("https://foo.googleapis.com")
138+
== "https://foo.mtls.googleapis.com"
139+
)
140+
assert (
141+
universe.get_default_mtls_endpoint("http://foo.googleapis.com:8080/v1")
142+
== "http://foo.mtls.googleapis.com:8080/v1"
143+
)
144+
145+
# Test valid API endpoints with ports
146+
assert (
147+
universe.get_default_mtls_endpoint("foo.googleapis.com:443")
148+
== "foo.mtls.googleapis.com:443"
149+
)
150+
assert (
151+
universe.get_default_mtls_endpoint("foo.sandbox.googleapis.com:443")
152+
== "foo.mtls.sandbox.googleapis.com:443"
153+
)
154+
# Test case-insensitivity with ports
155+
assert (
156+
universe.get_default_mtls_endpoint("foo.GoogleAPIs.com:443")
157+
== "foo.mtls.googleapis.com:443"
158+
)
159+
assert (
160+
universe.get_default_mtls_endpoint("foo.Sandbox.GoogleAPIs.com:443")
161+
== "foo.mtls.sandbox.googleapis.com:443"
162+
)
163+
164+
# Test endpoints that shouldn't be converted
165+
assert (
166+
universe.get_default_mtls_endpoint("foo.mtls.googleapis.com")
167+
== "foo.mtls.googleapis.com"
168+
)
169+
assert universe.get_default_mtls_endpoint("foo.com") == "foo.com"
170+
assert universe.get_default_mtls_endpoint("foo.com:8080") == "foo.com:8080"
171+
172+
# Test empty/None endpoints
173+
assert universe.get_default_mtls_endpoint("") == ""
174+
assert universe.get_default_mtls_endpoint(None) is None
175+
176+
# Test endpoints without host
177+
assert universe.get_default_mtls_endpoint("http://") == "http://"
178+
assert universe.get_default_mtls_endpoint("https://") == "https://"
179+
180+
181+
@pytest.mark.parametrize(
182+
"api_override,universe_domain,default_universe,default_mtls_endpoint,default_endpoint_template,use_mtls,expected",
183+
[
184+
(
185+
"foo.com",
186+
"googleapis.com",
187+
"googleapis.com",
188+
"foo.mtls.googleapis.com",
189+
"foo.{UNIVERSE_DOMAIN}",
190+
True,
191+
"foo.com",
192+
),
193+
(
194+
None,
195+
"googleapis.com",
196+
"googleapis.com",
197+
"foo.mtls.googleapis.com",
198+
"foo.{UNIVERSE_DOMAIN}",
199+
True,
200+
"foo.mtls.googleapis.com",
201+
),
202+
(
203+
None,
204+
"googleapis.com",
205+
"googleapis.com",
206+
"foo.mtls.googleapis.com",
207+
"foo.{UNIVERSE_DOMAIN}",
208+
False,
209+
"foo.googleapis.com",
210+
),
211+
(
212+
None,
213+
"bar.com",
214+
"googleapis.com",
215+
"foo.mtls.googleapis.com",
216+
"foo.{UNIVERSE_DOMAIN}",
217+
True,
218+
MutualTLSChannelError,
219+
),
220+
(
221+
None,
222+
"googleapis.com",
223+
"googleapis.com",
224+
None,
225+
"foo.{UNIVERSE_DOMAIN}",
226+
True,
227+
ValueError,
228+
),
229+
],
230+
)
231+
def test_get_api_endpoint(
232+
api_override,
233+
universe_domain,
234+
default_universe,
235+
default_mtls_endpoint,
236+
default_endpoint_template,
237+
use_mtls,
238+
expected,
239+
):
240+
if isinstance(expected, type) and issubclass(expected, Exception):
241+
with pytest.raises(expected):
242+
universe.get_api_endpoint(
243+
api_override,
244+
universe_domain,
245+
default_universe,
246+
default_mtls_endpoint,
247+
default_endpoint_template,
248+
use_mtls,
249+
)
250+
else:
251+
assert (
252+
universe.get_api_endpoint(
253+
api_override,
254+
universe_domain,
255+
default_universe,
256+
default_mtls_endpoint,
257+
default_endpoint_template,
258+
use_mtls,
259+
)
260+
== expected
261+
)

0 commit comments

Comments
 (0)