diff --git a/CHANGELOG.md b/CHANGELOG.md index 42cd455f1b..843c901fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ _TBD_ ### 📝 Maintenance -_TBD_ +- **API validation:** add a `validatedJsonBody(request, schema)` helper in `src/shared/validation/helpers.ts` that fuses JSON body parsing and Zod validation into a single call, returning either the type-narrowed data or a ready-to-return 400 `NextResponse` with the standard error envelope. Salvaged from the closed refactor PR #5075 (Tier 1 portable helper) with a focused 6-case regression test. Co-authored-by: KooshaPari --- diff --git a/src/shared/validation/helpers.ts b/src/shared/validation/helpers.ts index 395388b314..577041bada 100644 --- a/src/shared/validation/helpers.ts +++ b/src/shared/validation/helpers.ts @@ -1,3 +1,4 @@ +import { NextResponse } from "next/server"; import { z } from "zod"; type ValidationErrorDetail = { @@ -54,3 +55,63 @@ export function isValidationFailure( ): validation is ValidationFailure { return validation.success === false; } + +/** + * Result of attempting to parse and validate a JSON body against a Zod schema. + * + * On failure, `response` is a fully-prepared `NextResponse` (with the standard + * error envelope) that the caller should return directly, so route handlers can + * do `if (!r.success) return r.response;` without knowing the envelope shape. + */ +export type ValidatedJsonBodyResult = + | { success: true; data: TData } + | { success: false; response: NextResponse }; + +/** + * Parse a request body as JSON and validate it against a Zod schema in one + * step. Returns the parsed (and type-narrowed) data on success, or a ready-to- + * return 400 `NextResponse` on failure. Both the malformed-JSON and the failed- + * validation paths emit the same error envelope + * (`{ error: { message, details: [{ field, message }] } }`), so a single client + * parser covers both. + * + * Usage: + * + * ```ts + * const result = await validatedJsonBody(request, updateComboSchema); + * if (!result.success) return result.response; + * const body = result.data; // typed as z.infer + * ``` + */ +export async function validatedJsonBody( + request: Request, + schema: TSchema +): Promise>> { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return { + success: false, + response: NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ), + }; + } + + const validation = validateBody(schema, raw); + if (validation.success) { + return { success: true, data: validation.data }; + } + + return { + success: false, + response: NextResponse.json({ error: validation.error }, { status: 400 }), + }; +} diff --git a/tests/unit/api/validated-json-body.test.ts b/tests/unit/api/validated-json-body.test.ts new file mode 100644 index 0000000000..f8076dbad0 --- /dev/null +++ b/tests/unit/api/validated-json-body.test.ts @@ -0,0 +1,91 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; +import { validatedJsonBody } from "@/shared/validation/helpers"; + +function makeRequest(body: string, contentType = "application/json"): Request { + return new Request("http://localhost/test", { + method: "POST", + headers: { "content-type": contentType }, + body, + }); +} + +describe("validatedJsonBody", () => { + const schema = z.object({ + name: z.string().min(1), + count: z.number().int().nonnegative(), + }); + + test("returns the parsed and validated data on success", async () => { + const result = await validatedJsonBody(makeRequest('{"name":"hello","count":3}'), schema); + assert.equal(result.success, true); + if (result.success) { + assert.deepEqual(result.data, { name: "hello", count: 3 }); + } + }); + + test("returns a 400 with structured details when the body fails Zod validation", async () => { + const result = await validatedJsonBody(makeRequest('{"name":"","count":-1}'), schema); + assert.equal(result.success, false); + if (!result.success) { + assert.equal(result.response.status, 400); + const body = await result.response.json(); + assert.equal(body.error.message, "Invalid request"); + assert.ok(Array.isArray(body.error.details)); + const fields = body.error.details.map((d: { field: string }) => d.field); + assert.ok(fields.includes("name")); + assert.ok(fields.includes("count")); + } + }); + + test("returns a 400 with a body-parse failure for malformed JSON", async () => { + const result = await validatedJsonBody(makeRequest("not json at all"), schema); + assert.equal(result.success, false); + if (!result.success) { + assert.equal(result.response.status, 400); + const body = await result.response.json(); + assert.deepEqual(body, { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }); + } + }); + + test("returns a 400 for an empty body", async () => { + const result = await validatedJsonBody(makeRequest(""), schema); + assert.equal(result.success, false); + if (!result.success) { + assert.equal(result.response.status, 400); + } + }); + + test("returns a 400 when required fields are missing entirely", async () => { + const result = await validatedJsonBody(makeRequest("{}"), schema); + assert.equal(result.success, false); + if (!result.success) { + assert.equal(result.response.status, 400); + const body = await result.response.json(); + const fields = body.error.details.map((d: { field: string }) => d.field); + assert.ok(fields.includes("name")); + assert.ok(fields.includes("count")); + } + }); + + test("preserves the same envelope shape between parse and validate failure", async () => { + const parseFailure = await validatedJsonBody(makeRequest("nope"), schema); + const validateFailure = await validatedJsonBody(makeRequest("{}"), schema); + assert.equal(parseFailure.success, false); + assert.equal(validateFailure.success, false); + if (!parseFailure.success && !validateFailure.success) { + const parseBody = await parseFailure.response.json(); + const validateBody = await validateFailure.response.json(); + assert.equal(typeof parseBody.error.message, "string"); + assert.equal(typeof validateBody.error.message, "string"); + assert.ok(Array.isArray(parseBody.error.details)); + assert.ok(Array.isArray(validateBody.error.details)); + } + }); +});