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
54 changes: 45 additions & 9 deletions litestar/_signature/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class ErrorMessage(TypedDict):
"max_length",
)

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

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

if keys:
message["key"] = key = ".".join(keys)
if keys[0].startswith("data"):
field_name = keys[0]
message["key"] = ".".join(keys)

if field_name == "data":
message["key"] = message["key"].replace("data.", "")
message["source"] = "body"
elif key in connection.query_params:
elif field_name in connection.query_params:
delim = "."
if field_name in cls._fields and cls._fields[field_name].is_non_string_sequence:
delim = ""
message["key"] = delim.join(keys)
message["source"] = ParamType.QUERY
elif key in connection.path_params:
elif field_name in connection.path_params:
message["source"] = ParamType.PATH

elif key in cls._fields and isinstance(cls._fields[key].kwarg_definition, ParameterKwarg):
if cast(ParameterKwarg, cls._fields[key].kwarg_definition).cookie:
elif field_name in cls._fields and isinstance(cls._fields[field_name].kwarg_definition, ParameterKwarg):
delim = "" if cls._fields[field_name].is_non_string_sequence else "."
if cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).cookie:
message["key"] = delim.join(
[str(cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).cookie), *keys[1:]]
)
message["source"] = ParamType.COOKIE
elif cast(ParameterKwarg, cls._fields[key].kwarg_definition).header:
elif cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).header:
message["key"] = delim.join(
[str(cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).header), *keys[1:]]
)
message["source"] = ParamType.HEADER
elif cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).query:
message["key"] = delim.join(
[str(cast(ParameterKwarg, cls._fields[field_name].kwarg_definition).query), *keys[1:]]
)
message["source"] = ParamType.QUERY
else:
message["key"] = delim.join(keys)
message["source"] = ParamType.QUERY
elif field_name in cls._dependency_name_set:
message["key"] = field_name

return message

