Repository: litestar-org/litestar

Issue:
Bug: pydantic validations aren't being handled by DTOs
### Description

In the documentation of Litestar we have:

> When a value fails pydantic validation, the result will be a ValidationException with the extra key set to the pydantic validation errors. Thus, this data will be made available for the API consumers by default.

But when combined with DTO's in a post request, if a pydantic validation fails the exception is not handled and the client receives an InternalServerError (500) instead of a BadRequest (400):


```
    __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)
pydantic_core._pydantic_core.ValidationError: 1 validation error for User
name
  String should have at most 2 characters [type=string_too_long, input_value='abc', input_type=str]
    For further information visit https://errors.pydantic.dev/2.2/v/string_too_long

Status code: 500
```

If the validation isn't handled by pydantic (or at least it isn't handled **first** by pydantic) it works, for example an Enum validation returns a proper 400 status code because `msgspec` catches it first:

```python
from enum import Enum

class NameEnum(str, Enum):
    a = "A"
    b = "B"

# (replace the User class of the example with this)
class User(BaseModel):
    name: NameEnum
```

Output for incorrect Enum:

```
  File ".../.venv/lib64/python3.11/site-packages/litestar/serialization/msgspec_hooks.py", line 191, in decode_json
    raise SerializationException(str(msgspec_error)) from msgspec_error
litestar.exceptions.base_exceptions.SerializationException: Invalid enum value 'abc' - at `$.name`


...

  File ".../.venv/lib64/python3.11/site-packages/litestar/routes/http.py", line 186, in _get_response_data
    raise ClientException(str(e)) from e
litestar.exceptions.http_exceptions.ClientException: 400: Invalid enum value 'abc' - at `$.name`
```

### URL to code causing the issue

_No response_

### MCVE

```python
from litestar import Litestar, post
from litestar.contrib.pydantic import PydanticDTO
from litestar.testing import TestClient
from pydantic import BaseModel, Field


class User(BaseModel):
    name: str = Field(max_length=2)


UserDTO = PydanticDTO[User]


@post("/user", dto=UserDTO, sync_to_thread=False)
def create_user(data: User) -> User:
    return data


with TestClient(Litestar([create_user], debug=True)) as client:
    response = client.post("/user", json={"name": "abc"})
    print(response.text)
    print(f"Status code: {response.status_code}")
    assert response.status_code == 201
```


### Steps to reproduce

```bash
1. Execute the MCVE
```


### Screenshots

_No response_

### Logs

_No response_

### Litestar Version

2.0.0rc1

### Platform

- [X] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---

## Funding
* If you would like to see an issue prioritized, make a pledge towards it!
* We receive the pledge once the issue is completed & verified

<a href="https://polar.sh/litestar-org/litestar/issues/2190">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2190/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2190/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->

StaticFilesConfig and virtual directories
I'm trying to write a ``FileSystemProtocol`` to load files from the package data using [importlib_resources](https://importlib-resources.readthedocs.io/en/latest/using.html#). But because ``directories`` is defined as ``DirectoryPath``, pydantic checks if the given directories exist in the local filesystem. 

This is not generally true, especially in any kind of virtual filesystem (e.g. a zipped package). I think this condition should be relaxed to support virtual filesystems.

https://github.com/starlite-api/starlite/blob/9bb6dcd57c10a591377cf8e3a537e9292566d5b9/starlite/config/static_files.py#L32


Discussion:
If no one else is looking into this, could someone assign this to me please?
I agree. Do you want to submit a PR?

Resolving patch:
diff --git a/litestar/contrib/pydantic/pydantic_dto_factory.py b/litestar/contrib/pydantic/pydantic_dto_factory.py
--- a/litestar/contrib/pydantic/pydantic_dto_factory.py
+++ b/litestar/contrib/pydantic/pydantic_dto_factory.py
@@ -3,20 +3,23 @@
 from dataclasses import replace
 from typing import TYPE_CHECKING, Collection, Generic, TypeVar
 
+from typing_extensions import override
+
 from litestar.dto.base_dto import AbstractDTO
 from litestar.dto.data_structures import DTOFieldDefinition
 from litestar.dto.field import DTO_FIELD_META_KEY, DTOField
-from litestar.exceptions import MissingDependencyException
+from litestar.exceptions import MissingDependencyException, ValidationException
 from litestar.types.empty import Empty
 
 if TYPE_CHECKING:
-    from typing import Generator
+    from typing import Any, Generator
 
     from litestar.typing import FieldDefinition
 
 
 try:
     import pydantic
+    from pydantic import ValidationError
 
     if pydantic.VERSION.startswith("2"):
         from pydantic_core import PydanticUndefined
@@ -33,6 +36,20 @@
 class PydanticDTO(AbstractDTO[T], Generic[T]):
     """Support for domain modelling with Pydantic."""
 
+    @override
+    def decode_builtins(self, value: dict[str, Any]) -> Any:
+        try:
+            return super().decode_builtins(value)
+        except ValidationError as ex:
+            raise ValidationException(extra=ex.errors()) from ex
+
+    @override
+    def decode_bytes(self, value: bytes) -> Any:
+        try:
+            return super().decode_bytes(value)
+        except ValidationError as ex:
+            raise ValidationException(extra=ex.errors()) from ex
+
     @classmethod
     def generate_field_definitions(
         cls, model_type: type[pydantic.BaseModel]


Test patch:
diff --git a/tests/unit/test_contrib/test_pydantic/test_integration.py b/tests/unit/test_contrib/test_pydantic/test_integration.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_contrib/test_pydantic/test_integration.py
@@ -0,0 +1,54 @@
+from typing import Any
+
+from pydantic import VERSION, BaseModel, Field
+
+from litestar import post
+from litestar.contrib.pydantic.pydantic_dto_factory import PydanticDTO
+from litestar.testing import create_test_client
+
+
+def test_pydantic_validation_error_raises_400() -> None:
+    class Model(BaseModel):
+        foo: str = Field(max_length=2)
+
+    ModelDTO = PydanticDTO[Model]
+
+    @post(dto=ModelDTO)
+    def handler(data: Model) -> Model:
+        return data
+
+    model_json = {"foo": "too long"}
+    expected_errors: list[dict[str, Any]]
+
+    if VERSION.startswith("1"):
+        expected_errors = [
+            {
+                "loc": ["foo"],
+                "msg": "ensure this value has at most 2 characters",
+                "type": "value_error.any_str.max_length",
+                "ctx": {"limit_value": 2},
+            }
+        ]
+    else:
+        expected_errors = [
+            {
+                "type": "string_too_long",
+                "loc": ["foo"],
+                "msg": "String should have at most 2 characters",
+                "input": "too long",
+                "ctx": {"max_length": 2},
+            }
+        ]
+
+    with create_test_client(route_handlers=handler) as client:
+        response = client.post("/", json=model_json)
+
+        assert response.status_code == 400
+
+        extra = response.json()["extra"]
+
+        if VERSION.startswith("2"):
+            # the URL keeps on changing as per the installed pydantic version
+            extra[0].pop("url")
+
+        assert extra == expected_errors
diff --git a/tests/unit/test_contrib/test_pydantic/test_pydanticdt_dto_factory.py b/tests/unit/test_contrib/test_pydantic/test_pydantic_dto_factory.py
similarity index 100%
rename from tests/unit/test_contrib/test_pydantic/test_pydanticdt_dto_factory.py
rename to tests/unit/test_contrib/test_pydantic/test_pydantic_dto_factory.py