diff --git a/docs_src/custom_docs_ui/tutorial002.py b/docs_src/custom_docs_ui/tutorial002.py index 23ea368f8..cb0fa27f5 100644 --- a/docs_src/custom_docs_ui/tutorial002.py +++ b/docs_src/custom_docs_ui/tutorial002.py @@ -1,12 +1,13 @@ from fastapi import FastAPI from fastapi.openapi.docs import ( get_redoc_html, + get_stoplight_elements_html, get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html, ) from fastapi.staticfiles import StaticFiles -app = FastAPI(docs_url=None, redoc_url=None) +app = FastAPI(docs_url=None, redoc_url=None, stoplight_elements_url=None) app.mount("/static", StaticFiles(directory="static"), name="static") @@ -36,6 +37,16 @@ async def redoc_html(): ) +@app.get("/elements", include_in_schema=False) +async def elements_html(): + return get_stoplight_elements_html( + openapi_url=app.openapi_url, + title=app.title + " - Elements", + stoplight_elements_js_url="/static/web-components.min.js", + stoplight_elements_css_url="/static/styles.min.css", + ) + + @app.get("/users/{username}") async def read_user(username: str): return {"message": f"Hello {username}"} diff --git a/docs_src/extending_openapi/tutorial006.py b/docs_src/extending_openapi/tutorial006.py new file mode 100644 index 000000000..e82706f6c --- /dev/null +++ b/docs_src/extending_openapi/tutorial006.py @@ -0,0 +1,34 @@ +from fastapi import FastAPI +from fastapi.openapi.docs import ( + LayoutOptions, + RouterOptions, + TryItCredentialPolicyOptions, + get_stoplight_elements_html, +) + +app = FastAPI(stoplight_elements_url=None) + + +@app.get("/elements", include_in_schema=False) +async def elements_html(): + return get_stoplight_elements_html( + openapi_url=app.openapi_url, + title=app.title + " - Elements", + stoplight_elements_js_url="https://unpkg.com/@stoplight/elements/web-components.min.js", + stoplight_elements_css_url="https://unpkg.com/@stoplight/elements/styles.min.css", + stoplight_elements_favicon_url="https://fastapi.tiangolo.com/img/favicon.png", + api_description_document="", + base_path="", + hide_internal=False, + hide_try_it=False, + try_it_cors_proxy="", + try_it_credential_policy=TryItCredentialPolicyOptions.OMIT, + layout=LayoutOptions.SIDEBAR, + logo="", + router=RouterOptions.HISTORY, + ) + + +@app.get("/users/{username}") +async def read_user(username: str): + return {"message": f"Hello {username}"} diff --git a/fastapi/applications.py b/fastapi/applications.py index 02193312b..50c10802d 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -26,6 +26,7 @@ from fastapi.logger import logger from fastapi.middleware.asyncexitstack import AsyncExitStackMiddleware from fastapi.openapi.docs import ( get_redoc_html, + get_stoplight_elements_html, get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html, ) @@ -466,6 +467,30 @@ class FastAPI(Starlette): """ ), ] = "/docs/oauth2-redirect", + stoplight_elements_url: Annotated[ + Optional[str], + Doc( + """ + The path to the alternative automatic interactive API documentation + provided by Stoplight Elements. + + The default URL is `/elements`. You can disable it by setting it to `None`. + + If `openapi_url` is set to `None`, this will be automatically disabled. + + Read more in the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#docs-urls). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI(docs_url="/documentation", stoplight_elements_url="elementsdocs") + ``` + """ + ), + ] = "/elements", swagger_ui_init_oauth: Annotated[ Optional[Dict[str, Any]], Doc( @@ -869,6 +894,7 @@ class FastAPI(Starlette): self.docs_url = docs_url self.redoc_url = redoc_url self.swagger_ui_oauth2_redirect_url = swagger_ui_oauth2_redirect_url + self.stoplight_elements_url = stoplight_elements_url self.swagger_ui_init_oauth = swagger_ui_init_oauth self.swagger_ui_parameters = swagger_ui_parameters self.servers = servers or [] @@ -1133,6 +1159,21 @@ class FastAPI(Starlette): self.add_route(self.redoc_url, redoc_html, include_in_schema=False) + if self.openapi_url and self.stoplight_elements_url: + + async def stoplight_elements_html(req: Request) -> HTMLResponse: + root_path = req.scope.get("root_path", "").rstrip("/") + openapi_url = root_path + self.openapi_url + return get_stoplight_elements_html( + openapi_url=openapi_url, title=self.title + " - Stoplight Elements" + ) + + self.add_route( + self.stoplight_elements_url, + stoplight_elements_html, + include_in_schema=False, + ) + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if self.root_path: scope["root_path"] = self.root_path diff --git a/fastapi/openapi/docs.py b/fastapi/openapi/docs.py index 74b23a370..7d1e7645b 100644 --- a/fastapi/openapi/docs.py +++ b/fastapi/openapi/docs.py @@ -1,4 +1,5 @@ import json +from enum import Enum from typing import Any, Dict, Optional from annotated_doc import Doc @@ -343,3 +344,70 @@ def get_swagger_ui_oauth2_redirect_html() -> HTMLResponse: """ return HTMLResponse(content=html) + + +class TryItCredentialPolicyOptions(Enum): + OMIT = "omit" + include = "include" + SAME_ORIGIN = "same-origin" + + +class LayoutOptions(Enum): + SIDEBAR = "sidebar" + STACKED = "stacked" + + +class RouterOptions(Enum): + HISTORY = "history" + HASH = "hash" + MEMORY = "memory" + STATIC = "static" + + +def get_stoplight_elements_html( + *, + openapi_url: str, + title: str, + stoplight_elements_js_url: str = "https://unpkg.com/@stoplight/elements/web-components.min.js", + stoplight_elements_css_url: str = "https://unpkg.com/@stoplight/elements/styles.min.css", + stoplight_elements_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png", + api_description_document: str = "", + base_path: str = "", + hide_internal: bool = False, + hide_try_it: bool = False, + try_it_cors_proxy: str = "", + try_it_credential_policy: TryItCredentialPolicyOptions = TryItCredentialPolicyOptions.OMIT, + layout: LayoutOptions = LayoutOptions.SIDEBAR, + logo: str = "", + router: RouterOptions = RouterOptions.HISTORY, +) -> HTMLResponse: + html = f""" + + + + + + {title} + + + + + + + + + + + """ + return HTMLResponse(html) diff --git a/tests/test_stoplight_elements_docs.py b/tests/test_stoplight_elements_docs.py new file mode 100644 index 000000000..e27aec38a --- /dev/null +++ b/tests/test_stoplight_elements_docs.py @@ -0,0 +1,25 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +app = FastAPI(title="Example App") + + +@app.get("/a/b") +async def get_a_and_b(): + return {"a": "b"} + + +client = TestClient(app) + + +def test_elements_uit(): + response = client.get("/elements") + assert response.status_code == 200, response.text + print(response.text) + assert app.title in response.text + assert "Stoplight" in response.text + + +def test_response(): + response = client.get("/a/b") + assert response.json() == {"a": "b"} diff --git a/tests/test_tutorial/test_custom_docs_ui/test_tutorial002.py b/tests/test_tutorial/test_custom_docs_ui/test_tutorial002.py index 712618807..00a4133d6 100644 --- a/tests/test_tutorial/test_custom_docs_ui/test_tutorial002.py +++ b/tests/test_tutorial/test_custom_docs_ui/test_tutorial002.py @@ -36,6 +36,13 @@ def test_redoc_html(client: TestClient): assert "/static/redoc.standalone.js" in response.text +def test_elements_html(client: TestClient): + response = client.get("/elements") + assert response.status_code == 200, response.text + assert "/static/web-components.min.js" in response.text + assert "/static/styles.min.css" in response.text + + def test_api(client: TestClient): response = client.get("/users/john") assert response.status_code == 200, response.text diff --git a/tests/test_tutorial/test_extending_openapi/test_tutorial006.py b/tests/test_tutorial/test_extending_openapi/test_tutorial006.py new file mode 100644 index 000000000..3671b1543 --- /dev/null +++ b/tests/test_tutorial/test_extending_openapi/test_tutorial006.py @@ -0,0 +1,19 @@ +from fastapi.testclient import TestClient + +from docs_src.extending_openapi.tutorial006 import app + +client = TestClient(app) + + +def test_swagger_ui(): + response = client.get("/elements") + assert response.status_code == 200, response.text + assert 'router="history"' in response.text + assert 'layout="sidebar"' in response.text + assert 'tryItCredentialPolicy="omit"' in response.text + + +def test_get_users(): + response = client.get("/users/foo") + assert response.status_code == 200, response.text + assert response.json() == {"message": "Hello foo"}