Repository: litestar-org/litestar

Issue:
Bug: File upload example doesn't work with Swagger
### Summary

The examples demonstrating file uploads in the docs don't work well with Swagger.

[Example from docs](https://docs.litestar.dev/2/usage/requests.html#file-uploads)
```py
from typing import Annotated

from litestar import Litestar, MediaType, post
from litestar.datastructures import UploadFile
from litestar.enums import RequestEncodingType
from litestar.params import Body


@post(path="/", media_type=MediaType.TEXT)
async def handle_file_upload(
    data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)],
) -> str:
    content = await data.read()
    filename = data.filename
    return f"{filename}, {content.decode()}"


app = Litestar(route_handlers=[handle_file_upload])
```

# Result
http://127.0.0.1:8000/schema/swagger#/default/HandleFileUpload
<img width="1401" alt="image" src="https://github.com/litestar-org/litestar/assets/7423639/b5b2b4e2-55c0-4edd-9c3d-f6a1561e3519">

# Expected
![image](https://static1.smartbear.co/swagger/media/images/swagger-ui-file-upload.png)

https://swagger.io/docs/specification/2-0/file-upload/

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



Discussion:

Resolving patch:
diff --git a/litestar/_openapi/schema_generation/schema.py b/litestar/_openapi/schema_generation/schema.py
--- a/litestar/_openapi/schema_generation/schema.py
+++ b/litestar/_openapi/schema_generation/schema.py
@@ -134,10 +134,6 @@
     time: Schema(type=OpenAPIType.STRING, format=OpenAPIFormat.DURATION),
     timedelta: Schema(type=OpenAPIType.STRING, format=OpenAPIFormat.DURATION),
     tuple: Schema(type=OpenAPIType.ARRAY),
-    UploadFile: Schema(
-        type=OpenAPIType.STRING,
-        content_media_type="application/octet-stream",
-    ),
 }
 
 
@@ -349,11 +345,47 @@ def for_field_definition(self, field_definition: FieldDefinition) -> Schema | Re
             result = self.for_object_type(field_definition)
         elif self.is_constrained_field(field_definition):
             result = self.for_constrained_field(field_definition)
+        elif field_definition.is_subclass_of(UploadFile):
+            result = self.for_upload_file(field_definition)
         else:
             result = create_schema_for_annotation(field_definition.annotation)
 
         return self.process_schema_result(field_definition, result) if isinstance(result, Schema) else result
 
+    def for_upload_file(self, field_definition: FieldDefinition) -> Schema:
+        """Create schema for UploadFile.
+
+        Args:
+            field_definition: A field definition instance.
+
+        Returns:
+            A Schema instance.
+        """
+
+        property_key = "file"
+        schema = Schema(
+            type=OpenAPIType.STRING,
+            content_media_type="application/octet-stream",
+            format=OpenAPIFormat.BINARY,
+        )
+
+        # If the type is `dict[str, UploadFile]`, then it's the same as a `list[UploadFile]`
+        # but we will internally convert that into a `dict[str, UploadFile]`.
+        if field_definition.is_non_string_sequence or field_definition.is_mapping:
+            property_key = "files"
+            schema = Schema(type=OpenAPIType.ARRAY, items=schema)
+
+        # If the uploadfile is annotated directly on the handler, then the
+        # 'properties' needs to be created. Else, the 'properties' will be
+        # created by the corresponding plugin.
+        is_defined_on_handler = field_definition.name == "data" and isinstance(
+            field_definition.kwarg_definition, BodyKwarg
+        )
+        if is_defined_on_handler:
+            return Schema(type=OpenAPIType.OBJECT, properties={property_key: schema})
+
+        return schema
+
     def for_typevar(self) -> Schema:
         """Create a schema for a TypeVar.
 
@@ -408,6 +440,9 @@ def for_object_type(self, field_definition: FieldDefinition) -> Schema:
         Returns:
             A schema instance.
         """
+        if field_definition.has_inner_subclass_of(UploadFile):
+            return self.for_upload_file(field_definition)
+
         if field_definition.is_mapping:
             return Schema(
                 type=OpenAPIType.OBJECT,
@@ -422,6 +457,7 @@ def for_object_type(self, field_definition: FieldDefinition) -> Schema:
             # filters out ellipsis from tuple[int, ...] type annotations
             inner_types = (f for f in field_definition.inner_types if f.annotation is not Ellipsis)
             items = list(map(self.for_field_definition, inner_types or ()))
+
             return Schema(
                 type=OpenAPIType.ARRAY,
                 items=Schema(one_of=items) if len(items) > 1 else items[0],


Test patch:
diff --git a/tests/unit/test_openapi/test_request_body.py b/tests/unit/test_openapi/test_request_body.py
--- a/tests/unit/test_openapi/test_request_body.py
+++ b/tests/unit/test_openapi/test_request_body.py
@@ -56,50 +56,83 @@ def test_create_request_body(person_controller: Type[Controller], create_request
                 assert request_body
 
 
-def test_upload_file_request_body_generation() -> None:
-    @post(path="/form-upload")
-    async def handle_form_upload(
-        data: FormData = Body(media_type=RequestEncodingType.MULTI_PART),
-    ) -> None:
-        return None
-
+def test_upload_single_file_schema_generation() -> None:
     @post(path="/file-upload")
     async def handle_file_upload(
         data: UploadFile = Body(media_type=RequestEncodingType.MULTI_PART),
     ) -> None:
         return None
 
+    app = Litestar([handle_file_upload])
+    schema = app.openapi_schema.to_schema()
+
+    assert schema["paths"]["/file-upload"]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"] == {
+        "properties": {"file": {"type": "string", "format": "binary", "contentMediaType": "application/octet-stream"}},
+        "type": "object",
+    }
+
+
+def test_upload_list_of_files_schema_generation() -> None:
     @post(path="/file-list-upload")
     async def handle_file_list_upload(
         data: List[UploadFile] = Body(media_type=RequestEncodingType.MULTI_PART),
     ) -> None:
         return None
 
-    app = Litestar(route_handlers=[handle_form_upload, handle_file_upload, handle_file_list_upload])
-    schema_dict = app.openapi_schema.to_schema()
-    paths = schema_dict["paths"]
-    components = schema_dict["components"]
-    assert paths["/file-upload"]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"] == {
-        "type": "string",
-        "contentMediaType": "application/octet-stream",
+    app = Litestar([handle_file_list_upload])
+    schema = app.openapi_schema.to_schema()
+
+    assert schema["paths"]["/file-list-upload"]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"] == {
+        "type": "object",
+        "properties": {
+            "files": {
+                "items": {"type": "string", "contentMediaType": "application/octet-stream", "format": "binary"},
+                "type": "array",
+            }
+        },
     }
-    assert paths["/file-list-upload"]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"] == {
-        "items": {"type": "string", "contentMediaType": "application/octet-stream"},
-        "type": "array",
+
+
+def test_upload_file_dict_schema_generation() -> None:
+    @post(path="/file-dict-upload")
+    async def handle_file_list_upload(
+        data: Dict[str, UploadFile] = Body(media_type=RequestEncodingType.MULTI_PART),
+    ) -> None:
+        return None
+
+    app = Litestar([handle_file_list_upload])
+    schema = app.openapi_schema.to_schema()
+
+    assert schema["paths"]["/file-dict-upload"]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"] == {
+        "type": "object",
+        "properties": {
+            "files": {
+                "items": {"type": "string", "contentMediaType": "application/octet-stream", "format": "binary"},
+                "type": "array",
+            }
+        },
     }
 
-    assert components == {
+
+def test_upload_file_model_schema_generation() -> None:
+    @post(path="/form-upload")
+    async def handle_form_upload(
+        data: FormData = Body(media_type=RequestEncodingType.MULTI_PART),
+    ) -> None:
+        return None
+
+    app = Litestar([handle_form_upload])
+    schema = app.openapi_schema.to_schema()
+
+    assert schema["paths"]["/form-upload"]["post"]["requestBody"]["content"]["multipart/form-data"] == {
+        "schema": {"$ref": "#/components/schemas/FormData"}
+    }
+    assert schema["components"] == {
         "schemas": {
             "FormData": {
                 "properties": {
-                    "cv": {
-                        "type": "string",
-                        "contentMediaType": "application/octet-stream",
-                    },
-                    "image": {
-                        "type": "string",
-                        "contentMediaType": "application/octet-stream",
-                    },
+                    "cv": {"type": "string", "contentMediaType": "application/octet-stream", "format": "binary"},
+                    "image": {"type": "string", "contentMediaType": "application/octet-stream", "format": "binary"},
                 },
                 "type": "object",
                 "required": ["cv", "image"],