Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
93c5270
feat(api-core): move mtls and client configuration logic to gapic_v1 …
hebaalazzeh Jul 16, 2026
8808918
feat(api-core): move universe and endpoint routing logic to gapic_v1 …
hebaalazzeh Jul 16, 2026
3c47f56
refactor(api-core): revert unrelated changes in noxfile and test_bidi
hebaalazzeh Jul 16, 2026
f953c80
refactor(api-core): update routing tests to match generator's test te…
hebaalazzeh Jul 16, 2026
0fe63b5
fix(api-core): update get_default_mtls_endpoint to use robust string …
hebaalazzeh Jul 16, 2026
d90149a
test(api-core): align mtls and config helper tests with generator tem…
hebaalazzeh Jul 16, 2026
daf631e
style(api-core): format client cert and config helper tests with black
hebaalazzeh Jul 16, 2026
b504222
fix(api-core): raise ValueError on missing cert source in get_client_…
hebaalazzeh Jul 16, 2026
2e0174c
revert(api-core): revert unrelated changes to noxfile.py and test_bid…
hebaalazzeh Jul 16, 2026
2d0808b
docs(api-core): address routing review feedback on docstrings, types,…
hebaalazzeh Jul 16, 2026
6596ac5
refactor(api-core): rename routing.py to client_utils.py as consolida…
hebaalazzeh Jul 17, 2026
4c53e7a
Merge branch 'feat/gapic-centralization-api-core-routing' into feat/g…
hebaalazzeh Jul 17, 2026
5462a36
refactor(api-core): consolidate client_cert and config_helpers into c…
hebaalazzeh Jul 17, 2026
093ac26
fix(api-core): make mTLS endpoint conversion case-insensitive
hebaalazzeh Jul 17, 2026
91d39e9
fix(api-core): satisfy lint and formatting for conftest and test_clie…
hebaalazzeh Jul 17, 2026
c1b8d0c
Merge branch 'main' into feat/gapic-centralization-api-core-routing
hebaalazzeh Jul 17, 2026
779ced3
Merge branch 'main' into feat/gapic-centralization-api-core-mtls
hebaalazzeh Jul 17, 2026
9673382
address PR comments on client_utils.py and test_client_utils.py
hebaalazzeh Jul 17, 2026
e2227a1
fix lint/formatting issues and resolve stray conflict markers in gapi…
hebaalazzeh Jul 17, 2026
2aeabc7
docs: clarify get_default_mtls_endpoint pass-through, fix: support po…
hebaalazzeh Jul 17, 2026
c0c5a67
fix(transport): propagate mTLS adapter to auth session and fix connec…
nbayati Jul 17, 2026
a52129a
fix syntax/conflict residue and formatting issues in gapic_v1
hebaalazzeh Jul 17, 2026
3158f86
refactor(api-core): remove deprecated fallback client cert helpers an…
hebaalazzeh Jul 17, 2026
1abba92
refactor(api-core): parse ports using urlparse and utilize should_use…
hebaalazzeh Jul 17, 2026
1716f4a
Merge branch 'feat/gapic-centralization-api-core-routing' into feat/g…
hebaalazzeh Jul 17, 2026
c0b7860
style(api-core): format code with ruff
hebaalazzeh Jul 17, 2026
3b3b4f6
Merge branch 'feat/gapic-centralization-api-core-routing' into feat/g…
hebaalazzeh Jul 17, 2026
c862011
fix(api-core): resolve lint warnings in client_utils and tests
hebaalazzeh Jul 17, 2026
ddcaee1
refactor(api-core): decouple client_utils helpers from client details…
hebaalazzeh Jul 17, 2026
46e4347
style: remove obsolete utf-8 coding headers
hebaalazzeh Jul 17, 2026
0314206
style: remove obsolete utf-8 coding header from conftest.py
hebaalazzeh Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
# Older Python versions safely ignore this variable.
__lazy_modules__: Set[str] = {
"google.api_core.gapic_v1.client_info",
"google.api_core.gapic_v1.client_utils",
"google.api_core.gapic_v1.requests",
"google.api_core.gapic_v1.routing_header",
}
__all__ = ["client_info", "requests", "routing_header"]
__all__ = ["client_info", "client_utils", "requests", "routing_header"]


