mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 07:13:13 +00:00
feat(api): stock Swagger UI for /docs (vendored offline) (#128)
Some checks are pending
Deploy / deploy (push) Waiting to run
Some checks are pending
Deploy / deploy (push) Waiting to run
* feat(api): use stock Swagger UI for the /docs page Replaces the custom renderer with vendored stock Swagger UI (swagger-ui-dist 5.17.14, Apache-2.0), kept offline (no CDN) and inlined server-side into the single token-gated /docs response alongside the spec. BaseLayout (topbar hidden); try-it-out disabled since data routes need SigV4 (use Postman for live calls). Breakout guards on the inlined css/js/spec. Bundle-consistency + spec-drift + handler tests updated for the two vendored assets. cdk diff = Lambda code asset only (no IAM/API/policy change). * fix(api): render Swagger UI with the canonical StandaloneLayout recipe The BaseLayout-only init (apis preset, no standalone preset) rendered incorrectly. Switch to the canonical swagger-ui-dist recipe: vendor swagger-ui-standalone-preset.js and init with presets:[apis, SwaggerUIStandalonePreset] + layout:"StandaloneLayout" (topbar hidden, try-it-out disabled). Verified via headless Chrome against the live deployed page: all 9 endpoints + 4 webhooks + models render. Tests/bundling updated for the third vendored asset. * fix(api): declutter the Swagger UI docs page The page rendered (stock Swagger UI, StandaloneLayout) but looked cramped: a dense info.description wall of inline-code chips collided across Swagger UI's tight default line-height, and the Servers box showed a SEE-STACK-OUTPUT placeholder. - Trim info.description to a few concise lines (detail lives in README + the webhook contract doc). - Inject the live invoke URL into servers[0].url per request (from the API Gateway request context; committed literal is the fallback), so the Servers box shows the real endpoint and drops the server-variables section. - CSS: loosen description line-height + inline-code padding so chips never overlap; tidy the scheme container spacing. - Handler: cache the heavy CSS/JS/preset shell once; splice the (server- injected) spec per request. Verified via headless Chrome against the live deployed page.
This commit is contained in:
parent
f67d8b9907
commit
8156b275a9
11 changed files with 166 additions and 253 deletions
|
|
@ -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) | self-contained HTML reference page / the committed spec |
|
||||
| `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 |
|
||||
|
||||
- **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,7 +436,9 @@ 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 # self-contained reference page (spec inlined at request time; no CDN)
|
||||
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
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -110,13 +110,17 @@ class ProcurementApiStack(Stack):
|
|||
"bash",
|
||||
"-c",
|
||||
# Non-recursive glob ships every api/ sibling (the
|
||||
# allowlist-omission trap from PR #105/PR #2); the spec
|
||||
# and docs page 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.
|
||||
# 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.
|
||||
"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 shared/web_ui_auth.py /asset-output/ && "
|
||||
"rm -rf /asset-output/__pycache__",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -4,226 +4,51 @@
|
|||
<meta charset="UTF-8">
|
||||
<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.
|
||||
-->
|
||||
<style>__SWAGGER_UI_CSS__</style>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; line-height: 1.55; }
|
||||
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 20px 80px; }
|
||||
h1 { font-size: 24px; margin-bottom: 4px; }
|
||||
h2 { font-size: 18px; margin: 36px 0 14px; padding-bottom: 6px; border-bottom: 2px solid #e2e8f0; }
|
||||
.muted { color: #64748b; font-size: 14px; }
|
||||
.desc { margin: 14px 0; font-size: 14px; color: #334155; }
|
||||
.desc p { margin-bottom: 8px; }
|
||||
code { background: #e2e8f0; border-radius: 4px; padding: 1px 5px; font-size: 12.5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.card { background: #fff; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 12px; overflow: hidden; }
|
||||
.op-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; cursor: pointer; }
|
||||
.op-head:hover { background: #f8fafc; }
|
||||
.method { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 6px; color: #fff; min-width: 58px; text-align: center; }
|
||||
.m-get { background: #10b981; } .m-post { background: #f59e0b; } .m-patch { background: #8b5cf6; }
|
||||
.m-webhook { background: #3b82f6; }
|
||||
.path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; font-weight: 600; }
|
||||
.summary { color: #64748b; font-size: 13px; flex: 1; }
|
||||
.planned { background: #fef3c7; color: #92400e; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; }
|
||||
.op-body { display: none; padding: 4px 16px 16px; border-top: 1px solid #f1f5f9; }
|
||||
.open .op-body { display: block; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 13px; }
|
||||
th { text-align: left; padding: 6px 8px; color: #64748b; font-size: 11px; text-transform: uppercase; border-bottom: 2px solid #e2e8f0; }
|
||||
td { padding: 6px 8px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
|
||||
.sec-label { font-size: 12px; font-weight: 700; color: #64748b; text-transform: uppercase; margin: 14px 0 4px; }
|
||||
.schema-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
|
||||
.type { color: #7c3aed; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||
.req { color: #dc2626; font-size: 11px; font-weight: 700; }
|
||||
.badge-auth { background: #dbeafe; color: #1d4ed8; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; }
|
||||
a { color: #3b82f6; text-decoration: none; }
|
||||
.enum { color: #0f766e; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||
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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap" id="app"><p class="muted">Loading spec…</p></div>
|
||||
<script id="spec" type="application/json">__OPENAPI_SPEC_JSON__</script>
|
||||
<div id="swagger-ui"></div>
|
||||
<script>__SWAGGER_UI_JS__</script>
|
||||
<script>__SWAGGER_UI_STANDALONE_PRESET_JS__</script>
|
||||
<script>
|
||||
"use strict";
|
||||
const spec = JSON.parse(document.getElementById("spec").textContent);
|
||||
const el = (tag, cls, html) => {
|
||||
const node = document.createElement(tag);
|
||||
if (cls) node.className = cls;
|
||||
if (html !== undefined) node.innerHTML = html;
|
||||
return node;
|
||||
};
|
||||
const escapeHtml = s => String(s).replace(/[&<>"']/g,
|
||||
c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
const md = s => escapeHtml(s || "")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.split(/\n\n+/).map(p => `<p>${p.replace(/\n/g, "<br>")}</p>`).join("");
|
||||
|
||||
function deref(node) {
|
||||
if (node && node.$ref) {
|
||||
const parts = node.$ref.replace("#/", "").split("/");
|
||||
let cur = spec;
|
||||
for (const p of parts) cur = cur[p];
|
||||
return { name: node.$ref.split("/").pop(), ...cur };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
const typeStr = schema => {
|
||||
// Returns an HTML-escaped fragment: this is the one spec-derived string
|
||||
// that reaches innerHTML (the type cells), so it must escape like every
|
||||
// other renderer here. The array<...> wrapper composes already-escaped
|
||||
// pieces, so its literal </> are safe.
|
||||
if (!schema) return "";
|
||||
if (schema.$ref) return escapeHtml(schema.$ref.split("/").pop());
|
||||
if (Array.isArray(schema.type) && schema.type.includes("array") && schema.items)
|
||||
return `array<${typeStr(schema.items)}>`;
|
||||
if (schema.type === "array" && schema.items) return `array<${typeStr(schema.items)}>`;
|
||||
const t = Array.isArray(schema.type) ? schema.type.join(" | ") : (schema.type || "");
|
||||
return escapeHtml(t);
|
||||
};
|
||||
|
||||
function schemaTable(schema, depth = 0) {
|
||||
schema = deref(schema);
|
||||
if (schema.allOf) {
|
||||
const merged = { properties: {}, required: [] };
|
||||
for (const part of schema.allOf.map(deref)) {
|
||||
Object.assign(merged.properties, part.properties || {});
|
||||
merged.required.push(...(part.required || []));
|
||||
}
|
||||
schema = merged;
|
||||
}
|
||||
if (!schema.properties) return null;
|
||||
const table = el("table");
|
||||
table.appendChild(el("tr", "", "<th>Field</th><th>Type</th><th>Description</th>"));
|
||||
for (const [name, raw] of Object.entries(schema.properties)) {
|
||||
const prop = raw.$ref ? deref(raw) : raw;
|
||||
const required = (schema.required || []).includes(name);
|
||||
let desc = md(prop.description);
|
||||
if (prop.enum) desc += `<div class="enum">${prop.enum.filter(v => v !== null).map(escapeHtml).join(" | ")}</div>`;
|
||||
const tr = el("tr", "", `
|
||||
<td><span class="schema-name">${escapeHtml(name)}</span>${required ? ' <span class="req">required</span>' : ""}</td>
|
||||
<td><span class="type">${typeStr(raw)}</span></td>
|
||||
<td>${desc}</td>`);
|
||||
table.appendChild(tr);
|
||||
const inner = prop.type === "object" || (Array.isArray(prop.type) && prop.type.includes("object"));
|
||||
if (inner && prop.properties && depth < 2) {
|
||||
const cell = el("td", "", "");
|
||||
cell.colSpan = 3;
|
||||
cell.style.paddingLeft = "24px";
|
||||
cell.appendChild(schemaTable(prop, depth + 1));
|
||||
const row = el("tr");
|
||||
row.appendChild(cell);
|
||||
table.appendChild(row);
|
||||
}
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
function opCard(method, path, op, isWebhook) {
|
||||
const card = el("div", "card");
|
||||
const head = el("div", "op-head");
|
||||
const cls = isWebhook ? "m-webhook" : `m-${method.toLowerCase()}`;
|
||||
head.appendChild(el("span", `method ${cls}`, isWebhook ? "EVENT" : method.toUpperCase()));
|
||||
head.appendChild(el("span", "path", escapeHtml(path)));
|
||||
head.appendChild(el("span", "summary", escapeHtml(op.summary || "")));
|
||||
if (op["x-planned"]) head.appendChild(el("span", "planned", "PLANNED"));
|
||||
const secSchemes = op.security || spec.security || [];
|
||||
const secName = secSchemes.length ? Object.keys(secSchemes[0])[0] : null;
|
||||
if (!isWebhook && secName) head.appendChild(el("span", "badge-auth", escapeHtml(secName)));
|
||||
const body = el("div", "op-body");
|
||||
if (op.description) body.appendChild(el("div", "desc", md(op.description)));
|
||||
|
||||
const params = (op.parameters || []).map(deref);
|
||||
if (params.length) {
|
||||
body.appendChild(el("div", "sec-label", "Parameters"));
|
||||
const table = el("table");
|
||||
table.appendChild(el("tr", "", "<th>Name</th><th>In</th><th>Type</th><th>Description</th>"));
|
||||
for (const p of params) {
|
||||
table.appendChild(el("tr", "", `
|
||||
<td><span class="schema-name">${escapeHtml(p.name)}</span>${p.required ? ' <span class="req">required</span>' : ""}</td>
|
||||
<td>${escapeHtml(p.in)}</td>
|
||||
<td><span class="type">${typeStr(p.schema)}</span></td>
|
||||
<td>${md(p.description)}</td>`));
|
||||
}
|
||||
body.appendChild(table);
|
||||
}
|
||||
|
||||
const reqSchema = op.requestBody && op.requestBody.content &&
|
||||
op.requestBody.content["application/json"] && op.requestBody.content["application/json"].schema;
|
||||
if (reqSchema) {
|
||||
body.appendChild(el("div", "sec-label", `Payload (${typeStr(reqSchema)})`));
|
||||
const table = schemaTable(reqSchema);
|
||||
if (table) body.appendChild(table);
|
||||
}
|
||||
|
||||
body.appendChild(el("div", "sec-label", "Responses"));
|
||||
const rtable = el("table");
|
||||
rtable.appendChild(el("tr", "", "<th>Status</th><th>Description</th><th>Schema</th>"));
|
||||
for (const [code, raw] of Object.entries(op.responses || {})) {
|
||||
const resp = deref(raw);
|
||||
const schema = resp.content && resp.content["application/json"] && resp.content["application/json"].schema;
|
||||
rtable.appendChild(el("tr", "", `
|
||||
<td><span class="schema-name">${escapeHtml(code)}</span></td>
|
||||
<td>${md(resp.description)}</td>
|
||||
<td><span class="type">${schema ? typeStr(schema) : ""}</span></td>`));
|
||||
if (code.startsWith("2") && schema) {
|
||||
const cell = el("td", "", "");
|
||||
cell.colSpan = 3;
|
||||
cell.style.paddingLeft = "24px";
|
||||
const table = schemaTable(schema);
|
||||
if (table) cell.appendChild(table);
|
||||
const row = el("tr");
|
||||
row.appendChild(cell);
|
||||
rtable.appendChild(row);
|
||||
}
|
||||
}
|
||||
body.appendChild(rtable);
|
||||
head.addEventListener("click", () => card.classList.toggle("open"));
|
||||
card.appendChild(head);
|
||||
card.appendChild(body);
|
||||
return card;
|
||||
}
|
||||
|
||||
const app = document.getElementById("app");
|
||||
app.innerHTML = "";
|
||||
app.appendChild(el("h1", "", escapeHtml(spec.info.title)));
|
||||
app.appendChild(el("div", "muted", `v${escapeHtml(spec.info.version)} · ${escapeHtml((spec.servers && spec.servers[0] && spec.servers[0].url) || "")}`));
|
||||
app.appendChild(el("div", "desc", md(spec.info.description)));
|
||||
|
||||
app.appendChild(el("h2", "", "Authentication"));
|
||||
for (const [name, scheme] of Object.entries((spec.components || {}).securitySchemes || {})) {
|
||||
const card = el("div", "card");
|
||||
card.appendChild(el("div", "op-head",
|
||||
`<span class="badge-auth">${escapeHtml(name)}</span>
|
||||
<span class="summary">${escapeHtml(scheme.name)} (${escapeHtml(scheme.in)}) — ${md(scheme.description)}</span>`));
|
||||
app.appendChild(card);
|
||||
}
|
||||
|
||||
app.appendChild(el("h2", "", "Endpoints (this API can be called)"));
|
||||
for (const [path, ops] of Object.entries(spec.paths || {})) {
|
||||
for (const [method, op] of Object.entries(ops)) {
|
||||
app.appendChild(opCard(method, path, op, false));
|
||||
}
|
||||
}
|
||||
|
||||
app.appendChild(el("h2", "", "Outbound webhooks (this API calls you)"));
|
||||
for (const [name, ops] of Object.entries(spec.webhooks || {})) {
|
||||
for (const [method, op] of Object.entries(ops)) {
|
||||
app.appendChild(opCard(method, name, op, true));
|
||||
}
|
||||
}
|
||||
|
||||
app.appendChild(el("h2", "", "Schemas"));
|
||||
for (const [name, schema] of Object.entries((spec.components || {}).schemas || {})) {
|
||||
const card = el("div", "card open");
|
||||
const head = el("div", "op-head");
|
||||
head.appendChild(el("span", "schema-name", escapeHtml(name)));
|
||||
head.appendChild(el("span", "summary", escapeHtml(schema.description || "")));
|
||||
card.appendChild(head);
|
||||
const body = el("div", "op-body");
|
||||
const table = schemaTable(schema);
|
||||
if (table) body.appendChild(table);
|
||||
card.appendChild(body);
|
||||
head.addEventListener("click", () => card.classList.toggle("open"));
|
||||
app.appendChild(card);
|
||||
}
|
||||
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: []
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -27,13 +27,24 @@ logger.setLevel(logging.INFO)
|
|||
_MODULE_DIR = Path(__file__).resolve().parent
|
||||
_SPEC_PATH = _MODULE_DIR / "openapi.json"
|
||||
_DOCS_PATH = _MODULE_DIR / "docs.html"
|
||||
# The docs page template carries this placeholder where the spec JSON is
|
||||
# inlined, so /docs is a single token-gated request (a browser can't attach
|
||||
# the auth header to a follow-up asset fetch).
|
||||
_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"
|
||||
# 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.
|
||||
_SPEC_PLACEHOLDER = "__OPENAPI_SPEC_JSON__"
|
||||
_CSS_PLACEHOLDER = "__SWAGGER_UI_CSS__"
|
||||
_JS_PLACEHOLDER = "__SWAGGER_UI_JS__"
|
||||
_PRESET_PLACEHOLDER = "__SWAGGER_UI_STANDALONE_PRESET_JS__"
|
||||
|
||||
_spec_cache = None
|
||||
_docs_cache = None
|
||||
_docs_shell_cache = None
|
||||
# The server URL committed in openapi.json; swapped for the live invoke URL
|
||||
# (derived from the request) when the spec is served, so the docs page shows a
|
||||
# correct, current endpoint even if the RestApi is recreated.
|
||||
_COMMITTED_SERVER_URL = "https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod"
|
||||
|
||||
_REPO_FUNCS = {
|
||||
"list_work_orders": wo_repo.list_work_orders,
|
||||
|
|
@ -63,26 +74,63 @@ _DOCS_SECURITY_HEADERS = {
|
|||
|
||||
|
||||
def _load_spec() -> str:
|
||||
"""Raw spec bytes, served verbatim at /openapi.json (byte-faithful JSON)."""
|
||||
"""The committed spec text (cached)."""
|
||||
global _spec_cache
|
||||
if _spec_cache is None:
|
||||
_spec_cache = _SPEC_PATH.read_text(encoding="utf-8")
|
||||
return _spec_cache
|
||||
|
||||
|
||||
def _load_docs_html() -> str:
|
||||
global _docs_cache
|
||||
if _docs_cache is None:
|
||||
# Neutralize any "</script"/"<!--" a future spec edit could introduce:
|
||||
# the spec is spliced into a <script type="application/json"> block
|
||||
# where the HTML parser ends the element at the first literal "<"
|
||||
# sequence regardless of JSON quoting. "<" is the same JSON
|
||||
# string value ("<") to JSON.parse but can never close the script tag.
|
||||
inlined_spec = _load_spec().replace("<", "\\u003c")
|
||||
_docs_cache = _DOCS_PATH.read_text(encoding="utf-8").replace(
|
||||
_SPEC_PLACEHOLDER, inlined_spec
|
||||
def _base_url(event: dict) -> str | None:
|
||||
rc = event.get("requestContext") or {}
|
||||
domain, stage = rc.get("domainName"), rc.get("stage")
|
||||
return f"https://{domain}/{stage}" if domain and stage else None
|
||||
|
||||
|
||||
def _spec_for_request(event: dict) -> str:
|
||||
"""Committed spec with servers[0].url set to the live invoke URL when the
|
||||
request context provides it (falls back to the committed literal)."""
|
||||
raw = _load_spec()
|
||||
base = _base_url(event)
|
||||
return raw.replace(_COMMITTED_SERVER_URL, base) if base else raw
|
||||
|
||||
|
||||
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).
|
||||
|
||||
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".
|
||||
"""
|
||||
global _docs_shell_cache
|
||||
if _docs_shell_cache is None:
|
||||
css = _SWAGGER_CSS_PATH.read_text(encoding="utf-8").replace(
|
||||
"</style", "<\\/style"
|
||||
)
|
||||
return _docs_cache
|
||||
js = _SWAGGER_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)
|
||||
)
|
||||
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.
|
||||
spec = _spec_for_request(event).replace("<", "\\u003c")
|
||||
return _docs_shell().replace(_SPEC_PLACEHOLDER, spec)
|
||||
|
||||
|
||||
def _with_token_shim(event: dict) -> dict:
|
||||
|
|
@ -117,7 +165,7 @@ def _docs_response(resource: str, event: dict) -> dict:
|
|||
return {
|
||||
"statusCode": 200,
|
||||
"headers": {"Content-Type": "application/json", **_DOCS_SECURITY_HEADERS},
|
||||
"body": _load_spec(),
|
||||
"body": _spec_for_request(event),
|
||||
}
|
||||
return {
|
||||
"statusCode": 200,
|
||||
|
|
@ -125,7 +173,7 @@ def _docs_response(resource: str, event: dict) -> dict:
|
|||
"Content-Type": "text/html; charset=utf-8",
|
||||
**_DOCS_SECURITY_HEADERS,
|
||||
},
|
||||
"body": _load_docs_html(),
|
||||
"body": _render_docs_html(event),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,12 @@
|
|||
"info": {
|
||||
"title": "Procurement Ingest API",
|
||||
"version": "1.0.0",
|
||||
"description": "Read API over the procurement-ingest pipelines (work orders + purchase orders), plus the outbound SHOC work-order webhook feed (see `webhooks`).\n\n**Purpose:** reconciliation and backfill for downstream consumers (primarily SHOC) - this API replaces SHOC's retired SyncController DynamoDB scan. Listings are **unordered** paginated scans: follow `next_cursor` until it is `null`. `cursor` is opaque; a malformed cursor returns `400`. Field names mirror the DynamoDB attributes written by the pipelines (source of truth: `lambdas/wo/email_processor/persistence.py` and `lambdas/po/email_processor/persistence.py`).\n\n**Auth:** data endpoints require AWS IAM SigV4 (service `execute-api`, region `us-east-1`); cross-account callers must also be allowed by the API resource policy. `/docs` and `/openapi.json` use the shared docs token instead (header `X-Auth-Token`, or `?token=` in a browser). No CORS is configured (server-to-server and Postman callers only).\n\n**Write endpoints** marked `x-planned` are phase 2: documented here for contract visibility, the API answers `501` until they ship."
|
||||
"description": "Read API over the procurement-ingest pipelines (work orders and purchase orders). The outbound SHOC work-order webhook feed is documented under **webhooks** below.\n\nData endpoints use AWS IAM SigV4; the `/docs` and `/openapi.json` routes use a shared token. Listings are unordered, cursor-paginated scans. Endpoints tagged **x-planned** are phase 2 and currently answer `501`.\n\nThis API replaces SHOC's retired SyncController DynamoDB scan as the reconciliation and backfill path. Full detail: the repo README and `docs/shoc-webhook-contract.md`."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://{apiId}.execute-api.us-east-1.amazonaws.com/prod",
|
||||
"description": "seahaven-prod (011934824531). The concrete apiId is in the procurement-api stack output ApiEndpointUrl.",
|
||||
"variables": {
|
||||
"apiId": {
|
||||
"default": "SEE-STACK-OUTPUT"
|
||||
}
|
||||
}
|
||||
"url": "https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod",
|
||||
"description": "seahaven-prod (011934824531)"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
|
|
|||
2
lambdas/api/swagger-ui-bundle.js
Normal file
2
lambdas/api/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
2
lambdas/api/swagger-ui-standalone-preset.js
Normal file
2
lambdas/api/swagger-ui-standalone-preset.js
Normal file
File diff suppressed because one or more lines are too long
3
lambdas/api/swagger-ui.css
Normal file
3
lambdas/api/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -113,8 +113,17 @@ 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"]
|
||||
assert "Procurement Ingest API" in docs["body"]
|
||||
assert mod._SPEC_PLACEHOLDER not 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,
|
||||
):
|
||||
assert placeholder not in docs["body"]
|
||||
|
||||
spec = mod.handler(_event("GET", "/openapi.json"), None)
|
||||
assert spec["statusCode"] == 200
|
||||
|
|
|
|||
|
|
@ -105,10 +105,30 @@ 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 either file is
|
||||
# missing from lambdas/api/ the bundle would 500 at runtime.
|
||||
# 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
|
||||
# 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()
|
||||
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
|
||||
# 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(
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -107,11 +107,14 @@ API_PIPELINE_MODULES = frozenset(
|
|||
}
|
||||
)
|
||||
# Non-.py files the api bundle must also EXECUTE cps for: the handler serves
|
||||
# the spec and docs page from its own package dir, so dropping either cp
|
||||
# 500s /docs at runtime with green CI.
|
||||
# the spec, docs page, and the vendored Swagger UI assets 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|$)",
|
||||
)
|
||||
|
||||
# Pipeline-scoped glob shapes the per-stack ships-all pins accept. Phase 2
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue