From 07e1e83ee886c4cad8a0746217cf9753fcc375b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:12:38 +0200 Subject: [PATCH] feat(api): read and validate a JSON request body in one call parseJsonBody composes safeJson, the Zod safeParse, the validation-failed wide-event action and the multi-issue 422 envelope, with the variations routes use (size cap, input derivation, issue count, error code, message, status, an onInvalid hook) as options. The refusals are the existing helpers' bytes, which the unit test compares directly. --- src/lib/api/__tests__/body.test.ts | 151 +++++++++++++++++++++++++++++ src/lib/api/body.ts | 131 +++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 src/lib/api/__tests__/body.test.ts create mode 100644 src/lib/api/body.ts diff --git a/src/lib/api/__tests__/body.test.ts b/src/lib/api/__tests__/body.test.ts new file mode 100644 index 000000000..3353f22d8 --- /dev/null +++ b/src/lib/api/__tests__/body.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod/v4"; + +import { parseJsonBody } from "@/lib/api/body"; +import { + apiValidationError, + returnAllZodIssues, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { WideEventBuilder } from "@/lib/logging/event-builder"; +import { eventStorage } from "@/lib/logging/context"; + +const schema = z.object({ name: z.string().min(1), count: z.number().int() }); + +function post(body: string, contentType = "application/json"): Request { + return new Request("http://localhost/api/x", { + method: "POST", + headers: { "content-type": contentType }, + body, + }); +} + +/** Status and JSON body of a response, for byte-level comparison. */ +async function snapshot(res: Response) { + return { status: res.status, body: await res.text() }; +} + +function issuesOf(input: unknown) { + const result = schema.safeParse(input); + if (result.success) throw new Error("fixture must be invalid"); + return result.error; +} + +async function withEvent(fn: () => Promise) { + const evt = new WideEventBuilder("http"); + const result = await eventStorage.run(evt, fn); + return { result, event: evt.toJSON() }; +} + +describe("parseJsonBody", () => { + it("returns the schema's output and the decoded body", async () => { + const parsed = await parseJsonBody( + post(JSON.stringify({ name: "a", count: 2, extra: true })), + schema, + ); + expect(parsed).toEqual({ + ok: true, + data: { name: "a", count: 2 }, + body: { name: "a", count: 2, extra: true }, + }); + }); + + it("refuses a wrong content type, an oversized body and malformed JSON as safeJson does", async () => { + const wrongType = await parseJsonBody(post("{}", "text/plain"), schema); + const tooLarge = await parseJsonBody( + post(`{"name":"${"x".repeat(64)}"}`), + schema, + { + maxBytes: 16, + }, + ); + const malformed = await parseJsonBody(post("{"), schema); + const statuses = [wrongType, tooLarge, malformed].map((p) => + p.ok ? 0 : p.error.status, + ); + expect(statuses).toEqual([415, 413, 400]); + }); + + it("answers a validation failure with returnAllZodIssues' exact bytes", async () => { + const input = { name: "", count: 1.5 }; + const parsed = await parseJsonBody(post(JSON.stringify(input)), schema); + if (parsed.ok) throw new Error("expected a refusal"); + expect(await snapshot(parsed.error)).toEqual( + await snapshot(returnAllZodIssues(issuesOf(input), 422)), + ); + }); + + it("carries the error code, status and message the route names", async () => { + const input = { name: 3 }; + const coded = await parseJsonBody(post(JSON.stringify(input)), schema, { + errorCode: "thing.invalid", + }); + const worded = await parseJsonBody(post(JSON.stringify(input)), schema, { + message: "Invalid input", + errorCode: "thing.invalid", + status: 400, + }); + if (coded.ok || worded.ok) throw new Error("expected refusals"); + expect(await snapshot(coded.error)).toEqual( + await snapshot( + returnAllZodIssues(issuesOf(input), 422, { + errorCode: "thing.invalid", + }), + ), + ); + expect(await snapshot(worded.error)).toEqual( + await snapshot( + apiValidationError( + "Invalid input", + sanitiseZodIssues(issuesOf(input).issues), + 400, + { errorCode: "thing.invalid" }, + ), + ), + ); + }); + + it("validates what `input` derives from the body", async () => { + const parsed = await parseJsonBody(post("null"), schema.partial(), { + input: (body) => body ?? {}, + }); + expect(parsed).toEqual({ ok: true, data: {}, body: null }); + }); + + it("stamps the action, the issue count and the extra meta on a refusal only", async () => { + const options = { + action: "thing.validation-failed", + countIssues: true, + meta: { thingId: "t1" }, + }; + const refused = await withEvent(() => + parseJsonBody(post(JSON.stringify({ name: "" })), schema, options), + ); + expect(refused.event.action).toEqual({ name: "thing.validation-failed" }); + expect(refused.event.meta).toMatchObject({ issue_count: 2, thingId: "t1" }); + + const accepted = await withEvent(() => + parseJsonBody( + post(JSON.stringify({ name: "a", count: 1 })), + schema, + options, + ), + ); + expect(accepted.event.action).toBeUndefined(); + }); + + it("runs onInvalid with the error and the body before answering", async () => { + const seen: unknown[] = []; + const parsed = await parseJsonBody( + post(JSON.stringify({ count: "x" })), + schema, + { + onInvalid: (error, body) => { + seen.push(error.issues.length, body); + }, + }, + ); + expect(parsed.ok).toBe(false); + expect(seen).toEqual([2, { count: "x" }]); + }); +}); diff --git a/src/lib/api/body.ts b/src/lib/api/body.ts new file mode 100644 index 000000000..f91a3a8a3 --- /dev/null +++ b/src/lib/api/body.ts @@ -0,0 +1,131 @@ +/** + * Read a JSON request body and validate it against a Zod schema, in one call. + * + * Every body-accepting route did the same four steps by hand: `safeJson` + * (415 on a wrong content type, 413 over the size cap, 400 on malformed + * JSON), return its error, `schema.safeParse(body)`, and on failure answer + * the multi-issue 422 envelope (`returnAllZodIssues`), often after stamping + * a validation-failed action on the wide event. This is those steps, with + * the variations the routes actually use named as options, so a route reads + * as what it accepts and how it refuses rather than as the plumbing. + * + * ```ts + * const parsed = await parseJsonBody(request, createThingSchema, { + * maxBytes: 16 * 1024, + * action: "thing.validation-failed", + * countIssues: true, + * errorCode: "thing.invalid", + * }); + * if (!parsed.ok) return parsed.error; + * const input = parsed.data; + * ``` + * + * The refusals are the existing helpers' bytes: `safeJson`'s 415/413/400, + * `returnAllZodIssues` for the 422 (or `apiValidationError` when the route + * names its own top-level `message`). `src/__tests__/route-body-parse-ratchet.test.ts` + * counts the routes that still call `safeJson` themselves; the count may + * only fall. + */ +import type { NextResponse } from "next/server"; +import type { z, ZodError } from "zod/v4"; + +import { + apiValidationError, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +type ErrorMeta = NonNullable[2]>; + +export interface ParseJsonBodyOptions { + /** Size cap in bytes; a larger body is refused 413 before parsing. */ + maxBytes?: number; + /** + * What the schema is given, from the decoded body. Defaults to the body + * itself; a route that validates `body ?? {}`, or merges a path parameter + * in, says so here. + */ + input?: (body: unknown) => unknown; + /** Wide-event action stamped on a validation refusal. */ + action?: string; + /** + * Put `issue_count` (the number of Zod issues) on the refusal's wide-event + * meta. Needs `action`. + */ + countIssues?: boolean; + /** Further wide-event meta on the refusal, after `issue_count`. Needs `action`. */ + meta?: Record; + /** `meta.errorCode` on the 422 envelope. */ + errorCode?: string; + /** + * The envelope's top-level `error` sentence. Unset, it is + * `returnAllZodIssues`'s "Validation failed". + */ + message?: string; + /** The refusal's status. Defaults to 422. */ + status?: number; + /** + * Runs on a validation refusal, after the wide-event action and before the + * envelope is built: an audit row, a breadcrumb the options above cannot + * express. Receives the Zod error and the decoded body. + */ + onInvalid?: (error: ZodError, body: unknown) => void | Promise; +} + +export type ParsedJsonBody = + | { + ok: true; + /** The schema's output. */ + data: T; + /** The decoded body as the client sent it, before validation. */ + body: unknown; + } + | { ok: false; error: NextResponse | Response }; + +export async function parseJsonBody( + request: Request, + schema: S, + options: ParseJsonBodyOptions = {}, +): Promise>> { + const json = await safeJson( + request, + options.maxBytes === undefined ? undefined : { maxBytes: options.maxBytes }, + ); + if (json.error) return { ok: false, error: json.error }; + const body: unknown = json.data; + + const parsed = schema.safeParse(options.input ? options.input(body) : body); + if (parsed.success) return { ok: true, data: parsed.data, body }; + + if (options.action) { + const meta = { + ...(options.countIssues + ? { issue_count: parsed.error.issues.length } + : {}), + ...options.meta, + }; + annotate( + Object.keys(meta).length > 0 + ? { action: { name: options.action }, meta } + : { action: { name: options.action } }, + ); + } + if (options.onInvalid) await options.onInvalid(parsed.error, body); + + const status = options.status ?? 422; + const meta: ErrorMeta | undefined = options.errorCode + ? { errorCode: options.errorCode } + : undefined; + const error = + options.message === undefined + ? returnAllZodIssues(parsed.error, status, meta) + : apiValidationError( + options.message, + sanitiseZodIssues(parsed.error.issues), + status, + meta, + ); + return { ok: false, error }; +}