errors.js 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  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. /**
  14. * Default JSON response headers for all API responses that return JSON.
  15. *
  16. * RHL-006 (Caching / Freshness):
  17. * - We explicitly disable HTTP caching for ALL JSON API responses by default.
  18. * - Reason #1: NAS filesystem contents can change at any time (new scans appear).
  19. * - Reason #2: Auth/RBAC-protected responses must never be cached/shared by accident
  20. * (e.g. via browser cache, proxies, reverse proxies, or intermediate gateways).
  21. *
  22. * If a future endpoint truly needs caching, it should override headers explicitly
  23. * at the call site via `json(..., status, { "Cache-Control": "..." })`.
  24. */
  25. const JSON_HEADERS = {
  26. "Content-Type": "application/json",
  27. "Cache-Control": "no-store",
  28. };
  29. /**
  30. * Create a JSON `Response` with the given payload.
  31. *
  32. * @param {any} data - The JSON-serializable payload.
  33. * @param {number} status - HTTP status code.
  34. * @param {Record<string,string>} headers - Optional extra headers.
  35. * @returns {Response}
  36. */
  37. export function json(data, status = 200, headers = {}) {
  38. return new Response(JSON.stringify(data), {
  39. status,
  40. headers: { ...JSON_HEADERS, ...headers },
  41. });
  42. }
  43. /**
  44. * Build the standardized error payload.
  45. *
  46. * Contract:
  47. * {
  48. * "error": {
  49. * "message": "Human readable message",
  50. * "code": "SOME_MACHINE_CODE",
  51. * "details": { ... } // optional
  52. * }
  53. * }
  54. *
  55. * IMPORTANT:
  56. * - message is for humans (UI/logs).
  57. * - code is for machines (frontend decisions, monitoring, tests).
  58. * - details is optional and should only include safe data.
  59. *
  60. * @param {{code:string, message:string, details?:any}} input
  61. * @returns {{error:{message:string, code:string, details?:any}}}
  62. */
  63. export function buildErrorPayload({ code, message, details }) {
  64. const error = { message, code };
  65. // Only include details when explicitly provided.
  66. // This keeps the error payload minimal and predictable.
  67. if (details !== undefined) error.details = details;
  68. return { error };
  69. }
  70. /**
  71. * Create a standardized JSON error response.
  72. *
  73. * @param {number} status - HTTP status code (400/401/403/404/500).
  74. * @param {string} code - Stable machine-readable error code.
  75. * @param {string} message - Human-readable message (safe to expose).
  76. * @param {any=} details - Optional structured details (validation fields, params, etc.).
  77. * @returns {Response}
  78. */
  79. export function jsonError(status, code, message, details) {
  80. return json(buildErrorPayload({ code, message, details }), status);
  81. }
  82. /**
  83. * Custom error type for “expected” API failures.
  84. *
  85. * We throw ApiError from route handlers instead of returning ad-hoc JSON.
  86. * The `withErrorHandling` wrapper converts it into a standardized response.
  87. */
  88. export class ApiError extends Error {
  89. /**
  90. * @param {{
  91. * status: number,
  92. * code: string,
  93. * message: string,
  94. * details?: any,
  95. * cause?: any
  96. * }} input
  97. */
  98. constructor({ status, code, message, details, cause }) {
  99. // Modern Node supports `cause`, which preserves the original error chain.
  100. super(message, cause ? { cause } : undefined);
  101. this.name = "ApiError";
  102. this.status = status;
  103. this.code = code;
  104. // Only attach details if explicitly provided.
  105. if (details !== undefined) this.details = details;
  106. }
  107. }
  108. /**
  109. * Convenience constructors for the common HTTP statuses used by the API.
  110. * This prevents magic numbers and keeps code readable in route handlers.
  111. */
  112. export function badRequest(code, message, details) {
  113. return new ApiError({ status: 400, code, message, details });
  114. }
  115. export function unauthorized(code, message, details) {
  116. return new ApiError({ status: 401, code, message, details });
  117. }
  118. export function forbidden(code, message, details) {
  119. return new ApiError({ status: 403, code, message, details });
  120. }
  121. export function notFound(code, message, details) {
  122. return new ApiError({ status: 404, code, message, details });
  123. }
  124. /**
  125. * Convert a thrown error into a standardized API response.
  126. *
  127. * Policy:
  128. * - If it's an ApiError, we trust its status/code/message (they should be safe).
  129. * - Otherwise, we return a generic 500 without leaking internals.
  130. *
  131. * @param {unknown} err
  132. * @returns {Response}
  133. */
  134. export function toApiErrorResponse(err) {
  135. if (err instanceof ApiError) {
  136. return jsonError(err.status, err.code, err.message, err.details);
  137. }
  138. // Never leak unexpected error details to clients.
  139. return jsonError(500, "INTERNAL_SERVER_ERROR", "Internal server error");
  140. }
  141. /**
  142. * Wrap a Next.js route handler to enforce consistent error handling.
  143. *
  144. * Usage:
  145. * export const GET = withErrorHandling(async function GET(request, ctx) { ... })
  146. *
  147. * Benefits:
  148. * - No repeated try/catch blocks in every route.
  149. * - All thrown ApiErrors become standardized JSON responses.
  150. * - Unknown errors become a safe, generic 500 response.
  151. *
  152. * @param {Function} handler
  153. * @param {{logPrefix?:string}=} options
  154. * @returns {Function}
  155. */
  156. export function withErrorHandling(handler, { logPrefix = "[api]" } = {}) {
  157. const shouldLog = process.env.NODE_ENV !== "test";
  158. return async (...args) => {
  159. try {
  160. return await handler(...args);
  161. } catch (err) {
  162. if (err instanceof ApiError) {
  163. if (shouldLog && err.status >= 500) {
  164. console.error(`${logPrefix} ${err.code}:`, err.cause || err);
  165. }
  166. return toApiErrorResponse(err);
  167. }
  168. if (shouldLog) {
  169. console.error(`${logPrefix} Unhandled error:`, err);
  170. }
  171. return toApiErrorResponse(err);
  172. }
  173. };
  174. }