diff --git a/litestar/_signature/model.py b/litestar/_signature/model.py index b9a8255c09..f2f1ba09a8 100644 --- a/litestar/_signature/model.py +++ b/litestar/_signature/model.py @@ -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)), @@ -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 @@ -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 diff --git a/tests/unit/test_plugins/test_pydantic/test_integration.py b/tests/unit/test_plugins/test_pydantic/test_integration.py index 64a7bc6674..a413cd3a24 100644 --- a/tests/unit/test_plugins/test_pydantic/test_integration.py +++ b/tests/unit/test_plugins/test_pydantic/test_integration.py @@ -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"] == [ @@ -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 diff --git a/tests/unit/test_signature/test_validation.py b/tests/unit/test_signature/test_validation.py index 23f1cb0318..5fb3575d1f 100644 --- a/tests/unit/test_signature/test_validation.py +++ b/tests/unit/test_signature/test_validation.py @@ -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¶ms=2¶ms=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), @@ -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"}, ] @@ -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"}, ] @@ -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"}, ]