mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Object-typed query parameters with explode=true (the default) were passed as raw Python dicts to httpx, which called str() on them — producing Python repr syntax (single quotes, capitalized booleans) instead of proper query parameter serialization. Per the OpenAPI specification, style=form with explode=true on objects expands each property as a separate query parameter (e.g. ?myAttribute=true). This change handles dict values in both the explode=true and explode=false branches of _serialize_query_params, using the existing _query_scalar_to_str helper for correct boolean formatting. Fixes #2857
This commit is contained in:
parent
2491993327
commit
16eb2ffcb0
2 changed files with 114 additions and 11 deletions
|
|
@ -259,6 +259,28 @@ class RequestDirector:
|
|||
param_info = param_lookup.get(key)
|
||||
if param_info is not None:
|
||||
explode = param_info.explode if param_info.explode is not None else True
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
continue
|
||||
if explode:
|
||||
# form,explode=true on objects: each property becomes
|
||||
# a separate query parameter.
|
||||
# e.g. {"R": 100, "G": 200} → R=100&G=200
|
||||
for k, v in value.items():
|
||||
serialized[_query_scalar_to_str(k)] = _query_scalar_to_str(
|
||||
v
|
||||
)
|
||||
else:
|
||||
style = param_info.style or "form"
|
||||
delimiter = self._STYLE_DELIMITERS.get(style, ",")
|
||||
# form,explode=false on objects: key,value pairs
|
||||
# e.g. {"R": 100, "G": 200} → "R,100,G,200"
|
||||
parts: list[str] = []
|
||||
for k, v in value.items():
|
||||
parts.append(_query_scalar_to_str(k))
|
||||
parts.append(_query_scalar_to_str(v))
|
||||
serialized[key] = delimiter.join(parts)
|
||||
continue
|
||||
if not explode:
|
||||
style = param_info.style or "form"
|
||||
delimiter = self._STYLE_DELIMITERS.get(style, ",")
|
||||
|
|
@ -269,17 +291,6 @@ class RequestDirector:
|
|||
_query_scalar_to_str(v) for v in value
|
||||
)
|
||||
continue
|
||||
elif isinstance(value, dict):
|
||||
if not value:
|
||||
continue
|
||||
# form,explode=false on objects: key,value pairs
|
||||
# e.g. {"R": 100, "G": 200} → "R,100,G,200"
|
||||
parts: list[str] = []
|
||||
for k, v in value.items():
|
||||
parts.append(_query_scalar_to_str(k))
|
||||
parts.append(_query_scalar_to_str(v))
|
||||
serialized[key] = delimiter.join(parts)
|
||||
continue
|
||||
serialized[key] = value
|
||||
return serialized
|
||||
|
||||
|
|
|
|||
|
|
@ -832,6 +832,98 @@ class TestQueryParameterSerialization:
|
|||
# Should contain alternating key,value pairs
|
||||
assert "100" in url and "200" in url and "150" in url
|
||||
|
||||
def test_explode_true_dict_expands_to_separate_params(self, director):
|
||||
"""style=form, explode=true on objects expands each property as a query param."""
|
||||
route = HTTPRoute(
|
||||
path="/test",
|
||||
method="GET",
|
||||
operation_id="test_endpoint",
|
||||
parameters=[
|
||||
ParameterInfo(
|
||||
name="data",
|
||||
location="query",
|
||||
required=True,
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"myAttribute": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
explode=True,
|
||||
)
|
||||
],
|
||||
parameter_map={
|
||||
"data": {"location": "query", "openapi_name": "data"},
|
||||
},
|
||||
)
|
||||
|
||||
request = director.build(
|
||||
route, {"data": {"myAttribute": True}}, "https://example.com"
|
||||
)
|
||||
url = str(request.url)
|
||||
# Should expand to myAttribute=true (not data={'myAttribute': True})
|
||||
assert "myAttribute=true" in url
|
||||
assert "data=" not in url
|
||||
|
||||
def test_explode_default_dict_expands_to_separate_params(self, director):
|
||||
"""Default explode (None → true) on objects expands properties."""
|
||||
route = HTTPRoute(
|
||||
path="/test",
|
||||
method="GET",
|
||||
operation_id="test_endpoint",
|
||||
parameters=[
|
||||
ParameterInfo(
|
||||
name="filter",
|
||||
location="query",
|
||||
required=True,
|
||||
schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {"type": "string"},
|
||||
"active": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
# explode defaults to None → treated as true
|
||||
)
|
||||
],
|
||||
parameter_map={
|
||||
"filter": {"location": "query", "openapi_name": "filter"},
|
||||
},
|
||||
)
|
||||
|
||||
request = director.build(
|
||||
route,
|
||||
{"filter": {"category": "electronics", "active": False}},
|
||||
"https://example.com",
|
||||
)
|
||||
url = str(request.url)
|
||||
assert "category=electronics" in url
|
||||
assert "active=false" in url
|
||||
assert "filter=" not in url
|
||||
|
||||
def test_explode_true_empty_dict_omitted(self, director):
|
||||
"""Empty dict with explode=true omits the parameter."""
|
||||
route = HTTPRoute(
|
||||
path="/items",
|
||||
method="GET",
|
||||
operation_id="list_items",
|
||||
parameters=[
|
||||
ParameterInfo(
|
||||
name="filter",
|
||||
location="query",
|
||||
required=False,
|
||||
schema={"type": "object"},
|
||||
explode=True,
|
||||
)
|
||||
],
|
||||
parameter_map={
|
||||
"filter": {"location": "query", "openapi_name": "filter"},
|
||||
},
|
||||
)
|
||||
|
||||
request = director.build(route, {"filter": {}}, "https://example.com")
|
||||
assert "filter" not in str(request.url)
|
||||
|
||||
def test_explode_false_empty_dict_omitted(self, director):
|
||||
"""Empty dict with explode=false omits the parameter."""
|
||||
route = HTTPRoute(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue