fix: Keep query params embedded in OpenAPI paths when using httpx

The migration from requests to httpx in v1.24.0 broke ApplicationIntegrationToolset because httpx replaces the URL query string when a `params` dict is passed, even if empty. The requests library merged them instead. This extracts any query parameters embedded in the URL path into the explicit params dict before passing to httpx.

Close #4555

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 874112143
This commit is contained in:
George Weale
2026-02-23 09:27:36 -08:00
committed by Copybara-Service
parent 87fcd77caa
commit ffbcc0a626
2 changed files with 167 additions and 0 deletions
@@ -24,6 +24,9 @@ from typing import Literal
from typing import Optional
from typing import Tuple
from typing import Union
from urllib.parse import parse_qs
from urllib.parse import urlparse
from urllib.parse import urlunparse
from fastapi.openapi.models import Operation
from fastapi.openapi.models import Schema
@@ -375,6 +378,14 @@ class RestApiTool(BaseTool):
base_url = base_url[:-1] if base_url.endswith("/") else base_url
url = f"{base_url}{self.endpoint.path.format(**path_params)}"
# Move query params embedded in the path into query_params, since httpx
# replaces (rather than merges) the URL query string when `params` is set.
parsed_url = urlparse(url)
if parsed_url.query or parsed_url.fragment:
for key, values in parse_qs(parsed_url.query).items():
query_params.setdefault(key, values[0] if len(values) == 1 else values)
url = urlunparse(parsed_url._replace(query="", fragment=""))
# Construct body
body_kwargs: Dict[str, Any] = {}
request_body = self.operation.requestBody
@@ -1268,6 +1268,162 @@ class TestRestApiTool:
assert result == {"result": "success"}
def test_prepare_request_params_extracts_embedded_query_params(
self, sample_auth_credential, sample_auth_scheme
):
"""Test that query params embedded in the URL path are extracted.
ApplicationIntegrationToolset embeds query params and fragments directly
in the OpenAPI path (e.g. '...execute?triggerId=api_trigger/Name#action').
These must be moved into the explicit query_params dict so httpx does not
strip them when it replaces the URL query string with the `params` arg.
Regression test for https://github.com/google/adk-python/issues/4555.
"""
integration_path = (
"/v2/projects/my-proj/locations/us-central1"
"/integrations/ExecuteConnection:execute"
"?triggerId=api_trigger/ExecuteConnection"
"#POST_files"
)
endpoint = OperationEndpoint(
base_url="https://integrations.googleapis.com",
path=integration_path,
method="POST",
)
operation = Operation(operationId="test_op")
tool = RestApiTool(
name="test_tool",
description="test",
endpoint=endpoint,
operation=operation,
auth_credential=sample_auth_credential,
auth_scheme=sample_auth_scheme,
)
request_params = tool._prepare_request_params([], {})
# The embedded query param must appear in params
assert request_params["params"]["triggerId"] == (
"api_trigger/ExecuteConnection"
)
# The URL must NOT contain the query string or fragment
assert "?" not in request_params["url"]
assert "#" not in request_params["url"]
assert request_params["url"] == (
"https://integrations.googleapis.com"
"/v2/projects/my-proj/locations/us-central1"
"/integrations/ExecuteConnection:execute"
)
def test_prepare_request_params_merges_embedded_and_explicit_query_params(
self, sample_auth_credential, sample_auth_scheme
):
"""Embedded URL query params merge with explicitly defined query params."""
endpoint = OperationEndpoint(
base_url="https://example.com",
path="/api?embedded_key=embedded_val",
method="GET",
)
operation = Operation(operationId="test_op")
tool = RestApiTool(
name="test_tool",
description="test",
endpoint=endpoint,
operation=operation,
auth_credential=sample_auth_credential,
auth_scheme=sample_auth_scheme,
)
params = [
ApiParameter(
original_name="explicit_key",
py_name="explicit_key",
param_location="query",
param_schema=OpenAPISchema(type="string"),
),
]
kwargs = {"explicit_key": "explicit_val"}
request_params = tool._prepare_request_params(params, kwargs)
assert request_params["params"]["embedded_key"] == "embedded_val"
assert request_params["params"]["explicit_key"] == "explicit_val"
assert "?" not in request_params["url"]
def test_prepare_request_params_explicit_query_param_takes_precedence(
self, sample_auth_credential, sample_auth_scheme
):
"""Explicitly defined query params take precedence over embedded ones."""
endpoint = OperationEndpoint(
base_url="https://example.com",
path="/api?key=embedded",
method="GET",
)
operation = Operation(operationId="test_op")
tool = RestApiTool(
name="test_tool",
description="test",
endpoint=endpoint,
operation=operation,
auth_credential=sample_auth_credential,
auth_scheme=sample_auth_scheme,
)
params = [
ApiParameter(
original_name="key",
py_name="key",
param_location="query",
param_schema=OpenAPISchema(type="string"),
),
]
kwargs = {"key": "explicit"}
request_params = tool._prepare_request_params(params, kwargs)
# Explicit value wins over the embedded one
assert request_params["params"]["key"] == "explicit"
def test_prepare_request_params_strips_fragment_only(
self, sample_auth_credential, sample_auth_scheme
):
"""Fragment-only paths (no query string) are also cleaned."""
endpoint = OperationEndpoint(
base_url="https://example.com",
path="/api#fragment",
method="GET",
)
operation = Operation(operationId="test_op")
tool = RestApiTool(
name="test_tool",
description="test",
endpoint=endpoint,
operation=operation,
auth_credential=sample_auth_credential,
auth_scheme=sample_auth_scheme,
)
request_params = tool._prepare_request_params([], {})
assert "#" not in request_params["url"]
assert request_params["url"] == "https://example.com/api"
def test_prepare_request_params_plain_url_unchanged(
self, sample_endpoint, sample_auth_credential, sample_auth_scheme
):
"""URLs without embedded query or fragment are not modified."""
operation = Operation(operationId="test_op")
tool = RestApiTool(
name="test_tool",
description="test",
endpoint=sample_endpoint,
operation=operation,
auth_credential=sample_auth_credential,
auth_scheme=sample_auth_scheme,
)
request_params = tool._prepare_request_params([], {})
assert request_params["url"] == "https://example.com/test"
def test_snake_to_lower_camel():
assert snake_to_lower_camel("single") == "single"