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.
@@ -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
+ ]
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/fastapi-csp-docs.svg)](https://pypi.org/project/fastapi-csp-docs/)
35
+ [![Test](https://github.com/mat81black/fastapi-csp-docs/actions/workflows/test.yml/badge.svg)](https://github.com/mat81black/fastapi-csp-docs/actions/workflows/test.yml)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/fastapi-csp-docs.svg)](https://pypi.org/project/fastapi-csp-docs/)
37
+ [![License: MIT](https://img.shields.io/pypi/l/fastapi-csp-docs.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.