2026-05-09 13:03:36 -05:00
|
|
|
/**
|
|
|
|
|
* Centralized error handling utility for API routes
|
2026-07-06 10:47:16 -05:00
|
|
|
*
|
2026-05-09 13:03:36 -05:00
|
|
|
* Standard error format:
|
|
|
|
|
* {
|
|
|
|
|
* error: 'ErrorType',
|
|
|
|
|
* message: 'Human-readable description',
|
|
|
|
|
* field: 'optional-field-name',
|
|
|
|
|
* code: 'machine-readable-code'
|
|
|
|
|
* }
|
|
|
|
|
*/
|
|
|
|
|
|
2026-07-06 10:47:16 -05:00
|
|
|
import type { Req, Res, Next } from '../types/http';
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
class ApiError extends Error {
|
2026-07-06 10:47:16 -05:00
|
|
|
code: string;
|
|
|
|
|
status: number;
|
|
|
|
|
details: any;
|
|
|
|
|
field?: string;
|
|
|
|
|
|
|
|
|
|
constructor(code: string, message: string, status = 500, details: any = {}) {
|
2026-05-09 13:03:36 -05:00
|
|
|
super(message);
|
|
|
|
|
this.name = 'ApiError';
|
|
|
|
|
this.code = code;
|
|
|
|
|
this.status = status;
|
|
|
|
|
this.details = details;
|
2026-07-06 10:47:16 -05:00
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
// Extract field name from details if provided
|
|
|
|
|
if (details.field) {
|
|
|
|
|
this.field = details.field;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a standardized validation error
|
|
|
|
|
*/
|
2026-07-06 10:47:16 -05:00
|
|
|
function ValidationError(message: string, field?: string, code = 'VALIDATION_ERROR'): ApiError {
|
2026-05-09 13:03:36 -05:00
|
|
|
return new ApiError(code, message, 400, { field });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a standardized authentication error
|
|
|
|
|
*/
|
2026-07-06 10:47:16 -05:00
|
|
|
function AuthError(message = 'Authentication required', code = 'AUTH_ERROR'): ApiError {
|
2026-05-09 13:03:36 -05:00
|
|
|
return new ApiError(code, message, 401);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a standardized authorization error
|
|
|
|
|
*/
|
2026-07-06 10:47:16 -05:00
|
|
|
function ForbiddenError(message = 'Access denied', code = 'FORBIDDEN'): ApiError {
|
2026-05-09 13:03:36 -05:00
|
|
|
return new ApiError(code, message, 403);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a standardized not found error
|
|
|
|
|
*/
|
2026-07-06 14:46:32 -05:00
|
|
|
function NotFoundError(
|
|
|
|
|
message = 'Resource not found',
|
|
|
|
|
field?: string,
|
|
|
|
|
code = 'NOT_FOUND',
|
|
|
|
|
): ApiError {
|
|
|
|
|
return new ApiError(code, message, 404, { field });
|
2026-05-09 13:03:36 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a standardized conflict error
|
|
|
|
|
*/
|
2026-07-06 14:46:32 -05:00
|
|
|
function ConflictError(message = 'Resource conflict', field?: string, code = 'CONFLICT'): ApiError {
|
|
|
|
|
return new ApiError(code, message, 409, { field });
|
2026-05-09 13:03:36 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a standardized rate limit error
|
|
|
|
|
*/
|
2026-07-06 10:47:16 -05:00
|
|
|
function RateLimitError(message = 'Too many requests', code = 'RATE_LIMITED'): ApiError {
|
2026-05-09 13:03:36 -05:00
|
|
|
return new ApiError(code, message, 429);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format an error for JSON response
|
|
|
|
|
* This ensures consistent error format across all routes
|
|
|
|
|
*/
|
2026-07-06 14:33:38 -05:00
|
|
|
function formatError(err: any): { error: string; message: string; code: string; field?: string } {
|
2026-05-09 13:03:36 -05:00
|
|
|
if (err instanceof ApiError) {
|
2026-07-06 14:33:38 -05:00
|
|
|
// `error` and `code` are both the machine code (the client reads `code`);
|
|
|
|
|
// `error` is kept for backward compatibility.
|
|
|
|
|
const output: { error: string; message: string; code: string; field?: string } = {
|
2026-05-09 13:03:36 -05:00
|
|
|
error: err.code,
|
|
|
|
|
message: err.message,
|
2026-07-06 14:33:38 -05:00
|
|
|
code: err.code,
|
2026-05-09 13:03:36 -05:00
|
|
|
};
|
|
|
|
|
if (err.field) output.field = err.field;
|
|
|
|
|
return output;
|
|
|
|
|
}
|
2026-07-06 10:47:16 -05:00
|
|
|
|
2026-07-06 14:33:38 -05:00
|
|
|
// Non-ApiError: hide internals on any 5xx (missing status is treated as 500).
|
|
|
|
|
const status = err.status || 500;
|
|
|
|
|
const code = err.code || 'INTERNAL_ERROR';
|
|
|
|
|
const safeMessage = status >= 500 ? 'Internal server error' : err.message || 'An error occurred';
|
2026-07-06 10:47:16 -05:00
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
return {
|
2026-07-06 14:33:38 -05:00
|
|
|
error: code,
|
2026-05-09 13:03:36 -05:00
|
|
|
message: safeMessage,
|
2026-07-06 14:33:38 -05:00
|
|
|
code,
|
2026-05-09 13:03:36 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Express middleware to handle errors centrally
|
|
|
|
|
*/
|
2026-07-06 10:47:16 -05:00
|
|
|
function errorHandler(err: any, req: Req, res: Res, next: Next): void {
|
2026-05-09 13:03:36 -05:00
|
|
|
const formatted = formatError(err);
|
|
|
|
|
const status = err.status || (err instanceof ApiError ? 500 : 500);
|
2026-07-06 10:47:16 -05:00
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
// Only log non-500 errors to avoid exposing sensitive info
|
|
|
|
|
if (status !== 500) {
|
|
|
|
|
console.error(`[error] ${formatted.error}: ${formatted.message}`);
|
|
|
|
|
}
|
2026-07-06 10:47:16 -05:00
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
if (!res.headersSent) {
|
|
|
|
|
res.status(status).json(formatted);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
ApiError,
|
|
|
|
|
ValidationError,
|
|
|
|
|
AuthError,
|
|
|
|
|
ForbiddenError,
|
|
|
|
|
NotFoundError,
|
|
|
|
|
ConflictError,
|
|
|
|
|
RateLimitError,
|
|
|
|
|
formatError,
|
|
|
|
|
errorHandler,
|
|
|
|
|
};
|