Repository: litestar-org/litestar (python)

Issue:
Documentation: Examples using `dict[str, UploadFile]` don't work as advertised
### Description

A msgpsec validation error is raised when annotating as `data: dict[str, UploadFile]`. This is because what we actually give msgspec is list of `UploadFiles`, but they have to converted into a dictionary before that. Also, I wasn't able to find any tests for these (maybe I missed them) which may mean this has never worked :D

### URL to code causing the issue

_No response_

### MCVE

```python
from __future__ import annotations

from litestar import post
from litestar.datastructures import UploadFile
from litestar.enums import RequestEncodingType
from litestar.params import Body
from litestar.status_codes import HTTP_201_CREATED
from litestar.testing.helpers import create_test_client


@post(path="/")
async def upload_files_object(
    data: dict[str, UploadFile] = Body(media_type=RequestEncodingType.MULTI_PART)
) -> list[str]:
    return list(data.keys())


with create_test_client([upload_files_object]) as client:
    files = [("files", ("filename", b"1")), ("files", ("another_filename", b"1"))]
    response = client.post("/", files=files)

    assert response.status_code == HTTP_201_CREATED
    assert response.json() == ["filename", "another_filename"]
```


### Steps to reproduce

```bash
1. Run the MCVE
```


### Screenshots

_No response_

### Logs

```bash
Traceback (most recent call last):
  File "/home/guacs/open-source/litestar/litestar/_signature/model.py", line 93, in _deserializer
    return default_deserializer(target_type, value)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/guacs/open-source/litestar/litestar/serialization/msgspec_hooks.py", line 118, in default_deserializer
    raise TypeError(f"Unsupported type: {type(value)!r}")
TypeError: Unsupported type: <class 'list'>

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/guacs/open-source/litestar/litestar/_signature/model.py", line 189, in parse_values_from_connection_kwargs
    return convert(kwargs, cls, strict=False, dec_hook=deserializer, str_keys=True).to_dict()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
msgspec.ValidationError: Unsupported type: <class 'list'> - at `$.data[...]`

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/guacs/open-source/litestar/litestar/middleware/exceptions/middleware.py", line 191, in __call__
    await self.app(scope, receive, send)
  File "/home/guacs/open-source/litestar/litestar/routes/http.py", line 82, in handle
    response = await self._get_response_for_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/guacs/open-source/litestar/litestar/routes/http.py", line 134, in _get_response_for_request
    return await self._call_handler_function(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/guacs/open-source/litestar/litestar/routes/http.py", line 154, in _call_handler_function
    response_data, cleanup_group = await self._get_response_data(
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/guacs/open-source/litestar/litestar/routes/http.py", line 193, in _get_response_data
    parsed_kwargs = route_handler.signature_model.parse_values_from_connection_kwargs(
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/guacs/open-source/litestar/litestar/_signature/model.py", line 202, in parse_values_from_connection_kwargs
    raise cls._create_exception(messages=messages, connection=connection) from e
litestar.exceptions.http_exceptions.ValidationException: 400: Validation failed for POST http://testserver.local/
Traceback (most recent call last):
  File "/home/guacs/open-source/litestar/litestar/_signature/model.py", line 93, in _deserializer
    return default_deserializer(target_type, value)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/guacs/open-source/litestar/litestar/serialization/msgspec_hooks.py", line 118, in default_deserializer
    raise TypeError(f"Unsupported type: {type(value)!r}")
TypeError: Unsupported type: <class 'list'>

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/guacs/open-source/litestar/litestar/_signature/model.py", line 189, in parse_values_from_connection_kwargs
    return convert(kwargs, cls, strict=False, dec_hook=deserializer, str_keys=True).to_dict()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
msgspec.ValidationError: Unsupported type: <class 'list'> - at `$.data[...]`

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/guacs/open-source/litestar/litestar/middleware/exceptions/middleware.py", line 191, in __call__
    await self.app(scope, receive, send)
  File "/home/guacs/open-source/litestar/litestar/routes/http.py", line 82, in handle
    response = await self._get_response_for_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/guacs/open-source/litestar/litestar/routes/http.py", line 134, in _get_response_for_request
    return await self._call_handler_function(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/guacs/open-source/litestar/litestar/routes/http.py", line 154, in _call_handler_function
    response_data, cleanup_group = await self._get_response_data(
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/guacs/open-source/litestar/litestar/routes/http.py", line 193, in _get_response_data
    parsed_kwargs = route_handler.signature_model.parse_values_from_connection_kwargs(
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/guacs/open-source/litestar/litestar/_signature/model.py", line 202, in parse_values_from_connection_kwargs
    raise cls._create_exception(messages=messages, connection=connection) from e
litestar.exceptions.http_exceptions.ValidationException: 400: Validation failed for POST http://testserver.local/
```


