From 262c808f193591bbff8d6efb82c891acd25d33c7 Mon Sep 17 00:00:00 2001 From: Livio Brunner Date: Thu, 14 Sep 2023 19:05:11 +0200 Subject: [PATCH] feat: Do not cache health checks per default (#2335) When using the `@HealthCheck` decorator it will now per default set the following header: `Cache-Control: no-cache, no-store, must-revalidate` To disable this behavior set `@HealthCheck({ noCache: false })` resolves #2328 --- e2e/health-checks/health-check.e2e-spec.ts | 26 +++++++ e2e/helper/bootstrap-testing-module.ts | 19 +++-- lib/health-check/health-check.decorator.ts | 85 ++++++++++++++++------ 3 files changed, 104 insertions(+), 26 deletions(-) create mode 100644 e2e/health-checks/health-check.e2e-spec.ts diff --git a/e2e/health-checks/health-check.e2e-spec.ts b/e2e/health-checks/health-check.e2e-spec.ts new file mode 100644 index 000000000..8b531c5d9 --- /dev/null +++ b/e2e/health-checks/health-check.e2e-spec.ts @@ -0,0 +1,26 @@ +import * as request from 'supertest'; +import { INestApplication } from '@nestjs/common'; +import { DynamicHealthEndpointFn, bootstrapTestingModule } from '../helper'; +import { HealthIndicatorResult } from '../../lib'; + +describe.only('HealthCheck', () => { + let app: INestApplication; + let setHealthEndpoint: DynamicHealthEndpointFn; + + const healthyCheck = () => + Promise.resolve({ status: 'up' } as any); + + beforeEach( + () => (setHealthEndpoint = bootstrapTestingModule().setHealthEndpoint), + ); + + it('should set the Cache-Control header to no-cache, no-store, must-revalidate', async () => { + app = await setHealthEndpoint(({ healthCheck }) => + healthCheck.check([healthyCheck]), + ).start(); + + return request(app.getHttpServer()) + .get('/health') + .expect('Cache-Control', 'no-cache, no-store, must-revalidate'); + }); +}); diff --git a/e2e/helper/bootstrap-testing-module.ts b/e2e/helper/bootstrap-testing-module.ts index f9d8cabce..d6b734a6d 100644 --- a/e2e/helper/bootstrap-testing-module.ts +++ b/e2e/helper/bootstrap-testing-module.ts @@ -11,6 +11,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { DiskHealthIndicator, + HealthCheck, HealthCheckResult, HealthCheckService, HttpHealthIndicator, @@ -27,6 +28,7 @@ import { MongooseModule } from '@nestjs/mongoose'; import { HttpModule } from '@nestjs/axios'; import { MikroOrmHealthIndicator } from '../../lib/health-indicator/database/mikro-orm.health'; import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { HealthCheckOptions } from '../../lib/health-check'; type TestingHealthFunc = (props: { healthCheck: HealthCheckService; @@ -41,7 +43,10 @@ type TestingHealthFunc = (props: { prisma: PrismaHealthIndicator; }) => Promise; -function createHealthController(func: TestingHealthFunc) { +function createHealthController( + func: TestingHealthFunc, + options: { healthCheckOptions?: HealthCheckOptions }, +) { @Controller() class HealthController { constructor( @@ -57,6 +62,7 @@ function createHealthController(func: TestingHealthFunc) { private readonly prisma: PrismaHealthIndicator, ) {} @Get('health') + @HealthCheck(options.healthCheckOptions) health() { return func({ healthCheck: this.healthCheck, @@ -78,7 +84,10 @@ function createHealthController(func: TestingHealthFunc) { type PropType = TObj[TProp]; -export type DynamicHealthEndpointFn = (func: TestingHealthFunc) => { +export type DynamicHealthEndpointFn = ( + func: TestingHealthFunc, + options?: { healthCheckOptions?: HealthCheckOptions }, +) => { start( httpAdapter?: FastifyAdapter | ExpressAdapter, ): Promise; @@ -87,10 +96,10 @@ export type DynamicHealthEndpointFn = (func: TestingHealthFunc) => { export function bootstrapTestingModule() { const imports: PropType = [TerminusModule]; - function setHealthEndpoint(func: TestingHealthFunc) { + const setHealthEndpoint: DynamicHealthEndpointFn = (func, options = {}) => { const testingModule = Test.createTestingModule({ imports, - controllers: [createHealthController(func)], + controllers: [createHealthController(func, options)], }); async function start( @@ -106,7 +115,7 @@ export function bootstrapTestingModule() { } return { start }; - } + }; function withMongoose() { imports.push(MongooseModule.forRoot('mongodb://0.0.0.0:27017/test')); diff --git a/lib/health-check/health-check.decorator.ts b/lib/health-check/health-check.decorator.ts index ba67dc7ca..5d3861239 100644 --- a/lib/health-check/health-check.decorator.ts +++ b/lib/health-check/health-check.decorator.ts @@ -1,14 +1,74 @@ +import { Header } from '@nestjs/common'; import { getHealthCheckSchema } from './health-check.schema'; type Swagger = typeof import('@nestjs/swagger'); -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {}; +/** + * @publicApi + */ +export interface HealthCheckOptions { + /** + * Whether to cache the response or not. + * - If set to `true`, the response header will be set to `Cache-Control: no-cache, no-store, must-revalidate`. + * - If set to `false`, no header will be set and can be set manually with e.g. `@Header('Cache-Control', 'max-age=604800')`. + * + * @default true + */ + noCache?: boolean; + /** + * Whether to document the endpoint with Swagger or not. + * + * @default true + */ + swaggerDocumentation?: boolean; +} + +/** + * Marks the endpoint as a Health Check endpoint. + * + * - If the `@nestjs/swagger` package is installed, the endpoint will be documented. + * - Per default, the response will not be cached. + * + * @publicApi + */ +export const HealthCheck = ( + { noCache, swaggerDocumentation }: HealthCheckOptions = { + noCache: true, + swaggerDocumentation: true, + }, +) => { + const decorators: MethodDecorator[] = []; + + if (swaggerDocumentation) { + let swagger: Swagger | null = null; + try { + swagger = require('@nestjs/swagger'); + } catch {} + + if (swagger) { + decorators.push(...getSwaggerDefinitions(swagger)); + } + } + + if (noCache) { + const CacheControl = Header( + 'Cache-Control', + 'no-cache, no-store, must-revalidate', + ); + + decorators.push(CacheControl); + } + + return (target: any, key: any, descriptor: PropertyDescriptor) => { + decorators.forEach((decorator) => { + decorator(target, key, descriptor); + }); + }; +}; function getSwaggerDefinitions(swagger: Swagger) { const { ApiOkResponse, ApiServiceUnavailableResponse } = swagger; - // Possible HTTP Status const ServiceUnavailable = ApiServiceUnavailableResponse({ description: 'The Health Check is not successful', schema: getHealthCheckSchema('error'), @@ -19,22 +79,5 @@ function getSwaggerDefinitions(swagger: Swagger) { schema: getHealthCheckSchema('ok'), }); - // Combine all the SwaggerDecorators - return (target: any, key: any, descriptor: PropertyDescriptor) => { - ServiceUnavailable(target, key, descriptor); - Ok(target, key, descriptor); - }; + return [ServiceUnavailable, Ok]; } - -export const HealthCheck = () => { - let swagger: Swagger | null = null; - try { - // Dynamically load swagger, in case it is not installed - swagger = require('@nestjs/swagger'); - } catch {} - - if (swagger) { - return getSwaggerDefinitions(swagger); - } - return noop; -};