From fd8fc0ec0e811e0b1e5d18c0ad94fb11b0881b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 04:03:02 +0200 Subject: [PATCH] docs(api): publish `details.issues` and open `meta` on the error envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every documented 422, 401, 403, 409, 410 and 429 points at one `ErrorEnvelope`, and that schema declared `data`, `error` and a `meta` closed to `requestId` + `errorCode`. Two things were wrong with it. `returnAllZodIssues` has sent a third top-level key since v1.4.42 — `details.issues`, one entry per rejected field — from 166 route files. It exists so a client can fix three bad fields in one round-trip instead of three, and the doc comment says so, yet no component schema anywhere declared it. A client generated from the contract had no field to read it from, so the feature was invisible to exactly the consumer it was built for. It is now a schema, with the `unrecognized_keys` list and the promise that no rejected value is echoed back. And `meta` was closed while real refusals put more in it: `removedIn` / `replacedBy` on a 410, `module` beside `module.disabled`, the per-integration context on a failed connection test. Closed plus those keys means the response was invalid against its own published schema and a strict decoder is entitled to reject it. It is open now. No wire change; both halves describe what the server already sends. --- docs/api/openapi.yaml | 48 +++++++++++++++++++++++-- src/lib/openapi/routes/shared.ts | 62 ++++++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 580ddaf59..71a91a954 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29018,19 +29018,63 @@ components: type: "null" error: type: string + details: + description: "Present on a 422 raised by the multi-issue validator: every field the request got wrong, not only the + first. Absent on every other status." + type: object + properties: + issues: + type: array + items: + $ref: "#/components/schemas/ValidationIssue" + required: + - issues + additionalProperties: false meta: type: object properties: requestId: type: string errorCode: + description: Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be + reworded. type: string - additionalProperties: false + additionalProperties: {} required: - data - error additionalProperties: false - description: "Standard error response: data is null, error is human prose." + description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable + machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue + validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in + one round-trip." + ValidationIssue: + type: object + properties: + path: + type: string + description: Dot-joined path to the rejected field, e.g. `entries.3.measuredAt`. Empty string when the whole body was + rejected. + code: + type: string + description: Zod issue code, e.g. `invalid_type`, `too_small`, `unrecognized_keys`. Treat an unfamiliar code as a + generic rejection of `path`. + message: + type: string + description: English sentence describing the rejection. Safe to log; show the user your own wording keyed on `path`. + keys: + description: "Only on `unrecognized_keys`: the refused key names, bounded to 10 and 64 characters each. Zod reports + every unknown key of one object in a single issue whose `path` is the object, so the names live here and + nowhere in `path`." + type: array + items: + type: string + required: + - path + - code + - message + additionalProperties: false + description: "One rejected field in a multi-issue 422. Sanitised: no rejected VALUE is echoed back." CycleCustomSymptomCreatedEnvelope: type: object properties: diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index dad0f7624..f75a366a9 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -21,20 +21,76 @@ import { createBatchWorkoutSchema as createBatchWorkoutSchemaBase } from "@/lib/ * `{ data, error, meta? }`. The OpenAPI surface mirrors that contract * so iOS / external-ingest clients can decode uniformly. */ +/** + * One rejected field from a multi-issue 422. + * + * `returnAllZodIssues` has emitted this since v1.4.42 so a client can fix + * three bad fields in one round-trip instead of three, and 166 route files + * send it — but no component schema declared it, so the very consumer it was + * built for could not read it from the contract. `params` never ships: some + * Zod codes embed the offending value in it and that is user content. + */ +const validationIssue = z + .object({ + path: z + .string() + .describe( + "Dot-joined path to the rejected field, e.g. `entries.3.measuredAt`. Empty string when the whole body was rejected.", + ), + code: z + .string() + .describe( + "Zod issue code, e.g. `invalid_type`, `too_small`, `unrecognized_keys`. Treat an unfamiliar code as a generic rejection of `path`.", + ), + message: z + .string() + .describe( + "English sentence describing the rejection. Safe to log; show the user your own wording keyed on `path`.", + ), + keys: z + .array(z.string()) + .optional() + .describe( + "Only on `unrecognized_keys`: the refused key names, bounded to 10 and 64 characters each. Zod reports every unknown key of one object in a single issue whose `path` is the object, so the names live here and nowhere in `path`.", + ), + }) + .meta({ + id: "ValidationIssue", + description: + "One rejected field in a multi-issue 422. Sanitised: no rejected VALUE is echoed back.", + }); + export const errorEnvelope = z .object({ data: z.null(), error: z.string(), + details: z + .object({ issues: z.array(validationIssue) }) + .optional() + .describe( + "Present on a 422 raised by the multi-issue validator: every field the request got wrong, not only the first. Absent on every other status.", + ), + // Loose on purpose. Real refusals put more than the two named keys here — + // `removedIn` / `replacedBy` on a 410, `module` beside `module.disabled`, + // the per-integration context on a failed connection test — and a closed + // object made the body invalid against its own published schema, which any + // strict generated decoder is entitled to reject. meta: z - .object({ + .looseObject({ requestId: z.string().optional(), - errorCode: z.string().optional(), + errorCode: z + .string() + .optional() + .describe( + "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded.", + ), }) .optional(), }) .meta({ id: "ErrorEnvelope", - description: "Standard error response: data is null, error is human prose.", + description: + "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in one round-trip.", }); export function dataEnvelope(payload: T, id: string) {