### Litestar Version

Main

### Platform

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

Pull request:
docs: test most requests.rst literalinclude
<!--
By submitting this pull request, you agree to:
- follow [Litestar's Code of Conduct](https://github.com/litestar-org/.github/blob/main/CODE_OF_CONDUCT.md)
- follow [Litestar's contribution guidelines](https://github.com/litestar-org/.github/blob/main/CONTRIBUTING.md)
- follow the [PSFs's Code of Conduct](https://www.python.org/psf/conduct/)
-->
## Description

- this renames most /docs/examples/request_data files adding a `test_` prefix
- thus the  `make test-examples` command take them into account
- tests have been added


<!--
Please add in issue numbers this pull request will close, if applicable
Examples: Fixes #4321 or Closes #1234

Ensure you are using a supported keyword to properly link an issue:
https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword
-->
## Closes

Fixes #2940


Patch:
diff --git a/docs/examples/request_data/request_data_10.py b/docs/examples/request_data/request_data_10.py
index d71aa35a0..566d75742 100644
--- a/docs/examples/request_data/request_data_10.py
+++ b/docs/examples/request_data/request_data_10.py
@@ -1,4 +1,4 @@
-from typing import Dict, List
+from typing import Any, Dict, List, Tuple
 
 from typing_extensions import Annotated
 
@@ -11,13 +11,14 @@ from litestar.params import Body
 @post(path="/")
 async def handle_file_upload(
     data: Annotated[List[UploadFile], Body(media_type=RequestEncodingType.MULTI_PART)],
-) -> Dict[str, str]:
-    file_contents = {}
+) -> Dict[str, Tuple[str, str, Any]]:
+    result = {}
+
     for file in data:
         content = await file.read()
-        file_contents[file.filename] = content.decode()
+        result[file.filename] = (len(content), file.content_type, file.headers)
 
-    return file_contents
+    return result
 
 
 app = Litestar(route_handlers=[handle_file_upload])
diff --git a/docs/examples/request_data/request_data_5.py b/docs/examples/request_data/request_data_5.py
index a06ae2486..ab8cef1d2 100644
--- a/docs/examples/request_data/request_data_5.py
+++ b/docs/examples/request_data/request_data_5.py
@@ -1,8 +1,10 @@
 from dataclasses import dataclass
+from typing import Dict
 
 from typing_extensions import Annotated
 
 from litestar import Litestar, post
+from litestar.datastructures import UploadFile
 from litestar.enums import RequestEncodingType
 from litestar.params import Body
 
@@ -11,13 +13,16 @@ from litestar.params import Body
 class User:
     id: int
     name: str
+    form_input_name: UploadFile
 
 
 @post(path="/")
 async def create_user(
     data: Annotated[User, Body(media_type=RequestEncodingType.MULTI_PART)],
-) -> User:
-    return data
+) -> Dict[str, str]:
+    content = await data.form_input_name.read()
+    filename = data.form_input_name.filename
+    return {"id": data.id, "name": data.name, "filename": filename, "size": len(content)}
 
 
 app = Litestar(route_handlers=[create_user])
diff --git a/docs/examples/request_data/request_data_6.py b/docs/examples/request_data/request_data_6.py
index 63010be7d..f7e54c864 100644
--- a/docs/examples/request_data/request_data_6.py
+++ b/docs/examples/request_data/request_data_6.py
@@ -12,7 +12,7 @@ async def handle_file_upload(
 ) -> str:
     content = await data.read()
     filename = data.filename
-    return f"{filename}, {content.decode()}"
+    return f"{filename},length: {len(content)}"
 
 
 app = Litestar(route_handlers=[handle_file_upload])
diff --git a/docs/examples/request_data/request_data_7.py b/docs/examples/request_data/request_data_7.py
index fbe85849b..4d8ff2be8 100644
--- a/docs/examples/request_data/request_data_7.py
+++ b/docs/examples/request_data/request_data_7.py
@@ -12,7 +12,7 @@ def handle_file_upload(
 ) -> str:
     content = data.file.read()
     filename = data.filename
-    return f"{filename}, {content.decode()}"
+    return f"{filename},length: {len(content)}"
 
 
 app = Litestar(route_handlers=[handle_file_upload])
diff --git a/docs/examples/request_data/request_data_8.py b/docs/examples/request_data/request_data_8.py
index 8cb672413..374d58544 100644
--- a/docs/examples/request_data/request_data_8.py
+++ b/docs/examples/request_data/request_data_8.py
@@ -1,4 +1,4 @@
-from typing import Any, Dict
+from typing import Dict
 
 from pydantic import BaseModel, ConfigDict
 from typing_extensions import Annotated
@@ -18,7 +18,7 @@ class FormData(BaseModel):
 @post(path="/")
 async def handle_file_upload(
     data: Annotated[FormData, Body(media_type=RequestEncodingType.MULTI_PART)],
-) -> Dict[str, Any]:
+) -> Dict[str, str]:
     cv_content = await data.cv.read()
     diploma_content = await data.diploma.read()
 
