Repository: litestar-org/litestar

Issue:
Bug: In 2.8.0, openapi triggers an ImproperlyConfiguredException
### Description

[#route-handler-indexing](https://docs.litestar.dev/2/usage/routing/handlers.html#route-handler-indexing) states that an 'ImproperlyConfiguredException' will be raised if not all route have unique names.

After upgrading from 2.7.0 to 2.8.0, an ImproperlyConfiguredException is raised when launching my Litestar app.
The final lines of the output seems to suggest the exception is being triggered by a route handler name clash with litestar_openapi_json. The program runs fine in version 2.7.0 with no changes to the code.


### /app/app.py

```python
import logging

from litestar import get, Litestar, MediaType, Response, Router
from litestar.logging import LoggingConfig
from litestar.openapi import OpenAPIConfig, OpenAPIController
from litestar.response import Redirect

from app.settings import URL_PATH_PREFIX
from app.auth import auth_middleware

LOGGER = logging.getLogger()
logging_config = LoggingConfig(
	root={"level": logging.getLevelName(logging.DEBUG), "handlers": ["console"]},
	formatters={"standard": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}},
)

@get(["", "/index"], exclude_from_auth=True, include_in_schema=False)
async def index() -> Redirect:
	return Redirect(f"/{URL_PATH_PREFIX}/schema/redoc")

@get("/health", exclude_from_auth=True, include_in_schema=False)
async def health() -> Response:
	return Response(media_type=MediaType.TEXT, content="I'm feeling healthy!\n")

class MyOpenAPIController(OpenAPIController):
	path = "/schema"

internal_router = Router(path="/", route_handlers=[health, index])
external_router = Router(path=f"/{URL_PATH_PREFIX}", route_handlers=[index, MyOpenAPIController])

app = Litestar(
	[external_router, internal_router],
	middleware=[auth_middleware],
	openapi_config=OpenAPIConfig("api", version="0.9.0"),
)
```

### Steps to reproduce

```bash
uvicorn app.app:app --host 0.0.0.0 --port 7000 --reload
```

### Logs

```bash
PS C:\Users\user\GitLab\api>  & 'c:\Users\user\GitLab\api\.venv\Scripts\python.exe' 'c:\Users\user\.vscode\extensions\ms-python.debugpy-2024.4.0-win32-x64\bundled\libs\debugpy\adapter/../..\debugpy\launcher' '63609' '--' '-m' 'uvicorn' 'app.app:app' '--host' '0.0.0.0' '--port' '7000' '--reload' 
INFO:     Will watch for changes in these directories: ['C:\\Users\\user\\GitLab\\api']
INFO:     Uvicorn running on http://0.0.0.0:7000 (Press CTRL+C to quit)
INFO:     Started reloader process [26988] using WatchFiles
Process SpawnProcess-1:
Traceback (most recent call last):
  File "C:\Program Files\Python312\Lib\multiprocessing\process.py", line 314, in _bootstrap
    self.run()
  File "C:\Program Files\Python312\Lib\multiprocessing\process.py", line 108, in run
    self._target(*self._args, **self._kwargs)
  File "c:\Users\user\GitLab\api\.venv\Lib\site-packages\uvicorn\_subprocess.py", line 78, in subprocess_started
    target(sockets=sockets)
  File "c:\Users\user\GitLab\api\.venv\Lib\site-packages\uvicorn\server.py", line 65, in run
    return asyncio.run(self.serve(sockets=sockets))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Program Files\Python312\Lib\asyncio\runners.py", line 194, in run
    return runner.run(main)
           ^^^^^^^^^^^^^^^^
  File "C:\Program Files\Python312\Lib\asyncio\runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Program Files\Python312\Lib\asyncio\base_events.py", line 685, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "c:\Users\user\GitLab\api\.venv\Lib\site-packages\uvicorn\server.py", line 69, in serve
    await self._serve(sockets)
  File "c:\Users\user\GitLab\api\.venv\Lib\site-packages\uvicorn\server.py", line 76, in _serve
    config.load()
  File "c:\Users\user\GitLab\api\.venv\Lib\site-packages\uvicorn\config.py", line 433, in load
    self.loaded_app = import_from_string(self.app)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "c:\Users\user\GitLab\api\.venv\Lib\site-packages\uvicorn\importer.py", line 19, in import_from_string
    module = importlib.import_module(module_str)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Program Files\Python312\Lib\importlib\__init__.py", line 90, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap>", line 1387, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1360, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1331, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 935, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 995, in exec_module
  File "<frozen importlib._bootstrap>", line 488, in _call_with_frames_removed
  File "C:\Users\user\GitLab\api\app\app.py", line 32, in <module>
    app = Litestar(
          ^^^^^^^^^
  File "c:\Users\user\GitLab\api\.venv\Lib\site-packages\litestar\app.py", line 483, in __init__
    self.register(route_handler)
  File "c:\Users\user\GitLab\api\.venv\Lib\site-packages\litestar\app.py", line 678, in register
    self.asgi_router.construct_routing_trie()
  File "c:\Users\user\GitLab\api\.venv\Lib\site-packages\litestar\_asgi\asgi_router.py", line 144, in construct_routing_trie
    self._store_handler_to_route_mapping(route)
  File "c:\Users\user\GitLab\api\.venv\Lib\site-packages\litestar\_asgi\asgi_router.py", line 123, in _store_handler_to_route_mapping
    raise ImproperlyConfiguredException(
litestar.exceptions.http_exceptions.ImproperlyConfiguredException: 500: route handler names must be unique - 097bebbb88884f068c1c19a750884fee_litestar_openapi_json is not unique.
```

### Litestar Version

2.8.0

### Platform

- [ ] Linux
- [ ] Mac
- [X] Windows


Discussion:
Usually, the openapi controller should be registered on the `OpenAPIConfig` object, e.g.,:

```py

class MyOpenAPIController(OpenAPIController):
	path = "/schema"


internal_router = Router(path="/", route_handlers=[health, index])
external_router = Router(path="/prefix", route_handlers=[index])


app = Litestar(
	[internal_router, external_router],
	openapi_config=OpenAPIConfig("api", version="0.9.0", openapi_controller=MyOpenAPIController),
)
```
Is there a reason for registering it directly on that router?

> Is there a reason for registering it directly on that router?

The schema end point needed to be reachable via both "/{URL_PATH_PREFIX}/schema" and "/schema".
Disabling its registration with the external_router resolves the issue, but removes the desired route.
I guess I need a solution that works with 2.8.0 that allows me multiple schema end points.
2.8.0 has deprecated the use of the `OpenAPIController` in favor of a plugin system where plugins are registered on the `OpenAPIConfig` object.

If an `OpenAPIController` is not registered on the `OpenAPIConfig` object, it implicitly opts into the new behavior which does not use the controller at all, and instead registers handlers for the openapi routes on a router. This is combined with a patch that was released in 2.7.1 that started using an explicit name for the openapi json route to facilitate rendering absolute urls to openapi.json into the javascript for the OpenAPI UI rendering.

To get the 2.7 behavior, also declare ~your controller~ `OpenAPIController` on the `OpenAPIConfig`, e.g.:

```py
class MyOpenAPIController(OpenAPIController):
	path = "/schema"


internal_router = Router(path="/", route_handlers=[health, index])
external_router = Router(path="/prefix", route_handlers=[index, MyOpenAPIController])


app = Litestar(
	[internal_router, external_router],
	openapi_config=OpenAPIConfig("api", version="0.9.0", openapi_controller=OpenAPIController),
)
```

```
(.venv) peter@pop-os:~/PycharmProjects/litestar-import-test$ litestar routes --schema
Using Litestar app from app:app
/ (HTTP)
├── options_handler sync OPTIONS
├── / index async GET
└── /index index async GET
/health (HTTP)
├── health async GET
└── options_handler sync OPTIONS
/index (HTTP)
├── options_handler sync OPTIONS
├── / index async GET
└── /index index async GET
/prefix (HTTP)
├── options_handler sync OPTIONS
├── / index async GET
└── /index index async GET
/prefix/index (HTTP)
├── options_handler sync OPTIONS
├── / index async GET
└── /index index async GET
/prefix/schema (HTTP)
├── root sync GET
└── options_handler sync OPTIONS
/prefix/schema/elements (HTTP)
├── options_handler sync OPTIONS
└── stoplight_elements sync GET
/prefix/schema/oauth2-redirect.html (HTTP)
├── options_handler sync OPTIONS
└── swagger_ui_oauth2_redirect sync GET
/prefix/schema/openapi.json (HTTP)
├── options_handler sync OPTIONS
└── 020d496bb2644584b776daded6a21182_litestar_openapi_json sync GET
/prefix/schema/openapi.yaml (HTTP)
├── /openapi.yaml retrieve_schema_yaml sync GET
├── /openapi.yml retrieve_schema_yaml sync GET
└── options_handler sync OPTIONS
/prefix/schema/openapi.yml (HTTP)
├── /openapi.yaml retrieve_schema_yaml sync GET
├── /openapi.yml retrieve_schema_yaml sync GET
└── options_handler sync OPTIONS
/prefix/schema/rapidoc (HTTP)
├── rapidoc sync GET
└── options_handler sync OPTIONS
/prefix/schema/redoc (HTTP)
├── redoc sync GET
└── options_handler sync OPTIONS
/prefix/schema/swagger (HTTP)
├── swagger_ui sync GET
└── options_handler sync OPTIONS
/schema (HTTP)
├── root sync GET
└── options_handler sync OPTIONS
/schema/elements (HTTP)
├── stoplight_elements sync GET
└── options_handler sync OPTIONS
/schema/oauth2-redirect.html (HTTP)
├── swagger_ui_oauth2_redirect sync GET
└── options_handler sync OPTIONS
/schema/openapi.json (HTTP)
├── 020d496bb2644584b776daded6a21182_litestar_openapi_json sync GET
└── options_handler sync OPTIONS
/schema/openapi.yaml (HTTP)
├── /openapi.yaml retrieve_schema_yaml sync GET
├── /openapi.yml retrieve_schema_yaml sync GET
└── options_handler sync OPTIONS
/schema/openapi.yml (HTTP)
├── /openapi.yaml retrieve_schema_yaml sync GET
├── /openapi.yml retrieve_schema_yaml sync GET
└── options_handler sync OPTIONS
/schema/rapidoc (HTTP)
├── rapidoc sync GET
└── options_handler sync OPTIONS
/schema/redoc (HTTP)
├── redoc sync GET
└── options_handler sync OPTIONS
/schema/swagger (HTTP)
├── swagger_ui sync GET
└── options_handler sync OPTIONS
```
At this point, I'm not sure how I would implement your pattern in v3.0 when there is no OpenAPIController. This will need some thought, and is probably a feature request for that branch.
> At this point, I'm not sure how I would implement your pattern in v3.0 when there is no OpenAPIController. This will need some thought, and is probably a feature request for that branch.

Perhaps allowing a sequence of paths or routers on the config object, e.g., `OpenAPIConfig(path=["/schema", "/prefix/schema"])`.
Well your solution of assigning the OpenAPIController to the OpenAPIConfig works. Much thanks for the prompt and detailed reply. I'd be happy to remove the OpenAPIController if it is now deprecated, once a solution in >2.8.0 exists.
It might be better to give it a list of routers and a end point to OpenAPIConfig.
```py
app = Litestar(
	[external_router, internal_router],
	openapi_config=OpenAPIConfig(
		...,
		openapi_endpoint="/schema",
		openapi_routers=[external_router, internal_router],
	),
)
```

Resolving patch:
diff --git a/litestar/openapi/controller.py b/litestar/openapi/controller.py
--- a/litestar/openapi/controller.py
+++ b/litestar/openapi/controller.py
@@ -1,9 +1,10 @@
 from __future__ import annotations
 
 from functools import cached_property
-from typing import TYPE_CHECKING, Any, Callable, Literal
+from typing import TYPE_CHECKING, Any, Callable, Final, Literal
+from uuid import uuid4
 
-from litestar.constants import OPENAPI_JSON_HANDLER_NAME, OPENAPI_NOT_INITIALIZED
+from litestar.constants import OPENAPI_NOT_INITIALIZED
 from litestar.controller import Controller
 from litestar.enums import MediaType, OpenAPIMediaType
 from litestar.exceptions import ImproperlyConfiguredException
@@ -14,13 +15,17 @@
 from litestar.serialization.msgspec_hooks import decode_json
 from litestar.status_codes import HTTP_404_NOT_FOUND
 
-__all__ = ("OpenAPIController",)
-
-
 if TYPE_CHECKING:
     from litestar.connection.request import Request
     from litestar.openapi.spec.open_api import OpenAPI
 
+__all__ = ("OpenAPIController",)
+
+# NOTE: We are explicitly using a different name to the one defined in litestar.constants so that an openapi
+#   controller can be added to a router on the same application as the openapi router.
+#   See: https://github.com/litestar-org/litestar/issues/3337
+OPENAPI_JSON_HANDLER_NAME: Final = f"{uuid4().hex}_litestar_openapi_json"
+
 
 class OpenAPIController(Controller):
     """Controller for OpenAPI endpoints."""


Test patch:
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
@@ -10,7 +10,7 @@
 import yaml
 from typing_extensions import Annotated
 
-from litestar import Controller, Litestar, delete, get, patch, post
+from litestar import Controller, Litestar, Router, delete, get, patch, post
 from litestar._openapi.plugin import OpenAPIPlugin
 from litestar.enums import MediaType, OpenAPIMediaType, ParamType
 from litestar.openapi import OpenAPIConfig, OpenAPIController
@@ -503,3 +503,40 @@ def delete_handler(self, data: B) -> None: ...
         "test_components_schemas_in_alphabetical_order.C",
     ]
     assert list(openapi.components.schemas.keys()) == expected_keys
