Skip to content

Commit b213a26

Browse files
authored
fix(params): support typed collection casting (#409)
1 parent ce629e6 commit b213a26

7 files changed

Lines changed: 349 additions & 54 deletions

File tree

docs/en/docs/release-notes.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Release Notes
22

3+
## 0.26.2
4+
5+
### Fixed
6+
7+
- Query, header, and cookie parameter casting now handles repeated values correctly, preserving list-style inputs instead of nesting them.
8+
- Parameter casting now supports typed collection casts such as `list[str]`, `dict[str, int]`, and nested union collections like `list[str | int | dict[str, str]]`.
9+
- `cast=list` now treats a single string value as a one-item list instead of splitting it into characters.
10+
- Default parameter values are now passed through their configured cast when applicable.
11+
312
## 0.26.1
413

514
### Added

lilya/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.26.1"
1+
__version__ = "0.26.2"

lilya/_internal/_responses.py

Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
UnprocessableEntity,
3838
WebSocketException,
3939
)
40-
from lilya.params import Cookie, Header, Query
40+
from lilya.params import Cookie, Header, Query, get_cast_name
4141
from lilya.requests import Request
4242
from lilya.responses import Ok, Response
4343
from lilya.serializers import serializer
@@ -59,6 +59,22 @@
5959
_SIG_CACHE_ATTR = "__lilya_resolved_signature__"
6060
_PLAN_CACHE_ATTR = "__lilya_handler_plan__"
6161

62+
63+
def _resolve_bound_param_value(field: Query | Header | Cookie, raw_value: Any) -> Any:
64+
if raw_value is None:
65+
default = getattr(field, "default", None)
66+
return (
67+
field.resolve(default, field.cast) if field.cast and default is not None else default
68+
)
69+
70+
if field.cast:
71+
return field.resolve(raw_value, field.cast)
72+
73+
if isinstance(raw_value, list) and len(raw_value) == 1:
74+
return raw_value[0]
75+
return raw_value
76+
77+
6278
ZeroArgAsyncHandler = Callable[[], Awaitable[Any]]
6379
KwargsAsyncHandler = Callable[..., Awaitable[Any]]
6480
ZeroArgSyncHandler = Callable[[], Any]
@@ -486,24 +502,13 @@ def get_request() -> Request:
486502
if field.required and raw_value is None:
487503
raise UnprocessableEntity(f"Missing mandatory query parameter '{key}'")
488504

489-
if raw_value is None:
490-
fast_params[n] = getattr(field, "default", None)
491-
else:
492-
try:
493-
if field.cast and isinstance(raw_value, list):
494-
fast_params[n] = [raw_value]
495-
elif field.cast:
496-
fast_params[n] = field.resolve(raw_value, field.cast)
497-
else:
498-
fast_params[n] = (
499-
raw_value[0]
500-
if isinstance(raw_value, list) and len(raw_value) == 1
501-
else raw_value
502-
)
503-
except (TypeError, ValueError):
504-
raise UnprocessableEntity(
505-
f"Invalid value for query parameter '{key}': expected {field.cast.__name__}"
506-
) from None
505+
try:
506+
fast_params[n] = _resolve_bound_param_value(field, raw_value)
507+
except (TypeError, ValueError):
508+
raise UnprocessableEntity(
509+
f"Invalid value for query parameter '{key}': "
510+
f"expected {get_cast_name(field.cast)}"
511+
) from None
507512

