mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge pull request #809 from MBombeck/docs/idempotency-contract
Publish the idempotency contract the server has honoured all along
This commit is contained in:
22 files changed
+753
-237
No files matched your search
+355
-230
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Every idempotent route says so in the contract, and only those do.
|
||||
*
|
||||
* `withIdempotency` has wrapped writes since v1.5.x. `Idempotency-Key` appeared
|
||||
* in a dozen route comments and in no published schema, so an app generated
|
||||
* against `docs/api/openapi.yaml` could not tell that a retry was safe — which
|
||||
* is exactly what the iOS side asked for. Publishing it once is easy; keeping
|
||||
* it published as routes come and go is what this guard is for.
|
||||
*
|
||||
* Both directions matter, and for different reasons:
|
||||
*
|
||||
* - A route that gains `withIdempotency` and forgets to publish it leaves the
|
||||
* capability invisible, which is today's defect repeating itself.
|
||||
* - A route that publishes the parameter and drops the wrapper is worse: the
|
||||
* contract then promises a retry is safe when it is not, and a client that
|
||||
* believes it writes twice.
|
||||
*
|
||||
* The wrapper is the source of truth here, not this file. Nothing is
|
||||
* hand-listed: the expected set is read out of the route tree on every run, so
|
||||
* the guard cannot fall behind the code it watches.
|
||||
*/
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
const API_ROOT = join(process.cwd(), "src", "app", "api");
|
||||
|
||||
/** Every `route.ts` under `src/app/api`, tests excluded. */
|
||||
function routeFiles(dir: string): string[] {
|
||||
const found: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "__tests__") continue;
|
||||
found.push(...routeFiles(full));
|
||||
continue;
|
||||
}
|
||||
if (entry.name === "route.ts") found.push(full);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** `src/app/api/medications/[id]/intake/route.ts` -> `/api/medications/{id}/intake` */
|
||||
function toOpenApiPath(file: string): string {
|
||||
const rel = relative(API_ROOT, file).split(sep).slice(0, -1);
|
||||
const segments = rel.map((s) =>
|
||||
s.startsWith("[") && s.endsWith("]") ? `{${s.slice(1, -1)}}` : s,
|
||||
);
|
||||
return `/api/${segments.join("/")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method-level, not path-level, and the difference is the whole point.
|
||||
*
|
||||
* `/api/measurements` exports GET, POST, PUT and DELETE, and only POST is
|
||||
* wrapped:
|
||||
*
|
||||
* export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
|
||||
* export const PUT = apiHandler(putMeasurement);
|
||||
*
|
||||
* A path-level check would go green on that path the moment ANY operation
|
||||
* declared the header, and publishing it on PUT as well would promise a safe
|
||||
* retry the server does not honour. So the unit here is `POST /api/x`, and the
|
||||
* matcher requires the wrapper in the same assignment as the export.
|
||||
*/
|
||||
function idempotentOperationsFromSource(): Set<string> {
|
||||
const found = new Set<string>();
|
||||
// `export const POST = ...withIdempotency...` up to the end of that
|
||||
// statement. Non-greedy to the next top-level `export const`, so a wrapped
|
||||
// POST cannot lend its wrapper to the PUT declared below it.
|
||||
const pattern =
|
||||
/export\s+const\s+(POST|PUT|PATCH|DELETE)\s*=\s*([\s\S]*?)(?=\n(?:export\s+const|export\s+async\s+function|$))/g;
|
||||
for (const file of routeFiles(API_ROOT)) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (!source.includes("withIdempotency")) continue;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
if (!/\bwithIdempotency\s*[(<]/.test(match[2])) continue;
|
||||
found.add(`${match[1].toLowerCase()} ${toOpenApiPath(file)}`);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Operations in the emitted document that declare the `Idempotency-Key`. */
|
||||
function idempotentOperationsFromContract(): Set<string> {
|
||||
const doc = buildOpenApiDocument() as {
|
||||
paths?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const found = new Set<string>();
|
||||
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
const parameters = (operation as { parameters?: unknown })?.parameters;
|
||||
if (!Array.isArray(parameters)) continue;
|
||||
const declares = parameters.some(
|
||||
(p) => (p as { name?: string })?.name === "Idempotency-Key",
|
||||
);
|
||||
if (declares) found.add(`${method.toLowerCase()} ${path}`);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent operations that cannot be published, because the PATH is not in
|
||||
* the document at all. A header cannot hang on a path that does not exist.
|
||||
*
|
||||
* Two entries, and the list was four until the test below refused it. The first
|
||||
* draft claimed the whole `/api/medications` family was missing, on the
|
||||
* strength of a grep for path literals in `src/lib/openapi/routes/` that came
|
||||
* back empty. It came back empty because those paths are not written as
|
||||
* searchable literals there — the emitted document carries 19 of them,
|
||||
* including every one the iOS client actually argues about. Measure the
|
||||
* artefact, not the source that builds it.
|
||||
*
|
||||
* Listed rather than filtered silently, so the exclusion stays visible in the
|
||||
* file that would otherwise claim full coverage.
|
||||
*/
|
||||
const UNPUBLISHED_PATHS: Readonly<Record<string, string>> = {
|
||||
"/api/insights/feedback":
|
||||
"Coach feedback write; never added to the route table. A real gap, small.",
|
||||
"/api/admin/backups/{id}/restore":
|
||||
"Admin surface: cookie-only by construction, so arguably out of scope for a client contract.",
|
||||
};
|
||||
|
||||
describe("idempotency — the contract matches the wrapper", () => {
|
||||
it("finds idempotent operations in the source tree at all", () => {
|
||||
// Without this the two comparisons below could both pass on empty sets,
|
||||
// which is the failure mode this whole file exists to prevent elsewhere.
|
||||
const fromSource = idempotentOperationsFromSource();
|
||||
expect(fromSource.size).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it("publishes the header on every route that wraps withIdempotency", () => {
|
||||
const fromSource = idempotentOperationsFromSource();
|
||||
const fromContract = idempotentOperationsFromContract();
|
||||
|
||||
const unpublished = [...fromSource]
|
||||
.filter((p) => !fromContract.has(p))
|
||||
.filter((p) => !(p.split(" ")[1] in UNPUBLISHED_PATHS))
|
||||
.sort();
|
||||
|
||||
expect(
|
||||
unpublished,
|
||||
`These routes accept an Idempotency-Key and do not say so in the ` +
|
||||
`contract, so a generated client cannot know a retry is safe. Add ` +
|
||||
`\`idempotencyKeyParameter\` to the write operation and ` +
|
||||
`\`...idempotentWrite()\` to its responses.`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the unpublished list honest — every entry is still absent", () => {
|
||||
const doc = buildOpenApiDocument() as { paths?: Record<string, unknown> };
|
||||
const present = Object.keys(doc.paths ?? {});
|
||||
|
||||
const nowPublished = Object.keys(UNPUBLISHED_PATHS)
|
||||
.filter((p) => present.includes(p))
|
||||
.sort();
|
||||
|
||||
expect(
|
||||
nowPublished,
|
||||
`These paths are in the contract now, so their exemption above is stale. ` +
|
||||
`Delete the entry and add \`idempotencyKeyParameter\` to the operation ` +
|
||||
`— an exemption nobody removes is how a gap becomes permanent.`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not promise idempotency on a route that dropped the wrapper", () => {
|
||||
const fromSource = idempotentOperationsFromSource();
|
||||
const fromContract = idempotentOperationsFromContract();
|
||||
|
||||
const overclaimed = [...fromContract]
|
||||
.filter((p) => !fromSource.has(p))
|
||||
.sort();
|
||||
|
||||
expect(
|
||||
overclaimed,
|
||||
`The contract offers an Idempotency-Key on these paths and no handler ` +
|
||||
`honours it. A client that trusts this writes twice on a retry — the ` +
|
||||
`more dangerous of the two directions.`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -102,11 +104,13 @@ export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Record an allergy (v1.25)",
|
||||
description:
|
||||
"Creates one allergy/intolerance record for the caller. The free-text reaction + note are AES-256-GCM encrypted before write. Audits as `allergy.create`.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: allergyCreateSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Allergy created.",
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -101,11 +103,13 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Define a biomarker",
|
||||
description:
|
||||
"Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as `biomarker.create`.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createBiomarkerSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Created biomarker.",
|
||||
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
conflictResponse409,
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
invalidBaseTokenResponse,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
@@ -1285,6 +1287,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Create an anamnesis fact",
|
||||
description:
|
||||
"Creates the first current revision for one closed fact kind. The answer is encrypted before persistence. A second current value for the same kind returns 409; corrections use the revision endpoint.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -1292,6 +1295,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"201": {
|
||||
description: "The newly created current revision.",
|
||||
content: {
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -159,11 +161,13 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Define a custom metric",
|
||||
description:
|
||||
"Creates a user-scoped custom metric. Re-creating a name that was previously soft-deleted revives that definition. Audits as `customMetric.create`.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createCustomMetricSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"201": {
|
||||
description: "Created custom metric.",
|
||||
content: {
|
||||
@@ -288,6 +292,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -295,6 +300,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"201": {
|
||||
description: "Created value.",
|
||||
content: {
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -461,11 +463,13 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Capture a single cycle day-log (v1.15.0)",
|
||||
description:
|
||||
"Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: cycleDayLogInputSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "Existing day-log updated.",
|
||||
content: {
|
||||
@@ -541,11 +545,13 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0)",
|
||||
description:
|
||||
"Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:<userId>` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: cycleBulkSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "Batch processed (per-entry results).",
|
||||
content: {
|
||||
@@ -565,11 +571,13 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Period-boundary shortcut (v1.15.0)",
|
||||
description:
|
||||
"One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: cyclePeriodSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "Cycle + boundary day-log.",
|
||||
content: {
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -359,6 +361,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Documents"],
|
||||
summary: "Store a document (no extraction)",
|
||||
description:
|
||||
@@ -397,6 +400,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description:
|
||||
"Duplicate — the same bytes are already stored: the existing row, with `meta.duplicate: true` at the envelope level.",
|
||||
@@ -1096,6 +1100,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"The ONLY write path out of staging. Commits the user's approvals into the structured stores (labs / conditions / medications) and discards rejections. A low-confidence fact that was never edited cannot be approved (fail-closed). Idempotent (Idempotency-Key). Audits as `documents.inbound.confirm`.",
|
||||
parameters: [
|
||||
idempotencyKeyParameter,
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
@@ -1108,6 +1113,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: inboundConfirmSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "Per-decision outcome.",
|
||||
content: {
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -246,6 +248,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Records"],
|
||||
summary: "File or book a visit",
|
||||
description:
|
||||
@@ -255,6 +258,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: encounterCreateSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Visit filed.",
|
||||
@@ -467,6 +471,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Records"],
|
||||
summary: "Add a practitioner",
|
||||
description:
|
||||
@@ -476,6 +481,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: practitionerCreateSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Practitioner added.",
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -98,6 +100,7 @@ export const familyHistoryPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Record a family-history entry (v1.25)",
|
||||
description:
|
||||
"Creates one condition-by-relative record for the caller. The free-text note is AES-256-GCM encrypted before write. Audits as `family-history.create`.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -105,6 +108,7 @@ export const familyHistoryPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Entry created.",
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -318,6 +320,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Open an illness episode (v1.18.1)",
|
||||
description:
|
||||
"Creates one episode for the caller. `onsetAt` defaults to 'now' when omitted; the optional note is AES-256-GCM encrypted before write. A `parentConditionId` must reference an owned, live episode. Audits as `illness.episode.create`. Born-gated.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -325,6 +328,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Episode created.",
|
||||
@@ -422,7 +426,9 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"Clears `deletedAt`, bringing the episode and its preserved day-logs + encrypted note back into every read. A soft-delete keeps the row and children intact, so restore is a pure flag flip — nothing is re-created (the delete-Undo affordance). Idempotent — restoring a live episode is a no-op. Audits as `illness.episode.restore`. Owner-scoped + born-gated.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
parameters: [idempotencyKeyParameter],
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Episode restored.",
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -139,11 +141,13 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Record a lab result",
|
||||
description:
|
||||
'Creates a single lab result for the caller. The optional `source` marks provenance (`"MANUAL"` | `"OCR"`); it defaults to `"MANUAL"` when omitted, so the legacy hand-entry contract is unchanged. The on-device-OCR client posts `"OCR"` after the human confirms the parsed values — the raw image never touches the server on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.',
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createLabResultSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Created lab result.",
|
||||
@@ -232,6 +236,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Restore soft-deleted lab results",
|
||||
description:
|
||||
"Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A foreign / live id is a silent no-op. Audits via the wide-event ledger.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -243,6 +248,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Restore count.",
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -61,6 +63,7 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
||||
summary: "Create a Vorsorge reminder (v1.17.1)",
|
||||
description:
|
||||
"Creates a reminder and computes its server-authoritative nextDueAt. Exactly one of intervalDays (rolling) or rrule (RFC-5545) is required. Wrapped in withIdempotency. 201 on insert.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -68,6 +71,7 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Reminder created.",
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
MODULE_DISABLED_DESCRIPTION,
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -764,6 +766,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Create one measurement (or a small array)",
|
||||
description:
|
||||
"Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -776,6 +779,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description:
|
||||
@@ -889,6 +893,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"/api/measurements/batch": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Measurements"],
|
||||
summary: "Apple Health batch ingest",
|
||||
description:
|
||||
@@ -898,6 +903,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: batchPayloadSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "Batch processed.",
|
||||
content: {
|
||||
@@ -940,6 +946,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"/api/measurements/bulk-delete": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Measurements"],
|
||||
summary: "Bulk soft-delete measurements",
|
||||
description:
|
||||
@@ -951,6 +958,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Bulk delete processed.",
|
||||
@@ -969,6 +977,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"/api/measurements/restore": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Measurements"],
|
||||
summary: "Restore soft-deleted measurements",
|
||||
description:
|
||||
@@ -980,6 +989,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Restore processed.",
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
conflictResponse409,
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
invalidBaseTokenResponse,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
@@ -171,6 +173,7 @@ const medicationIntakeImportJobStatusEnvelope = dataEnvelope(
|
||||
export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/medications/intake/bulk": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Medications"],
|
||||
summary: "Bulk medication-intake backfill (iOS SyncMode)",
|
||||
description:
|
||||
@@ -180,6 +183,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: bulkIntakePayload } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "Batch processed (always 200 on a well-formed body).",
|
||||
content: {
|
||||
@@ -410,6 +414,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"/api/medications/{id}/intake": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Medications"],
|
||||
summary: "Log an intake event for a medication",
|
||||
description:
|
||||
@@ -422,6 +427,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: intakeSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Intake event created.",
|
||||
|
||||
@@ -19,7 +19,13 @@ import {
|
||||
listAssessmentsSchema,
|
||||
} from "@/lib/validations/mental-health";
|
||||
|
||||
import { dataEnvelope, recordRefusal, stdResponses } from "./shared";
|
||||
import {
|
||||
dataEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
createAssessmentSchema.meta({
|
||||
id: "CreateMentalHealthAssessmentRequest",
|
||||
@@ -127,12 +133,14 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Record a completed screener",
|
||||
description:
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: createAssessmentSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Assessment recorded.",
|
||||
|
||||
@@ -46,15 +46,17 @@ import {
|
||||
WORK_STATUS_KEYS,
|
||||
} from "@/lib/mood/context-vocabulary";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
MODULE_DISABLED_DESCRIPTION,
|
||||
baseUpdatedAtField,
|
||||
updatedAtTokenField,
|
||||
conflictResponse409,
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
invalidBaseTokenResponse,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
updatedAtTokenField,
|
||||
} from "./shared";
|
||||
|
||||
// ── Request schemas — annotated for spec emission ────────────────────
|
||||
@@ -762,6 +764,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"/api/mood-entries/bulk": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Mood"],
|
||||
summary: "Bulk mood backfill (iOS SyncMode)",
|
||||
description:
|
||||
@@ -771,6 +774,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: bulkMoodPayload } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "Batch processed (always 200 on a well-formed body).",
|
||||
content: {
|
||||
@@ -1080,6 +1084,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Mood"],
|
||||
summary: "Create a mood entry",
|
||||
description:
|
||||
@@ -1089,6 +1094,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: createMoodEntryRequest } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"201": {
|
||||
description:
|
||||
"Entry created (or upserted in place via `externalId`), with its persisted tag keys, rated factors and context.",
|
||||
@@ -1165,6 +1171,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"/api/mood-entries/restore": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Mood"],
|
||||
summary: "Restore soft-deleted mood entries",
|
||||
description:
|
||||
@@ -1174,6 +1181,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: moodEntryIdsPayload } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "`restored` counts the rows actually un-tombstoned.",
|
||||
content: {
|
||||
@@ -1192,6 +1200,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"/api/mood-entries/bulk-delete": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Mood"],
|
||||
summary: "Soft-delete mood entries in bulk",
|
||||
description:
|
||||
@@ -1201,6 +1210,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: moodEntryIdsPayload } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "`deleted` counts the rows actually tombstoned.",
|
||||
content: {
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -50,6 +52,7 @@ const catalogTable = NUTRIENT_DEFINITIONS.map(
|
||||
export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/nutrients/batch": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Nutrients"],
|
||||
summary: "Ingest micronutrient day totals (v1.28)",
|
||||
description:
|
||||
@@ -60,6 +63,7 @@ export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: nutrientBatchSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description:
|
||||
"Batch processed; per-entry statuses inserted | updated | skipped.",
|
||||
@@ -127,6 +131,7 @@ export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"/api/nutrients/water": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Nutrients"],
|
||||
summary: "Manual water quick-add (v1.29)",
|
||||
description:
|
||||
@@ -138,6 +143,7 @@ export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "The MANUAL water row after the write.",
|
||||
content: {
|
||||
|
||||
@@ -17,7 +17,12 @@ import { z } from "zod/v4";
|
||||
|
||||
import { ocrCommitSchema } from "@/lib/validations/labs-ocr";
|
||||
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
import {
|
||||
dataEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
ocrCommitSchema.meta({
|
||||
id: "OcrCommitRequest",
|
||||
@@ -197,6 +202,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"/api/labs/ocr/commit": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Labs"],
|
||||
summary: "Commit confirmed Lab-OCR rows",
|
||||
description:
|
||||
@@ -206,6 +212,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: ocrCommitSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "Inserted + skipped rows.",
|
||||
content: {
|
||||
|
||||
@@ -177,6 +177,99 @@ export const stdResponses = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── Idempotent writes ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What a route wrapped in `withIdempotency` accepts and answers.
|
||||
*
|
||||
* The mechanism has worked since long before it was published: send
|
||||
* `Idempotency-Key` on a write, and a retry of the same key replays the first
|
||||
* response instead of doing the work twice. It was described in route comments
|
||||
* and nowhere a client could read it, so an app generated against the contract
|
||||
* had no way to know a retry was safe. This publishes it.
|
||||
*
|
||||
* Three things a client cannot infer and has to be told:
|
||||
*
|
||||
* 1. **A malformed key is ignored, not refused.** `getIdempotencyKey` tests
|
||||
* `^[A-Za-z0-9_\-:.]{8,128}$` and returns null on a miss, so a key that is
|
||||
* too short or carries a stray character is indistinguishable from sending
|
||||
* none at all — the write proceeds, unprotected, with a 200. This is the
|
||||
* one failure here that is silent, which is why it leads.
|
||||
* 2. **The replay is marked.** A response served from the cache carries
|
||||
* `X-Idempotent-Replay: true`. Without reading it a client cannot tell a
|
||||
* fresh 201 from a replayed one, which matters when the body reports what
|
||||
* was created.
|
||||
* 3. **A concurrent duplicate is refused, not queued.** While the first
|
||||
* request is still running, a second one under the same key gets 409 with
|
||||
* `X-Idempotent-Replay: false` — the header is present in both directions
|
||||
* precisely so the two cases are told apart. Retry after a delay.
|
||||
*
|
||||
* Scope, so nobody reads more into it than is there: only POST, PUT, PATCH and
|
||||
* DELETE are covered (`SUPPORTED_METHODS`), the key is scoped per user and per
|
||||
* `(method, path)`, and `isCachableStatus` refuses to cache 5xx or the
|
||||
* transient 401 / 403 / 408 / 429 — a retry after one of those runs for real.
|
||||
*
|
||||
* `src/__tests__/idempotency-contract-publication.test.ts` holds this to the
|
||||
* routes that actually wrap `withIdempotency`, in both directions, so a new
|
||||
* idempotent route that forgets to say so fails, and so does a claim here for
|
||||
* a route that dropped the wrapper.
|
||||
*/
|
||||
export const idempotencyKeyParameter = {
|
||||
name: "Idempotency-Key",
|
||||
in: "header" as const,
|
||||
required: false,
|
||||
schema: {
|
||||
type: "string" as const,
|
||||
pattern: "^[A-Za-z0-9_\\-:.]{8,128}$",
|
||||
minLength: 8,
|
||||
maxLength: 128,
|
||||
},
|
||||
description:
|
||||
"Makes this write safe to retry. Send the same key again and the first response is replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different route is a different key. While a first request under this key is still in flight, a second is refused with 409 rather than queued.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Response headers are Zod here, not an OpenAPI literal — `zod-openapi` types
|
||||
* `headers` as a `ZodObject` and emits the schema from it. Writing the literal
|
||||
* shape by hand typechecks nowhere and was the first thing this file got wrong.
|
||||
*/
|
||||
const idempotentReplayHeaders = z.object({
|
||||
"X-Idempotent-Replay": z.enum(["true", "false"]).meta({
|
||||
description:
|
||||
"`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that refuses a duplicate still in flight. Absent when the request carried no usable key.",
|
||||
}),
|
||||
});
|
||||
|
||||
interface IdempotentConflictResponse {
|
||||
"409": {
|
||||
description: string;
|
||||
content: { "application/json": { schema: typeof errorEnvelope } };
|
||||
headers: typeof idempotentReplayHeaders;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The 409 an idempotent route answers while an earlier request under the same
|
||||
* key is still running, plus the replay header on the way back.
|
||||
*
|
||||
* Cached by identity like {@link recordRefusal} above, for the same reason: a
|
||||
* fresh object per call site would print this paragraph thirty-seven times.
|
||||
*/
|
||||
let idempotentConflictCache: IdempotentConflictResponse | null = null;
|
||||
|
||||
export function idempotentWrite(): IdempotentConflictResponse {
|
||||
if (idempotentConflictCache) return idempotentConflictCache;
|
||||
idempotentConflictCache = {
|
||||
"409": {
|
||||
description:
|
||||
"A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
headers: idempotentReplayHeaders,
|
||||
},
|
||||
};
|
||||
return idempotentConflictCache;
|
||||
}
|
||||
|
||||
// ── The delegated-record refusal (v1.37.0) ───────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,8 @@ import { measurementReminderDto } from "@/lib/validations/measurement-reminders"
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -237,6 +239,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Records"],
|
||||
summary: "Log a vaccination",
|
||||
description:
|
||||
@@ -246,6 +249,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: vaccinationCreateSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Dose logged.",
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
MODULE_DISABLED_DESCRIPTION,
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
moduleDisabledResponse,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
@@ -309,6 +311,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"/api/workouts/batch": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Measurements"],
|
||||
summary: "Typed workout batch ingest (v1.4.25 W16b)",
|
||||
description:
|
||||
@@ -318,6 +321,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: createBatchWorkoutSchema } },
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "Batch processed.",
|
||||
content: {
|
||||
|
||||
Reference in new issue
Block a user