From ec9e060d877d99cb7082190325f5ffc8fcfed09e Mon Sep 17 00:00:00 2001 From: Naveen Chatlapalli Date: Sat, 19 Sep 2026 00:44:30 -0500 Subject: [PATCH] fix(openapi): honor query parameter style and explode RestApiTool passed query values to httpx as the model produced them and ApiParameter did not record the spec's style/explode. httpx repeats the key for a list, which only matches the default for arrays, and sends a dict as its Python repr, so every object-typed query parameter went out as e.g. filter={'status': 'open'}. Non-exploded and delimited arrays were exploded as well. Carry style and explode from the spec's Parameter into ApiParameter (optional fields, so serialized parameters still load) and serialize query values per OpenAPI: objects are spread (form, exploded, the default), bracketed (deepObject) or k,v-joined (form, not exploded), and non-exploded arrays are joined with ",", " " or "|". Scalars and default arrays are unchanged. Fixes #7204 Claude-Session: https://claude.ai/code/session_013vXxD1ga1hnCq2uFRwNks7 --- .../adk/tools/openapi_tool/common/common.py | 3 + .../openapi_spec_parser/operation_parser.py | 2 + .../openapi_spec_parser/rest_api_tool.py | 40 +++++++++++- .../test_operation_parser.py | 27 ++++++++ .../openapi_spec_parser/test_rest_api_tool.py | 63 +++++++++++++++++++ 5 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/google/adk/tools/openapi_tool/common/common.py b/src/google/adk/tools/openapi_tool/common/common.py index db5e3cac00..ca18a8f802 100644 --- a/src/google/adk/tools/openapi_tool/common/common.py +++ b/src/google/adk/tools/openapi_tool/common/common.py @@ -85,6 +85,9 @@ class ApiParameter(BaseModel): type_value: object = Field(default=None, init_var=False) type_hint: str | None = Field(default=None, init_var=False) required: bool = False + # OpenAPI serialization of the value; None means the location's default. + style: str | None = None + explode: bool | None = None def model_post_init(self, _: Any) -> None: if not self.py_name: diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py index 36a2b19402..257c8c5764 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/operation_parser.py @@ -150,6 +150,8 @@ def _process_operation_parameters(self) -> None: description=description, required=required, py_name=self._get_py_name(original_name), + style=param.style, + explode=param.explode, ) ) 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 b9f91f8e21..62ff3d797f 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 @@ -415,7 +415,7 @@ def _prepare_request_params( path_params[original_k] = quote(str(v), safe="") elif param_location == "query": if v is not None: - query_params[original_k] = v + query_params.update(_serialize_query_param(param_obj, v)) elif param_location == "header": header_params[original_k] = v elif param_location == "cookie": @@ -717,6 +717,44 @@ def __repr__(self): ) +def _serialize_query_param(param: ApiParameter, value: Any) -> Dict[str, Any]: + """Applies a query parameter's OpenAPI `style` and `explode` to its value. + + httpx already repeats the key for a list, which is the default (style=form, + explode=true) for arrays, but it sends a dict as its Python repr. Objects are + therefore expanded as the spec describes, and non-exploded or delimited + arrays are joined. + + Args: + param: The query parameter, carrying the spec's `style` and `explode`. + value: The value the model supplied for it. + + Returns: + The query entries to send, keyed by query parameter name. + """ + + def to_str(item: Any) -> str: + if isinstance(item, bool): + return "true" if item else "false" + return str(item) + + name = param.original_name + style = param.style or "form" + explode = param.explode if param.explode is not None else style == "form" + if isinstance(value, dict): + if style == "deepObject": + return {f"{name}[{key}]": item for key, item in value.items()} + if explode: + return dict(value) + return { + name: ",".join(f"{key},{to_str(item)}" for key, item in value.items()) + } + if isinstance(value, list) and not explode: + separator = {"spaceDelimited": " ", "pipeDelimited": "|"}.get(style, ",") + return {name: separator.join(to_str(item) for item in value)} + return {name: value} + + async def _request( *, httpx_client_factory: Optional[HttpxClientFactory] = None, diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py index bf72ecadb6..710ba40f2d 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_operation_parser.py @@ -95,6 +95,33 @@ def test_process_operation_parameters(sample_operation): assert parser._params[1].param_location == 'header' +def test_process_operation_parameters_keeps_style_and_explode(): + operation = Operation( + operationId='listTickets', + parameters=[ + Parameter(**{ + 'name': 'filter', + 'in': 'query', + 'style': 'deepObject', + 'explode': True, + 'schema': Schema(type='object'), + }), + Parameter(**{ + 'name': 'q', + 'in': 'query', + 'schema': Schema(type='string'), + }), + ], + ) + parser = OperationParser(operation, should_parse=False) + parser._process_operation_parameters() + assert (parser._params[0].style, parser._params[0].explode) == ( + 'deepObject', + True, + ) + assert (parser._params[1].style, parser._params[1].explode) == (None, None) + + def test_process_request_body(sample_operation): """Test _process_request_body method.""" parser = OperationParser(sample_operation, should_parse=False) 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 5cd2d340e8..b78c62c6e0 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 @@ -1524,6 +1524,69 @@ def test_prepare_request_params_preserves_falsy_query_params( "empty_param": "", } + @pytest.mark.parametrize( + "schema_type, style, explode, value, expected", + [ + # style=form, explode=true is the default for query parameters. + ( + "object", + None, + None, + {"status": "open", "priority": "P1"}, + {"status": "open", "priority": "P1"}, + ), + ( + "object", + "form", + False, + {"status": "open", "active": True}, + {"match": "status,open,active,true"}, + ), + ( + "object", + "deepObject", + True, + {"status": "open", "priority": "P1"}, + {"match[status]": "open", "match[priority]": "P1"}, + ), + ("array", None, None, ["a", "b"], {"match": ["a", "b"]}), + ("array", "form", False, ["a", "b"], {"match": "a,b"}), + ("array", "spaceDelimited", False, ["a", "b"], {"match": "a b"}), + ("array", "pipeDelimited", False, ["a", "b"], {"match": "a|b"}), + ("string", "form", False, "plain", {"match": "plain"}), + ], + ) + def test_prepare_request_params_query_style_and_explode( + self, + sample_endpoint, + schema_type, + style, + explode, + value, + expected, + ): + """httpx sends a dict query value as its Python repr, never per the spec.""" + tool = RestApiTool( + name="test_tool", + description="test", + endpoint=sample_endpoint, + operation=Operation(operationId="test_op"), + ) + params = [ + ApiParameter( + original_name="match", + py_name="match", + param_location="query", + param_schema=OpenAPISchema(type=schema_type), + style=style, + explode=explode, + ) + ] + + request_params = tool._prepare_request_params(params, {"match": value}) + + assert request_params["params"] == expected + def test_prepare_request_params_array( self, sample_endpoint, sample_auth_scheme, sample_auth_credential ):