|
|
@@ -0,0 +1,178 @@
|
|
|
+/**
|
|
|
+ * 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);
|
|
|
+ }
|
|
|
+ };
|
|
|
+}
|