if _has_grpc:
__lazy_modules__.update(
Expand All @@ -42,6 +44,7 @@

from google.api_core.gapic_v1 import ( # noqa: E402
client_info,
client_utils,
requests,
routing_header,
)
Expand Down
133 changes: 133 additions & 0 deletions packages/google-api-core/google/api_core/gapic_v1/client_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

from typing import Optional
from urllib.parse import urlparse, urlunparse

from google.auth.exceptions import MutualTLSChannelError # type: ignore


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

Convert "*.sandbox.googleapis.com" and "*.googleapis.com" to
"*.mtls.sandbox.googleapis.com" and "*.mtls.googleapis.com" respectively.
Other URLs (including those that do not match these domain suffixes or
already contain '.mtls.') are passed through as-is.

Args:
api_endpoint (Optional[str]): the api endpoint to convert.

Returns:
Optional[str]: converted mTLS api endpoint.
"""
if not api_endpoint or ".mtls." in api_endpoint.lower():
return api_endpoint

has_scheme = "://" in api_endpoint
if not has_scheme:
parsed = urlparse("//" + api_endpoint)
else:
parsed = urlparse(api_endpoint)

host = parsed.hostname
if not host:
return api_endpoint

port = f":{parsed.port}" if parsed.port else ""

lowered_host = host.lower()
if lowered_host.endswith(".sandbox.googleapis.com"):
new_host = host[:-23] + ".mtls.sandbox.googleapis.com"
elif lowered_host.endswith(".googleapis.com"):
new_host = host[:-15] + ".mtls.googleapis.com"
else:
return api_endpoint

netloc = new_host + port
new_parsed = parsed._replace(netloc=netloc)

if not has_scheme:
return urlunparse(new_parsed)[2:]
else:
return urlunparse(new_parsed)


def get_api_endpoint(
api_override: Optional[str],
universe_domain: str,
default_universe: str,
default_mtls_endpoint: Optional[str],
default_endpoint_template: str,
use_mtls: bool,
) -> str:
"""Return the API endpoint used by the client.

Args:
api_override (Optional[str]): The API endpoint override. If specified,
this is always returned.
universe_domain (str): The universe domain used by the client.
default_universe (str): The default universe domain.
default_mtls_endpoint (Optional[str]): The default mTLS endpoint.
default_endpoint_template (str): The default endpoint template containing
a placeholder `{UNIVERSE_DOMAIN}`.
use_mtls (bool): Whether to use the mTLS endpoint.

Returns:
str: The API endpoint to be used by the client.

Raises:
google.auth.exceptions.MutualTLSChannelError: If mTLS is requested but
not supported in the configured universe domain.
ValueError: If mTLS is requested but no mTLS endpoint is available.
"""
if api_override is not None:
return api_override

if use_mtls:
if universe_domain.lower() != default_universe.lower():
raise MutualTLSChannelError(
f"mTLS is not supported in any universe other than {default_universe}."
)
if not default_mtls_endpoint:
raise ValueError("mTLS endpoint is not available.")
return default_mtls_endpoint
else:
return default_endpoint_template.format(UNIVERSE_DOMAIN=universe_domain)


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

Args:
universe_domain (Optional[str]): The configured universe domain.
default_universe (str): The default universe domain.

Returns:
str: The universe domain to be used by the client.

Raises:
ValueError: If the resolved universe domain is an empty string.
"""
resolved = (
universe_domain.strip() if universe_domain is not None else default_universe
)

if not resolved:
raise ValueError("Universe Domain cannot be an empty string.")
return resolved
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
if they are not already set.
"""

from typing import Union
import uuid
from typing import Union

import google.protobuf.message

Expand Down
31 changes: 31 additions & 0 deletions packages/google-api-core/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import os
from unittest import mock

import pytest


@pytest.fixture(scope="session", autouse=True)
def mock_mtls_env():
"""Autouse session-scoped fixture to isolate unit tests from workstation mTLS environments."""
with mock.patch.dict(
os.environ,
{
"GOOGLE_API_USE_CLIENT_CERTIFICATE": "false",
"CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE": "false",
},
):
yield
179 changes: 179 additions & 0 deletions packages/google-api-core/tests/unit/gapic/test_client_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import pytest
from google.auth.exceptions import MutualTLSChannelError

from google.api_core.gapic_v1.client_utils import (
get_api_endpoint,
get_default_mtls_endpoint,
get_universe_domain,
)


def test_get_default_mtls_endpoint():
# Test valid API endpoints
assert get_default_mtls_endpoint("foo.googleapis.com") == "foo.mtls.googleapis.com"
assert (
get_default_mtls_endpoint("foo.sandbox.googleapis.com")
== "foo.mtls.sandbox.googleapis.com"
)
# Test case-insensitivity
assert get_default_mtls_endpoint("foo.GoogleAPIs.com") == "foo.mtls.googleapis.com"
assert (
get_default_mtls_endpoint("foo.Sandbox.GoogleAPIs.com")
== "foo.mtls.sandbox.googleapis.com"
)

# Test valid API endpoints with schemes
assert (
get_default_mtls_endpoint("https://foo.googleapis.com")
== "https://foo.mtls.googleapis.com"
)
assert (
get_default_mtls_endpoint("http://foo.googleapis.com:8080/v1")
== "http://foo.mtls.googleapis.com:8080/v1"
)

# Test valid API endpoints with ports
assert (
get_default_mtls_endpoint("foo.googleapis.com:443")
== "foo.mtls.googleapis.com:443"
)
assert (
get_default_mtls_endpoint("foo.sandbox.googleapis.com:443")
== "foo.mtls.sandbox.googleapis.com:443"
)
# Test case-insensitivity with ports
assert (
get_default_mtls_endpoint("foo.GoogleAPIs.com:443")
== "foo.mtls.googleapis.com:443"
)
assert (
get_default_mtls_endpoint("foo.Sandbox.GoogleAPIs.com:443")
== "foo.mtls.sandbox.googleapis.com:443"
)

# Test endpoints that shouldn't be converted
assert (
get_default_mtls_endpoint("foo.mtls.googleapis.com")
== "foo.mtls.googleapis.com"
)
assert get_default_mtls_endpoint("foo.com") == "foo.com"
assert get_default_mtls_endpoint("foo.com:8080") == "foo.com:8080"

# Test empty/None endpoints
assert get_default_mtls_endpoint("") == ""
assert get_default_mtls_endpoint(None) is None


@pytest.mark.parametrize(
"api_override,universe_domain,default_universe,default_mtls_endpoint,default_endpoint_template,use_mtls,expected",
[
(
"foo.com",
"googleapis.com",
"googleapis.com",
"foo.mtls.googleapis.com",
"foo.{UNIVERSE_DOMAIN}",
True,
"foo.com",
),
(
None,
"googleapis.com",
"googleapis.com",
"foo.mtls.googleapis.com",
"foo.{UNIVERSE_DOMAIN}",
True,
"foo.mtls.googleapis.com",
),
(
None,
"googleapis.com",
"googleapis.com",
"foo.mtls.googleapis.com",
"foo.{UNIVERSE_DOMAIN}",
False,
"foo.googleapis.com",
),
(
None,
"bar.com",
"googleapis.com",
"foo.mtls.googleapis.com",
"foo.{UNIVERSE_DOMAIN}",
True,
MutualTLSChannelError,
),
(
None,
"googleapis.com",
"googleapis.com",
None,
"foo.{UNIVERSE_DOMAIN}",
True,
ValueError,
),
],
)
def test_get_api_endpoint(
api_override,
universe_domain,
default_universe,
default_mtls_endpoint,
default_endpoint_template,
use_mtls,
expected,
):
if isinstance(expected, type) and issubclass(expected, Exception):
with pytest.raises(expected):
get_api_endpoint(
api_override,
universe_domain,
default_universe,
default_mtls_endpoint,
default_endpoint_template,
use_mtls,
)
else:
assert (
get_api_endpoint(
api_override,
universe_domain,
default_universe,
default_mtls_endpoint,
default_endpoint_template,
use_mtls,
)
== expected
)


def test_get_universe_domain():
# When universe_domain is provided
assert get_universe_domain("foo.com", "default.com") == "foo.com"
assert get_universe_domain(" foo.com ", "default.com") == "foo.com"

# When universe_domain is None, falls back to default_universe
assert get_universe_domain(None, "default.com") == "default.com"

# ValueError raised when resolved value is empty string
with pytest.raises(ValueError) as excinfo:
get_universe_domain("", "default.com")
assert str(excinfo.value) == "Universe Domain cannot be an empty string."

with pytest.raises(ValueError) as excinfo:
get_universe_domain(" ", "default.com")
assert str(excinfo.value) == "Universe Domain cannot be an empty string."
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

from google.api_core.gapic_v1.requests import setup_request_id


# --- Mock Request Helper Classes ---


Expand Down
Loading
Loading