Skip to content

Commit ffbcc0a

Browse files
GWealecopybara-github
authored andcommitted
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
1 parent 87fcd77 commit ffbcc0a

File tree

2 files changed

+167
-0
lines changed

2 files changed

+167
-0
lines changed

src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
from typing import Optional
2525
from typing import Tuple
2626
from typing import Union
27+
from urllib.parse import parse_qs
28+
from urllib.parse import urlparse
29+
from urllib.parse import urlunparse
2730

2831
from fastapi.openapi.models import Operation
2932
from fastapi.openapi.models import Schema
@@ -375,6 +378,14 @@ def _prepare_request_params(
375378
base_url = base_url[:-1] if base_url.endswith("/") else base_url
376379
url = f"{base_url}{self.endpoint.path.format(**path_params)}"
377380

381+
# Move query params embedded in the path into query_params, since httpx
382+
# replaces (rather than merges) the URL query string when `params` is set.
383+
parsed_url = urlparse(url)
384+
if parsed_url.query or parsed_url.fragment:
385+
for key, values in parse_qs(parsed_url.query).items():
386+
query_params.setdefault(key, values[0] if len(values) == 1 else values)
387+
url = urlunparse(parsed_url._replace(query="", fragment=""))
388+
378389
# Construct body
379390
body_kwargs: Dict[str, Any] = {}
380391
request_body = self.operation.requestBody

tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,162 @@ async def test_call_without_header_provider(
12681268

12691269
assert result == {"result": "success"}
12701270

1271+
def test_prepare_request_params_extracts_embedded_query_params(
1272+
self, sample_auth_credential, sample_auth_scheme
1273+
):
1274+
"""Test that query params embedded in the URL path are extracted.
1275+
1276+
ApplicationIntegrationToolset embeds query params and fragments directly
1277+
in the OpenAPI path (e.g. '...execute?triggerId=api_trigger/Name#action').
1278+
These must be moved into the explicit query_params dict so httpx does not
1279+
strip them when it replaces the URL query string with the `params` arg.
1280+
Regression test for https://github.com/google/adk-python/issues/4555.
1281+
"""
1282+
integration_path = (
1283+
"/v2/projects/my-proj/locations/us-central1"
1284+
"/integrations/ExecuteConnection:execute"
1285+
"?triggerId=api_trigger/ExecuteConnection"
1286+
"#POST_files"
1287+
)
1288+
endpoint = OperationEndpoint(
1289+
base_url="https://integrations.googleapis.com",
1290+
path=integration_path,
1291+
method="POST",
1292+
)
1293+
operation = Operation(operationId="test_op")
1294+
tool = RestApiTool(
1295+
name="test_tool",
1296+
description="test",
1297+
endpoint=endpoint,
1298+
operation=operation,
1299+
auth_credential=sample_auth_credential,
1300+
auth_scheme=sample_auth_scheme,
1301+
)
1302+
1303+
request_params = tool._prepare_request_params([], {})
1304+
1305+
# The embedded query param must appear in params
1306+
assert request_params["params"]["triggerId"] == (
1307+
"api_trigger/ExecuteConnection"
1308+
)
1309+
# The URL must NOT contain the query string or fragment
1310+
assert "?" not in request_params["url"]
1311+
assert "#" not in request_params["url"]
1312+
assert request_params["url"] == (
1313+
"https://integrations.googleapis.com"
1314+
"/v2/projects/my-proj/locations/us-central1"
1315+
"/integrations/ExecuteConnection:execute"
1316+
)
1317+
1318+
def test_prepare_request_params_merges_embedded_and_explicit_query_params(
1319+
self, sample_auth_credential, sample_auth_scheme
1320+
):
1321+
"""Embedded URL query params merge with explicitly defined query params."""
1322+
endpoint = OperationEndpoint(
1323+
base_url="https://example.com",
1324+
path="/api?embedded_key=embedded_val",
1325+
method="GET",
1326+
)
1327+
operation = Operation(operationId="test_op")
1328+
tool = RestApiTool(
1329+
name="test_tool",
1330+
description="test",
1331+
endpoint=endpoint,
1332+
operation=operation,
1333+
auth_credential=sample_auth_credential,
1334+
auth_scheme=sample_auth_scheme,
1335+
)
1336+
params = [
1337+
ApiParameter(
1338+
original_name="explicit_key",
1339+
py_name="explicit_key",
1340+
param_location="query",
1341+
param_schema=OpenAPISchema(type="string"),
1342+
),
1343+
]
1344+
kwargs = {"explicit_key": "explicit_val"}
1345+
1346+
request_params = tool._prepare_request_params(params, kwargs)
1347+
1348+
assert request_params["params"]["embedded_key"] == "embedded_val"
1349+
assert request_params["params"]["explicit_key"] == "explicit_val"
1350+
assert "?" not in request_params["url"]
1351+
1352+
def test_prepare_request_params_explicit_query_param_takes_precedence(
1353+
self, sample_auth_credential, sample_auth_scheme
1354+
):
1355+
"""Explicitly defined query params take precedence over embedded ones."""
1356+
endpoint = OperationEndpoint(
1357+
base_url="https://example.com",
1358+
path="/api?key=embedded",
1359+
method="GET",
1360+
)
1361+
operation = Operation(operationId="test_op")
1362+
tool = RestApiTool(
1363+
name="test_tool",
1364+
description="test",
1365+
endpoint=endpoint,
1366+
operation=operation,
1367+
auth_credential=sample_auth_credential,
1368+
auth_scheme=sample_auth_scheme,
1369+
)
1370+
params = [
1371+
ApiParameter(
1372+
original_name="key",
1373+
py_name="key",
1374+
param_location="query",
1375+
param_schema=OpenAPISchema(type="string"),
1376+
),
1377+
]
1378+
kwargs = {"key": "explicit"}
1379+
1380+
request_params = tool._prepare_request_params(params, kwargs)
1381+
1382+
# Explicit value wins over the embedded one
1383+
assert request_params["params"]["key"] == "explicit"
1384+
1385+
def test_prepare_request_params_strips_fragment_only(
1386+
self, sample_auth_credential, sample_auth_scheme
1387+
):
1388+
"""Fragment-only paths (no query string) are also cleaned."""
1389+
endpoint = OperationEndpoint(
1390+
base_url="https://example.com",
1391+
path="/api#fragment",
1392+
method="GET",
1393+
)
1394+
operation = Operation(operationId="test_op")
1395+
tool = RestApiTool(
1396+
name="test_tool",
1397+
description="test",
1398+
endpoint=endpoint,
1399+
operation=operation,
1400+
auth_credential=sample_auth_credential,
1401+
auth_scheme=sample_auth_scheme,
1402+
)
1403+
1404+
request_params = tool._prepare_request_params([], {})
1405+
1406+
assert "#" not in request_params["url"]
1407+
assert request_params["url"] == "https://example.com/api"
1408+
1409+
def test_prepare_request_params_plain_url_unchanged(
1410+
self, sample_endpoint, sample_auth_credential, sample_auth_scheme
1411+
):
1412+
"""URLs without embedded query or fragment are not modified."""
1413+
operation = Operation(operationId="test_op")
1414+
tool = RestApiTool(
1415+
name="test_tool",
1416+
description="test",
1417+
endpoint=sample_endpoint,
1418+
operation=operation,
1419+
auth_credential=sample_auth_credential,
1420+
auth_scheme=sample_auth_scheme,
1421+
)
1422+
1423+
request_params = tool._prepare_request_params([], {})
1424+
1425+
assert request_params["url"] == "https://example.com/test"
1426+
12711427

12721428
def test_snake_to_lower_camel():
12731429
assert snake_to_lower_camel("single") == "single"

0 commit comments

Comments
 (0)