refactor(api): add validatedJsonBody helper (salvage #5075) (#5931)

Fuses JSON body parsing + Zod validation into a single call that returns
either type-narrowed data or a ready-to-return 400 NextResponse with the
standard error envelope. Salvaged as the Tier 1 portable helper from the
closed refactor PR #5075; the bulk route migration is intentionally not
ported. Adds a focused 6-case regression test.

Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 20:27:47 -03:00
committed by GitHub
parent 17d8cb9f67
commit 4e6918530a
3 changed files with 153 additions and 1 deletions

View File

@@ -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 <KooshaPari@users.noreply.github.com>
---

View File

@@ -1,3 +1,4 @@
import { NextResponse } from "next/server";
import { z } from "zod";
type ValidationErrorDetail = {
@@ -54,3 +55,63 @@ export function isValidationFailure<TData>(
): 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<TData> =
| { 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<typeof updateComboSchema>
* ```
*/
export async function validatedJsonBody<TSchema extends z.ZodTypeAny>(
request: Request,
schema: TSchema
): Promise<ValidatedJsonBodyResult<z.infer<TSchema>>> {
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 }),
};
}

View File

@@ -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));
}
});
});