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 ):