Files
craftvia/src/server/api/respond.ts
T
msolarczekandClaude Opus 5 1f8e6413fe L1 Stammdaten: Services, Dublettenprüfung, Server Actions und API v1
Kunden (Nummernkreis, Ansprechpartner, vorläufig bestätigen, Zusammenführen mit
Bestätigung), Objekte inkl. Historie, Teams mit Mitgliedschaften, Dublettenlogik
(lib + Service), API-Kontext/Antwortformat unter src/server/api und die Endpunkte
/api/v1/customers, /api/v1/sites, /api/v1/sites/[id]/history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:26:07 +02:00

105 lines
3.5 KiB
TypeScript

import { ZodError } from "zod";
import { ServiceError } from "@/server/services/context";
import { ForbiddenError } from "@/server/rbac";
import { ModuleDisabledError } from "@/server/modules";
/**
* JSON response helpers for /api/v1 route handlers (spec §29.2).
* Error format: `{ error: { code, message, details? } }`; list format:
* `{ data: [...], pagination: { page, pageSize, total } }`.
* Internal error details never leave the server (CWE-209).
*/
export type ApiErrorCode =
| "unauthorized"
| "forbidden"
| "not_found"
| "invalid"
| "conflict"
| "blocked"
| "payload_too_large"
| "internal";
const STATUS: Record<ApiErrorCode, number> = {
unauthorized: 401,
forbidden: 403,
not_found: 404,
invalid: 422,
conflict: 409,
blocked: 409,
payload_too_large: 413,
internal: 500,
};
export class ApiError extends Error {
constructor(
public code: ApiErrorCode,
message: string,
public details?: unknown,
) {
super(message);
this.name = "ApiError";
}
}
export function errorResponse(code: ApiErrorCode, message: string, details?: unknown): Response {
return Response.json(
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
{ status: STATUS[code], headers: { "Cache-Control": "no-store" } },
);
}
/** Map any thrown error to a JSON error response. */
export function toErrorResponse(err: unknown): Response {
if (err instanceof ApiError) return errorResponse(err.code, err.message, err.details);
if (err instanceof ServiceError) return errorResponse(err.code, err.message, err.details);
if (err instanceof ZodError) {
return errorResponse(
"invalid",
"validation failed",
err.issues.map((i) => ({ path: i.path.join("."), code: i.code })),
);
}
if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden");
if (err instanceof ModuleDisabledError) return errorResponse("forbidden", "module disabled");
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found");
console.error("[api] unhandled error", err);
return errorResponse("internal", "internal error");
}
export function json(data: unknown, init?: { status?: number }): Response {
return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store" } });
}
export function paginated<T>(items: T[], total: number, page: number, pageSize: number): Response {
return json({ data: items, pagination: { page, pageSize, total } });
}
/** `?page&pageSize` with sane bounds (pageSize 1..100, default 25). */
export function parsePagination(url: URL | string, defaults = { pageSize: 25 }): { page: number; pageSize: number } {
const u = typeof url === "string" ? new URL(url) : url;
const page = Math.max(1, Math.floor(Number(u.searchParams.get("page")) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(u.searchParams.get("pageSize")) || defaults.pageSize)));
return { page, pageSize };
}
/** Wrap a handler so every thrown error becomes a JSON error response. */
export function withApi<A extends unknown[]>(handler: (...args: A) => Promise<Response>) {
return async (...args: A): Promise<Response> => {
try {
return await handler(...args);
} catch (err) {
return toErrorResponse(err);
}
};
}
/** Read a JSON body; malformed JSON → 422. */
export async function readJson(req: Request): Promise<unknown> {
try {
return await req.json();
} catch {
throw new ApiError("invalid", "malformed JSON body");
}
}