API error contract

This commit is contained in:
adilallo
2026-04-22 19:15:04 -06:00
parent 4d066dad0e
commit 5457d3554b
18 changed files with 717 additions and 117 deletions
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { afterEach, describe, expect, it, vi } from "vitest";
const errorMock = vi.fn();
vi.mock("../../lib/logger", () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: (...args: unknown[]) => errorMock(...args),
},
}));
import { apiRoute } from "../../lib/server/apiRoute";
import { REQUEST_ID_HEADER } from "../../lib/server/requestId";
afterEach(() => {
errorMock.mockReset();
});
function makeReq(headers: Record<string, string> = {}): NextRequest {
return new NextRequest("https://x.test/api/x", { headers });
}
describe("lib/server/apiRoute", () => {
it("attaches a generated x-request-id to a successful response", async () => {
const handler = apiRoute("test.scope", () =>
NextResponse.json({ ok: true }),
);
const res = await handler(makeReq(), undefined);
expect(res.status).toBe(200);
const id = res.headers.get(REQUEST_ID_HEADER);
expect(id).toBeTruthy();
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it("forwards an incoming x-request-id and exposes it to the handler", async () => {
const incoming = "req_forwarded-1";
let seen: string | undefined;
const handler = apiRoute("test.scope", (_req, _ctx, { requestId }) => {
seen = requestId;
return NextResponse.json({ ok: true });
});
const res = await handler(
makeReq({ [REQUEST_ID_HEADER]: incoming }),
undefined,
);
expect(seen).toBe(incoming);
expect(res.headers.get(REQUEST_ID_HEADER)).toBe(incoming);
});
it("returns canonical 500 + logs when the handler throws", async () => {
const handler = apiRoute("test.scope", () => {
throw new Error("boom");
});
const res = await handler(makeReq(), undefined);
expect(res.status).toBe(500);
const body = (await res.json()) as {
error: { code: string; message: string };
};
expect(body.error.code).toBe("internal_error");
expect(res.headers.get(REQUEST_ID_HEADER)).toBeTruthy();
expect(errorMock).toHaveBeenCalledTimes(1);
const payload = errorMock.mock.calls[0][0] as Record<string, unknown>;
expect(payload.scope).toBe("test.scope");
expect(payload.requestId).toBe(res.headers.get(REQUEST_ID_HEADER));
expect(payload.message).toBe("boom");
});
it("passes the route ctx through to the handler", async () => {
type Ctx = { params: Promise<{ id: string }> };
const handler = apiRoute<Ctx>("test.scope", async (_req, ctx) => {
const { id } = await ctx.params;
return NextResponse.json({ id });
});
const res = await handler(makeReq(), {
params: Promise.resolve({ id: "abc" }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { id: string };
expect(body.id).toBe("abc");
});
});
+100
View File
@@ -0,0 +1,100 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const isDatabaseConfiguredMock = vi.fn();
const getSessionUserMock = vi.fn();
const findUniqueMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
ruleDraft: {
findUnique: (...args: unknown[]) => findUniqueMock(...args),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
},
}));
vi.mock("../../lib/server/session", () => ({
getSessionUser: () => getSessionUserMock(),
}));
import { GET } from "../../app/api/drafts/me/route";
beforeEach(() => {
isDatabaseConfiguredMock.mockReset();
getSessionUserMock.mockReset();
findUniqueMock.mockReset();
});
describe("GET /api/drafts/me", () => {
it("returns 503 with the canonical shape when the database is not configured", async () => {
isDatabaseConfiguredMock.mockReturnValue(false);
const res = await GET(
new NextRequest("https://x.test/api/drafts/me"),
undefined,
);
expect(res.status).toBe(503);
const body = (await res.json()) as {
error: { code: string; message: string };
};
expect(body.error.code).toBe("db_unavailable");
expect(res.headers.get("x-request-id")).toBeTruthy();
});
it("returns 401 unauthorized with the canonical shape when no session user", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValueOnce(null);
const res = await GET(
new NextRequest("https://x.test/api/drafts/me"),
undefined,
);
expect(res.status).toBe(401);
const body = (await res.json()) as {
error: { code: string; message: string };
};
expect(body.error).toEqual({
code: "unauthorized",
message: "Unauthorized",
});
expect(res.headers.get("x-request-id")).toBeTruthy();
expect(findUniqueMock).not.toHaveBeenCalled();
});
it("forwards an incoming x-request-id on the response", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValueOnce(null);
const res = await GET(
new NextRequest("https://x.test/api/drafts/me", {
headers: { "x-request-id": "req_drafts-1" },
}),
undefined,
);
expect(res.headers.get("x-request-id")).toBe("req_drafts-1");
});
it("returns the draft when present", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValueOnce({
id: "u1",
email: "x@y.test",
});
findUniqueMock.mockResolvedValueOnce({
payload: { foo: 1 },
updatedAt: new Date("2026-01-01T00:00:00Z"),
});
const res = await GET(
new NextRequest("https://x.test/api/drafts/me"),
undefined,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
draft: { payload: { foo: number } } | null;
};
expect(body.draft?.payload).toEqual({ foo: 1 });
});
});
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { describe, expect, it } from "vitest";
import {
REQUEST_ID_HEADER,
getOrCreateRequestId,
withRequestId,
} from "../../lib/server/requestId";
function reqWith(headers: Record<string, string>): Request {
return new Request("https://x.test/api/x", { headers });
}
describe("lib/server/requestId", () => {
it("generates a UUID when no header is present", () => {
const id = getOrCreateRequestId(reqWith({}));
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it("preserves a well-formed incoming x-request-id", () => {
const incoming = "req_abc-123.456";
const id = getOrCreateRequestId(reqWith({ [REQUEST_ID_HEADER]: incoming }));
expect(id).toBe(incoming);
});
it("trims surrounding whitespace from a well-formed id", () => {
const id = getOrCreateRequestId(
reqWith({ [REQUEST_ID_HEADER]: " abc-123 " }),
);
expect(id).toBe("abc-123");
});
it("rejects oversized ids and falls back to a UUID", () => {
const huge = "a".repeat(200);
const id = getOrCreateRequestId(reqWith({ [REQUEST_ID_HEADER]: huge }));
expect(id).not.toBe(huge);
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it("rejects ids with disallowed characters", () => {
// Spaces, semicolons, slashes are valid HTTP header bytes but disallowed
// by our `[A-Za-z0-9_.-]` pattern.
const bad = "abc def;<script>";
const id = getOrCreateRequestId(reqWith({ [REQUEST_ID_HEADER]: bad }));
expect(id).not.toBe(bad);
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it("withRequestId attaches the header to a NextResponse", () => {
const res = NextResponse.json({ ok: true });
const out = withRequestId(res, "req_xyz");
expect(out).toBe(res);
expect(out.headers.get(REQUEST_ID_HEADER)).toBe("req_xyz");
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
dbUnavailable,
errorJson,
internalError,
notFound,
rateLimited,
serverMisconfigured,
unauthorized,
} from "../../lib/server/responses";
async function readBody(res: Response): Promise<{
error: { code: string; message: string };
details?: unknown;
}> {
return (await res.json()) as {
error: { code: string; message: string };
details?: unknown;
};
}
describe("lib/server/responses", () => {
it("errorJson returns the canonical shape, status, and details", async () => {
const res = errorJson("validation_error", "Bad input", 400, {
details: { field: "email" },
headers: { "x-custom": "1" },
});
expect(res.status).toBe(400);
expect(res.headers.get("x-custom")).toBe("1");
const body = await readBody(res);
expect(body).toEqual({
error: { code: "validation_error", message: "Bad input" },
details: { field: "email" },
});
});
it("errorJson omits details when not provided", async () => {
const res = errorJson("internal_error", "Boom", 500);
const body = await readBody(res);
expect(body.details).toBeUndefined();
expect(body.error).toEqual({ code: "internal_error", message: "Boom" });
});
it("dbUnavailable → 503 db_unavailable", async () => {
const res = dbUnavailable();
expect(res.status).toBe(503);
const body = await readBody(res);
expect(body.error.code).toBe("db_unavailable");
});
it("unauthorized → 401 unauthorized", async () => {
const res = unauthorized();
expect(res.status).toBe(401);
const body = await readBody(res);
expect(body.error).toEqual({
code: "unauthorized",
message: "Unauthorized",
});
});
it("notFound → 404 not_found with optional message", async () => {
const res = notFound("Rule not found");
expect(res.status).toBe(404);
const body = await readBody(res);
expect(body.error).toEqual({
code: "not_found",
message: "Rule not found",
});
});
it("rateLimited → 429 with Retry-After header (seconds, ceil) and details", async () => {
const res = rateLimited(2500);
expect(res.status).toBe(429);
expect(res.headers.get("retry-after")).toBe("3");
const body = await readBody(res);
expect(body.error.code).toBe("rate_limited");
expect(body.details).toEqual({ retryAfterMs: 2500 });
});
it("rateLimited clamps Retry-After to at least 1 second", () => {
const res = rateLimited(0);
expect(res.headers.get("retry-after")).toBe("1");
});
it("serverMisconfigured → 500 server_misconfigured", async () => {
const res = serverMisconfigured();
expect(res.status).toBe(500);
const body = await readBody(res);
expect(body.error.code).toBe("server_misconfigured");
});
it("internalError → 500 internal_error", async () => {
const res = internalError();
expect(res.status).toBe(500);
const body = await readBody(res);
expect(body.error.code).toBe("internal_error");
});
});
+27 -7
View File
@@ -1,3 +1,4 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const isDatabaseConfiguredMock = vi.fn();
@@ -29,31 +30,50 @@ beforeEach(() => {
describe("GET /api/rules/[id]", () => {
it("returns 503 when the database is not configured", async () => {
isDatabaseConfiguredMock.mockReturnValue(false);
const res = await GET(new Request("https://x.test/api/rules/abc"), makeContext("abc"));
const res = await GET(
new NextRequest("https://x.test/api/rules/abc"),
makeContext("abc"),
);
expect(res.status).toBe(503);
expect(findUniqueMock).not.toHaveBeenCalled();
});
it("returns 404 when no published rule matches the id", async () => {
it("returns 404 with the canonical error shape when no published rule matches the id", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
findUniqueMock.mockResolvedValueOnce(null);
const res = await GET(
new Request("https://x.test/api/rules/missing"),
new NextRequest("https://x.test/api/rules/missing"),
makeContext("missing"),
);
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(typeof body.error).toBe("string");
expect(res.headers.get("x-request-id")).toBeTruthy();
const body = (await res.json()) as {
error: { code: string; message: string };
};
expect(body.error.code).toBe("not_found");
expect(typeof body.error.message).toBe("string");
expect(findUniqueMock).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "missing" } }),
);
});
it("forwards an incoming x-request-id on the response", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
findUniqueMock.mockResolvedValueOnce(null);
const res = await GET(
new NextRequest("https://x.test/api/rules/missing", {
headers: { "x-request-id": "req_test-1" },
}),
makeContext("missing"),
);
expect(res.headers.get("x-request-id")).toBe("req_test-1");
});
it("returns 404 when the query throws (swallowed by helper)", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
findUniqueMock.mockRejectedValueOnce(new Error("db down"));
const res = await GET(
new Request("https://x.test/api/rules/broken"),
new NextRequest("https://x.test/api/rules/broken"),
makeContext("broken"),
);
expect(res.status).toBe(404);
@@ -71,7 +91,7 @@ describe("GET /api/rules/[id]", () => {
};
findUniqueMock.mockResolvedValueOnce(row);
const res = await GET(
new Request("https://x.test/api/rules/rule-1"),
new NextRequest("https://x.test/api/rules/rule-1"),
makeContext("rule-1"),
);
expect(res.status).toBe(200);