fastapi-csp-docs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_csp_docs/__init__.py +21 -0
- fastapi_csp_docs/docs.py +252 -0
- fastapi_csp_docs/py.typed +0 -0
- fastapi_csp_docs/setup.py +145 -0
- fastapi_csp_docs-0.1.0.dist-info/METADATA +220 -0
- fastapi_csp_docs-0.1.0.dist-info/RECORD +8 -0
- fastapi_csp_docs-0.1.0.dist-info/WHEEL +4 -0
- fastapi_csp_docs-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from fastapi_csp_docs.docs import (
|
|
2
|
+
get_redoc_css,
|
|
3
|
+
get_redoc_html,
|
|
4
|
+
get_swagger_ui_html,
|
|
5
|
+
get_swagger_ui_init_js,
|
|
6
|
+
get_swagger_ui_oauth2_redirect_html,
|
|
7
|
+
get_swagger_ui_oauth2_redirect_js,
|
|
8
|
+
)
|
|
9
|
+
from fastapi_csp_docs.setup import setup
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"get_redoc_css",
|
|
15
|
+
"get_redoc_html",
|
|
16
|
+
"get_swagger_ui_html",
|
|
17
|
+
"get_swagger_ui_init_js",
|
|
18
|
+
"get_swagger_ui_oauth2_redirect_html",
|
|
19
|
+
"get_swagger_ui_oauth2_redirect_js",
|
|
20
|
+
"setup",
|
|
21
|
+
]
|
fastapi_csp_docs/docs.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi.encoders import jsonable_encoder
|
|
6
|
+
from fastapi.openapi.docs import swagger_ui_default_parameters
|
|
7
|
+
from starlette.responses import HTMLResponse, Response
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _html_safe_json(value: Any) -> str:
|
|
11
|
+
"""Serialize a value to JSON with HTML special characters escaped.
|
|
12
|
+
|
|
13
|
+
This prevents injection when the JSON is embedded inside a <script> tag.
|
|
14
|
+
"""
|
|
15
|
+
return json.dumps(value).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_swagger_ui_init_js(
|
|
19
|
+
*,
|
|
20
|
+
openapi_url: str,
|
|
21
|
+
oauth2_redirect_url: str | None = None,
|
|
22
|
+
init_oauth: dict[str, Any] | None = None,
|
|
23
|
+
swagger_ui_parameters: dict[str, Any] | None = None,
|
|
24
|
+
) -> Response:
|
|
25
|
+
"""
|
|
26
|
+
Generate and return the response that initializes Swagger UI.
|
|
27
|
+
|
|
28
|
+
This is normally served as `swagger-initializer.js`, referenced from the
|
|
29
|
+
HTML generated by [get_swagger_ui_html][fastapi_csp_docs.docs.get_swagger_ui_html].
|
|
30
|
+
|
|
31
|
+
You would only call this function yourself if you needed to override some parts,
|
|
32
|
+
for example to serve the initializer script from your own endpoint.
|
|
33
|
+
|
|
34
|
+
:param openapi_url: URL of the OpenAPI schema for Swagger UI to render.
|
|
35
|
+
:param oauth2_redirect_url: URL of the OAuth2 redirect endpoint, or None to disable
|
|
36
|
+
OAuth2 login in the UI.
|
|
37
|
+
:param init_oauth: OAuth2 configuration passed to `ui.initOAuth()`, or None to skip it.
|
|
38
|
+
:param swagger_ui_parameters: extra Swagger UI parameters merged over FastAPI's defaults.
|
|
39
|
+
"""
|
|
40
|
+
current_swagger_ui_parameters = swagger_ui_default_parameters.copy()
|
|
41
|
+
if swagger_ui_parameters:
|
|
42
|
+
current_swagger_ui_parameters.update(swagger_ui_parameters)
|
|
43
|
+
|
|
44
|
+
js = f"""
|
|
45
|
+
window.onload = function() {{
|
|
46
|
+
const ui = SwaggerUIBundle({{
|
|
47
|
+
url: '{openapi_url}',
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
for key, value in current_swagger_ui_parameters.items():
|
|
51
|
+
js += f"{_html_safe_json(key)}: {_html_safe_json(jsonable_encoder(value))},\n"
|
|
52
|
+
|
|
53
|
+
if oauth2_redirect_url:
|
|
54
|
+
js += f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"
|
|
55
|
+
|
|
56
|
+
js += """
|
|
57
|
+
presets: [
|
|
58
|
+
SwaggerUIBundle.presets.apis,
|
|
59
|
+
SwaggerUIBundle.SwaggerUIStandalonePreset
|
|
60
|
+
],
|
|
61
|
+
})"""
|
|
62
|
+
|
|
63
|
+
if init_oauth:
|
|
64
|
+
js += f"""
|
|
65
|
+
ui.initOAuth({_html_safe_json(jsonable_encoder(init_oauth))})
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
js += """
|
|
69
|
+
window.ui = ui;
|
|
70
|
+
};
|
|
71
|
+
"""
|
|
72
|
+
return Response(content=js, media_type="text/javascript")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_swagger_ui_html(
|
|
76
|
+
*,
|
|
77
|
+
title: str,
|
|
78
|
+
swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js",
|
|
79
|
+
swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css",
|
|
80
|
+
swagger_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png",
|
|
81
|
+
swagger_ui_init_script_url: str = "/docs/swagger-initializer.js",
|
|
82
|
+
) -> HTMLResponse:
|
|
83
|
+
"""
|
|
84
|
+
Generate and return the HTML that loads Swagger UI for the interactive
|
|
85
|
+
API docs (normally served at `/docs`).
|
|
86
|
+
|
|
87
|
+
You would only call this function yourself if you needed to override some parts,
|
|
88
|
+
for example the URLs to use to load Swagger UI's JavaScript and CSS.
|
|
89
|
+
|
|
90
|
+
Read more about it in the
|
|
91
|
+
[FastAPI docs for Configure Swagger UI](https://fastapi.tiangolo.com/how-to/configure-swagger-ui/).
|
|
92
|
+
|
|
93
|
+
:param title: HTML page title.
|
|
94
|
+
:param swagger_js_url: URL of the Swagger UI JavaScript bundle.
|
|
95
|
+
:param swagger_css_url: URL of the Swagger UI CSS.
|
|
96
|
+
:param swagger_favicon_url: URL of the favicon.
|
|
97
|
+
:param swagger_ui_init_script_url: URL of the script that initializes Swagger UI, see
|
|
98
|
+
[get_swagger_ui_init_js][fastapi_csp_docs.docs.get_swagger_ui_init_js].
|
|
99
|
+
"""
|
|
100
|
+
html = f"""
|
|
101
|
+
<!DOCTYPE html>
|
|
102
|
+
<html>
|
|
103
|
+
<head>
|
|
104
|
+
<meta charset="UTF-8">
|
|
105
|
+
<title>{title}</title>
|
|
106
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
107
|
+
<link type="text/css" rel="stylesheet" href="{swagger_css_url}">
|
|
108
|
+
<link rel="shortcut icon" href="{swagger_favicon_url}">
|
|
109
|
+
</head>
|
|
110
|
+
<body>
|
|
111
|
+
<div id="swagger-ui"></div>
|
|
112
|
+
<script src="{swagger_js_url}" charset="UTF-8"></script>
|
|
113
|
+
<!-- `SwaggerUIBundle` is now available on the page -->
|
|
114
|
+
<script src="{swagger_ui_init_script_url}" charset="UTF-8"></script>
|
|
115
|
+
</body>
|
|
116
|
+
</html>
|
|
117
|
+
"""
|
|
118
|
+
return HTMLResponse(html)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_redoc_html(
|
|
122
|
+
*,
|
|
123
|
+
openapi_url: str,
|
|
124
|
+
title: str,
|
|
125
|
+
redoc_js_url: str = "https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js",
|
|
126
|
+
redoc_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png",
|
|
127
|
+
with_google_fonts: bool = True,
|
|
128
|
+
redoc_css_url: str = "/redoc/redoc.css",
|
|
129
|
+
google_fonts_css_url: str = "https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700",
|
|
130
|
+
disable_search: bool = False,
|
|
131
|
+
) -> HTMLResponse:
|
|
132
|
+
"""
|
|
133
|
+
Generate and return the HTML response that loads ReDoc for the alternative
|
|
134
|
+
API docs (normally served at `/redoc`).
|
|
135
|
+
|
|
136
|
+
You would only call this function yourself if you needed to override some parts,
|
|
137
|
+
for example the URLs to use to load ReDoc's JavaScript and CSS.
|
|
138
|
+
|
|
139
|
+
:param openapi_url: URL of the OpenAPI schema for ReDoc to render.
|
|
140
|
+
:param title: HTML page title.
|
|
141
|
+
:param redoc_js_url: URL of the ReDoc JavaScript bundle.
|
|
142
|
+
:param redoc_favicon_url: URL of the favicon.
|
|
143
|
+
:param with_google_fonts: whether to load the Montserrat/Roboto fonts from Google Fonts.
|
|
144
|
+
:param redoc_css_url: URL of the CSS reset required by ReDoc, see
|
|
145
|
+
[get_redoc_css][fastapi_csp_docs.docs.get_redoc_css].
|
|
146
|
+
:param google_fonts_css_url: URL of the Google Fonts stylesheet, used only when
|
|
147
|
+
`with_google_fonts` is True.
|
|
148
|
+
:param disable_search: whether to disable ReDoc's client-side search box (and the Web
|
|
149
|
+
Worker it runs in) — see the README section on `worker-src`.
|
|
150
|
+
"""
|
|
151
|
+
html = f"""
|
|
152
|
+
<!DOCTYPE html>
|
|
153
|
+
<html>
|
|
154
|
+
<head>
|
|
155
|
+
<title>{title}</title>
|
|
156
|
+
<!-- needed for adaptive design -->
|
|
157
|
+
<meta charset="utf-8"/>
|
|
158
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
159
|
+
"""
|
|
160
|
+
if with_google_fonts:
|
|
161
|
+
html += f"""
|
|
162
|
+
<link href="{google_fonts_css_url}" rel="stylesheet">
|
|
163
|
+
"""
|
|
164
|
+
html += f"""
|
|
165
|
+
<link rel="shortcut icon" href="{redoc_favicon_url}">
|
|
166
|
+
<link rel="stylesheet" type="text/css" href="{redoc_css_url}">
|
|
167
|
+
<!--
|
|
168
|
+
ReDoc doesn't change outer page styles
|
|
169
|
+
-->
|
|
170
|
+
</head>
|
|
171
|
+
<body>
|
|
172
|
+
<noscript>
|
|
173
|
+
ReDoc requires Javascript to function. Please enable it to browse the documentation.
|
|
174
|
+
</noscript>
|
|
175
|
+
<redoc spec-url="{openapi_url}"{' disable-search="true"' if disable_search else ""}></redoc>
|
|
176
|
+
<script src="{redoc_js_url}"> </script>
|
|
177
|
+
</body>
|
|
178
|
+
</html>
|
|
179
|
+
"""
|
|
180
|
+
return HTMLResponse(html)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_redoc_css() -> Response:
|
|
184
|
+
"""
|
|
185
|
+
Generate and return the response with the CSS reset required by ReDoc.
|
|
186
|
+
|
|
187
|
+
This is normally served as `redoc.css`, referenced from the HTML
|
|
188
|
+
generated by [get_redoc_html][fastapi_csp_docs.docs.get_redoc_html].
|
|
189
|
+
|
|
190
|
+
You normally don't need to use or change this.
|
|
191
|
+
"""
|
|
192
|
+
return Response(content="body{margin:0;padding:0;}", media_type="text/css")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_swagger_ui_oauth2_redirect_html(
|
|
196
|
+
*,
|
|
197
|
+
oauth2_redirect_script_url: str = "oauth2-redirect.js",
|
|
198
|
+
) -> HTMLResponse:
|
|
199
|
+
"""
|
|
200
|
+
Generate the HTML response with the OAuth2 redirection for Swagger UI.
|
|
201
|
+
|
|
202
|
+
You would only call this function yourself if you needed to override some parts,
|
|
203
|
+
for example the URL of the OAuth2 redirect script.
|
|
204
|
+
|
|
205
|
+
:param oauth2_redirect_script_url: URL of the OAuth2 redirect script, see
|
|
206
|
+
[get_swagger_ui_oauth2_redirect_js][fastapi_csp_docs.docs.get_swagger_ui_oauth2_redirect_js].
|
|
207
|
+
"""
|
|
208
|
+
html = f"""
|
|
209
|
+
<!doctype html>
|
|
210
|
+
<html lang="en-US">
|
|
211
|
+
<head>
|
|
212
|
+
<title>Swagger UI: OAuth2 Redirect</title>
|
|
213
|
+
</head>
|
|
214
|
+
<body>
|
|
215
|
+
<script src="{oauth2_redirect_script_url}"></script>
|
|
216
|
+
</body>
|
|
217
|
+
</html>
|
|
218
|
+
"""
|
|
219
|
+
return HTMLResponse(content=html)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_swagger_ui_oauth2_redirect_js() -> Response:
|
|
223
|
+
"""
|
|
224
|
+
Generate and return the response with the OAuth2 redirection logic for
|
|
225
|
+
Swagger UI.
|
|
226
|
+
|
|
227
|
+
This is normally served as `oauth2-redirect.js`, referenced from the HTML
|
|
228
|
+
generated by
|
|
229
|
+
[get_swagger_ui_oauth2_redirect_html][fastapi_csp_docs.docs.get_swagger_ui_oauth2_redirect_html].
|
|
230
|
+
|
|
231
|
+
You normally don't need to use or change this.
|
|
232
|
+
"""
|
|
233
|
+
# minified, copied from https://github.com/swagger-api/swagger-ui/blob/v5.32.8/dist/oauth2-redirect.js
|
|
234
|
+
js = (
|
|
235
|
+
'"use strict";function run(){var e,r,t,a=window.opener.swaggerUIRedirectOauth2,'
|
|
236
|
+
"o=a.state,n=a.redirectUrl;if((t=(r=/code|token|error/.test(window.location.hash)?"
|
|
237
|
+
'window.location.hash.substring(1).replace("?","&"):location.search.substring(1))'
|
|
238
|
+
'.split("&")).forEach((function(e,r,t){t[r]=\'"\'+e.replace("=",\'":"\')+\'"\'})),'
|
|
239
|
+
'e=(r=r?JSON.parse("{"+t.join()+"}",(function(e,r){return""===e?r:decodeURIComponent(r)}))'
|
|
240
|
+
':{}).state===o,"accessCode"!==a.auth.schema.get("flow")&&"authorizationCode"!=='
|
|
241
|
+
'a.auth.schema.get("flow")&&"authorization_code"!==a.auth.schema.get("flow")||a.auth.code)'
|
|
242
|
+
"a.callback({auth:a.auth,token:r,isValid:e,redirectUrl:n});else if(e||a.errCb({authId:"
|
|
243
|
+
'a.auth.name,source:"auth",level:"warning",message:"Authorization may be unsafe, passed '
|
|
244
|
+
"state was changed in server Passed state wasn't returned from auth server\"}),r.code)"
|
|
245
|
+
"delete a.state,a.auth.code=r.code,a.callback({auth:a.auth,redirectUrl:n});else{let e;"
|
|
246
|
+
'r.error&&(e="["+r.error+"]: "+(r.error_description?r.error_description+". ":"no accessCode '
|
|
247
|
+
'received from the server. ")+(r.error_uri?"More info: "+r.error_uri:"")),a.errCb({authId:'
|
|
248
|
+
'a.auth.name,source:"auth",level:"error",message:e||"[Authorization failed]: no accessCode '
|
|
249
|
+
'received from the server"})}window.close()}"loading"!==document.readyState?run():'
|
|
250
|
+
'document.addEventListener("DOMContentLoaded",(function(){run()}));'
|
|
251
|
+
)
|
|
252
|
+
return Response(content=js, media_type="text/javascript")
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from starlette.requests import Request
|
|
3
|
+
from starlette.responses import HTMLResponse, Response
|
|
4
|
+
|
|
5
|
+
from fastapi_csp_docs.docs import (
|
|
6
|
+
get_redoc_css,
|
|
7
|
+
get_redoc_html,
|
|
8
|
+
get_swagger_ui_html,
|
|
9
|
+
get_swagger_ui_init_js,
|
|
10
|
+
get_swagger_ui_oauth2_redirect_html,
|
|
11
|
+
get_swagger_ui_oauth2_redirect_js,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def setup(
|
|
16
|
+
app: FastAPI,
|
|
17
|
+
*,
|
|
18
|
+
docs_url: str | None = "/docs",
|
|
19
|
+
redoc_url: str | None = "/redoc",
|
|
20
|
+
) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Wire CSP-safe Swagger UI and ReDoc routes onto a FastAPI app.
|
|
23
|
+
|
|
24
|
+
Registers replacement routes for the interactive API docs (Swagger UI) and the
|
|
25
|
+
alternative API docs (ReDoc) that load their JavaScript/CSS from dedicated endpoints
|
|
26
|
+
instead of FastAPI's inline `<script>`/`<style>` tags, so a Content-Security-Policy
|
|
27
|
+
without `'unsafe-inline'` can be enforced.
|
|
28
|
+
|
|
29
|
+
The app must already be created with `docs_url=None`/`redoc_url=None`, e.g.
|
|
30
|
+
`FastAPI(docs_url=None, redoc_url=None)` — otherwise FastAPI's own built-in docs
|
|
31
|
+
would shadow the routes registered here.
|
|
32
|
+
|
|
33
|
+
Read more about it in the
|
|
34
|
+
[README](https://github.com/mat81black/fastapi-csp-docs#readme).
|
|
35
|
+
|
|
36
|
+
:param app: the FastAPI app to wire the docs routes onto.
|
|
37
|
+
:param docs_url: path to serve Swagger UI at, or None to skip it.
|
|
38
|
+
:param redoc_url: path to serve ReDoc at, or None to skip it.
|
|
39
|
+
:raises RuntimeError: if `app.docs_url` or `app.redoc_url` is still set.
|
|
40
|
+
"""
|
|
41
|
+
if docs_url:
|
|
42
|
+
if app.docs_url:
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
f"fastapi_csp_docs.setup() cannot mount Swagger UI at {docs_url!r} because "
|
|
45
|
+
f"the app still serves its built-in docs at {app.docs_url!r}. Create the "
|
|
46
|
+
"FastAPI app with docs_url=None to disable it first, e.g. FastAPI(docs_url=None)."
|
|
47
|
+
)
|
|
48
|
+
if app.openapi_url:
|
|
49
|
+
_setup_swagger_ui(app, docs_url)
|
|
50
|
+
if redoc_url:
|
|
51
|
+
if app.redoc_url:
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
f"fastapi_csp_docs.setup() cannot mount ReDoc at {redoc_url!r} because "
|
|
54
|
+
f"the app still serves its built-in docs at {app.redoc_url!r}. Create the "
|
|
55
|
+
"FastAPI app with redoc_url=None to disable it first, e.g. FastAPI(redoc_url=None)."
|
|
56
|
+
)
|
|
57
|
+
if app.openapi_url:
|
|
58
|
+
_setup_redoc(app, redoc_url)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _setup_swagger_ui(app: FastAPI, docs_url: str) -> None:
|
|
62
|
+
swagger_ui_init_script_url = f"{docs_url.rstrip('/')}/swagger-initializer.js"
|
|
63
|
+
|
|
64
|
+
async def swagger_ui_init_script(req: Request) -> Response:
|
|
65
|
+
root_path = req.scope.get("root_path", "").rstrip("/")
|
|
66
|
+
openapi_url = root_path + app.openapi_url
|
|
67
|
+
oauth2_redirect_url = app.swagger_ui_oauth2_redirect_url
|
|
68
|
+
if oauth2_redirect_url:
|
|
69
|
+
oauth2_redirect_url = root_path + oauth2_redirect_url
|
|
70
|
+
return get_swagger_ui_init_js(
|
|
71
|
+
openapi_url=openapi_url,
|
|
72
|
+
oauth2_redirect_url=oauth2_redirect_url,
|
|
73
|
+
init_oauth=app.swagger_ui_init_oauth,
|
|
74
|
+
swagger_ui_parameters=app.swagger_ui_parameters,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
app.add_route(
|
|
78
|
+
swagger_ui_init_script_url,
|
|
79
|
+
swagger_ui_init_script,
|
|
80
|
+
include_in_schema=False,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def swagger_ui_html(req: Request) -> HTMLResponse:
|
|
84
|
+
root_path = req.scope.get("root_path", "").rstrip("/")
|
|
85
|
+
init_script_url = root_path + swagger_ui_init_script_url
|
|
86
|
+
return get_swagger_ui_html(
|
|
87
|
+
title=f"{app.title} - Swagger UI",
|
|
88
|
+
swagger_ui_init_script_url=init_script_url,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
app.add_route(docs_url, swagger_ui_html, include_in_schema=False)
|
|
92
|
+
|
|
93
|
+
if app.swagger_ui_oauth2_redirect_url:
|
|
94
|
+
_setup_swagger_ui_oauth2_redirect(app, app.swagger_ui_oauth2_redirect_url)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _setup_swagger_ui_oauth2_redirect(app: FastAPI, oauth2_redirect_url: str) -> None:
|
|
98
|
+
oauth2_redirect_script_url = f"{oauth2_redirect_url.rstrip('/')}.js"
|
|
99
|
+
|
|
100
|
+
oauth2_redirect_js_response = get_swagger_ui_oauth2_redirect_js()
|
|
101
|
+
|
|
102
|
+
async def oauth2_redirect_script(req: Request) -> Response:
|
|
103
|
+
return oauth2_redirect_js_response
|
|
104
|
+
|
|
105
|
+
app.add_route(
|
|
106
|
+
oauth2_redirect_script_url,
|
|
107
|
+
oauth2_redirect_script,
|
|
108
|
+
include_in_schema=False,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def swagger_ui_redirect(req: Request) -> HTMLResponse:
|
|
112
|
+
root_path = req.scope.get("root_path", "").rstrip("/")
|
|
113
|
+
redirect_script_url = root_path + oauth2_redirect_script_url
|
|
114
|
+
return get_swagger_ui_oauth2_redirect_html(
|
|
115
|
+
oauth2_redirect_script_url=redirect_script_url,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
app.add_route(
|
|
119
|
+
oauth2_redirect_url,
|
|
120
|
+
swagger_ui_redirect,
|
|
121
|
+
include_in_schema=False,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _setup_redoc(app: FastAPI, redoc_url: str) -> None:
|
|
126
|
+
redoc_css_url = f"{redoc_url.rstrip('/')}/redoc.css"
|
|
127
|
+
|
|
128
|
+
redoc_css_response = get_redoc_css()
|
|
129
|
+
|
|
130
|
+
async def redoc_css(req: Request) -> Response:
|
|
131
|
+
return redoc_css_response
|
|
132
|
+
|
|
133
|
+
app.add_route(redoc_css_url, redoc_css, include_in_schema=False)
|
|
134
|
+
|
|
135
|
+
async def redoc_html(req: Request) -> HTMLResponse:
|
|
136
|
+
root_path = req.scope.get("root_path", "").rstrip("/")
|
|
137
|
+
openapi_url = root_path + app.openapi_url
|
|
138
|
+
css_url = root_path + redoc_css_url
|
|
139
|
+
return get_redoc_html(
|
|
140
|
+
openapi_url=openapi_url,
|
|
141
|
+
title=f"{app.title} - ReDoc",
|
|
142
|
+
redoc_css_url=css_url,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
app.add_route(redoc_url, redoc_html, include_in_schema=False)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-csp-docs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CSP-safe replacements for FastAPI's built-in /docs, /redoc, and OAuth2 redirect endpoints, with no inline script or style
|
|
5
|
+
Project-URL: Homepage, https://github.com/mat81black/fastapi-csp-docs
|
|
6
|
+
Project-URL: Repository, https://github.com/mat81black/fastapi-csp-docs
|
|
7
|
+
Project-URL: Issues, https://github.com/mat81black/fastapi-csp-docs/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/mat81black/fastapi-csp-docs#readme
|
|
9
|
+
Project-URL: Changelog, https://github.com/mat81black/fastapi-csp-docs/blob/main/RELEASE_NOTES.md
|
|
10
|
+
Author-email: Matteo Nieddu <mat81black@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Requires-Dist: fastapi>=0.120.0
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# FastAPI CSP Docs
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/fastapi-csp-docs/)
|
|
35
|
+
[](https://github.com/mat81black/fastapi-csp-docs/actions/workflows/test.yml)
|
|
36
|
+
[](https://pypi.org/project/fastapi-csp-docs/)
|
|
37
|
+
[](https://github.com/mat81black/fastapi-csp-docs/blob/main/LICENSE)
|
|
38
|
+
|
|
39
|
+
FastAPI's built-in `/docs`, `/redoc`, and OAuth2 redirect pages embed inline `<script>`/`<style>`
|
|
40
|
+
tags, so they break under a Content-Security-Policy without `'unsafe-inline'`. Following FastAPI's
|
|
41
|
+
own ["Custom Docs UI Static Assets"](https://fastapi.tiangolo.com/how-to/custom-docs-ui-assets/)
|
|
42
|
+
recipe only swaps the CDN URLs for local ones — it doesn't remove any inline content:
|
|
43
|
+
`get_swagger_ui_html()` still embeds the Swagger UI bootstrap script inline, `get_redoc_html()`
|
|
44
|
+
still embeds a `<style>` reset inline, and `get_swagger_ui_oauth2_redirect_html()` still embeds
|
|
45
|
+
the OAuth2 redirect logic inline. `fastapi-csp-docs` replaces all three pages with versions that
|
|
46
|
+
load every script and stylesheet from a separate endpoint, with no inline content anywhere.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- **Drop-in `setup()`**: one call replaces FastAPI's built-in `/docs`, `/redoc`, and OAuth2
|
|
51
|
+
redirect wiring — no inline script or style left anywhere.
|
|
52
|
+
- **Fails fast on misconfiguration**: raises `RuntimeError` if the app's built-in docs aren't
|
|
53
|
+
disabled first, instead of silently letting them shadow the CSP-safe routes.
|
|
54
|
+
- **CDN or fully self-hosted**: works with the default jsdelivr CDN
|
|
55
|
+
(`script-src 'self' <cdn-host>`) or entirely offline with your own static assets
|
|
56
|
+
(`script-src 'self'`, zero external origins).
|
|
57
|
+
- **Reverse-proxy / sub-app aware**: doc URLs include the ASGI `root_path` computed per request,
|
|
58
|
+
so mounting under a prefix (`app.mount("/api", sub_app)`) works out of the box.
|
|
59
|
+
- **Independently configurable mount paths**: `docs_url`/`redoc_url` are explicit `setup()`
|
|
60
|
+
parameters, each can be disabled on its own by passing `None`.
|
|
61
|
+
- **Content generators exported for manual wiring**: the same building blocks `setup()` uses
|
|
62
|
+
internally are public, for a fully self-hosted setup with no CDN at all.
|
|
63
|
+
|
|
64
|
+
## Requirements
|
|
65
|
+
|
|
66
|
+
- Python >= 3.10
|
|
67
|
+
- FastAPI >= 0.120.0
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install fastapi-csp-docs
|
|
73
|
+
# or
|
|
74
|
+
uv add fastapi-csp-docs
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Quick start
|
|
78
|
+
|
|
79
|
+
### Assisted mode (CDN-hosted Swagger UI / ReDoc)
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from fastapi import FastAPI, Request
|
|
83
|
+
from starlette.responses import Response
|
|
84
|
+
|
|
85
|
+
import fastapi_csp_docs
|
|
86
|
+
|
|
87
|
+
app = FastAPI(docs_url=None, redoc_url=None)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.middleware("http")
|
|
91
|
+
async def add_csp_header(request: Request, call_next) -> Response:
|
|
92
|
+
response = await call_next(request)
|
|
93
|
+
response.headers["Content-Security-Policy"] = (
|
|
94
|
+
"default-src 'self'; "
|
|
95
|
+
"script-src 'self' https://cdn.jsdelivr.net; "
|
|
96
|
+
"style-src 'self' https://cdn.jsdelivr.net"
|
|
97
|
+
)
|
|
98
|
+
return response
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
fastapi_csp_docs.setup(app)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`docs_url`/`redoc_url` must already be `None` on the app for whichever mode you're enabling —
|
|
105
|
+
`setup()` raises `RuntimeError` otherwise, so FastAPI's built-in inline-script docs can't
|
|
106
|
+
silently shadow the routes it registers.
|
|
107
|
+
|
|
108
|
+
Full runnable version:
|
|
109
|
+
[`examples/assisted_app.py`](https://github.com/mat81black/fastapi-csp-docs/blob/main/examples/assisted_app.py).
|
|
110
|
+
|
|
111
|
+
### Fully self-hosted mode (no CDN at all)
|
|
112
|
+
|
|
113
|
+
For a CSP with zero external origins (`script-src 'self'`), skip `setup()` and wire the
|
|
114
|
+
endpoints directly — same mechanics as FastAPI's own
|
|
115
|
+
["Custom Docs UI Static Assets"](https://fastapi.tiangolo.com/how-to/custom-docs-ui-assets/)
|
|
116
|
+
recipe, but built from this package's content generators instead of `fastapi.openapi.docs`, so
|
|
117
|
+
the OAuth2 redirect page stays script-free too:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from fastapi import FastAPI
|
|
121
|
+
from fastapi.staticfiles import StaticFiles
|
|
122
|
+
|
|
123
|
+
import fastapi_csp_docs
|
|
124
|
+
|
|
125
|
+
app = FastAPI(docs_url=None, redoc_url=None)
|
|
126
|
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.get("/docs", include_in_schema=False)
|
|
130
|
+
async def swagger_ui_html():
|
|
131
|
+
return fastapi_csp_docs.get_swagger_ui_html(
|
|
132
|
+
title=f"{app.title} - Swagger UI",
|
|
133
|
+
swagger_js_url="/static/swagger-ui-bundle.js",
|
|
134
|
+
swagger_css_url="/static/swagger-ui.css",
|
|
135
|
+
swagger_ui_init_script_url="/docs/swagger-initializer.js",
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Full runnable version, including the `swagger-initializer.js`/`redoc.css`/OAuth2 redirect
|
|
140
|
+
endpoints and the vendor assets:
|
|
141
|
+
[`examples/self_hosted_app.py`](https://github.com/mat81black/fastapi-csp-docs/blob/main/examples/self_hosted_app.py).
|
|
142
|
+
|
|
143
|
+
## ReDoc and Content-Security-Policy
|
|
144
|
+
|
|
145
|
+
Beyond the inline `<script>`/`<style>` tags this package removes from FastAPI's own generated
|
|
146
|
+
HTML, ReDoc's *own* runtime does three more things that need explicit CSP entries. Both
|
|
147
|
+
examples above already handle these (`examples/_csp.py` for the CDN-based ones,
|
|
148
|
+
`examples/self_hosted_app.py` for the fully self-hosted one) — this section explains why.
|
|
149
|
+
|
|
150
|
+
**Inline styles (`style-src`)** — ReDoc is built with `styled-components`, which injects
|
|
151
|
+
`<style>` tags into the page at runtime instead of shipping one static stylesheet. This is
|
|
152
|
+
unrelated to the inline content `fastapi-csp-docs` removes — it's ReDoc's own rendering
|
|
153
|
+
mechanism, and this package can't eliminate it. A CSP hash allowlist is used to allow them
|
|
154
|
+
without `'unsafe-inline'`: open DevTools, note the exact `'sha256-...'` value(s) the browser
|
|
155
|
+
reports as blocked, and add them to `style-src`. These hashes are tied to the exact ReDoc
|
|
156
|
+
build, so they need to be recaptured whenever `redoc.standalone.js` is upgraded.
|
|
157
|
+
|
|
158
|
+
**Web Worker (`worker-src`)** — ReDoc runs its search indexing in a Web Worker, instantiated
|
|
159
|
+
from a `blob:` URL rather than a separate `.js` file: the worker's entire source is inlined as
|
|
160
|
+
a string inside `redoc.standalone.js`, so there's no real file URL to point `worker-src` at
|
|
161
|
+
instead. Worker instantiation is governed by `worker-src`, not `script-src` — without it set
|
|
162
|
+
explicitly, browsers fall back to `script-src`, which never matches a `blob:` URL, so search
|
|
163
|
+
breaks the first time it's used (`Creating a worker from 'blob:...' violates ... worker-src`).
|
|
164
|
+
Two ways to fix it:
|
|
165
|
+
- Allow it explicitly: `worker-src 'self' blob:` — what `assisted_app.py` and the other
|
|
166
|
+
`setup()`-based examples do, since `setup()` deliberately has no CSP-specific parameters (it
|
|
167
|
+
mirrors FastAPI's own `docs_url`/`redoc_url` surface, nothing more).
|
|
168
|
+
- Or skip the worker (and the search box) entirely by not using `setup()` for the ReDoc route:
|
|
169
|
+
pass `redoc_url=None` to `setup()` and wire `/redoc` yourself with
|
|
170
|
+
`get_redoc_html(disable_search=True)` instead — no `blob:` needed in `worker-src` then. See
|
|
171
|
+
[`examples/redoc_disable_search_app.py`](https://github.com/mat81black/fastapi-csp-docs/blob/main/examples/redoc_disable_search_app.py).
|
|
172
|
+
|
|
173
|
+
**Logo image (`img-src`)** — ReDoc's "API docs by Redocly" attribution logo hardcodes an
|
|
174
|
+
absolute `cdn.redoc.ly` URL, with no supported way to override it or point it at a local file.
|
|
175
|
+
There's no CSP hash mechanism for images (hash-source only applies to `script-src`/`style-src`), so the only way to allow it is
|
|
176
|
+
whitelisting that origin in `img-src` — both examples do this, since it's a small, non-code
|
|
177
|
+
image request and doesn't weaken `script-src`/`style-src` at all. It's the one external origin
|
|
178
|
+
left in `self_hosted_app.py`'s otherwise fully self-hosted CSP.
|
|
179
|
+
|
|
180
|
+
## Reference
|
|
181
|
+
|
|
182
|
+
### `setup(app, *, docs_url="/docs", redoc_url="/redoc")`
|
|
183
|
+
|
|
184
|
+
| Parameter | Type | Default | Description |
|
|
185
|
+
|------------|---------------|-----------|-----------------------------------------------------------------------------------------------------------|
|
|
186
|
+
| `app` | `FastAPI` | required | The app to wire the CSP-safe docs routes onto. Must be created with `docs_url=None`/`redoc_url=None` for whichever of the two this call sets up. |
|
|
187
|
+
| `docs_url` | `str \| None` | `"/docs"` | Path to serve Swagger UI at, or `None` to skip it. |
|
|
188
|
+
| `redoc_url`| `str \| None` | `"/redoc"`| Path to serve ReDoc at, or `None` to skip it. |
|
|
189
|
+
|
|
190
|
+
Raises `RuntimeError` if `app.docs_url`/`app.redoc_url` is still set for the mode being enabled.
|
|
191
|
+
|
|
192
|
+
### Content generators (manual wiring)
|
|
193
|
+
|
|
194
|
+
| Function | Returns | Purpose |
|
|
195
|
+
|------------------------------------------|-----------------------------------|-----------------------------------------------------------------------------|
|
|
196
|
+
| `get_swagger_ui_html` | `HTMLResponse` | Swagger UI page, referencing an external init script instead of inline JS. |
|
|
197
|
+
| `get_swagger_ui_init_js` | `Response` (`text/javascript`) | Swagger UI bootstrap script, with HTML-escaped JSON config. |
|
|
198
|
+
| `get_swagger_ui_oauth2_redirect_html` | `HTMLResponse` | OAuth2 redirect page, referencing an external script instead of inline JS. |
|
|
199
|
+
| `get_swagger_ui_oauth2_redirect_js` | `Response` (`text/javascript`) | OAuth2 redirect handshake logic. |
|
|
200
|
+
| `get_redoc_html` | `HTMLResponse` | ReDoc page, referencing an external stylesheet instead of inline `<style>`. Takes `disable_search` to drop the search box/Web Worker, see "ReDoc and Content-Security-Policy" above. |
|
|
201
|
+
| `get_redoc_css` | `Response` (`text/css`) | Minimal CSS reset ReDoc needs. |
|
|
202
|
+
|
|
203
|
+
## Examples
|
|
204
|
+
|
|
205
|
+
| File | Description |
|
|
206
|
+
|-----------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
|
|
207
|
+
| [`examples/assisted_app.py`](https://github.com/mat81black/fastapi-csp-docs/blob/main/examples/assisted_app.py) | `setup()` + CDN-hosted Swagger UI/ReDoc bundles, CSP header set via middleware. |
|
|
208
|
+
| [`examples/self_hosted_app.py`](https://github.com/mat81black/fastapi-csp-docs/blob/main/examples/self_hosted_app.py) | Fully self-hosted docs (no CDN), manual wiring, vendor assets served from `examples/static/`. |
|
|
209
|
+
| [`examples/mounted_subapp_app.py`](https://github.com/mat81black/fastapi-csp-docs/blob/main/examples/mounted_subapp_app.py) | `setup()` on a sub-app mounted under a prefix (`app.mount("/api", sub_app)`) — doc URLs resolve correctly via the ASGI `root_path`. |
|
|
210
|
+
| [`examples/root_path_app.py`](https://github.com/mat81black/fastapi-csp-docs/blob/main/examples/root_path_app.py) | `setup()` on an app created with `FastAPI(root_path="/api")` — the reverse-proxy way of setting `root_path`, as opposed to mounting a sub-app. |
|
|
211
|
+
| [`examples/oauth2_redirect_app.py`](https://github.com/mat81black/fastapi-csp-docs/blob/main/examples/oauth2_redirect_app.py) | OAuth2 "Authorize" flow with a real security scheme, exercising the `/docs/oauth2-redirect` endpoint `setup()` registers. |
|
|
212
|
+
| [`examples/redoc_disable_search_app.py`](https://github.com/mat81black/fastapi-csp-docs/blob/main/examples/redoc_disable_search_app.py) | `setup()` for Swagger UI + manual ReDoc wiring with `disable_search=True`, avoiding `worker-src 'self' blob:` entirely. |
|
|
213
|
+
|
|
214
|
+
## Release Notes
|
|
215
|
+
|
|
216
|
+
See [RELEASE_NOTES.md](https://github.com/mat81black/fastapi-csp-docs/blob/main/RELEASE_NOTES.md).
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT — see [LICENSE](https://github.com/mat81black/fastapi-csp-docs/blob/main/LICENSE).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
fastapi_csp_docs/__init__.py,sha256=fKgm7WXvXSPA5ow5AHFpMl1570AmdkoaHNRw7orA3SU,486
|
|
2
|
+
fastapi_csp_docs/docs.py,sha256=gPJ2Fq7JuAPqCS8iZKDIsTgybvN5Y-EzgajEl5N7Ezo,10011
|
|
3
|
+
fastapi_csp_docs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
fastapi_csp_docs/setup.py,sha256=qeCLDOLFg46DYmqBtxU9e-Zd_qpr0vq1TYtNjI6Q1YQ,5373
|
|
5
|
+
fastapi_csp_docs-0.1.0.dist-info/METADATA,sha256=n76sJhtFKswkzhCpoNhbhJzHlCf56mPYEGbTk1MgjpQ,13640
|
|
6
|
+
fastapi_csp_docs-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
fastapi_csp_docs-0.1.0.dist-info/licenses/LICENSE,sha256=Tsif_IFIW5f-xYSy1KlhAy7v_oNEU4lP2cEnSQbMdE4,1086
|
|
8
|
+
fastapi_csp_docs-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018 Sebastián Ramírez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|