diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..148bd3e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +[core] + # avoid line ending conversion on windows + autocrlf=false diff --git a/package-lock.json b/package-lock.json index 9045ff6..442567f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/common": "^9.0.0", "@nestjs/core": "^9.0.0", "@nestjs/platform-express": "^9.0.0", + "@nestjs/terminus": "^10.1.1", "id128": "^1.6.6", "mongodb": "^5.8.0", "reflect-metadata": "^0.1.13", @@ -1878,6 +1879,75 @@ "typescript": ">=4.3.5" } }, + "node_modules/@nestjs/terminus": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.1.1.tgz", + "integrity": "sha512-aDoPK/uaR9PHn56xzand6zqpp+S3Ibm+y/OrG3M01F1WnScLfo29hbS6MdnIMqmVRAS11r/8X3xWTSo8TT/Lig==", + "dependencies": { + "boxen": "5.1.2", + "check-disk-space": "3.4.0" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@grpc/proto-loader": "*", + "@mikro-orm/core": "*", + "@mikro-orm/nestjs": "*", + "@nestjs/axios": "^1.0.0 || ^2.0.0 || ^3.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "@nestjs/microservices": "^9.0.0 || ^10.0.0", + "@nestjs/mongoose": "^9.0.0 || ^10.0.0", + "@nestjs/sequelize": "^9.0.0 || ^10.0.0", + "@nestjs/typeorm": "^9.0.0 || ^10.0.0", + "@prisma/client": "*", + "mongoose": "*", + "reflect-metadata": "0.1.x", + "rxjs": "7.x", + "sequelize": "*", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@grpc/proto-loader": { + "optional": true + }, + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/nestjs": { + "optional": true + }, + "@nestjs/axios": { + "optional": true + }, + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/mongoose": { + "optional": true + }, + "@nestjs/sequelize": { + "optional": true + }, + "@nestjs/typeorm": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "sequelize": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "9.4.3", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.4.3.tgz", @@ -2827,6 +2897,14 @@ } } }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dependencies": { + "string-width": "^4.1.0" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -2867,7 +2945,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -3216,6 +3293,38 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3461,6 +3570,14 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/check-disk-space": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz", + "integrity": "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==", + "engines": { + "node": ">=16" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -3524,6 +3641,17 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -4124,8 +4252,7 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/encodeurl": { "version": "1.0.2", @@ -5415,7 +5542,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -8384,7 +8510,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8398,7 +8523,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -9020,7 +9144,6 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, "engines": { "node": ">=10" }, @@ -9372,6 +9495,17 @@ "node": ">= 8" } }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/windows-release": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-4.0.0.tgz", @@ -9438,7 +9572,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", diff --git a/package.json b/package.json index c8638d6..6128e31 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@nestjs/common": "^9.0.0", "@nestjs/core": "^9.0.0", "@nestjs/platform-express": "^9.0.0", + "@nestjs/terminus": "^10.1.1", "id128": "^1.6.6", "mongodb": "^5.8.0", "reflect-metadata": "^0.1.13", diff --git a/src/app.module.ts b/src/app.module.ts index 2eb2bf3..f3f49cf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { DomainModule } from './domain/domain.module'; +import { HealthModule } from './health/health.module'; @Module({ - imports: [DomainModule], + imports: [DomainModule, HealthModule], controllers: [AppController], providers: [], }) diff --git a/src/health/health.controller.spec.ts b/src/health/health.controller.spec.ts new file mode 100644 index 0000000..033349a --- /dev/null +++ b/src/health/health.controller.spec.ts @@ -0,0 +1,48 @@ +import { HealthController } from './health.controller'; +import { createTestingModule } from '../../test/test.helper'; +import { AppModule } from '../app.module'; +import { MongodbHealthIndicator } from './mongodb-health-indicator'; +import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus'; +import { ServiceUnavailableException } from '@nestjs/common'; + +describe('HealthController', () => { + it('should return healthy', async () => { + await createTestingModule([AppModule], async (app) => { + const controller = app.get(HealthController); + expect(controller).toBeDefined(); + + const mongoIndicator = app.get(MongodbHealthIndicator); + mongoIndicator.isHealthy = jest.fn(async () => { + return { + mongodb: { + status: 'up', + }, + } as HealthIndicatorResult; + }); + const status = await controller.check(); + expect(status.status).toBe('ok'); + }); + }); + + it('should return unhealthy if mongodb is down', async () => { + await createTestingModule([AppModule], async (app) => { + const controller = app.get(HealthController); + + const mongoIndicator = app.get(MongodbHealthIndicator); + mongoIndicator.isHealthy = jest.fn(async () => { + throw new HealthCheckError('Mongodb check failed', { + mongodb: { + status: 'down', + }, + }); + }); + try { + await controller.check(); + fail('should not get here'); + } catch (e) { + expect(e).toBeInstanceOf(ServiceUnavailableException); + expect(e.response.status).toBe('error'); + } + }); + }); +}); diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts new file mode 100644 index 0000000..92fc1f4 --- /dev/null +++ b/src/health/health.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get } from '@nestjs/common'; +import { HealthCheckService, HealthCheck } from '@nestjs/terminus'; +import { PostgresHealthIndicator } from './postgres-health-indicator'; +import { MongodbHealthIndicator } from './mongodb-health-indicator'; + +@Controller('health') +export class HealthController { + constructor( + private health: HealthCheckService, + private postgres: PostgresHealthIndicator, + private mongodb: MongodbHealthIndicator, + ) {} + + @Get() + @HealthCheck() + check() { + return this.health.check([ + () => this.postgres.isHealthy(), + () => this.mongodb.isHealthy(), + ]); + } +} diff --git a/src/health/health.module.ts b/src/health/health.module.ts new file mode 100644 index 0000000..919a3e6 --- /dev/null +++ b/src/health/health.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; +import { PostgresHealthIndicator } from './postgres-health-indicator'; +import { TerminusModule } from '@nestjs/terminus'; +import { DomainModule } from '../domain/domain.module'; +import { MongodbHealthIndicator } from './mongodb-health-indicator'; + +@Module({ + imports: [TerminusModule, DomainModule], + controllers: [HealthController], + providers: [PostgresHealthIndicator, MongodbHealthIndicator], +}) +export class HealthModule {} diff --git a/src/health/mongodb-health-indicator.ts b/src/health/mongodb-health-indicator.ts new file mode 100644 index 0000000..6d342b7 --- /dev/null +++ b/src/health/mongodb-health-indicator.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { + HealthIndicator, + HealthIndicatorResult, + HealthCheckError, +} from '@nestjs/terminus'; +import { MongoClient } from 'mongodb'; + +@Injectable() +export class MongodbHealthIndicator extends HealthIndicator { + NAME = 'mongodb'; + async isHealthy(): Promise { + try { + const client = new MongoClient(process.env.MONGO_URI, { + serverSelectionTimeoutMS: 1000, + connectTimeoutMS: 1000, + socketTimeoutMS: 1000, + }); + await client.connect(); + const db = client.db('off'); + const products = db.collection('products'); + const cursor = products.find({}, { projection: { code: 1 } }); + await cursor.next(); + await cursor.close(); + await client.close(); + + return this.getStatus(this.NAME, true); + } catch (e) { + throw new HealthCheckError( + 'MongoDB check failed', + this.getStatus(this.NAME, false, e), + ); + } + } +} diff --git a/src/health/postgres-health-indicator.ts b/src/health/postgres-health-indicator.ts new file mode 100644 index 0000000..5cd1f29 --- /dev/null +++ b/src/health/postgres-health-indicator.ts @@ -0,0 +1,27 @@ +import { EntityManager } from '@mikro-orm/core'; +import { Injectable } from '@nestjs/common'; +import { + HealthIndicator, + HealthIndicatorResult, + HealthCheckError, +} from '@nestjs/terminus'; +import { Product } from '../domain/entities/product'; + +@Injectable() +export class PostgresHealthIndicator extends HealthIndicator { + NAME = 'postgres'; + constructor(private em: EntityManager) { + super(); + } + async isHealthy(): Promise { + try { + await this.em.find(Product, { code: 'x' }); + return this.getStatus(this.NAME, true); + } catch (e) { + throw new HealthCheckError( + 'Postgres check failed', + this.getStatus(this.NAME, false, e), + ); + } + } +}