Repository: litestar-org/litestar

Issue:
Bug: Incorrect OpenAPI Generated For Optional Literal Types
### Description

Incorrect OpenAPI schema is generated when any Optional[Literal[<types>]] annotation is present on any route interface, even on a nested member of a struct. For example,
``` py
type: Literal["sink", "source"] | None
```
converts into
``` json
{
  "name": "type",
  "in": "query",
  "schema": {
    "type": "string",
    "enum": [
      "sink",
      "source",
      null
    ]
  },
  "required": false,
  "deprecated": false,
  "allowEmptyValue": false,
  "allowReserved": false
}
```
which does not support the null type, and 
``` py
include_properties: Literal["sink", "source", None] = None
```
generates
``` json
{
  "name": "include_properties",
  "in": "query",
  "schema": {
    "type": "string",
    "enum": [
      "sink",
      "source",
      null,
      null
    ]
  },
  "required": false,
  "deprecated": false,
  "allowEmptyValue": false,
  "allowReserved": false
}
```

### URL to code causing the issue

_No response_

### MCVE

```python
from typing import Literal

import msgspec
from litestar import get, Litestar


class AudioDevice(msgspec.Struct):
    form_factor: Literal[
        "car",
        "computer",
        "hands-free",
        "handset",
        "headphone",
        "headset",
        "hifi",
        "internal",
        "microphone",
        "portable",
        "speaker",
        "tv",
        "webcam",
        None,
    ]


@get("/api1", sync_to_thread=False)
def api1(
        type: Literal["sink", "source"] | None, include_properties: Literal["sink", "source", None] = None
) -> AudioDevice:
    pass


app = Litestar(route_handlers=[api1])
```


### Steps to reproduce

```bash
1. Run MCVE
2. View generated JSON Schema, attached below for convenience

{
  "info": {
    "title": "Litestar API",
    "version": "1.0.0"
  },
  "openapi": "3.1.0",
  "servers": [
    {
      "url": "/"
    }
  ],
  "paths": {
    "/api1": {
      "get": {
        "summary": "Api1",
        "operationId": "Api1Api1",
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "sink",
                "source",
                null
              ]
            },
            "required": false,
            "deprecated": false,
            "allowEmptyValue": false,
            "allowReserved": false
          },
          {
            "name": "include_properties",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "sink",
                "source",
                null,
                null
              ]
            },
            "required": false,
            "deprecated": false,
            "allowEmptyValue": false,
            "allowReserved": false
          }
        ],
        "responses": {
          "200": {
            "description": "Request fulfilled, document follows",
            "headers": {},
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/app_AudioDevice"
                }
              }
            }
          },
          "400": {
            "description": "Bad request syntax or unsupported method",
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "status_code": {
                      "type": "integer"
                    },
                    "detail": {
                      "type": "string"
                    },
                    "extra": {
                      "additionalProperties": {},
                      "type": [
                        "null",
                        "object",
                        "array"
                      ]
                    }
                  },
                  "type": "object",
                  "required": [
                    "detail",
                    "status_code"
                  ],
                  "description": "Validation Exception",
                  "examples": {
                    "ValidationException": {
                      "value": {
                        "status_code": 400,
                        "detail": "Bad Request",
                        "extra": {}
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "deprecated": false
      }
    }
  },
  "components": {
    "schemas": {
      "app_AudioDevice": {
        "properties": {
          "form_factor": {
            "type": "string",
            "enum": [
              "car",
              "computer",
              "hands-free",
              "handset",
              "headphone",
              "headset",
              "hifi",
              "internal",
              "microphone",
              "portable",
              "speaker",
              "tv",
              "webcam",
              null
            ]
          }
        },
        "type": "object",
        "required": [
          "form_factor"
        ],
        "title": "AudioDevice"
      }
    }
  }
}
```

### Screenshots

_No response_

### Logs

_No response_

### Litestar Version

2.4.1

### 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 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/2812">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2812/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2812/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
This seems relatively easy to fix, I'll open a PR? 
Sure thing @hzhou0 - be sure to include tests please.

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
@@ -58,6 +58,7 @@
 from litestar.params import BodyKwarg, ParameterKwarg
 from litestar.plugins import OpenAPISchemaPlugin
 from litestar.types import Empty
