Skip to content

Commit 3f30198

Browse files
committed
feat: improve "key" property in validation error messages
* Start "key" with an array index when the validation error applies to a request body or a query parameter that is a list. * Use alternative name for query parameters, cookies, and headers in "key" instead of the parameter's name in a route handler's signature. * Parameters in route handlers that are not the request body but whose names start with "data" will not have their validation error message "source" set to "body". * Use a "key" that better resembles the format that msgspec uses when an ExtendedMsgSpecValidationError is raised.
1 parent ce67b6f commit 3f30198

File tree

3 files changed

+64
-17
lines changed

3 files changed

+64
-17
lines changed

litestar/_signature/model.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class ErrorMessage(TypedDict):
7474
"max_length",
7575
)
7676

77-
ERR_RE = re.compile(r"`\$\.(.+)`$")
77+
ERR_RE = re.compile(r"`\$\.?(.+)`$")
7878

7979
DEFAULT_TYPE_DECODERS = [
8080
(lambda x: is_class_and_subclass(x, (Path, PurePath, ImmutableState, UUID)), lambda t, v: t(v)),
@@ -150,22 +150,43 @@ def _build_error_message(cls, keys: Sequence[str], exc_msg: str, connection: ASG
150150
message: ErrorMessage = {"message": exc_msg.split(" - ")[0]}
151151

152152
if keys:
153-
message["key"] = key = ".".join(keys)
154-
if keys[0].startswith("data"):
153+
field_name = keys[0]
154+
message["key"] = ".".join(keys)
155+
156+
if field_name == "data":
155157
message["key"] = message["key"].replace("data.", "")
156158
message["source"] = "body"
157-
elif key in connection.query_params:
159+
elif field_name in connection.query_params:
160+
delim = "."
161+
if field_name in cls._fields and cls._fields[field_name].is_non_string_sequence:
162+
delim = ""
163+
message["key"] = delim.join(keys)
158164
message["source"] = ParamType.QUERY
159-
elif key in connection.path_params:
165+
elif field_name in connection.path_params:
160166
message["source"] = ParamType.PATH
161167

162-
elif key in cls._fields and isinstance(cls._fields[key].kwarg_definition, ParameterKwarg):
163-
if cast(ParameterKwarg, cls._fields[key].kwarg_definition).cookie:
168+
elif field_name in cls._fields and isinstance(cls._fields[field_name].kwarg_definition, ParameterKwarg):
169+
delim = "" if cls._fields[field_name].is_non_string_sequence else "."
170+
if cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).cookie:
171+
message["key"] = delim.join(
172+
[str(cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).cookie), *keys[1:]]
173+
)
164174
message["source"] = ParamType.COOKIE
165-
elif cast(ParameterKwarg, cls._fields[key].kwarg_definition).header:
175+
elif cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).header:
176+
message["key"] = delim.join(
177+
[str(cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).header), *keys[1:]]
178+
)
166179
message["source"] = ParamType.HEADER
180+
elif cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).query:
181+
message["key"] = delim.join(
182+
[str(cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).query), *keys[1:]]
183+
)
184+
message["source"] = ParamType.QUERY
167185
else:
186+
message["key"] = delim.join(keys)
168187
message["source"] = ParamType.QUERY
188+
elif field_name in cls._dependency_name_set:
189+
message["key"] = field_name
169190

170191
return message
171192

