mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
17d8cb9f67
commit
4e6918530a
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
91
tests/unit/api/validated-json-body.test.ts
Normal file
91
tests/unit/api/validated-json-body.test.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user