docs(api): publish details.issues and open meta on the error envelope

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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:03:02 +02:00
1 parent 1d2744a109
commit fd8fc0ec0e
2 files changed
+105 -5

No files matched your search

+46 -2
View File
@@ -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:
+59 -3
View File
@@ -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<T extends z.ZodType>(payload: T, id: string) {