feat(api): swap /docs from Swagger UI to Redoc (vendored offline) (#129)
Some checks are pending
Deploy / deploy (push) Waiting to run

Redoc 2.5.3 standalone bundle (MIT) replaces the three swagger-ui-dist
assets: one ~1.05MB JS file instead of ~1.8MB of JS+CSS+preset, and the
layout traps (StandaloneLayout/BaseLayout) go away. Redoc is read-only by
design, which matches the existing posture: try-it-out was already
disabled since data routes need SigV4 (Postman for live calls).

Unchanged: single token-gated response, offline vendoring (no CDN),
per-request server-URL injection, script-breakout guards, cached shell
with per-request spec splice. Bundle self-containment verified: the
search worker is an inlined Blob, and the only new Worker(filename) path
is Prism's async mode, which Redoc never invokes.

Verified via headless-Chrome render of the real handler output: all
routes, the OpenAPI 3.1 webhooks section, and planned-route markers
render; no placeholder leakage.
This commit is contained in:
Adam Moussa 2026-07-24 12:00:22 -04:00 • committed by GitHub
parent 8156b275a9
commit 24c89b43e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1899 additions and 112 deletions

View file

@ -96,7 +96,7 @@ A read-only REST API (API Gateway + the `procurement-api` Lambda, `lambdas/api/`
| `GET /work-orders/{id}` · `/purchase-orders/{id}` · `/verified-sites/{siteCode}` | IAM SigV4 | GetItem (404 on miss) |
| `GET /work-orders/{id}/comments` | IAM SigV4 | Query on the partition key, paginated |
| `POST /work-orders/{id}/comments`, `PATCH /work-orders/{id}` | — | **phase-2 planned** (`x-planned` in the spec); handler answers 501 |
| `GET /docs`, `GET /openapi.json` | shared docs token (`X-Auth-Token` header or `?token=` in a browser) | stock Swagger UI (vendored offline, no CDN) with the spec inlined / the committed spec |
| `GET /docs`, `GET /openapi.json` | shared docs token (`X-Auth-Token` header or `?token=` in a browser) | Redoc reference docs (vendored offline, no CDN) with the spec inlined / the committed spec |
- **Spec is source of truth:** `lambdas/api/openapi.json` (OpenAPI 3.1). Its top-level `webhooks` section documents the outbound SHOC work-order feed, so one page describes both directions (call + be-called). `tests/test_api_spec_drift.py` pins the spec's paths to the router table, so spec and implementation cannot drift.
- **Auth:** data routes use API Gateway `AWS_IAM` (SigV4) plus a resource policy allowing exactly `arn:aws:iam::396287094661:role/shoc-backend-dev` on `GET/*`; same-account admin callers authorize via identity policy (Postman signs SigV4 natively). Docs routes are auth `NONE` at the gateway (resource-policy carve-out for exactly those two GETs) but the handler fails closed on the shared token (`lambdas/shared/web_ui_auth.py`, secret `procurement-ingest/web-ui-auth-token`) — not an unauthenticated data path (INFRA-74 posture).
@ -436,9 +436,8 @@ lambdas/ # Phase 2: shared Code.from_asset("../lambdas") bundling
wo_repo.py # WorkOrders/WorkOrderComments reads (paginated Scan / PK Query)
po_repo.py # purchase-orders/verified-sites reads (no VendorReplies -- dead table)
openapi.json # OpenAPI 3.1 source of truth (paths + outbound `webhooks` section)
docs.html # Swagger UI shell; handler inlines the css/js/spec at request time (single token-gated request, no CDN)
swagger-ui-bundle.js # vendored stock Swagger UI (swagger-ui-dist 5.17.14, Apache-2.0), inlined into /docs
swagger-ui.css # vendored Swagger UI stylesheet, inlined into /docs
docs.html # Redoc shell; handler inlines the bundle/spec at request time (single token-gated request, no CDN)
redoc.standalone.js # vendored Redoc bundle (redoc 2.5.3, MIT), inlined into /docs; read-only docs, no try-it-out
po/ # PO pipeline Lambdas
email_processor/ # Phase 5: God-handler decomposed into flat siblings (bare-name
# imports; the Phase 0/2/3 `cp <pipeline>/email_processor/*.py`

View file

@ -111,16 +111,14 @@ class ProcurementApiStack(Stack):
"-c",
# Non-recursive glob ships every api/ sibling (the
# allowlist-omission trap from PR #105/PR #2); the spec,
# docs page, and the vendored Swagger UI assets ride
# along because the handler serves them from its own
# package dir. web_ui_auth.py must land FLAT beside
# handler.py for the bare import.
# docs page, and the vendored Redoc bundle ride along
# because the handler serves them from its own package
# dir. web_ui_auth.py must land FLAT beside handler.py
# for the bare import.
"cp api/*.py /asset-output/ && "
"cp api/openapi.json /asset-output/ && "
"cp api/docs.html /asset-output/ && "
"cp api/swagger-ui-bundle.js /asset-output/ && "
"cp api/swagger-ui-standalone-preset.js /asset-output/ && "
"cp api/swagger-ui.css /asset-output/ && "
"cp api/redoc.standalone.js /asset-output/ && "
"cp shared/web_ui_auth.py /asset-output/ && "
"rm -rf /asset-output/__pycache__",
],

View file

@ -5,50 +5,37 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Procurement Ingest API</title>
<!--
Stock Swagger UI (swagger-ui-dist, Apache-2.0), vendored offline and inlined
server-side so /docs is a single token-gated request with no CDN or
follow-up asset fetch. The CSS, swagger-ui-bundle.js, the standalone preset,
and the OpenAPI spec are substituted for the placeholders below by
lambdas/api/handler.py::_load_docs_html. The initializer is the canonical
swagger-ui-dist recipe (StandaloneLayout + both presets); the topbar's
spec-URL bar is hidden since the spec is fixed and inlined.
Redoc (redoc.standalone.js, MIT), vendored offline and inlined server-side
so /docs is a single token-gated request with no CDN or follow-up asset
fetch. The bundle and the OpenAPI spec are substituted for the placeholders
below by lambdas/api/handler.py::_load_docs_html. Redoc is read-only by
design -- there is no try-it-out to disable; live calls go through Postman
(Authorization type "AWS Signature") since data routes require SigV4.
The theme pins a system font stack: Redoc defaults to Roboto, which is not
vendored and must not be fetched from Google Fonts.
-->
<style>__SWAGGER_UI_CSS__</style>
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.swagger-ui .topbar { display: none; }
/* Give the description prose room: Swagger UI's default markdown line-height
is tight, so inline code chips (background + padding) visually collide
across lines. Loosen line-height and keep chips from wrapping. */
.swagger-ui .info { margin: 30px 0 20px; }
.swagger-ui .info .markdown p,
.swagger-ui .info .renderedMarkdown p { line-height: 1.75; margin: 0 0 12px; }
.swagger-ui .info code,
.swagger-ui .renderedMarkdown code { padding: 1px 5px; line-height: 1.6; white-space: nowrap; }
.swagger-ui .scheme-container { margin: 0 0 20px; padding: 20px 0; }
html, body { margin: 0; padding: 0; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script>__SWAGGER_UI_JS__</script>
<script>__SWAGGER_UI_STANDALONE_PRESET_JS__</script>
<div id="redoc"></div>
<script>__REDOC_JS__</script>
<script>
window.ui = SwaggerUIBundle({
spec: __OPENAPI_SPEC_JSON__,
dom_id: "#swagger-ui",
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
layout: "StandaloneLayout",
docExpansion: "list",
defaultModelsExpandDepth: 1,
// Try-it-out is disabled: data routes require AWS SigV4, which a browser
// can't sign. Use Postman (Authorization type "AWS Signature") against the
// same spec for live calls.
supportedSubmitMethods: []
});
Redoc.init(
__OPENAPI_SPEC_JSON__,
{
theme: {
typography: {
fontFamily: 'system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif',
headings: {
fontFamily: 'system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif'
}
}
}
},
document.getElementById("redoc")
);
</script>
</body>
</html>

View file

@ -27,17 +27,13 @@ logger.setLevel(logging.INFO)
_MODULE_DIR = Path(__file__).resolve().parent
_SPEC_PATH = _MODULE_DIR / "openapi.json"
_DOCS_PATH = _MODULE_DIR / "docs.html"
_SWAGGER_CSS_PATH = _MODULE_DIR / "swagger-ui.css"
_SWAGGER_JS_PATH = _MODULE_DIR / "swagger-ui-bundle.js"
_SWAGGER_PRESET_PATH = _MODULE_DIR / "swagger-ui-standalone-preset.js"
_REDOC_JS_PATH = _MODULE_DIR / "redoc.standalone.js"
# The docs page template carries these placeholders, filled server-side so
# /docs is a single token-gated request (a browser can't attach the auth
# header to a follow-up asset fetch) with the stock Swagger UI + spec inlined,
# no CDN.
# header to a follow-up asset fetch) with the vendored Redoc bundle + spec
# inlined, no CDN.
_SPEC_PLACEHOLDER = "__OPENAPI_SPEC_JSON__"
_CSS_PLACEHOLDER = "__SWAGGER_UI_CSS__"
_JS_PLACEHOLDER = "__SWAGGER_UI_JS__"
_PRESET_PLACEHOLDER = "__SWAGGER_UI_STANDALONE_PRESET_JS__"
_JS_PLACEHOLDER = "__REDOC_JS__"
_spec_cache = None
_docs_shell_cache = None
@ -96,39 +92,30 @@ def _spec_for_request(event: dict) -> str:
def _docs_shell() -> str:
"""The Swagger UI page with the vendored CSS/JS/preset inlined; the SPEC
placeholder is left intact so the per-request server-injected spec splices
in cheaply (the heavy ~1.8MB assets are assembled once and cached).
"""The Redoc page with the vendored bundle inlined; the SPEC placeholder
is left intact so the per-request server-injected spec splices in cheaply
(the heavy ~1.1MB bundle is assembled once and cached).
Each blob is spliced into a <style>/<script> block, where the HTML parser
ends the element at the first literal "</style"/"</script" regardless of
quoting, so guard each against a breakout a future asset update could add
(the pinned assets have none today): JS/preset "</script" -> "<\\/script"
(equivalent inside JS string/regex), CSS "</style" -> "<\\/style".
The bundle is spliced into a <script> block, where the HTML parser ends
the element at the first literal "</script" regardless of quoting, so
guard against a breakout a future asset update could add (the pinned
bundle has none today): "</script" -> "<\\/script" (equivalent inside a
JS string/regex).
"""
global _docs_shell_cache
if _docs_shell_cache is None:
css = _SWAGGER_CSS_PATH.read_text(encoding="utf-8").replace(
"</style", "<\\/style"
)
js = _SWAGGER_JS_PATH.read_text(encoding="utf-8").replace(
js = _REDOC_JS_PATH.read_text(encoding="utf-8").replace(
"</script", "<\\/script"
)
preset = _SWAGGER_PRESET_PATH.read_text(encoding="utf-8").replace(
"</script", "<\\/script"
)
_docs_shell_cache = (
_DOCS_PATH.read_text(encoding="utf-8")
.replace(_CSS_PLACEHOLDER, css)
.replace(_JS_PLACEHOLDER, js)
.replace(_PRESET_PLACEHOLDER, preset)
_docs_shell_cache = _DOCS_PATH.read_text(encoding="utf-8").replace(
_JS_PLACEHOLDER, js
)
return _docs_shell_cache
def _render_docs_html(event: dict) -> str:
# "<" -> < so a spec string can never close the <script> block (same
# value to JSON.parse); the spec object is inlined into SwaggerUIBundle.
# value to JSON.parse); the spec object is inlined into Redoc.init.
spec = _spec_for_request(event).replace("<", "\\u003c")
return _docs_shell().replace(_SPEC_PLACEHOLDER, spec)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -113,16 +113,12 @@ def test_docs_served_when_authenticated(api, monkeypatch):
docs = mod.handler(_event("GET", "/docs"), None)
assert docs["statusCode"] == 200
assert docs["headers"]["Content-Type"].startswith("text/html")
# Stock Swagger UI is inlined and the spec (carrying the title) with it.
assert "SwaggerUIBundle" in docs["body"]
# The vendored Redoc bundle is inlined and the spec (carrying the title)
# with it.
assert "Redoc.init" in docs["body"]
assert "Procurement Ingest API" in docs["body"]
# Every placeholder must be substituted, or the page renders broken.
for placeholder in (
mod._SPEC_PLACEHOLDER,
mod._CSS_PLACEHOLDER,
mod._JS_PLACEHOLDER,
mod._PRESET_PLACEHOLDER,
):
for placeholder in (mod._SPEC_PLACEHOLDER, mod._JS_PLACEHOLDER):
assert placeholder not in docs["body"]
spec = mod.handler(_event("GET", "/openapi.json"), None)

View file

@ -106,29 +106,20 @@ def test_spec_has_no_script_breakout_sequence():
def test_docs_and_spec_files_ship_with_the_handler():
# The handler serves these from its own package dir; if any file is missing
# from lambdas/api/ the bundle would 500 at runtime. Swagger UI is vendored
# from lambdas/api/ the bundle would 500 at runtime. Redoc is vendored
# offline (no CDN) and inlined into the single token-gated response.
api_dir = Path(REPO_ROOT) / "lambdas" / "api"
assert (api_dir / "openapi.json").is_file()
assert (api_dir / "docs.html").is_file()
assert (api_dir / "swagger-ui-bundle.js").is_file()
assert (api_dir / "swagger-ui-standalone-preset.js").is_file()
assert (api_dir / "swagger-ui.css").is_file()
assert (api_dir / "redoc.standalone.js").is_file()
docs = (api_dir / "docs.html").read_text(encoding="utf-8")
assert "__OPENAPI_SPEC_JSON__" in docs
assert "__SWAGGER_UI_CSS__" in docs
assert "__SWAGGER_UI_JS__" in docs
assert "__SWAGGER_UI_STANDALONE_PRESET_JS__" in docs
assert "SwaggerUIStandalonePreset" in docs
assert "StandaloneLayout" in docs
# The vendored Swagger UI blob must carry no raw </script>: it is inlined
assert "__REDOC_JS__" in docs
assert "Redoc.init" in docs
# The vendored Redoc bundle must carry no raw </script>: it is inlined
# into a <script> block, and a literal </script would break it out. (The
# handler also escapes it defensively, but keeping the pinned asset clean
# is the load-bearing guarantee and catches a bad version bump here.)
assert "</script" not in (api_dir / "swagger-ui-bundle.js").read_text(
assert "</script" not in (api_dir / "redoc.standalone.js").read_text(
encoding="utf-8"
)
assert "</script" not in (api_dir / "swagger-ui-standalone-preset.js").read_text(
encoding="utf-8"
)
assert "</style" not in (api_dir / "swagger-ui.css").read_text(encoding="utf-8")

View file

@ -107,14 +107,12 @@ API_PIPELINE_MODULES = frozenset(
}
)
# Non-.py files the api bundle must also EXECUTE cps for: the handler serves
# the spec, docs page, and the vendored Swagger UI assets from its own package
# the spec, docs page, and the vendored Redoc bundle from its own package
# dir, so dropping any cp 500s /docs at runtime with green CI.
API_DATA_FILES_CP_RES = (
r"(?:^|\s)api/openapi\.json(?:\s|$)",
r"(?:^|\s)api/docs\.html(?:\s|$)",
r"(?:^|\s)api/swagger-ui-bundle\.js(?:\s|$)",
r"(?:^|\s)api/swagger-ui-standalone-preset\.js(?:\s|$)",
r"(?:^|\s)api/swagger-ui\.css(?:\s|$)",
r"(?:^|\s)api/redoc\.standalone\.js(?:\s|$)",
)
# Pipeline-scoped glob shapes the per-stack ships-all pins accept. Phase 2