diff --git a/docs/examples/request_data/request_data_9.py b/docs/examples/request_data/request_data_9.py
index 4696fe0aa..ce543769d 100644
--- a/docs/examples/request_data/request_data_9.py
+++ b/docs/examples/request_data/request_data_9.py
@@ -15,7 +15,7 @@ async def handle_file_upload(
     file_contents = {}
     for name, file in data.items():
         content = await file.read()
-        file_contents[name] = content.decode()
+        file_contents[file.filename] = len(content)
 
     return file_contents
 
diff --git a/docs/usage/requests.rst b/docs/usage/requests.rst
index effeb9211..f18b0a79a 100644
--- a/docs/usage/requests.rst
+++ b/docs/usage/requests.rst
@@ -13,11 +13,14 @@ The body of HTTP requests can be accessed using the special ``data`` parameter i
 The type of ``data`` can be any supported type, including
 
 
-* :func:`dataclasses <dataclasses.dataclass>`
-* :class:`TypedDicts <typing.TypedDict>`
-* Pydantic models
 * Arbitrary stdlib types
-* Types supported via :doc:`plugins </usage/plugins/index>`
+* :class:`TypedDicts <typing.TypedDict>`
+* :func:`dataclasses <dataclasses.dataclass>`
+* Types supported via :doc:`plugins </usage/plugins/index>` ie.
+    - `Msgspec Struct <https://jcristharif.com/msgspec/structs.html>`_
+    - `Pydantic models <https://docs.pydantic.dev/usage/models/>`_
+    - `Attrs classes <https://www.attrs.org/en/stable/>`_
+
 
 .. literalinclude:: /examples/request_data/request_data_2.py
     :language: python
@@ -29,8 +32,19 @@ Validation and customization of OpenAPI documentation
 With the help of :class:`Body <litestar.params.Body>`, you have fine-grained control over the validation
 of the request body, and can also customize the OpenAPI documentation:
 
-.. literalinclude:: /examples/request_data/request_data_3.py
-    :language: python
+
+.. tab-set::
+
+    .. tab-item:: example
+
+        .. literalinclude:: /examples/request_data/request_data_3.py
+            :language: python
+
+    .. tab-item:: how to test
+
+        .. literalinclude:: ../../tests/examples/test_request_data.py
+            :language: python
+            :lines: 35-41
 
 
 Content-type
@@ -48,8 +62,18 @@ To access data sent as `url-encoded form data <https://developer.mozilla.org/en-
 i.e. ``application/x-www-form-urlencoded`` Content-Type header, use :class:`Body <litestar.params.Body>` and specify
 :class:`RequestEncodingType.URL_ENCODED <litestar.enums.RequestEncodingType>` as the ``media_type``:
 
-.. literalinclude:: /examples/request_data/request_data_4.py
-    :language: python
+.. tab-set::
+
+    .. tab-item:: example
+
+        .. literalinclude:: /examples/request_data/request_data_4.py
+            :language: python
+
+    .. tab-item:: how to test
+
+        .. literalinclude:: ../../tests/examples/test_request_data.py
+            :language: python
+            :lines: 44-48
 
 .. note::
 
@@ -64,9 +88,18 @@ You can access data uploaded using a request with a
 `multipart/form-data <https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST>`_
 Content-Type header by specifying it in the :class:`Body <litestar.params.Body>` function:
 
-.. literalinclude:: /examples/request_data/request_data_5.py
-    :language: python
+.. tab-set::
+
+    .. tab-item:: example
+
+        .. literalinclude:: /examples/request_data/request_data_5.py
+            :language: python
 
+    .. tab-item:: how to test
+
+        .. literalinclude:: ../../tests/examples/test_request_data.py
+            :language: python
+            :lines: 51-64
 
 
 File uploads
@@ -83,13 +116,33 @@ To access a single file simply type ``data`` as :class:`UploadFile <.datastructu
 
     .. tab-item:: Async
 
-        .. literalinclude:: /examples/request_data/request_data_6.py
-            :language: python
+        .. tab-set::
+
+            .. tab-item:: example
+
+                .. literalinclude:: /examples/request_data/request_data_6.py
+                    :language: python
+
+            .. tab-item:: how to test
+
+                .. literalinclude:: ../../tests/examples/test_request_data.py
+                    :language: python
+                    :lines: 67-71
 
     .. tab-item:: Sync
 
-        .. literalinclude:: /examples/request_data/request_data_7.py
-            :language: python
+        .. tab-set::
+
+            .. tab-item:: example
+
+                .. literalinclude:: /examples/request_data/request_data_7.py
+                    :language: python
+
+            .. tab-item:: how to test
+
+                .. literalinclude:: ../../tests/examples/test_request_data.py
+                    :language: python
+                    :lines: 74-78
 
 .. admonition:: Technical details
     :class: info
@@ -105,9 +158,19 @@ Multiple files
 
 To access multiple files with known filenames, you can use a pydantic model:
 
-.. literalinclude:: /examples/request_data/request_data_8.py
-    :language: python
 
+.. tab-set::
+
+    .. tab-item:: example
+
+        .. literalinclude:: /examples/request_data/request_data_8.py
+            :language: python
+
+    .. tab-item:: how to test
+
+        .. literalinclude:: ../../tests/examples/test_request_data.py
+            :language: python
+            :lines: 81-87
 
 
 Files as a dictionary
@@ -115,9 +178,18 @@ Files as a dictionary
 
 If you do not care about parsing and validation and only want to access the form data as a dictionary, you can use a ``dict`` instead:
 
-.. literalinclude:: /examples/request_data/request_data_9.py
-    :language: python
+.. tab-set::
+
+    .. tab-item:: example
 
+        .. literalinclude:: /examples/request_data/request_data_9.py
+            :language: python
+
+    .. tab-item:: how to test
+
+        .. literalinclude:: ../../tests/examples/test_request_data.py
+            :language: python
+            :lines: 90-97
 
 
 Files as a list
@@ -125,9 +197,18 @@ Files as a list
 
 Finally, you can also access the files as a list without the filenames:
 
-.. literalinclude:: /examples/request_data/request_data_10.py
-    :language: python
+.. tab-set::
+
+    .. tab-item:: example
+
+        .. literalinclude:: /examples/request_data/request_data_10.py
+            :language: python
 
+    .. tab-item:: how to test
+
+        .. literalinclude:: ../../tests/examples/test_request_data.py
+            :language: python
+            :lines: 100-133
 
 MessagePack data
 ----------------
@@ -135,10 +216,18 @@ MessagePack data
 To receive `MessagePack <https://msgpack.org/>`_ data, specify the appropriate ``Content-Type``
 for ``Body``\ , by using :class:`RequestEncodingType.MESSAGEPACK <.enums.RequestEncodingType>`:
 
-.. literalinclude:: /examples/request_data/msgpack_request.py
-   :caption: ``msgpack_request.py``
-   :language: python
+.. tab-set::
 
+    .. tab-item:: example
+
+        .. literalinclude:: /examples/request_data/msgpack_request.py
+            :language: python
+
+    .. tab-item:: how to test
+
+        .. literalinclude:: ../../tests/examples/test_request_data.py
+            :language: python
+            :lines: 136-141
 
 Custom Request
 --------------
@@ -150,8 +239,18 @@ The example below illustrates how to implement custom request class for the whol
 
 .. dropdown:: Example of a custom request at the application level
 
-    .. literalinclude:: /examples/request_data/custom_request.py
-        :language: python
+    .. tab-set::
+
+        .. tab-item:: example
+
+            .. literalinclude:: /examples/request_data/custom_request.py
+                :language: python
+
+        .. tab-item:: how to test
+
+            .. literalinclude:: ../../tests/examples/test_request_data.py
+                :language: python
+                :lines: 144-147
 
 .. admonition:: Layered architecture
 


Test patch:
diff --git a/tests/examples/test_request_data.py b/tests/examples/test_request_data.py
index 2c03e08bb..4a5e7877b 100644
--- a/tests/examples/test_request_data.py
+++ b/tests/examples/test_request_data.py
@@ -1,3 +1,6 @@
+import io
+from io import BytesIO
+
 from docs.examples.request_data.custom_request import app as custom_request_class_app
 from docs.examples.request_data.msgpack_request import app as msgpack_app
 from docs.examples.request_data.request_data_1 import app
@@ -34,6 +37,8 @@ def test_request_data_3() -> None:
         response = client.post("/", json={"id": 1, "name": "John"})
         assert response.status_code == 201
         assert response.json() == {"id": 1, "name": "John"}
+        schema = client.get("/schema/openapi.json")
+        assert "Create a new user." in schema.json()["components"]["schemas"]["User"]["description"]
 
 
 def test_request_data_4() -> None:
@@ -47,28 +52,30 @@ def test_request_data_5() -> None:
     with TestClient(app=app_5) as client:
         response = client.post(
             "/",
-            content=b'--d26a9a4ed2f441fba9ab42d04b42099e\r\nContent-Disposition: form-data; name="id"\r\n\r\n1\r\n--d26a9a4ed2f441fba9ab42d04b42099e\r\nContent-Disposition: form-data; name="name"\r\n\r\nJohn\r\n--d26a9a4ed2f441fba9ab42d04b42099e--\r\n',
-            headers={
-                "Content-Length": "211",
-                "Content-Type": "multipart/form-data; boundary=d26a9a4ed2f441fba9ab42d04b42099e",
-            },
+            files={"form_input_name": ("filename", BytesIO(b"file content"))},
+            data={"id": 1, "name": "John"},
         )
-        assert response.json() == {"id": 1, "name": "John"}
         assert response.status_code == 201
+        assert response.json() == {
+            "id": 1,
+            "name": "John",
+            "filename": "filename",
+            "size": len(b"file content"),
+        }
 
 
 def test_request_data_6() -> None:
     with TestClient(app=app_6) as client:
         response = client.post("/", files={"upload": ("hello", b"world")})
         assert response.status_code == 201
-        assert response.text == "hello, world"
+        assert response.text == f"hello,length: {len(b'world')}"
 
 
 def test_request_data_7() -> None:
     with TestClient(app=app_7) as client:
         response = client.post("/", files={"upload": ("hello", b"world")})
         assert response.status_code == 201
-        assert response.text == "hello, world"
+        assert response.text == f"hello,length: {len(b'world')}"
 
 
 def test_request_data_8() -> None:
@@ -82,16 +89,52 @@ def test_request_data_8() -> None:
 
 def test_request_data_9() -> None:
     with TestClient(app=app_9) as client:
-        response = client.post("/", files={"hello": b"there", "i'm": "steve"})
+        response = client.post("/", files={"hello": ("filename", b"there"), "i'm": ("another_filename", "steve")})
         assert response.status_code == 201
-        assert response.json() == {"hello": "there", "i'm": "steve"}
+        assert response.json() == {
+            "filename": len(b"there"),
+            "another_filename": len(b"steve"),
+        }
 
 
 def test_request_data_10() -> None:
     with TestClient(app=app_10) as client:
-        response = client.post("/", files={"foo": ("foo.txt", b"hello"), "bar": ("bar.txt", b"world")})
+        # if you pass a dict to the `files` parameter without specifying a filename, it will default to `upload
+        # so in this app it will be return the last one only...
+        #     # file (or bytes)
+        response = client.post(
+            "/",
+            files={
+                "will default to upload": io.BytesIO(b"hello world"),
+                "will default to upload also": io.BytesIO(b"another"),
+            },
+        )
+        assert response.status_code == 201
+        assert response.json().get("upload")[0] != len(b"hello world")
+        assert response.json().get("upload")[0] == len(b"another")
+
+        # if you pass the filename explicitly, it will be used as the filename
+        #     # (filename, file (or bytes))
+        response = client.post("/", files={"file": ("hello.txt", io.BytesIO(b"hello"))})
+        assert response.status_code == 201
+        assert response.json().get("hello.txt")[0] == len(b"hello")
+
+        # if you add the content type, it will be used as the content type
+        #     # (filename, file (or bytes), content_type)
+        response = client.post("/", files={"file": ("hello.txt", io.BytesIO(b"hello"), "application/x-bittorrent")})
+        assert response.status_code == 201
+        assert response.json().get("hello.txt")[0] == len(b"hello")
+        assert response.json().get("hello.txt")[1] == "application/x-bittorrent"
+
+        # finally you can specify headers like so
+        #     # (filename, file (or bytes), content_type, headers)
+        response = client.post(
+            "/", files={"file": ("hello.txt", io.BytesIO(b"hello"), "application/x-bittorrent", {"X-Foo": "bar"})}
+        )
         assert response.status_code == 201
-        assert response.json() == {"foo.txt": "hello", "bar.txt": "world"}
+        assert response.json().get("hello.txt")[0] == len(b"hello")
+        assert response.json().get("hello.txt")[1] == "application/x-bittorrent"
+        assert ("X-Foo", "bar") in response.json().get("hello.txt")[2].items()
 
 
 def test_msgpack_app() -> None: