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"