508513
# 3) Context (if requested)
509514
if needs_context:
@@ -652,25 +657,12 @@ def extract_request_params_information(
652657
if field.required and raw_value is None:
653658
raise UnprocessableEntity(f"Missing mandatory query parameter '{key}'") from None
654659

655-
# Fallback to default
656-
if raw_value is None:
657-
request_params[name] = field.default if hasattr(field, "default") else None
658-
continue
659-
660-
# Apply casting if defined
661660
try:
662-
if field.cast and isinstance(raw_value, list):
663-
request_params[name] = [raw_value]
664-
elif field.cast:
665-
request_params[name] = field.resolve(raw_value, field.cast)
666-
else:
667-
if isinstance(raw_value, list) and len(raw_value) == 1:
668-
request_params[name] = raw_value[0]
669-
else:
670-
request_params[name] = raw_value
661+
request_params[name] = _resolve_bound_param_value(field, raw_value)
671662
except (TypeError, ValueError):
672663
raise UnprocessableEntity(
673-
f"Invalid value for query parameter '{key}': expected {field.cast.__name__}"
664+
f"Invalid value for query parameter '{key}': "
665+
f"expected {get_cast_name(field.cast)}"
674666
) from None
675667

676668
return request_params

lilya/params.py

Lines changed: 121 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,134 @@
1+
import json
2+
from collections.abc import Mapping
13
from dataclasses import dataclass
2-
from typing import Any
4+
from types import UnionType
5+
from typing import Annotated, Any, Union, get_args, get_origin
6+
7+
TRUE_VALUES = {"true", "1", "yes", "on", "t"}
8+
FALSE_VALUES = {"false", "0", "no", "off", "f"}
9+
10+
11+
def _strip_annotated(cast: Any) -> Any:
12+
while get_origin(cast) is Annotated:
13+
cast = get_args(cast)[0]
14+
return cast
15+
16+
17+
def _is_union(cast: Any) -> bool:
18+
return get_origin(cast) in (Union, UnionType)
19+
20+
21+
def _as_list(value: Any) -> list[Any]:
22+
if isinstance(value, list):
23+
return value
24+
if isinstance(value, tuple | set | frozenset):
25+
return list(value)
26+
return [value]
27+
28+
29+
def _as_mapping(value: Any) -> Mapping[Any, Any]:
30+
if isinstance(value, Mapping):
31+
return value
32+
33+
if isinstance(value, bytes):
34+
value = value.decode("utf-8")
35+
36+
if isinstance(value, str):
37+
value = json.loads(value)
38+
if isinstance(value, Mapping):
39+
return value
40+
41+
return dict(value)
42+
43+
44+
def get_cast_name(cast: Any) -> str:
45+
return getattr(cast, "__name__", str(cast).replace("typing.", ""))
346

447

548
@dataclass
649
class BaseParam:
7-
def __cast__(self, value: Any, cast: type) -> Any:
8-
try:
9-
if str(value).lower() in ("true", "1", "yes", "on", "t") and cast is bool:
10-
value = True
11-
elif str(value).lower() in ("false", "0", "no", "off", "f") and cast is bool:
12-
value = False
13-
else:
14-
value = cast(value)
15-
except Exception:
16-
raise
17-
return value
50+
def __cast__(self, value: Any, cast: Any) -> Any:
51+
cast = _strip_annotated(cast)
52+
53+
if cast is Any or cast is object:
54+
return value
55+
56+
if _is_union(cast):
57+
union_types = [typ for typ in get_args(cast) if typ is not type(None)]
58+
if value is None and len(union_types) != len(get_args(cast)):
59+
return None
60+
61+
if isinstance(value, str) and str in union_types and len(union_types) > 1:
62+
union_types = [typ for typ in union_types if typ is not str] + [str]
63+
64+
for typ in union_types:
65+
try:
66+
return self.__cast__(value, typ)
67+
except (TypeError, ValueError):
68+
continue
69+
raise ValueError(f"Cannot cast value {value!r} to {get_cast_name(cast)}")
70+
71+
origin = get_origin(cast)
72+
args = get_args(cast)
73+
74+
if cast is bool:
75+
if isinstance(value, bool):
76+
return value
77+
if isinstance(value, str):
78+
value_lower = value.lower()
79+
if value_lower in TRUE_VALUES:
80+
return True
81+
if value_lower in FALSE_VALUES:
82+
return False
83+
raise ValueError(f"Cannot cast value {value!r} to bool")
84+
return bool(value)
85+
86+
if cast is list or origin is list:
87+
values = _as_list(value)
88+
item_cast = args[0] if args else Any
89+
return [self.__cast__(item, item_cast) for item in values]
90+
91+
if cast is tuple or origin is tuple:
92+
values = _as_list(value)
93+
if not args:
94+
return tuple(values)
95+
if len(args) == 2 and args[1] is Ellipsis:
96+
return tuple(self.__cast__(item, args[0]) for item in values)
97+
return tuple(
98+
self.__cast__(item, item_cast)
99+
for item, item_cast in zip(values, args, strict=False)
100+
)
101+
102+
if cast is set or origin is set:
103+
values = _as_list(value)
104+
item_cast = args[0] if args else Any
105+
return {self.__cast__(item, item_cast) for item in values}
106+
107+
if cast is frozenset or origin is frozenset:
108+
values = _as_list(value)
109+
item_cast = args[0] if args else Any
110+
return frozenset(self.__cast__(item, item_cast) for item in values)
111+
112+
if cast is dict or origin is dict:
113+
mapping = _as_mapping(value)
114+
key_cast, value_cast = args or (Any, Any)
115+
return {
116+
self.__cast__(key, key_cast): self.__cast__(item, value_cast)
117+
for key, item in mapping.items()
118+
}
119+
120+
return cast(value)
18121

19122

20123
@dataclass
21124
class Query(BaseParam):
22125
default: Any | None = None
23126
alias: str | None = None
24127
required: bool = False
25-
cast: type | None = None
128+
cast: Any | None = None
26129
description: str | None = None
27130

28-
def resolve(self, value: Any, cast: type) -> Any:
131+
def resolve(self, value: Any, cast: Any) -> Any:
29132
return self.__cast__(value, cast) if cast else value
30133

31134

@@ -34,10 +137,10 @@ class Header(BaseParam):
34137
value: Any
35138
alias: str | None = None
36139
required: bool = False
37-
cast: type | None = None
140+
cast: Any | None = None
38141
description: str | None = None
39142

40-
def resolve(self, value: Any, cast: type) -> Any:
143+
def resolve(self, value: Any, cast: Any) -> Any:
41144
return self.__cast__(value, cast) if cast else value
42145

43146

@@ -46,8 +149,8 @@ class Cookie(BaseParam):
46149
value: Any
47150
alias: str | None = None
48151
required: bool = False
49-
cast: type | None = None
152+
cast: Any | None = None
50153
description: str | None = None
51154

52-
def resolve(self, value: Any, cast: type) -> Any:
155+
def resolve(self, value: Any, cast: Any) -> Any:
53156
return self.__cast__(value, cast) if cast else value

tests/params/test_cookie.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ async def cookie_casted(visitor: int = Cookie(value="visit_count", cast=int)):
2929
return {"visitor": visitor}
3030

3131

32+
async def cookie_typed_list(scope: list[str] = Cookie(value="scope", cast=list[str])):
33+
return {"scope": scope}
34+
35+
36+
async def cookie_typed_dict(
37+
settings: dict[str, bool] = Cookie(value="settings", cast=dict[str, bool]),
38+
):
39+
return {"settings": settings}
40+
41+
3242
async def cookie_invalid_cast(visitor: int = Cookie(value="visit_count", cast=int)):
3343
return {"visitor": visitor}
3444

@@ -82,6 +92,22 @@ def test_cookie_casted(test_client_factory):
8292
assert response.json() == {"visitor": 5}
8393

8494

95+
def test_cookie_typed_list(test_client_factory):
96+
with create_client(
97+
routes=[Path("/", cookie_typed_list)], settings_module=EncoderSettings
98+
) as client:
99+
response = client.get("/", cookies={"scope": "read"})
100+
assert response.json() == {"scope": ["read"]}
101+
102+
103+
def test_cookie_typed_dict(test_client_factory):
104+
with create_client(
105+
routes=[Path("/", cookie_typed_dict)], settings_module=EncoderSettings
106+
) as client:
107+
response = client.get("/", cookies={"settings": '{"dark":"true","beta":false}'})
108+
assert response.json() == {"settings": {"dark": True, "beta": False}}
109+
110+
85111
def test_cookie_invalid_cast(test_client_factory):
86112
with create_client(
87113
routes=[Path("/", cookie_invalid_cast)], settings_module=EncoderSettings

tests/params/test_headers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ async def header_casted(content_length: int = Header(value="Content-Length", cas
2929
return {"length": content_length}
3030

3131

32+
async def header_typed_list(role: list[str] = Header(value="X-ROLE", cast=list[str])):
33+
return {"role": role}
34+
35+
36+
async def header_typed_dict(meta: dict[str, int] = Header(value="X-META", cast=dict[str, int])):
37+
return {"meta": meta}
38+
39+
3240
async def header_invalid_cast(content_length: int = Header(value="Content-Length", cast=int)):
3341
return {"length": content_length}
3442

@@ -66,6 +74,33 @@ def test_header_casted():
6674
assert response.json() == {"length": 123}
6775

6876

77+
def test_header_typed_list_single(test_client_factory):
78+
with create_client(
79+
routes=[Path("/", header_typed_list)], settings_module=EncoderSettings
80+
) as client:
81+
response = client.get("/", headers={"X-ROLE": "admin"})
82+
83+
assert response.json() == {"role": ["admin"]}
84+
85+
86+
def test_header_typed_list_multiple(test_client_factory):
87+
with create_client(
88+
routes=[Path("/", header_typed_list)], settings_module=EncoderSettings
89+
) as client:
90+
response = client.get("/", headers=[("X-ROLE", "admin"), ("X-ROLE", "staff")])
91+
92+
assert response.json() == {"role": ["admin", "staff"]}
93+
94+
95+
def test_header_typed_dict(test_client_factory):
96+
with create_client(
97+
routes=[Path("/", header_typed_dict)], settings_module=EncoderSettings
98+
) as client:
99+
response = client.get("/", headers={"X-META": '{"page":"2","limit":10}'})
100+
101+
assert response.json() == {"meta": {"page": 2, "limit": 10}}
102+
103+
69104
def test_header_invalid_cast(test_client_factory):
70105
with create_client(
71106
routes=[Path("/", header_invalid_cast)], settings_module=EncoderSettings

0 commit comments

Comments
 (0)