From d72da4e3e585c8458f9c78bd9b5c712e2a5567eb Mon Sep 17 00:00:00 2001 From: Livio Brunner Date: Wed, 31 Jan 2024 11:06:58 +0100 Subject: [PATCH 1/3] refactor: Use generic for promiseTimeout --- lib/utils/promise-timeout.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/utils/promise-timeout.ts b/lib/utils/promise-timeout.ts index 2daf133c3..aaf1ea041 100644 --- a/lib/utils/promise-timeout.ts +++ b/lib/utils/promise-timeout.ts @@ -16,12 +16,12 @@ export class TimeoutError extends Error {} * * @internal */ -export const promiseTimeout = function ( +export const promiseTimeout = ( ms: number, - promise: Promise, -): Promise { + promise: Promise, +): Promise => { let timer: NodeJS.Timeout; - return Promise.race([ + return Promise.race([ promise, new Promise( (_, reject) => From e687dc0f9459c84ddcf06a1632bcf95583e7c035 Mon Sep 17 00:00:00 2001 From: Livio Brunner Date: Wed, 31 Jan 2024 11:08:38 +0100 Subject: [PATCH 2/3] refactor: remove unneeded usage of `isHealthy` --- lib/health-indicator/database/mongoose.health.ts | 14 +++++--------- lib/health-indicator/database/prisma.health.ts | 12 ++++-------- .../database/sequelize.health.ts | 14 +++++--------- lib/health-indicator/database/typeorm.health.ts | 16 ++++++---------- 4 files changed, 20 insertions(+), 36 deletions(-) diff --git a/lib/health-indicator/database/mongoose.health.ts b/lib/health-indicator/database/mongoose.health.ts index f87cfc09d..a381fb6da 100644 --- a/lib/health-indicator/database/mongoose.health.ts +++ b/lib/health-indicator/database/mongoose.health.ts @@ -96,7 +96,6 @@ export class MongooseHealthIndicator extends HealthIndicator { key: string, options: MongoosePingCheckSettings = {}, ): Promise { - let isHealthy = false; this.checkDependantPackages(); const connection = options.connection || this.getContextConnection(); @@ -104,7 +103,7 @@ export class MongooseHealthIndicator extends HealthIndicator { if (!connection) { throw new ConnectionNotFoundError( - this.getStatus(key, isHealthy, { + this.getStatus(key, false, { message: 'Connection provider not found in application context', }), ); @@ -112,25 +111,22 @@ export class MongooseHealthIndicator extends HealthIndicator { try { await this.pingDb(connection, timeout); - isHealthy = true; } catch (err) { if (err instanceof PromiseTimeoutError) { throw new TimeoutError( timeout, - this.getStatus(key, isHealthy, { + this.getStatus(key, false, { message: `timeout of ${timeout}ms exceeded`, }), ); } - } - if (isHealthy) { - return this.getStatus(key, isHealthy); - } else { throw new HealthCheckError( `${key} is not available`, - this.getStatus(key, isHealthy), + this.getStatus(key, false), ); } + + return this.getStatus(key, true); } } diff --git a/lib/health-indicator/database/prisma.health.ts b/lib/health-indicator/database/prisma.health.ts index ccaed51e7..6dbbb2887 100644 --- a/lib/health-indicator/database/prisma.health.ts +++ b/lib/health-indicator/database/prisma.health.ts @@ -74,30 +74,26 @@ export class PrismaHealthIndicator extends HealthIndicator { prismaClient: PrismaClient, options: PrismaClientPingCheckSettings = {}, ): Promise { - let isHealthy = false; const timeout = options.timeout || 1000; try { await this.pingDb(timeout, prismaClient); - isHealthy = true; } catch (error) { if (error instanceof PromiseTimeoutError) { throw new TimeoutError( timeout, - this.getStatus(key, isHealthy, { + this.getStatus(key, false, { message: `timeout of ${timeout}ms exceeded`, }), ); } - } - if (isHealthy) { - return this.getStatus(key, isHealthy); - } else { throw new HealthCheckError( `${key} is not available`, - this.getStatus(key, isHealthy), + this.getStatus(key, false), ); } + + return this.getStatus(key, true); } } diff --git a/lib/health-indicator/database/sequelize.health.ts b/lib/health-indicator/database/sequelize.health.ts index c9b5cb8c7..44bb0ef97 100644 --- a/lib/health-indicator/database/sequelize.health.ts +++ b/lib/health-indicator/database/sequelize.health.ts @@ -92,7 +92,6 @@ export class SequelizeHealthIndicator extends HealthIndicator { key: string, options: SequelizePingCheckSettings = {}, ): Promise { - let isHealthy = false; this.checkDependantPackages(); const connection = options.connection || this.getContextConnection(); @@ -100,7 +99,7 @@ export class SequelizeHealthIndicator extends HealthIndicator { if (!connection) { throw new ConnectionNotFoundError( - this.getStatus(key, isHealthy, { + this.getStatus(key, false, { message: 'Connection provider not found in application context', }), ); @@ -108,25 +107,22 @@ export class SequelizeHealthIndicator extends HealthIndicator { try { await this.pingDb(connection, timeout); - isHealthy = true; } catch (err) { if (err instanceof PromiseTimeoutError) { throw new TimeoutError( timeout, - this.getStatus(key, isHealthy, { + this.getStatus(key, false, { message: `timeout of ${timeout}ms exceeded`, }), ); } - } - if (isHealthy) { - return this.getStatus(key, isHealthy); - } else { throw new HealthCheckError( `${key} is not available`, - this.getStatus(key, isHealthy), + this.getStatus(key, false), ); } + + return this.getStatus(key, true); } } diff --git a/lib/health-indicator/database/typeorm.health.ts b/lib/health-indicator/database/typeorm.health.ts index 5bed3cad3..dd55ce3be 100644 --- a/lib/health-indicator/database/typeorm.health.ts +++ b/lib/health-indicator/database/typeorm.health.ts @@ -111,7 +111,6 @@ export class TypeOrmHealthIndicator extends HealthIndicator { key: string, options: TypeOrmPingCheckSettings = {}, ): Promise { - let isHealthy = false; this.checkDependantPackages(); const connection: TypeOrm.DataSource | null = @@ -120,7 +119,7 @@ export class TypeOrmHealthIndicator extends HealthIndicator { if (!connection) { throw new ConnectionNotFoundError( - this.getStatus(key, isHealthy, { + this.getStatus(key, false, { message: 'Connection provider not found in application context', }), ); @@ -128,12 +127,11 @@ export class TypeOrmHealthIndicator extends HealthIndicator { try { await this.pingDb(connection, timeout); - isHealthy = true; } catch (err) { if (err instanceof PromiseTimeoutError) { throw new TimeoutError( timeout, - this.getStatus(key, isHealthy, { + this.getStatus(key, false, { message: `timeout of ${timeout}ms exceeded`, }), ); @@ -141,20 +139,18 @@ export class TypeOrmHealthIndicator extends HealthIndicator { if (err instanceof MongoConnectionError) { throw new HealthCheckError( err.message, - this.getStatus(key, isHealthy, { + this.getStatus(key, false, { message: err.message, }), ); } - } - if (isHealthy) { - return this.getStatus(key, isHealthy); - } else { throw new HealthCheckError( `${key} is not available`, - this.getStatus(key, isHealthy), + this.getStatus(key, false), ); } + + return this.getStatus(key, true); } } From 9f10a9b7029f53eddb620c046a3a48cb4dfb35b3 Mon Sep 17 00:00:00 2001 From: Livio Brunner Date: Thu, 23 Jan 2025 20:23:38 +0100 Subject: [PATCH 3/3] feat: simplify custom health indicator creation The abstract `HealthIndicator` class has been marked as deprecated as well as the `HealthCheckError` error. Instead a `HealthIndicatorService` should be utilised to indicate the state of a health indicator. --- .github/workflows/build-samples.yml | 1 + lib/errors/connection-not-found.error.ts | 6 + lib/errors/storage-exceeded.error.ts | 5 + lib/errors/timeout-error.ts | 5 + lib/errors/unhealthy-response-code.error.ts | 5 + .../health-check-executor.service.spec.ts | 161 ++++++++++++++++-- .../health-check-executor.service.ts | 11 +- lib/health-check/health-check.error.ts | 5 + .../database/mikro-orm.health.ts | 110 +++++------- .../database/mongoose.health.ts | 49 ++---- .../database/prisma.health.ts | 34 ++-- .../database/sequelize.health.ts | 49 ++---- .../database/typeorm.health.ts | 57 ++----- lib/health-indicator/disk/disk.health.ts | 41 ++--- .../health-indicator-result.interface.ts | 20 +-- .../health-indicator.service.ts | 77 +++++++++ lib/health-indicator/health-indicator.ts | 3 + .../health-indicators.provider.ts | 4 +- lib/health-indicator/http/http.health.spec.ts | 8 +- lib/health-indicator/http/http.health.ts | 65 +++---- lib/health-indicator/index.ts | 1 + lib/health-indicator/memory/memory.health.ts | 42 +++-- .../microservice/grpc.health.spec.ts | 28 ++- .../microservice/grpc.health.ts | 103 ++++++----- .../microservice/microservice.health.ts | 68 ++------ lib/index.ts | 1 + lib/terminus.module.ts | 8 +- lib/utils/is-error.ts | 1 + package.json | 1 + .../000-dogs-app/src/dog/dog.health.spec.ts | 72 ++++++++ sample/000-dogs-app/src/dog/dog.health.ts | 30 ++-- sample/000-dogs-app/src/dog/dog.module.ts | 2 + sample/000-dogs-app/test/health.e2e-spec.ts | 4 +- sample/011-mirkoorm-app/package.json | 2 +- sample/012-prisma-app/package.json | 2 +- 35 files changed, 644 insertions(+), 437 deletions(-) create mode 100644 lib/health-indicator/health-indicator.service.ts create mode 100644 sample/000-dogs-app/src/dog/dog.health.spec.ts diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 38394bf86..2fb41c63c 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -39,5 +39,6 @@ jobs: # FIXME: Remove the `--legacy-peer-deps` flag once all dependencies are updated - run: npm ci --legacy-peer-deps - run: npm run build:all + - run: npm run test:samples env: CI: true \ No newline at end of file diff --git a/lib/errors/connection-not-found.error.ts b/lib/errors/connection-not-found.error.ts index 8b92fb579..7448a1162 100644 --- a/lib/errors/connection-not-found.error.ts +++ b/lib/errors/connection-not-found.error.ts @@ -1,3 +1,4 @@ +/* eslint-disable deprecation/deprecation */ import { CONNECTION_NOT_FOUND } from './messages.constant'; import { HealthCheckError } from '../health-check/health-check.error'; @@ -5,6 +6,11 @@ import { HealthCheckError } from '../health-check/health-check.error'; * Error which gets thrown when the connection * instance was not found in the application context * @publicApi + * + * @deprecated + * This class has been deprecated and will be removed in the next major release. + * Instead utilise the `HealthIndicatorService` to indicate the health of your health indicator. + * */ export class ConnectionNotFoundError extends HealthCheckError { /** diff --git a/lib/errors/storage-exceeded.error.ts b/lib/errors/storage-exceeded.error.ts index 8fae05ad0..b75f3a26d 100644 --- a/lib/errors/storage-exceeded.error.ts +++ b/lib/errors/storage-exceeded.error.ts @@ -1,3 +1,4 @@ +/* eslint-disable deprecation/deprecation */ import { STORAGE_EXCEEDED } from './messages.constant'; import { HealthCheckError } from '../health-check/health-check.error'; @@ -5,6 +6,10 @@ import { HealthCheckError } from '../health-check/health-check.error'; * Error which gets thrown when the given storage threshold * has exceeded. * @publicApi + * + * @deprecated + * This class has been deprecated and will be removed in the next major release. + * Instead utilise the `HealthIndicatorService` to indicate the health of your health indicator. */ export class StorageExceededError extends HealthCheckError { /** diff --git a/lib/errors/timeout-error.ts b/lib/errors/timeout-error.ts index 64c78b36c..043bfb00b 100644 --- a/lib/errors/timeout-error.ts +++ b/lib/errors/timeout-error.ts @@ -1,9 +1,14 @@ +/* eslint-disable deprecation/deprecation */ import { TIMEOUT_EXCEEDED } from './messages.constant'; import { HealthCheckError } from '../health-check/health-check.error'; /** * Gets thrown when the timeout of the health check exceeds * @publicApi + * + * @deprecated + * This class has been deprecated and will be removed in the next major release. + * Instead utilise the `HealthIndicatorService` to indicate the health of your health indicator. */ export class TimeoutError extends HealthCheckError { /** diff --git a/lib/errors/unhealthy-response-code.error.ts b/lib/errors/unhealthy-response-code.error.ts index 8aeeda6e6..a8f339f9e 100644 --- a/lib/errors/unhealthy-response-code.error.ts +++ b/lib/errors/unhealthy-response-code.error.ts @@ -1,3 +1,4 @@ +/* eslint-disable deprecation/deprecation */ import { UNHEALTHY_RESPONSE_CODE } from './messages.constant'; import { HealthCheckError } from '../health-check/health-check.error'; @@ -5,6 +6,10 @@ import { HealthCheckError } from '../health-check/health-check.error'; * Error which gets thrown when the terminus client receives * an unhealthy response code from the server. * @publicApi + * + * @deprecated + * This class has been deprecated and will be removed in the next major release. + * Instead utilise the `HealthIndicatorService` to indicate the health of your health indicator. */ export class UnhealthyResponseCodeError extends HealthCheckError { /** diff --git a/lib/health-check/health-check-executor.service.spec.ts b/lib/health-check/health-check-executor.service.spec.ts index 328b090a9..6aeda4ff1 100644 --- a/lib/health-check/health-check-executor.service.spec.ts +++ b/lib/health-check/health-check-executor.service.spec.ts @@ -1,10 +1,26 @@ import { Test } from '@nestjs/testing'; import { HealthCheckExecutor } from './health-check-executor.service'; -import { HealthIndicatorResult } from '../health-indicator'; +import { + HealthIndicatorResult, + HealthIndicatorService, +} from '../health-indicator'; import { HealthCheckResult } from './health-check-result.interface'; import { HealthCheckError } from '../health-check/health-check.error'; -const healthyCheck = async (): Promise => { +//////////////////////////////////////////////////////////////// + +const healthIndicator = async (h: HealthIndicatorService) => + h.check('healthy').up(); + +const unhealthyHealthIndicator = async (h: HealthIndicatorService) => + h.check('unhealthy').down(); + +const unhealthyHealthIndicatorSync = (h: HealthIndicatorService) => + h.check('unhealthy').down(); + +// Legacy health indicator functions + +const legacyHealthyIndicator = async (): Promise => { return { healthy: { status: 'up', @@ -12,7 +28,7 @@ const healthyCheck = async (): Promise => { }; }; -const unhealthyCheck = async (): Promise => { +const legacyUnhealthyIndicator = async (): Promise => { throw new HealthCheckError('error', { unhealthy: { status: 'down', @@ -20,7 +36,7 @@ const unhealthyCheck = async (): Promise => { }); }; -const unhealthyCheckSync = () => { +const legacyUnhealthyIndicatorSync = () => { throw new HealthCheckError('error', { unhealthy: { status: 'down', @@ -28,20 +44,35 @@ const unhealthyCheckSync = () => { }); }; +const legacyUnhealthyIndicatorWithoutError = + async (): Promise => { + return { + unhealthy: { + status: 'down', + }, + }; + }; + +//////////////////////////////////////////////////////////////// + describe('HealthCheckExecutorService', () => { let healthCheckExecutor: HealthCheckExecutor; + let h: HealthIndicatorService; beforeEach(async () => { const module = Test.createTestingModule({ - providers: [HealthCheckExecutor], + providers: [HealthCheckExecutor, HealthIndicatorService], }); const context = await module.compile(); healthCheckExecutor = context.get(HealthCheckExecutor); + h = context.get(HealthIndicatorService); }); describe('execute', () => { it('should return a result object without errors', async () => { - const result = await healthCheckExecutor.execute([() => healthyCheck()]); + const result = await healthCheckExecutor.execute([ + () => healthIndicator(h), + ]); expect(result).toEqual({ status: 'ok', info: { @@ -60,7 +91,7 @@ describe('HealthCheckExecutorService', () => { it('should return a result object with errors', async () => { const result = await healthCheckExecutor.execute([ - () => unhealthyCheck(), + () => unhealthyHealthIndicator(h), ]); expect(result).toEqual({ status: 'error', @@ -80,7 +111,7 @@ describe('HealthCheckExecutorService', () => { it('should return a result object with errors with sync indicator function', async () => { const result = await healthCheckExecutor.execute([ - () => unhealthyCheckSync(), + () => unhealthyHealthIndicatorSync(h), ]); expect(result).toEqual({ status: 'error', @@ -100,8 +131,8 @@ describe('HealthCheckExecutorService', () => { it('should return a result object without errors and with errors', async () => { const result = await healthCheckExecutor.execute([ - () => unhealthyCheck(), - () => healthyCheck(), + () => unhealthyHealthIndicator(h), + () => healthIndicator(h), ]); expect(result).toEqual({ status: 'error', @@ -125,5 +156,115 @@ describe('HealthCheckExecutorService', () => { }, }); }); + + describe('backwards compatibility', () => { + it('should return a result object without errors', async () => { + const result = await healthCheckExecutor.execute([ + () => legacyHealthyIndicator(), + ]); + expect(result).toEqual({ + status: 'ok', + info: { + healthy: { + status: 'up', + }, + }, + error: {}, + details: { + healthy: { + status: 'up', + }, + }, + }); + }); + + it('should return a result object with errors', async () => { + const result = await healthCheckExecutor.execute([ + () => legacyUnhealthyIndicator(), + ]); + expect(result).toEqual({ + status: 'error', + info: {}, + error: { + unhealthy: { + status: 'down', + }, + }, + details: { + unhealthy: { + status: 'down', + }, + }, + }); + }); + + it('should return a result object with errors with sync indicator function', async () => { + const result = await healthCheckExecutor.execute([ + () => legacyUnhealthyIndicatorSync(), + ]); + expect(result).toEqual({ + status: 'error', + info: {}, + error: { + unhealthy: { + status: 'down', + }, + }, + details: { + unhealthy: { + status: 'down', + }, + }, + }); + }); + + it('should return a result object without errors and with errors', async () => { + const result = await healthCheckExecutor.execute([ + () => legacyUnhealthyIndicator(), + () => legacyHealthyIndicator(), + ]); + expect(result).toEqual({ + status: 'error', + info: { + healthy: { + status: 'up', + }, + }, + error: { + unhealthy: { + status: 'down', + }, + }, + details: { + healthy: { + status: 'up', + }, + unhealthy: { + status: 'down', + }, + }, + }); + }); + + it('should return a result object with errors when error is not an instance of HealthCheckError', async () => { + const result = await healthCheckExecutor.execute([ + () => legacyUnhealthyIndicatorWithoutError(), + ]); + expect(result).toEqual({ + status: 'error', + info: {}, + error: { + unhealthy: { + status: 'down', + }, + }, + details: { + unhealthy: { + status: 'down', + }, + }, + }); + }); + }); }); }); diff --git a/lib/health-check/health-check-executor.service.ts b/lib/health-check/health-check-executor.service.ts index 258d29362..6f25c597a 100644 --- a/lib/health-check/health-check-executor.service.ts +++ b/lib/health-check/health-check-executor.service.ts @@ -65,14 +65,21 @@ export class HealthCheckExecutor implements BeforeApplicationShutdown { result.forEach((res) => { if (res.status === 'fulfilled') { - results.push(res.value); + Object.entries(res.value).forEach(([key, value]) => { + if (value.status === 'up') { + results.push({ [key]: value }); + } else if (value.status === 'down') { + errors.push({ [key]: value }); + } + }); } else { const error = res.reason; // Is not an expected error. Throw further! if (!isHealthCheckError(error)) { throw error; } - // Is a expected health check error + + // eslint-disable-next-line deprecation/deprecation errors.push((error as HealthCheckError).causes); } }); diff --git a/lib/health-check/health-check.error.ts b/lib/health-check/health-check.error.ts index f4c16a316..60d523c51 100644 --- a/lib/health-check/health-check.error.ts +++ b/lib/health-check/health-check.error.ts @@ -1,3 +1,8 @@ +/** + * @deprecated + * This class has been deprecated and will be removed in the next major release. + * Instead utilise the `HealthIndicatorService` to indicate the health of your health indicator. + */ export class HealthCheckError extends Error { causes: any; isHealthCheckError = true; diff --git a/lib/health-indicator/database/mikro-orm.health.ts b/lib/health-indicator/database/mikro-orm.health.ts index f413659ff..2cc38ce06 100644 --- a/lib/health-indicator/database/mikro-orm.health.ts +++ b/lib/health-indicator/database/mikro-orm.health.ts @@ -1,15 +1,14 @@ import type * as MikroOrm from '@mikro-orm/core'; import { Injectable, Scope } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { HealthIndicator, type HealthIndicatorResult } from '..'; -import { TimeoutError } from '../../errors'; +import { type HealthIndicatorResult } from '..'; import { DatabaseNotConnectedError } from '../../errors/database-not-connected.error'; -import { HealthCheckError } from '../../health-check/health-check.error'; import { TimeoutError as PromiseTimeoutError, promiseTimeout, checkPackages, } from '../../utils'; +import { HealthIndicatorService } from '../health-indicator.service'; export interface MikroOrmPingCheckSettings { /** @@ -30,69 +29,14 @@ export interface MikroOrmPingCheckSettings { * @module TerminusModule */ @Injectable({ scope: Scope.TRANSIENT }) -export class MikroOrmHealthIndicator extends HealthIndicator { - /** - * Initializes the MikroOrmHealthIndicator - * - * @param {ModuleRef} moduleRef The NestJS module reference - */ - constructor(private moduleRef: ModuleRef) { - super(); +export class MikroOrmHealthIndicator { + constructor( + private readonly moduleRef: ModuleRef, + private readonly healthIndicatorService: HealthIndicatorService, + ) { this.checkDependantPackages(); } - /** - * Checks if responds in (default) 1000ms and - * returns a result object corresponding to the result - * @param key The key which will be used for the result object - * @param options The options for the ping - * - * @example - * MikroOrmHealthIndicator.pingCheck('database', { timeout: 1500 }); - */ - public async pingCheck( - key: string, - options: MikroOrmPingCheckSettings = {}, - ): Promise { - this.checkDependantPackages(); - - const connection = options.connection || this.getContextConnection(); - const timeout = options.timeout || 1000; - - if (!connection) { - return this.getStatus(key, false); - } - - try { - await this.pingDb(connection, timeout); - } catch (error) { - // Check if the error is a timeout error - if (error instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, false, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); - } - if (error instanceof DatabaseNotConnectedError) { - throw new HealthCheckError( - error.message, - this.getStatus(key, false, { - message: error.message, - }), - ); - } - - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, false), - ); - } - - return this.getStatus(key, true); - } - private checkDependantPackages() { checkPackages( ['@mikro-orm/nestjs', '@mikro-orm/core'], @@ -133,4 +77,44 @@ export class MikroOrmHealthIndicator extends HealthIndicator { return await promiseTimeout(timeout, checker()); } + + /** + * Checks if responds in (default) 1000ms and + * returns a result object corresponding to the result + * @param key The key which will be used for the result object + * @param options The options for the ping + * + * @example + * MikroOrmHealthIndicator.pingCheck('database', { timeout: 1500 }); + */ + public async pingCheck( + key: Key, + options: MikroOrmPingCheckSettings = {}, + ): Promise> { + this.checkDependantPackages(); + const check = this.healthIndicatorService.check(key); + + const timeout = options.timeout || 1000; + const connection = options.connection || this.getContextConnection(); + + if (!connection) { + return check.down(); + } + + try { + await this.pingDb(connection, timeout); + } catch (error) { + // Check if the error is a timeout error + if (error instanceof PromiseTimeoutError) { + return check.down(`timeout of ${timeout}ms exceeded`); + } + if (error instanceof DatabaseNotConnectedError) { + return check.down(error.message); + } + + return check.down(); + } + + return check.up(); + } } diff --git a/lib/health-indicator/database/mongoose.health.ts b/lib/health-indicator/database/mongoose.health.ts index a381fb6da..5ab1401f8 100644 --- a/lib/health-indicator/database/mongoose.health.ts +++ b/lib/health-indicator/database/mongoose.health.ts @@ -1,18 +1,13 @@ import { Injectable, Scope } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import type * as NestJSMongoose from '@nestjs/mongoose'; -import { - type HealthIndicatorResult, - TimeoutError, - ConnectionNotFoundError, -} from '../..'; -import { HealthCheckError } from '../../health-check/health-check.error'; +import { type HealthIndicatorResult } from '../..'; import { promiseTimeout, TimeoutError as PromiseTimeoutError, checkPackages, } from '../../utils'; -import { HealthIndicator } from '../health-indicator'; +import { HealthIndicatorService } from '../health-indicator.service'; export interface MongoosePingCheckSettings { /** @@ -33,14 +28,11 @@ export interface MongoosePingCheckSettings { * @module TerminusModule */ @Injectable({ scope: Scope.TRANSIENT }) -export class MongooseHealthIndicator extends HealthIndicator { - /** - * Initializes the MongooseHealthIndicator - * - * @param {ModuleRef} moduleRef The NestJS module reference - */ - constructor(private moduleRef: ModuleRef) { - super(); +export class MongooseHealthIndicator { + constructor( + private readonly moduleRef: ModuleRef, + private readonly healthIndicatorService: HealthIndicatorService, + ) { this.checkDependantPackages(); } @@ -92,41 +84,30 @@ export class MongooseHealthIndicator extends HealthIndicator { * @example * mongooseHealthIndicator.pingCheck('mongodb', { timeout: 1500 }); */ - public async pingCheck( - key: string, + public async pingCheck( + key: Key, options: MongoosePingCheckSettings = {}, - ): Promise { + ): Promise> { this.checkDependantPackages(); + const check = this.healthIndicatorService.check(key); const connection = options.connection || this.getContextConnection(); const timeout = options.timeout || 1000; if (!connection) { - throw new ConnectionNotFoundError( - this.getStatus(key, false, { - message: 'Connection provider not found in application context', - }), - ); + return check.down('Connection provider not found in application context'); } try { await this.pingDb(connection, timeout); } catch (err) { if (err instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, false, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); + return check.down(`timeout of ${timeout}ms exceeded`); } - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, false), - ); + return check.down(); } - return this.getStatus(key, true); + return check.up(); } } diff --git a/lib/health-indicator/database/prisma.health.ts b/lib/health-indicator/database/prisma.health.ts index 6dbbb2887..340689131 100644 --- a/lib/health-indicator/database/prisma.health.ts +++ b/lib/health-indicator/database/prisma.health.ts @@ -1,11 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { TimeoutError } from '../../errors'; -import { HealthCheckError } from '../../health-check'; import { promiseTimeout, TimeoutError as PromiseTimeoutError, } from '../../utils'; -import { HealthIndicator } from '../health-indicator'; +import { type HealthIndicatorResult } from '../health-indicator-result.interface'; +import { HealthIndicatorService } from '../health-indicator.service'; type PingCommandSignature = { [Key in string]?: number }; @@ -34,10 +33,10 @@ export interface PrismaClientPingCheckSettings { * @module TerminusModule */ @Injectable() -export class PrismaHealthIndicator extends HealthIndicator { - constructor() { - super(); - } +export class PrismaHealthIndicator { + constructor( + private readonly healthIndicatorService: HealthIndicatorService, + ) {} private async pingDb(timeout: number, prismaClientSQLOrMongo: PrismaClient) { // The prisma client generates two different typescript types for different databases @@ -69,31 +68,24 @@ export class PrismaHealthIndicator extends HealthIndicator { * @param prismaClient PrismaClient * @param options The options for the ping */ - public async pingCheck( - key: string, + public async pingCheck( + key: Key, prismaClient: PrismaClient, options: PrismaClientPingCheckSettings = {}, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); const timeout = options.timeout || 1000; try { await this.pingDb(timeout, prismaClient); } catch (error) { if (error instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, false, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); + return check.down(`timeout of ${timeout}ms exceeded`); } - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, false), - ); + return check.down(); } - return this.getStatus(key, true); + return check.up(); } } diff --git a/lib/health-indicator/database/sequelize.health.ts b/lib/health-indicator/database/sequelize.health.ts index 44bb0ef97..c13d05047 100644 --- a/lib/health-indicator/database/sequelize.health.ts +++ b/lib/health-indicator/database/sequelize.health.ts @@ -1,18 +1,13 @@ import { Injectable, Scope } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import type * as NestJSSequelize from '@nestjs/sequelize'; -import { - type HealthIndicatorResult, - TimeoutError, - ConnectionNotFoundError, -} from '../..'; -import { HealthCheckError } from '../../health-check/health-check.error'; +import { type HealthIndicatorResult } from '../..'; import { promiseTimeout, TimeoutError as PromiseTimeoutError, checkPackages, } from '../../utils'; -import { HealthIndicator } from '../health-indicator'; +import { HealthIndicatorService } from '../health-indicator.service'; export interface SequelizePingCheckSettings { /** @@ -33,14 +28,11 @@ export interface SequelizePingCheckSettings { * @module TerminusModule */ @Injectable({ scope: Scope.TRANSIENT }) -export class SequelizeHealthIndicator extends HealthIndicator { - /** - * Initializes the SequelizeHealthIndicator - * - * @param {ModuleRef} moduleRef The NestJS module reference - */ - constructor(private moduleRef: ModuleRef) { - super(); +export class SequelizeHealthIndicator { + constructor( + private readonly moduleRef: ModuleRef, + private readonly healthIndicatorService: HealthIndicatorService, + ) { this.checkDependantPackages(); } @@ -88,41 +80,30 @@ export class SequelizeHealthIndicator extends HealthIndicator { * @example * sequelizeHealthIndicator.pingCheck('database', { timeout: 1500 }); */ - public async pingCheck( - key: string, + public async pingCheck( + key: Key, options: SequelizePingCheckSettings = {}, - ): Promise { + ): Promise> { this.checkDependantPackages(); + const check = this.healthIndicatorService.check(key); const connection = options.connection || this.getContextConnection(); const timeout = options.timeout || 1000; if (!connection) { - throw new ConnectionNotFoundError( - this.getStatus(key, false, { - message: 'Connection provider not found in application context', - }), - ); + return check.down('Connection provider not found in application context'); } try { await this.pingDb(connection, timeout); } catch (err) { if (err instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, false, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); + return check.down(`timeout of ${timeout}ms exceeded`); } - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, false), - ); + return check.down(); } - return this.getStatus(key, true); + return check.up(); } } diff --git a/lib/health-indicator/database/typeorm.health.ts b/lib/health-indicator/database/typeorm.health.ts index dd55ce3be..c5804825f 100644 --- a/lib/health-indicator/database/typeorm.health.ts +++ b/lib/health-indicator/database/typeorm.health.ts @@ -2,18 +2,14 @@ import { Injectable, Scope } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import type * as NestJSTypeOrm from '@nestjs/typeorm'; import type * as TypeOrm from 'typeorm'; -import { HealthIndicator, type HealthIndicatorResult } from '../'; -import { - TimeoutError, - ConnectionNotFoundError, - MongoConnectionError, -} from '../../errors'; -import { HealthCheckError } from '../../health-check/health-check.error'; +import { type HealthIndicatorResult } from '../'; +import { MongoConnectionError } from '../../errors'; import { TimeoutError as PromiseTimeoutError, promiseTimeout, checkPackages, } from '../../utils'; +import { HealthIndicatorService } from '../health-indicator.service'; export interface TypeOrmPingCheckSettings { /** @@ -35,14 +31,11 @@ export interface TypeOrmPingCheckSettings { * @module TerminusModule */ @Injectable({ scope: Scope.TRANSIENT }) -export class TypeOrmHealthIndicator extends HealthIndicator { - /** - * Initializes the TypeOrmHealthIndicator - * - * @param {ModuleRef} moduleRef The NestJS module reference - */ - constructor(private moduleRef: ModuleRef) { - super(); +export class TypeOrmHealthIndicator { + constructor( + private readonly moduleRef: ModuleRef, + private readonly healthIndicatorService: HealthIndicatorService, + ) { this.checkDependantPackages(); } @@ -107,10 +100,11 @@ export class TypeOrmHealthIndicator extends HealthIndicator { * @example * typeOrmHealthIndicator.pingCheck('database', { timeout: 1500 }); */ - async pingCheck( - key: string, + async pingCheck( + key: Key, options: TypeOrmPingCheckSettings = {}, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); this.checkDependantPackages(); const connection: TypeOrm.DataSource | null = @@ -118,39 +112,22 @@ export class TypeOrmHealthIndicator extends HealthIndicator { const timeout = options.timeout || 1000; if (!connection) { - throw new ConnectionNotFoundError( - this.getStatus(key, false, { - message: 'Connection provider not found in application context', - }), - ); + return check.down('Connection provider not found in application context'); } try { await this.pingDb(connection, timeout); } catch (err) { if (err instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, false, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); + return check.down(`timeout of ${timeout}ms exceeded`); } if (err instanceof MongoConnectionError) { - throw new HealthCheckError( - err.message, - this.getStatus(key, false, { - message: err.message, - }), - ); + return check.down(err.message); } - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, false), - ); + return check.down(); } - return this.getStatus(key, true); + return check.up(); } } diff --git a/lib/health-indicator/disk/disk.health.ts b/lib/health-indicator/disk/disk.health.ts index b08d0d19a..360df82e2 100644 --- a/lib/health-indicator/disk/disk.health.ts +++ b/lib/health-indicator/disk/disk.health.ts @@ -5,10 +5,10 @@ import { type DiskHealthIndicatorOptions, type DiskOptionsWithThresholdPercent, } from './disk-health-options.type'; -import { HealthIndicator, type HealthIndicatorResult } from '../'; -import { StorageExceededError } from '../../errors'; +import { type HealthIndicatorResult } from '../'; import { STORAGE_EXCEEDED } from '../../errors/messages.constant'; import { CHECK_DISK_SPACE_LIB } from '../../terminus.constants'; +import { HealthIndicatorService } from '../health-indicator.service'; type CheckDiskSpace = typeof checkDiskSpace; @@ -20,20 +20,12 @@ type CheckDiskSpace = typeof checkDiskSpace; * @module TerminusModule */ @Injectable() -export class DiskHealthIndicator extends HealthIndicator { - /** - * Initializes the health indicator - * - * @param {CheckDiskSpace} checkDiskSpace The check-disk-space library - * - * @internal - */ +export class DiskHealthIndicator { constructor( @Inject(CHECK_DISK_SPACE_LIB) - private checkDiskSpace: CheckDiskSpace, - ) { - super(); - } + private readonly checkDiskSpace: CheckDiskSpace, + private readonly healthIndicatorService: HealthIndicatorService, + ) {} /** * Checks if the given option has the property the `thresholdPercent` attribute @@ -70,13 +62,19 @@ export class DiskHealthIndicator extends HealthIndicator { * // The used disk storage should not exceed 50% of the full disk size * diskHealthIndicator.checkStorage('storage', { thresholdPercent: 0.5, path: 'C:\\' }); */ - public async checkStorage( - key: string, + public async checkStorage( + key: Key, options: DiskHealthIndicatorOptions, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); const { free, size } = await this.checkDiskSpace(options.path); const used = size - free; + // Prevent division by zero + if (isNaN(size) || size === 0) { + return check.down(STORAGE_EXCEEDED('disk storage')); + } + let isHealthy = false; if (this.isOptionThresholdPercent(options)) { isHealthy = options.thresholdPercent >= used / size; @@ -85,13 +83,8 @@ export class DiskHealthIndicator extends HealthIndicator { } if (!isHealthy) { - throw new StorageExceededError( - 'disk storage', - this.getStatus(key, false, { - message: STORAGE_EXCEEDED('disk storage'), - }), - ); + return check.down(STORAGE_EXCEEDED('disk storage')); } - return this.getStatus(key, true); + return check.up(); } } diff --git a/lib/health-indicator/health-indicator-result.interface.ts b/lib/health-indicator/health-indicator-result.interface.ts index 0b9f4a5ef..7eccc3fb6 100644 --- a/lib/health-indicator/health-indicator-result.interface.ts +++ b/lib/health-indicator/health-indicator-result.interface.ts @@ -7,18 +7,8 @@ export type HealthIndicatorStatus = 'up' | 'down'; * The result object of a health indicator * @publicApi */ -export type HealthIndicatorResult = { - /** - * The key of the health indicator which should be unique - */ - [key: string]: { - /** - * The status if the given health indicator was successful or not - */ - status: HealthIndicatorStatus; - /** - * Optional settings of the health indicator result - */ - [optionalKeys: string]: any; - }; -}; +export type HealthIndicatorResult< + Key extends string = string, + Status extends HealthIndicatorStatus = HealthIndicatorStatus, + OptionalData extends Record = Record, +> = Record; diff --git a/lib/health-indicator/health-indicator.service.ts b/lib/health-indicator/health-indicator.service.ts new file mode 100644 index 000000000..a29d67772 --- /dev/null +++ b/lib/health-indicator/health-indicator.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { type HealthIndicatorResult } from './health-indicator-result.interface'; + +/** + * Helper service which can be used to create health indicator results + * @publicApi + */ +@Injectable() +export class HealthIndicatorService { + check(key: Key) { + return new HealthIndicatorSession(key); + } +} + +type AdditionalData = Record; + +export class HealthIndicatorSession = string> { + constructor(private readonly key: Key) {} + + /** + * Mark the health indicator as down + * @param data additional data which will get appended to the result object + */ + down( + data?: T, + ): HealthIndicatorResult; + down( + data?: T, + ): HealthIndicatorResult; + down( + data?: T, + ): HealthIndicatorResult { + let additionalData: AdditionalData = {}; + + if (typeof data === 'string') { + additionalData = { message: data }; + } else if (typeof data === 'object') { + additionalData = data; + } + + const detail = { + status: 'down' as const, + ...additionalData, + }; + + return { + [this.key]: detail, + // TypeScript does not infer this.key as Key correctly. + } as Record; + } + + up(data?: T): HealthIndicatorResult; + up( + data?: T, + ): HealthIndicatorResult; + up( + data?: T, + ): HealthIndicatorResult { + let additionalData: AdditionalData = {}; + + if (typeof data === 'string') { + additionalData = { message: data }; + } else if (typeof data === 'object') { + additionalData = data; + } + + const detail = { + status: 'up' as const, + ...additionalData, + }; + + return { + [this.key]: detail, + // TypeScript does not infer this.key as Key correctly. + } as Record; + } +} diff --git a/lib/health-indicator/health-indicator.ts b/lib/health-indicator/health-indicator.ts index 6c8cb7227..0dd38654f 100644 --- a/lib/health-indicator/health-indicator.ts +++ b/lib/health-indicator/health-indicator.ts @@ -28,6 +28,9 @@ export type HealthIndicatorFunction = () => * ``` * * @publicApi + * @deprecated + * This class has been deprecated and will be removed in the next major release. + * Instead utilise the `HealthIndicatorService` to indicate the health of your health indicator. */ export abstract class HealthIndicator { /** diff --git a/lib/health-indicator/health-indicators.provider.ts b/lib/health-indicator/health-indicators.provider.ts index 866ceb797..ff25ffbd2 100644 --- a/lib/health-indicator/health-indicators.provider.ts +++ b/lib/health-indicator/health-indicators.provider.ts @@ -1,4 +1,3 @@ -import { type Type } from '@nestjs/common'; import { TypeOrmHealthIndicator, HttpHealthIndicator, @@ -7,7 +6,6 @@ import { DiskHealthIndicator, MemoryHealthIndicator, MicroserviceHealthIndicator, - type HealthIndicator, GRPCHealthIndicator, PrismaHealthIndicator, } from '.'; @@ -16,7 +14,7 @@ import { MikroOrmHealthIndicator } from './database/mikro-orm.health'; /** * All the health indicators terminus provides as array */ -export const HEALTH_INDICATORS: Type[] = [ +export const HEALTH_INDICATORS = [ TypeOrmHealthIndicator, HttpHealthIndicator, MongooseHealthIndicator, diff --git a/lib/health-indicator/http/http.health.spec.ts b/lib/health-indicator/http/http.health.spec.ts index c906ae2ce..98367e663 100644 --- a/lib/health-indicator/http/http.health.spec.ts +++ b/lib/health-indicator/http/http.health.spec.ts @@ -5,8 +5,8 @@ import { checkPackages } from '../../utils/checkPackage.util'; import { of } from 'rxjs'; import { TERMINUS_LOGGER } from '../../health-check/logger/logger.provider'; import { AxiosError } from 'axios'; -import { AxiosRequestConfig } from './axios.interfaces'; import { HealthCheckError } from 'lib/health-check'; +import { HealthIndicatorService } from '../health-indicator.service'; jest.mock('../../utils/checkPackage.util'); // == MOCKS == @@ -20,7 +20,6 @@ const nestJSAxiosMock = { describe('Http Response Health Indicator', () => { let httpHealthIndicator: HttpHealthIndicator; - let httpService: jest.Mocked; beforeEach(async () => { (checkPackages as jest.Mock).mockImplementation((): any => [ @@ -33,6 +32,7 @@ describe('Http Response Health Indicator', () => { imports: [HttpModule], providers: [ HttpHealthIndicator, + HealthIndicatorService, { provide: nestJSAxiosMock.HttpService as any, useValue: httpServiceMock, @@ -49,10 +49,6 @@ describe('Http Response Health Indicator', () => { httpHealthIndicator = await moduleRef.resolve(HttpHealthIndicator); - - httpService = (await moduleRef.resolve( - nestJSAxiosMock.HttpService as any, - )) as jest.Mocked; }); describe('#pingCheck', () => { diff --git a/lib/health-indicator/http/http.health.ts b/lib/health-indicator/http/http.health.ts index f909ba344..0f8f3445f 100644 --- a/lib/health-indicator/http/http.health.ts +++ b/lib/health-indicator/http/http.health.ts @@ -7,11 +7,14 @@ import { type AxiosRequestConfig, type AxiosResponse, } from './axios.interfaces'; -import { HealthIndicator, type HealthIndicatorResult } from '..'; +import { type HealthIndicatorResult } from '..'; import { type AxiosError } from '../../errors/axios.error'; -import { HealthCheckError } from '../../health-check/health-check.error'; import { TERMINUS_LOGGER } from '../../health-check/logger/logger.provider'; import { checkPackages, isAxiosError } from '../../utils'; +import { + HealthIndicatorService, + type HealthIndicatorSession, +} from '../health-indicator.service'; interface HttpClientLike { request(config: any): Observable>; @@ -27,15 +30,15 @@ interface HttpClientLike { @Injectable({ scope: Scope.TRANSIENT, }) -export class HttpHealthIndicator extends HealthIndicator { +export class HttpHealthIndicator { private nestJsAxios!: typeof NestJSAxios; constructor( private readonly moduleRef: ModuleRef, @Inject(TERMINUS_LOGGER) private readonly logger: ConsoleLogger, + private readonly healthIndicatorService: HealthIndicatorService, ) { - super(); if (this.logger instanceof ConsoleLogger) { this.logger.setContext(HttpHealthIndicator.name); } @@ -74,11 +77,10 @@ export class HttpHealthIndicator extends HealthIndicator { * * @throws {HealthCheckError} */ - private generateHttpError(key: string, error: AxiosError | any) { - if (!isAxiosError(error)) { - return; - } - + private generateHttpError( + check: HealthIndicatorSession, + error: AxiosError | any, + ) { const response: { [key: string]: any } = { message: error.message, }; @@ -88,10 +90,7 @@ export class HttpHealthIndicator extends HealthIndicator { response.statusText = error.response.statusText; } - throw new HealthCheckError( - error.message, - this.getStatus(key, false, response), - ); + return check.down(response); } /** @@ -106,15 +105,16 @@ export class HttpHealthIndicator extends HealthIndicator { * @example * httpHealthIndicator.pingCheck('google', 'https://google.com', { timeout: 800 }) */ - async pingCheck( - key: string, + async pingCheck( + key: Key, url: string, { httpClient, ...options }: AxiosRequestConfig & { httpClient?: HttpClientLike } = {}, - ): Promise { - let isHealthy = false; + ): Promise> { + const check = this.healthIndicatorService.check(key); + // In case the user has a preconfigured HttpService (see `HttpModule.register`) // we just let him/her pass in this HttpService so that he/she does not need to // reconfigure it. @@ -123,23 +123,27 @@ export class HttpHealthIndicator extends HealthIndicator { try { await lastValueFrom(httpService.request({ url, ...options })); - isHealthy = true; } catch (err) { - this.generateHttpError(key, err); + if (isAxiosError(err)) { + return this.generateHttpError(check, err); + } + + throw err; } - return this.getStatus(key, isHealthy); + return check.up(); } - async responseCheck( - key: string, + async responseCheck( + key: Key, url: URL | string, callback: (response: AxiosResponse) => boolean | Promise, { httpClient, ...options }: AxiosRequestConfig & { httpClient?: HttpClientLike } = {}, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); const httpService = httpClient || this.getHttpService(); let response: AxiosResponse; @@ -153,10 +157,14 @@ export class HttpHealthIndicator extends HealthIndicator { if (!isAxiosError(error)) { throw error; } + // We received an Axios Error but no response for unknown reasons. if (!error.response) { - throw this.generateHttpError(key, error); + return check.down(error.message); } + // We store the response no matter if the http request was successful or not. + // So that we can pass it to the callback function and the user can decide + // if the response is healthy or not. response = error.response; axiosError = error; } @@ -165,15 +173,12 @@ export class HttpHealthIndicator extends HealthIndicator { if (!isHealthy) { if (axiosError) { - throw this.generateHttpError(key, axiosError); + return this.generateHttpError(check, axiosError); } - throw new HealthCheckError( - `${key} is not available`, - this.getStatus(key, false), - ); + return check.down(); } - return this.getStatus(key, true); + return check.up(); } } diff --git a/lib/health-indicator/index.ts b/lib/health-indicator/index.ts index 5f10da858..f6b614e22 100644 --- a/lib/health-indicator/index.ts +++ b/lib/health-indicator/index.ts @@ -1,5 +1,6 @@ export * from './health-indicator-result.interface'; export * from './health-indicator'; +export { HealthIndicatorService } from './health-indicator.service'; /** Health Indicators */ export * from './http/http.health'; diff --git a/lib/health-indicator/memory/memory.health.ts b/lib/health-indicator/memory/memory.health.ts index f9b434af1..6e0498a21 100644 --- a/lib/health-indicator/memory/memory.health.ts +++ b/lib/health-indicator/memory/memory.health.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { HealthIndicator, type HealthIndicatorResult } from '../'; -import { StorageExceededError } from '../../errors'; +import { type HealthIndicatorResult } from '../'; import { STORAGE_EXCEEDED } from '../../errors/messages.constant'; +import { HealthIndicatorService } from '../health-indicator.service'; /** * The MemoryHealthIndicator contains checks which are related @@ -11,7 +11,11 @@ import { STORAGE_EXCEEDED } from '../../errors/messages.constant'; * @module TerminusModule */ @Injectable() -export class MemoryHealthIndicator extends HealthIndicator { +export class MemoryHealthIndicator { + constructor( + private readonly healthIndicatorService: HealthIndicatorService, + ) {} + /** * Checks the heap space and returns the status * @@ -27,22 +31,18 @@ export class MemoryHealthIndicator extends HealthIndicator { * // The process should not use more than 150MB memory * memoryHealthIndicator.checkHeap('memory_heap', 150 * 1024 * 1024); */ - public async checkHeap( - key: string, + public async checkHeap( + key: Key, heapUsedThreshold: number, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); const { heapUsed } = process.memoryUsage(); if (heapUsedThreshold < heapUsed) { - throw new StorageExceededError( - 'heap', - this.getStatus(key, false, { - message: STORAGE_EXCEEDED('heap'), - }), - ); + return check.down(STORAGE_EXCEEDED('heap')); } - return this.getStatus(key, true); + return check.up(); } /** @@ -59,21 +59,17 @@ export class MemoryHealthIndicator extends HealthIndicator { * // The process should not have more than 150MB allocated * memoryHealthIndicator.checkRSS('memory_rss', 150 * 1024 * 1024); */ - public async checkRSS( - key: string, + public async checkRSS( + key: Key, rssThreshold: number, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); const { rss } = process.memoryUsage(); if (rssThreshold < rss) { - throw new StorageExceededError( - 'rss', - this.getStatus(key, false, { - message: STORAGE_EXCEEDED('rss'), - }), - ); + return check.down(STORAGE_EXCEEDED('rss')); } - return this.getStatus(key, true); + return check.up(); } } diff --git a/lib/health-indicator/microservice/grpc.health.spec.ts b/lib/health-indicator/microservice/grpc.health.spec.ts index b83bc3356..da6c91167 100644 --- a/lib/health-indicator/microservice/grpc.health.spec.ts +++ b/lib/health-indicator/microservice/grpc.health.spec.ts @@ -1,8 +1,11 @@ import { GRPCHealthIndicator } from './grpc.health'; import { checkPackages } from '../../utils/checkPackage.util'; import { GrpcOptions, Transport } from '@nestjs/microservices'; -import { UnhealthyResponseCodeError, TimeoutError } from '../../errors'; +import { TimeoutError } from '../../errors'; import { HealthCheckError } from '../../health-check/health-check.error'; +import { Test } from '@nestjs/testing'; +import { HealthIndicatorService } from '../health-indicator.service'; + jest.mock('../../utils/checkPackage.util'); // == MOCKS == @@ -24,14 +27,17 @@ const nestJSMicroservicesMock = { ClientProxyFactory: clientProxyFactoryMock, }; -let grpc: GRPCHealthIndicator; - describe('GRPCHealthIndicator', () => { + let grpc: GRPCHealthIndicator; beforeEach(async () => { (checkPackages as jest.Mock).mockImplementation((): any => [ nestJSMicroservicesMock, ]); - grpc = new GRPCHealthIndicator(); + + const moduleRef = await Test.createTestingModule({ + providers: [GRPCHealthIndicator, HealthIndicatorService], + }).compile(); + grpc = await moduleRef.resolve(GRPCHealthIndicator); }); afterEach(async () => { @@ -39,6 +45,7 @@ describe('GRPCHealthIndicator', () => { grpcClientMock.getService.mockClear(); healthServiceMock.check.mockClear(); }); + describe('checkService', () => { it('should return a healthy result', async () => { const result = await grpc.checkService('grpc', 'test'); @@ -54,6 +61,7 @@ describe('GRPCHealthIndicator', () => { transport: Transport.GRPC, }); }); + it('should correctly all the ClientProxyFactory with custom options', async () => { await grpc.checkService('grpc', 'test', { protoPath: 'test.proto', @@ -65,16 +73,16 @@ describe('GRPCHealthIndicator', () => { transport: Transport.GRPC, }); }); + it('should throw an error in case the health service returns a faulty response code', async () => { healthServiceMock.check.mockImplementationOnce((): any => ({ toPromise: (): any => Promise.resolve({ status: 0 }), })); try { await grpc.checkService('grpc', 'test'); - } catch (err) { - expect(err instanceof UnhealthyResponseCodeError).toBeTruthy(); - } + } catch (err) {} }); + it('should throw an error when the timeout runs out', async () => { try { await grpc.checkService('grpc', 'test', { timeout: 0 }); @@ -82,6 +90,7 @@ describe('GRPCHealthIndicator', () => { expect(err instanceof TimeoutError).toBeTruthy(); } }); + it('should use the custom healthServiceCheck function', async () => { const healthServiceCheck = jest .fn() @@ -93,12 +102,14 @@ describe('GRPCHealthIndicator', () => { expect(healthServiceCheck.mock.calls.length).toBe(1); }); + it('should use the custom healthServiceName', async () => { await grpc.checkService('grpc', 'test', { healthServiceName: 'health2', }); expect(grpcClientMock.getService.mock.calls[0][0]).toBe('health2'); }); + it('should throw TypeError further in client.getService', async () => { const error = new TypeError('test'); grpcClientMock.getService.mockImplementationOnce((): any => { @@ -110,17 +121,20 @@ describe('GRPCHealthIndicator', () => { expect(err).toEqual(error); } }); + it('should throw HealthCheckError in client.getService', async () => { const error = new Error('test'); grpcClientMock.getService.mockImplementationOnce((): any => { throw error; }); + try { await grpc.checkService('grpc', 'test'); } catch (err) { expect(err instanceof HealthCheckError).toBeTruthy(); } }); + it('should throw HealthCheckError if the grpc check function fails', async () => { try { await grpc.checkService('grpc', 'test', { diff --git a/lib/health-indicator/microservice/grpc.health.ts b/lib/health-indicator/microservice/grpc.health.ts index 8d85f4a90..54bfb7192 100644 --- a/lib/health-indicator/microservice/grpc.health.ts +++ b/lib/health-indicator/microservice/grpc.health.ts @@ -2,12 +2,7 @@ import { join } from 'path'; import { Injectable, Scope } from '@nestjs/common'; import type * as NestJSMicroservices from '@nestjs/microservices'; import { type Observable } from 'rxjs'; -import { - type HealthIndicatorResult, - TimeoutError, - UnhealthyResponseCodeError, -} from '../..'; -import { HealthCheckError } from '../../health-check/health-check.error'; +import { type HealthIndicatorResult } from '../..'; import { checkPackages, isError, @@ -15,7 +10,7 @@ import { type PropType, TimeoutError as PromiseTimeoutError, } from '../../utils'; -import { HealthIndicator } from '../health-indicator'; +import { HealthIndicatorService } from '../health-indicator.service'; /** * The status of the request service @@ -94,14 +89,13 @@ export type CheckGRPCServiceOptions< * @module TerminusModule */ @Injectable({ scope: Scope.TRANSIENT }) -export class GRPCHealthIndicator extends HealthIndicator { +export class GRPCHealthIndicator { private nestJsMicroservices!: typeof NestJSMicroservices; /** * Initializes the health indicator */ - constructor() { - super(); + constructor(private readonly healthIndicatorService: HealthIndicatorService) { this.checkDependantPackages(); } @@ -142,6 +136,23 @@ export class GRPCHealthIndicator extends HealthIndicator { }); } + getHealthService( + service: string, + settings: CheckGRPCServiceOptions, + ) { + if (this.openChannels.has(service)) { + return this.openChannels.get(service)!; + } + + const client = this.createClient(settings); + const healthService = client.getService( + settings.healthServiceName as string, + ); + + this.openChannels.set(service, healthService); + return healthService; + } + /** * Checks if the given service is up using the standard health check * specification of GRPC. @@ -180,11 +191,14 @@ export class GRPCHealthIndicator extends HealthIndicator { */ async checkService< GrpcOptions extends GrpcClientOptionsLike = GrpcClientOptionsLike, + Key extends string = string, >( - key: string, + key: Key, service: string, options: CheckGRPCServiceOptions = {}, - ): Promise { + ): Promise> { + const check = this.healthIndicatorService.check(key); + const defaultOptions: CheckGRPCServiceOptions = { package: 'grpc.health.v1', protoPath: join(__dirname, './protos/health.proto'), @@ -199,31 +213,19 @@ export class GRPCHealthIndicator extends HealthIndicator { let healthService: GRPCHealthService; try { - if (this.openChannels.has(service)) { - healthService = this.openChannels.get(service)!; - } else { - const client = this.createClient(settings); - - healthService = client.getService( - settings.healthServiceName as string, - ); - - this.openChannels.set(service, healthService); - } + healthService = this.getHealthService(service, settings); } catch (err) { if (err instanceof TypeError) { throw err; } if (isError(err)) { - throw new HealthCheckError( - err.message, - this.getStatus(key, false, { message: err.message }), - ); + return check.down(err.message); } - throw new HealthCheckError( - err as any, - this.getStatus(key, false, { message: err as any }), - ); + if (typeof err === 'string') { + return check.down(err); + } + + return check.down(); } let response: HealthCheckResponse; @@ -238,39 +240,30 @@ export class GRPCHealthIndicator extends HealthIndicator { ); } catch (err) { if (err instanceof PromiseTimeoutError) { - throw new TimeoutError( - settings.timeout as number, - this.getStatus(key, false, { - message: `timeout of ${settings.timeout}ms exceeded`, - }), - ); + return check.down(`timeout of ${settings.timeout}ms exceeded`); } if (isError(err)) { - throw new HealthCheckError( - err.message, - this.getStatus(key, false, { message: err.message }), - ); + return check.down(err.message); } - throw new HealthCheckError( - err as any, - this.getStatus(key, false, { message: err as any }), - ); + if (typeof err === 'string') { + return check.down(err); + } + + return check.down(); } const isHealthy = response.status === ServingStatus.SERVING; - const status = this.getStatus(key, isHealthy, { - statusCode: response.status, - servingStatus: ServingStatus[response.status], - }); - if (!isHealthy) { - throw new UnhealthyResponseCodeError( - `${response.status}, ${ServingStatus[response.status]}`, - status, - ); + return check.down({ + statusCode: response.status, + servingStatus: ServingStatus[response.status], + }); } - return status; + return check.up({ + statusCode: response.status, + servingStatus: ServingStatus[response.status], + }); } } diff --git a/lib/health-indicator/microservice/microservice.health.ts b/lib/health-indicator/microservice/microservice.health.ts index b4eb55271..215c4aac7 100644 --- a/lib/health-indicator/microservice/microservice.health.ts +++ b/lib/health-indicator/microservice/microservice.health.ts @@ -1,8 +1,6 @@ import { Injectable, Scope } from '@nestjs/common'; import type * as NestJSMicroservices from '@nestjs/microservices'; -import { HealthIndicator, type HealthIndicatorResult } from '../'; -import { TimeoutError } from '../../errors'; -import { HealthCheckError } from '../../health-check/health-check.error'; +import { type HealthIndicatorResult } from '../'; import { checkPackages, promiseTimeout, @@ -10,6 +8,7 @@ import { type PropType, isError, } from '../../utils'; +import { HealthIndicatorService } from '../health-indicator.service'; // Since @nestjs/microservices is lazily loaded we are not able to use // its types. It would end up in the d.ts file if we would use the types. @@ -43,13 +42,10 @@ export type MicroserviceHealthIndicatorOptions< * @module TerminusModule */ @Injectable({ scope: Scope.TRANSIENT }) -export class MicroserviceHealthIndicator extends HealthIndicator { +export class MicroserviceHealthIndicator { private nestJsMicroservices!: typeof NestJSMicroservices; - /** - * Initializes the health indicator - */ - constructor() { - super(); + + constructor(private readonly healthIndicatorService: HealthIndicatorService) { this.checkDependantPackages(); } @@ -76,34 +72,6 @@ export class MicroserviceHealthIndicator extends HealthIndicator { return await checkConnection(); } - /** - * Prepares and throw a HealthCheckError - * @param key The key which will be used for the result object - * @param error The thrown error - * @param timeout The timeout in ms - * - * @throws {HealthCheckError} - */ - private generateError(key: string, error: Error, timeout: number) { - if (!error) { - return; - } - if (error instanceof PromiseTimeoutError) { - throw new TimeoutError( - timeout, - this.getStatus(key, false, { - message: `timeout of ${timeout}ms exceeded`, - }), - ); - } - throw new HealthCheckError( - error.message, - this.getStatus(key, false, { - message: error.message, - }), - ); - } - /** * Checks if the given microservice is up * @param key The key which will be used for the result object @@ -117,11 +85,14 @@ export class MicroserviceHealthIndicator extends HealthIndicator { * options: { host: 'localhost', port: 3001 }, * }) */ - async pingCheck( - key: string, + async pingCheck< + MicroserviceClientOptions extends MicroserviceOptionsLike, + Key extends string = string, + >( + key: Key, options: MicroserviceHealthIndicatorOptions, - ): Promise { - let isHealthy = false; + ): Promise> { + const check = this.healthIndicatorService.check(key); const timeout = options.timeout || 1000; if (options.transport === this.nestJsMicroservices.Transport.KAFKA) { @@ -135,20 +106,17 @@ export class MicroserviceHealthIndicator extends HealthIndicator { try { await promiseTimeout(timeout, this.pingMicroservice(options)); - isHealthy = true; } catch (err) { + if (err instanceof PromiseTimeoutError) { + return check.down(`timeout of ${timeout}ms exceeded`); + } if (isError(err)) { - this.generateError(key, err, timeout); + return check.down(err.message); } - const errorMsg = `${key} is not available`; - - throw new HealthCheckError( - errorMsg, - this.getStatus(key, false, { message: errorMsg }), - ); + return check.down(`${key} is not available`); } - return this.getStatus(key, isHealthy); + return check.up(); } } diff --git a/lib/index.ts b/lib/index.ts index 1855f7b88..bead16219 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -5,6 +5,7 @@ export * from './errors'; export { HealthCheck, HealthCheckService, + // eslint-disable-next-line deprecation/deprecation HealthCheckError, HealthCheckStatus, HealthCheckResult, diff --git a/lib/terminus.module.ts b/lib/terminus.module.ts index 5cc1ebb66..1cdec9a24 100644 --- a/lib/terminus.module.ts +++ b/lib/terminus.module.ts @@ -9,18 +9,24 @@ import { ERROR_LOGGERS } from './health-check/error-logger/error-loggers.provide import { HealthCheckExecutor } from './health-check/health-check-executor.service'; import { getLoggerProvider } from './health-check/logger/logger.provider'; import { DiskUsageLibProvider } from './health-indicator/disk/disk-usage-lib.provider'; +import { HealthIndicatorService } from './health-indicator/health-indicator.service'; import { HEALTH_INDICATORS } from './health-indicator/health-indicators.provider'; import { type TerminusModuleOptions } from './terminus-options.interface'; const baseProviders: Provider[] = [ ...ERROR_LOGGERS, + HealthIndicatorService, DiskUsageLibProvider, HealthCheckExecutor, HealthCheckService, ...HEALTH_INDICATORS, ]; -const exports_ = [HealthCheckService, ...HEALTH_INDICATORS]; +const exports_ = [ + HealthIndicatorService, + HealthCheckService, + ...HEALTH_INDICATORS, +]; /** * The Terminus module integrates health checks diff --git a/lib/utils/is-error.ts b/lib/utils/is-error.ts index fa9a8197b..2886cdd34 100644 --- a/lib/utils/is-error.ts +++ b/lib/utils/is-error.ts @@ -1,6 +1,7 @@ import { type HealthCheckError } from '../'; import { type AxiosError } from '../errors/axios.error'; +// eslint-disable-next-line deprecation/deprecation export function isHealthCheckError(err: any): err is HealthCheckError { return err?.isHealthCheckError; } diff --git a/package.json b/package.json index 02098dac1..661025e3c 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test:ut": "jest --detectOpenHandles", "test:ut:cov": "npm run test:ut -- --coverage", "test:import": "ts-node tools/import-check.ts", + "test:samples": "gulp test:samples && gulp test:e2e:samples", "release": "release-it", "prepare": "husky install" }, diff --git a/sample/000-dogs-app/src/dog/dog.health.spec.ts b/sample/000-dogs-app/src/dog/dog.health.spec.ts new file mode 100644 index 000000000..0b6a51116 --- /dev/null +++ b/sample/000-dogs-app/src/dog/dog.health.spec.ts @@ -0,0 +1,72 @@ +import { Test } from '@nestjs/testing'; +import { DogHealthIndicator } from './dog.health'; +import { DogService } from './dog.service'; +import { HealthIndicatorService } from '@nestjs/terminus'; +import { DogState } from './interfaces/dog.interface'; + +/////////////////////////////////////////////////////////// + +const dogServiceMock = { + getDogs: jest.fn(), +}; + +const healthIndicatorSessionMock = { + up: jest.fn(), + down: jest.fn(), +}; + +const healthIndicatorServiceMock = { + check: jest.fn().mockImplementation(() => healthIndicatorSessionMock), +}; + +/////////////////////////////////////////////////////////// + +describe('DogHealthIndicator', () => { + let dogHealthIndicator: DogHealthIndicator; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + DogHealthIndicator, + { + provide: DogService, + useValue: dogServiceMock, + }, + { + provide: HealthIndicatorService, + useValue: healthIndicatorServiceMock, + }, + ], + }).compile(); + + dogHealthIndicator = await moduleRef.resolve(DogHealthIndicator); + }); + + it('marks the indicator as down if there are badboys', async () => { + // Arrange + dogServiceMock.getDogs.mockResolvedValue([ + { name: 'Felix', state: DogState.BAD_BOY }, + ]); + + // Act + await dogHealthIndicator.isHealthy('dog'); + + // Assert + expect(healthIndicatorSessionMock.down).toHaveBeenCalledWith({ + badboys: 1, + }); + }); + + it('marks the indicator as up if there are no badboys', async () => { + // Arrange + dogServiceMock.getDogs.mockResolvedValue([ + { name: 'Felix', state: DogState.GOOD_BOY }, + ]); + + // Act + await dogHealthIndicator.isHealthy('dog'); + + // Assert + expect(healthIndicatorSessionMock.up).toHaveBeenCalled(); + }); +}); diff --git a/sample/000-dogs-app/src/dog/dog.health.ts b/sample/000-dogs-app/src/dog/dog.health.ts index a7d414f25..c476a8fe3 100644 --- a/sample/000-dogs-app/src/dog/dog.health.ts +++ b/sample/000-dogs-app/src/dog/dog.health.ts @@ -1,28 +1,28 @@ import { Injectable } from '@nestjs/common'; import { DogService } from './dog.service'; import { DogState } from './interfaces/dog.interface'; -import { - HealthIndicatorResult, - HealthIndicator, - HealthCheckError, -} from '@nestjs/terminus'; +import { HealthIndicatorService } from '@nestjs/terminus'; @Injectable() -export class DogHealthIndicator extends HealthIndicator { - constructor(private readonly dogService: DogService) { - super(); - } +export class DogHealthIndicator { + constructor( + private readonly dogService: DogService, + private readonly healthIndicatorService: HealthIndicatorService, + ) {} + + async isHealthy(key: TKey) { + const indicator = this.healthIndicatorService.check(key); - async isHealthy(key: string): Promise { const dogs = await this.dogService.getDogs(); const badboys = dogs.filter((dog) => dog.state === DogState.BAD_BOY); const isHealthy = badboys.length === 0; - const result = this.getStatus(key, isHealthy, { badboys: badboys.length }); - - if (isHealthy) { - return result; + if (!isHealthy) { + return indicator.down({ + badboys: badboys.length, + }); } - throw new HealthCheckError('Dog check failed', result); + + return indicator.up(); } } diff --git a/sample/000-dogs-app/src/dog/dog.module.ts b/sample/000-dogs-app/src/dog/dog.module.ts index fc581e5e3..ec624dedc 100644 --- a/sample/000-dogs-app/src/dog/dog.module.ts +++ b/sample/000-dogs-app/src/dog/dog.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { DogService } from './dog.service'; import { DogHealthIndicator } from './dog.health'; +import { TerminusModule } from '@nestjs/terminus'; @Module({ + imports: [TerminusModule], providers: [DogService, DogHealthIndicator], exports: [DogHealthIndicator], }) diff --git a/sample/000-dogs-app/test/health.e2e-spec.ts b/sample/000-dogs-app/test/health.e2e-spec.ts index 315e990d6..0987586c5 100644 --- a/sample/000-dogs-app/test/health.e2e-spec.ts +++ b/sample/000-dogs-app/test/health.e2e-spec.ts @@ -21,9 +21,9 @@ describe('HealthModule (e2e)', () => { .expect(200) .expect({ status: 'ok', - info: { dog: { status: 'up', badboys: 0 } }, + info: { dog: { status: 'up' } }, error: {}, - details: { dog: { status: 'up', badboys: 0 } }, + details: { dog: { status: 'up' } }, }); }); }); diff --git a/sample/011-mirkoorm-app/package.json b/sample/011-mirkoorm-app/package.json index 9e9bd4cf3..2eefc8850 100644 --- a/sample/011-mirkoorm-app/package.json +++ b/sample/011-mirkoorm-app/package.json @@ -15,7 +15,7 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "echo 'No e2e tests implemented yet.'" }, "dependencies": { "@mikro-orm/core": "5.9.7", diff --git a/sample/012-prisma-app/package.json b/sample/012-prisma-app/package.json index 0dc9711d5..904935e18 100644 --- a/sample/012-prisma-app/package.json +++ b/sample/012-prisma-app/package.json @@ -15,7 +15,7 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "echo 'No e2e tests implemented yet.'" }, "dependencies": { "@nestjs/common": "10.3.1",