errors.js 6.1 KB

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