Repository: litestar-org/litestar

Issue:
Bug: RapiDoc and Stoplight Elements fail when used as root schema
### Description

Setting `root_schema_site="rapidoc"` or `root_schema_site="elements"` in the `OpenAPIConfig` results in an error when visiting `/schema`. Both `/schema/rapidoc` and `/schema/elements` work as expected.

My guess is that this might be related to the fact that both RapiDoc and StopLight elements [are configured with `spec-url`](https://github.com/litestar-org/litestar/blob/747fb90772721e7867f2e1ff2da6636f8442a6fb/litestar/openapi/controller.py#L494) whereas Swagger UI and Redoc HTML has the OpenAPI JSON inlined via a call to `OpenAPIController._get_schema_as_json`.

### MCVE

```python
from litestar import Litestar, get
from litestar.openapi import OpenAPIConfig


@get("/")
async def hello_world() -> str:
    return "Hello world!"


app = Litestar(
    route_handlers=[hello_world],
    openapi_config=OpenAPIConfig(
        title="Test app", version="0.0.1", root_schema_site="rapidoc"
    ),
)
```


### Steps to reproduce

```text
1. Run app with `litestar run`
2. Visit `localhost:8000/schema` in your browser.
```


### Screenshots

<img width="859" alt="image" src="https://github.com/litestar-org/litestar/assets/15220906/c962e9b0-acba-40bd-bf29-e0603756d91c">

### Litestar Version

2.5.0

### Platform

- [X] Linux
- [X] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)


Discussion:
Can you update to the latest version (`pip install -U litestar==2.5.3`) and confirm this issue persists?
Upgraded, and unfortunately the issue persists with `2.5.3` installed.
Using the provided MCVE I don't seem to be able to reproduce :\
```
~ via 🐋 colima via  pyenv
➜ ntp -v 3047
Directory /tmp/testing/3047 created and switched to.
Virtual environment created and activated.
➜ vim mcve.py
➜ pip install litestar[standard] &&  litestar run --reload --reload-dir . --debug
➜ browse 127.0.0.1/schema
```
result:
<img width="1201" alt="image" src="https://github.com/litestar-org/litestar/assets/45884264/a39060ad-0e03-4d95-8172-791564cc71bd">
and same for Stoplight.

tested on release in 2.5 - 2.6.

Am i doing something dumb here?
Wow, that's really strange... I just tested again at my end with a clean virtual environment and the above example and saw the same issue.

```
pip install -U "litestar[standard]"
litestar run --reload --reload-dir . --debug
open http://127.0.0.1:8000/schema
```

The request is being received by the backend as expected

```
INFO:     127.0.0.1:55586 - "GET /schema HTTP/1.1" 200 OK
INFO:     127.0.0.1:55586 - "GET /openapi.json HTTP/1.1" 404 Not Found
ERROR - 2024-02-11 10:46:25,461 - litestar - config - exception raised on http connection to route /openapi.json
```

Issue does appear to be `/openapi.json` route as if I visit `/schema/rapidoc` instead I get

```
INFO:     127.0.0.1:55628 - "GET /schema/rapidoc HTTP/1.1" 200 OK
INFO:     127.0.0.1:55628 - "GET /schema/openapi.json HTTP/1.1" 200 OK
```

I tried private windows and force refreshing the page but always the same result

<img width="864" alt="image" src="https://github.com/litestar-org/litestar/assets/15220906/6aae1341-07b9-453c-9883-46ed004bc1be">

In case it helps I tested just now with Python 3.11.4 and this is the result of `pip freeze`.
[pip-freeze.txt](https://github.com/litestar-org/litestar/files/14232018/pip-freeze.txt)

No idea what's going on but I'm also very open to the possibility that I'm doing something dumb on this end 😅 
@tcbegley I also can't reproduce this. Are you sure that you're running the correct application file?
Hey @provinzkraut, thanks for also looking into this.

I noticed from @JacobCoffee's screenshot that he has `/schema/` in his address bar whereas I have `/schema`. Sure enough if I visit `/schema/` then I don't get the error. By contrast `/schema/rapidoc` works but `/schema/rapidoc/` does not. I guess an issue with relative paths resolving to the wrong place? Ditto for StopLight Elements. Are you able to reproduce by visiting `/schema`?

To answer your original question, definitely running the right application file. To double check I added

```python
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app)
```

to the bottom of the above example and ran `python app.py` and observed the same result.
Yes, I was able to reproduce this with the help of @Alc-Alc who came to the same conclusion as you.

I'm not yet sure if this is an upstream issue or a bug on our side, but he provided some additional insight:

```
INFO:     127.0.0.1:49743 - "GET /schema HTTP/1.1" 200 OK
INFO:     127.0.0.1:49743 - "GET /openapi.json HTTP/1.1" 404 Not Found
INFO:     127.0.0.1:49743 - "GET /schema HTTP/1.1" 200 OK
INFO:     127.0.0.1:49743 - "GET /schema/openapi.json HTTP/1.1" 200 OK
```

these are the paths requested with/without the trailing slash, so something in there is not building them correctly.

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,7 +1,7 @@
 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 yaml import dump as dump_yaml
 
@@ -22,6 +22,8 @@
     from litestar.connection.request import Request
     from litestar.openapi.spec.open_api import OpenAPI
 
+_OPENAPI_JSON_ROUTER_NAME: Final = "__litestar_openapi_json"
+
 
 class OpenAPIController(Controller):
     """Controller for OpenAPI endpoints."""
@@ -171,7 +173,13 @@ def retrieve_schema_yaml(self, request: Request[Any, Any, Any]) -> ASGIResponse:
             return ASGIResponse(body=self._dumped_yaml_schema, media_type=OpenAPIMediaType.OPENAPI_YAML)
         return ASGIResponse(body=b"", status_code=HTTP_404_NOT_FOUND, media_type=MediaType.HTML)
 
-    @get(path="/openapi.json", media_type=OpenAPIMediaType.OPENAPI_JSON, include_in_schema=False, sync_to_thread=False)
+    @get(
+        path="/openapi.json",
+        media_type=OpenAPIMediaType.OPENAPI_JSON,
+        include_in_schema=False,
+        sync_to_thread=False,
+        name=_OPENAPI_JSON_ROUTER_NAME,
+    )
     def retrieve_schema_json(self, request: Request[Any, Any, Any]) -> ASGIResponse:
         """Return the OpenAPI schema as JSON with an ``application/vnd.oai.openapi+json`` Content-Type header.
 
@@ -457,10 +465,10 @@ def render_stoplight_elements(self, request: Request[Any, Any, Any]) -> bytes:
           </head>
         """
 
-        body = """
+        body = f"""
           <body>
             <elements-api
-                apiDescriptionUrl="openapi.json"
+                apiDescriptionUrl="{request.app.route_reverse(_OPENAPI_JSON_ROUTER_NAME)}"
                 router="hash"
                 layout="sidebar"
             />
@@ -489,9 +497,9 @@ def render_rapidoc(self, request: Request[Any, Any, Any]) -> bytes:  # pragma: n
           </head>
         """
 
-        body = """
+        body = f"""
           <body>
-            <rapi-doc spec-url="openapi.json" />
+            <rapi-doc spec-url="{request.app.route_reverse(_OPENAPI_JSON_ROUTER_NAME)}" />
           </body>
         """
 


Test patch: