feat: add api liveness and readiness checks

This commit is contained in:
Bastian Wagner
2026-08-17 13:39:15 +02:00
parent db6d62c303
commit 0815f702d2
12 changed files with 327 additions and 5 deletions

View File

@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { ConfigurationModule } from '../../../libs/configuration/src';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { HealthModule } from './health/health.module';
@Module({
imports: [ConfigurationModule],
imports: [ConfigurationModule, HealthModule],
controllers: [AppController],
providers: [AppService],
})

View File

@@ -0,0 +1,29 @@
import { Test } from '@nestjs/testing';
import { HealthController } from './health.controller';
import { ReadinessService } from './readiness.service';
describe('HealthController', () => {
it('returns liveness without dependency checks', async () => {
const readiness = { check: jest.fn() };
const moduleRef = await Test.createTestingModule({
controllers: [HealthController],
providers: [{ provide: ReadinessService, useValue: readiness }],
}).compile();
expect(moduleRef.get(HealthController).live()).toEqual({ status: 'ok' });
expect(readiness.check).not.toHaveBeenCalled();
});
it('delegates readiness to dependency checks', async () => {
const readiness = { check: jest.fn().mockResolvedValue({ status: 'ok' }) };
const moduleRef = await Test.createTestingModule({
controllers: [HealthController],
providers: [{ provide: ReadinessService, useValue: readiness }],
}).compile();
await expect(moduleRef.get(HealthController).ready()).resolves.toEqual({
status: 'ok',
});
expect(readiness.check).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { ReadinessService } from './readiness.service';
@Controller()
export class HealthController {
constructor(private readonly readiness: ReadinessService) {}
@Get('health/live')
live(): { status: 'ok' } {
return { status: 'ok' };
}
@Get('health/ready')
ready(): Promise<{ status: 'ok' }> {
return this.readiness.check();
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import {
PostgresModule,
RedisModule,
} from '../../../../libs/infrastructure/src';
import { HealthController } from './health.controller';
import { ReadinessService } from './readiness.service';
@Module({
imports: [PostgresModule, RedisModule],
controllers: [HealthController],
providers: [ReadinessService],
})
export class HealthModule {}

View File

@@ -0,0 +1,22 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Pool } from 'pg';
import type Redis from 'ioredis';
import {
POSTGRES_POOL,
REDIS_CLIENT,
} from '../../../../libs/infrastructure/src';
@Injectable()
export class ReadinessService {
constructor(
@Inject(POSTGRES_POOL) private readonly postgresPool: Pool,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
async check(): Promise<{ status: 'ok' }> {
await this.postgresPool.query('SELECT 1');
const pong = await this.redis.ping();
if (pong !== 'PONG') throw new Error('Redis ping failed');
return { status: 'ok' as const };
}
}

View File

@@ -1,10 +1,16 @@
import { RequestMethod } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ApiModule } from './api.module';
export async function bootstrapApi(): Promise<void> {
const app = await NestFactory.create(ApiModule);
app.enableShutdownHooks();
app.setGlobalPrefix('api/v1');
app.setGlobalPrefix('api/v1', {
exclude: [
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'health/ready', method: RequestMethod.GET },
],
});
await app.listen(3000, '0.0.0.0');
}

View File

@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common';
import { ConfigurationModule } from '../../../libs/configuration/src';
import { PostgresModule, RedisModule } from '../../../libs/infrastructure/src';
@Module({
imports: [ConfigurationModule],
imports: [ConfigurationModule, PostgresModule, RedisModule],
})
export class WorkerModule {}

View File

@@ -0,0 +1,2 @@
export * from './postgres/postgres.module';
export * from './redis/redis.module';

View File

@@ -0,0 +1,27 @@
import { Inject, Injectable, Module, OnModuleDestroy } from '@nestjs/common';
import { Pool } from 'pg';
import { APP_ENVIRONMENT, AppEnvironment } from '../../../configuration/src';
export const POSTGRES_POOL = Symbol('POSTGRES_POOL');
export const postgresPoolProvider = {
provide: POSTGRES_POOL,
inject: [APP_ENVIRONMENT],
useFactory: (env: AppEnvironment) =>
new Pool({ connectionString: env.databaseUrl }),
};
@Injectable()
class PostgresLifecycle implements OnModuleDestroy {
constructor(@Inject(POSTGRES_POOL) private readonly pool: Pool) {}
async onModuleDestroy(): Promise<void> {
await this.pool.end();
}
}
@Module({
providers: [postgresPoolProvider, PostgresLifecycle],
exports: [postgresPoolProvider],
})
export class PostgresModule {}

View File

@@ -0,0 +1,27 @@
import { Inject, Injectable, Module, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
import { APP_ENVIRONMENT, AppEnvironment } from '../../../configuration/src';
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
export const redisClientProvider = {
provide: REDIS_CLIENT,
inject: [APP_ENVIRONMENT],
useFactory: (env: AppEnvironment) =>
new Redis(env.redisUrl, { lazyConnect: false }),
};
@Injectable()
class RedisLifecycle implements OnModuleDestroy {
constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {}
async onModuleDestroy(): Promise<void> {
await this.client.quit();
}
}
@Module({
providers: [redisClientProvider, RedisLifecycle],
exports: [redisClientProvider],
})
export class RedisModule {}

View File

@@ -26,6 +26,8 @@
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"ioredis": "^6.0.0",
"pg": "^8.23.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -38,6 +40,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/pg": "^8.21.0",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
@@ -62,8 +65,13 @@
],
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"testPathIgnorePatterns": ["<rootDir>/node_modules/", "<rootDir>/dist/"],
"setupFiles": ["<rootDir>/test/setup-env.ts"],
"testPathIgnorePatterns": [
"<rootDir>/node_modules/",
"<rootDir>/dist/"
],
"setupFiles": [
"<rootDir>/test/setup-env.ts"
],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},