From 8a7f85f3b554408ec9a91148120f1bd8d5ea59c7 Mon Sep 17 00:00:00 2001 From: Marshall Sorenson Date: Wed, 22 May 2024 18:13:43 -0400 Subject: [PATCH 1/2] feat: exceptions handling improvements --- .../custom-not-found.exception.fixture.ts | 20 ++++----- .../src/exceptions/runtime.exception.ts | 45 +++++++++++++++++++ .../src/filters/exceptions.filter.e2e-spec.ts | 7 ++- .../src/filters/exceptions.filter.ts | 36 ++++++++++++--- packages/nestjs-exception/src/index.ts | 7 +++ .../runtime-exception-options.interface.ts | 10 +++++ .../interfaces/runtime-exception.interface.ts | 23 ++++++++++ .../interfaces/exception.interface.ts | 2 +- .../src/exceptions/not-an-error.exception.ts | 3 +- 9 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 packages/nestjs-exception/src/exceptions/runtime.exception.ts create mode 100644 packages/nestjs-exception/src/interfaces/runtime-exception-options.interface.ts create mode 100644 packages/nestjs-exception/src/interfaces/runtime-exception.interface.ts 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..0ad87c347 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'; +import { HttpStatus } from '@nestjs/common'; +import { RuntimeException } from '../../exceptions/runtime.exception'; -export class CustomNotFoundExceptionFixture - extends NotFoundException - implements ExceptionInterface -{ +export class CustomNotFoundExceptionFixture extends RuntimeException { + httpStatus = HttpStatus.NOT_FOUND; errorCode = 'CUSTOM_NOT_FOUND'; 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], + }); } } 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..80fbcdbbb --- /dev/null +++ b/packages/nestjs-exception/src/exceptions/runtime.exception.ts @@ -0,0 +1,45 @@ +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'; + +export class RuntimeException + extends Error + implements RuntimeExceptionInterface +{ + errorCode = 'RUNTIME_EXCEPTION'; + httpStatus?: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + safeMessage?: string; + + context: { + originalError: Error; + }; + + 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), + }; + } +} 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..5da099035 100644 --- a/packages/nestjs-exception/src/index.ts +++ b/packages/nestjs-exception/src/index.ts @@ -1,2 +1,9 @@ // 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..f253b1e56 --- /dev/null +++ b/packages/nestjs-exception/src/interfaces/runtime-exception.interface.ts @@ -0,0 +1,23 @@ +import { HttpStatus } from '@nestjs/common'; +import { ExceptionInterface } from '@concepta/ts-core/src/exceptions/interfaces/exception.interface'; + +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?: Record & { originalError?: Error }; +} diff --git a/packages/ts-core/src/exceptions/interfaces/exception.interface.ts b/packages/ts-core/src/exceptions/interfaces/exception.interface.ts index 03697a7a0..31f11eb22 100644 --- a/packages/ts-core/src/exceptions/interfaces/exception.interface.ts +++ b/packages/ts-core/src/exceptions/interfaces/exception.interface.ts @@ -7,5 +7,5 @@ export interface ExceptionInterface extends Error { /** * Additional context */ - context?: Record; + context?: Record & { originalError?: unknown }; } 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, }; From c6cfad8aa20d5bf177c3aeb58cc00661d4457b80 Mon Sep 17 00:00:00 2001 From: Marshall Sorenson Date: Thu, 23 May 2024 13:16:43 -0400 Subject: [PATCH 2/2] feat: add some property protection --- .../custom-not-found.exception.fixture.ts | 6 ++-- .../nestjs-exception/src/exception.types.ts | 5 +++ .../src/exceptions/runtime.exception.ts | 36 ++++++++++++------- packages/nestjs-exception/src/index.ts | 3 ++ .../interfaces/runtime-exception.interface.ts | 3 +- packages/ts-core/src/core.types.ts | 3 ++ .../interfaces/exception.interface.ts | 4 ++- packages/ts-core/src/index.ts | 2 ++ 8 files changed, 45 insertions(+), 17 deletions(-) create mode 100644 packages/nestjs-exception/src/exception.types.ts create mode 100644 packages/ts-core/src/core.types.ts 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 0ad87c347..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 @@ -2,13 +2,13 @@ import { HttpStatus } from '@nestjs/common'; import { RuntimeException } from '../../exceptions/runtime.exception'; export class CustomNotFoundExceptionFixture extends RuntimeException { - httpStatus = HttpStatus.NOT_FOUND; - errorCode = 'CUSTOM_NOT_FOUND'; - constructor(itemId: number) { 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 index 80fbcdbbb..3eec5f3c4 100644 --- a/packages/nestjs-exception/src/exceptions/runtime.exception.ts +++ b/packages/nestjs-exception/src/exceptions/runtime.exception.ts @@ -3,18 +3,16 @@ 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 { - errorCode = 'RUNTIME_EXCEPTION'; - httpStatus?: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR; - safeMessage?: string; - - context: { - originalError: Error; - }; + private _errorCode = 'RUNTIME_EXCEPTION'; + private _httpStatus: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + private _safeMessage?: string; + public context: RuntimeExceptionContext = {}; constructor( options: RuntimeExceptionOptions = { message: 'Runtime Exception' }, @@ -31,15 +29,29 @@ export class RuntimeException super(format(message, ...messageParams)); if (httpStatus) { - this.httpStatus = httpStatus; + this._httpStatus = httpStatus; } if (safeMessage) { - this.safeMessage = format(safeMessage, ...safeMessageParams); + this._safeMessage = format(safeMessage, ...safeMessageParams); } - this.context = { - originalError: mapNonErrorToException(originalError), - }; + 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/index.ts b/packages/nestjs-exception/src/index.ts index 5da099035..563b64ac4 100644 --- a/packages/nestjs-exception/src/index.ts +++ b/packages/nestjs-exception/src/index.ts @@ -1,3 +1,6 @@ +// types +export { RuntimeExceptionContext } from './exception.types'; + // filters export { ExceptionsFilter } from './filters/exceptions.filter'; diff --git a/packages/nestjs-exception/src/interfaces/runtime-exception.interface.ts b/packages/nestjs-exception/src/interfaces/runtime-exception.interface.ts index f253b1e56..e1e1df3b1 100644 --- a/packages/nestjs-exception/src/interfaces/runtime-exception.interface.ts +++ b/packages/nestjs-exception/src/interfaces/runtime-exception.interface.ts @@ -1,5 +1,6 @@ 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 { /** @@ -19,5 +20,5 @@ export interface RuntimeExceptionInterface extends ExceptionInterface { /** * Additional context */ - context?: Record & { originalError?: Error }; + 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 31f11eb22..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 & { originalError?: unknown }; + context?: ExceptionContext; } 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';