errors.js 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. /**
  2. * Central API response helpers for consistent JSON output and standardized errors.
  3. *
  4. * Why this exists:
  5. * - Route handlers should NOT each invent their own error JSON shape.
  6. * - The frontend (and tests/logging) should rely on a stable contract:
  7. * { error: { message, code, details? } }
  8. * - We want consistent HTTP status codes (400/401/403/404/500).
  9. *
  10. * This file intentionally uses the Web Fetch `Response` object (not NextResponse),
  11. * because Next.js route handlers can return any `Response` and it keeps tests simple.
  12. */
  13. /** Default JSON response headers for all API responses that return JSON. */
  14. const JSON_HEADERS = { "Content-Type": "application/json" };
  15. /**
  16. * Create a JSON `Response` with the given payload.
  17. *
  18. * @param {any} data - The JSON-serializable payload.
  19. * @param {number} status - HTTP status code.
  20. * @param {Record<string,string>} headers - Optional extra headers.
  21. * @returns {Response}
  22. */
  23. export function json(data, status = 200, headers = {}) {
  24. return new Response(JSON.stringify(data), {
  25. status,
  26. headers: { ...JSON_HEADERS, ...headers },
  27. });
  28. }
  29. /**
  30. * Build the standardized error payload.
  31. *
  32. * Contract:
  33. * {
  34. * "error": {
  35. * "message": "Human readable message",
  36. * "code": "SOME_MACHINE_CODE",
  37. * "details": { ... } // optional
  38. * }
  39. * }
  40. *
  41. * IMPORTANT:
  42. * - message is for humans (UI/logs).
  43. * - code is for machines (frontend decisions, monitoring, tests).
  44. * - details is optional and should only include safe data.
  45. *
  46. * @param {{code:string, message:string, details?:any}} input
  47. * @returns {{error:{message:string, code:string, details?:any}}}
  48. */
  49. export function buildErrorPayload({ code, message, details }) {
  50. const error = { message, code };
  51. // Only include details when explicitly provided.
  52. // This keeps the error payload minimal and predictable.
  53. if (details !== undefined) error.details = details;
  54. return { error };
  55. }
  56. /**
  57. * Create a standardized JSON error response.
  58. *
  59. * @param {number} status - HTTP status code (400/401/403/404/500).
  60. * @param {string} code - Stable machine-readable error code.
  61. * @param {string} message - Human-readable message (safe to expose).
  62. * @param {any=} details - Optional structured details (validation fields, params, etc.).
  63. * @returns {Response}
  64. */
  65. export function jsonError(status, code, message, details) {
  66. return json(buildErrorPayload({ code, message, details }), status);
  67. }
  68. /**
  69. * Custom error type for “expected” API failures.
  70. *
  71. * We throw ApiError from route handlers instead of returning ad-hoc JSON.
  72. * The `withErrorHandling` wrapper converts it into a standardized response.
  73. */
  74. export class ApiError extends Error {
  75. /**
  76. * @param {{
  77. * status: number,
  78. * code: string,
  79. * message: string,
  80. * details?: any,
  81. * cause?: any
  82. * }} input
  83. */
  84. constructor({ status, code, message, details, cause }) {
  85. // Modern Node supports `cause`, which preserves the original error chain.
  86. super(message, cause ? { cause } : undefined);
  87. this.name = "ApiError";
  88. this.status = status;
  89. this.code = code;
  90. // Only attach details if explicitly provided.
  91. if (details !== undefined) this.details = details;
  92. }
  93. }
  94. /**
  95. * Convenience constructors for the common HTTP statuses used by the API.
  96. * This prevents magic numbers and keeps code readable in route handlers.
  97. */
  98. export function badRequest(code, message, details) {
  99. return new ApiError({ status: 400, code, message, details });
  100. }
  101. export function unauthorized(code, message, details) {
  102. return new ApiError({ status: 401, code, message, details });
  103. }
  104. export function forbidden(code, message, details) {
  105. return new ApiError({ status: 403, code, message, details });
  106. }
  107. export function notFound(code, message, details) {
  108. return new ApiError({ status: 404, code, message, details });
  109. }
  110. /**
  111. * Convert a thrown error into a standardized API response.
  112. *
  113. * Policy:
  114. * - If it's an ApiError, we trust its status/code/message (they should be safe).
  115. * - Otherwise, we return a generic 500 without leaking internals.
  116. *
  117. * @param {unknown} err
  118. * @returns {Response}
  119. */
  120. export function toApiErrorResponse(err) {
  121. if (err instanceof ApiError) {
  122. return jsonError(err.status, err.code, err.message, err.details);
  123. }
  124. // Never leak unexpected error details to clients.
  125. return jsonError(500, "INTERNAL_SERVER_ERROR", "Internal server error");
  126. }
  127. /**
  128. * Wrap a Next.js route handler to enforce consistent error handling.
  129. *
  130. * Usage:
  131. * export const GET = withErrorHandling(async function GET(request, ctx) { ... })
  132. *
  133. * Benefits:
  134. * - No repeated try/catch blocks in every route.
  135. * - All thrown ApiErrors become standardized JSON responses.
  136. * - Unknown errors become a safe, generic 500 response.
  137. *
  138. * @param {Function} handler
  139. * @param {{logPrefix?:string}=} options
  140. * @returns {Function}
  141. */
  142. export function withErrorHandling(handler, { logPrefix = "[api]" } = {}) {
  143. return async (...args) => {
  144. try {
  145. return await handler(...args);
  146. } catch (err) {
  147. // Log unexpected errors always.
  148. // For ApiErrors, only log server-side failures (5xx) by default
  149. // to avoid noisy logs for client mistakes (4xx).
  150. if (err instanceof ApiError) {
  151. if (err.status >= 500) {
  152. console.error(`${logPrefix} ${err.code}:`, err.cause || err);
  153. }
  154. return toApiErrorResponse(err);
  155. }
  156. console.error(`${logPrefix} Unhandled error:`, err);
  157. return toApiErrorResponse(err);
  158. }
  159. };
  160. }