mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
104 lines
4.0 KiB
TypeScript
104 lines
4.0 KiB
TypeScript
import { HttpException } from '@nestjs/common';
|
|
|
|
/**
|
|
* A refusal a service raises for its caller to pass on as it stands: the HTTP
|
|
* status, the exact `error` text the client sees, and any further fields the
|
|
* body carries next to it.
|
|
*
|
|
* It replaces the `return { error, status }` result a controller had to turn
|
|
* into `throw new HttpException({ error: r.error }, r.status)` and an MCP tool
|
|
* into `errorResult(r.error)`. Raised once in the service, it reaches both
|
|
* channels in their existing shapes:
|
|
*
|
|
* - REST: it IS an HttpException whose response is `{ error, ...details }`, so
|
|
* TrekExceptionFilter writes that body verbatim at `status` (5xx included,
|
|
* exactly as the hand-thrown HttpException did), and every
|
|
* `if (err instanceof HttpException) throw err` along the way lets it pass.
|
|
* - MCP: the registry's error mapper (`trekMcpErrorMapper`) answers the tool
|
|
* call with `errorResult(publicMessage)`.
|
|
*
|
|
* `code` names the refusal for logs and tests; it never reaches the body, so
|
|
* adding one changes no response. `mcpMessage` is for the few refusals whose
|
|
* MCP wording has always differed from the REST body (the trip gate's
|
|
* "Trip not found or access denied."); it names that drift in one place
|
|
* instead of in every tool.
|
|
*/
|
|
export class DomainError extends HttpException {
|
|
readonly code: string;
|
|
readonly publicMessage: string;
|
|
readonly details?: Readonly<Record<string, unknown>>;
|
|
readonly mcpMessage?: string;
|
|
|
|
constructor(
|
|
status: number,
|
|
publicMessage: string,
|
|
options: { code?: string; details?: Record<string, unknown>; mcpMessage?: string } = {},
|
|
) {
|
|
super(bodyOf(publicMessage, options.details), status);
|
|
this.name = 'DomainError';
|
|
this.message = publicMessage;
|
|
this.publicMessage = publicMessage;
|
|
this.code = options.code ?? defaultCode(status);
|
|
if (options.details) this.details = { ...options.details };
|
|
if (options.mcpMessage !== undefined) this.mcpMessage = options.mcpMessage;
|
|
}
|
|
|
|
/** The JSON body REST answers with: `{ error, ...details }`. */
|
|
toBody(): Record<string, unknown> {
|
|
return bodyOf(this.publicMessage, this.details);
|
|
}
|
|
}
|
|
|
|
/** `error` first, as every hand-written body had it, and never overridden by a detail. */
|
|
function bodyOf(error: string, details: Readonly<Record<string, unknown>> | undefined): Record<string, unknown> {
|
|
const body: Record<string, unknown> = { error };
|
|
for (const [key, value] of Object.entries(details ?? {})) if (key !== 'error') body[key] = value;
|
|
return body;
|
|
}
|
|
|
|
const CODES: Record<number, string> = {
|
|
400: 'bad_request',
|
|
401: 'unauthorized',
|
|
403: 'forbidden',
|
|
404: 'not_found',
|
|
409: 'conflict',
|
|
410: 'gone',
|
|
413: 'payload_too_large',
|
|
422: 'unprocessable',
|
|
429: 'too_many_requests',
|
|
500: 'internal',
|
|
502: 'bad_gateway',
|
|
503: 'unavailable',
|
|
};
|
|
|
|
function defaultCode(status: number): string {
|
|
return CODES[status] ?? `http_${status}`;
|
|
}
|
|
|
|
/** Shorthands for the statuses services raise most. */
|
|
export const badRequest = (message: string, details?: Record<string, unknown>) =>
|
|
new DomainError(400, message, { details });
|
|
export const unauthorized = (message: string, details?: Record<string, unknown>) =>
|
|
new DomainError(401, message, { details });
|
|
export const forbidden = (message: string, details?: Record<string, unknown>) =>
|
|
new DomainError(403, message, { details });
|
|
export const notFound = (message: string, details?: Record<string, unknown>) =>
|
|
new DomainError(404, message, { details });
|
|
export const conflict = (message: string, details?: Record<string, unknown>) =>
|
|
new DomainError(409, message, { details });
|
|
|
|
/**
|
|
* Runs `fn` and returns the DomainError it raised instead of throwing it; any
|
|
* other error propagates. For the few callers that must act on a refusal before
|
|
* passing it on (an audit row, a constant-time pad) and for service tests.
|
|
* Test the result with `instanceof DomainError`.
|
|
*/
|
|
export async function catchDomainError<T>(fn: () => Promise<T> | T): Promise<T | DomainError> {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
if (err instanceof DomainError) return err;
|
|
throw err;
|
|
}
|
|
}
|