+
+
+def test_openapi_controller_and_openapi_router_on_same_app() -> None:
+    """Test that OpenAPIController and OpenAPIRouter can coexist on the same app.
+
+    As part of backward compatibility with new plugin-based OpenAPI router approach, we did not consider
+    the case where an OpenAPIController is registered on the application by means other than via the
+    OpenAPIConfig object. This is an approach that has been used to serve the openapi both under the
+    `/schema` and `/some-prefix/schema` paths. This test ensures that the OpenAPIController and OpenAPIRouter
+    can coexist on the same app.
+
+    See: https://github.com/litestar-org/litestar/issues/3337
+    """
+    router = Router(path="/abc", route_handlers=[OpenAPIController])
+    openapi_config = OpenAPIConfig("Litestar", "v0.0.1")  # no openapi_controller specified means we use the router
+    app = Litestar([router], openapi_config=openapi_config)
+    assert sorted(r.path for r in app.routes) == [
+        "/abc/schema",
+        "/abc/schema/elements",
+        "/abc/schema/oauth2-redirect.html",
+        "/abc/schema/openapi.json",
+        "/abc/schema/openapi.yaml",
+        "/abc/schema/openapi.yml",
+        "/abc/schema/rapidoc",
+        "/abc/schema/redoc",
+        "/abc/schema/swagger",
+        "/schema",
+        "/schema/elements",
+        "/schema/oauth2-redirect.html",
+        "/schema/openapi.json",
+        "/schema/openapi.yaml",
+        "/schema/openapi.yml",
+        "/schema/rapidoc",
+        "/schema/redoc",
+        "/schema/swagger",
+        "/schema/{path:str}",
+    ]