"""Procurement API Lambda (API Gateway REST proxy integration).
Auth is split by route class and enforced at two layers:
- Data routes: AWS_IAM at the gateway (SigV4; cross-account callers allowed by
the API resource policy). The handler does NOT re-check a token there --
authorization is API Gateway's job on those routes.
- Docs routes (/docs, /openapi.json): reachable at the gateway (auth NONE +
resource-policy carve-out) but the handler fails closed on the shared
header token via web_ui_auth (same secret + constant-time compare as the
web UIs), so they are never an unauthenticated data path (INFRA-74).
"""
import logging
from pathlib import Path
import po_repo
import sentry_init # noqa: F401
import wo_repo
from botocore.exceptions import ClientError
from pagination import BadCursor, clamp_limit
from router import DATA_ROUTES, DOCS_ROUTES, PLANNED_ROUTES
from serialization import error_response, json_response
from web_ui_auth import is_authenticated
logger = logging.getLogger()
logger.setLevel(logging.INFO)
_MODULE_DIR = Path(__file__).resolve().parent
_SPEC_PATH = _MODULE_DIR / "openapi.json"
_DOCS_PATH = _MODULE_DIR / "docs.html"
_REDOC_JS_PATH = _MODULE_DIR / "redoc.standalone.js"
_FONTS_CSS_PATH = _MODULE_DIR / "fonts.css"
# 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 vendored Redoc bundle, the
# design-system fonts, and the spec inlined, no CDN.
_SPEC_PLACEHOLDER = "__OPENAPI_SPEC_JSON__"
_JS_PLACEHOLDER = "__REDOC_JS__"
_FONTS_PLACEHOLDER = "__FONTS_CSS__"
_spec_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,
"get_work_order": wo_repo.get_work_order,
"list_comments": wo_repo.list_comments,
"list_purchase_orders": po_repo.list_purchase_orders,
"get_purchase_order": po_repo.get_purchase_order,
"list_verified_sites": po_repo.list_verified_sites,
"get_verified_site": po_repo.get_verified_site,
}
_PATH_PARAM_BY_RESOURCE = {
"/work-orders/{workOrderId}": "workOrderId",
"/work-orders/{workOrderId}/comments": "workOrderId",
"/purchase-orders/{poNumber}": "poNumber",
"/verified-sites/{siteCode}": "siteCode",
}
# Headers on the docs responses: the token rides in the ?token= query shim,
# so keep the token-keyed URL and page out of shared/browser caches and out of
# any Referer sent to a followed link.
_DOCS_SECURITY_HEADERS = {
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
}
def _load_spec() -> str:
"""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 _base_url(event: dict) -> str | None:
rc = event.get("requestContext") or {}
domain, stage = rc.get("domainName"), rc.get("stage")
if not domain:
return None
# A custom domain (base-path mapping) serves the stage at the root, so the
# public URL has NO /{stage} segment; only the raw execute-api host carries
# it. Emitting /{stage} for a custom-domain request would advertise a
# broken server URL in the docs. All API Gateway default domains end with
# .amazonaws.com, so check the suffix rather than a substring.
if domain.endswith(".amazonaws.com"):
return f"https://{domain}/{stage}" if stage else None
return f"https://{domain}"
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 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 "<\\/script" (equivalent
inside a JS string/regex), CSS " "<\\/style".
"""
global _docs_shell_cache
if _docs_shell_cache is None:
js = _REDOC_JS_PATH.read_text(encoding="utf-8").replace(
" str:
# "<" -> < so a spec string can never close the