Files
hope-events/backend/src/v2/errors.ts
T

113 lines
5.0 KiB
TypeScript

import type { ErrorRequestHandler, RequestHandler } from 'express';
import { ZodError } from 'zod';
/**
* /api/v2 errors all look the same:
* { "error": { "code": "VALIDATION_FAILED", "message": "…", "fields": { "limit": "…" } } }
* `code` is stable (clients switch on it), `message` is for people, `fields` only on validation.
*/
export type ErrorCode =
| 'VALIDATION_FAILED'
| 'UNAUTHENTICATED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'RATE_LIMITED'
| 'READ_ONLY'
| 'INTERNAL';
export const ERROR_STATUS: Record<ErrorCode, number> = {
VALIDATION_FAILED: 400,
UNAUTHENTICATED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
RATE_LIMITED: 429,
READ_ONLY: 403,
INTERNAL: 500,
};
export class ApiError extends Error {
readonly code: ErrorCode;
readonly status: number;
readonly fields?: Record<string, string>;
/** A more specific cause, e.g. CAMPUS_HAS_EVENTS or TWO_FACTOR_REQUIRED. */
reason?: string;
constructor(code: ErrorCode, message: string, fields?: Record<string, string>) {
super(message);
this.code = code;
this.status = ERROR_STATUS[code];
this.fields = fields;
}
}
export const notFound = (what = 'That'): ApiError => new ApiError('NOT_FOUND', `${what} wasn't found.`);
/** "limit" or "items.0.quantity" → the first problem with it. */
export function zodFields(err: ZodError): Record<string, string> {
const fields: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.length ? issue.path.join('.') : '_';
if (!fields[key]) fields[key] = issue.message;
}
return fields;
}
const STATUS_CODE: Record<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED' };
const PRISMA_CODE: Record<string, ErrorCode> = { P2002: 'CONFLICT', P2025: 'NOT_FOUND', P2003: 'CONFLICT' };
type LegacyError = Error & { statusCode?: number; status?: number; code?: string };
/** Turns anything thrown (ApiError, zod, Prisma, the v1 auth middleware's errors) into the v2 shape. */
export function toApiError(err: unknown, responseStatus: number): ApiError {
if (err instanceof ApiError) return err;
if (err instanceof ZodError) return new ApiError('VALIDATION_FAILED', 'Some of what was sent is not valid.', zodFields(err));
const e = (err || {}) as LegacyError;
if (typeof e.code === 'string' && PRISMA_CODE[e.code]) {
return new ApiError(PRISMA_CODE[e.code], e.code === 'P2002' ? 'A record with that value already exists.' : e.code === 'P2025' ? 'Record not found.' : 'A related record prevents this.');
}
// The shared v1 middleware (protect, staff…) sets res.status() and passes an Error;
// utils throw errors with statusCode. Those messages are written for people already.
const status = e.statusCode || e.status || (responseStatus >= 400 ? responseStatus : 500);
const code = STATUS_CODE[status];
if (code) {
const out = new ApiError(code, e.message || 'Request failed.');
// A service's own code (not a Prisma one) says more precisely what happened.
if (typeof e.code === 'string' && e.code && !/^P\d{4}$/.test(e.code)) out.reason = e.code;
return out;
}
return new ApiError('INTERNAL', 'Something went wrong on the server.');
}
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const apiErr = toApiError(err, res.statusCode);
if (apiErr.code === 'INTERNAL') console.error('[api/v2]', req.method, req.originalUrl, err);
res.status(apiErr.status).json({ error: { code: apiErr.code, message: apiErr.message, ...(apiErr.fields ? { fields: apiErr.fields } : {}), ...(apiErr.reason ? { reason: apiErr.reason } : {}) } });
};
export const notFoundHandler: RequestHandler = (_req, _res, next) => next(new ApiError('NOT_FOUND', 'There is no such API endpoint.'));
// v1-style codes from shared middleware, kept as the v2 error's `reason`.
const READ_ONLY_REASONS = new Set(['IMPERSONATION_READ_ONLY', 'LICENCE_READ_ONLY']);
/**
* Mounted in front of everything on /api/v2: middleware shared with v1 (rate limits, the
* read-only guards, the auth checks, campus refusals) answers `{ message, code? }`; on v2 that
* becomes `{ error: { code, message, reason? } }`, where `reason` is the specific old code
* (e.g. TWO_FACTOR_REQUIRED, LICENCE_READ_ONLY, OTHER_CAMPUS).
*/
export const legacyErrorsToV2: RequestHandler = (_req, res, next) => {
const json = res.json.bind(res);
res.json = (body?: unknown) => {
const b = body as { message?: unknown; code?: unknown; error?: unknown } | null;
if (res.statusCode >= 400 && b && typeof b === 'object' && !('error' in b) && typeof b.message === 'string') {
const reason = typeof b.code === 'string' ? b.code : undefined;
const code: ErrorCode = reason && READ_ONLY_REASONS.has(reason) ? 'READ_ONLY' : STATUS_CODE[res.statusCode] || (res.statusCode >= 500 ? 'INTERNAL' : 'VALIDATION_FAILED');
return json({ error: { code, message: b.message, ...(reason ? { reason } : {}) } });
}
return json(body);
};
next();
};