Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
113 lines
5.0 KiB
TypeScript
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();
|
|
};
|