diff --git a/README.md b/README.md index 929ee75..651f2ca 100644 --- a/README.md +++ b/README.md @@ -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 /email_processor/*.py` diff --git a/cdk/procurement_api_stack.py b/cdk/procurement_api_stack.py index fccb655..3231458 100644 --- a/cdk/procurement_api_stack.py +++ b/cdk/procurement_api_stack.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__", ], diff --git a/lambdas/api/docs.html b/lambdas/api/docs.html index 06f5aea..0c9fecf 100644 --- a/lambdas/api/docs.html +++ b/lambdas/api/docs.html @@ -4,226 +4,51 @@ Procurement Ingest API + + -

Loading spec…

- +
+ + diff --git a/lambdas/api/handler.py b/lambdas/api/handler.py index 283bcb8..ca9a91b 100644 --- a/lambdas/api/handler.py +++ b/lambdas/api/handler.py @@ -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 " 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