Repository: litestar-org/litestar

Issue:
Bug: Uploading list of files via an object fails validation if there's only a single file
### Description

If there's some model like the one below and only one file is provided by the client, then the validation fails.

```python
class FileModel(Struct):
    files: list[UploadFile]
```


This is the same issue as in #2753. That PR, unfortunately, only handled this scenario for `data: list[FileUpload]` defined directly on the handlers. We'll have to go through all the fields of the `FieldDefinition` and do that same check that was introduced in that PR. I don't think this has to be done recursively though since complex objects aren't supported.


### URL to code causing the issue

_No response_

### MCVE

```python
from __future__ import annotations

from msgspec import Struct

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


class FileUpload(Struct):
    files: list[UploadFile]


@post(path="/")
async def upload_files_object(data: FileUpload = Body(media_type=RequestEncodingType.MULTI_PART)) -> list[str]:
    return [f.filename for f in data.files]


with create_test_client([upload_files_object], debug=True) as client:
    # Works if more than 1 file is provided.
    # files = [("files", b"1"), ("files", b"2")]

    # Fails if only 1 file is provided.
    files = [("files", b"1")]
    response = client.post("/", files=files)
```


### 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 189, in parse_values_from_connection_kwargs
    return convert(kwargs, cls, strict=False, dec_hook=deserializer, str_keys=True).to_dict()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
msgspec.ValidationError: Expected `array`, got `UploadFile` - at `$.data.files`

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 189, in parse_values_from_connection_kwargs
    return convert(kwargs, cls, strict=False, dec_hook=deserializer, str_keys=True).to_dict()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
msgspec.ValidationError: Expected `array`, got `UploadFile` - at `$.data.files`

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

HEAD

### 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/2939">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2939/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2939/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
@@ -15,6 +15,7 @@
 from litestar.exceptions import ValidationException
 from litestar.params import BodyKwarg
 from litestar.types import Empty
+from litestar.utils.predicates import is_non_string_sequence
 from litestar.utils.scope.state import ScopeState
 
 if TYPE_CHECKING:
@@ -355,7 +356,7 @@ async def extract_multipart(
 
         if field_definition.is_non_string_sequence:
             values = list(form_values.values())
-            if field_definition.inner_types[0].annotation is UploadFile and isinstance(values[0], list):
+            if field_definition.has_inner_subclass_of(UploadFile) and isinstance(values[0], list):
                 return values[0]
 
             return values
@@ -366,7 +367,15 @@ async def extract_multipart(
         if not form_values and is_data_optional:
             return None
 
-        return data_dto(connection).decode_builtins(form_values) if data_dto else form_values
+        if data_dto:
+            return data_dto(connection).decode_builtins(form_values)
+
+        for name, tp in field_definition.get_type_hints().items():
+            value = form_values.get(name)
+            if value is not None and is_non_string_sequence(tp) and not isinstance(value, list):
+                form_values[name] = [value]
+
+        return form_values
 
     return cast("Callable[[ASGIConnection[Any, Any, Any, Any]], Coroutine[Any, Any, Any]]", extract_multipart)
 


Test patch:
diff --git a/tests/unit/test_kwargs/test_multipart_data.py b/tests/unit/test_kwargs/test_multipart_data.py
--- a/tests/unit/test_kwargs/test_multipart_data.py
+++ b/tests/unit/test_kwargs/test_multipart_data.py
@@ -410,6 +410,27 @@ async def handler(data: List[UploadFile] = Body(media_type=RequestEncodingType.M
         assert response.status_code == HTTP_201_CREATED
 
 
+@dataclass
+class Files:
+    file_list: List[UploadFile]
+
+
+@pytest.mark.parametrize("file_count", (1, 2))
+def test_upload_multiple_files_in_model(file_count: int) -> None:
+    @post("/")
+    async def handler(data: Files = Body(media_type=RequestEncodingType.MULTI_PART)) -> None:
+        assert len(data.file_list) == file_count
+
+        for file in data.file_list:
+            assert await file.read() == b"1"
+
+    with create_test_client([handler]) as client:
+        files_to_upload = [("file_list", b"1") for _ in range(file_count)]
+        response = client.post("/", files=files_to_upload)
+
+        assert response.status_code == HTTP_201_CREATED
+
+
 def test_optional_formdata() -> None:
     @post("/", signature_types=[UploadFile])
     async def hello_world(data: Optional[UploadFile] = Body(media_type=RequestEncodingType.MULTI_PART)) -> None: