From 23a4a0f12b38a79ffa97908e6c1d733c23a68420 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 18 May 2026 00:55:47 -0500 Subject: [PATCH] docs(openapi): Idempotency-Key parameter on all 16 single-POST endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The middleware applies to every /v1/* POST, but the OpenAPI spec previously only documented the header on the bulk variants (via the bulkPath() helper). SDK code-generators reading the spec saw single-create POSTs as non-idempotent, even though clients could in fact send Idempotency-Key safely. 16 single-POST entries gain `parameters: [idempotencyKeyHeader]`: customer, timeentry, worker, billingtype, inventoryitem, company, job, invoice, customerpayment, invoicejob, productentry, versioninfo, purchaseordervendor, purchaseorderheader, purchaseorderline, inventorytransaction. We don't add a `409` response code on the single POSTs — the same-key-different-body case is rare enough that just documenting the request header is sufficient for code-gen. The bulk variants keep their 409 doc (bulkPath() helper, unchanged). Tests: new OpenAPI assertion (1 case, 16 expectations) pins the Idempotency-Key header is present on each single-POST. Full suite: 479 pass / 4 skip (was 478/4). Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/openapi.js | 19 ++++++++++++++++--- tests/api/openapi.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/config/openapi.js b/app/config/openapi.js index adcc13a..bd7a2e4 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -549,6 +549,7 @@ const spec = { post: { summary: 'Create a customer', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { @@ -566,6 +567,7 @@ const spec = { post: { summary: 'Create a time entry', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { @@ -642,6 +644,7 @@ const spec = { post: { summary: 'Create a worker', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { @@ -692,6 +695,7 @@ const spec = { post: { summary: 'Create a billing type', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/BillingType' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, }, @@ -733,6 +737,7 @@ const spec = { post: { summary: 'Create an inventory item', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/InventoryItem' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, }, @@ -774,6 +779,7 @@ const spec = { post: { summary: 'Create a company (master keys only)', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Company' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Non-master key' } }, }, @@ -812,6 +818,7 @@ const spec = { post: { summary: 'Create a job', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Job' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, }, @@ -837,6 +844,7 @@ const spec = { post: { summary: 'Create an invoice', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/Invoice' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, }, @@ -862,6 +870,7 @@ const spec = { post: { summary: 'Create a customer payment', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CustomerPayment' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, }, @@ -887,6 +896,7 @@ const spec = { post: { summary: 'Create an invoice line (job → invoice)', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/InvoiceJob' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, }, @@ -912,6 +922,7 @@ const spec = { post: { summary: 'Create a product entry', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/ProductEntry' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, }, @@ -937,6 +948,7 @@ const spec = { post: { summary: 'Create a version info record (master keys only)', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionInfo' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Non-master key' } }, }, @@ -959,6 +971,7 @@ const spec = { post: { summary: 'Create a PO vendor', security: [{ authKey: [] }], + parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderVendor' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } }, }, @@ -981,7 +994,7 @@ const spec = { }, }, '/v1/purchaseorderheader': { - post: { summary: 'Create a PO header', security: [{ authKey: [] }], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderHeader' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } } }, + post: { summary: 'Create a PO header', security: [{ authKey: [] }], parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderHeader' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } } }, }, '/v1/purchaseorderheader/{id}': { get: { summary: 'Get one PO header', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, @@ -1001,7 +1014,7 @@ const spec = { }, }, '/v1/purchaseorderline': { - post: { summary: 'Create a PO line', security: [{ authKey: [] }], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderLine' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } } }, + post: { summary: 'Create a PO line', security: [{ authKey: [] }], parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/PurchaseOrderLine' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } } }, }, '/v1/purchaseorderline/{id}': { get: { summary: 'Get one PO line', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, @@ -1021,7 +1034,7 @@ const spec = { }, }, '/v1/inventorytransaction': { - post: { summary: 'Create an inventory transaction', security: [{ authKey: [] }], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/InventoryTransaction' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } } }, + post: { summary: 'Create an inventory transaction', security: [{ authKey: [] }], parameters: [idempotencyKeyHeader], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/InventoryTransaction' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } } }, }, '/v1/inventorytransaction/{id}': { get: { summary: 'Get one inventory transaction', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } }, diff --git a/tests/api/openapi.test.js b/tests/api/openapi.test.js index fd753c6..1fc7895 100644 --- a/tests/api/openapi.test.js +++ b/tests/api/openapi.test.js @@ -93,6 +93,31 @@ describe('OpenAPI spec', () => { } }); + test('single-create POSTs document the Idempotency-Key header', async () => { + // The middleware applies to every /v1/* POST, so the spec + // should advertise the header on the single-create endpoints + // too — not just the bulk variants. We don't pin the 409 + // response on single POSTs (the same-key-different-body case + // is rare enough that documenting just the request header is + // sufficient for SDK code-gen). + const res = await request(app).get('/openapi.json'); + const targets = [ + '/v1/customer', '/v1/timeentry', '/v1/worker', '/v1/billingtype', + '/v1/inventoryitem', '/v1/company', '/v1/job', '/v1/invoice', + '/v1/customerpayment', '/v1/invoicejob', '/v1/productentry', + '/v1/versioninfo', '/v1/purchaseordervendor', + '/v1/purchaseorderheader', '/v1/purchaseorderline', + '/v1/inventorytransaction', + ]; + for (const path of targets) { + const post = res.body.paths[path] && res.body.paths[path].post; + expect(post, `${path} POST should be documented`).toBeDefined(); + const params = post.parameters || []; + const idem = params.find((p) => p.name === 'Idempotency-Key'); + expect(idem, `${path} POST should document the Idempotency-Key header`).toBeDefined(); + } + }); + 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'];