Repository: litestar-org/litestar

Issue:
Bug: OpenAPI generation throws exception when using an optional path value
### Description

I think I found an OpenAPI generation bug when using multiple paths on the same route with an optional path parameter value. The first call to /schema always fails with a 500 status code. Subsequent calls are successful but do not contain the problem route. I assume this is due to caching.

Another interesting thing I found is that if you load /schema/openapi.json first, you still get a 500 the first time but the cache is built with the route defined for subsequent calls. Although, the parameter is listed as a query parameter instead of a path parameter.

Thanks!
Kyle




### URL to code causing the issue

_No response_

### MCVE

```python
from litestar import Litestar, get, MediaType

@get(path=["/", "/{message:str}"], media_type=MediaType.TEXT, sync_to_thread=False)
def route(message: str | None) -> str:
    if message:
        return message
    return "no message"

app = Litestar(route_handlers=[route])
```


### Steps to reproduce

```bash
* Run app: litestar run
* Load http://localhost:8000/schema
* Get 500 Internal Server Error
```


### Screenshots

_No response_

### Logs

```bash
Traceback (most recent call last):
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 79, in handle
    response = await self._get_response_for_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 131, in _get_response_for_request
    response = await self._call_handler_function(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 160, in _call_handler_function
    response_data, cleanup_group = await self._get_response_data(
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 206, in _get_response_data
    data = route_handler.fn.value(**parsed_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/openapi/controller.py", line 197, in root
    return ASGIResponse(body=render_method(request), media_type=MediaType.HTML)
                             ^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/openapi/controller.py", line 357, in render_redoc
    schema = self.get_schema_from_request(request)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/openapi/controller.py", line 85, in get_schema_from_request
    return request.app.openapi_schema
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/app.py", line 562, in openapi_schema
    self.update_openapi_schema()
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/app.py", line 818, in update_openapi_schema
    path_item, created_operation_ids = create_path_item(
                                       ^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/path_item.py", line 97, in create_path_item
    create_parameter_for_handler(
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/parameters.py", line 214, in create_parameter_for_handler
    for parameter in get_recursive_handler_parameters(
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/parameters.py", line 131, in get_recursive_handler_parameters
    create_parameter(
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/parameters.py", line 87, in create_parameter
    result = schema_creator.for_field_definition(replace(field_definition, annotation=path_parameter.type))
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/schema_generation/schema.py", line 265, in for_field_definition
    result = self.for_optional_field(field_definition)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/schema_generation/schema.py", line 306, in for_optional_field
    annotation=make_non_optional_union(field_definition.annotation),
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/utils/typing.py", line 175, in make_non_optional_union
    return cast("UnionT", Union[args])  # pyright: ignore
                          ~~~~~^^^^^^
  File "/home/ksmith/.pyenv/versions/3.11.4/lib/python3.11/typing.py", line 355, in inner
    return func(*args, **kwds)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/ksmith/.pyenv/versions/3.11.4/lib/python3.11/typing.py", line 478, in __getitem__
    return self._getitem(self, parameters)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ksmith/.pyenv/versions/3.11.4/lib/python3.11/typing.py", line 685, in Union
    raise TypeError("Cannot take a Union of no types.")
TypeError: Cannot take a Union of no types.
Traceback (most recent call last):
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/middleware/exceptions/middleware.py", line 191, in __call__
    await self.app(scope, receive, send)
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 79, in handle
    response = await self._get_response_for_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 131, in _get_response_for_request
    response = await self._call_handler_function(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 160, in _call_handler_function
    response_data, cleanup_group = await self._get_response_data(
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 206, in _get_response_data
    data = route_handler.fn.value(**parsed_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/openapi/controller.py", line 197, in root
    return ASGIResponse(body=render_method(request), media_type=MediaType.HTML)
                             ^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/openapi/controller.py", line 357, in render_redoc
    schema = self.get_schema_from_request(request)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/openapi/controller.py", line 85, in get_schema_from_request
    return request.app.openapi_schema
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/app.py", line 562, in openapi_schema
    self.update_openapi_schema()
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/app.py", line 818, in update_openapi_schema
    path_item, created_operation_ids = create_path_item(
                                       ^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/path_item.py", line 97, in create_path_item
    create_parameter_for_handler(
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/parameters.py", line 214, in create_parameter_for_handler
    for parameter in get_recursive_handler_parameters(
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/parameters.py", line 131, in get_recursive_handler_parameters
    create_parameter(
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/parameters.py", line 87, in create_parameter
    result = schema_creator.for_field_definition(replace(field_definition, annotation=path_parameter.type))
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/schema_generation/schema.py", line 265, in for_field_definition
    result = self.for_optional_field(field_definition)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/_openapi/schema_generation/schema.py", line 306, in for_optional_field
    annotation=make_non_optional_union(field_definition.annotation),
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/mvce/.venv/lib/python3.11/site-packages/litestar/utils/typing.py", line 175, in make_non_optional_union
    return cast("UnionT", Union[args])  # pyright: ignore
                          ~~~~~^^^^^^
  File "/home/ksmith/.pyenv/versions/3.11.4/lib/python3.11/typing.py", line 355, in inner
    return func(*args, **kwds)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/ksmith/.pyenv/versions/3.11.4/lib/python3.11/typing.py", line 478, in __getitem__
    return self._getitem(self, parameters)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ksmith/.pyenv/versions/3.11.4/lib/python3.11/typing.py", line 685, in Union
    raise TypeError("Cannot take a Union of no types.")
TypeError: Cannot take a Union of no types.
```


