diff --git a/e2e/health-checks/mikro-orm.health.e2e-spec.ts b/e2e/health-checks/mikro-orm.health.e2e-spec.ts new file mode 100644 index 000000000..05f53d955 --- /dev/null +++ b/e2e/health-checks/mikro-orm.health.e2e-spec.ts @@ -0,0 +1,53 @@ +import * as request from 'supertest'; +import { INestApplication } from '@nestjs/common'; +import { bootstrapTestingModule, DynamicHealthEndpointFn } from '../helper'; + +describe('MikroOrmHealthIndicator', () => { + let app: INestApplication; + let setHealthEndpoint: DynamicHealthEndpointFn; + + beforeEach( + () => + (setHealthEndpoint = + bootstrapTestingModule().withMikroOrm().setHealthEndpoint), + ); + + describe('#pingCheck', () => { + it('should check if the mikroOrm is available', async () => { + app = await setHealthEndpoint(({ healthCheck, mikroOrm }) => + healthCheck.check([async () => mikroOrm.pingCheck('mikroOrm')]), + ).start(); + const details = { mikroOrm: { status: 'up' } }; + return request(app.getHttpServer()).get('/health').expect(200).expect({ + status: 'ok', + info: details, + error: {}, + details, + }); + }); + + it('should throw an error if runs into timeout error', async () => { + app = await setHealthEndpoint(({ healthCheck, mikroOrm }) => + healthCheck.check([ + async () => mikroOrm.pingCheck('mikroOrm', { timeout: 1 }), + ]), + ).start(); + + const details = { + mikroOrm: { + status: 'down', + message: 'timeout of 1ms exceeded', + }, + }; + + return request(app.getHttpServer()).get('/health').expect(503).expect({ + status: 'error', + info: {}, + error: details, + details, + }); + }); + }); + + afterEach(async () => await app.close()); +}); diff --git a/e2e/helper/bootstrap-testing-module.ts b/e2e/helper/bootstrap-testing-module.ts index d09cc5343..bdf287eb3 100644 --- a/e2e/helper/bootstrap-testing-module.ts +++ b/e2e/helper/bootstrap-testing-module.ts @@ -24,6 +24,8 @@ import { import { SequelizeModule } from '@nestjs/sequelize'; 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'; type TestingHealthFunc = (props: { healthCheck: HealthCheckService; @@ -34,6 +36,7 @@ type TestingHealthFunc = (props: { mongoose: MongooseHealthIndicator; sequelize: SequelizeHealthIndicator; typeorm: TypeOrmHealthIndicator; + mikroOrm: MikroOrmHealthIndicator; }) => Promise; function createHealthController(func: TestingHealthFunc) { @@ -48,6 +51,7 @@ function createHealthController(func: TestingHealthFunc) { private readonly mongoose: MongooseHealthIndicator, private readonly sequelize: SequelizeHealthIndicator, private readonly typeorm: TypeOrmHealthIndicator, + private readonly mikroOrm: MikroOrmHealthIndicator, ) {} // @ts-ignore @Get('health') @@ -61,6 +65,7 @@ function createHealthController(func: TestingHealthFunc) { mongoose: this.mongoose, sequelize: this.sequelize, typeorm: this.typeorm, + mikroOrm: this.mikroOrm, }); } } @@ -140,6 +145,23 @@ export function bootstrapTestingModule() { return { setHealthEndpoint }; } + function withMikroOrm() { + imports.push( + MikroOrmModule.forRoot({ + type: 'mysql', + host: '0.0.0.0', + port: 3306, + user: 'root', + password: 'root', + dbName: 'test', + discovery: { warnWhenNoEntities: false }, + strict: true, + }), + ); + + return { setHealthEndpoint }; + } + function withHttp() { imports.push(HttpModule); return { setHealthEndpoint }; @@ -150,6 +172,7 @@ export function bootstrapTestingModule() { withTypeOrm, withSequelize, withHttp, + withMikroOrm, setHealthEndpoint, }; } diff --git a/lib/health-indicator/database/mikro-orm.health.ts b/lib/health-indicator/database/mikro-orm.health.ts new file mode 100644 index 000000000..025522c65 --- /dev/null +++ b/lib/health-indicator/database/mikro-orm.health.ts @@ -0,0 +1,139 @@ +import { Injectable, NotImplementedException, Scope } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { HealthCheckError } from '../../health-check/health-check.error'; + +import { Connection, MikroORM } from '@mikro-orm/core'; +import { TimeoutError } from '../../errors'; +import { + TimeoutError as PromiseTimeoutError, + promiseTimeout, + checkPackages, +} from '../../utils'; +import { HealthIndicator, HealthIndicatorResult } from '..'; + +export interface MikroOrmPingCheckSettings { + /** + * The connection which the ping check should get executed + */ + connection?: any; + /** + * The amount of time the check should require in ms + */ + timeout?: number; +} + +/** + * The MikroOrmHealthIndicator contains health indicators + * which are used for health checks related to MikroOrm + * + * @publicApi + * @module TerminusModule + */ +@Injectable({ scope: Scope.TRANSIENT }) +export class MikroOrmHealthIndicator extends HealthIndicator { + /** + * Initializes the MikroOrmHealthIndicator + * + * @param {ModuleRef} moduleRef The NestJS module reference + */ + constructor(private moduleRef: ModuleRef) { + super(); + this.checkDependantPackages(); + } + + /** + * Checks if responds in (default) 1000ms and + * returns a result object corresponding to the result + * @param key The key which will be used for the result object + * @param options The options for the ping + * + * @example + * MikroOrmHealthIndicator.pingCheck('database', { timeout: 1500 }); + */ + public async pingCheck( + key: string, + options: MikroOrmPingCheckSettings = {}, + ): Promise { + this.checkDependantPackages(); + + const connection = options.connection || this.getContextConnection(); + const timeout = options.timeout || 1000; + + if (!connection) { + return this.getStatus(key, false); + } + + try { + await this.pingDb(connection, timeout); + } catch (error) { + // Check if the error is a timeout error + if (error instanceof PromiseTimeoutError) { + throw new TimeoutError( + timeout, + this.getStatus(key, false, { + message: `timeout of ${timeout}ms exceeded`, + }), + ); + } + + // Check if the error is a connection not found error + if (error instanceof Error) { + throw new HealthCheckError( + error.message, + this.getStatus(key, false, { + message: error.message, + }), + ); + } + } + + return this.getStatus(key, true); + } + + private checkDependantPackages() { + checkPackages( + ['@mikro-orm/nestjs', '@mikro-orm/core'], + this.constructor.name, + ); + } + + /** + * Returns the connection of the current DI context + */ + private getContextConnection(): Connection | null { + const mikro = this.moduleRef.get(MikroORM, { strict: false }); + + const connection: Connection = mikro.em.getConnection(); + + if (!connection) { + return null; + } + return connection; + } + + /** + * Pings a mikro-orm connection + * + * @param connection The connection which the ping should get executed + * @param timeout The timeout how long the ping should maximum take + * + */ + private async pingDb(connection: Connection, timeout: number) { + let check: Promise; + const type = connection.getPlatform().getConfig().get('type'); + + switch (type) { + case 'postgresql': + case 'mysql': + case 'mariadb': + case 'sqlite': + check = connection.execute('SELECT 1'); + break; + default: + throw new NotImplementedException( + `${type} ping check is not implemented yet`, + ); + } + return await promiseTimeout(timeout, check); + } +} diff --git a/lib/health-indicator/health-indicators.provider.ts b/lib/health-indicator/health-indicators.provider.ts index 424290c2c..11b4703b1 100644 --- a/lib/health-indicator/health-indicators.provider.ts +++ b/lib/health-indicator/health-indicators.provider.ts @@ -11,6 +11,7 @@ import { HealthIndicator, GRPCHealthIndicator, } from '.'; +import { MikroOrmHealthIndicator } from './database/mikro-orm.health'; /** * All the health indicators terminus provides as array @@ -24,4 +25,5 @@ export const HEALTH_INDICATORS: Type[] = [ MemoryHealthIndicator, MicroserviceHealthIndicator, GRPCHealthIndicator, + MikroOrmHealthIndicator, ]; diff --git a/package-lock.json b/package-lock.json index 1bd0af704..1c4ebc823 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1124,6 +1124,69 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@mikro-orm/core": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/core/-/core-5.2.1.tgz", + "integrity": "sha512-p6+fdltIP8DfGSnxWO8yE5c71VsDxogSrk9BAlfry+4KQKQLWEiogJ+zuVCGVTB1xozczNCgRIA8ZYAjHnnBPg==", + "dev": true, + "requires": { + "dotenv": "16.0.1", + "escaya": "0.0.61", + "fs-extra": "10.1.0", + "globby": "11.0.4", + "mikro-orm": "^5.2.0", + "reflect-metadata": "0.1.13" + }, + "dependencies": { + "dotenv": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.1.tgz", + "integrity": "sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==", + "dev": true + }, + "globby": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", + "integrity": "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.1.1", + "ignore": "^5.1.4", + "merge2": "^1.3.0", + "slash": "^3.0.0" + } + } + } + }, + "@mikro-orm/knex": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/knex/-/knex-5.2.1.tgz", + "integrity": "sha512-Lw91BqftBZZqJ9LEA62x84TF8akC6H4UfRIAXPnE+95JM6kQ+kWy7WTCI2n6v/PrzFyeNShOvPRnHpdOXpnzKQ==", + "dev": true, + "requires": { + "fs-extra": "10.1.0", + "knex": "2.1.0", + "sqlstring": "2.3.3" + } + }, + "@mikro-orm/mysql": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/mysql/-/mysql-5.2.1.tgz", + "integrity": "sha512-Dni7uSJPjd+zV72HMHSPShf03No3eIP0+to1ff+9fuRR5vOejRcU7nXIOrSWwt/Rk4mAZqCdO+gdMc8qGRJrKQ==", + "dev": true, + "requires": { + "@mikro-orm/knex": "^5.2.1", + "mysql2": "2.3.3" + } + }, + "@mikro-orm/nestjs": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@mikro-orm/nestjs/-/nestjs-5.0.2.tgz", + "integrity": "sha512-YqRHOMSn5J2GMc1je4dgKja0hyuf2z73JxzCrfgVlqCzEFhwF7HeuJqVEyLVq1vALck13Kbov/ofCtlGR9LNuw==", + "dev": true + }, "@nestjs/axios": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-0.0.7.tgz", @@ -4945,6 +5008,12 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true }, + "escaya": { + "version": "0.0.61", + "resolved": "https://registry.npmjs.org/escaya/-/escaya-0.0.61.tgz", + "integrity": "sha512-WLLmvdG72Z0pCq8XUBd03GEJlAiMceXFanjdQeEzeSiuV1ZgrJqbkU7ZEe/hu0OsBlg5wLlySEeOvfzcGoO8mg==", + "dev": true + }, "escodegen": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", @@ -5250,6 +5319,12 @@ "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", "dev": true }, + "esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "dev": true + }, "espree": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.2.tgz", @@ -5701,18 +5776,16 @@ "requires": { "fastify-cors-deprecated": "npm:fastify-cors@6.0.3", "process-warning": "^1.0.0" - }, - "dependencies": { - "fastify-cors-deprecated": { - "version": "npm:fastify-cors@6.0.3", - "resolved": "https://registry.npmjs.org/fastify-cors/-/fastify-cors-6.0.3.tgz", - "integrity": "sha512-fMbXubKKyBHHCfSBtsCi3+7VyVRdhJQmGes5gM+eGKkRErCdm0NaYO0ozd31BQBL1ycoTIjbqOZhJo4RTF/Vlg==", - "dev": true, - "requires": { - "fastify-plugin": "^3.0.0", - "vary": "^1.1.2" - } - } + } + }, + "fastify-cors-deprecated": { + "version": "npm:fastify-cors@6.0.3", + "resolved": "https://registry.npmjs.org/fastify-cors/-/fastify-cors-6.0.3.tgz", + "integrity": "sha512-fMbXubKKyBHHCfSBtsCi3+7VyVRdhJQmGes5gM+eGKkRErCdm0NaYO0ozd31BQBL1ycoTIjbqOZhJo4RTF/Vlg==", + "dev": true, + "requires": { + "fastify-plugin": "^3.0.0", + "vary": "^1.1.2" } }, "fastify-formbody": { @@ -6478,6 +6551,12 @@ "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", "dev": true }, + "getopts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", + "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", + "dev": true + }, "git-hooks-list": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-1.0.3.tgz", @@ -8977,6 +9056,66 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, + "knex": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/knex/-/knex-2.1.0.tgz", + "integrity": "sha512-vVsnD6UJdSJy55TvCXfFF9syfwyXNxfE9mvr2hJL/4Obciy2EPGoqjDpgRSlMruHuPWDOeYAG25nyrGvU+jJog==", + "dev": true, + "requires": { + "colorette": "2.0.16", + "commander": "^9.1.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.5.0", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "dependencies": { + "commander": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.3.0.tgz", + "integrity": "sha512-hv95iU5uXPbK83mjrJKuZyFM/LBAoCV/XhVGkS5Je6tl7sxr6A0ITMw5WoRV46/UaJ46Nllm3Xt7IaJhXTIkzw==", + "dev": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true + }, + "rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "requires": { + "resolve": "^1.20.0" + } + }, + "tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", + "dev": true + } + } + }, "last-run": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/last-run/-/last-run-1.1.1.tgz", @@ -9859,6 +9998,12 @@ } } }, + "mikro-orm": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mikro-orm/-/mikro-orm-5.2.0.tgz", + "integrity": "sha512-t6up4g6PHN2Davm9djB3yGzdhgUGiPsX1JM6pFx7U3Qei1pFcmZtmZa65jthQOpTsMIFTXPSn8isp1NNemwm6w==", + "dev": true + }, "mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -13472,6 +13617,12 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "dev": true }, + "sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "dev": true + }, "stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -13793,6 +13944,12 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "dev": true + }, "terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", diff --git a/package.json b/package.json index d6e5cb850..48f7feafa 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,9 @@ "@commitlint/config-angular": "17.0.0", "@grpc/grpc-js": "1.6.7", "@grpc/proto-loader": "0.6.12", + "@mikro-orm/core": "^5.2.1", + "@mikro-orm/mysql": "^5.2.1", + "@mikro-orm/nestjs": "^5.0.2", "@nestjs/axios": "0.0.7", "@nestjs/common": "8.4.5", "@nestjs/core": "8.4.5",