Repository: litestar-org/litestar

Issue:
Bug: DTO support empty payload
### Description

DTO supported data fails if no body is sent with the request, even if a default value is provided for the parameter.

### URL to code causing the issue

_No response_

### MCVE

```python
from dataclasses import dataclass
from typing import Optional

from litestar import Litestar, post
from litestar.contrib.pydantic import PydanticPlugin
from litestar.dto import DataclassDTO
from litestar.logging import LoggingConfig


@dataclass
class Foo:
    foo: str


@post(path="/", dto=DataclassDTO[Foo])
def test(data: Optional[Foo] = None) -> dict:
    return {"foo": data}


app = Litestar(
    route_handlers=[test],
    logging_config=LoggingConfig(),
    middleware=[],
    plugins=[PydanticPlugin(prefer_alias=True)],
)
```


### Steps to reproduce

```bash
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
```


### Screenshots

```bash
"![SCREENSHOT_DESCRIPTION](SCREENSHOT_LINK.png)"
```


### Logs

```bash
FO:     127.0.0.1:38110 - "POST / HTTP/1.1" 400 Bad Request
ERROR - 2023-12-15 02:27:22,851 - litestar - config - exception raised on http connection to route /

Traceback (most recent call last):
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/serialization/msgspec_hooks.py", line 187, in decode_json
    return msgspec.json.decode(
msgspec.DecodeError: Input data was truncated

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/routes/http.py", line 177, in _get_response_data
    kwargs["data"] = await kwargs["data"]
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/_kwargs/extractors.py", line 450, in dto_extractor
    return data_dto(connection).decode_bytes(body)
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/dto/base_dto.py", line 97, in decode_bytes
    return backend.populate_data_from_raw(value, self.asgi_connection)
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/dto/_backend.py", line 301, in populate_data_from_raw
    source_data=self.parse_raw(raw, asgi_connection),
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/dto/_backend.py", line 208, in parse_raw
    result = decode_json(value=raw, target_type=self.annotation, type_decoders=type_decoders)
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/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: Input data was truncated

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/middleware/exceptions/middleware.py", line 191, in __call__
    await self.app(scope, receive, send)
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/routes/http.py", line 81, in handle
    response = await self._get_response_for_request(
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/routes/http.py", line 133, in _get_response_for_request
    return await self._call_handler_function(
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/routes/http.py", line 153, in _call_handler_function
    response_data, cleanup_group = await self._get_response_data(
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/routes/http.py", line 179, in _get_response_data
    raise ClientException(str(e)) from e
litestar.exceptions.http_exceptions.ClientException: 400: Input data was truncated
Traceback (most recent call last):
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/serialization/msgspec_hooks.py", line 187, in decode_json
    return msgspec.json.decode(
msgspec.DecodeError: Input data was truncated

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/routes/http.py", line 177, in _get_response_data
    kwargs["data"] = await kwargs["data"]
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/_kwargs/extractors.py", line 450, in dto_extractor
    return data_dto(connection).decode_bytes(body)
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/dto/base_dto.py", line 97, in decode_bytes
    return backend.populate_data_from_raw(value, self.asgi_connection)
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/dto/_backend.py", line 301, in populate_data_from_raw
    source_data=self.parse_raw(raw, asgi_connection),
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/dto/_backend.py", line 208, in parse_raw
    result = decode_json(value=raw, target_type=self.annotation, type_decoders=type_decoders)
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/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: Input data was truncated

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/middleware/exceptions/middleware.py", line 191, in __call__
    await self.app(scope, receive, send)
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/routes/http.py", line 81, in handle
    response = await self._get_response_for_request(
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/routes/http.py", line 133, in _get_response_for_request
    return await self._call_handler_function(
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/routes/http.py", line 153, in _call_handler_function
    response_data, cleanup_group = await self._get_response_data(
  File "/home/simon/miniconda3/envs/resonance/lib/python3.10/site-packages/litestar/routes/http.py", line 179, in _get_response_data
    raise ClientException(str(e)) from e
litestar.exceptions.http_exceptions.ClientException: 400: Input data was truncated
```


### Litestar Version

main

### Platform

- [X] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/litestar/issues/2902">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2902/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2902/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:

Resolving patch:
diff --git a/litestar/_kwargs/extractors.py b/litestar/_kwargs/extractors.py
--- a/litestar/_kwargs/extractors.py
+++ b/litestar/_kwargs/extractors.py
@@ -295,6 +295,8 @@ async def json_extractor(connection: Request[Any, Any, Any]) -> Any:
     Returns:
         The JSON value.
     """
+    if not await connection.body():
+        return Empty
     return await connection.json()
 
 
@@ -310,6 +312,8 @@ async def msgpack_extractor(connection: Request[Any, Any, Any]) -> Any:
     Returns:
         The MessagePack value.
     """
