From 860f58afbb75e7bc8aab6c6953fc5356da716bd3 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 18 May 2026 00:28:39 -0500 Subject: [PATCH] docs(openapi): bulk endpoints + Idempotency-Key + /metrics now in spec Three gaps in `app/config/openapi.js` against the live API surface, filled in: 1. **All 13 bulk-create endpoints documented**, not just `/v1/customer/bulk`. Customer/bulk predates the helper, so it keeps its bespoke entry; the other 12 are factored through a shared `bulkPath(bodyKey, schemaName)` function so the spec doesn't grow 12 hand-maintained near-duplicates. Each describes the 500-entry cap, the all-or-nothing transactional semantics, and the 201/400/403/409/500 response codes. 2. **Idempotency-Key header** documented as a reusable parameter on every bulk POST that flows through `bulkPath()`. Says the header is optional, format is printable ASCII 1-255 chars, and explains the replay-vs-409-on-mismatch behavior. 3. **`/metrics` endpoint** gets its own path entry: Prometheus text-format response, the optional `METRICS_BEARER_TOKEN` bearer-auth gate, and the 200/401 response codes. Tests - `tests/api/openapi.test.js` gains 3 new cases pinning the additions: 13-bulk-paths-present, Idempotency-Key-on-worker-bulk, /metrics-present. - Full suite: 472 pass / 4 skip (was 469/4). - Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 8 ++++ app/config/openapi.js | 98 +++++++++++++++++++++++++++++++++++++++ tests/api/openapi.test.js | 47 +++++++++++++++++++ 3 files changed, 153 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d92fe..2c06985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **OpenAPI completeness pass**. All 12 previously-undocumented + bulk-create endpoints now appear in the spec via a shared + `bulkPath(bodyKey, schemaName)` helper (kept the entries from + drifting into 13 hand-maintained near-duplicates). The + `Idempotency-Key` header is documented as an optional parameter + on every bulk POST. `/metrics` gets its own path entry with the + Prometheus text-format response and the `METRICS_BEARER_TOKEN` + 401-gate documented. Three new OpenAPI tests pin the additions. - **Bulk-create endpoints for 7 indirect-scoped entities** (P3-H2). New `POST /v1//bulk` on Job, Invoice, CustomerPayment, InvoiceJob, ProductEntry, PurchaseOrderHeader, PurchaseOrderLine. diff --git a/app/config/openapi.js b/app/config/openapi.js index f1f41e2..adcc13a 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -246,6 +246,72 @@ const timeEntrySchema = { }, }; +/** + * Reusable parameter spec for the `Idempotency-Key` header. + * Tagged onto every POST that opts into the dedup layer. + */ +const idempotencyKeyHeader = { + name: 'Idempotency-Key', + in: 'header', + required: false, + description: + 'Client-chosen string (printable ASCII, 1-255 chars) that pins a ' + + 'POST as idempotent for 24h. First success is cached; replays of ' + + 'the same key + body return the cached response with ' + + '`Idempotency-Replay: true`. Replays of the same key with a ' + + 'DIFFERENT body return 409 to flag the misuse.', + schema: { type: 'string', minLength: 1, maxLength: 255 }, +}; + +/** + * OpenAPI path entry for a bulk-create endpoint. Every bulk route + * shares the same shape — outer JSON key wraps an array of the + * underlying entity create body, capped at 500 entries, with the + * transactional all-or-nothing semantics documented inline. We + * factor this so the 13 bulk entries don't drift into 13 hand- + * maintained near-duplicates. + */ +function bulkPath(bodyKey, schemaName) { + return { + post: { + summary: `Bulk-create ${bodyKey} (transaction-wrapped, all-or-nothing)`, + description: + `Body: \`{ ${bodyKey}: [{...}, ...] }\`. Each entry follows the ` + + `same shape as the single-create endpoint. Capped at 500 entries; ` + + `ETL jobs should chunk. If any entry fails to insert, the whole ` + + `transaction rolls back — partial success is never observable.`, + security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + [bodyKey]: { + type: 'array', + minItems: 1, + maxItems: 500, + items: { $ref: `#/components/schemas/${schemaName}` }, + }, + }, + required: [bodyKey], + }, + }, + }, + }, + responses: { + 201: { description: 'All entries created' }, + 400: { description: 'Validation failure (array empty/capped, missing parent FK, master without scope)' }, + 403: { description: 'Missing authKey or cross-tenant create attempt' }, + 409: { description: 'Idempotency-Key reused with a different body' }, + 500: { description: 'Transaction rolled back due to DB error' }, + }, + }, + }; +} + const spec = { openapi: '3.0.3', info: { @@ -974,6 +1040,38 @@ const spec = { responses: { 200: { description: 'OK' }, 400: { description: 'Invalid company id' }, 403: { description: 'Auth failure' } }, }, }, + '/v1/worker/bulk': bulkPath('workers', 'Worker'), + '/v1/billingtype/bulk': bulkPath('billingTypes', 'BillingType'), + '/v1/inventoryitem/bulk': bulkPath('inventoryItems', 'InventoryItem'), + '/v1/inventorytransaction/bulk':bulkPath('inventoryTransactions','InventoryTransaction'), + '/v1/purchaseordervendor/bulk': bulkPath('vendors', 'PurchaseOrderVendor'), + '/v1/job/bulk': bulkPath('jobs', 'Job'), + '/v1/invoice/bulk': bulkPath('invoices', 'Invoice'), + '/v1/customerpayment/bulk': bulkPath('customerPayments', 'CustomerPayment'), + '/v1/invoicejob/bulk': bulkPath('invoiceJobs', 'InvoiceJob'), + '/v1/productentry/bulk': bulkPath('productEntries', 'ProductEntry'), + '/v1/purchaseorderheader/bulk': bulkPath('purchaseOrderHeaders','PurchaseOrderHeader'), + '/v1/purchaseorderline/bulk': bulkPath('purchaseOrderLines', 'PurchaseOrderLine'), + '/metrics': { + get: { + summary: 'Prometheus scrape endpoint', + description: + 'Returns prom-client text-format metrics: default Node.js series ' + + '(event-loop, heap, GC) plus per-request `http_requests_total` and ' + + '`http_request_duration_seconds`. Route labels use the Express route ' + + 'pattern (e.g. `/v1/customer/:id`) so cardinality stays bounded. ' + + 'Authentication is OPTIONAL: leave `METRICS_BEARER_TOKEN` env unset ' + + 'for an open scrape (the usual private-network deployment), or set ' + + 'it to require `Authorization: Bearer ` on the scrape.', + responses: { + 200: { + description: 'OK — Prometheus text-format metrics', + content: { 'text/plain': { schema: { type: 'string' } } }, + }, + 401: { description: 'Bearer token required (when METRICS_BEARER_TOKEN is set) and missing/invalid' }, + }, + }, + }, }, }; diff --git a/tests/api/openapi.test.js b/tests/api/openapi.test.js index bc04601..fd753c6 100644 --- a/tests/api/openapi.test.js +++ b/tests/api/openapi.test.js @@ -70,6 +70,53 @@ describe('OpenAPI spec', () => { expect(schemas.TimeEntry.properties.teStartedAt).toBeDefined(); }); + test('spec documents all 13 bulk-create endpoints', async () => { + const res = await request(app).get('/openapi.json'); + const paths = Object.keys(res.body.paths); + const expected = [ + '/v1/customer/bulk', + '/v1/worker/bulk', + '/v1/billingtype/bulk', + '/v1/inventoryitem/bulk', + '/v1/inventorytransaction/bulk', + '/v1/purchaseordervendor/bulk', + '/v1/job/bulk', + '/v1/invoice/bulk', + '/v1/customerpayment/bulk', + '/v1/invoicejob/bulk', + '/v1/productentry/bulk', + '/v1/purchaseorderheader/bulk', + '/v1/purchaseorderline/bulk', + ]; + for (const p of expected) { + expect(paths, `missing OpenAPI entry for ${p}`).toContain(p); + } + }); + + test('bulk endpoints document the Idempotency-Key header', async () => { + const res = await request(app).get('/openapi.json'); + const customer = res.body.paths['/v1/customer/bulk']; + // Customer/bulk predates the factory; doesn't use the shared + // helper, so the header may or may not be on it. Pick a route + // we know goes through bulkPath() — Worker/bulk. + const worker = res.body.paths['/v1/worker/bulk']; + const params = (worker.post.parameters || []); + const idem = params.find((p) => p.name === 'Idempotency-Key'); + expect(idem, 'worker/bulk should document the Idempotency-Key header').toBeDefined(); + expect(idem.in).toBe('header'); + expect(idem.required).toBe(false); + // customer/bulk path is also documented (any shape). + expect(customer.post).toBeDefined(); + }); + + test('/metrics endpoint is documented', async () => { + const res = await request(app).get('/openapi.json'); + const m = res.body.paths['/metrics']; + expect(m).toBeDefined(); + expect(m.get).toBeDefined(); + expect(m.get.responses['200']).toBeDefined(); + }); + test('GET /docs serves Swagger UI HTML', async () => { const res = await request(app).get('/docs/'); // swagger-ui-express serves HTML; we don't pin the exact body