Skip to content

Add Stoplight Elements as built-in docs tool #5168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 35 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
25b5784
feat(docs.py): add stoplight elements as docs tool
ShaharIlany Jul 18, 2022
af89583
feat(applications.py): add stoplight elements to app settings
ShaharIlany Jul 18, 2022
8b1c987
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Jul 18, 2022
07929dc
fix(docs.py): replace None with empty string on params from type str
ShaharIlany Jul 18, 2022
1a07fa8
Merge branch 'master' of https://github.com/ShaharIlany/fastapi
ShaharIlany Jul 18, 2022
1070fee
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Jul 18, 2022
80cb9b7
fix(docs.py): use enum values only
ShaharIlany Jul 18, 2022
212efca
feat(test_stoplight_elements_docs.py): add tests for the new feature
ShaharIlany Jul 18, 2022
bf74d31
Merge branch 'master' of https://github.com/ShaharIlany/fastapi
ShaharIlany Jul 18, 2022
9016390
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Jul 18, 2022
4979f90
cr fixes
ShaharIlany Aug 24, 2022
72f7d1b
Merge branch 'master' into master
ShaharIlany Aug 24, 2022
703499e
fix function naming
ShaharIlany Aug 24, 2022
b7eb62e
feat(tutorial002.py): add elements to tutorial
ShaharIlany Aug 24, 2022
d652b2c
feat(tutorial006.py): create new tutorial for elements customization
ShaharIlany Aug 24, 2022
c43827d
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Aug 24, 2022
bbffb0e
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Aug 24, 2022
aacac66
fix(test_tutorial): fix tests to improve coverage
ShaharIlany Aug 24, 2022
740e4a8
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Aug 24, 2022
9e3b015
fix(test_tutorial006.py): fix line breaks
ShaharIlany Aug 24, 2022
b4cd1f2
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Aug 24, 2022
f275e35
fix(tutorial006.py): fix tutorial file
ShaharIlany Aug 24, 2022
75d3342
fix(tutorial006.py): remove unused
ShaharIlany Aug 24, 2022
45f749d
fix(extending-openapi.md): fix hl lines for tutorial006
ShaharIlany Aug 24, 2022
5ac4a30
Merge branch 'master' into master
ShaharIlany Aug 27, 2022
18af7a8
Merge branch 'master' into master
ShaharIlany Nov 4, 2022
9ddd736
Merge branch 'master' into master
tiangolo Dec 16, 2022
3c4b96b
fix: :rotating_light: Fix True comparison
ShaharIlany Dec 17, 2022
0c4a71d
Merge branch 'master' into master
ShaharIlany Dec 17, 2022
1d8a475
Merge branch 'master' of https://github.com/fastapi/fastapi
ShaharIlany Aug 9, 2025
27e8cd8
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Aug 9, 2025
d1f7817
Merge branch 'master' of https://github.com/fastapi/fastapi
ShaharIlany Aug 9, 2025
5c48120
Merge branch 'master' of https://github.com/ShaharIlany/fastapi
ShaharIlany Aug 9, 2025
3b19dee
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Aug 9, 2025
31fe59d
🗑️ Remove outdated documentation on extending OpenAPI
ShaharIlany Aug 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs_src/custom_docs_ui/tutorial002.py
Original file line number Diff line number Diff line change
@@ -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")

Expand Down Expand Up @@ -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}"}
34 changes: 34 additions & 0 deletions docs_src/extending_openapi/tutorial006.py
Original file line number Diff line number Diff line change
@@ -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}"}
41 changes: 41 additions & 0 deletions fastapi/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from fastapi.logger import logger
from fastapi.openapi.docs import (
get_redoc_html,
get_stoplight_elements_html,
get_swagger_ui_html,
get_swagger_ui_oauth2_redirect_html,
)
Expand Down Expand Up @@ -457,6 +458,30 @@ async def read_items():
"""
),
] = "/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(
Expand Down Expand Up @@ -834,6 +859,7 @@ class Item(BaseModel):
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 []
Expand Down Expand Up @@ -1048,6 +1074,21 @@ async def redoc_html(req: Request) -> HTMLResponse:

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
Expand Down
68 changes: 68 additions & 0 deletions fastapi/openapi/docs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from enum import Enum
from typing import Any, Dict, Optional

from fastapi.encoders import jsonable_encoder
Expand Down Expand Up @@ -342,3 +343,70 @@ def get_swagger_ui_oauth2_redirect_html() -> HTMLResponse:
</html>
"""
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"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{title}</title>
<link rel="shortcut icon" href="{stoplight_elements_favicon_url}">
<script src="{stoplight_elements_js_url}"></script>
<link rel="stylesheet" href="{stoplight_elements_css_url}">
</head>
<body>

<elements-api
{f'apiDescriptionUrl="{openapi_url}"' if openapi_url != "" else ""}
{f'apiDescriptionDocument="{api_description_document}"' if api_description_document != "" else ""}
{f'basePath="{base_path}"' if base_path != "" else ""}
{'hideInternal="true"' if hide_internal is True else ""}
{'hideTryIt="true"' if hide_try_it is True else ""}
{f'tryItCorsProxy="{try_it_cors_proxy}"' if try_it_cors_proxy != "" else ""}
tryItCredentialPolicy="{try_it_credential_policy.value}"
layout="{layout.value}"
{f'logo="{logo}"' if logo != "" else ""}
router="{router.value}"
/>

</body>
</html>
"""
return HTMLResponse(html)
25 changes: 25 additions & 0 deletions tests/test_stoplight_elements_docs.py
Original file line number Diff line number Diff line change
@@ -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"}
7 changes: 7 additions & 0 deletions tests/test_tutorial/test_custom_docs_ui/test_tutorial002.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions tests/test_tutorial/test_extending_openapi/test_tutorial006.py
Original file line number Diff line number Diff line change
@@ -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"}