Skip to content

Commit 86fea61

Browse files
committed
feat(api-core): add get_universe_domain helper to universe.py
1 parent 07f7503 commit 86fea61

2 files changed

Lines changed: 312 additions & 8 deletions

File tree

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

Lines changed: 124 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,93 @@ 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+
if lowered_host.endswith(".sandbox.googleapis.com"):
142+
new_host = host[:-23] + ".mtls.sandbox.googleapis.com"
143+
elif lowered_host.endswith(".googleapis.com"):
144+
new_host = host[:-15] + ".mtls.googleapis.com"
145+
else:
146+
return api_endpoint
147+
148+
netloc = new_host + port
149+
new_parsed = parsed._replace(netloc=netloc)
150+
151+
if not has_scheme:
152+
return urlunparse(new_parsed)[2:]
153+
else:
154+
return urlunparse(new_parsed)
155+
156+
157+
def get_api_endpoint(
158+
api_override: Optional[str],
159+
universe_domain: str,
160+
default_universe: str,
161+
default_mtls_endpoint: Optional[str],
162+
default_endpoint_template: str,
163+
use_mtls: bool,
164+
) -> str:
165+
"""Return the API endpoint used by the client.
166+
167+
Args:
168+
api_override (Optional[str]): The API endpoint override. If specified,
169+
this is always returned.
170+
universe_domain (str): The universe domain used by the client.
171+
default_universe (str): The default universe domain.
172+
default_mtls_endpoint (Optional[str]): The default mTLS endpoint.
173+
default_endpoint_template (str): The default endpoint template containing
174+
a placeholder `{UNIVERSE_DOMAIN}`.
175+
use_mtls (bool): Whether to use the mTLS endpoint.
176+
177+
Returns:
178+
str: The API endpoint to be used by the client.
179+
180+
Raises:
181+
google.auth.exceptions.MutualTLSChannelError: If mTLS is requested but
182+
not supported in the configured universe domain.
183+
ValueError: If mTLS is requested but no mTLS endpoint is available.
184+
"""
185+
if api_override is not None:
186+
return api_override
187+
188+
if use_mtls:
189+
if universe_domain.lower() != default_universe.lower():
190+
raise MutualTLSChannelError(
191+
f"mTLS is not supported in any universe other than {default_universe}."
192+
)
193+
if not default_mtls_endpoint:
194+
raise ValueError("mTLS endpoint is not available.")
195+
return default_mtls_endpoint
196+
else:
197+
return default_endpoint_template.format(UNIVERSE_DOMAIN=universe_domain)
198+

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

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import pytest
1616

1717
from google.api_core import universe
18+
from google.auth.exceptions import MutualTLSChannelError
1819

1920

2021
class _Fake_Credentials:
@@ -62,3 +63,190 @@ 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 universe.get_default_mtls_endpoint("foo.googleapis.com") == "foo.mtls.googleapis.com"
118+
assert (
119+
universe.get_default_mtls_endpoint("foo.sandbox.googleapis.com")
120+
== "foo.mtls.sandbox.googleapis.com"
121+
)
122+
# Test case-insensitivity
123+
assert universe.get_default_mtls_endpoint("foo.GoogleAPIs.com") == "foo.mtls.googleapis.com"
124+
assert (
125+
universe.get_default_mtls_endpoint("foo.Sandbox.GoogleAPIs.com")
126+
== "foo.mtls.sandbox.googleapis.com"
127+
)
128+
129+
# Test valid API endpoints with schemes
130+
assert (
131+
universe.get_default_mtls_endpoint("https://foo.googleapis.com")
132+
== "https://foo.mtls.googleapis.com"
133+
)
134+
assert (
135+
universe.get_default_mtls_endpoint("http://foo.googleapis.com:8080/v1")
136+
== "http://foo.mtls.googleapis.com:8080/v1"
137+
)
138+
139+
# Test valid API endpoints with ports
140+
assert (
141+
universe.get_default_mtls_endpoint("foo.googleapis.com:443")
142+
== "foo.mtls.googleapis.com:443"
143+
)
144+
assert (
145+
universe.get_default_mtls_endpoint("foo.sandbox.googleapis.com:443")
146+
== "foo.mtls.sandbox.googleapis.com:443"
147+
)
148+
# Test case-insensitivity with ports
149+
assert (
150+
universe.get_default_mtls_endpoint("foo.GoogleAPIs.com:443")
151+
== "foo.mtls.googleapis.com:443"
152+
)
153+
assert (
154+
universe.get_default_mtls_endpoint("foo.Sandbox.GoogleAPIs.com:443")
155+
== "foo.mtls.sandbox.googleapis.com:443"
156+
)
157+
158+
# Test endpoints that shouldn't be converted
159+
assert (
160+
universe.get_default_mtls_endpoint("foo.mtls.googleapis.com")
161+
== "foo.mtls.googleapis.com"
162+
)
163+
assert universe.get_default_mtls_endpoint("foo.com") == "foo.com"
164+
assert universe.get_default_mtls_endpoint("foo.com:8080") == "foo.com:8080"
165+
166+
# Test empty/None endpoints
167+
assert universe.get_default_mtls_endpoint("") == ""
168+
assert universe.get_default_mtls_endpoint(None) is None
169+
170+
171+
@pytest.mark.parametrize(
172+
"api_override,universe_domain,default_universe,default_mtls_endpoint,default_endpoint_template,use_mtls,expected",
173+
[
174+
(
175+
"foo.com",
176+
"googleapis.com",
177+
"googleapis.com",
178+
"foo.mtls.googleapis.com",
179+
"foo.{UNIVERSE_DOMAIN}",
180+
True,
181+
"foo.com",
182+
),
183+
(
184+
None,
185+
"googleapis.com",
186+
"googleapis.com",
187+
"foo.mtls.googleapis.com",
188+
"foo.{UNIVERSE_DOMAIN}",
189+
True,
190+
"foo.mtls.googleapis.com",
191+
),
192+
(
193+
None,
194+
"googleapis.com",
195+
"googleapis.com",
196+
"foo.mtls.googleapis.com",
197+
"foo.{UNIVERSE_DOMAIN}",
198+
False,
199+
"foo.googleapis.com",
200+
),
201+
(
202+
None,
203+
"bar.com",
204+
"googleapis.com",
205+
"foo.mtls.googleapis.com",
206+
"foo.{UNIVERSE_DOMAIN}",
207+
True,
208+
MutualTLSChannelError,
209+
),
210+
(
211+
None,
212+
"googleapis.com",
213+
"googleapis.com",
214+
None,
215+
"foo.{UNIVERSE_DOMAIN}",
216+
True,
217+
ValueError,
218+
),
219+
],
220+
)
221+
def test_get_api_endpoint(
222+
api_override,
223+
universe_domain,
224+
default_universe,
225+
default_mtls_endpoint,
226+
default_endpoint_template,
227+
use_mtls,
228+
expected,
229+
):
230+
if isinstance(expected, type) and issubclass(expected, Exception):
231+
with pytest.raises(expected):
232+
universe.get_api_endpoint(
233+
api_override,
234+
universe_domain,
235+
default_universe,
236+
default_mtls_endpoint,
237+
default_endpoint_template,
238+
use_mtls,
239+
)
240+
else:
241+
assert (
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+
== expected
251+
)
252+

0 commit comments

Comments
 (0)