From 4146ab761af15ee0383626bbc72b1a49e790989b Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:18:18 -0500 Subject: [PATCH 1/5] fix: preserve URL fragment as query params in _prepare_request_params Fixes #4598: When a URL contains a fragment component (e.g., #triggerId=abc123), the fragment was being silently dropped. This caused HTTP 400 errors when APIs expect fragment-encoded parameters to be passed as query string parameters. This change parses the URL fragment using parse_qs and merges the extracted key-value pairs into query_params (using setdefault to avoid overriding explicitly-passed values), consistent with how URL query strings are handled. --- .../tools/openapi_tool/openapi_spec_parser/rest_api_tool.py | 3 +++ 1 file changed, 3 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 5f83548980..3326697ee4 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 @@ -384,6 +384,9 @@ def _prepare_request_params( 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) + if parsed_url.fragment: + for key, values in parse_qs(parsed_url.fragment).items(): + query_params.setdefault(key, values[0] if len(values) == 1 else values) url = urlunparse(parsed_url._replace(query="", fragment="")) # Construct body From b899c49ce1bb9c669fc7fc889fc28269b3815187 Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:22:12 -0500 Subject: [PATCH 2/5] test: add test for fragment key=value extraction in _prepare_request_params Adds test_prepare_request_params_extracts_fragment_key_value_pairs to verify that URL fragments containing key=value pairs (e.g. #action=POST) are correctly parsed and added to query_params, alongside query string params. Regression test for issue #4598. --- .../openapi_spec_parser/test_rest_api_tool.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 1131181acd..18abad734d 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 @@ -1423,6 +1423,42 @@ def test_prepare_request_params_plain_url_unchanged( request_params = tool._prepare_request_params([], {}) assert request_params["url"] == "https://example.com/test" + def test_prepare_request_params_extracts_fragment_key_value_pairs( + self, sample_auth_credential, sample_auth_scheme + ): + """Fragment with key=value pairs should be parsed as query params. + + When a URL fragment contains key=value pairs (e.g. #key=value), + they should be extracted and added to query_params, consistent with + how embedded query string params are handled. + + Regression test for https://github.com/google/adk-python/issues/4598. + """ + endpoint = OperationEndpoint( + base_url="https://example.com", + path="/api?triggerId=api_trigger/Name#action=POST", + 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([], {}) + + # Query string param must be extracted + assert request_params["params"]["triggerId"] == "api_trigger/Name" + # Fragment key=value pair must be extracted as a query param + assert request_params["params"]["action"] == "POST" + # The URL must NOT contain query string or fragment + assert "?" not in request_params["url"] + assert "#" not in request_params["url"] + assert request_params["url"] == "https://example.com/api" def test_snake_to_lower_camel(): From e8eca1be7b8b096590e405e410b6aed8f551b27c Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:58:36 -0500 Subject: [PATCH 3/5] fix: parse URL fragment key=value pairs into query params Refactor query parameter extraction to handle both query and fragment in a single loop. --- .../openapi_spec_parser/rest_api_tool.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 3326697ee4..0e1d8099ed 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 @@ -382,12 +382,15 @@ def _prepare_request_params( # 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) - if parsed_url.fragment: - for key, values in parse_qs(parsed_url.fragment).items(): - query_params.setdefault(key, values[0] if len(values) == 1 else values) - url = urlunparse(parsed_url._replace(query="", fragment="")) + for part in (parsed_url.query, parsed_url.fragment): + if part: + for key, values in parse_qs(part).items(): + query_params.setdefault( + key, + values[0] if len(values) == 1 else values + ) + # URL without query and fragment + url = urlunparse(parsed_url._replace(query="", fragment="")) # Construct body body_kwargs: Dict[str, Any] = {} From 9ba41affc72021adefcca6d96214344510151582 Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:03:41 -0500 Subject: [PATCH 4/5] test: add coverage for fragment params being extracted as query params Added a test using a realistic Google Cloud integration URL that has both a query string param (triggerId) and a fragment param (httpMethod). Confirms both get moved into query_params and the final URL is clean. --- .../openapi_spec_parser/test_rest_api_tool.py | 61 ++++++++++--------- 1 file changed, 33 insertions(+), 28 deletions(-) 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 18abad734d..02bfdcdf5c 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 @@ -1423,42 +1423,47 @@ def test_prepare_request_params_plain_url_unchanged( request_params = tool._prepare_request_params([], {}) assert request_params["url"] == "https://example.com/test" - def test_prepare_request_params_extracts_fragment_key_value_pairs( + def test_prepare_request_params_fragment_params_become_query_params( self, sample_auth_credential, sample_auth_scheme ): - """Fragment with key=value pairs should be parsed as query params. - - When a URL fragment contains key=value pairs (e.g. #key=value), - they should be extracted and added to query_params, consistent with - how embedded query string params are handled. - - Regression test for https://github.com/google/adk-python/issues/4598. - """ - endpoint = OperationEndpoint( - base_url="https://example.com", - path="/api?triggerId=api_trigger/Name#action=POST", - method="GET", - ) - operation = Operation(operationId="test_op") + # When the ApplicationIntegrationToolset builds an endpoint URL, it sometimes + # puts params in the fragment (e.g. #triggerId=my_trigger). Without this fix + # those params were silently dropped and the API returned a 400 error. + # See: https://github.com/google/adk-python/issues/4598 + integration_endpoint = OperationEndpoint( + base_url="https://integrations.googleapis.com", + path=( + "/v2/projects/demo/locations/us-central1" + "/integrations/MyFlow:execute" + "?triggerId=api_trigger/MyFlow" + "#httpMethod=POST" + ), + method="POST", + ) + op = Operation(operationId="run_integration") tool = RestApiTool( - name="test_tool", - description="test", - endpoint=endpoint, - operation=operation, + name="run_integration", + description="Runs a Google Cloud integration flow", + endpoint=integration_endpoint, + operation=op, auth_credential=sample_auth_credential, auth_scheme=sample_auth_scheme, ) - request_params = tool._prepare_request_params([], {}) + result = tool._prepare_request_params([], {}) - # Query string param must be extracted - assert request_params["params"]["triggerId"] == "api_trigger/Name" - # Fragment key=value pair must be extracted as a query param - assert request_params["params"]["action"] == "POST" - # The URL must NOT contain query string or fragment - assert "?" not in request_params["url"] - assert "#" not in request_params["url"] - assert request_params["url"] == "https://example.com/api" + # Both the query string and fragment params should land in query params + assert result["params"]["triggerId"] == "api_trigger/MyFlow" + assert result["params"]["httpMethod"] == "POST" + + # The final URL should be clean — no leftover ? or # + assert "?" not in result["url"] + assert "#" not in result["url"] + assert result["url"] == ( + "https://integrations.googleapis.com" + "/v2/projects/demo/locations/us-central1" + "/integrations/MyFlow:execute" + ) def test_snake_to_lower_camel(): From 71e49d5e8fb156d66ea83d32c9473b3f4eb6c5e1 Mon Sep 17 00:00:00 2001 From: saiiiii <49656052+saiprasanth-git@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:28:25 -0500 Subject: [PATCH 5/5] fix: remove redundant outer if guard causing mypy syntax error Remove unnecessary check for query and fragment in URL parsing. --- .../adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py | 1 - 1 file changed, 1 deletion(-) 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 0e1d8099ed..73a87dd760 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 @@ -381,7 +381,6 @@ def _prepare_request_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 part in (parsed_url.query, parsed_url.fragment): if part: for key, values in parse_qs(part).items():