@@ -205,7 +226,16 @@ def parse_values_from_connection_kwargs(cls, connection: ASGIConnection, kwargs:
205226
return convert(kwargs, cls, strict=False, dec_hook=deserializer, str_keys=True).to_dict()
206227
except ExtendedMsgSpecValidationError as e:
207228
for exc in e.errors:
208-
keys = [str(loc) for loc in exc["loc"]]
229+
keys = [exc["loc"][0]] if exc["loc"] else []
230+
path = ""
231+
for i, loc in enumerate(exc["loc"][1:]):
232+
if isinstance(loc, int):
233+
path += f"[{loc}]"
234+
else:
235+
if i > 0:
236+
path += "."
237+
path += str(loc)
238+
keys.append(path)
209239
message = cls._build_error_message(keys=keys, exc_msg=exc["msg"], connection=connection)
210240
messages.append(message)
211241
raise cls._create_exception(messages=messages, connection=connection) from e

tests/unit/test_plugins/test_pydantic/test_integration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ def test(
180180
assert data["extra"] == [
181181
{"key": "child.val", "message": "value is not a valid integer"},
182182
{"key": "child.other_val", "message": "value is not a valid integer"},
183-
{"key": "other_child.val.1", "message": "value is not a valid integer"},
183+
{"key": "other_child.val[1]", "message": "value is not a valid integer"},
184184
]
185185
else:
186186
assert data["extra"] == [
@@ -194,7 +194,7 @@ def test(
194194
},
195195
{
196196
"message": "Input should be a valid integer, unable to parse string as an integer",
197-
"key": "other_child.val.1",
197+
"key": "other_child.val[1]",
198198
},
199199
]
200200

tests/unit/test_signature/test_validation.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,23 @@ def test(param: Annotated[int, Parameter(le=10)]) -> None: ...
9292
}
9393

9494

95+
def test_invalid_list_exception_key() -> None:
96+
@post("/")
97+
def test(data: List[int], data_list: Annotated[List[int], Parameter(query="params")]) -> None: ...
98+
99+
with create_test_client(route_handlers=[test]) as client:
100+
response = client.post("/", json=[1, 2, "oops"], params={"params": [1, 2, "oops"]})
101+
102+
assert response.json() == {
103+
"status_code": 400,
104+
"detail": "Validation failed for POST /?params=1&params=2&params=oops",
105+
"extra": [
106+
{"message": "Expected `int`, got `str`", "key": "[2]", "source": "body"},
107+
{"message": "Expected `int`, got `str`", "key": "params[2]", "source": "query"},
108+
],
109+
}
110+
111+
95112
def test_client_backend_error_precedence_over_server_error() -> None:
96113
dependencies = {
97114
"dep": Provide(lambda: "thirteen", sync_to_thread=False),
@@ -189,8 +206,8 @@ def test(
189206
assert data["extra"] == [
190207
{"message": "Expected `int`, got `str`", "key": "child.val", "source": "body"},
191208
{"message": "Expected `int`, got `str`", "key": "int_param", "source": "query"},
192-
{"message": "Expected `int`, got `str`", "key": "int_header", "source": "header"},
193-
{"message": "Expected `int`, got `str`", "key": "int_cookie", "source": "cookie"},
209+
{"message": "Expected `int`, got `str`", "key": "X-SOME-INT", "source": "header"},
210+
{"message": "Expected `int`, got `str`", "key": "int-cookie", "source": "cookie"},
194211
]
195212

196213

@@ -236,8 +253,8 @@ def test(
236253
{"message": "Expected `int`, got `str`", "key": "child.val", "source": "body"},
237254
{"message": "Expected `int`, got `str`", "key": "int_param", "source": "query"},
238255
{"message": "Expected `str` of length >= 2", "key": "length_param", "source": "query"},
239-
{"message": "Expected `int`, got `str`", "key": "int_header", "source": "header"},
240-
{"message": "Expected `int`, got `str`", "key": "int_cookie", "source": "cookie"},
256+
{"message": "Expected `int`, got `str`", "key": "X-SOME-INT", "source": "header"},
257+
{"message": "Expected `int`, got `str`", "key": "int-cookie", "source": "cookie"},
241258
]
242259

243260

@@ -280,8 +297,8 @@ def test(
280297
{"message": "Expected `int`, got `str`", "key": "child.val", "source": "body"},
281298
{"message": "Expected `int`, got `str`", "key": "int_param", "source": "query"},
282299
{"message": "Expected `str` of length >= 2", "key": "length_param", "source": "query"},
283-
{"message": "Expected `int`, got `str`", "key": "int_header", "source": "header"},
284-
{"message": "Expected `int`, got `str`", "key": "int_cookie", "source": "cookie"},
300+
{"message": "Expected `int`, got `str`", "key": "X-SOME-INT", "source": "header"},
301+
{"message": "Expected `int`, got `str`", "key": "int-cookie", "source": "cookie"},
285302
]
286303

287304

0 commit comments

Comments
 (0)