diff --git a/lib/health-check/health-check-executor.service.ts b/lib/health-check/health-check-executor.service.ts index 6f25c597a..348ba7f0f 100644 --- a/lib/health-check/health-check-executor.service.ts +++ b/lib/health-check/health-check-executor.service.ts @@ -5,6 +5,7 @@ import { } from './health-check-result.interface'; import { type HealthCheckError } from '../health-check/health-check.error'; import { + type InferHealthIndicatorResults, type HealthIndicatorFunction, type HealthIndicatorResult, } from '../health-indicator'; @@ -37,13 +38,15 @@ export class HealthCheckExecutor implements BeforeApplicationShutdown { * @returns the result of given health indicators * @param healthIndicators The health indicators which should get executed */ - async execute( - healthIndicators: HealthIndicatorFunction[], - ): Promise { + async execute( + healthIndicators: TFns, + ) { const { results, errors } = await this.executeHealthIndicators(healthIndicators); - return this.getResult(results, errors); + return this.getResult(results, errors) as HealthCheckResult< + InferHealthIndicatorResults + >; } /** diff --git a/lib/health-check/health-check-result.interface.ts b/lib/health-check/health-check-result.interface.ts index 30b1557d6..d5fc51b60 100644 --- a/lib/health-check/health-check-result.interface.ts +++ b/lib/health-check/health-check-result.interface.ts @@ -9,7 +9,15 @@ export type HealthCheckStatus = 'error' | 'ok' | 'shutting_down'; * The result of a health check * @publicApi */ -export interface HealthCheckResult { +export type HealthCheckResult< + TDetails extends HealthIndicatorResult = HealthIndicatorResult, + TInfo extends Partial | undefined = + | Partial + | undefined, + TError extends Partial | undefined = + | Partial + | undefined, +> = { /** * The overall status of the Health Check */ @@ -18,14 +26,14 @@ export interface HealthCheckResult { * The info object contains information of each health indicator * which is of status "up" */ - info?: HealthIndicatorResult; + info?: TInfo; /** * The error object contains information of each health indicator * which is of status "down" */ - error?: HealthIndicatorResult; + error?: TError; /** * The details object contains information of every health indicator. */ - details: HealthIndicatorResult; -} + details: TDetails; +}; diff --git a/lib/health-check/health-check.service.ts b/lib/health-check/health-check.service.ts index 1a7bc940e..d3e221333 100644 --- a/lib/health-check/health-check.service.ts +++ b/lib/health-check/health-check.service.ts @@ -4,11 +4,11 @@ import { Inject, ConsoleLogger, LoggerService, + InternalServerErrorException, } from '@nestjs/common'; import { ErrorLogger } from './error-logger/error-logger.interface'; import { ERROR_LOGGER } from './error-logger/error-logger.provider'; import { HealthCheckExecutor } from './health-check-executor.service'; -import { type HealthCheckResult } from './health-check-result.interface'; import { type HealthIndicatorFunction } from '../health-indicator'; import { TERMINUS_LOGGER } from './logger/logger.provider'; @@ -43,22 +43,31 @@ export class HealthCheckService { * ``` * @param healthIndicators The health indicators which should be checked */ - async check( - healthIndicators: HealthIndicatorFunction[], - ): Promise { + async check( + healthIndicators: TFns, + ) { const result = await this.healthCheckExecutor.execute(healthIndicators); - if (result.status === 'ok') { - return result; - } - if (result.status === 'error') { - const msg = this.errorLogger.getErrorMessage( - 'Health Check has failed!', - result.details, - ); - this.logger.error(msg); - } + switch (result.status) { + case 'ok': + return result; - throw new ServiceUnavailableException(result); + case 'error': + const msg = this.errorLogger.getErrorMessage( + 'Health Check has failed!', + result.details, + ); + this.logger.error(msg); + throw new ServiceUnavailableException(result); + + case 'shutting_down': + throw new ServiceUnavailableException(result); + + default: + // Ensure that we have exhaustively checked all cases + // eslint-disable-next-line unused-imports/no-unused-vars + const exhaustiveCheck: never = result.status; + throw new InternalServerErrorException(); + } } } diff --git a/lib/health-indicator/health-indicator-result.interface.ts b/lib/health-indicator/health-indicator-result.interface.ts index 7eccc3fb6..ae2d47f3d 100644 --- a/lib/health-indicator/health-indicator-result.interface.ts +++ b/lib/health-indicator/health-indicator-result.interface.ts @@ -1,3 +1,5 @@ +import { type HealthIndicatorFunction } from './health-indicator'; + /** * @publicApi */ @@ -12,3 +14,24 @@ export type HealthIndicatorResult< Status extends HealthIndicatorStatus = HealthIndicatorStatus, OptionalData extends Record = Record, > = Record; + +/** + * @internal + */ +export type InferHealthIndicatorResult< + Fn extends HealthIndicatorFunction = HealthIndicatorFunction, +> = Awaited>; + +/** + * @internal + */ +export type InferHealthIndicatorResults< + Fns extends readonly HealthIndicatorFunction[] = HealthIndicatorFunction[], + R extends readonly any[] = [], +> = Fns extends readonly [ + infer Fn extends HealthIndicatorFunction, + ...infer Rest extends readonly HealthIndicatorFunction[], +] + ? InferHealthIndicatorResults & + InferHealthIndicatorResult + : HealthIndicatorResult; diff --git a/lib/health-indicator/health-indicator.service.ts b/lib/health-indicator/health-indicator.service.ts index 97cc98d91..ac4669287 100644 --- a/lib/health-indicator/health-indicator.service.ts +++ b/lib/health-indicator/health-indicator.service.ts @@ -28,13 +28,13 @@ export class HealthIndicatorSession = string> { */ down( data?: T, - ): HealthIndicatorResult; + ): HealthIndicatorResult; down( data?: T, - ): HealthIndicatorResult; + ): HealthIndicatorResult; down( data?: T, - ): HealthIndicatorResult { + ): HealthIndicatorResult { let additionalData: AdditionalData = {}; if (typeof data === 'string') { diff --git a/lib/index.ts b/lib/index.ts index bead16219..451970e4f 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,5 +1,9 @@ export { TerminusModule } from './terminus.module'; -export { TerminusModuleOptions } from './terminus-options.interface'; +export { + TerminusModuleOptions, + TerminusModuleAsyncOptions, + TerminusModuleOptionsFactory, +} from './terminus-options.interface'; export * from './health-indicator'; export * from './errors'; export { diff --git a/lib/terminus-options.interface.ts b/lib/terminus-options.interface.ts index 06339328e..edbb97c08 100644 --- a/lib/terminus-options.interface.ts +++ b/lib/terminus-options.interface.ts @@ -1,4 +1,8 @@ -import { type LoggerService, type Type } from '@nestjs/common'; +import { + type LoggerService, + type ModuleMetadata, + type Type, +} from '@nestjs/common'; export type ErrorLogStyle = 'pretty' | 'json'; @@ -26,3 +30,39 @@ export interface TerminusModuleOptions { */ gracefulShutdownTimeoutMs?: number; } + +/** + * Options factory interface for creating TerminusModuleOptions + * @publicApi + */ +export interface TerminusModuleOptionsFactory { + createTerminusOptions(): + | Promise + | TerminusModuleOptions; +} + +/** + * Async options for TerminusModule + * @publicApi + */ +export interface TerminusModuleAsyncOptions + extends Pick { + /** + * Factory function that returns TerminusModuleOptions + */ + useFactory?: ( + ...args: any[] + ) => Promise | TerminusModuleOptions; + /** + * Dependencies to inject into the factory function + */ + inject?: any[]; + /** + * Class to use as options factory + */ + useClass?: Type; + /** + * Existing instance to use as options factory + */ + useExisting?: Type; +} diff --git a/lib/terminus.constants.ts b/lib/terminus.constants.ts index 898210600..1799ae03b 100644 --- a/lib/terminus.constants.ts +++ b/lib/terminus.constants.ts @@ -3,3 +3,9 @@ * @internal */ export const CHECK_DISK_SPACE_LIB = 'CheckDiskSpaceLib'; + +/** + * The inject token for the TerminusModuleOptions + * @internal + */ +export const TERMINUS_MODULE_OPTIONS = 'TERMINUS_MODULE_OPTIONS'; diff --git a/lib/terminus.module.spec.ts b/lib/terminus.module.spec.ts new file mode 100644 index 000000000..09d7211d4 --- /dev/null +++ b/lib/terminus.module.spec.ts @@ -0,0 +1,226 @@ +import { Test } from '@nestjs/testing'; +import { Injectable, Module } from '@nestjs/common'; +import { TerminusModule } from './terminus.module'; +import { HealthCheckService } from './health-check'; +import { HealthIndicatorService } from './health-indicator/health-indicator.service'; +import { + type TerminusModuleOptions, + type TerminusModuleOptionsFactory, +} from './terminus-options.interface'; +import { TERMINUS_MODULE_OPTIONS } from './terminus.constants'; +import { TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT } from './graceful-shutdown-timeout/graceful-shutdown-timeout.service'; + +@Injectable() +class MockConfigService { + get(key: string, defaultValue?: any) { + const config: Record = { + TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT: 16000, + TERMINUS_ERROR_LOG_STYLE: 'pretty', + TERMINUS_LOGGER_ENABLED: true, + }; + return config[key] ?? defaultValue; + } +} + +@Injectable() +class OptionsFactory implements TerminusModuleOptionsFactory { + constructor(private configService: MockConfigService) {} + + createTerminusOptions(): TerminusModuleOptions { + return { + gracefulShutdownTimeoutMs: this.configService.get( + 'TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT', + 0, + ), + errorLogStyle: this.configService.get('TERMINUS_ERROR_LOG_STYLE', 'json'), + logger: this.configService.get('TERMINUS_LOGGER_ENABLED', true), + }; + } +} + +describe('TerminusModule', () => { + describe('forRoot', () => { + it('should create module with default options', async () => { + const module = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + }).compile(); + + const healthCheckService = module.get(HealthCheckService); + const healthIndicatorService = module.get(HealthIndicatorService); + + expect(healthCheckService).toBeDefined(); + expect(healthIndicatorService).toBeDefined(); + }); + + it('should create module with custom options', async () => { + const module = await Test.createTestingModule({ + imports: [ + TerminusModule.forRoot({ + gracefulShutdownTimeoutMs: 5000, + errorLogStyle: 'pretty', + logger: true, + }), + ], + }).compile(); + + const healthCheckService = module.get(HealthCheckService); + const gracefulShutdownTimeout = module.get( + TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT, + ); + + expect(healthCheckService).toBeDefined(); + expect(gracefulShutdownTimeout).toBe(5000); + }); + }); + + describe('forRootAsync', () => { + it('should create module with useFactory', async () => { + @Module({ + providers: [MockConfigService], + exports: [MockConfigService], + }) + class ConfigModule {} + + const module = await Test.createTestingModule({ + imports: [ + TerminusModule.forRootAsync({ + imports: [ConfigModule], + inject: [MockConfigService], + useFactory: (configService: MockConfigService) => ({ + gracefulShutdownTimeoutMs: configService.get( + 'TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT', + 16000, + ), + errorLogStyle: configService.get( + 'TERMINUS_ERROR_LOG_STYLE', + 'json', + ), + logger: configService.get('TERMINUS_LOGGER_ENABLED', true), + }), + }), + ], + }).compile(); + + const healthCheckService = module.get(HealthCheckService); + const healthIndicatorService = module.get(HealthIndicatorService); + const moduleOptions = module.get(TERMINUS_MODULE_OPTIONS); + const gracefulShutdownTimeout = module.get( + TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT, + ); + + expect(healthCheckService).toBeDefined(); + expect(healthIndicatorService).toBeDefined(); + expect(moduleOptions).toEqual({ + gracefulShutdownTimeoutMs: 16000, + errorLogStyle: 'pretty', + logger: true, + }); + expect(gracefulShutdownTimeout).toBe(16000); + }); + + it('should create module with useClass', async () => { + @Module({ + providers: [MockConfigService], + exports: [MockConfigService], + }) + class ConfigModule {} + + const module = await Test.createTestingModule({ + imports: [ + TerminusModule.forRootAsync({ + imports: [ConfigModule], + useClass: OptionsFactory, + }), + ], + }).compile(); + + const healthCheckService = module.get(HealthCheckService); + const healthIndicatorService = module.get(HealthIndicatorService); + const moduleOptions = module.get(TERMINUS_MODULE_OPTIONS); + + expect(healthCheckService).toBeDefined(); + expect(healthIndicatorService).toBeDefined(); + expect(moduleOptions).toEqual({ + gracefulShutdownTimeoutMs: 16000, + errorLogStyle: 'pretty', + logger: true, + }); + }); + + it('should create module with useExisting', async () => { + @Module({ + providers: [MockConfigService, OptionsFactory], + exports: [MockConfigService, OptionsFactory], + }) + class ConfigModule {} + + const module = await Test.createTestingModule({ + imports: [ + TerminusModule.forRootAsync({ + imports: [ConfigModule], + useExisting: OptionsFactory, + }), + ], + }).compile(); + + const healthCheckService = module.get(HealthCheckService); + const healthIndicatorService = module.get(HealthIndicatorService); + const moduleOptions = module.get(TERMINUS_MODULE_OPTIONS); + + expect(healthCheckService).toBeDefined(); + expect(healthIndicatorService).toBeDefined(); + expect(moduleOptions).toEqual({ + gracefulShutdownTimeoutMs: 16000, + errorLogStyle: 'pretty', + logger: true, + }); + }); + + it('should create module with async factory returning promise', async () => { + @Module({ + providers: [MockConfigService], + exports: [MockConfigService], + }) + class ConfigModule {} + + const module = await Test.createTestingModule({ + imports: [ + TerminusModule.forRootAsync({ + imports: [ConfigModule], + inject: [MockConfigService], + useFactory: async (configService: MockConfigService) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + gracefulShutdownTimeoutMs: configService.get( + 'TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT', + 16000, + ), + errorLogStyle: configService.get( + 'TERMINUS_ERROR_LOG_STYLE', + 'json', + ), + logger: configService.get('TERMINUS_LOGGER_ENABLED', true), + }; + }, + }), + ], + }).compile(); + + const healthCheckService = module.get(HealthCheckService); + const moduleOptions = module.get(TERMINUS_MODULE_OPTIONS); + + expect(healthCheckService).toBeDefined(); + expect(moduleOptions).toEqual({ + gracefulShutdownTimeoutMs: 16000, + errorLogStyle: 'pretty', + logger: true, + }); + }); + + it('should throw error when no configuration method is provided', () => { + expect(() => { + TerminusModule.forRootAsync({} as any); + }).toThrow('Invalid TerminusModule async options'); + }); + }); +}); diff --git a/lib/terminus.module.ts b/lib/terminus.module.ts index 1cdec9a24..82ee777f0 100644 --- a/lib/terminus.module.ts +++ b/lib/terminus.module.ts @@ -1,17 +1,36 @@ -import { type DynamicModule, Module, type Provider } from '@nestjs/common'; +import { + type DynamicModule, + Logger, + type LoggerService, + Module, + type Provider, +} from '@nestjs/common'; import { GracefulShutdownService, TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT, } from './graceful-shutdown-timeout/graceful-shutdown-timeout.service'; import { HealthCheckService } from './health-check'; -import { getErrorLoggerProvider } from './health-check/error-logger/error-logger.provider'; +import { + ERROR_LOGGER, + getErrorLoggerProvider, +} from './health-check/error-logger/error-logger.provider'; import { ERROR_LOGGERS } from './health-check/error-logger/error-loggers.provider'; +import { JsonErrorLogger } from './health-check/error-logger/json-error-logger.service'; +import { PrettyErrorLogger } from './health-check/error-logger/pretty-error-logger.service'; import { HealthCheckExecutor } from './health-check/health-check-executor.service'; -import { getLoggerProvider } from './health-check/logger/logger.provider'; +import { + getLoggerProvider, + TERMINUS_LOGGER, +} 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'; +import { + type TerminusModuleAsyncOptions, + type TerminusModuleOptions, + type TerminusModuleOptionsFactory, +} from './terminus-options.interface'; +import { TERMINUS_MODULE_OPTIONS } from './terminus.constants'; const baseProviders: Provider[] = [ ...ERROR_LOGGERS, @@ -67,4 +86,144 @@ export class TerminusModule { exports: exports_, }; } + + static forRootAsync(options: TerminusModuleAsyncOptions): DynamicModule { + const asyncProviders = this.createAsyncProviders(options); + const providers: Provider[] = [ + ...baseProviders, + ...asyncProviders, + { + provide: TERMINUS_GRACEFUL_SHUTDOWN_TIMEOUT, + useFactory: (moduleOptions: TerminusModuleOptions) => { + return moduleOptions.gracefulShutdownTimeoutMs || 0; + }, + inject: [TERMINUS_MODULE_OPTIONS], + }, + ]; + + // Add conditional providers based on options + const conditionalProviders: Provider[] = [ + { + provide: GracefulShutdownService, + useFactory: ( + moduleOptions: TerminusModuleOptions, + logger: LoggerService, + ) => { + if ( + moduleOptions.gracefulShutdownTimeoutMs && + moduleOptions.gracefulShutdownTimeoutMs > 0 + ) { + const service = new GracefulShutdownService( + logger, + moduleOptions.gracefulShutdownTimeoutMs, + ); + return service; + } + return null; + }, + inject: [TERMINUS_MODULE_OPTIONS, TERMINUS_LOGGER], + }, + ]; + + // Dynamically set error logger and logger providers + providers.push( + getErrorLoggerProvider('json'), // Default provider, will be overridden + getLoggerProvider(true), // Default provider, will be overridden + ...conditionalProviders.filter((p) => p !== null), + ); + + // Add provider overrides based on module options + providers.push({ + provide: ERROR_LOGGER, + useFactory: (moduleOptions: TerminusModuleOptions) => { + const errorLogStyle = moduleOptions.errorLogStyle || 'json'; + if (errorLogStyle === 'pretty') { + return new PrettyErrorLogger(); + } + return new JsonErrorLogger(); + }, + inject: [TERMINUS_MODULE_OPTIONS], + }); + + providers.push({ + provide: TERMINUS_LOGGER, + useFactory: (moduleOptions: TerminusModuleOptions) => { + const loggerOption = moduleOptions.logger; + if (loggerOption === false) { + return { + // eslint-disable-next-line @typescript-eslint/no-empty-function + log: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + error: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + warn: () => {}, + }; + } + if (loggerOption === true || loggerOption === undefined) { + return new Logger(); + } + // If it's a custom logger class, we need to instantiate it + // This is handled in a more complex way in real implementations + return new Logger(); + }, + inject: [TERMINUS_MODULE_OPTIONS], + }); + + return { + module: TerminusModule, + imports: options.imports || [], + providers: providers.filter((p) => p !== undefined && p !== null), + exports: exports_, + }; + } + + private static createAsyncProviders( + options: TerminusModuleAsyncOptions, + ): Provider[] { + const providers: Provider[] = []; + + if (options.useFactory) { + providers.push(this.createAsyncOptionsProvider(options)); + } else if (options.useClass) { + providers.push(this.createAsyncOptionsProvider(options), { + provide: options.useClass, + useClass: options.useClass, + }); + } else if (options.useExisting) { + providers.push(this.createAsyncOptionsProvider(options)); + } else { + throw new Error('Invalid TerminusModule async options'); + } + + return providers; + } + + private static createAsyncOptionsProvider( + options: TerminusModuleAsyncOptions, + ): Provider { + if (options.useFactory) { + return { + provide: TERMINUS_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }; + } + + if (options.useClass) { + return { + provide: TERMINUS_MODULE_OPTIONS, + useFactory: async (optionsFactory: TerminusModuleOptionsFactory) => + await optionsFactory.createTerminusOptions(), + inject: [options.useClass], + }; + } + + // Must be useExisting + return { + provide: TERMINUS_MODULE_OPTIONS, + useFactory: async (optionsFactory: TerminusModuleOptionsFactory) => + await optionsFactory.createTerminusOptions(), + inject: [options.useExisting!], + }; + } }