diff --git a/packages/nestjs-exception/src/__fixtures__/exceptions/custom-not-found.exception.fixture.ts b/packages/nestjs-exception/src/__fixtures__/exceptions/custom-not-found.exception.fixture.ts index 007593f24..93c3f6a84 100644 --- a/packages/nestjs-exception/src/__fixtures__/exceptions/custom-not-found.exception.fixture.ts +++ b/packages/nestjs-exception/src/__fixtures__/exceptions/custom-not-found.exception.fixture.ts @@ -1,18 +1,14 @@ -import { format } from 'util'; -import { NotFoundException } from '@nestjs/common'; -import { ExceptionInterface } from '@concepta/ts-core'; - -export class CustomNotFoundExceptionFixture - extends NotFoundException - implements ExceptionInterface -{ - errorCode = 'CUSTOM_NOT_FOUND'; +import { HttpStatus } from '@nestjs/common'; +import { RuntimeException } from '../../exceptions/runtime.exception'; +export class CustomNotFoundExceptionFixture extends RuntimeException { constructor(itemId: number) { - super( - NotFoundException.createBody( - format('Item with id %d was not found.', itemId), - ), - ); + super({ + message: 'Item with id %d was not found.', + messageParams: [itemId], + httpStatus: HttpStatus.NOT_FOUND, + }); + + this.errorCode = 'CUSTOM_NOT_FOUND'; } } diff --git a/packages/nestjs-exception/src/exception.types.ts b/packages/nestjs-exception/src/exception.types.ts new file mode 100644 index 000000000..74ace7955 --- /dev/null +++ b/packages/nestjs-exception/src/exception.types.ts @@ -0,0 +1,5 @@ +import { ExceptionContext } from '@concepta/ts-core'; + +export type RuntimeExceptionContext = ExceptionContext & { + originalError?: Error; +}; diff --git a/packages/nestjs-exception/src/exceptions/runtime.exception.ts b/packages/nestjs-exception/src/exceptions/runtime.exception.ts new file mode 100644 index 000000000..3eec5f3c4 --- /dev/null +++ b/packages/nestjs-exception/src/exceptions/runtime.exception.ts @@ -0,0 +1,57 @@ +import { format } from 'util'; +import { HttpStatus } from '@nestjs/common'; +import { mapNonErrorToException } from '@concepta/ts-core'; +import { RuntimeExceptionInterface } from '../interfaces/runtime-exception.interface'; +import { RuntimeExceptionOptions } from '../interfaces/runtime-exception-options.interface'; +import { RuntimeExceptionContext } from '../exception.types'; + +export class RuntimeException + extends Error + implements RuntimeExceptionInterface +{ + private _errorCode = 'RUNTIME_EXCEPTION'; + private _httpStatus: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + private _safeMessage?: string; + public context: RuntimeExceptionContext = {}; + + constructor( + options: RuntimeExceptionOptions = { message: 'Runtime Exception' }, + ) { + const { + message = '', + messageParams = [], + safeMessage, + safeMessageParams = [], + originalError, + httpStatus, + } = options; + + super(format(message, ...messageParams)); + + if (httpStatus) { + this._httpStatus = httpStatus; + } + + if (safeMessage) { + this._safeMessage = format(safeMessage, ...safeMessageParams); + } + + this.context.originalError = mapNonErrorToException(originalError); + } + + public get errorCode() { + return this._errorCode; + } + + protected set errorCode(v: string) { + this._errorCode = v; + } + + public get httpStatus() { + return this._httpStatus; + } + + public get safeMessage() { + return this._safeMessage; + } +} diff --git a/packages/nestjs-exception/src/filters/exceptions.filter.e2e-spec.ts b/packages/nestjs-exception/src/filters/exceptions.filter.e2e-spec.ts index a5a063be1..a1fb4f654 100644 --- a/packages/nestjs-exception/src/filters/exceptions.filter.e2e-spec.ts +++ b/packages/nestjs-exception/src/filters/exceptions.filter.e2e-spec.ts @@ -1,7 +1,7 @@ import supertest from 'supertest'; import { HttpAdapterHost } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { AppModuleFixture } from '../__fixtures__/app.module.fixture'; import { ExceptionsFilter } from './exceptions.filter'; @@ -27,19 +27,24 @@ describe('Exception (e2e)', () => { it('Should return unknown', async () => { const response = await supertest(app.getHttpServer()).get('/test/unknown'); + expect(response.body.statusCode).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); expect(response.body.errorCode).toEqual('UNKNOWN'); + expect(response.body.message).toEqual('Internal Server Error'); }); it('Should return bad request', async () => { const response = await supertest(app.getHttpServer()).get( '/test/bad-request', ); + expect(response.body.statusCode).toEqual(HttpStatus.BAD_REQUEST); expect(response.body.errorCode).toEqual('HTTP_BAD_REQUEST'); + expect(response.body.message).toEqual('Bad Request'); }); it('Should return not found', async () => { const response = await supertest(app.getHttpServer()).get('/test/123'); expect(response.body.errorCode).toEqual('CUSTOM_NOT_FOUND'); + expect(response.body.statusCode).toEqual(HttpStatus.NOT_FOUND); expect(response.body.message).toEqual('Item with id 123 was not found.'); }); }); diff --git a/packages/nestjs-exception/src/filters/exceptions.filter.ts b/packages/nestjs-exception/src/filters/exceptions.filter.ts index 55f10cd17..bbafbebca 100644 --- a/packages/nestjs-exception/src/filters/exceptions.filter.ts +++ b/packages/nestjs-exception/src/filters/exceptions.filter.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import { ExceptionInterface } from '@concepta/ts-core'; import { ERROR_CODE_UNKNOWN } from '../constants/error-codes.constants'; +import { RuntimeException } from '../exceptions/runtime.exception'; import { mapHttpStatus } from '../utils/map-http-status.util'; @Catch() @@ -20,24 +21,47 @@ export class ExceptionsFilter implements ExceptionFilter { const ctx = host.switchToHttp(); + // error code is UNKNOWN unless it gets overridden + let errorCode = ERROR_CODE_UNKNOWN; + // error is 500 unless it gets overridden let statusCode = 500; + // what will this message be? + let message: string = 'Internal Server Error'; + // is this an http exception? if (exception instanceof HttpException) { // set the status code statusCode = exception.getStatus(); - // are we missing the error code? - if (!exception.errorCode) { - // its missing, try to set it - exception.errorCode = mapHttpStatus(statusCode); + // map the error code + errorCode = mapHttpStatus(statusCode); + // set the message + message = exception.message; + } else if (exception instanceof RuntimeException) { + // its a runtime exception, set error code + errorCode = exception.errorCode; + // did they provide a status hint? + if (exception?.httpStatus) { + statusCode = exception.httpStatus; + } + // set the message + if (statusCode >= 500) { + // use safe message or internal sever error + message = exception?.safeMessage ?? 'Internal Server Error'; + } else if (exception?.safeMessage) { + // use the safe message + message = exception.safeMessage; + } else { + // use the error message + message = exception.message; } } const responseBody = { statusCode, - errorCode: exception?.errorCode ?? ERROR_CODE_UNKNOWN, - message: exception.message, + errorCode, + message, timestamp: new Date().toISOString(), }; diff --git a/packages/nestjs-exception/src/index.ts b/packages/nestjs-exception/src/index.ts index 1f19cb59c..563b64ac4 100644 --- a/packages/nestjs-exception/src/index.ts +++ b/packages/nestjs-exception/src/index.ts @@ -1,2 +1,12 @@ +// types +export { RuntimeExceptionContext } from './exception.types'; + // filters export { ExceptionsFilter } from './filters/exceptions.filter'; + +// interfaces +export { RuntimeExceptionOptions } from './interfaces/runtime-exception-options.interface'; +export { RuntimeExceptionInterface } from './interfaces/runtime-exception.interface'; + +// exceptions +export { RuntimeException } from './exceptions/runtime.exception'; diff --git a/packages/nestjs-exception/src/interfaces/runtime-exception-options.interface.ts b/packages/nestjs-exception/src/interfaces/runtime-exception-options.interface.ts new file mode 100644 index 000000000..153201996 --- /dev/null +++ b/packages/nestjs-exception/src/interfaces/runtime-exception-options.interface.ts @@ -0,0 +1,10 @@ +import { HttpStatus } from '@nestjs/common'; + +export interface RuntimeExceptionOptions { + httpStatus?: HttpStatus; + message?: string; + messageParams?: (string | number)[]; + safeMessage?: string; + safeMessageParams?: (string | number)[]; + originalError?: unknown; +} diff --git a/packages/nestjs-exception/src/interfaces/runtime-exception.interface.ts b/packages/nestjs-exception/src/interfaces/runtime-exception.interface.ts new file mode 100644 index 000000000..e1e1df3b1 --- /dev/null +++ b/packages/nestjs-exception/src/interfaces/runtime-exception.interface.ts @@ -0,0 +1,24 @@ +import { HttpStatus } from '@nestjs/common'; +import { ExceptionInterface } from '@concepta/ts-core/src/exceptions/interfaces/exception.interface'; +import { RuntimeExceptionContext } from '../exception.types'; + +export interface RuntimeExceptionInterface extends ExceptionInterface { + /** + * Optional HTTP status code to use only when this exception is sent over an HTTP service. + * + * Please consider this to be a hint for API error responses. + */ + httpStatus?: HttpStatus; + + /** + * If set, this message will be used on responses instead of `message`. + * + * Use this when the main message might expose + */ + safeMessage?: string; + + /** + * Additional context + */ + context: RuntimeExceptionContext; +} diff --git a/packages/ts-core/src/core.types.ts b/packages/ts-core/src/core.types.ts new file mode 100644 index 000000000..904ae2899 --- /dev/null +++ b/packages/ts-core/src/core.types.ts @@ -0,0 +1,3 @@ +export type ExceptionContext = Record & { + originalError?: unknown; +}; diff --git a/packages/ts-core/src/exceptions/interfaces/exception.interface.ts b/packages/ts-core/src/exceptions/interfaces/exception.interface.ts index 03697a7a0..441e2e82c 100644 --- a/packages/ts-core/src/exceptions/interfaces/exception.interface.ts +++ b/packages/ts-core/src/exceptions/interfaces/exception.interface.ts @@ -1,3 +1,5 @@ +import { ExceptionContext } from '../../core.types'; + export interface ExceptionInterface extends Error { /** * The error code. @@ -7,5 +9,5 @@ export interface ExceptionInterface extends Error { /** * Additional context */ - context?: Record; + context?: ExceptionContext; } diff --git a/packages/ts-core/src/exceptions/not-an-error.exception.ts b/packages/ts-core/src/exceptions/not-an-error.exception.ts index e067ff779..469f99940 100644 --- a/packages/ts-core/src/exceptions/not-an-error.exception.ts +++ b/packages/ts-core/src/exceptions/not-an-error.exception.ts @@ -1,4 +1,3 @@ -import { format } from 'util'; import { ExceptionInterface } from './interfaces/exception.interface'; export class NotAnErrorException extends Error implements ExceptionInterface { @@ -12,7 +11,7 @@ export class NotAnErrorException extends Error implements ExceptionInterface { originalError: unknown, message = 'An error was caught that is not an Error object', ) { - super(format(message)); + super(message); this.context = { originalError, }; diff --git a/packages/ts-core/src/index.ts b/packages/ts-core/src/index.ts index 8558be0d0..21fa9817e 100644 --- a/packages/ts-core/src/index.ts +++ b/packages/ts-core/src/index.ts @@ -1,3 +1,5 @@ +export { ExceptionContext } from './core.types'; + export { ExceptionInterface } from './exceptions/interfaces/exception.interface'; export { Type } from './utils/interfaces/type.interface';