diff --git a/lib/plugins/healthcheck/commonHealthcheckPlugin.spec.ts b/lib/plugins/healthcheck/commonHealthcheckPlugin.spec.ts index b4f5ef9..932b7da 100644 --- a/lib/plugins/healthcheck/commonHealthcheckPlugin.spec.ts +++ b/lib/plugins/healthcheck/commonHealthcheckPlugin.spec.ts @@ -1,319 +1,322 @@ import type { FastifyInstance } from 'fastify' import fastify from 'fastify' -import type { HealthChecker } from './healthcheckCommons' -import { commonHealthcheckPlugin, type CommonHealthcheckPluginOptions } from './commonHealthcheckPlugin.js' import { describe } from 'vitest' +import { + type CommonHealthcheckPluginOptions, + commonHealthcheckPlugin, +} from './commonHealthcheckPlugin.js' +import type { HealthChecker } from './healthcheckCommons' const positiveHealthcheckChecker: HealthChecker = () => { - return Promise.resolve({ result: true }) + return Promise.resolve({ result: true }) } const negativeHealthcheckChecker: HealthChecker = () => { - return Promise.resolve({ error: new Error('Something exploded') }) + return Promise.resolve({ error: new Error('Something exploded') }) } async function initApp(opts: CommonHealthcheckPluginOptions) { - const app = fastify() - await app.register(commonHealthcheckPlugin, opts) - await app.ready() - return app + const app = fastify() + await app.register(commonHealthcheckPlugin, opts) + await app.ready() + return app } const PUBLIC_ENDPOINT = '/' const PRIVATE_ENDPOINT = '/health' describe('commonHealthcheckPlugin', () => { - let app: FastifyInstance - afterAll(async () => { - await app.close() - }) + let app: FastifyInstance + afterAll(async () => { + await app.close() + }) - describe('public endpoint', () => { - it('returns a heartbeat', async () => { - app = await initApp({ healthChecks: [] }) + describe('public endpoint', () => { + it('returns a heartbeat', async () => { + app = await initApp({ healthChecks: [] }) - const response = await app.inject().get(PUBLIC_ENDPOINT).end() - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ heartbeat: 'HEALTHY', checks: {} }) - }) + const response = await app.inject().get(PUBLIC_ENDPOINT).end() + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ heartbeat: 'HEALTHY', checks: {} }) + }) - it('returns custom heartbeat', async () => { - app = await initApp({ responsePayload: { version: 1 }, healthChecks: [] }) + it('returns custom heartbeat', async () => { + app = await initApp({ responsePayload: { version: 1 }, healthChecks: [] }) - const response = await app.inject().get(PUBLIC_ENDPOINT).end() - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - heartbeat: 'HEALTHY', - version: 1, - checks: {}, - }) - }) + const response = await app.inject().get(PUBLIC_ENDPOINT).end() + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + heartbeat: 'HEALTHY', + version: 1, + checks: {}, + }) + }) - it('returns false if one mandatory healthcheck fails', async () => { - app = await initApp({ - responsePayload: { version: 1 }, - healthChecks: [ - { - name: 'check1', - isMandatory: true, - checker: negativeHealthcheckChecker, - }, - { - name: 'check2', - isMandatory: true, - checker: positiveHealthcheckChecker, - }, - ], - }) + it('returns false if one mandatory healthcheck fails', async () => { + app = await initApp({ + responsePayload: { version: 1 }, + healthChecks: [ + { + name: 'check1', + isMandatory: true, + checker: negativeHealthcheckChecker, + }, + { + name: 'check2', + isMandatory: true, + checker: positiveHealthcheckChecker, + }, + ], + }) - const response = await app.inject().get(PUBLIC_ENDPOINT).end() - expect(response.statusCode).toBe(500) - expect(response.json()).toEqual({ - heartbeat: 'FAIL', - version: 1, - checks: { - aggregation: 'FAIL', - }, - }) - }) + const response = await app.inject().get(PUBLIC_ENDPOINT).end() + expect(response.statusCode).toBe(500) + expect(response.json()).toEqual({ + heartbeat: 'FAIL', + version: 1, + checks: { + aggregation: 'FAIL', + }, + }) + }) - it('returns partial if optional healthcheck fails', async () => { - app = await initApp({ - responsePayload: { version: 1 }, - healthChecks: [ - { - name: 'check1', - isMandatory: false, - checker: negativeHealthcheckChecker, - }, - { - name: 'check2', - isMandatory: true, - checker: positiveHealthcheckChecker, - }, - ], - }) + it('returns partial if optional healthcheck fails', async () => { + app = await initApp({ + responsePayload: { version: 1 }, + healthChecks: [ + { + name: 'check1', + isMandatory: false, + checker: negativeHealthcheckChecker, + }, + { + name: 'check2', + isMandatory: true, + checker: positiveHealthcheckChecker, + }, + ], + }) - const response = await app.inject().get(PUBLIC_ENDPOINT).end() - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - heartbeat: 'PARTIALLY_HEALTHY', - version: 1, - checks: { - aggregation: 'PARTIALLY_HEALTHY', - }, - }) - }) + const response = await app.inject().get(PUBLIC_ENDPOINT).end() + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + heartbeat: 'PARTIALLY_HEALTHY', + version: 1, + checks: { + aggregation: 'PARTIALLY_HEALTHY', + }, + }) + }) - it('returns true if all healthchecks pass', async () => { - app = await initApp({ - responsePayload: { version: 1 }, - healthChecks: [ - { - name: 'check1', - isMandatory: true, - checker: positiveHealthcheckChecker, - }, - { - name: 'check2', - isMandatory: true, - checker: positiveHealthcheckChecker, - }, - ], - }) + it('returns true if all healthchecks pass', async () => { + app = await initApp({ + responsePayload: { version: 1 }, + healthChecks: [ + { + name: 'check1', + isMandatory: true, + checker: positiveHealthcheckChecker, + }, + { + name: 'check2', + isMandatory: true, + checker: positiveHealthcheckChecker, + }, + ], + }) - const response = await app.inject().get(PUBLIC_ENDPOINT).end() - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - heartbeat: 'HEALTHY', - version: 1, - checks: { - aggregation: 'HEALTHY', - }, - }) - }) + const response = await app.inject().get(PUBLIC_ENDPOINT).end() + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + heartbeat: 'HEALTHY', + version: 1, + checks: { + aggregation: 'HEALTHY', + }, + }) + }) - it('omits extra info if data provider is set', async () => { - app = await initApp({ - responsePayload: { version: 1 }, - healthChecks: [ - { - name: 'check1', - isMandatory: true, - checker: positiveHealthcheckChecker, - }, - ], - infoProviders: [ - { - name: 'provider1', - dataResolver: () => { - return { - someData: 1, - } - }, - }, - ], - }) + it('omits extra info if data provider is set', async () => { + app = await initApp({ + responsePayload: { version: 1 }, + healthChecks: [ + { + name: 'check1', + isMandatory: true, + checker: positiveHealthcheckChecker, + }, + ], + infoProviders: [ + { + name: 'provider1', + dataResolver: () => { + return { + someData: 1, + } + }, + }, + ], + }) - const response = await app.inject().get(PUBLIC_ENDPOINT).end() - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - heartbeat: 'HEALTHY', - version: 1, - checks: { - aggregation: 'HEALTHY', - }, - }) - }) + const response = await app.inject().get(PUBLIC_ENDPOINT).end() + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + heartbeat: 'HEALTHY', + version: 1, + checks: { + aggregation: 'HEALTHY', + }, + }) }) + }) - describe('private endpoint', () => { - it('returns a heartbeat', async () => { - app = await initApp({ healthChecks: [] }) + describe('private endpoint', () => { + it('returns a heartbeat', async () => { + app = await initApp({ healthChecks: [] }) - const response = await app.inject().get(PRIVATE_ENDPOINT).end() - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ heartbeat: 'HEALTHY', checks: {} }) - }) + const response = await app.inject().get(PRIVATE_ENDPOINT).end() + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ heartbeat: 'HEALTHY', checks: {} }) + }) - it('returns custom heartbeat', async () => { - app = await initApp({ responsePayload: { version: 1 }, healthChecks: [] }) + it('returns custom heartbeat', async () => { + app = await initApp({ responsePayload: { version: 1 }, healthChecks: [] }) - const response = await app.inject().get(PRIVATE_ENDPOINT).end() - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - heartbeat: 'HEALTHY', - version: 1, - checks: {}, - }) - }) + const response = await app.inject().get(PRIVATE_ENDPOINT).end() + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + heartbeat: 'HEALTHY', + version: 1, + checks: {}, + }) + }) - it('returns false if one mandatory healthcheck fails', async () => { - app = await initApp({ - responsePayload: { version: 1 }, - healthChecks: [ - { - name: 'check1', - isMandatory: true, - checker: negativeHealthcheckChecker, - }, - { - name: 'check2', - isMandatory: true, - checker: positiveHealthcheckChecker, - }, - ], - }) + it('returns false if one mandatory healthcheck fails', async () => { + app = await initApp({ + responsePayload: { version: 1 }, + healthChecks: [ + { + name: 'check1', + isMandatory: true, + checker: negativeHealthcheckChecker, + }, + { + name: 'check2', + isMandatory: true, + checker: positiveHealthcheckChecker, + }, + ], + }) - const response = await app.inject().get(PRIVATE_ENDPOINT).end() - expect(response.statusCode).toBe(500) - expect(response.json()).toEqual({ - heartbeat: 'FAIL', - version: 1, - checks: { - check1: 'FAIL', - check2: 'HEALTHY', - }, - }) - }) + const response = await app.inject().get(PRIVATE_ENDPOINT).end() + expect(response.statusCode).toBe(500) + expect(response.json()).toEqual({ + heartbeat: 'FAIL', + version: 1, + checks: { + check1: 'FAIL', + check2: 'HEALTHY', + }, + }) + }) - it('returns partial if optional healthcheck fails', async () => { - app = await initApp({ - responsePayload: { version: 1 }, - healthChecks: [ - { - name: 'check1', - isMandatory: false, - checker: negativeHealthcheckChecker, - }, - { - name: 'check2', - isMandatory: true, - checker: positiveHealthcheckChecker, - }, - ], - }) + it('returns partial if optional healthcheck fails', async () => { + app = await initApp({ + responsePayload: { version: 1 }, + healthChecks: [ + { + name: 'check1', + isMandatory: false, + checker: negativeHealthcheckChecker, + }, + { + name: 'check2', + isMandatory: true, + checker: positiveHealthcheckChecker, + }, + ], + }) - const response = await app.inject().get(PRIVATE_ENDPOINT).end() - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - heartbeat: 'PARTIALLY_HEALTHY', - version: 1, - checks: { - check1: 'FAIL', - check2: 'HEALTHY', - }, - }) - }) + const response = await app.inject().get(PRIVATE_ENDPOINT).end() + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + heartbeat: 'PARTIALLY_HEALTHY', + version: 1, + checks: { + check1: 'FAIL', + check2: 'HEALTHY', + }, + }) + }) - it('returns true if all healthchecks pass', async () => { - app = await initApp({ - responsePayload: { version: 1 }, - healthChecks: [ - { - name: 'check1', - isMandatory: true, - checker: positiveHealthcheckChecker, - }, - { - name: 'check2', - isMandatory: true, - checker: positiveHealthcheckChecker, - }, - ], - }) + it('returns true if all healthchecks pass', async () => { + app = await initApp({ + responsePayload: { version: 1 }, + healthChecks: [ + { + name: 'check1', + isMandatory: true, + checker: positiveHealthcheckChecker, + }, + { + name: 'check2', + isMandatory: true, + checker: positiveHealthcheckChecker, + }, + ], + }) - const response = await app.inject().get(PRIVATE_ENDPOINT).end() - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - heartbeat: 'HEALTHY', - version: 1, - checks: { - check1: 'HEALTHY', - check2: 'HEALTHY', - }, - }) - }) + const response = await app.inject().get(PRIVATE_ENDPOINT).end() + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + heartbeat: 'HEALTHY', + version: 1, + checks: { + check1: 'HEALTHY', + check2: 'HEALTHY', + }, + }) + }) - it('returns extra info if data provider is set', async () => { - app = await initApp({ - responsePayload: { version: 1 }, - healthChecks: [ - { - name: 'check1', - isMandatory: true, - checker: positiveHealthcheckChecker, - }, - ], - infoProviders: [ - { - name: 'provider1', - dataResolver: () => { - return { - someData: 1, - } - }, - }, - ], - }) + it('returns extra info if data provider is set', async () => { + app = await initApp({ + responsePayload: { version: 1 }, + healthChecks: [ + { + name: 'check1', + isMandatory: true, + checker: positiveHealthcheckChecker, + }, + ], + infoProviders: [ + { + name: 'provider1', + dataResolver: () => { + return { + someData: 1, + } + }, + }, + ], + }) - const response = await app.inject().get(PRIVATE_ENDPOINT).end() - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - heartbeat: 'HEALTHY', - version: 1, - checks: { - check1: 'HEALTHY', - }, - extraInfo: [ - { - name: 'provider1', - value: { - someData: 1, - }, - }, - ], - }) - }) + const response = await app.inject().get(PRIVATE_ENDPOINT).end() + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + heartbeat: 'HEALTHY', + version: 1, + checks: { + check1: 'HEALTHY', + }, + extraInfo: [ + { + name: 'provider1', + value: { + someData: 1, + }, + }, + ], + }) }) + }) }) diff --git a/lib/plugins/healthcheck/commonHealthcheckPlugin.ts b/lib/plugins/healthcheck/commonHealthcheckPlugin.ts index 8312dfd..a401b4a 100644 --- a/lib/plugins/healthcheck/commonHealthcheckPlugin.ts +++ b/lib/plugins/healthcheck/commonHealthcheckPlugin.ts @@ -5,170 +5,175 @@ import type { AnyFastifyInstance } from '../pluginsCommon' import type { HealthChecker } from './healthcheckCommons' export interface CommonHealthcheckPluginOptions { - responsePayload?: Record - logLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent' - healthChecks: readonly HealthCheck[] - infoProviders?: readonly InfoProvider[] + responsePayload?: Record + logLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent' + healthChecks: readonly HealthCheck[] + infoProviders?: readonly InfoProvider[] } type HealthcheckRouteOptions = { - url: string - useHealthcheckAggregations: boolean + url: string + useHealthcheckAggregations: boolean } type HealthcheckResult = { - name: string - isMandatory: boolean - result: Either + name: string + isMandatory: boolean + result: Either } type ResolvedHealthcheckResponse = { - isFullyHealthy: boolean - isPartiallyHealthy: boolean - healthChecks: Record + isFullyHealthy: boolean + isPartiallyHealthy: boolean + healthChecks: Record } export type InfoProvider = { - name: string - dataResolver: () => Record + name: string + dataResolver: () => Record } export type HealthCheck = { - name: string - isMandatory: boolean - checker: HealthChecker + name: string + isMandatory: boolean + checker: HealthChecker } function resolveHealthcheckResults( - results: HealthcheckResult[], - opts: CommonHealthcheckPluginOptions, - routeOpts: HealthcheckRouteOptions, + results: HealthcheckResult[], + opts: CommonHealthcheckPluginOptions, + routeOpts: HealthcheckRouteOptions, ): ResolvedHealthcheckResponse { - const healthChecks: Record = {} - - if (routeOpts.useHealthcheckAggregations) { - // Use aggregation to determine the overall health of the service - // Omit separate healthcheck results from the response - const isFullyHealthy = results.every((entry) => !entry.result.error) - const isPartiallyHealthy = results.every((entry) => !entry.result.error || !entry.isMandatory) - healthChecks.aggregation = isFullyHealthy ? 'HEALTHY' : (isPartiallyHealthy ? 'PARTIALLY_HEALTHY' : 'FAIL') - - return { - isFullyHealthy, - isPartiallyHealthy, - healthChecks, - } - } + const healthChecks: Record = {} + + if (routeOpts.useHealthcheckAggregations) { + // Use aggregation to determine the overall health of the service + // Omit separate healthcheck results from the response + const isFullyHealthy = results.every((entry) => !entry.result.error) + const isPartiallyHealthy = results.every((entry) => !entry.result.error || !entry.isMandatory) + healthChecks.aggregation = isFullyHealthy + ? 'HEALTHY' + : isPartiallyHealthy + ? 'PARTIALLY_HEALTHY' + : 'FAIL' - let isFullyHealthy = true - let isPartiallyHealthy = false - - // Return detailed healthcheck results - for (let i = 0; i < results.length; i++) { - const entry = results[i] - healthChecks[entry.name] = entry.result.error ? 'FAIL' : 'HEALTHY' - if (entry.result.error && opts.healthChecks[i].isMandatory) { - isFullyHealthy = false - isPartiallyHealthy = false - } - - // Check if we are only partially healthy (only optional dependencies are failing) - if (isFullyHealthy && entry.result.error && !opts.healthChecks[i].isMandatory) { - isFullyHealthy = false - isPartiallyHealthy = true - } + return { + isFullyHealthy, + isPartiallyHealthy, + healthChecks, + } + } + + let isFullyHealthy = true + let isPartiallyHealthy = false + + // Return detailed healthcheck results + for (let i = 0; i < results.length; i++) { + const entry = results[i] + healthChecks[entry.name] = entry.result.error ? 'FAIL' : 'HEALTHY' + if (entry.result.error && opts.healthChecks[i].isMandatory) { + isFullyHealthy = false + isPartiallyHealthy = false } - return { - isFullyHealthy, - isPartiallyHealthy, - healthChecks, + // Check if we are only partially healthy (only optional dependencies are failing) + if (isFullyHealthy && entry.result.error && !opts.healthChecks[i].isMandatory) { + isFullyHealthy = false + isPartiallyHealthy = true } + } + + return { + isFullyHealthy, + isPartiallyHealthy, + healthChecks, + } } function addRoute( - app: AnyFastifyInstance, - opts: CommonHealthcheckPluginOptions, - routeOpts: HealthcheckRouteOptions, + app: AnyFastifyInstance, + opts: CommonHealthcheckPluginOptions, + routeOpts: HealthcheckRouteOptions, ): void { - const responsePayload = opts.responsePayload ?? {} - - app.route({ - url: routeOpts.url, - method: 'GET', - logLevel: opts.logLevel ?? 'info', - schema: { - // hide route from swagger plugins - // @ts-expect-error - hide: true, - }, - - handler: async (_, reply) => { - let isFullyHealthy = true - let isPartiallyHealthy = false - let healthChecks: Record = {} - - if (opts.healthChecks.length) { - const results = await Promise.all( - opts.healthChecks.map(async (healthcheck) => { - const result = await healthcheck.checker(app) - if (result.error) { - app.log.error(result.error, `${healthcheck.name} healthcheck has failed`) - } - return { - name: healthcheck.name, - result, - isMandatory: healthcheck.isMandatory, - } - }), - ) - - const resolvedHealthcheckResponse = resolveHealthcheckResults(results, opts, routeOpts) - healthChecks = resolvedHealthcheckResponse.healthChecks - isFullyHealthy = resolvedHealthcheckResponse.isFullyHealthy - isPartiallyHealthy = resolvedHealthcheckResponse.isPartiallyHealthy - } + const responsePayload = opts.responsePayload ?? {} + + app.route({ + url: routeOpts.url, + method: 'GET', + logLevel: opts.logLevel ?? 'info', + schema: { + // hide route from swagger plugins + // @ts-expect-error + hide: true, + }, - const extraInfo = opts.infoProviders && !routeOpts.useHealthcheckAggregations - ? opts.infoProviders.map((infoProvider) => { - return { - name: infoProvider.name, - value: infoProvider.dataResolver(), - } - }) - : undefined - - return reply.status(isFullyHealthy || isPartiallyHealthy ? 200 : 500).send({ - ...responsePayload, - checks: healthChecks, - ...(extraInfo && { extraInfo }), - heartbeat: isFullyHealthy ? 'HEALTHY' : isPartiallyHealthy ? 'PARTIALLY_HEALTHY' : 'FAIL', + handler: async (_, reply) => { + let isFullyHealthy = true + let isPartiallyHealthy = false + let healthChecks: Record = {} + + if (opts.healthChecks.length) { + const results = await Promise.all( + opts.healthChecks.map(async (healthcheck) => { + const result = await healthcheck.checker(app) + if (result.error) { + app.log.error(result.error, `${healthcheck.name} healthcheck has failed`) + } + return { + name: healthcheck.name, + result, + isMandatory: healthcheck.isMandatory, + } + }), + ) + + const resolvedHealthcheckResponse = resolveHealthcheckResults(results, opts, routeOpts) + healthChecks = resolvedHealthcheckResponse.healthChecks + isFullyHealthy = resolvedHealthcheckResponse.isFullyHealthy + isPartiallyHealthy = resolvedHealthcheckResponse.isPartiallyHealthy + } + + const extraInfo = + opts.infoProviders && !routeOpts.useHealthcheckAggregations + ? opts.infoProviders.map((infoProvider) => { + return { + name: infoProvider.name, + value: infoProvider.dataResolver(), + } }) - }, - }) + : undefined + + return reply.status(isFullyHealthy || isPartiallyHealthy ? 200 : 500).send({ + ...responsePayload, + checks: healthChecks, + ...(extraInfo && { extraInfo }), + heartbeat: isFullyHealthy ? 'HEALTHY' : isPartiallyHealthy ? 'PARTIALLY_HEALTHY' : 'FAIL', + }) + }, + }) } function plugin( - app: AnyFastifyInstance, - opts: CommonHealthcheckPluginOptions, - done: () => void, + app: AnyFastifyInstance, + opts: CommonHealthcheckPluginOptions, + done: () => void, ): void { - addRoute(app, opts, { - url: '/', - useHealthcheckAggregations: true, - }) - addRoute(app, opts, { - url: '/health', - useHealthcheckAggregations: false, - }) - - done() + addRoute(app, opts, { + url: '/', + useHealthcheckAggregations: true, + }) + addRoute(app, opts, { + url: '/health', + useHealthcheckAggregations: false, + }) + + done() } export const commonHealthcheckPlugin: FastifyPluginCallback = fp( - plugin, - { - fastify: '5.x', - name: 'common-healthcheck-plugin', - }, + plugin, + { + fastify: '5.x', + name: 'common-healthcheck-plugin', + }, )