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 }; +}