feat(api): stock Swagger UI for /docs (vendored offline) (#128)
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:
Adam Moussa 2026-07-23 20:19:47 -04:00 • committed by GitHub
parent f67d8b9907
commit 8156b275a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 166 additions and 253 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) | 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`

View file

@ -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__",
],

View file

@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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 &lt;/&gt; 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&lt;${typeStr(schema.items)}&gt;`;
if (schema.type === "array" && schema.items) return `array&lt;${typeStr(schema.items)}&gt;`;
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)} &middot; ${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)}) &mdash; ${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>

View file

@ -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),
}

View file

@ -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": [

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,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

View file

@ -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")

View file

@ -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