Skip to content

Commit 85cd706

Browse files
committed
feat(api-core): add endpoint routing logic to gapic_v1 public helpers
1 parent d461da7 commit 85cd706

4 files changed

Lines changed: 306 additions & 1 deletion

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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
from typing import Optional
17+
from urllib.parse import urlparse, urlunparse
18+
19+
from google.auth.exceptions import MutualTLSChannelError # type: ignore
20+
21+
from google.api_core.universe import get_universe_domain # noqa: F401
22+
23+
24+
def get_default_mtls_endpoint(api_endpoint: Optional[str]) -> Optional[str]:
25+
"""Converts api endpoint to mTLS endpoint.
26+
27+
Convert "*.sandbox.googleapis.com" and "*.googleapis.com" to
28+
"*.mtls.sandbox.googleapis.com" and "*.mtls.googleapis.com" respectively.
29+
Other URLs (including those that do not match these domain suffixes or
30+
already contain '.mtls.') are passed through as-is.
31+
32+
Args:
33+
api_endpoint (Optional[str]): the api endpoint to convert.
34+
35+
Returns:
36+
Optional[str]: converted mTLS api endpoint.
37+
"""
38+
if not api_endpoint or ".mtls." in api_endpoint.lower():
39+
return api_endpoint
40+
41+
has_scheme = "://" in api_endpoint
42+
if not has_scheme:
43+
parsed = urlparse("//" + api_endpoint)
44+
else:
45+
parsed = urlparse(api_endpoint)
46+
47+
host = parsed.hostname
48+
if not host:
49+
return api_endpoint
50+
51+
port = f":{parsed.port}" if parsed.port else ""
52+
53+
lowered_host = host.lower()
54+
if lowered_host.endswith(".sandbox.googleapis.com"):
55+
new_host = host[:-23] + ".mtls.sandbox.googleapis.com"
56+
elif lowered_host.endswith(".googleapis.com"):
57+
new_host = host[:-15] + ".mtls.googleapis.com"
58+
else:
59+
return api_endpoint
60+
61+
netloc = new_host + port
62+
new_parsed = parsed._replace(netloc=netloc)
63+
64+
if not has_scheme:
65+
return urlunparse(new_parsed)[2:]
66+
else:
67+
return urlunparse(new_parsed)
68+
69+
70+
def get_api_endpoint(
71+
api_override: Optional[str],
72+
universe_domain: str,
73+
default_universe: str,
74+
default_mtls_endpoint: Optional[str],
75+
default_endpoint_template: str,
76+
use_mtls: bool,
77+
) -> str:
78+
"""Return the API endpoint used by the client.
79+
80+
Args:
81+
api_override (Optional[str]): The API endpoint override. If specified,
82+
this is always returned.
83+
universe_domain (str): The universe domain used by the client.
84+
default_universe (str): The default universe domain.
85+
default_mtls_endpoint (Optional[str]): The default mTLS endpoint.
86+
default_endpoint_template (str): The default endpoint template containing
87+
a placeholder `{UNIVERSE_DOMAIN}`.
88+
use_mtls (bool): Whether to use the mTLS endpoint.
89+
90+
Returns:
91+
str: The API endpoint to be used by the client.
92+
93+
Raises:
94+
google.auth.exceptions.MutualTLSChannelError: If mTLS is requested but
95+
not supported in the configured universe domain.
96+
ValueError: If mTLS is requested but no mTLS endpoint is available.
97+
"""
98+
if api_override is not None:
99+
return api_override
100+
101+
if use_mtls:
102+
if universe_domain.lower() != default_universe.lower():
103+
raise MutualTLSChannelError(
104+
f"mTLS is not supported in any universe other than {default_universe}."
105+
)
106+
if not default_mtls_endpoint:
107+
raise ValueError("mTLS endpoint is not available.")
108+
return default_mtls_endpoint
109+
else:
110+
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
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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 pytest
16+
from google.auth.exceptions import MutualTLSChannelError
17+
18+
from google.api_core.gapic_v1.client_utils import (
19+
get_api_endpoint,
20+
get_default_mtls_endpoint,
21+
)
22+
23+
24+
def test_get_default_mtls_endpoint():
25+
# Test valid API endpoints
26+
assert get_default_mtls_endpoint("foo.googleapis.com") == "foo.mtls.googleapis.com"
27+
assert (
28+
get_default_mtls_endpoint("foo.sandbox.googleapis.com")
29+
== "foo.mtls.sandbox.googleapis.com"
30+
)
31+
# Test case-insensitivity
32+
assert get_default_mtls_endpoint("foo.GoogleAPIs.com") == "foo.mtls.googleapis.com"
33+
assert (
34+
get_default_mtls_endpoint("foo.Sandbox.GoogleAPIs.com")
35+
== "foo.mtls.sandbox.googleapis.com"
36+
)
37+
38+
# Test valid API endpoints with schemes
39+
assert (
40+
get_default_mtls_endpoint("https://foo.googleapis.com")
41+
== "https://foo.mtls.googleapis.com"
42+
)
43+
assert (
44+
get_default_mtls_endpoint("http://foo.googleapis.com:8080/v1")
45+
== "http://foo.mtls.googleapis.com:8080/v1"
46+
)
47+
48+
# Test valid API endpoints with ports
49+
assert (
50+
get_default_mtls_endpoint("foo.googleapis.com:443")
51+
== "foo.mtls.googleapis.com:443"
52+
)
53+
assert (
54+
get_default_mtls_endpoint("foo.sandbox.googleapis.com:443")
55+
== "foo.mtls.sandbox.googleapis.com:443"
56+
)
57+
# Test case-insensitivity with ports
58+
assert (
59+
get_default_mtls_endpoint("foo.GoogleAPIs.com:443")
60+
== "foo.mtls.googleapis.com:443"
61+
)
62+
assert (
63+
get_default_mtls_endpoint("foo.Sandbox.GoogleAPIs.com:443")
64+
== "foo.mtls.sandbox.googleapis.com:443"
65+
)
66+
67+
# Test endpoints that shouldn't be converted
68+
assert (
69+
get_default_mtls_endpoint("foo.mtls.googleapis.com")
70+
== "foo.mtls.googleapis.com"
71+
)
72+
assert get_default_mtls_endpoint("foo.com") == "foo.com"
73+
assert get_default_mtls_endpoint("foo.com:8080") == "foo.com:8080"
74+
assert get_default_mtls_endpoint("/") == "/"
75+
76+
# Test empty/None endpoints
77+
assert get_default_mtls_endpoint("") == ""
78+
assert get_default_mtls_endpoint(None) is None
79+
80+
81+
@pytest.mark.parametrize(
82+
"api_override,universe_domain,default_universe,default_mtls_endpoint,default_endpoint_template,use_mtls,expected",
83+
[
84+
(
85+
"foo.com",
86+
"googleapis.com",
87+
"googleapis.com",
88+
"foo.mtls.googleapis.com",
89+
"foo.{UNIVERSE_DOMAIN}",
90+
True,
91+
"foo.com",
92+
),
93+
(
94+
None,
95+
"googleapis.com",
96+
"googleapis.com",
97+
"foo.mtls.googleapis.com",
98+
"foo.{UNIVERSE_DOMAIN}",
99+
True,
100+
"foo.mtls.googleapis.com",
101+
),
102+
(
103+
None,
104+
"googleapis.com",
105+
"googleapis.com",
106+
"foo.mtls.googleapis.com",
107+
"foo.{UNIVERSE_DOMAIN}",
108+
False,
109+
"foo.googleapis.com",
110+
),
111+
(
112+
None,
113+
"bar.com",
114+
"googleapis.com",
115+
"foo.mtls.googleapis.com",
116+
"foo.{UNIVERSE_DOMAIN}",
117+
True,
118+
MutualTLSChannelError,
119+
),
120+
(
121+
None,
122+
"googleapis.com",
123+
"googleapis.com",
124+
None,
125+
"foo.{UNIVERSE_DOMAIN}",
126+
True,
127+
ValueError,
128+
),
129+
],
130+
)
131+
def test_get_api_endpoint(
132+
api_override,
133+
universe_domain,
134+
default_universe,
135+
default_mtls_endpoint,
136+
default_endpoint_template,
137+
use_mtls,
138+
expected,
139+
):
140+
if isinstance(expected, type) and issubclass(expected, Exception):
141+
with pytest.raises(expected):
142+
get_api_endpoint(
143+
api_override,
144+
universe_domain,
145+
default_universe,
146+
default_mtls_endpoint,
147+
default_endpoint_template,
148+
use_mtls,
149+
)
150+
else:
151+
assert (
152+
get_api_endpoint(
153+
api_override,
154+
universe_domain,
155+
default_universe,
156+
default_mtls_endpoint,
157+
default_endpoint_template,
158+
use_mtls,
159+
)
160+
== expected
161+
)

0 commit comments

Comments
 (0)