+from litestar.types.builtin_types import NoneType
 from litestar.typing import FieldDefinition
 from litestar.utils.helpers import get_name
 from litestar.utils.predicates import (
@@ -116,6 +117,7 @@
     MutableMapping: Schema(type=OpenAPIType.OBJECT),
     MutableSequence: Schema(type=OpenAPIType.ARRAY),
     None: Schema(type=OpenAPIType.NULL),
+    NoneType: Schema(type=OpenAPIType.NULL),
     OrderedDict: Schema(type=OpenAPIType.OBJECT),
     Path: Schema(type=OpenAPIType.STRING, format=OpenAPIFormat.URI),
     Pattern: Schema(type=OpenAPIType.STRING, format=OpenAPIFormat.REGEX),
@@ -146,6 +148,29 @@
 }
 
 
+def _types_in_list(lst: list[Any]) -> list[OpenAPIType] | OpenAPIType:
+    """Extract unique OpenAPITypes present in the values of a list.
+
+    Args:
+        lst: A list of values
+
+    Returns:
+        OpenAPIType in the given list. If more then one exists, return
+        a list of OpenAPITypes.
+    """
+    schema_types: list[OpenAPIType] = []
+    for item in lst:
+        schema_type = TYPE_MAP[type(item)].type
+        if isinstance(schema_type, OpenAPIType):
+            schema_types.append(schema_type)
+        elif schema_type is None:
+            raise RuntimeError("Item in TYPE_MAP must have a type that is not None")
+        else:
+            schema_types.extend(schema_type)
+    schema_types = list(set(schema_types))
+    return schema_types[0] if len(schema_types) == 1 else schema_types
+
+
 def _get_type_schema_name(field_definition: FieldDefinition) -> str:
     """Extract the schema name from a data container.
 
@@ -178,10 +203,9 @@ def create_enum_schema(annotation: EnumMeta, include_null: bool = False) -> Sche
         A schema instance.
     """
     enum_values: list[str | int | None] = [v.value for v in annotation]  # type: ignore
-    if include_null:
+    if include_null and None not in enum_values:
         enum_values.append(None)
-    openapi_type = OpenAPIType.STRING if isinstance(enum_values[0], str) else OpenAPIType.INTEGER
-    return Schema(type=openapi_type, enum=enum_values)
+    return Schema(type=_types_in_list(enum_values), enum=enum_values)
 
 
 def _iter_flat_literal_args(annotation: Any) -> Iterable[Any]:
@@ -211,9 +235,9 @@ def create_literal_schema(annotation: Any, include_null: bool = False) -> Schema
         A schema instance.
     """
     args = list(_iter_flat_literal_args(annotation))
-    if include_null:
+    if include_null and None not in args:
         args.append(None)
-    schema = copy(TYPE_MAP[type(args[0])])
+    schema = Schema(type=_types_in_list(args))
     if len(args) > 1:
         schema.enum = args
     else:


Test patch:
diff --git a/tests/unit/test_openapi/test_schema.py b/tests/unit/test_openapi/test_schema.py
--- a/tests/unit/test_openapi/test_schema.py
+++ b/tests/unit/test_openapi/test_schema.py
@@ -466,14 +466,16 @@ class Foo(Enum):
 
     schema = SchemaCreator().for_field_definition(FieldDefinition.from_annotation(Optional[Foo]))
     assert isinstance(schema, Schema)
-    assert schema.type == OpenAPIType.INTEGER
+    assert schema.type is not None
+    assert set(schema.type) == {OpenAPIType.INTEGER, OpenAPIType.NULL}
     assert schema.enum == [1, 2, None]
 
 
 def test_optional_literal() -> None:
     schema = SchemaCreator().for_field_definition(FieldDefinition.from_annotation(Optional[Literal[1]]))
     assert isinstance(schema, Schema)
-    assert schema.type == OpenAPIType.INTEGER
+    assert schema.type is not None
+    assert set(schema.type) == {OpenAPIType.INTEGER, OpenAPIType.NULL}
     assert schema.enum == [1, None]