+    if not await connection.body():
+        return Empty
     return await connection.msgpack()
 
 
@@ -454,7 +458,8 @@ def create_dto_extractor(
     """
 
     async def dto_extractor(connection: Request[Any, Any, Any]) -> Any:
-        body = await connection.body()
+        if not (body := await connection.body()):
+            return Empty
         return data_dto(connection).decode_bytes(body)
 
     return dto_extractor  # type:ignore[return-value]
diff --git a/litestar/routes/http.py b/litestar/routes/http.py
--- a/litestar/routes/http.py
+++ b/litestar/routes/http.py
@@ -14,6 +14,7 @@
 from litestar.response import Response
 from litestar.routes.base import BaseRoute
 from litestar.status_codes import HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST
+from litestar.types.empty import Empty
 from litestar.utils.scope.state import ScopeState
 
 if TYPE_CHECKING:
@@ -174,10 +175,15 @@ async def _get_response_data(
 
             if "data" in kwargs:
                 try:
-                    kwargs["data"] = await kwargs["data"]
+                    data = await kwargs["data"]
                 except SerializationException as e:
                     raise ClientException(str(e)) from e
 
+                if data is Empty:
+                    del kwargs["data"]
+                else:
+                    kwargs["data"] = data
+
             if "body" in kwargs:
                 kwargs["body"] = await kwargs["body"]
 


Test patch:
diff --git a/tests/unit/test_dto/test_factory/test_integration.py b/tests/unit/test_dto/test_factory/test_integration.py
--- a/tests/unit/test_dto/test_factory/test_integration.py
+++ b/tests/unit/test_dto/test_factory/test_integration.py
@@ -829,3 +829,22 @@ async def get_definition() -> Dict[str, Lexeme]:
     with create_test_client(route_handlers=[get_definition]) as client:
         response = client.get("/")
         assert response.json() == {"hello": {"name": "hello"}, "world": {"name": "world"}}
+
+
+def test_data_dto_with_default() -> None:
+    """A POST request without Body should inject the default value.
+
+    https://github.com/litestar-org/litestar/issues/2902
+    """
+
+    @dataclass
+    class Foo:
+        foo: str
+
+    @post(path="/", dto=DataclassDTO[Foo], signature_types=[Foo])
+    def test(data: Optional[Foo] = None) -> dict:
+        return {"foo": data}
+
+    with create_test_client([test]) as client:
+        response = client.post("/")
+        assert response.json() == {"foo": None}
diff --git a/tests/unit/test_kwargs/test_json_data.py b/tests/unit/test_kwargs/test_json_data.py
--- a/tests/unit/test_kwargs/test_json_data.py
+++ b/tests/unit/test_kwargs/test_json_data.py
@@ -1,5 +1,7 @@
 from dataclasses import asdict
 
+from msgspec import Struct
+
 from litestar import post
 from litestar.params import Body
 from litestar.status_codes import HTTP_201_CREATED
@@ -26,3 +28,19 @@ def test_method(data: dict) -> None:
     with create_test_client(test_method) as client:
         response = client.post("/test", json={})
         assert response.status_code == HTTP_201_CREATED
+
+
+def test_no_body_with_default() -> None:
+    class Test(Struct, frozen=True):
+        name: str
+
+    default = Test(name="default")
+
+    @post(path="/test", signature_types=[Test])
+    def test_method(data: Test = default) -> Test:
+        return data
+
+    with create_test_client(test_method) as client:
+        response = client.post("/test")
+        assert response.status_code == HTTP_201_CREATED
+        assert response.json() == {"name": "default"}
diff --git a/tests/unit/test_kwargs/test_msgpack_data.py b/tests/unit/test_kwargs/test_msgpack_data.py
--- a/tests/unit/test_kwargs/test_msgpack_data.py
+++ b/tests/unit/test_kwargs/test_msgpack_data.py
@@ -1,3 +1,6 @@
+from msgspec import Struct
+from typing_extensions import Annotated
+
 from litestar import post
 from litestar.enums import RequestEncodingType
 from litestar.params import Body
@@ -22,3 +25,19 @@ def test_annotated(data: dict = Body(media_type=RequestEncodingType.MESSAGEPACK)
     with create_test_client([test_header, test_annotated]) as client:
         response = client.post("/annotated", content=encode_msgpack(test_data))
         assert response.status_code == HTTP_201_CREATED
+
+
+def test_no_body_with_default() -> None:
+    class Test(Struct, frozen=True):
+        name: str
+
+    default = Test(name="default")
+
+    @post(path="/test", signature_types=[Test])
+    def test_method(data: Annotated[Test, Body(media_type=RequestEncodingType.MESSAGEPACK)] = default) -> Test:
+        return data
+
+    with create_test_client(test_method) as client:
+        response = client.post("/test")
+        assert response.status_code == HTTP_201_CREATED
+        assert response.json() == {"name": "default"}