Skip to content

Commit 06260e4

Browse files
committed
feat(api-core): move universe and endpoint routing logic to gapic_v1 public helpers
Introduces routing module containing get_api_endpoint, get_default_mtls_endpoint, and get_universe_domain helpers. Exposes the module as public in gapic_v1.
1 parent 75d30d5 commit 06260e4

6 files changed

Lines changed: 313 additions & 5 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@
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.routing",
2829
"google.api_core.gapic_v1.routing_header",
2930
}
30-
__all__ = ["client_info", "routing_header"]
31+
__all__ = ["client_info", "routing", "routing_header"]
3132

3233
if _has_grpc:
3334
__lazy_modules__.update(
@@ -41,6 +42,7 @@
4142

4243
from google.api_core.gapic_v1 import ( # noqa: E402
4344
client_info,
45+
routing,
4446
routing_header,
4547
)
4648

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
"""Helpers for routing and endpoint resolution."""
18+
19+
import re
20+
from typing import Any, Optional
21+
22+
from google.auth.exceptions import MutualTLSChannelError # type: ignore
23+
24+
_MTLS_ENDPOINT_RE = re.compile(
25+
r"(?P<name>[^.]+)(?P<mtls>\.mtls)?(?P<sandbox>\.sandbox)?"
26+
r"(?P<googledomain>\.googleapis\.com)?"
27+
)
28+
29+
30+
def get_default_mtls_endpoint(api_endpoint: Optional[str]) -> Optional[str]:
31+
"""Converts api endpoint to mTLS endpoint.
32+
33+
Convert "*.sandbox.googleapis.com" and "*.googleapis.com" to
34+
"*.mtls.sandbox.googleapis.com" and "*.mtls.googleapis.com" respectively.
35+
Args:
36+
api_endpoint (Optional[str]): the api endpoint to convert.
37+
Returns:
38+
Optional[str]: converted mTLS api endpoint.
39+
"""
40+
if not api_endpoint:
41+
return api_endpoint
42+
43+
m = _MTLS_ENDPOINT_RE.match(api_endpoint)
44+
if m is None:
45+
# Could not parse api_endpoint; return as-is.
46+
return api_endpoint
47+
48+
name, mtls_group, sandbox, googledomain = m.groups()
49+
if mtls_group or not googledomain:
50+
return api_endpoint
51+
52+
if sandbox:
53+
return api_endpoint.replace(
54+
"sandbox.googleapis.com", "mtls.sandbox.googleapis.com"
55+
)
56+
57+
return api_endpoint.replace(".googleapis.com", ".mtls.googleapis.com")
58+
59+
60+
def get_api_endpoint(
61+
api_override: Optional[str],
62+
client_cert_source: Optional[Any],
63+
universe_domain: str,
64+
use_mtls_endpoint: str,
65+
default_universe: str,
66+
default_mtls_endpoint: Optional[str],
67+
default_endpoint_template: Optional[str],
68+
) -> Optional[str]:
69+
"""Return the API endpoint used by the client."""
70+
if api_override is not None:
71+
return api_override
72+
elif use_mtls_endpoint == "always" or (
73+
use_mtls_endpoint == "auto" and client_cert_source
74+
):
75+
if universe_domain.lower() != default_universe.lower():
76+
raise MutualTLSChannelError(
77+
f"mTLS is not supported in any universe other than {default_universe}."
78+
)
79+
return default_mtls_endpoint
80+
else:
81+
return (
82+
default_endpoint_template.format(UNIVERSE_DOMAIN=universe_domain)
83+
if default_endpoint_template
84+
else None
85+
)
86+
87+
88+
def get_universe_domain(
89+
client_universe_domain: Optional[str],
90+
universe_domain_env: Optional[str],
91+
default_universe: str,
92+
) -> str:
93+
"""Return the universe domain used by the client."""
94+
universe_domain = default_universe
95+
if client_universe_domain is not None:
96+
universe_domain = client_universe_domain.strip()
97+
elif universe_domain_env is not None:
98+
universe_domain = universe_domain_env.strip()
99+
if not universe_domain:
100+
raise ValueError("Universe Domain cannot be an empty string.")
101+
return universe_domain

packages/google-api-core/noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ def prerelease_deps(session):
350350
@nox.session(python=DEFAULT_PYTHON_VERSION)
351351
def core_deps_from_source(session):
352352
"""Run the test suite installing dependencies from source."""
353-
default(session, prerelease=True)
353+
default(session, install_deps_from_source=True)
354354

355355

356356
@nox.session(python=DEFAULT_PYTHON_VERSION)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import os
17+
from unittest import mock
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: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
from unittest import mock
17+
18+
import pytest
19+
20+
from google.auth.exceptions import MutualTLSChannelError
21+
22+
from google.api_core.gapic_v1.routing import (
23+
get_api_endpoint,
24+
get_default_mtls_endpoint,
25+
get_universe_domain,
26+
)
27+
28+
29+
def test_get_default_mtls_endpoint():
30+
# Test valid API endpoints
31+
assert get_default_mtls_endpoint("foo.googleapis.com") == "foo.mtls.googleapis.com"
32+
assert (
33+
get_default_mtls_endpoint("foo.sandbox.googleapis.com")
34+
== "foo.mtls.sandbox.googleapis.com"
35+
)
36+
37+
# Test endpoints that shouldn't be converted
38+
assert (
39+
get_default_mtls_endpoint("foo.mtls.googleapis.com")
40+
== "foo.mtls.googleapis.com"
41+
)
42+
assert get_default_mtls_endpoint("foo.com") == "foo.com"
43+
44+
# Test empty/None endpoints
45+
assert get_default_mtls_endpoint("") == ""
46+
assert get_default_mtls_endpoint(None) is None
47+
48+
49+
def test_get_api_endpoint_override():
50+
# If api_override is provided, it should be returned
51+
# regardless of other args
52+
endpoint = get_api_endpoint(
53+
api_override="custom.endpoint.com",
54+
client_cert_source=None,
55+
universe_domain="googleapis.com",
56+
use_mtls_endpoint="auto",
57+
default_universe="googleapis.com",
58+
default_mtls_endpoint="foo.mtls.googleapis.com",
59+
default_endpoint_template="foo.{UNIVERSE_DOMAIN}",
60+
)
61+
assert endpoint == "custom.endpoint.com"
62+
63+
64+
def test_get_api_endpoint_mtls_always():
65+
# use_mtls_endpoint == "always" should use the default mtls endpoint
66+
endpoint = get_api_endpoint(
67+
api_override=None,
68+
client_cert_source=None,
69+
universe_domain="googleapis.com",
70+
use_mtls_endpoint="always",
71+
default_universe="googleapis.com",
72+
default_mtls_endpoint="foo.mtls.googleapis.com",
73+
default_endpoint_template="foo.{UNIVERSE_DOMAIN}",
74+
)
75+
assert endpoint == "foo.mtls.googleapis.com"
76+
77+
78+
def test_get_api_endpoint_mtls_auto_with_cert():
79+
# "auto" with client_cert_source should use mtls
80+
endpoint = get_api_endpoint(
81+
api_override=None,
82+
client_cert_source=mock.Mock(),
83+
universe_domain="googleapis.com",
84+
use_mtls_endpoint="auto",
85+
default_universe="googleapis.com",
86+
default_mtls_endpoint="foo.mtls.googleapis.com",
87+
default_endpoint_template="foo.{UNIVERSE_DOMAIN}",
88+
)
89+
assert endpoint == "foo.mtls.googleapis.com"
90+
91+
92+
def test_get_api_endpoint_mtls_auto_no_cert():
93+
# "auto" without client_cert_source should use the default template
94+
endpoint = get_api_endpoint(
95+
api_override=None,
96+
client_cert_source=None,
97+
universe_domain="googleapis.com",
98+
use_mtls_endpoint="auto",
99+
default_universe="googleapis.com",
100+
default_mtls_endpoint="foo.mtls.googleapis.com",
101+
default_endpoint_template="foo.{UNIVERSE_DOMAIN}",
102+
)
103+
assert endpoint == "foo.googleapis.com"
104+
105+
106+
def test_get_api_endpoint_mtls_universe_mismatch():
107+
# mTLS is only supported in the default universe
108+
with pytest.raises(MutualTLSChannelError, match="mTLS is not supported"):
109+
get_api_endpoint(
110+
api_override=None,
111+
client_cert_source=mock.Mock(),
112+
universe_domain="custom-universe.com",
113+
use_mtls_endpoint="auto",
114+
default_universe="googleapis.com",
115+
default_mtls_endpoint="foo.mtls.googleapis.com",
116+
default_endpoint_template="foo.{UNIVERSE_DOMAIN}",
117+
)
118+
119+
120+
def test_get_api_endpoint_mtls_case_insensitive():
121+
# mTLS universe check should be case insensitive
122+
endpoint = get_api_endpoint(
123+
api_override=None,
124+
client_cert_source=mock.Mock(),
125+
universe_domain="GOOGLEAPIS.COM",
126+
use_mtls_endpoint="auto",
127+
default_universe="googleapis.com",
128+
default_mtls_endpoint="foo.mtls.googleapis.com",
129+
default_endpoint_template="foo.{UNIVERSE_DOMAIN}",
130+
)
131+
assert endpoint == "foo.mtls.googleapis.com"
132+
133+
134+
def test_get_universe_domain():
135+
# client_universe_domain takes precedence
136+
assert (
137+
get_universe_domain("client.com", "env.com", "default.com") # noqa: E501
138+
== "client.com"
139+
)
140+
141+
# env takes precedence over default
142+
assert (
143+
get_universe_domain(None, "env.com", "default.com") == "env.com" # noqa: E501
144+
)
145+
146+
# fallback to default
147+
assert get_universe_domain(None, None, "default.com") == "default.com" # noqa: E501
148+
149+
150+
def test_get_universe_domain_strip():
151+
# check that whitespace is stripped
152+
assert (
153+
get_universe_domain(" client.com ", "env.com", "default.com") == "client.com"
154+
)
155+
assert get_universe_domain(None, " env.com ", "default.com") == "env.com"
156+
157+
158+
def test_get_universe_domain_empty():
159+
with pytest.raises(ValueError, match="cannot be an empty string"):
160+
get_universe_domain("", None, "default.com")
161+
with pytest.raises(ValueError, match="cannot be an empty string"):
162+
get_universe_domain(" ", None, "default.com")
163+
164+
165+
def test_get_api_endpoint_none_template():
166+
endpoint = get_api_endpoint(
167+
api_override=None,
168+
client_cert_source=None,
169+
universe_domain="googleapis.com",
170+
use_mtls_endpoint="never",
171+
default_universe="googleapis.com",
172+
default_mtls_endpoint=None,
173+
default_endpoint_template=None,
174+
)
175+
assert endpoint is None

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@
3131
except ImportError: # pragma: NO COVER
3232
pytest.skip("No GRPC", allow_module_level=True)
3333

34-
from google.api_core import bidi
35-
from google.api_core import exceptions
34+
from google.api_core import bidi, exceptions
3635

3736

3837
class Test_RequestQueueGenerator(object):
@@ -195,7 +194,7 @@ def test_delays_entry_attempts_above_threshold(self):
195194
# (NOTE: not using assert all(...), b/c the coverage check would complain)
196195
for i, entry in enumerate(entries):
197196
if i != 3:
198-
assert entry["reported_wait"] == 0.0
197+
assert entry["reported_wait"] < 0.01
199198

200199
# The delayed entry is expected to have been delayed for a significant
201200
# chunk of the full second, and the actual and reported delay times

0 commit comments

Comments
 (0)