mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 03:43:12 +00:00
feat(api): Redocly lint gate + SHOC-themed /docs (Redoc theming, topbar, collapsible samples) (#130)
Some checks are pending
Deploy / deploy (push) Waiting to run
Some checks are pending
Deploy / deploy (push) Waiting to run
* feat(api): Add @redocly/cli as a dev dependency
Signed-off-by: Adam Moussa <adam@seahavenind.com>
* feat(api): Add Redocly configuration file with custom rules
Signed-off-by: Adam Moussa <adam@seahavenind.com>
* chore(api): Redocly lint config + bring openapi.json into compliance
redocly.yaml from the Redocly guidelines builder, with three generated
rules corrected: response-contains-property had the status codes as the
required body fields (intent was the Error schema's top-level 'error';
403 exempt since API Gateway emits AWS's {message} shape, 501 not 503);
operation-4xx-problem-details-rfc7807 off (adopting RFC 7807 would be a
runtime + SHOC-contract change, decided against); the two inert casing
rules (parameter names, schema properties) removed because both name
sets are contract-pinned (gateway resource paths, DynamoDB items).
Spec changes, no runtime impact: operationIds renamed to method-prefixed
kebab-case (get-work-orders, post-work-order-comment, ...); tags added to
all 15 operations + root tags object (groups the Redoc sidebar); examples
on all six parameters; license field; server description punctuation; two
descriptions reworded to start capitalized. Real linter catches fixed:
the two x-planned ops were missing their {workOrderId} path parameter
and any 4xx response (403 added - true today, gateway rejects unsigned).
.redocly.lint-ignore.yaml pins the six deliberate exceptions: webhook
keys are the shipped SHOC contract event names (not renameable), and the
x-planned ops answer only 501 (no 2xx to document).
package.json: npm run lint:api. Verified: lint 0 errors, 675 pytest,
headless-Chrome render of the tagged docs page.
* feat(api): SHOC design-system theme for /docs (vendored fonts)
Themes the Redoc page with the canonical SHOC token set: Montserrat 600
headings / DM Sans body / JetBrains Mono code, primary #1c75bc, navy
#262262 sidebar text + right panel, #f9fafb background, 244px sidebar.
sortRequiredPropsFirst on; 200 responses pre-expanded.
Fonts ship as lambdas/api/fonts.css (latin woff2 subsets from
@fontsource 5.3.0, embedded as data URIs, ~90KB) and inline via a new
__FONTS_CSS__ placeholder with the same </style breakout guard --
the offline single-response invariant holds, nothing fetches Google
Fonts (test-pinned). Bundling cp + bundle-consistency pin + spec-drift
asset checks extended.
Verified: headless-Chrome render (theme + fonts applied), ruff, 675
pytest, cdk synth + staged-asset check.
* feat(api): SHOC gradient topbar on /docs
64px fixed header with the SHOC shell gradient token (#1b1f52 ->
#1c4f8f -> #1c75bc), Sea Haven wordmark in Montserrat 600, page name
right-aligned in DM Sans. Redoc's scrollYOffset: 64 keeps the sticky
sidebar and anchor scrolling clear of the fixed bar. Verified via
headless-Chrome render.
* style(api): normalize /docs header and right-panel blues
The right panel's #262262 is a purple-leaning navy that clashed with
the cyan-leaning gradient, and the bar's brightest point sat directly
over the dark panel. Right panel now uses #1b1f52 (the gradient's own
dark endpoint) and the gradient runs bright-to-dark so its dark end
lands flush on the panel -- no seam, one blue family. Verified via
headless-Chrome render.
* style(api): right-panel gradient on /docs via bundle-pinned override
Redoc's theme only takes solid colors (it derives shades from
rightPanel.backgroundColor), so the gradient (#1b3d79 -> #1b3068 ->
#1b1f52, continuing the topbar blend) rides as a CSS override on the
styled-components classes of the per-section right-panel divs
(.sc-iGgWBj.sc-gsFSXq + the .sc-dExYaf stub). Those names are
deterministic for the vendored 2.5.3 bundle (verified across loads) but
change on any Redoc bump: re-derive via headless probe (find elements
whose computed background equals the rightPanel color). If they stop
matching, the panel falls back to the solid #1b1f52 theme color --
cosmetic only. Verified via headless-Chrome render.
* feat(api): collapsible samples column on /docs
Redoc CE has no built-in panel toggle, so the topbar gains a Hide/Show
samples button that flips .samples-collapsed on <html>: the right-panel
divs hide (same bundle-pinned styled-components classes as the gradient
override) and each section's content half takes the full width. Choice
persists in localStorage; aria-pressed tracks state. If the pinned
classes stop matching after a Redoc bump the toggle goes inert --
cosmetic only. Both states verified via headless-Chrome render.
* ci(api): spec-lint CI gate + npm Dependabot coverage
New spec-lint job mirrors the local npm run lint:api so openapi.json
cannot drift from redocly.yaml with green CI. Dependabot gains the npm
ecosystem (package.json is new; nothing watched @redocly/cli).
* feat(api): docs finishing touches - x-tagGroups, favicon, docs:preview
x-tagGroups sections the Redoc sidebar (Read API / Meta / SHOC Feed);
inline data-URI SVG favicon (SHOC blue) stops the browser's follow-up
/favicon.ico request 403ing at the gateway; npm run docs:preview wraps
the real-handler local render (scripts/preview_docs.py); README gains a
docs-page architecture section covering the inline pattern, theme,
pinned-selector caveat, and tooling. Lint 0 errors, 675 pytest,
headless render verified.
---------
Signed-off-by: Adam Moussa <adam@seahavenind.com>
This commit is contained in:
parent
24c89b43e5
commit
b89e98a879
16 changed files with 533 additions and 44 deletions
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
|
|
@ -72,3 +72,12 @@ updates:
|
||||||
update-types:
|
update-types:
|
||||||
- "minor"
|
- "minor"
|
||||||
- "patch"
|
- "patch"
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
|
|
||||||
12
.github/workflows/ci.yaml
vendored
12
.github/workflows/ci.yaml
vendored
|
|
@ -14,3 +14,15 @@ jobs:
|
||||||
run-tests: true
|
run-tests: true
|
||||||
run-cdk-synth: true
|
run-cdk-synth: true
|
||||||
run-sam-validate: false
|
run-sam-validate: false
|
||||||
|
# The published OpenAPI contract must stay compliant with redocly.yaml;
|
||||||
|
# this is the CI mirror of the local `npm run lint:api`.
|
||||||
|
spec-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint:api
|
||||||
|
|
|
||||||
17
.redocly.lint-ignore.yaml
Normal file
17
.redocly.lint-ignore.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API.
|
||||||
|
# See https://redocly.com/docs/cli/ for more information.
|
||||||
|
#
|
||||||
|
# operation-2xx-response: the two x-planned phase-2 write endpoints answer
|
||||||
|
# ONLY 501 until built -- documenting a 2xx they cannot return would lie.
|
||||||
|
# paths-kebab-case: webhook keys are the shipped SHOC contract event names
|
||||||
|
# (docs/shoc-webhook-contract.md Rev 2026-07-23, event_type enum) -- not
|
||||||
|
# renameable without a contract break.
|
||||||
|
lambdas/api/openapi.json:
|
||||||
|
operation-2xx-response:
|
||||||
|
- '#/paths/~1work-orders~1{workOrderId}/patch/responses'
|
||||||
|
- '#/paths/~1work-orders~1{workOrderId}~1comments/post/responses'
|
||||||
|
paths-kebab-case:
|
||||||
|
- '#/webhooks/work_order.created'
|
||||||
|
- '#/webhooks/work_order.updated'
|
||||||
|
- '#/webhooks/work_order.cancelled'
|
||||||
|
- '#/webhooks/work_order.comment_added'
|
||||||
|
|
@ -103,6 +103,7 @@ A read-only REST API (API Gateway + the `procurement-api` Lambda, `lambdas/api/`
|
||||||
- **KMS:** `purchase-orders` is CMK-encrypted; the imported-by-name table doesn't carry the key association, so the stack grants `kms:Decrypt`/`DescribeKey` on the CMK from SSM `/seahaven/dynamodb/cmk-arn` explicitly (the INFRA-104 failure class).
|
- **KMS:** `purchase-orders` is CMK-encrypted; the imported-by-name table doesn't carry the key association, so the stack grants `kms:Decrypt`/`DescribeKey` on the CMK from SSM `/seahaven/dynamodb/cmk-arn` explicitly (the INFRA-104 failure class).
|
||||||
- **No access logging in v1** (keeps the `?token=` shim out of any log and avoids the account-level API Gateway CloudWatch role); rotate the docs token before ever enabling it. No CORS (server-to-server + Postman callers).
|
- **No access logging in v1** (keeps the `?token=` shim out of any log and avoids the account-level API Gateway CloudWatch role); rotate the docs token before ever enabling it. No CORS (server-to-server + Postman callers).
|
||||||
- **Alarms:** `procurement-api-errors`/`-throttles`/`-duration` (p99 ≥ 22.5 s) + gateway `procurement-api-5xx`, all ALARM-only → `site-alerts`. No 4XX alarm (401/403/404 are expected traffic).
|
- **Alarms:** `procurement-api-errors`/`-throttles`/`-duration` (p99 ≥ 22.5 s) + gateway `procurement-api-5xx`, all ALARM-only → `site-alerts`. No 4XX alarm (401/403/404 are expected traffic).
|
||||||
|
- **Docs page:** `/docs` serves Redoc (read-only reference docs; live calls go through Postman since data routes need SigV4) as ONE token-gated response: the handler inlines the vendored `redoc.standalone.js`, the design-system fonts (`fonts.css`, data-URI `@font-face` — nothing may fetch from Google Fonts, test-pinned), and the spec into `docs.html`, with `</script>`/`</style>` breakout guards on every blob. Theme = SHOC tokens (Montserrat/DM Sans/JetBrains Mono, primary `#1c75bc`, 64px gradient topbar). The right-panel gradient and the topbar's Hide/Show-samples toggle target styled-components class names that are deterministic for the pinned Redoc bundle but change on any bump — re-derive them then (headless probe: elements whose computed background equals the `rightPanel` color); stale selectors degrade to a solid panel / inert toggle, cosmetic only. Tooling: `npm run lint:api` lints the spec against `redocly.yaml` (CI job `spec-lint`; deliberate exceptions live in `.redocly.lint-ignore.yaml`), `npm run docs:preview` renders the real handler output locally.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -436,8 +437,9 @@ lambdas/ # Phase 2: shared Code.from_asset("../lambdas") bundling
|
||||||
wo_repo.py # WorkOrders/WorkOrderComments reads (paginated Scan / PK Query)
|
wo_repo.py # WorkOrders/WorkOrderComments reads (paginated Scan / PK Query)
|
||||||
po_repo.py # purchase-orders/verified-sites reads (no VendorReplies -- dead table)
|
po_repo.py # purchase-orders/verified-sites reads (no VendorReplies -- dead table)
|
||||||
openapi.json # OpenAPI 3.1 source of truth (paths + outbound `webhooks` section)
|
openapi.json # OpenAPI 3.1 source of truth (paths + outbound `webhooks` section)
|
||||||
docs.html # Redoc shell; handler inlines the bundle/spec at request time (single token-gated request, no CDN)
|
docs.html # Redoc shell, SHOC design-system theme; handler inlines bundle/fonts/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
|
redoc.standalone.js # vendored Redoc bundle (redoc 2.5.3, MIT), inlined into /docs; read-only docs, no try-it-out
|
||||||
|
fonts.css # SHOC fonts (DM Sans/Montserrat/JetBrains Mono, @fontsource latin subsets) as data URIs, inlined into /docs
|
||||||
po/ # PO pipeline Lambdas
|
po/ # PO pipeline Lambdas
|
||||||
email_processor/ # Phase 5: God-handler decomposed into flat siblings (bare-name
|
email_processor/ # Phase 5: God-handler decomposed into flat siblings (bare-name
|
||||||
# imports; the Phase 0/2/3 `cp <pipeline>/email_processor/*.py`
|
# imports; the Phase 0/2/3 `cp <pipeline>/email_processor/*.py`
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ class ProcurementApiStack(Stack):
|
||||||
"cp api/openapi.json /asset-output/ && "
|
"cp api/openapi.json /asset-output/ && "
|
||||||
"cp api/docs.html /asset-output/ && "
|
"cp api/docs.html /asset-output/ && "
|
||||||
"cp api/redoc.standalone.js /asset-output/ && "
|
"cp api/redoc.standalone.js /asset-output/ && "
|
||||||
|
"cp api/fonts.css /asset-output/ && "
|
||||||
"cp shared/web_ui_auth.py /asset-output/ && "
|
"cp shared/web_ui_auth.py /asset-output/ && "
|
||||||
"rm -rf /asset-output/__pycache__",
|
"rm -rf /asset-output/__pycache__",
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -4,38 +4,159 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Procurement Ingest API</title>
|
<title>Procurement Ingest API</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3E%3Crect%20width='16'%20height='16'%20rx='3'%20fill='%231c75bc'/%3E%3Ctext%20x='8'%20y='12'%20font-size='10'%20font-family='sans-serif'%20font-weight='700'%20text-anchor='middle'%20fill='white'%3ES%3C/text%3E%3C/svg%3E">
|
||||||
<!--
|
<!--
|
||||||
Redoc (redoc.standalone.js, MIT), vendored offline and inlined server-side
|
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
|
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
|
fetch. The bundle, the SHOC design-system fonts (fonts.css, data-URI
|
||||||
below by lambdas/api/handler.py::_load_docs_html. Redoc is read-only by
|
@font-face), and the OpenAPI spec are substituted for the placeholders
|
||||||
design -- there is no try-it-out to disable; live calls go through Postman
|
below by lambdas/api/handler.py::_docs_shell / _render_docs_html. Redoc is
|
||||||
(Authorization type "AWS Signature") since data routes require SigV4.
|
read-only by design -- there is no try-it-out to disable; live calls go
|
||||||
The theme pins a system font stack: Redoc defaults to Roboto, which is not
|
through Postman (Authorization type "AWS Signature") since data routes
|
||||||
vendored and must not be fetched from Google Fonts.
|
require SigV4.
|
||||||
|
Theme = the SHOC design-system token set (the Sea Haven visual standard):
|
||||||
|
Montserrat headings / DM Sans body / JetBrains Mono code, primary #1c75bc,
|
||||||
|
navy #262262, page background #f9fafb, 244px sidebar. Fonts are vendored --
|
||||||
|
nothing here may fetch from Google Fonts.
|
||||||
-->
|
-->
|
||||||
|
<style>__FONTS_CSS__</style>
|
||||||
<style>
|
<style>
|
||||||
html, body { margin: 0; padding: 0; }
|
html, body { margin: 0; padding: 0; background: #f9fafb; }
|
||||||
|
/* SHOC shell topbar: 64px, gradient header token. Redoc has no topbar of
|
||||||
|
its own; scrollYOffset below keeps its sticky sidebar/scroll accounting
|
||||||
|
clear of this fixed bar. Gradient runs bright-to-dark (reversed from the
|
||||||
|
SHOC shell) so its dark end (#1b1f52) sits flush over the right panel,
|
||||||
|
which uses the same color -- one continuous blue family, no seam. */
|
||||||
|
#topbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 64px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: linear-gradient(90deg, #1c75bc 0%, #1c4f8f 45%, #1b1f52 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: "Montserrat", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
#topbar .brand {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
#topbar .page {
|
||||||
|
margin-left: 16px;
|
||||||
|
font-family: "DM Sans", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
#samples-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 5px 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: "DM Sans", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#samples-toggle:hover { border-color: #ffffff; background: rgba(255, 255, 255, 0.1); }
|
||||||
|
#redoc { padding-top: 64px; }
|
||||||
|
/* Collapsible samples column: Redoc CE has no built-in panel toggle, so
|
||||||
|
the topbar button flips .samples-collapsed on <html>, hiding the
|
||||||
|
right-panel divs (same bundle-pinned classes as the gradient below)
|
||||||
|
and letting each section's content half take the full width. If the
|
||||||
|
pinned classes stop matching after a Redoc bump the toggle goes inert
|
||||||
|
-- cosmetic only. Choice persists in localStorage. */
|
||||||
|
html.samples-collapsed .sc-iGgWBj.sc-gsFSXq,
|
||||||
|
html.samples-collapsed div.sc-dExYaf { display: none; }
|
||||||
|
html.samples-collapsed [data-section-id] > div:first-child { width: 100%; }
|
||||||
|
/* Right-panel gradient (#1b3d79 -> #1b1f52, continuing the topbar blend).
|
||||||
|
Redoc's theme only accepts solid colors (it derives shades from
|
||||||
|
rightPanel.backgroundColor), so the solid #1b1f52 stays in the theme as
|
||||||
|
the fallback and this override layers the gradient on top.
|
||||||
|
BUNDLE-PINNED SELECTORS: .sc-iGgWBj.sc-gsFSXq are the styled-components
|
||||||
|
classes of the per-section right-panel divs (plus .sc-dExYaf, the
|
||||||
|
full-height background stub) generated by the vendored
|
||||||
|
redoc.standalone.js 2.5.3. They are deterministic for this exact bundle
|
||||||
|
but WILL change on any Redoc bump -- re-derive them then (headless probe:
|
||||||
|
find elements whose computed background is the rightPanel color). If
|
||||||
|
they stop matching, the panel silently falls back to solid #1b1f52 --
|
||||||
|
cosmetic only, nothing breaks. Every stripe shares the same horizontal
|
||||||
|
geometry, so the per-section gradients read as one continuous column. */
|
||||||
|
.sc-iGgWBj.sc-gsFSXq,
|
||||||
|
div.sc-dExYaf {
|
||||||
|
background-image: linear-gradient(90deg, #1b3d79 0%, #1b3068 45%, #1b1f52 100%);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="topbar">
|
||||||
|
<span class="brand">Sea Haven Industries</span>
|
||||||
|
<button id="samples-toggle" type="button" aria-pressed="false">Hide samples</button>
|
||||||
|
<span class="page">Procurement Ingest API</span>
|
||||||
|
</div>
|
||||||
<div id="redoc"></div>
|
<div id="redoc"></div>
|
||||||
<script>__REDOC_JS__</script>
|
<script>__REDOC_JS__</script>
|
||||||
<script>
|
<script>
|
||||||
Redoc.init(
|
Redoc.init(
|
||||||
__OPENAPI_SPEC_JSON__,
|
__OPENAPI_SPEC_JSON__,
|
||||||
{
|
{
|
||||||
|
sortRequiredPropsFirst: true,
|
||||||
|
expandResponses: "200",
|
||||||
|
scrollYOffset: 64,
|
||||||
theme: {
|
theme: {
|
||||||
|
colors: {
|
||||||
|
primary: { main: "#1c75bc" }
|
||||||
|
},
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: 'system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif',
|
fontFamily: '"DM Sans", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif',
|
||||||
|
fontWeightBold: "600",
|
||||||
headings: {
|
headings: {
|
||||||
fontFamily: 'system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif'
|
fontFamily: '"Montserrat", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif',
|
||||||
|
fontWeight: "600"
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
width: "244px",
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
textColor: "#262262"
|
||||||
|
},
|
||||||
|
rightPanel: {
|
||||||
|
backgroundColor: "#1b1f52"
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
backgroundColor: "#1c75bc"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
document.getElementById("redoc")
|
document.getElementById("redoc")
|
||||||
);
|
);
|
||||||
|
(function () {
|
||||||
|
var KEY = "procurement-docs-samples-collapsed";
|
||||||
|
var btn = document.getElementById("samples-toggle");
|
||||||
|
function apply(collapsed) {
|
||||||
|
document.documentElement.classList.toggle("samples-collapsed", collapsed);
|
||||||
|
btn.textContent = collapsed ? "Show samples" : "Hide samples";
|
||||||
|
btn.setAttribute("aria-pressed", String(collapsed));
|
||||||
|
}
|
||||||
|
var initial = false;
|
||||||
|
try { initial = localStorage.getItem(KEY) === "1"; } catch (e) {}
|
||||||
|
apply(initial);
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var collapsed = !document.documentElement.classList.contains("samples-collapsed");
|
||||||
|
try { localStorage.setItem(KEY, collapsed ? "1" : "0"); } catch (e) {}
|
||||||
|
apply(collapsed);
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
37
lambdas/api/fonts.css
Normal file
37
lambdas/api/fonts.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -28,12 +28,14 @@ _MODULE_DIR = Path(__file__).resolve().parent
|
||||||
_SPEC_PATH = _MODULE_DIR / "openapi.json"
|
_SPEC_PATH = _MODULE_DIR / "openapi.json"
|
||||||
_DOCS_PATH = _MODULE_DIR / "docs.html"
|
_DOCS_PATH = _MODULE_DIR / "docs.html"
|
||||||
_REDOC_JS_PATH = _MODULE_DIR / "redoc.standalone.js"
|
_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
|
# 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
|
# /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 + spec
|
# header to a follow-up asset fetch) with the vendored Redoc bundle, the
|
||||||
# inlined, no CDN.
|
# design-system fonts, and the spec inlined, no CDN.
|
||||||
_SPEC_PLACEHOLDER = "__OPENAPI_SPEC_JSON__"
|
_SPEC_PLACEHOLDER = "__OPENAPI_SPEC_JSON__"
|
||||||
_JS_PLACEHOLDER = "__REDOC_JS__"
|
_JS_PLACEHOLDER = "__REDOC_JS__"
|
||||||
|
_FONTS_PLACEHOLDER = "__FONTS_CSS__"
|
||||||
|
|
||||||
_spec_cache = None
|
_spec_cache = None
|
||||||
_docs_shell_cache = None
|
_docs_shell_cache = None
|
||||||
|
|
@ -96,19 +98,24 @@ def _docs_shell() -> str:
|
||||||
is left intact so the per-request server-injected spec splices in cheaply
|
is left intact so the per-request server-injected spec splices in cheaply
|
||||||
(the heavy ~1.1MB bundle is assembled once and cached).
|
(the heavy ~1.1MB bundle is assembled once and cached).
|
||||||
|
|
||||||
The bundle is spliced into a <script> block, where the HTML parser ends
|
Each blob is spliced into a <style>/<script> block, where the HTML parser
|
||||||
the element at the first literal "</script" regardless of quoting, so
|
ends the element at the first literal "</style"/"</script" regardless of
|
||||||
guard against a breakout a future asset update could add (the pinned
|
quoting, so guard against a breakout a future asset update could add (the
|
||||||
bundle has none today): "</script" -> "<\\/script" (equivalent inside a
|
pinned assets have none today): JS "</script" -> "<\\/script" (equivalent
|
||||||
JS string/regex).
|
inside a JS string/regex), CSS "</style" -> "<\\/style".
|
||||||
"""
|
"""
|
||||||
global _docs_shell_cache
|
global _docs_shell_cache
|
||||||
if _docs_shell_cache is None:
|
if _docs_shell_cache is None:
|
||||||
js = _REDOC_JS_PATH.read_text(encoding="utf-8").replace(
|
js = _REDOC_JS_PATH.read_text(encoding="utf-8").replace(
|
||||||
"</script", "<\\/script"
|
"</script", "<\\/script"
|
||||||
)
|
)
|
||||||
_docs_shell_cache = _DOCS_PATH.read_text(encoding="utf-8").replace(
|
fonts = _FONTS_CSS_PATH.read_text(encoding="utf-8").replace(
|
||||||
_JS_PLACEHOLDER, js
|
"</style", "<\\/style"
|
||||||
|
)
|
||||||
|
_docs_shell_cache = (
|
||||||
|
_DOCS_PATH.read_text(encoding="utf-8")
|
||||||
|
.replace(_JS_PLACEHOLDER, js)
|
||||||
|
.replace(_FONTS_PLACEHOLDER, fonts)
|
||||||
)
|
)
|
||||||
return _docs_shell_cache
|
return _docs_shell_cache
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,15 @@
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Procurement Ingest API",
|
"title": "Procurement Ingest API",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"license": {
|
||||||
|
"name": "Proprietary (Sea Haven Industries, internal)"
|
||||||
|
},
|
||||||
"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`."
|
"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": [
|
"servers": [
|
||||||
{
|
{
|
||||||
"url": "https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod",
|
"url": "https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod",
|
||||||
"description": "seahaven-prod (011934824531)"
|
"description": "seahaven-prod (011934824531)."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": [
|
"security": [
|
||||||
|
|
@ -16,10 +19,47 @@
|
||||||
"sigv4": []
|
"sigv4": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "Work Orders",
|
||||||
|
"description": "Work orders ingested from APM emails (WorkOrders and WorkOrderComments tables)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Purchase Orders",
|
||||||
|
"description": "Coupa purchase orders ingested from PO emails (purchase-orders table)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Verified Sites",
|
||||||
|
"description": "Amazon site directory maintained by the PO site extractor (verified-sites table)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Docs",
|
||||||
|
"description": "This documentation page and the raw OpenAPI document (token-gated)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Outbound Webhooks",
|
||||||
|
"description": "Events pushed by workorder-shoc-emitter to the configured SHOC endpoint."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-tagGroups": [
|
||||||
|
{
|
||||||
|
"name": "Read API",
|
||||||
|
"tags": ["Work Orders", "Purchase Orders", "Verified Sites"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Meta",
|
||||||
|
"tags": ["Docs"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SHOC Feed",
|
||||||
|
"tags": ["Outbound Webhooks"]
|
||||||
|
}
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"/work-orders": {
|
"/work-orders": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listWorkOrders",
|
"operationId": "get-work-orders",
|
||||||
|
"tags": ["Work Orders"],
|
||||||
"summary": "List work orders (unordered, paginated)",
|
"summary": "List work orders (unordered, paginated)",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -63,7 +103,8 @@
|
||||||
},
|
},
|
||||||
"/work-orders/{workOrderId}": {
|
"/work-orders/{workOrderId}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getWorkOrder",
|
"operationId": "get-work-order",
|
||||||
|
"tags": ["Work Orders"],
|
||||||
"summary": "Get one work order",
|
"summary": "Get one work order",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -91,10 +132,19 @@
|
||||||
},
|
},
|
||||||
"patch": {
|
"patch": {
|
||||||
"x-planned": true,
|
"x-planned": true,
|
||||||
"operationId": "patchWorkOrder",
|
"operationId": "patch-work-order",
|
||||||
|
"tags": ["Work Orders"],
|
||||||
"summary": "PLANNED (phase 2): update dispatch fields on a work order",
|
"summary": "PLANNED (phase 2): update dispatch fields on a work order",
|
||||||
"description": "Not implemented - returns 501. Phase-2 write-back for SHOC dispatch workflow (status/assignment). Writes will stamp `write_origin: shoc-write-api` so the outbound webhook never echoes SHOC's own writes back at it. Ships with its own IAM diff and cross-family review.",
|
"description": "Not implemented - returns 501. Phase-2 write-back for SHOC dispatch workflow (status/assignment). Writes will stamp `write_origin: shoc-write-api` so the outbound webhook never echoes SHOC's own writes back at it. Ships with its own IAM diff and cross-family review.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/WorkOrderId"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
},
|
||||||
"501": {
|
"501": {
|
||||||
"$ref": "#/components/responses/NotImplemented"
|
"$ref": "#/components/responses/NotImplemented"
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +153,8 @@
|
||||||
},
|
},
|
||||||
"/work-orders/{workOrderId}/comments": {
|
"/work-orders/{workOrderId}/comments": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listWorkOrderComments",
|
"operationId": "get-work-order-comments",
|
||||||
|
"tags": ["Work Orders"],
|
||||||
"summary": "List comments/events for a work order (paginated)",
|
"summary": "List comments/events for a work order (paginated)",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -149,10 +200,19 @@
|
||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
"x-planned": true,
|
"x-planned": true,
|
||||||
"operationId": "createWorkOrderComment",
|
"operationId": "post-work-order-comment",
|
||||||
|
"tags": ["Work Orders"],
|
||||||
"summary": "PLANNED (phase 2): append a SHOC-authored comment",
|
"summary": "PLANNED (phase 2): append a SHOC-authored comment",
|
||||||
"description": "Not implemented - returns 501. Phase-2 write-back: SHOC dispatch notes land in WorkOrderComments with `write_origin: shoc-write-api` (append-only; no field conflicts with the email pipeline). Ships with its own IAM diff and cross-family review.",
|
"description": "Not implemented - returns 501. Phase-2 write-back: SHOC dispatch notes land in WorkOrderComments with `write_origin: shoc-write-api` (append-only; no field conflicts with the email pipeline). Ships with its own IAM diff and cross-family review.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/WorkOrderId"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
},
|
||||||
"501": {
|
"501": {
|
||||||
"$ref": "#/components/responses/NotImplemented"
|
"$ref": "#/components/responses/NotImplemented"
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +221,8 @@
|
||||||
},
|
},
|
||||||
"/purchase-orders": {
|
"/purchase-orders": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listPurchaseOrders",
|
"operationId": "get-purchase-orders",
|
||||||
|
"tags": ["Purchase Orders"],
|
||||||
"summary": "List purchase orders (unordered, paginated)",
|
"summary": "List purchase orders (unordered, paginated)",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -205,7 +266,8 @@
|
||||||
},
|
},
|
||||||
"/purchase-orders/{poNumber}": {
|
"/purchase-orders/{poNumber}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getPurchaseOrder",
|
"operationId": "get-purchase-order",
|
||||||
|
"tags": ["Purchase Orders"],
|
||||||
"summary": "Get one purchase order",
|
"summary": "Get one purchase order",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -215,7 +277,8 @@
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Coupa PO number, e.g. `2D-22030794`."
|
"description": "Coupa PO number, e.g. `2D-22030794`.",
|
||||||
|
"example": "2D-22030794"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -240,7 +303,8 @@
|
||||||
},
|
},
|
||||||
"/verified-sites": {
|
"/verified-sites": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listVerifiedSites",
|
"operationId": "get-verified-sites",
|
||||||
|
"tags": ["Verified Sites"],
|
||||||
"summary": "List verified Amazon sites (unordered, paginated)",
|
"summary": "List verified Amazon sites (unordered, paginated)",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -284,7 +348,8 @@
|
||||||
},
|
},
|
||||||
"/verified-sites/{siteCode}": {
|
"/verified-sites/{siteCode}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getVerifiedSite",
|
"operationId": "get-verified-site",
|
||||||
|
"tags": ["Verified Sites"],
|
||||||
"summary": "Get one verified site",
|
"summary": "Get one verified site",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -294,7 +359,8 @@
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Amazon site code, e.g. `JFK8`."
|
"description": "Amazon site code, e.g. `JFK8`.",
|
||||||
|
"example": "JFK8"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -319,7 +385,8 @@
|
||||||
},
|
},
|
||||||
"/docs": {
|
"/docs": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getDocs",
|
"operationId": "get-docs",
|
||||||
|
"tags": ["Docs"],
|
||||||
"summary": "This documentation page (token-gated)",
|
"summary": "This documentation page (token-gated)",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
|
|
@ -334,7 +401,8 @@
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Browser convenience: the docs token as a query parameter (browsers can't set headers on navigation). Prefer the X-Auth-Token header from tooling."
|
"description": "Browser convenience: the docs token as a query parameter (browsers can't set headers on navigation). Prefer the X-Auth-Token header from tooling.",
|
||||||
|
"example": "your-docs-token"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -352,7 +420,8 @@
|
||||||
},
|
},
|
||||||
"/openapi.json": {
|
"/openapi.json": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getOpenApiSpec",
|
"operationId": "get-openapi-spec",
|
||||||
|
"tags": ["Docs"],
|
||||||
"summary": "This spec (token-gated)",
|
"summary": "This spec (token-gated)",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
|
|
@ -376,6 +445,8 @@
|
||||||
"webhooks": {
|
"webhooks": {
|
||||||
"work_order.created": {
|
"work_order.created": {
|
||||||
"post": {
|
"post": {
|
||||||
|
"operationId": "post-work-order-created",
|
||||||
|
"tags": ["Outbound Webhooks"],
|
||||||
"summary": "Outbound: a work order was created",
|
"summary": "Outbound: a work order was created",
|
||||||
"description": "Sent by `workorder-shoc-emitter` (seahaven-prod) to the configured SHOC endpoint. Full contract incl. HMAC verification, ordering, and retry semantics: `docs/shoc-webhook-contract.md` (Rev 2026-07-23). Requests carry `X-SH-Timestamp`, `X-SH-Key-Id`, and `X-SH-Signature: v1=hex(HMAC_SHA256(secret, \"{timestamp}.{raw_body}\"))`; verify over the raw body, constant-time, +/-300s window, fail closed.",
|
"description": "Sent by `workorder-shoc-emitter` (seahaven-prod) to the configured SHOC endpoint. Full contract incl. HMAC verification, ordering, and retry semantics: `docs/shoc-webhook-contract.md` (Rev 2026-07-23). Requests carry `X-SH-Timestamp`, `X-SH-Key-Id`, and `X-SH-Signature: v1=hex(HMAC_SHA256(secret, \"{timestamp}.{raw_body}\"))`; verify over the raw body, constant-time, +/-300s window, fail closed.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
|
|
@ -396,6 +467,8 @@
|
||||||
},
|
},
|
||||||
"work_order.updated": {
|
"work_order.updated": {
|
||||||
"post": {
|
"post": {
|
||||||
|
"operationId": "post-work-order-updated",
|
||||||
|
"tags": ["Outbound Webhooks"],
|
||||||
"summary": "Outbound: a work order changed",
|
"summary": "Outbound: a work order changed",
|
||||||
"description": "Same envelope and semantics as work_order.created; `data` is the full current state, not a diff.",
|
"description": "Same envelope and semantics as work_order.created; `data` is the full current state, not a diff.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
|
|
@ -416,6 +489,8 @@
|
||||||
},
|
},
|
||||||
"work_order.cancelled": {
|
"work_order.cancelled": {
|
||||||
"post": {
|
"post": {
|
||||||
|
"operationId": "post-work-order-cancelled",
|
||||||
|
"tags": ["Outbound Webhooks"],
|
||||||
"summary": "Outbound: a work order transitioned to cancelled",
|
"summary": "Outbound: a work order transitioned to cancelled",
|
||||||
"description": "A specialization of work_order.updated (same body) emitted when `wo_status` transitions to `cancelled`.",
|
"description": "A specialization of work_order.updated (same body) emitted when `wo_status` transitions to `cancelled`.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
|
|
@ -436,6 +511,8 @@
|
||||||
},
|
},
|
||||||
"work_order.comment_added": {
|
"work_order.comment_added": {
|
||||||
"post": {
|
"post": {
|
||||||
|
"operationId": "post-work-order-comment-added",
|
||||||
|
"tags": ["Outbound Webhooks"],
|
||||||
"summary": "Outbound: a comment/event record was ingested",
|
"summary": "Outbound: a comment/event record was ingested",
|
||||||
"description": "One per source email (comments, updates, and cancellation event records). Dedupe on `delivery_id` or `data.comment_id`. May occasionally arrive before the work order's created event - upsert a skeleton work order and let the state event backfill it.",
|
"description": "One per source email (comments, updates, and cancellation event records). Dedupe on `delivery_id` or `data.comment_id`. May occasionally arrive before the work order's created event - upsert a skeleton work order and let the state event backfill it.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
|
|
@ -482,7 +559,8 @@
|
||||||
"maximum": 500,
|
"maximum": 500,
|
||||||
"default": 100
|
"default": 100
|
||||||
},
|
},
|
||||||
"description": "Page size; values outside 1-500 are clamped."
|
"description": "Page size; values outside 1-500 are clamped.",
|
||||||
|
"example": 100
|
||||||
},
|
},
|
||||||
"Cursor": {
|
"Cursor": {
|
||||||
"name": "cursor",
|
"name": "cursor",
|
||||||
|
|
@ -491,7 +569,8 @@
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Opaque pagination cursor from the previous page's `next_cursor`. Malformed cursors return 400."
|
"description": "Opaque pagination cursor from the previous page's `next_cursor`. Malformed cursors return 400.",
|
||||||
|
"example": "eyJ3b3JrX29yZGVyX2lkIjogIjExMTQ0NTgwNzMwIn0"
|
||||||
},
|
},
|
||||||
"WorkOrderId": {
|
"WorkOrderId": {
|
||||||
"name": "workOrderId",
|
"name": "workOrderId",
|
||||||
|
|
@ -501,7 +580,8 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[0-9]+$"
|
"pattern": "^[0-9]+$"
|
||||||
},
|
},
|
||||||
"description": "Numeric APM work-order id, e.g. `11144580730`."
|
"description": "Numeric APM work-order id, e.g. `11144580730`.",
|
||||||
|
"example": "11144580730"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -575,7 +655,7 @@
|
||||||
"wo_status": {
|
"wo_status": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
"enum": ["new", "assigned", "in_progress", "on_hold", "completed", "cancelled", "unknown", null],
|
"enum": ["new", "assigned", "in_progress", "on_hold", "completed", "cancelled", "unknown", null],
|
||||||
"description": "`unknown` is a real emitted value - map it explicitly."
|
"description": "The `unknown` value is genuinely emitted - map it explicitly."
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": ["string", "null"]
|
"type": ["string", "null"]
|
||||||
|
|
@ -639,7 +719,7 @@
|
||||||
},
|
},
|
||||||
"comment_id": {
|
"comment_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "`{work_order_id}#{time|nocomment}#{sha256(s3_key)[:12]}` - unique per source email, stable across retries; a dedupe key."
|
"description": "Format: `{work_order_id}#{time|nocomment}#{sha256(s3_key)[:12]}` - unique per source email, stable across retries; a dedupe key."
|
||||||
},
|
},
|
||||||
"record_type": {
|
"record_type": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
|
|
|
||||||
27
package-lock.json
generated
Normal file
27
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "procurement-ingest",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"devDependencies": {
|
||||||
|
"@redocly/cli": "^2.40.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/cli": {
|
||||||
|
"version": "2.40.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.40.0.tgz",
|
||||||
|
"integrity": "sha512-1uQ4GeNjhApy9EtypZgp70ZN5GC2JFfst3UkNEXSqkXgVIPGdEAnlz5Xwgax/4cEUGOvaZoM3X25iSQcqbplFg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"openapi": "bin/cli.js",
|
||||||
|
"redocly": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0 || >=20.19.0 <21.0.0",
|
||||||
|
"npm": ">=10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
package.json
Normal file
9
package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"lint:api": "redocly lint lambdas/api/openapi.json --config=redocly.yaml",
|
||||||
|
"docs:preview": ".venv/bin/python scripts/preview_docs.py"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@redocly/cli": "^2.40.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
122
redocly.yaml
Normal file
122
redocly.yaml
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
extends:
|
||||||
|
- recommended
|
||||||
|
rules:
|
||||||
|
# Proprietary internal license -- no SPDX identifier or public URL exists.
|
||||||
|
info-license-strict: off
|
||||||
|
rule/info-title-api:
|
||||||
|
subject:
|
||||||
|
type: Info
|
||||||
|
property: title
|
||||||
|
assertions:
|
||||||
|
pattern: /.*API.*/
|
||||||
|
rule/info-description:
|
||||||
|
subject:
|
||||||
|
type: Info
|
||||||
|
property: description
|
||||||
|
assertions:
|
||||||
|
defined: true
|
||||||
|
operation-4xx-response: error
|
||||||
|
# Off: the live contract is {"error": string} as plain application/json
|
||||||
|
# (serialization.py error_response). Adopting RFC 7807 would be a runtime
|
||||||
|
# + SHOC-contract change, decided against 2026-07-24.
|
||||||
|
operation-4xx-problem-details-rfc7807: off
|
||||||
|
operation-operationId: error
|
||||||
|
rule/operationId-casing:
|
||||||
|
subject:
|
||||||
|
type: Operation
|
||||||
|
property: operationId
|
||||||
|
assertions:
|
||||||
|
casing: kebab-case
|
||||||
|
rule/operationId-prefix:
|
||||||
|
subject:
|
||||||
|
type: Operation
|
||||||
|
property: operationId
|
||||||
|
assertions:
|
||||||
|
pattern: /^GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH|TRACE/i
|
||||||
|
rule/operation-summary-period:
|
||||||
|
subject:
|
||||||
|
type: Operation
|
||||||
|
property: summary
|
||||||
|
assertions:
|
||||||
|
pattern: /[^.]$/
|
||||||
|
path-not-include-query: error
|
||||||
|
# No parameter-casing rule: path parameter names (workOrderId, poNumber,
|
||||||
|
# siteCode) are camelCase by contract -- they are baked into the API Gateway
|
||||||
|
# resource paths and read by the handler's pathParameters lookup.
|
||||||
|
rule/params-must-include-examples:
|
||||||
|
severity: error
|
||||||
|
subject:
|
||||||
|
type: Parameter
|
||||||
|
assertions:
|
||||||
|
requireAny:
|
||||||
|
- example
|
||||||
|
- examples
|
||||||
|
no-http-verbs-in-paths: error
|
||||||
|
no-ambiguous-paths: error
|
||||||
|
path-segment-plural:
|
||||||
|
severity: error
|
||||||
|
exceptions:
|
||||||
|
- docs
|
||||||
|
- openapi.json
|
||||||
|
paths-kebab-case: error
|
||||||
|
no-invalid-schema-examples: error
|
||||||
|
# No schema-properties casing rule: property names mirror the DynamoDB
|
||||||
|
# items and the shipped SHOC webhook contract -- snake_case for WO/PO
|
||||||
|
# tables, camelCase for verified-sites (legacy, issue #24). Not lintable
|
||||||
|
# to one casing without a contract break.
|
||||||
|
# Error bodies must carry the top-level "error" field (the Error schema).
|
||||||
|
# 403 is exempt: it is emitted by API Gateway's SigV4 layer with AWS's
|
||||||
|
# {"message"} shape, not by the Lambda.
|
||||||
|
response-contains-property:
|
||||||
|
severity: error
|
||||||
|
names:
|
||||||
|
'400':
|
||||||
|
- error
|
||||||
|
'401':
|
||||||
|
- error
|
||||||
|
'404':
|
||||||
|
- error
|
||||||
|
'501':
|
||||||
|
- error
|
||||||
|
request-mime-type:
|
||||||
|
severity: error
|
||||||
|
allowedValues:
|
||||||
|
- application/json
|
||||||
|
response-mime-type:
|
||||||
|
severity: error
|
||||||
|
allowedValues:
|
||||||
|
- application/json
|
||||||
|
- text/html
|
||||||
|
no-server-example.com: error
|
||||||
|
rule/no-server-localhost:
|
||||||
|
subject:
|
||||||
|
type: Server
|
||||||
|
property: url
|
||||||
|
assertions:
|
||||||
|
notPattern: /(localhost|127.0.0.1)
|
||||||
|
operation-singular-tag: error
|
||||||
|
operation-tag-defined: error
|
||||||
|
rule/tag-description:
|
||||||
|
subject:
|
||||||
|
type: Tag
|
||||||
|
property: description
|
||||||
|
assertions:
|
||||||
|
defined: true
|
||||||
|
rule/description-capitalization:
|
||||||
|
subject:
|
||||||
|
type: any
|
||||||
|
property: description
|
||||||
|
assertions:
|
||||||
|
pattern: /^([A-Z]|true|seahaven-prod)/
|
||||||
|
rule/description-punctuation:
|
||||||
|
subject:
|
||||||
|
type: any
|
||||||
|
property: description
|
||||||
|
assertions:
|
||||||
|
pattern: /(\.|server)$/
|
||||||
|
rule/avoid-words-in-descriptions:
|
||||||
|
subject:
|
||||||
|
type: any
|
||||||
|
property: description
|
||||||
|
assertions:
|
||||||
|
notPattern: /(simply|easy|easily|just|obviously|notethat)/i
|
||||||
34
scripts/preview_docs.py
Normal file
34
scripts/preview_docs.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Render the /docs page locally and open it in the default browser.
|
||||||
|
|
||||||
|
Goes through the real handler code path (vendored Redoc bundle + fonts
|
||||||
|
inlined, breakout guards applied, spec spliced), so the preview is
|
||||||
|
byte-identical to what the Lambda serves, minus the token gate. No AWS
|
||||||
|
credentials or network needed; without a request context the spec keeps
|
||||||
|
its committed fallback server URL.
|
||||||
|
|
||||||
|
Run from the repo root: `npm run docs:preview` (or
|
||||||
|
`.venv/bin/python scripts/preview_docs.py`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from tests.support import load_lambda_module # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
handler = load_lambda_module("api", "handler")
|
||||||
|
html = handler._render_docs_html({})
|
||||||
|
out = Path(tempfile.gettempdir()) / "procurement-docs-preview.html"
|
||||||
|
out.write_text(html, encoding="utf-8")
|
||||||
|
print(f"wrote {out} ({len(html)} bytes)")
|
||||||
|
webbrowser.open(out.as_uri())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -118,7 +118,11 @@ def test_docs_served_when_authenticated(api, monkeypatch):
|
||||||
assert "Redoc.init" in docs["body"]
|
assert "Redoc.init" in docs["body"]
|
||||||
assert "Procurement Ingest API" in docs["body"]
|
assert "Procurement Ingest API" in docs["body"]
|
||||||
# Every placeholder must be substituted, or the page renders broken.
|
# Every placeholder must be substituted, or the page renders broken.
|
||||||
for placeholder in (mod._SPEC_PLACEHOLDER, mod._JS_PLACEHOLDER):
|
for placeholder in (
|
||||||
|
mod._SPEC_PLACEHOLDER,
|
||||||
|
mod._JS_PLACEHOLDER,
|
||||||
|
mod._FONTS_PLACEHOLDER,
|
||||||
|
):
|
||||||
assert placeholder not in docs["body"]
|
assert placeholder not in docs["body"]
|
||||||
|
|
||||||
spec = mod.handler(_event("GET", "/openapi.json"), None)
|
spec = mod.handler(_event("GET", "/openapi.json"), None)
|
||||||
|
|
|
||||||
|
|
@ -112,14 +112,20 @@ def test_docs_and_spec_files_ship_with_the_handler():
|
||||||
assert (api_dir / "openapi.json").is_file()
|
assert (api_dir / "openapi.json").is_file()
|
||||||
assert (api_dir / "docs.html").is_file()
|
assert (api_dir / "docs.html").is_file()
|
||||||
assert (api_dir / "redoc.standalone.js").is_file()
|
assert (api_dir / "redoc.standalone.js").is_file()
|
||||||
|
assert (api_dir / "fonts.css").is_file()
|
||||||
docs = (api_dir / "docs.html").read_text(encoding="utf-8")
|
docs = (api_dir / "docs.html").read_text(encoding="utf-8")
|
||||||
assert "__OPENAPI_SPEC_JSON__" in docs
|
assert "__OPENAPI_SPEC_JSON__" in docs
|
||||||
assert "__REDOC_JS__" in docs
|
assert "__REDOC_JS__" in docs
|
||||||
|
assert "__FONTS_CSS__" in docs
|
||||||
assert "Redoc.init" in docs
|
assert "Redoc.init" in docs
|
||||||
# The vendored Redoc bundle must carry no raw </script>: it is inlined
|
# The docs page must never fetch fonts (or anything else) off-box: the
|
||||||
# into a <script> block, and a literal </script would break it out. (The
|
# design-system fonts ride inline as data URIs in fonts.css.
|
||||||
# handler also escapes it defensively, but keeping the pinned asset clean
|
assert "fonts.googleapis.com" not in docs
|
||||||
# is the load-bearing guarantee and catches a bad version bump here.)
|
# The vendored blobs must carry no raw </script>/</style>: each is inlined
|
||||||
|
# into a <script>/<style> block a literal terminator would break out of.
|
||||||
|
# (The handler also escapes them defensively, but keeping the pinned
|
||||||
|
# assets clean is the load-bearing guarantee and catches a bad bump here.)
|
||||||
assert "</script" not in (api_dir / "redoc.standalone.js").read_text(
|
assert "</script" not in (api_dir / "redoc.standalone.js").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
assert "</style" not in (api_dir / "fonts.css").read_text(encoding="utf-8")
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ API_DATA_FILES_CP_RES = (
|
||||||
r"(?:^|\s)api/openapi\.json(?:\s|$)",
|
r"(?:^|\s)api/openapi\.json(?:\s|$)",
|
||||||
r"(?:^|\s)api/docs\.html(?:\s|$)",
|
r"(?:^|\s)api/docs\.html(?:\s|$)",
|
||||||
r"(?:^|\s)api/redoc\.standalone\.js(?:\s|$)",
|
r"(?:^|\s)api/redoc\.standalone\.js(?:\s|$)",
|
||||||
|
r"(?:^|\s)api/fonts\.css(?:\s|$)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pipeline-scoped glob shapes the per-stack ships-all pins accept. Phase 2
|
# Pipeline-scoped glob shapes the per-stack ships-all pins accept. Phase 2
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue