From ffbcc0a626deb24fe38eab402b3d6ace484115df Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 23 Feb 2026 09:27:36 -0800 Subject: [PATCH] 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 PiperOrigin-RevId: 874112143 --- .../openapi_spec_parser/rest_api_tool.py | 11 ++ .../openapi_spec_parser/test_rest_api_tool.py | 156 ++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py index 300c47e1..5f835489 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py @@ -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 diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py index 81d44f0b..1131181a 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py @@ -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"