diff --git a/.env.dist b/.env.dist index 850a500..0831c67 100644 --- a/.env.dist +++ b/.env.dist @@ -2,6 +2,7 @@ SERVICE_URL=0.0.0.0 SERVICE_PORT=5004 SERVICE_CONFIGURATION_DOMAIN=TERRITORY +HEALTH_SERVICE_PORT=6004 # PRISMA DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=territory" diff --git a/docker-compose.yml b/docker-compose.yml index 537af33..d5b131a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: command: npm run start:dev ports: - ${SERVICE_PORT:-5004}:${SERVICE_PORT:-5004} + - ${HEALTH_SERVICE_PORT:-6004}:${HEALTH_SERVICE_PORT:-6004} networks: v3-network: aliases: diff --git a/package-lock.json b/package-lock.json index 36b0b91..2012f8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@nestjs/cqrs": "^9.0.1", "@nestjs/microservices": "^9.3.2", "@nestjs/platform-express": "^9.0.0", + "@nestjs/terminus": "^9.2.2", "@prisma/client": "^4.9.0", "cache-manager": "^5.1.5", "cache-manager-ioredis-yet": "^1.1.0", @@ -1973,6 +1974,71 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/@nestjs/terminus": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-9.2.2.tgz", + "integrity": "sha512-AWUA8XLcgxWUjUFYHDqi42M7CZn2e+DEWxP+MqNAbMzz4ybB5jGcFK5Fy8qwaNBoWg6KMF1JiXOOygGXgk9ydg==", + "dependencies": { + "boxen": "5.1.2", + "check-disk-space": "3.3.1" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@grpc/proto-loader": "*", + "@mikro-orm/core": "*", + "@mikro-orm/nestjs": "*", + "@nestjs/axios": "*", + "@nestjs/common": "9.x", + "@nestjs/core": "9.x", + "@nestjs/microservices": "*", + "@nestjs/mongoose": "*", + "@nestjs/sequelize": "*", + "@nestjs/typeorm": "*", + "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 + }, + "mongoose": { + "optional": true + }, + "sequelize": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.3.2.tgz", @@ -2939,6 +3005,14 @@ "node": ">=10" } }, + "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", @@ -3301,6 +3375,53 @@ "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/boxen/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3520,6 +3641,14 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/check-disk-space": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.3.1.tgz", + "integrity": "sha512-iOrT8yCZjSnyNZ43476FE2rnssvgw5hnuwOM0hm8Nj1qa0v4ieUUEbCyxxsEliaoDUb/75yCOL71zkDiDBLbMQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -3592,6 +3721,17 @@ "validator": "^13.7.0" } }, + "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", @@ -8674,7 +8814,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" }, @@ -8971,6 +9110,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", @@ -10577,6 +10727,15 @@ } } }, + "@nestjs/terminus": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-9.2.2.tgz", + "integrity": "sha512-AWUA8XLcgxWUjUFYHDqi42M7CZn2e+DEWxP+MqNAbMzz4ybB5jGcFK5Fy8qwaNBoWg6KMF1JiXOOygGXgk9ydg==", + "requires": { + "boxen": "5.1.2", + "check-disk-space": "3.3.1" + } + }, "@nestjs/testing": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.3.2.tgz", @@ -11367,6 +11526,14 @@ "url-parse": "~1.5.1" } }, + "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==", + "requires": { + "string-width": "^4.1.0" + } + }, "ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -11655,6 +11822,37 @@ } } }, + "boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "requires": { + "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" + }, + "dependencies": { + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -11804,6 +12002,11 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "check-disk-space": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.3.1.tgz", + "integrity": "sha512-iOrT8yCZjSnyNZ43476FE2rnssvgw5hnuwOM0hm8Nj1qa0v4ieUUEbCyxxsEliaoDUb/75yCOL71zkDiDBLbMQ==" + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -11853,6 +12056,11 @@ "validator": "^13.7.0" } }, + "cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==" + }, "cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -15622,8 +15830,7 @@ "type-fest": { "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 + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==" }, "type-is": { "version": "1.6.18", @@ -15836,6 +16043,14 @@ "isexe": "^2.0.0" } }, + "widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "requires": { + "string-width": "^4.0.0" + } + }, "windows-release": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-4.0.0.tgz", diff --git a/package.json b/package.json index 79e349a..f792b4e 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@nestjs/cqrs": "^9.0.1", "@nestjs/microservices": "^9.3.2", "@nestjs/platform-express": "^9.0.0", + "@nestjs/terminus": "^9.2.2", "@prisma/client": "^4.9.0", "cache-manager": "^5.1.5", "cache-manager-ioredis-yet": "^1.1.0", @@ -89,6 +90,7 @@ "modulePathIgnorePatterns": [ ".controller.ts", ".module.ts", + ".request.ts", "main.ts" ], "rootDir": "src", diff --git a/src/main.ts b/src/main.ts index 1df0373..bf30f77 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,24 +4,24 @@ import { join } from 'path'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.createMicroservice( - AppModule, - { - transport: Transport.GRPC, - options: { - package: ['territory', 'health'], - protoPath: [ - join( - __dirname, - 'modules/territory/adapters/primaries/territory.proto', - ), - join(__dirname, 'modules/health/adapters/primaries/health.proto'), - ], - url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT, - loader: { keepCase: true }, - }, + const app = await NestFactory.create(AppModule); + app.connectMicroservice({ + transport: Transport.TCP, + }); + app.connectMicroservice({ + transport: Transport.GRPC, + options: { + package: ['territory', 'health'], + protoPath: [ + join(__dirname, 'modules/territory/adapters/primaries/territory.proto'), + join(__dirname, 'modules/health/adapters/primaries/health.proto'), + ], + url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT, + loader: { keepCase: true }, }, - ); - await app.listen(); + }); + + await app.startAllMicroservices(); + await app.listen(process.env.HEALTH_SERVICE_PORT); } bootstrap(); diff --git a/src/modules/databases/database.module.ts b/src/modules/database/database.module.ts similarity index 100% rename from src/modules/databases/database.module.ts rename to src/modules/database/database.module.ts diff --git a/src/modules/databases/src/adapters/secondaries/prisma-repository.abstract.ts b/src/modules/database/src/adapters/secondaries/prisma-repository.abstract.ts similarity index 93% rename from src/modules/databases/src/adapters/secondaries/prisma-repository.abstract.ts rename to src/modules/database/src/adapters/secondaries/prisma-repository.abstract.ts index 7a90a1d..2827de9 100644 --- a/src/modules/databases/src/adapters/secondaries/prisma-repository.abstract.ts +++ b/src/modules/database/src/adapters/secondaries/prisma-repository.abstract.ts @@ -236,4 +236,21 @@ export abstract class PrismaRepository implements IRepository { } } } + + async healthCheck(): Promise { + try { + await this._prisma.$queryRaw`SELECT 1`; + return true; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + throw new DatabaseException( + PrismaClientKnownRequestError.name, + e.code, + e.message, + ); + } else { + throw new DatabaseException(); + } + } + } } diff --git a/src/modules/databases/src/adapters/secondaries/prisma-service.ts b/src/modules/database/src/adapters/secondaries/prisma-service.ts similarity index 100% rename from src/modules/databases/src/adapters/secondaries/prisma-service.ts rename to src/modules/database/src/adapters/secondaries/prisma-service.ts diff --git a/src/modules/databases/src/domain/point.type.ts b/src/modules/database/src/domain/point.type.ts similarity index 100% rename from src/modules/databases/src/domain/point.type.ts rename to src/modules/database/src/domain/point.type.ts diff --git a/src/modules/databases/src/domain/territory-repository.ts b/src/modules/database/src/domain/territory-repository.ts similarity index 100% rename from src/modules/databases/src/domain/territory-repository.ts rename to src/modules/database/src/domain/territory-repository.ts diff --git a/src/modules/databases/src/exceptions/database.exception.ts b/src/modules/database/src/exceptions/database.exception.ts similarity index 100% rename from src/modules/databases/src/exceptions/database.exception.ts rename to src/modules/database/src/exceptions/database.exception.ts diff --git a/src/modules/databases/src/interfaces/collection.interface.ts b/src/modules/database/src/interfaces/collection.interface.ts similarity index 100% rename from src/modules/databases/src/interfaces/collection.interface.ts rename to src/modules/database/src/interfaces/collection.interface.ts diff --git a/src/modules/databases/src/interfaces/repository.interface.ts b/src/modules/database/src/interfaces/repository.interface.ts similarity index 100% rename from src/modules/databases/src/interfaces/repository.interface.ts rename to src/modules/database/src/interfaces/repository.interface.ts diff --git a/src/modules/databases/tests/unit/prisma-repository.spec.ts b/src/modules/database/tests/unit/prisma-repository.spec.ts similarity index 94% rename from src/modules/databases/tests/unit/prisma-repository.spec.ts rename to src/modules/database/tests/unit/prisma-repository.spec.ts index 08323e2..bc194c4 100644 --- a/src/modules/databases/tests/unit/prisma-repository.spec.ts +++ b/src/modules/database/tests/unit/prisma-repository.spec.ts @@ -87,6 +87,23 @@ const mockPrismaService = { .mockImplementationOnce((fields: object) => { throw new Error('an unknown error'); }), + $queryRaw: jest + .fn() + .mockImplementationOnce(() => { + throw new PrismaClientKnownRequestError('unknown request', { + code: 'code', + clientVersion: 'version', + }); + }) + .mockImplementationOnce(() => { + return true; + }) + .mockImplementation(() => { + throw new PrismaClientKnownRequestError('Database unavailable', { + code: 'code', + clientVersion: 'version', + }); + }), fake: { create: jest .fn() @@ -532,4 +549,23 @@ describe('PrismaRepository', () => { ).rejects.toBeInstanceOf(DatabaseException); }); }); + + describe('healthCheck', () => { + it('should throw a DatabaseException for client error', async () => { + await expect(fakeRepository.healthCheck()).rejects.toBeInstanceOf( + DatabaseException, + ); + }); + + it('should return a healthy result', async () => { + const res = await fakeRepository.healthCheck(); + expect(res).toBeTruthy(); + }); + + it('should throw an exception if database is not available', async () => { + await expect(fakeRepository.healthCheck()).rejects.toBeInstanceOf( + DatabaseException, + ); + }); + }); }); diff --git a/src/modules/databases/tests/unit/territory-repository.spec.ts b/src/modules/database/tests/unit/territory-repository.spec.ts similarity index 100% rename from src/modules/databases/tests/unit/territory-repository.spec.ts rename to src/modules/database/tests/unit/territory-repository.spec.ts diff --git a/src/modules/health/adapters/primaries/health-server.controller.ts b/src/modules/health/adapters/primaries/health-server.controller.ts new file mode 100644 index 0000000..b58c761 --- /dev/null +++ b/src/modules/health/adapters/primaries/health-server.controller.ts @@ -0,0 +1,42 @@ +import { Controller } from '@nestjs/common'; +import { GrpcMethod } from '@nestjs/microservices'; +import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase'; + +enum ServingStatus { + UNKNOWN = 0, + SERVING = 1, + NOT_SERVING = 2, +} + +interface HealthCheckRequest { + service: string; +} + +interface HealthCheckResponse { + status: ServingStatus; +} + +@Controller() +export class HealthServerController { + constructor( + private readonly _prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase, + ) {} + + @GrpcMethod('Health', 'Check') + async check( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + data: HealthCheckRequest, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + metadata: any, + ): Promise { + const healthCheck = await this._prismaHealthIndicatorUseCase.isHealthy( + 'prisma', + ); + return { + status: + healthCheck['prisma'].status == 'up' + ? ServingStatus.SERVING + : ServingStatus.NOT_SERVING, + }; + } +} diff --git a/src/modules/health/adapters/primaries/health.controller.ts b/src/modules/health/adapters/primaries/health.controller.ts index 843a4a9..810652b 100644 --- a/src/modules/health/adapters/primaries/health.controller.ts +++ b/src/modules/health/adapters/primaries/health.controller.ts @@ -1,27 +1,34 @@ -import { Controller } from '@nestjs/common'; -import { GrpcMethod } from '@nestjs/microservices'; +import { Controller, Get } from '@nestjs/common'; +import { + HealthCheckService, + HealthCheck, + HealthCheckResult, +} from '@nestjs/terminus'; +import { Messager } from '../secondaries/messager'; +import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase'; -enum ServingStatus { - UNKNOWN = 0, - SERVING = 1, - NOT_SERVING = 2, -} - -interface HealthCheckRequest { - service: string; -} - -interface HealthCheckResponse { - status: ServingStatus; -} - -@Controller() +@Controller('health') export class HealthController { - @GrpcMethod('Health', 'Check') - // eslint-disable-next-line @typescript-eslint/no-unused-vars - check(data: HealthCheckRequest, metadata: any): HealthCheckResponse { - return { - status: ServingStatus.SERVING, - }; + constructor( + private readonly _prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase, + private _healthCheckService: HealthCheckService, + private _messager: Messager, + ) {} + + @Get() + @HealthCheck() + async check() { + try { + return await this._healthCheckService.check([ + async () => this._prismaHealthIndicatorUseCase.isHealthy('prisma'), + ]); + } catch (error) { + const healthCheckResult: HealthCheckResult = error.response; + this._messager.publish( + 'logging.territory.health.crit', + JSON.stringify(healthCheckResult.error), + ); + throw error; + } } } diff --git a/src/modules/health/adapters/secondaries/message-broker.ts b/src/modules/health/adapters/secondaries/message-broker.ts new file mode 100644 index 0000000..594aa43 --- /dev/null +++ b/src/modules/health/adapters/secondaries/message-broker.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export abstract class IMessageBroker { + exchange: string; + + constructor(exchange: string) { + this.exchange = exchange; + } + + abstract publish(routingKey: string, message: string): void; +} diff --git a/src/modules/health/adapters/secondaries/messager.ts b/src/modules/health/adapters/secondaries/messager.ts new file mode 100644 index 0000000..0725261 --- /dev/null +++ b/src/modules/health/adapters/secondaries/messager.ts @@ -0,0 +1,18 @@ +import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IMessageBroker } from './message-broker'; + +@Injectable() +export class Messager extends IMessageBroker { + constructor( + private readonly _amqpConnection: AmqpConnection, + configService: ConfigService, + ) { + super(configService.get('RMQ_EXCHANGE')); + } + + publish(routingKey: string, message: string): void { + this._amqpConnection.publish(this.exchange, routingKey, message); + } +} diff --git a/src/modules/health/domain/usecases/prisma.health-indicator.usecase.ts b/src/modules/health/domain/usecases/prisma.health-indicator.usecase.ts new file mode 100644 index 0000000..7f30583 --- /dev/null +++ b/src/modules/health/domain/usecases/prisma.health-indicator.usecase.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { + HealthCheckError, + HealthIndicator, + HealthIndicatorResult, +} from '@nestjs/terminus'; +import { TerritoriesRepository } from '../../../territory/adapters/secondaries/territories.repository'; + +@Injectable() +export class PrismaHealthIndicatorUseCase extends HealthIndicator { + constructor(private readonly _repository: TerritoriesRepository) { + super(); + } + + async isHealthy(key: string): Promise { + try { + await this._repository.healthCheck(); + return this.getStatus(key, true); + } catch (e) { + throw new HealthCheckError('Prisma', { + prisma: e.message, + }); + } + } +} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts index 1f715d5..0ab131b 100644 --- a/src/modules/health/health.module.ts +++ b/src/modules/health/health.module.ts @@ -1,7 +1,34 @@ import { Module } from '@nestjs/common'; +import { HealthServerController } from './adapters/primaries/health-server.controller'; +import { PrismaHealthIndicatorUseCase } from './domain/usecases/prisma.health-indicator.usecase'; +import { TerritoriesRepository } from '../territory/adapters/secondaries/territories.repository'; +import { DatabaseModule } from '../database/database.module'; import { HealthController } from './adapters/primaries/health.controller'; +import { TerminusModule } from '@nestjs/terminus'; +import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Messager } from './adapters/secondaries/messager'; @Module({ - controllers: [HealthController], + imports: [ + TerminusModule, + RabbitMQModule.forRootAsync(RabbitMQModule, { + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + exchanges: [ + { + name: configService.get('RMQ_EXCHANGE'), + type: 'topic', + }, + ], + uri: configService.get('RMQ_URI'), + connectionInitOptions: { wait: false }, + }), + inject: [ConfigService], + }), + DatabaseModule, + ], + controllers: [HealthServerController, HealthController], + providers: [PrismaHealthIndicatorUseCase, TerritoriesRepository, Messager], }) export class HealthModule {} diff --git a/src/modules/health/tests/unit/messager.spec.ts b/src/modules/health/tests/unit/messager.spec.ts new file mode 100644 index 0000000..0331332 --- /dev/null +++ b/src/modules/health/tests/unit/messager.spec.ts @@ -0,0 +1,47 @@ +import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Messager } from '../../adapters/secondaries/messager'; + +const mockAmqpConnection = { + publish: jest.fn().mockImplementation(), +}; + +const mockConfigService = { + get: jest.fn().mockResolvedValue({ + RMQ_EXCHANGE: 'mobicoop', + }), +}; + +describe('Messager', () => { + let messager: Messager; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + Messager, + { + provide: AmqpConnection, + useValue: mockAmqpConnection, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + messager = module.get(Messager); + }); + + it('should be defined', () => { + expect(messager).toBeDefined(); + }); + + it('should publish a message', async () => { + jest.spyOn(mockAmqpConnection, 'publish'); + messager.publish('test.create.info', 'my-test'); + expect(mockAmqpConnection.publish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/health/tests/unit/prisma.health-indicator.usecase.spec.ts b/src/modules/health/tests/unit/prisma.health-indicator.usecase.spec.ts new file mode 100644 index 0000000..971a836 --- /dev/null +++ b/src/modules/health/tests/unit/prisma.health-indicator.usecase.spec.ts @@ -0,0 +1,58 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase'; +import { TerritoriesRepository } from '../../../territory/adapters/secondaries/territories.repository'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime'; +import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus'; + +const mockTerritoriesRepository = { + healthCheck: jest + .fn() + .mockImplementationOnce(() => { + return Promise.resolve(true); + }) + .mockImplementation(() => { + throw new PrismaClientKnownRequestError('Service unavailable', { + code: 'code', + clientVersion: 'version', + }); + }), +}; + +describe('PrismaHealthIndicatorUseCase', () => { + let prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: TerritoriesRepository, + useValue: mockTerritoriesRepository, + }, + PrismaHealthIndicatorUseCase, + ], + }).compile(); + + prismaHealthIndicatorUseCase = module.get( + PrismaHealthIndicatorUseCase, + ); + }); + + it('should be defined', () => { + expect(prismaHealthIndicatorUseCase).toBeDefined(); + }); + + describe('execute', () => { + it('should check health successfully', async () => { + const healthIndicatorResult: HealthIndicatorResult = + await prismaHealthIndicatorUseCase.isHealthy('prisma'); + + expect(healthIndicatorResult['prisma'].status).toBe('up'); + }); + + it('should throw an error if database is unavailable', async () => { + await expect( + prismaHealthIndicatorUseCase.isHealthy('prisma'), + ).rejects.toBeInstanceOf(HealthCheckError); + }); + }); +}); diff --git a/src/modules/territory/adapters/primaries/territory.controller.ts b/src/modules/territory/adapters/primaries/territory.controller.ts index bb0816a..cd49e76 100644 --- a/src/modules/territory/adapters/primaries/territory.controller.ts +++ b/src/modules/territory/adapters/primaries/territory.controller.ts @@ -6,7 +6,7 @@ import { GrpcMethod, RpcException } from '@nestjs/microservices'; import { Territory } from '../../domain/entities/territory'; import { FindAllTerritoriesForPointQuery } from '../../queries/find-all-territories-for-point.query'; import { TerritoryPresenter } from './territory.presenter'; -import { ICollection } from '../../../databases/src/interfaces/collection.interface'; +import { ICollection } from '../../../database/src/interfaces/collection.interface'; import { RpcValidationPipe } from '../../../../utils/pipes/rpc.validation-pipe'; import { FindAllTerritoriesForPointRequest } from '../../domain/dtos/find-all-territories-for-point.request'; import { FindAllTerritoriesRequest } from '../../domain/dtos/find-all-territories.request'; @@ -15,7 +15,7 @@ import { FindTerritoryByUuidRequest } from '../../domain/dtos/find-territory-by- import { FindTerritoryByUuidQuery } from '../../queries/find-territory-by-uuid.query'; import { CreateTerritoryRequest } from '../../domain/dtos/create-territory.request'; import { CreateTerritoryCommand } from '../../commands/create-territory.command'; -import { DatabaseException } from 'src/modules/databases/src/exceptions/database.exception'; +import { DatabaseException } from 'src/modules/database/src/exceptions/database.exception'; import { UpdateTerritoryRequest } from '../../domain/dtos/update-territory.request'; import { UpdateTerritoryCommand } from '../../commands/update-territory.command'; import { DeleteTerritoryCommand } from '../../commands/delete-territory.command'; diff --git a/src/modules/territory/adapters/secondaries/territories.repository.ts b/src/modules/territory/adapters/secondaries/territories.repository.ts index f4e1fba..6c617c4 100644 --- a/src/modules/territory/adapters/secondaries/territories.repository.ts +++ b/src/modules/territory/adapters/secondaries/territories.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { TerritoryRepository } from '../../../databases/src/domain/territory-repository'; +import { TerritoryRepository } from '../../../database/src/domain/territory-repository'; import { Territory } from '../../domain/entities/territory'; @Injectable() diff --git a/src/modules/territory/domain/usecases/find-all-territories-for-point.usecase.ts b/src/modules/territory/domain/usecases/find-all-territories-for-point.usecase.ts index 8888b4a..c39701d 100644 --- a/src/modules/territory/domain/usecases/find-all-territories-for-point.usecase.ts +++ b/src/modules/territory/domain/usecases/find-all-territories-for-point.usecase.ts @@ -1,5 +1,5 @@ import { QueryHandler } from '@nestjs/cqrs'; -import { ICollection } from 'src/modules/databases/src/interfaces/collection.interface'; +import { ICollection } from 'src/modules/database/src/interfaces/collection.interface'; import { TerritoriesRepository } from '../../adapters/secondaries/territories.repository'; import { FindAllTerritoriesForPointQuery } from '../../queries/find-all-territories-for-point.query'; import { Territory } from '../entities/territory'; diff --git a/src/modules/territory/domain/usecases/find-all-territories-for-points.usecase.ts b/src/modules/territory/domain/usecases/find-all-territories-for-points.usecase.ts index d3faf5e..8b103d7 100644 --- a/src/modules/territory/domain/usecases/find-all-territories-for-points.usecase.ts +++ b/src/modules/territory/domain/usecases/find-all-territories-for-points.usecase.ts @@ -1,5 +1,5 @@ import { QueryHandler } from '@nestjs/cqrs'; -import { ICollection } from 'src/modules/databases/src/interfaces/collection.interface'; +import { ICollection } from 'src/modules/database/src/interfaces/collection.interface'; import { TerritoriesRepository } from '../../adapters/secondaries/territories.repository'; import { FindAllTerritoriesForPointsQuery } from '../../queries/find-all-territories-for-points.query'; import { Territory } from '../entities/territory'; diff --git a/src/modules/territory/domain/usecases/find-all-territories.usecase.ts b/src/modules/territory/domain/usecases/find-all-territories.usecase.ts index f1f7f62..dadb3f9 100644 --- a/src/modules/territory/domain/usecases/find-all-territories.usecase.ts +++ b/src/modules/territory/domain/usecases/find-all-territories.usecase.ts @@ -1,5 +1,5 @@ import { QueryHandler } from '@nestjs/cqrs'; -import { ICollection } from 'src/modules/databases/src/interfaces/collection.interface'; +import { ICollection } from 'src/modules/database/src/interfaces/collection.interface'; import { TerritoriesRepository } from '../../adapters/secondaries/territories.repository'; import { FindAllTerritoriesQuery } from '../../queries/find-all-territories.query'; import { Territory } from '../entities/territory'; diff --git a/src/modules/territory/territory.module.ts b/src/modules/territory/territory.module.ts index 87e308e..fe8d15c 100644 --- a/src/modules/territory/territory.module.ts +++ b/src/modules/territory/territory.module.ts @@ -4,7 +4,7 @@ import { CacheModule, Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { CqrsModule } from '@nestjs/cqrs'; import { redisStore } from 'cache-manager-ioredis-yet'; -import { DatabaseModule } from '../databases/database.module'; +import { DatabaseModule } from '../database/database.module'; import { TerritoryController } from './adapters/primaries/territory.controller'; import { TerritoriesRepository } from './adapters/secondaries/territories.repository'; import { Messager } from './adapters/secondaries/messager'; diff --git a/src/modules/territory/tests/integration/territories.repository.spec.ts b/src/modules/territory/tests/integration/territories.repository.spec.ts index 4a43e97..63e9655 100644 --- a/src/modules/territory/tests/integration/territories.repository.spec.ts +++ b/src/modules/territory/tests/integration/territories.repository.spec.ts @@ -1,7 +1,7 @@ import { TestingModule, Test } from '@nestjs/testing'; -import { DatabaseModule } from '../../../databases/database.module'; -import { PrismaService } from '../../../databases/src/adapters/secondaries/prisma-service'; -import { DatabaseException } from '../../../databases/src/exceptions/database.exception'; +import { DatabaseModule } from '../../../database/database.module'; +import { PrismaService } from '../../../database/src/adapters/secondaries/prisma-service'; +import { DatabaseException } from '../../../database/src/exceptions/database.exception'; import { TerritoriesRepository } from '../../adapters/secondaries/territories.repository'; import { v4 as uuidv4 } from 'uuid'; import { Territory } from '../../domain/entities/territory';