Expand Down Expand Up @@ -205,7 +226,22 @@ def parse_values_from_connection_kwargs(cls, connection: ASGIConnection, kwargs:
return convert(kwargs, cls, strict=False, dec_hook=deserializer, str_keys=True).to_dict()
except ExtendedMsgSpecValidationError as e:
for exc in e.errors:
keys = [str(loc) for loc in exc["loc"]]
keys = []
path = ""
for i, loc in enumerate(exc["loc"]):
x = ""
if isinstance(loc, int):
x = f"[{loc}]"
else:
if i > 1:
x += "."
x += str(loc)
if i == 0:
keys.append(x)
else:
path += x
if path:
keys.append(path)
message = cls._build_error_message(keys=keys, exc_msg=exc["msg"], connection=connection)
messages.append(message)
raise cls._create_exception(messages=messages, connection=connection) from e
Expand Down
48 changes: 46 additions & 2 deletions tests/unit/test_plugins/test_pydantic/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def test(
assert data["extra"] == [
{"key": "child.val", "message": "value is not a valid integer"},
{"key": "child.other_val", "message": "value is not a valid integer"},
{"key": "other_child.val.1", "message": "value is not a valid integer"},
{"key": "other_child.val[1]", "message": "value is not a valid integer"},
]
else:
assert data["extra"] == [
Expand All @@ -194,11 +194,55 @@ def test(
},
{
"message": "Input should be a valid integer, unable to parse string as an integer",
"key": "other_child.val.1",
"key": "other_child.val[1]",
},
]


def test_signature_model_invalid_input_nested(base_model: BaseModelType, pydantic_version: PydanticVersion) -> None:
class OtherChild(base_model): # type: ignore[misc, valid-type]
val: List[int]

class Child(base_model): # type: ignore[misc, valid-type]
other_val: OtherChild

class Parent(base_model): # type: ignore[misc, valid-type]
child: Child

@post("/")
def test(data: Parent) -> None: ...

with create_test_client(route_handlers=[test], signature_types=[Parent]) as client:
response = client.post("/", json={"child": {"other_val": {"val": [1, "c"]}}})

assert response.status_code == HTTP_400_BAD_REQUEST

data = response.json()
assert data
if pydantic_version == "v1":
assert data["extra"] == [
{"key": "child.other_val.val[1]", "message": "value is not a valid integer"},
]
else:
assert data["extra"] == [
{
"message": "Input should be a valid integer, unable to parse string as an integer",
"key": "child.other_val.val[1]",
},
]

response = client.post("/", json=[])

assert response.status_code == HTTP_400_BAD_REQUEST

data = response.json()
assert data
if pydantic_version == "v1":
assert data["extra"] == [{"message": "field required", "key": "child"}]
else:
assert data["extra"] == [{"message": "Input should be a valid dictionary or instance of Parent"}]


class V1ModelWithPrivateFields(pydantic_v1.BaseModel):
class Config:
underscore_fields_are_private = True
Expand Down
29 changes: 23 additions & 6 deletions tests/unit/test_signature/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,23 @@ def test(param: Annotated[int, Parameter(le=10)]) -> None: ...
}


def test_invalid_list_exception_key() -> None:
@post("/")
def test(data: List[int], data_list: Annotated[List[int], Parameter(query="params")]) -> None: ...

with create_test_client(route_handlers=[test]) as client:
response = client.post("/", json=[1, 2, "oops"], params={"params": [1, 2, "oops"]})

assert response.json() == {
"status_code": 400,
"detail": "Validation failed for POST /?params=1&params=2&params=oops",
"extra": [
{"message": "Expected `int`, got `str`", "key": "[2]", "source": "body"},
{"message": "Expected `int`, got `str`", "key": "params[2]", "source": "query"},
],
}


def test_client_backend_error_precedence_over_server_error() -> None:
dependencies = {
"dep": Provide(lambda: "thirteen", sync_to_thread=False),
Expand Down Expand Up @@ -189,8 +206,8 @@ def test(
assert data["extra"] == [
{"message": "Expected `int`, got `str`", "key": "child.val", "source": "body"},
{"message": "Expected `int`, got `str`", "key": "int_param", "source": "query"},
{"message": "Expected `int`, got `str`", "key": "int_header", "source": "header"},
{"message": "Expected `int`, got `str`", "key": "int_cookie", "source": "cookie"},
{"message": "Expected `int`, got `str`", "key": "X-SOME-INT", "source": "header"},
{"message": "Expected `int`, got `str`", "key": "int-cookie", "source": "cookie"},
]


Expand Down Expand Up @@ -236,8 +253,8 @@ def test(
{"message": "Expected `int`, got `str`", "key": "child.val", "source": "body"},
{"message": "Expected `int`, got `str`", "key": "int_param", "source": "query"},
{"message": "Expected `str` of length >= 2", "key": "length_param", "source": "query"},
{"message": "Expected `int`, got `str`", "key": "int_header", "source": "header"},
{"message": "Expected `int`, got `str`", "key": "int_cookie", "source": "cookie"},
{"message": "Expected `int`, got `str`", "key": "X-SOME-INT", "source": "header"},
{"message": "Expected `int`, got `str`", "key": "int-cookie", "source": "cookie"},
]


Expand Down Expand Up @@ -280,8 +297,8 @@ def test(
{"message": "Expected `int`, got `str`", "key": "child.val", "source": "body"},
{"message": "Expected `int`, got `str`", "key": "int_param", "source": "query"},
{"message": "Expected `str` of length >= 2", "key": "length_param", "source": "query"},
{"message": "Expected `int`, got `str`", "key": "int_header", "source": "header"},
{"message": "Expected `int`, got `str`", "key": "int_cookie", "source": "cookie"},
{"message": "Expected `int`, got `str`", "key": "X-SOME-INT", "source": "header"},
{"message": "Expected `int`, got `str`", "key": "int-cookie", "source": "cookie"},
]


Expand Down
Loading