-
Notifications
You must be signed in to change notification settings - Fork 0
Custom indicator api and health service #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
afcf4dd
2e42a1d
f856137
c74b8ae
41d7af3
8f9af5e
5dff0e2
af6f235
a5d8377
914f341
60eedae
f27a411
a3ff296
e732894
15f7b7f
686dc9e
90f4e9d
e0c0e53
de52d73
224ad90
831dcc1
5b5f872
bcdc00c
3cb5a91
dcf3e60
84b2254
b285d64
cbd5cf2
bdcd32c
881d0f2
9bfea9f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1 @@ | ||
| #!/usr/bin/env sh | ||
| . "$(dirname -- "$0")/_/husky.sh" | ||
|
|
||
| npx lint-staged | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,42 +1,80 @@ | ||
| // @ts-check | ||
| import eslint from "@eslint/js"; | ||
| import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; | ||
| import globals from "globals"; | ||
| import importPlugin from "eslint-plugin-import"; | ||
| import tseslint from "typescript-eslint"; | ||
|
|
||
| export default tseslint.config( | ||
| export default [ | ||
| { | ||
| ignores: ["eslint.config.mjs"], | ||
| ignores: [ | ||
| "dist/**", | ||
| "coverage/**", | ||
| "node_modules/**", | ||
| // Ignore all example files for CSR architecture | ||
| "src/example-kit.*", | ||
| "src/controllers/example.controller.ts", | ||
| "src/services/example.service.ts", | ||
| "src/entities/example.entity.ts", | ||
| "src/repositories/example.repository.ts", | ||
| "src/guards/example.guard.ts", | ||
| "src/decorators/example.decorator.ts", | ||
| "src/dto/create-example.dto.ts", | ||
| "src/dto/update-example.dto.ts", | ||
| ], | ||
| }, | ||
|
|
||
| eslint.configs.recommended, | ||
| ...tseslint.configs.recommendedTypeChecked, | ||
| eslintPluginPrettierRecommended, | ||
|
|
||
| // TypeScript ESLint (includes recommended rules) | ||
| ...tseslint.configs.recommended, | ||
|
|
||
| // Base TS rules (all TS files) | ||
| { | ||
| files: ["**/*.ts"], | ||
| languageOptions: { | ||
| globals: { | ||
| ...globals.node, | ||
| ...globals.jest, | ||
| }, | ||
| sourceType: "commonjs", | ||
| parser: tseslint.parser, | ||
| parserOptions: { | ||
| projectService: true, | ||
| project: "./tsconfig.eslint.json", | ||
| tsconfigRootDir: import.meta.dirname, | ||
| ecmaVersion: "latest", | ||
| sourceType: "module", | ||
| }, | ||
| globals: { ...globals.node, ...globals.jest }, | ||
| }, | ||
| plugins: { | ||
| "@typescript-eslint": tseslint.plugin, | ||
| import: importPlugin, | ||
| }, | ||
| rules: { | ||
| "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], | ||
| "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], | ||
|
|
||
| "import/no-duplicates": "error", | ||
| "import/order": [ | ||
| "error", | ||
| { | ||
| "newlines-between": "always", | ||
| alphabetize: { order: "asc", caseInsensitive: true }, | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
|
|
||
| // Architecture boundary: core must not import Nest | ||
| { | ||
| files: ["src/core/**/*.ts"], | ||
| rules: { | ||
| "@typescript-eslint/no-explicit-any": "off", | ||
| "@typescript-eslint/no-floating-promises": "warn", | ||
| "@typescript-eslint/no-unsafe-argument": "warn", | ||
| "@typescript-eslint/no-unused-vars": [ | ||
| "no-restricted-imports": [ | ||
| "error", | ||
| { | ||
| argsIgnorePattern: "^_", | ||
| varsIgnorePattern: "^_", | ||
| patterns: [ | ||
| { | ||
| group: ["@nestjs/*"], | ||
| message: "Do not import NestJS in core/. Keep core framework-free.", | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| "no-unused-vars": "off", | ||
| }, | ||
| }, | ||
| ); | ||
| ]; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { ServiceUnavailableException } from "@nestjs/common"; | ||
| import type { TestingModule } from "@nestjs/testing"; | ||
| import { Test } from "@nestjs/testing"; | ||
| import { HealthService } from "@services/health.service"; | ||
| import type { HealthCheckResult } from "@services/health.service"; | ||
|
|
||
| import { createHealthController } from "./health.controller"; | ||
|
|
||
| // ── Helpers ────────────────────────────────────────────────────────────────── | ||
|
|
||
| const makeService = (liveness: "ok" | "error", readiness: "ok" | "error") => | ||
| ({ | ||
| checkLiveness: jest.fn().mockResolvedValue({ status: liveness, indicators: [] }), | ||
| checkReadiness: jest.fn().mockResolvedValue({ status: readiness, indicators: [] }), | ||
| }) as unknown as HealthService; | ||
|
|
||
| interface HealthControllerInstance { | ||
| live(): Promise<HealthCheckResult>; | ||
| ready(): Promise<HealthCheckResult>; | ||
| } | ||
|
|
||
| async function buildController( | ||
| liveness: "ok" | "error", | ||
| readiness: "ok" | "error", | ||
| ): Promise<HealthControllerInstance> { | ||
| const HealthController = createHealthController("health"); | ||
| const moduleRef: TestingModule = await Test.createTestingModule({ | ||
| controllers: [HealthController], | ||
| providers: [{ provide: HealthService, useValue: makeService(liveness, readiness) }], | ||
| }).compile(); | ||
| return moduleRef.get<HealthControllerInstance>(HealthController as never); | ||
| } | ||
|
|
||
| // ── Tests ───────────────────────────────────────────────────────────────────── | ||
|
|
||
| describe("HealthController (factory)", () => { | ||
| describe("GET /health/live", () => { | ||
| it("returns result when all liveness indicators are up", async () => { | ||
| const controller = await buildController("ok", "ok"); | ||
| const result = await controller.live(); | ||
| expect(result.status).toBe("ok"); | ||
| }); | ||
|
|
||
| it("throws ServiceUnavailableException (503) when any liveness indicator is down", async () => { | ||
| const controller = await buildController("error", "ok"); | ||
| await expect(controller.live()).rejects.toThrow(ServiceUnavailableException); | ||
| }); | ||
| }); | ||
|
|
||
| describe("GET /health/ready", () => { | ||
| it("returns result when all readiness indicators are up", async () => { | ||
| const controller = await buildController("ok", "ok"); | ||
| const result = await controller.ready(); | ||
| expect(result.status).toBe("ok"); | ||
| }); | ||
|
|
||
| it("throws ServiceUnavailableException (503) when any readiness indicator is down", async () => { | ||
| const controller = await buildController("ok", "error"); | ||
| await expect(controller.ready()).rejects.toThrow(ServiceUnavailableException); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { | ||
| Controller, | ||
| Get, | ||
| HttpCode, | ||
| HttpStatus, | ||
| ServiceUnavailableException, | ||
| Type, | ||
| } from "@nestjs/common"; | ||
| import { HealthService } from "@services/health.service"; | ||
| import type { HealthCheckResult } from "@services/health.service"; | ||
|
|
||
| /** | ||
| * Factory that returns a NestJS controller class configured with the | ||
| * caller-supplied `path` prefix (e.g. `"health"`). | ||
| * | ||
| * Platform-agnostic — works with Express and Fastify. | ||
| * Returns 200 when all indicators are "up", | ||
| * throws ServiceUnavailableException (503) when any indicator is "down". | ||
| */ | ||
| export function createHealthController(path: string): Type<unknown> { | ||
| @Controller(path) | ||
| class HealthController { | ||
| constructor(private readonly healthService: HealthService) {} | ||
|
|
||
| @Get("live") | ||
| @HttpCode(HttpStatus.OK) | ||
| async live(): Promise<HealthCheckResult> { | ||
| const result = await this.healthService.checkLiveness(); | ||
| if (result.status === "error") throw new ServiceUnavailableException(result); | ||
| return result; | ||
| } | ||
|
|
||
| @Get("ready") | ||
| @HttpCode(HttpStatus.OK) | ||
| async ready(): Promise<HealthCheckResult> { | ||
| const result = await this.healthService.checkReadiness(); | ||
| if (result.status === "error") throw new ServiceUnavailableException(result); | ||
| return result; | ||
| } | ||
| } | ||
|
|
||
| return HealthController; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import "reflect-metadata"; | ||
| import { HEALTH_INDICATOR_METADATA, HealthIndicator } from "./health-indicator.decorator"; | ||
|
|
||
| class SomeIndicator {} | ||
|
Check warning on line 4 in src/decorators/health-indicator.decorator.spec.ts
|
||
| class AnotherIndicator {} | ||
|
Check warning on line 5 in src/decorators/health-indicator.decorator.spec.ts
|
||
| class UndecotratedIndicator {} | ||
|
Check warning on line 6 in src/decorators/health-indicator.decorator.spec.ts
|
||
|
|
||
|
Comment on lines
+4
to
+7
|
||
| @HealthIndicator("liveness") | ||
| class LivenessIndicator extends SomeIndicator {} | ||
|
|
||
| @HealthIndicator("readiness") | ||
| class ReadinessIndicator extends AnotherIndicator {} | ||
|
|
||
| describe("@HealthIndicator decorator", () => { | ||
| it("attaches liveness metadata to the target class", () => { | ||
| const scope = Reflect.getMetadata(HEALTH_INDICATOR_METADATA, LivenessIndicator); | ||
| expect(scope).toBe("liveness"); | ||
| }); | ||
|
|
||
| it("attaches readiness metadata to the target class", () => { | ||
| const scope = Reflect.getMetadata(HEALTH_INDICATOR_METADATA, ReadinessIndicator); | ||
| expect(scope).toBe("readiness"); | ||
| }); | ||
|
|
||
| it("returns undefined for undecorated classes", () => { | ||
| const scope = Reflect.getMetadata(HEALTH_INDICATOR_METADATA, UndecotratedIndicator); | ||
| expect(scope).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("does not affect other classes when decorating one", () => { | ||
| const livScope = Reflect.getMetadata(HEALTH_INDICATOR_METADATA, LivenessIndicator); | ||
| const readScope = Reflect.getMetadata(HEALTH_INDICATOR_METADATA, ReadinessIndicator); | ||
| expect(livScope).toBe("liveness"); | ||
| expect(readScope).toBe("readiness"); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Husky hook header was removed. Without the shebang and sourcing
./_/husky.sh, the hook can fail in some environments (e.g., PATH not set to include local node binaries). Restore the standard Husky pre-commit header like the pre-push hook uses.