BillTracker/utils/apiError.cts

138 lines
3.4 KiB
TypeScript

/**
* Centralized error handling utility for API routes
*
* Standard error format:
* {
* error: 'ErrorType',
* message: 'Human-readable description',
* field: 'optional-field-name',
* code: 'machine-readable-code'
* }
*/
import type { Req, Res, Next } from '../types/http';
class ApiError extends Error {
code: string;
status: number;
details: any;
field?: string;
constructor(code: string, message: string, status = 500, details: any = {}) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
this.details = details;
// Extract field name from details if provided
if (details.field) {
this.field = details.field;
}
}
}
/**
* Create a standardized validation error
*/
function ValidationError(message: string, field?: string, code = 'VALIDATION_ERROR'): ApiError {
return new ApiError(code, message, 400, { field });
}
/**
* Create a standardized authentication error
*/
function AuthError(message = 'Authentication required', code = 'AUTH_ERROR'): ApiError {
return new ApiError(code, message, 401);
}
/**
* Create a standardized authorization error
*/
function ForbiddenError(message = 'Access denied', code = 'FORBIDDEN'): ApiError {
return new ApiError(code, message, 403);
}
/**
* Create a standardized not found error
*/
function NotFoundError(
message = 'Resource not found',
field?: string,
code = 'NOT_FOUND',
): ApiError {
return new ApiError(code, message, 404, { field });
}
/**
* Create a standardized conflict error
*/
function ConflictError(message = 'Resource conflict', field?: string, code = 'CONFLICT'): ApiError {
return new ApiError(code, message, 409, { field });
}
/**
* Create a standardized rate limit error
*/
function RateLimitError(message = 'Too many requests', code = 'RATE_LIMITED'): ApiError {
return new ApiError(code, message, 429);
}
/**
* Format an error for JSON response
* This ensures consistent error format across all routes
*/
function formatError(err: any): { error: string; message: string; code: string; field?: string } {
if (err instanceof ApiError) {
// `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 } = {
error: err.code,
message: err.message,
code: err.code,
};
if (err.field) output.field = err.field;
return output;
}
// 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';
return {
error: code,
message: safeMessage,
code,
};
}
/**
* Express middleware to handle errors centrally
*/
function errorHandler(err: any, req: Req, res: Res, next: Next): void {
const formatted = formatError(err);
const status = err.status || (err instanceof ApiError ? 500 : 500);
// Only log non-500 errors to avoid exposing sensitive info
if (status !== 500) {
console.error(`[error] ${formatted.error}: ${formatted.message}`);
}
if (!res.headersSent) {
res.status(status).json(formatted);
}
}
module.exports = {
ApiError,
ValidationError,
AuthError,
ForbiddenError,
NotFoundError,
ConflictError,
RateLimitError,
formatError,
errorHandler,
};