### Litestar Version

2.0.1

### Platform

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

<!-- POLAR PLEDGE BADGE START -->
---

## Funding
* If you would like to see an issue prioritized, make a pledge towards it!
* We receive the pledge once the issue is completed & verified

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



Discussion:
+1:  I noticed this behavior yesterday, I'm also getting a 500 on first page load of the schema paths
Can you give a minimum reproduction?

Resolving patch:
diff --git a/litestar/_openapi/parameters.py b/litestar/_openapi/parameters.py
--- a/litestar/_openapi/parameters.py
+++ b/litestar/_openapi/parameters.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-from dataclasses import replace
 from typing import TYPE_CHECKING
 
 from litestar.constants import RESERVED_KWARGS
@@ -49,8 +48,17 @@ def add(self, parameter: Parameter) -> None:
         If an existing parameter with the same name but different type exists, raises
         ``ImproperlyConfiguredException``.
         """
+
         if parameter.name not in self._parameters:
-            self._parameters[parameter.name] = parameter
+            # because we are defining routes as unique per path, we have to handle here a situation when there is an optional
+            # path parameter. e.g. get(path=["/", "/{param:str}"]). When parsing the parameter for path, the route handler
+            # would still have a kwarg called param:
+            # def handler(param: str | None) -> ...
+            if parameter.param_in != ParamType.QUERY or all(
+                "{" + parameter.name + ":" not in path
+                for path in self.route_handler.paths
+            ):
+                self._parameters[parameter.name] = parameter
             return
 
         pre_existing = self._parameters[parameter.name]
@@ -83,8 +91,7 @@ def create_parameter(
     if any(path_param.name == parameter_name for path_param in path_parameters):
         param_in = ParamType.PATH
         is_required = True
-        path_parameter = next(p for p in path_parameters if parameter_name in p.name)
-        result = schema_creator.for_field_definition(replace(field_definition, annotation=path_parameter.type))
+        result = schema_creator.for_field_definition(field_definition)
     elif kwarg_definition and kwarg_definition.header:
         parameter_name = kwarg_definition.header
         param_in = ParamType.HEADER
@@ -193,7 +200,6 @@ def create_parameter_for_handler(
     """Create a list of path/query/header Parameter models for the given PathHandler."""
     parameters = ParameterCollection(route_handler=route_handler)
     dependency_providers = route_handler.resolve_dependencies()
-
     layered_parameters = route_handler.resolve_layered_parameters()
 
     unique_handler_fields = tuple(


Test patch:
diff --git a/tests/unit/test_kwargs/test_path_params.py b/tests/unit/test_kwargs/test_path_params.py
--- a/tests/unit/test_kwargs/test_path_params.py
+++ b/tests/unit/test_kwargs/test_path_params.py
@@ -1,7 +1,7 @@
 from datetime import date, datetime, time, timedelta
 from decimal import Decimal
 from pathlib import Path
-from typing import Any
+from typing import Any, Optional
 from unittest.mock import MagicMock
 from uuid import UUID, uuid1, uuid4
 
@@ -172,3 +172,17 @@ def post_greeting(title: str) -> str:
         response = client.post("/Moishe")
         assert response.status_code == HTTP_201_CREATED
         assert response.text == "Hello, Moishe!"
+
+
+def test_optional_path_parameter() -> None:
+    @get(path=["/", "/{message:str}"], media_type=MediaType.TEXT, sync_to_thread=False)
+    def handler(message: Optional[str]) -> str:
+        return message if message else "no message"
+
+    with create_test_client(route_handlers=[handler]) as client:
+        response = client.get("/")
+        assert response.status_code == HTTP_200_OK
+        assert response.text == "no message"
+        response = client.get("/hello")
+        assert response.status_code == HTTP_200_OK
+        assert response.text == "hello"
diff --git a/tests/unit/test_openapi/test_integration.py b/tests/unit/test_openapi/test_integration.py
--- a/tests/unit/test_openapi/test_integration.py
+++ b/tests/unit/test_openapi/test_integration.py
@@ -1,4 +1,4 @@
-from typing import Type
+from __future__ import annotations
 
 import msgspec
 import pytest
@@ -6,9 +6,9 @@
 from pydantic import BaseModel, Field
 from typing_extensions import Annotated
 
-from litestar import Controller, post
+from litestar import Controller, get, post
 from litestar.app import DEFAULT_OPENAPI_CONFIG
-from litestar.enums import OpenAPIMediaType
+from litestar.enums import MediaType, OpenAPIMediaType, ParamType
 from litestar.openapi import OpenAPIConfig, OpenAPIController
 from litestar.serialization.msgspec_hooks import decode_json, encode_json, get_serializer
 from litestar.status_codes import HTTP_200_OK, HTTP_404_NOT_FOUND
@@ -19,7 +19,7 @@
 
 @pytest.mark.parametrize("create_examples", CREATE_EXAMPLES_VALUES)
 def test_openapi_yaml(
-    person_controller: Type[Controller], pet_controller: Type[Controller], create_examples: bool
+    person_controller: type[Controller], pet_controller: type[Controller], create_examples: bool
 ) -> None:
     openapi_config = OpenAPIConfig("Example API", "1.0.0", create_examples=create_examples)
     with create_test_client([person_controller, pet_controller], openapi_config=openapi_config) as client:
@@ -37,7 +37,7 @@ def test_openapi_yaml(
 
 @pytest.mark.parametrize("create_examples", CREATE_EXAMPLES_VALUES)
 def test_openapi_json(
-    person_controller: Type[Controller], pet_controller: Type[Controller], create_examples: bool
+    person_controller: type[Controller], pet_controller: type[Controller], create_examples: bool
 ) -> None:
     openapi_config = OpenAPIConfig("Example API", "1.0.0", create_examples=create_examples)
     with create_test_client([person_controller, pet_controller], openapi_config=openapi_config) as client:
@@ -52,7 +52,7 @@ def test_openapi_json(
         assert response.content == encode_json(openapi_schema.to_schema(), serializer)
 
 
-def test_openapi_yaml_not_allowed(person_controller: Type[Controller], pet_controller: Type[Controller]) -> None:
+def test_openapi_yaml_not_allowed(person_controller: type[Controller], pet_controller: type[Controller]) -> None:
     openapi_config = DEFAULT_OPENAPI_CONFIG
     openapi_config.enabled_endpoints.discard("openapi.yaml")
 
@@ -64,7 +64,7 @@ def test_openapi_yaml_not_allowed(person_controller: Type[Controller], pet_contr
         assert response.status_code == HTTP_404_NOT_FOUND
 
 
-def test_openapi_json_not_allowed(person_controller: Type[Controller], pet_controller: Type[Controller]) -> None:
+def test_openapi_json_not_allowed(person_controller: type[Controller], pet_controller: type[Controller]) -> None:
     openapi_config = DEFAULT_OPENAPI_CONFIG
     openapi_config.enabled_endpoints.discard("openapi.json")
 
@@ -157,6 +157,7 @@ async def example_route() -> Lookup:
             version="1.0.0",
             create_examples=create_examples,
         ),
+        signature_namespace={"Lookup": Lookup},
     ) as client:
         response = client.get("/schema/openapi.json")
         assert response.status_code == HTTP_200_OK
@@ -193,6 +194,7 @@ async def example_route() -> Lookup:
             version="1.0.0",
             create_examples=create_examples,
         ),
+        signature_namespace={"Lookup": Lookup},
     ) as client:
         response = client.get("/schema/openapi.json")
         assert response.status_code == HTTP_200_OK
@@ -203,3 +205,25 @@ async def example_route() -> Lookup:
             "minLength": 12,
             "type": "string",
         }
+
+
+def test_schema_for_optional_path_parameter() -> None:
+    @get(path=["/", "/{test_message:str}"], media_type=MediaType.TEXT, sync_to_thread=False)
+    def handler(test_message: str | None) -> str:
+        return test_message if test_message else "no message"
+
+    with create_test_client(
+        route_handlers=[handler],
+        openapi_config=OpenAPIConfig(
+            title="Example API",
+            version="1.0.0",
+            create_examples=True,
+        ),
+    ) as client:
+        response = client.get("/schema/openapi.json")
+        assert response.status_code == HTTP_200_OK
+        assert "parameters" not in response.json()["paths"]["/"]["get"]  # type[ignore]
+        parameter = response.json()["paths"]["/{test_message}"]["get"]["parameters"][0]  # type[ignore]
+        assert parameter
+        assert parameter["in"] == ParamType.PATH
+        assert parameter["name"] == "test_message"