| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- /**
- * Central API response helpers for consistent JSON output and standardized errors.
- *
- * Why this exists:
- * - Route handlers should NOT each invent their own error JSON shape.
- * - The frontend (and tests/logging) should rely on a stable contract:
- * { error: { message, code, details? } }
- * - We want consistent HTTP status codes (400/401/403/404/500).
- *
- * This file intentionally uses the Web Fetch `Response` object (not NextResponse),
- * because Next.js route handlers can return any `Response` and it keeps tests simple.
- */
- /** Default JSON response headers for all API responses that return JSON. */
- const JSON_HEADERS = { "Content-Type": "application/json" };
- /**
- * Create a JSON `Response` with the given payload.
- *
- * @param {any} data - The JSON-serializable payload.
- * @param {number} status - HTTP status code.
- * @param {Record<string,string>} headers - Optional extra headers.
- * @returns {Response}
- */
- export function json(data, status = 200, headers = {}) {
- return new Response(JSON.stringify(data), {
- status,
- headers: { ...JSON_HEADERS, ...headers },
- });
- }
- /**
- * Build the standardized error payload.
- *
- * Contract:
- * {
- * "error": {
- * "message": "Human readable message",
- * "code": "SOME_MACHINE_CODE",
- * "details": { ... } // optional
- * }
- * }
- *
- * IMPORTANT:
- * - message is for humans (UI/logs).
- * - code is for machines (frontend decisions, monitoring, tests).
- * - details is optional and should only include safe data.
- *
- * @param {{code:string, message:string, details?:any}} input
- * @returns {{error:{message:string, code:string, details?:any}}}
- */
- export function buildErrorPayload({ code, message, details }) {
- const error = { message, code };
- // Only include details when explicitly provided.
- // This keeps the error payload minimal and predictable.
- if (details !== undefined) error.details = details;
- return { error };
- }
- /**
- * Create a standardized JSON error response.
- *
- * @param {number} status - HTTP status code (400/401/403/404/500).
- * @param {string} code - Stable machine-readable error code.
- * @param {string} message - Human-readable message (safe to expose).
- * @param {any=} details - Optional structured details (validation fields, params, etc.).
- * @returns {Response}
- */
- export function jsonError(status, code, message, details) {
- return json(buildErrorPayload({ code, message, details }), status);
- }
- /**
- * Custom error type for “expected” API failures.
- *
- * We throw ApiError from route handlers instead of returning ad-hoc JSON.
- * The `withErrorHandling` wrapper converts it into a standardized response.
- */
- export class ApiError extends Error {
- /**
- * @param {{
- * status: number,
- * code: string,
- * message: string,
- * details?: any,
- * cause?: any
- * }} input
- */
- constructor({ status, code, message, details, cause }) {
- // Modern Node supports `cause`, which preserves the original error chain.
- super(message, cause ? { cause } : undefined);
- this.name = "ApiError";
- this.status = status;
- this.code = code;
- // Only attach details if explicitly provided.
- if (details !== undefined) this.details = details;
- }
- }
- /**
- * Convenience constructors for the common HTTP statuses used by the API.
- * This prevents magic numbers and keeps code readable in route handlers.
- */
- export function badRequest(code, message, details) {
- return new ApiError({ status: 400, code, message, details });
- }
- export function unauthorized(code, message, details) {
- return new ApiError({ status: 401, code, message, details });
- }
- export function forbidden(code, message, details) {
- return new ApiError({ status: 403, code, message, details });
- }
- export function notFound(code, message, details) {
- return new ApiError({ status: 404, code, message, details });
- }
- /**
- * Convert a thrown error into a standardized API response.
- *
- * Policy:
- * - If it's an ApiError, we trust its status/code/message (they should be safe).
- * - Otherwise, we return a generic 500 without leaking internals.
- *
- * @param {unknown} err
- * @returns {Response}
- */
- export function toApiErrorResponse(err) {
- if (err instanceof ApiError) {
- return jsonError(err.status, err.code, err.message, err.details);
- }
- // Never leak unexpected error details to clients.
- return jsonError(500, "INTERNAL_SERVER_ERROR", "Internal server error");
- }
- /**
- * Wrap a Next.js route handler to enforce consistent error handling.
- *
- * Usage:
- * export const GET = withErrorHandling(async function GET(request, ctx) { ... })
- *
- * Benefits:
- * - No repeated try/catch blocks in every route.
- * - All thrown ApiErrors become standardized JSON responses.
- * - Unknown errors become a safe, generic 500 response.
- *
- * @param {Function} handler
- * @param {{logPrefix?:string}=} options
- * @returns {Function}
- */
- export function withErrorHandling(handler, { logPrefix = "[api]" } = {}) {
- return async (...args) => {
- try {
- return await handler(...args);
- } catch (err) {
- // Log unexpected errors always.
- // For ApiErrors, only log server-side failures (5xx) by default
- // to avoid noisy logs for client mistakes (4xx).
- if (err instanceof ApiError) {
- if (err.status >= 500) {
- console.error(`${logPrefix} ${err.code}:`, err.cause || err);
- }
- return toApiErrorResponse(err);
- }
- console.error(`${logPrefix} Unhandled error:`, err);
- return toApiErrorResponse(err);
- }
- };
- }
|