Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/google/adk/tools/openapi_tool/common/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down
Loading