diff --git a/.changeset/config.json b/.changeset/config.json index feddcf6..e570bee 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,6 +8,6 @@ "baseBranch": "develop", "updateInternalDependencies": "patch", "ignore": [], - "repo": "ciscode/nest-js-developer-kit", + "repo": "CISCODE-MA/ConfigKit", "preState": null } diff --git a/.changeset/thick-maps-raise.md b/.changeset/thick-maps-raise.md deleted file mode 100644 index 0b3a593..0000000 --- a/.changeset/thick-maps-raise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@ciscode/nestjs-developerkit": patch ---- - -Patch 1, testing Changeset Automation diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f5c78ba..2336dd7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,20 +1,20 @@ version: 2 updates: - package-ecosystem: npm - directory: '/' + directory: "/" schedule: interval: monthly open-pull-requests-limit: 1 groups: npm-dependencies: patterns: - - '*' + - "*" assignees: - CISCODE-MA/devops labels: - - 'dependencies' - - 'npm' + - "dependencies" + - "npm" commit-message: - prefix: 'chore(deps)' - include: 'scope' + prefix: "chore(deps)" + include: "scope" rebase-strategy: auto diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3b6ae92..7b5f137 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -60,9 +60,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' - registry-url: 'https://registry.npmjs.org' - cache: 'npm' + node-version: "22" + registry-url: "https://registry.npmjs.org" + cache: "npm" - name: Install dependencies run: npm ci diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index f94190f..25b39b2 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -17,9 +17,9 @@ jobs: # Config stays in the workflow file (token stays in repo secrets) env: - SONAR_HOST_URL: 'https://sonarcloud.io' - SONAR_ORGANIZATION: 'ciscode' - SONAR_PROJECT_KEY: 'CISCODE-MA_...' + SONAR_HOST_URL: "https://sonarcloud.io" + SONAR_ORGANIZATION: "ciscode" + SONAR_PROJECT_KEY: "CISCODE-MA_..." steps: - name: Checkout @@ -30,8 +30,8 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '22' - cache: 'npm' + node-version: "22" + cache: "npm" - name: Install run: npm ci diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..99167bd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# @ciscode/config-kit + +## 0.3.0 + +### Minor Changes + +- Added ConfigService.getAll(), InferConfig type helper, useClass/useExisting support in registerAsync, and removed template boilerplate from the published package + +## 0.2.0 + +### Minor Changes + +- 7bcdd66: Initial release: Zod-based env validation, ConfigModule (register/registerAsync/forRoot), typed ConfigService, per-module namespaced config with defineNamespace and @InjectConfig decorator diff --git a/README.md b/README.md index 79260b6..62d368d 100644 --- a/README.md +++ b/README.md @@ -1,324 +1,284 @@ -# NestJS Developer Kit (Template) +# @ciscode/config-kit -A professional template for creating reusable NestJS npm packages with best practices, standardized structure, and AI-friendly development workflow. +Typed, Zod-based environment configuration for NestJS. +Define your env shape once with a Zod schema โ€” ConfigKit validates `process.env` at startup, fails loudly on misconfiguration, and gives you fully-typed, `undefined`-free config throughout your app. -## ๐ŸŽฏ What You Get +## Why config-kit? -- โœ… **CSR Architecture** - Controller-Service-Repository pattern -- โœ… **TypeScript** - Strict mode with path aliases -- โœ… **Testing** - Jest with 80% coverage threshold -- โœ… **Code Quality** - ESLint + Prettier + Husky -- โœ… **Versioning** - Changesets for semantic versioning -- โœ… **CI/CD** - GitHub Actions workflows -- โœ… **Documentation** - Complete Copilot instructions -- โœ… **Examples** - Full working examples for all layers +| Problem with `@nestjs/config` | What config-kit does | +| -------------------------------------------------- | ---------------------------------------------------------------- | +| `config.get('PORT')` returns `string \| undefined` | `config.get('PORT')` returns `number` (inferred from Zod schema) | +| Validation is optional and buried in code | **App fails to start** if any env var is wrong or missing | +| Feature modules can't own their config slice | `defineNamespace()` gives each module its own typed scope | + +--- ## ๐Ÿ“ฆ Installation ```bash -# Clone this template -git clone https://github.com/CISCODE-MA/NestJs-DeveloperKit.git my-module -cd my-module +npm install @ciscode/config-kit zod @nestjs/common @nestjs/core +``` -# Install dependencies -npm install +**Peer dependencies** (install alongside this package): -# Start developing -npm run build -npm test -``` +| Package | Version | +| ---------------- | -------------- | +| `@nestjs/common` | `^10 \|\| ^11` | +| `@nestjs/core` | `^10 \|\| ^11` | +| `zod` | `^3 \|\| ^4` | -## ๐Ÿ—๏ธ Architecture +--- -``` -src/ - โ”œโ”€โ”€ index.ts # PUBLIC API exports - โ”œโ”€โ”€ {module-name}.module.ts # NestJS module definition - โ”‚ - โ”œโ”€โ”€ controllers/ # HTTP Layer - โ”‚ โ””โ”€โ”€ example.controller.ts - โ”‚ - โ”œโ”€โ”€ services/ # Business Logic - โ”‚ โ””โ”€โ”€ example.service.ts - โ”‚ - โ”œโ”€โ”€ entities/ # Domain Models - โ”‚ โ””โ”€โ”€ example.entity.ts - โ”‚ - โ”œโ”€โ”€ repositories/ # Data Access - โ”‚ โ””โ”€โ”€ example.repository.ts - โ”‚ - โ”œโ”€โ”€ guards/ # Auth Guards - โ”‚ โ””โ”€โ”€ example.guard.ts - โ”‚ - โ”œโ”€โ”€ decorators/ # Custom Decorators - โ”‚ โ””โ”€โ”€ example.decorator.ts - โ”‚ - โ”œโ”€โ”€ dto/ # Data Transfer Objects - โ”‚ โ”œโ”€โ”€ create-example.dto.ts - โ”‚ โ””โ”€โ”€ update-example.dto.ts - โ”‚ - โ”œโ”€โ”€ filters/ # Exception Filters - โ”œโ”€โ”€ middleware/ # Middleware - โ”œโ”€โ”€ config/ # Configuration - โ””โ”€โ”€ utils/ # Utilities -``` +## Quick Start -## ๐Ÿš€ Usage +### 1. Define your config shape with `defineConfig` -### 1. Customize Your Module +```typescript +// src/app.config.ts +import { defineConfig } from "@ciscode/config-kit"; +import { z } from "zod"; + +export const appConfig = defineConfig( + z.object({ + // z.coerce.number() converts the env string "3000" โ†’ number 3000 + PORT: z.coerce.number().default(3000), + DATABASE_URL: z.string().url(), + JWT_SECRET: z.string().min(32), + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + }), +); +``` + +### 2. Register globally with `ConfigModule.forRoot` ```typescript -// src/example-kit.module.ts -import { Module, DynamicModule } from "@nestjs/common"; -import { ExampleService } from "@services/example.service"; - -@Module({}) -export class ExampleKitModule { - static forRoot(options: ExampleKitOptions): DynamicModule { - return { - module: ExampleKitModule, - providers: [ExampleService], - exports: [ExampleService], - }; - } -} +// src/app.module.ts +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@ciscode/config-kit"; +import { appConfig } from "./app.config"; + +@Module({ + imports: [ + // Validates process.env at startup โ€” app never boots with bad config + ConfigModule.forRoot(appConfig), + ], +}) +export class AppModule {} ``` -### 2. Create Services +### 3. Inject `ConfigService` for typed access ```typescript -// src/services/example.service.ts +// src/server.service.ts import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@ciscode/config-kit"; +import { appConfig } from "./app.config"; @Injectable() -export class ExampleService { - async doSomething(data: string): Promise { - return `Processed: ${data}`; +export class ServerService { + constructor( + // Pass `typeof appConfig` so TypeScript knows the exact schema shape + private readonly config: ConfigService, + ) {} + + getPort(): number { + // Returns number โ€” not string, not string|undefined + return this.config.get("PORT"); + } + + getDatabaseUrl(): string { + return this.config.get("DATABASE_URL"); } } ``` -### 3. Define DTOs - -```typescript -// src/dto/create-example.dto.ts -import { IsString, IsNotEmpty } from "class-validator"; +--- -export class CreateExampleDto { - @IsString() - @IsNotEmpty() - name: string; -} -``` +## Async registration -### 4. Export Public API +Use `registerAsync` when the schema depends on another provider (e.g. a secrets vault): ```typescript -// src/index.ts -export { ExampleKitModule } from "./example-kit.module"; -export { ExampleService } from "./services/example.service"; -export { CreateExampleDto } from "./dto/create-example.dto"; +ConfigModule.registerAsync({ + imports: [VaultModule], + inject: [VaultService], + useFactory: async (vault: VaultService) => { + const secret = await vault.getSecret("JWT_SECRET"); + + return defineConfig( + z.object({ + JWT_SECRET: z.string().min(32).default(secret), + PORT: z.coerce.number().default(3000), + }), + ); + }, +}); ``` -## ๐Ÿ“ Scripts - -```bash -# Development -npm run build # Build the package -npm run build:watch # Build in watch mode -npm run typecheck # TypeScript type checking - -# Testing -npm test # Run tests -npm run test:watch # Run tests in watch mode -npm run test:cov # Run tests with coverage - -# Code Quality -npm run lint # Run ESLint -npm run format # Check formatting -npm run format:write # Fix formatting - -# Release -npx changeset # Create a changeset -npm run release # Publish to npm (CI does this) -``` +--- -## ๐Ÿ”„ Release Workflow +## Per-module config namespacing with `defineNamespace` -This template uses [Changesets](https://github.com/changesets/changesets) for version management. +Feature modules can own and validate their own config slice independently, without polluting the root schema. -### 1. Create a Feature +### 1. Define the namespace -```bash -git checkout develop -git checkout -b feature/my-feature -# Make your changes +```typescript +// src/auth/auth.config.ts +import { defineNamespace } from "@ciscode/config-kit"; +import { z } from "zod"; + +export const authConfig = defineNamespace( + "auth", + z.object({ + JWT_SECRET: z.string().min(32), + JWT_EXPIRES_IN: z.string().default("7d"), + }), +); ``` -### 2. Create a Changeset +### 2. Register in the feature module -```bash -npx changeset +```typescript +// src/auth/auth.module.ts +import { Module } from "@nestjs/common"; +import { authConfig } from "./auth.config"; +import { AuthService } from "./auth.service"; + +@Module({ + // authConfig.asProvider() validates this slice at startup and registers the DI token + providers: [authConfig.asProvider(), AuthService], +}) +export class AuthModule {} ``` -Select the change type: - -- **patch** - Bug fixes -- **minor** - New features (backwards compatible) -- **major** - Breaking changes +### 3. Inject with `@InjectConfig` -### 3. Commit and PR +```typescript +// src/auth/auth.service.ts +import { Injectable } from "@nestjs/common"; +import { InjectConfig } from "@ciscode/config-kit"; +import { z } from "zod"; +import { authConfig } from "./auth.config"; -```bash -git add . -git commit -m "feat: add new feature" -git push origin feature/my-feature -# Create PR โ†’ develop +@Injectable() +export class AuthService { + constructor( + // 'auth' matches the namespace name in defineNamespace('auth', ...) + @InjectConfig("auth") + private readonly cfg: z.output, + ) {} + + getSecret(): string { + return this.cfg.JWT_SECRET; // string โ€” never undefined + } +} ``` -### 4. Release +> **Note**: `AuthModule` must import `ConfigModule` (or be in an app that does `ConfigModule.forRoot()`) so the `NAMESPACE_REGISTRY_TOKEN` is available. -- Automation opens "Version Packages" PR -- Merge to `master` to publish +--- -## ๐Ÿงช Testing +## Startup failure example -Tests are MANDATORY for all public APIs. +If any required env var is missing or invalid, ConfigKit throws `ConfigValidationError` **before the app finishes booting**: -```typescript -// src/services/example.service.spec.ts -describe("ExampleService", () => { - let service: ExampleService; - - beforeEach(async () => { - const module = await Test.createTestingModule({ - providers: [ExampleService], - }).compile(); - - service = module.get(ExampleService); - }); - - it("should be defined", () => { - expect(service).toBeDefined(); - }); - - it("should process data correctly", async () => { - const result = await service.doSomething("test"); - expect(result).toBe("Processed: test"); - }); -}); ``` - -**Coverage threshold: 80%** - -## ๐Ÿ“š Path Aliases - -Configured in `tsconfig.json`: - -```typescript -import { ExampleService } from "@services/example.service"; -import { CreateExampleDto } from "@dtos/create-example.dto"; -import { Example } from "@entities/example.entity"; -import { ExampleRepository } from "@repos/example.repository"; +ConfigValidationError: Config validation failed: + โ€ข DATABASE_URL: Invalid url + โ€ข JWT_SECRET: String must contain at least 32 character(s) ``` -Available aliases: - -- `@/*` โ†’ `src/*` -- `@controllers/*` โ†’ `src/controllers/*` -- `@services/*` โ†’ `src/services/*` -- `@entities/*` โ†’ `src/entities/*` -- `@repos/*` โ†’ `src/repositories/*` -- `@dtos/*` โ†’ `src/dto/*` -- `@guards/*` โ†’ `src/guards/*` -- `@decorators/*` โ†’ `src/decorators/*` -- `@config/*` โ†’ `src/config/*` -- `@utils/*` โ†’ `src/utils/*` +You can catch this in `main.ts` for custom formatting: -## ๐Ÿ”’ Security Best Practices +```typescript +// src/main.ts +import { NestFactory } from "@nestjs/core"; +import { ConfigValidationError } from "@ciscode/config-kit"; +import { AppModule } from "./app.module"; + +async function bootstrap() { + try { + const app = await NestFactory.create(AppModule); + await app.listen(3000); + } catch (err) { + if (err instanceof ConfigValidationError) { + console.error("โŒ Invalid configuration:\n", err.message); + // err.fields is a ZodIssue[] โ€” inspect programmatically if needed + process.exit(1); + } + throw err; + } +} -- โœ… Input validation on all DTOs (class-validator) -- โœ… Environment variables for secrets -- โœ… No hardcoded credentials -- โœ… Proper error handling -- โœ… Rate limiting on public endpoints +bootstrap(); +``` -## ๐Ÿค– AI-Friendly Development +--- -This template includes comprehensive Copilot instructions in `.github/copilot-instructions.md`: +## API Reference -- Module architecture guidelines -- Naming conventions -- Testing requirements -- Documentation standards -- Export patterns -- Security best practices +### `defineConfig(schema)` -## ๐Ÿ“– Documentation +Declares the env shape. Returns a `ConfigDefinition` โ€” pass it to `ConfigModule`. -- [Architecture](docs/ARCHITECTURE.md) - Detailed architecture overview -- [Release Process](docs/RELEASE.md) - How to release versions -- [Copilot Instructions](.github/copilot-instructions.md) - AI development guidelines +```typescript +const appConfig = defineConfig(z.object({ PORT: z.coerce.number().default(3000) })); +``` -## ๐Ÿ› ๏ธ Customization +### `ConfigModule` -1. **Rename the module**: Update `package.json` name -2. **Update description**: Modify `package.json` description -3. **Configure exports**: Edit `src/index.ts` -4. **Add dependencies**: Update `peerDependencies` and `dependencies` -5. **Customize structure**: Add/remove directories as needed +| Method | Description | +| ------------------------------------- | ---------------------------------------------------------- | +| `ConfigModule.forRoot(definition)` | Global registration โ€” `ConfigService` available everywhere | +| `ConfigModule.register(definition)` | Non-global registration โ€” scoped to the importing module | +| `ConfigModule.registerAsync(options)` | Async registration with `useFactory` / `inject` | -## โš ๏ธ Important Notes +### `ConfigService` -### What to Export +| Method | Returns | +| ----------------- | --------------------------------------------------------------------- | +| `config.get(key)` | Zod output type for `key` โ€” never `undefined` unless schema allows it | -โœ… **DO export**: +### `defineNamespace(namespace, schema)` -- Module -- Services -- DTOs -- Guards -- Decorators -- Types/Interfaces +Scoped config for feature modules. Returns `NamespacedConfig`. -โŒ **DON'T export**: +| Member | Description | +| ------------------------------- | ------------------------------------------------------------- | +| `namespacedConfig.asProvider()` | NestJS `Provider` โ€” add to `providers` in your feature module | +| `@InjectConfig(namespace)` | Parameter decorator โ€” injects the validated slice | -- Entities -- Repositories +### Errors -Entities and repositories are internal implementation details. +| Class | When thrown | +| ------------------------- | ------------------------------------------------------------------------------- | +| `ConfigValidationError` | One or more env vars fail Zod validation at startup. Has `.fields: ZodIssue[]`. | +| `DuplicateNamespaceError` | Same namespace name registered twice across the app. | -### Versioning +--- -- **MAJOR** (x.0.0) - Breaking changes -- **MINOR** (0.x.0) - New features (backwards compatible) -- **PATCH** (0.0.x) - Bug fixes +## ๐Ÿ“ Scripts -## ๐Ÿ“‹ Checklist Before Publishing +```bash +npm run build # Compile TypeScript to dist/ +npm run typecheck # Type-check without emitting +npm test # Run Jest test suite +npm run test:cov # Tests + coverage report +npm run lint # ESLint (--max-warnings=0) +npm run format # Prettier check +``` -- [ ] All tests passing (80%+ coverage) -- [ ] No ESLint warnings -- [ ] TypeScript strict mode passing -- [ ] All public APIs documented (JSDoc) -- [ ] README updated -- [ ] Changeset created -- [ ] Breaking changes documented -- [ ] `.env.example` updated (if needed) +--- ## ๐Ÿ“„ License -MIT +MIT โ€” see [LICENSE](LICENSE) ## ๐Ÿค Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) -## ๐Ÿ†˜ Support - -- [Documentation](docs/) -- [GitHub Issues](https://github.com/CISCODE-MA/NestJs-DeveloperKit/issues) -- [Discussions](https://github.com/CISCODE-MA/NestJs-DeveloperKit/discussions) - --- **Made with โค๏ธ by CisCode** diff --git a/jest.config.ts b/jest.config.ts index 1d7bc2e..cf3dee6 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -27,10 +27,10 @@ const config: Config = { coverageDirectory: "coverage", coverageThreshold: { global: { - branches: 80, - functions: 80, - lines: 80, - statements: 80, + branches: 85, + functions: 85, + lines: 85, + statements: 85, }, }, moduleNameMapper: { diff --git a/package-lock.json b/package-lock.json index 8698ff7..f652882 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@ciscode/nestjs-developerkit", - "version": "1.0.0", + "name": "@ciscode/config-kit", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@ciscode/nestjs-developerkit", - "version": "1.0.0", + "name": "@ciscode/config-kit", + "version": "0.3.1", "license": "MIT", "dependencies": { "class-transformer": "^0.5.1", @@ -14,6 +14,7 @@ }, "devDependencies": { "@changesets/cli": "^2.27.7", + "@eslint/js": "^9.18.0", "@nestjs/common": "^10.4.0", "@nestjs/core": "^10.4.0", "@nestjs/mapped-types": "^2.0.0", @@ -35,7 +36,8 @@ "tsc-alias": "^1.8.10", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", - "typescript-eslint": "^8.50.1" + "typescript-eslint": "^8.50.1", + "zod": "^4.3.6" }, "engines": { "node": ">=20" @@ -2259,248 +2261,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@swc/core": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.7.tgz", - "integrity": "sha512-kTGB8XI7P+pTKW83tnUEDVP4zduF951u3UAOn5eTi0vyW6MvL56A3+ggMdfuVFtDI0/DsbSzf5z34HVBbuScWw==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.25" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.7", - "@swc/core-darwin-x64": "1.15.7", - "@swc/core-linux-arm-gnueabihf": "1.15.7", - "@swc/core-linux-arm64-gnu": "1.15.7", - "@swc/core-linux-arm64-musl": "1.15.7", - "@swc/core-linux-x64-gnu": "1.15.7", - "@swc/core-linux-x64-musl": "1.15.7", - "@swc/core-win32-arm64-msvc": "1.15.7", - "@swc/core-win32-ia32-msvc": "1.15.7", - "@swc/core-win32-x64-msvc": "1.15.7" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.7.tgz", - "integrity": "sha512-+hNVUfezUid7LeSHqnhoC6Gh3BROABxjlDNInuZ/fie1RUxaEX4qzDwdTgozJELgHhvYxyPIg1ro8ibnKtgO4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.7.tgz", - "integrity": "sha512-ZAFuvtSYZTuXPcrhanaD5eyp27H8LlDzx2NAeVyH0FchYcuXf0h5/k3GL9ZU6Jw9eQ63R1E8KBgpXEJlgRwZUQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.7.tgz", - "integrity": "sha512-K3HTYocpqnOw8KcD8SBFxiDHjIma7G/X+bLdfWqf+qzETNBrzOub/IEkq9UaeupaJiZJkPptr/2EhEXXWryS/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.7.tgz", - "integrity": "sha512-HCnVIlsLnCtQ3uXcXgWrvQ6SAraskLA9QJo9ykTnqTH6TvUYqEta+TdTdGjzngD6TOE7XjlAiUs/RBtU8Z0t+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.7.tgz", - "integrity": "sha512-/OOp9UZBg4v2q9+x/U21Jtld0Wb8ghzBScwhscI7YvoSh4E8RALaJ1msV8V8AKkBkZH7FUAFB7Vbv0oVzZsezA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.7.tgz", - "integrity": "sha512-VBbs4gtD4XQxrHuQ2/2+TDZpPQQgrOHYRnS6SyJW+dw0Nj/OomRqH+n5Z4e/TgKRRbieufipeIGvADYC/90PYQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.7.tgz", - "integrity": "sha512-kVuy2unodso6p0rMauS2zby8/bhzoGRYxBDyD6i2tls/fEYAE74oP0VPFzxIyHaIjK1SN6u5TgvV9MpyJ5xVug==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.7.tgz", - "integrity": "sha512-uddYoo5Xmo1XKLhAnh4NBIyy5d0xk33x1sX3nIJboFySLNz878ksCFCZ3IBqrt1Za0gaoIWoOSSSk0eNhAc/sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.7.tgz", - "integrity": "sha512-rqq8JjNMLx3QNlh0aPTtN/4+BGLEHC94rj9mkH1stoNRf3ra6IksNHMHy+V1HUqElEgcZyx+0yeXx3eLOTcoFw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.7.tgz", - "integrity": "sha512-4BK06EGdPnuplgcNhmSbOIiLdRgHYX3v1nl4HXo5uo4GZMfllXaCyBUes+0ePRfwbn9OFgVhCWPcYYjMT6hycQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true - }, - "node_modules/@swc/types": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", - "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3" - } - }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -8167,21 +7927,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/pidtree": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", @@ -10559,6 +10304,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 54f5ef5..6f237b2 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "@ciscode/nestjs-developerkit", - "version": "1.0.0", - "description": "Template for NestJS developer kits (npm packages).", + "name": "@ciscode/config-kit", + "version": "0.3.1", + "description": "Typed env config for NestJS with Zod validation at startup. Fails fast on misconfiguration. Per-module namespace injection. Most foundational backend package.", "author": "CisCode", "publishConfig": { "access": "public" }, "repository": { "type": "git", - "url": "git+https://github.com/CISCODE-MA/" + "url": "git+https://github.com/CISCODE-MA/ConfigKit.git" }, "license": "MIT", "files": [ @@ -47,7 +47,8 @@ "@nestjs/core": "^10 || ^11", "@nestjs/platform-express": "^10 || ^11", "reflect-metadata": "^0.2.2", - "rxjs": "^7" + "rxjs": "^7", + "zod": "^3 || ^4" }, "dependencies": { "class-transformer": "^0.5.1", @@ -77,6 +78,7 @@ "tsc-alias": "^1.8.10", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", - "typescript-eslint": "^8.50.1" + "typescript-eslint": "^8.50.1", + "zod": "^4.3.6" } } diff --git a/src/config.module.spec.ts b/src/config.module.spec.ts new file mode 100644 index 0000000..a248060 --- /dev/null +++ b/src/config.module.spec.ts @@ -0,0 +1,319 @@ +/** + * @file config.module.spec.ts + * @description + * Integration tests for ConfigModule and ConfigService using the NestJS + * testing module (Test.createTestingModule). + * + * Covers: + * - ConfigModule.register(): module compiles and ConfigService.get() returns + * the correct typed value + * - ConfigModule.register(): app fails to compile when validation fails + * - ConfigModule.registerAsync(): factory is called and config is resolved + * - ConfigModule.forRoot(): config is available in a nested feature module + * without re-importing ConfigModule + * - ConfigService.get() return type matches Zod schema output exactly + */ + +import { Module, Injectable } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { z } from "zod"; + +import { ConfigModule, ConfigModuleOptionsFactory } from "@/config.module"; +import { ConfigService } from "@/config.service"; +import { defineConfig, ConfigDefinition } from "@/define-config"; +import { ConfigValidationError } from "@/errors/config-validation.error"; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Shared test schema & fixture helpers +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// Minimal schema used across most tests +const appSchema = z.object({ + APP_HOST: z.string().default("localhost"), + APP_PORT: z.coerce.number().default(3000), +}); + +const appDef = defineConfig(appSchema); + +// Required-field schema to test validation failure path +const strictSchema = z.object({ + REQUIRED_VAR: z.string(), +}); +const strictDef = defineConfig(strictSchema); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// ConfigModule.register() +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("ConfigModule.register()", () => { + it("should compile and provide ConfigService", async () => { + // Arrange: parse with known env so we get predictable values + const module = await Test.createTestingModule({ + imports: [ + // Pass a custom env so the test is hermetic + ConfigModule.register(defineConfig(appSchema)), + ], + }) + .overrideProvider("CONFIG_KIT_VALUES") + .useValue(appDef.parse({ APP_HOST: "example.com", APP_PORT: "8080" })) + .compile(); + + // Act + const service = module.get(ConfigService); + + // Assert: service is injected and returns the overridden values + expect(service).toBeInstanceOf(ConfigService); + }); + + it("ConfigService.get() should return the correct value", async () => { + // Parse a known env and inject it directly so the test doesn't depend on + // actual process.env values + const parsed = appDef.parse({ APP_HOST: "myhost", APP_PORT: "9090" }); + + const module = await Test.createTestingModule({ + imports: [ConfigModule.register(appDef)], + }) + .overrideProvider("CONFIG_KIT_VALUES") + .useValue(parsed) + .compile(); + + const service = module.get>(ConfigService); + + // get() must return the exact Zod output type โ€” number not string + expect(service.get("APP_HOST")).toBe("myhost"); + expect(service.get("APP_PORT")).toBe(9090); + expect(typeof service.get("APP_PORT")).toBe("number"); + }); + + it("should throw ConfigValidationError when required fields are missing", () => { + // ConfigModule.register() calls definition.parse(process.env) synchronously + // at module build time โ€” ensure process.env doesn't have REQUIRED_VAR + const savedEnv = process.env.REQUIRED_VAR; + delete process.env.REQUIRED_VAR; + + try { + expect(() => + // Calling register() directly (not in createTestingModule) triggers parse immediately + ConfigModule.register(strictDef), + ).toThrow(ConfigValidationError); + } finally { + // Restore env to avoid polluting other tests + if (savedEnv !== undefined) process.env.REQUIRED_VAR = savedEnv; + } + }); +}); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// ConfigModule.registerAsync() +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("ConfigModule.registerAsync()", () => { + it("should call the factory and resolve the config", async () => { + // Track whether faculty was invoked + let factoryCalled = false; + + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.registerAsync({ + useFactory: () => { + factoryCalled = true; + // Return a definition with a known env so test is hermetic + return defineConfig(z.object({ ASYNC_VAR: z.string().default("async-value") })); + }, + }), + ], + }).compile(); + + // Factory must have been called during module compilation + expect(factoryCalled).toBe(true); + + // ConfigService must be available + const service = module.get(ConfigService); + expect(service).toBeInstanceOf(ConfigService); + }); + + it("should support async factories (Promise return)", async () => { + // useFactory can be async โ€” verify it awaits the result + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.registerAsync({ + useFactory: async () => { + // Simulate async work (e.g. fetching config from a vault) + await Promise.resolve(); + return defineConfig(z.object({ ASYNC_VAR: z.string().default("resolved") })); + }, + }), + ], + }).compile(); + + const service = module.get(ConfigService); + expect(service).toBeInstanceOf(ConfigService); + }); + + it("should inject provided tokens into the factory", async () => { + // Provide a mock value via a dedicated module so NestJS can resolve it + // inside registerAsync's factory โ€” the token must be importable from there + const MOCK_TOKEN = "MOCK_VALUE"; + + // Helper module that exports the mock token so registerAsync can inject it + @Module({ providers: [{ provide: MOCK_TOKEN, useValue: "hello" }], exports: [MOCK_TOKEN] }) + class MockModule {} + + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.registerAsync({ + imports: [MockModule], // make MOCK_TOKEN available inside the factory + inject: [MOCK_TOKEN], + useFactory: (mockValue: unknown) => { + // The injected value should be what MockModule provides + expect(mockValue).toBe("hello"); + return defineConfig(z.object({ KEY: z.string().default("x") })); + }, + }), + ], + }).compile(); + + expect(module.get(ConfigService)).toBeInstanceOf(ConfigService); + }); +}); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// ConfigModule.forRoot() โ€” global registration +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("ConfigModule.forRoot()", () => { + it("should make ConfigService available in a nested feature module without re-importing", async () => { + // A feature service that injects ConfigService โ€” it does NOT import ConfigModule + @Injectable() + class FeatureService { + constructor(public readonly config: ConfigService) {} + } + + // Feature module that does NOT import ConfigModule + @Module({ providers: [FeatureService], exports: [FeatureService] }) + class FeatureModule {} + + // Root module: registers ConfigModule globally and imports FeatureModule + @Module({ + imports: [ConfigModule.forRoot(appDef), FeatureModule], + }) + class RootModule {} + + const module = await Test.createTestingModule({ + imports: [RootModule], + }) + // Override the parsed values to keep the test hermetic + .overrideProvider("CONFIG_KIT_VALUES") + .useValue(appDef.parse({ APP_HOST: "global-host", APP_PORT: "4000" })) + .compile(); + + // FeatureService must have received ConfigService via global injection + const featureService = module.get(FeatureService); + expect(featureService.config).toBeInstanceOf(ConfigService); + }); +}); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// ConfigService.getAll() +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("ConfigService.getAll()", () => { + it("should return the complete validated config object", async () => { + const parsed = appDef.parse({ APP_HOST: "all-host", APP_PORT: "7777" }); + + const module = await Test.createTestingModule({ + imports: [ConfigModule.register(appDef)], + }) + .overrideProvider("CONFIG_KIT_VALUES") + .useValue(parsed) + .compile(); + + const service = module.get>(ConfigService); + + // getAll() should return the full config with all keys present + const all = service.getAll(); + expect(all.APP_HOST).toBe("all-host"); + expect(all.APP_PORT).toBe(7777); + }); + + it("should return the same reference as individual get() calls", async () => { + const parsed = appDef.parse({ APP_HOST: "ref-host", APP_PORT: "1234" }); + + const module = await Test.createTestingModule({ + imports: [ConfigModule.register(appDef)], + }) + .overrideProvider("CONFIG_KIT_VALUES") + .useValue(parsed) + .compile(); + + const service = module.get>(ConfigService); + + // Values returned by getAll() must match those returned by get() + const all = service.getAll(); + expect(all.APP_HOST).toBe(service.get("APP_HOST")); + expect(all.APP_PORT).toBe(service.get("APP_PORT")); + }); +}); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// ConfigModule.registerAsync() โ€” useClass / useExisting +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("ConfigModule.registerAsync() โ€” useClass", () => { + it("should instantiate the class and call createConfigDefinition()", async () => { + // Track whether createConfigDefinition was called on the class instance + let factoryCalled = false; + + @Injectable() + class TestConfigFactory implements ConfigModuleOptionsFactory { + createConfigDefinition(): ConfigDefinition< + z.ZodObject<{ USE_CLASS_VAR: z.ZodDefault }> + > { + factoryCalled = true; + return defineConfig(z.object({ USE_CLASS_VAR: z.string().default("class-value") })); + } + } + + const module = await Test.createTestingModule({ + imports: [ConfigModule.registerAsync({ useClass: TestConfigFactory })], + }).compile(); + + expect(factoryCalled).toBe(true); + expect(module.get(ConfigService)).toBeInstanceOf(ConfigService); + }); +}); + +describe("ConfigModule.registerAsync() โ€” useExisting", () => { + it("should reuse the existing provider and call createConfigDefinition()", async () => { + let factoryCalled = false; + + @Injectable() + class ExistingConfigFactory implements ConfigModuleOptionsFactory { + createConfigDefinition(): ConfigDefinition< + z.ZodObject<{ USE_EXISTING_VAR: z.ZodDefault }> + > { + factoryCalled = true; + return defineConfig(z.object({ USE_EXISTING_VAR: z.string().default("existing-value") })); + } + } + + // Helper module that provides the factory so it can be reused via useExisting + @Module({ + providers: [ExistingConfigFactory], + exports: [ExistingConfigFactory], + }) + class FactoryModule {} + + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.registerAsync({ + imports: [FactoryModule], + useExisting: ExistingConfigFactory, + }), + ], + }).compile(); + + expect(factoryCalled).toBe(true); + expect(module.get(ConfigService)).toBeInstanceOf(ConfigService); + }); +}); diff --git a/src/config.module.ts b/src/config.module.ts new file mode 100644 index 0000000..e21c9ba --- /dev/null +++ b/src/config.module.ts @@ -0,0 +1,359 @@ +/** + * @file config.module.ts + * @description + * NestJS dynamic module that wraps the Zod validation engine and provides + * ConfigService for typed environment config injection throughout the app. + * + * Three registration styles are supported: + * + * 1. `ConfigModule.register(definition)` + * Synchronous. Validates process.env immediately at module init. + * Use when the schema is known at import time (most common). + * + * 2. `ConfigModule.registerAsync({ useFactory, inject?, imports? })` + * Async. The factory resolves injected services first, then returns + * a ConfigDefinition which is parsed before any dependent providers + * resolve. Use when the schema depends on another provider. + * + * 3. `ConfigModule.forRoot(definition)` + * Same as `register()` but marks the module as `@Global()` so + * ConfigService is available everywhere without re-importing ConfigModule. + * + * In all three cases: + * - Validation runs BEFORE any other provider resolves. + * - If validation fails, ConfigValidationError is thrown and the app + * never finishes booting. + * - A shared namespace registry (Set) is exported so that + * NamespacedConfig.asProvider() can detect duplicate namespaces. + * + * Contents: + * - ConfigModuleAsyncOptions โ€” interface for registerAsync options + * - ConfigModule โ€” the NestJS dynamic module + */ + +import { DynamicModule, Global, Module, Type } from "@nestjs/common"; +import type { Provider } from "@nestjs/common"; +import type { z } from "zod"; + +import { ConfigService } from "@/config.service"; +import { CONFIG_VALUES_TOKEN, NAMESPACE_REGISTRY_TOKEN } from "@/constants"; +import type { ConfigDefinition } from "@/define-config"; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Internal type helpers +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Constrains Zod schemas to ZodObject so keys are accessible. */ +type AnyZodObject = z.ZodObject; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Options interfaces +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Interface that classes must implement to work with `ConfigModule.registerAsync({ useClass })`. + * + * Implement this in a class and pass that class to `registerAsync({ useClass: MyFactory })`. + * NestJS will instantiate the class (injecting its constructor deps) and call + * `createConfigDefinition()` to retrieve the `ConfigDefinition`. + * + * @example + * ```typescript + * @Injectable() + * export class AppConfigFactory implements ConfigModuleOptionsFactory { + * constructor(private readonly vault: VaultService) {} + * + * async createConfigDefinition() { + * const secret = await this.vault.getSecret('JWT_SECRET'); + * return defineConfig(z.object({ JWT_SECRET: z.string().default(secret) })); + * } + * } + * + * // app.module.ts + * ConfigModule.registerAsync({ useClass: AppConfigFactory }); + * ``` + */ +export interface ConfigModuleOptionsFactory { + /** + * Returns the `ConfigDefinition` to validate and provide. + * May be synchronous or async. + */ + createConfigDefinition(): Promise> | ConfigDefinition; +} + +/** + * Options for `ConfigModule.registerAsync()`. + * + * Supports three patterns โ€” pick exactly one of `useFactory`, `useClass`, or `useExisting`: + * + * - **`useFactory`**: provide an inline factory function (most common for simple cases) + * - **`useClass`**: provide a class implementing `ConfigModuleOptionsFactory`; NestJS + * instantiates it (and injects its constructor deps) before calling `createConfigDefinition()` + * - **`useExisting`**: same as `useClass` but reuses an already-registered provider instead + * of creating a new instance (avoids double-instantiation) + * + * @example useFactory + * ```typescript + * ConfigModule.registerAsync({ + * imports: [VaultModule], + * inject: [VaultService], + * useFactory: (vault: VaultService) => + * defineConfig(z.object({ PORT: z.coerce.number().default(3000) })), + * }); + * ``` + * + * @example useClass + * ```typescript + * ConfigModule.registerAsync({ useClass: AppConfigFactory }); + * ``` + * + * @example useExisting + * ```typescript + * ConfigModule.registerAsync({ useExisting: AppConfigFactory }); + * ``` + */ +export interface ConfigModuleAsyncOptions { + /** + * NestJS modules whose providers need to be available to the factory. + * Works exactly like the `imports` array of a regular `@Module()`. + */ + imports?: DynamicModule["imports"]; + + /** + * Tokens to inject into `useFactory` as positional arguments. + * Only used with `useFactory`. Ignored when using `useClass` or `useExisting`. + */ + inject?: unknown[]; + + /** + * Inline factory function. Receives the `inject`-ed tokens as positional args. + * May be synchronous or async. + */ + useFactory?: (...args: unknown[]) => Promise> | ConfigDefinition; + + /** + * A class implementing `ConfigModuleOptionsFactory`. NestJS creates a fresh + * instance (with its own injected deps) and calls `createConfigDefinition()`. + */ + useClass?: Type>; + + /** + * Same as `useClass` but reuses a provider already present in the DI container. + * The class must be provided elsewhere in `imports` or the root module. + */ + useExisting?: Type>; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// ConfigModule +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * NestJS module that validates environment variables at startup and exposes + * `ConfigService` for typed config injection. + * + * @example Sync registration (most common) + * ```typescript + * // app.module.ts + * @Module({ + * imports: [ConfigModule.forRoot(appConfig)], + * }) + * export class AppModule {} + * ``` + * + * @example Async registration (when schema depends on another provider) + * ```typescript + * ConfigModule.registerAsync({ + * imports: [VaultModule], + * inject: [VaultService], + * useFactory: (vault: VaultService) => defineConfig(mySchema), + * }); + * ``` + */ +@Global() // Applied only when used via forRoot(); register() and registerAsync() are non-global +@Module({}) +export class ConfigModule { + // โ”€โ”€ register (synchronous) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /** + * Registers ConfigModule synchronously. + * + * Calls `definition.parse(process.env)` immediately during module + * initialization โ€” before any other provider resolves. If validation fails, + * `ConfigValidationError` is thrown and the app never finishes booting. + * + * @param definition - The `ConfigDefinition` produced by `defineConfig()`. + * @returns A non-global `DynamicModule` that provides `ConfigService`. + * + * @example + * ```typescript + * @Module({ imports: [ConfigModule.register(appConfig)] }) + * export class FeatureModule {} + * ``` + */ + static register(definition: ConfigDefinition): DynamicModule { + // Parse and validate process.env synchronously โ€” throws on failure + const parsedConfig = definition.parse(process.env); + + // Provider that makes the validated config object available under the DI token + const configValuesProvider: Provider = { + provide: CONFIG_VALUES_TOKEN, + // useValue runs synchronously before any dependent provider resolves + useValue: parsedConfig, + }; + + // Namespace registry โ€” a shared Set that NamespacedConfig.asProvider() + // injects to detect duplicate namespace registrations at module init time. + // A fresh Set is created per ConfigModule instance. + const namespaceRegistryProvider: Provider = { + provide: NAMESPACE_REGISTRY_TOKEN, + useValue: new Set(), + }; + + return { + module: ConfigModule, + providers: [ + configValuesProvider, // parsed config values โ€” must come before ConfigService + namespaceRegistryProvider, // registry for namespace duplicate detection + ConfigService, + ], + // Export both ConfigService and the registry so feature modules' asProvider() + // can inject NAMESPACE_REGISTRY_TOKEN + exports: [ConfigService, NAMESPACE_REGISTRY_TOKEN], + // Non-global: only available in the importing module unless re-exported + global: false, + }; + } + + // โ”€โ”€ registerAsync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /** + * Registers ConfigModule asynchronously. + * + * The factory runs after its `inject`-ed dependencies resolve but before + * any provider that depends on `ConfigService` resolves. The returned + * `ConfigDefinition` is parsed immediately inside the factory, so validation + * still happens before the app finishes booting. + * + * @param options - `{ useFactory, inject?, imports? }` + * @returns A non-global `DynamicModule` that provides `ConfigService`. + * + * @example + * ```typescript + * ConfigModule.registerAsync({ + * imports: [VaultModule], + * inject: [VaultService], + * useFactory: async (vault: VaultService) => { + * const schema = await vault.getConfigSchema(); + * return defineConfig(schema); + * }, + * }); + * ``` + */ + static registerAsync( + options: ConfigModuleAsyncOptions, + ): DynamicModule { + // Build the CONFIG_VALUES_TOKEN provider differently based on which + // async pattern the consumer chose (useFactory / useClass / useExisting) + const configValuesProvider = ConfigModule.createAsyncConfigProvider(options); + + // Extra providers needed when consumer uses useClass (NestJS must instantiate the class) + const extraProviders: Provider[] = options.useClass + ? [{ provide: options.useClass, useClass: options.useClass }] + : []; + + // Namespace registry shared with feature modules' asProvider() factories + const namespaceRegistryProvider: Provider = { + provide: NAMESPACE_REGISTRY_TOKEN, + useValue: new Set(), + }; + + return { + module: ConfigModule, + // Make the imported modules available so the factory can resolve them + imports: options.imports ?? [], + providers: [ + configValuesProvider, + ...extraProviders, + namespaceRegistryProvider, + ConfigService, + ], + exports: [ConfigService, NAMESPACE_REGISTRY_TOKEN], + global: false, + }; + } + + /** + * Builds the `CONFIG_VALUES_TOKEN` provider for `registerAsync`. + * + * Handles all three async patterns: + * - `useFactory`: wraps the consumer's factory directly + * - `useClass` / `useExisting`: delegates to the factory class's `createConfigDefinition()` + */ + private static createAsyncConfigProvider( + options: ConfigModuleAsyncOptions, + ): Provider { + if (options.useFactory) { + // โ”€โ”€ useFactory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + return { + provide: CONFIG_VALUES_TOKEN, + useFactory: async (...args: unknown[]) => { + // Call the consumer's factory to get the ConfigDefinition + const definition = await options.useFactory!(...args); + // Parse and validate synchronously โ€” throws on bad env before app boots + return definition.parse(process.env); + }, + inject: (options.inject ?? []) as never[], + }; + } + + // โ”€โ”€ useClass / useExisting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Both patterns delegate to ConfigModuleOptionsFactory.createConfigDefinition() + // useClass โ†’ NestJS creates a fresh instance of the class + // useExisting โ†’ NestJS reuses the already-registered instance + const factoryToken = (options.useClass ?? options.useExisting)!; + + return { + provide: CONFIG_VALUES_TOKEN, + useFactory: async (factory: ConfigModuleOptionsFactory) => { + const definition = await factory.createConfigDefinition(); + return definition.parse(process.env); + }, + inject: [factoryToken], + }; + } + + // โ”€โ”€ forRoot (global) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /** + * Registers ConfigModule globally. + * + * Identical to `register()` but the module is marked global, meaning + * `ConfigService` is available in every module across the application + * without needing to import `ConfigModule` again. + * + * Recommended for app-wide config that every feature module needs + * (e.g. database URL, JWT secret, port number). + * + * @param definition - The `ConfigDefinition` produced by `defineConfig()`. + * @returns A **global** `DynamicModule` that provides `ConfigService`. + * + * @example + * ```typescript + * // app.module.ts โ€” register once at the root + * @Module({ imports: [ConfigModule.forRoot(appConfig)] }) + * export class AppModule {} + * + * // any-feature.service.ts โ€” inject without re-importing ConfigModule + * constructor(private config: ConfigService) {} + * ``` + */ + static forRoot(definition: ConfigDefinition): DynamicModule { + // Reuse register() and override global flag + return { + ...ConfigModule.register(definition), + // global: true makes ConfigService injectable everywhere in the app + global: true, + }; + } +} diff --git a/src/config.service.ts b/src/config.service.ts new file mode 100644 index 0000000..e3c9e6f --- /dev/null +++ b/src/config.service.ts @@ -0,0 +1,164 @@ +/** + * @file config.service.ts + * @description + * Typed configuration service for ConfigKit. + * + * Consumers inject ConfigService into their NestJS providers and call + * `.get(key)` to retrieve environment values. The return type of `.get()` + * is inferred directly from the Zod schema passed to `defineConfig()` โ€” no + * casting, no `string | undefined`. + * + * Usage pattern: + * ```typescript + * // 1. Define your config shape once + * export const appConfig = defineConfig(z.object({ PORT: z.coerce.number() })); + * + * // 2. Register the module + * ConfigModule.forRoot(appConfig) + * + * // 3. Inject the service with the correct generic so TypeScript knows the shape + * constructor(private config: ConfigService) {} + * + * // 4. Get a value โ€” fully typed, never undefined + * const port = this.config.get('PORT'); // โ†’ number + * ``` + * + * Contents: + * - ConfigService โ€” injectable service with typed get() + */ + +import { Inject, Injectable } from "@nestjs/common"; +import type { z } from "zod"; + +import { CONFIG_VALUES_TOKEN } from "@/constants"; +import type { ConfigDefinition } from "@/define-config"; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Internal type helpers +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Convenience alias used internally to constrain Zod schema generics. + * Mirrors the same private alias in define-config.ts. + */ +type AnyZodObject = z.ZodObject; + +/** + * Extracts the fully-parsed output type from a ConfigDefinition. + * + * Given `ConfigDefinition`, this produces `z.output` โ€” the shape of + * the object after Zod has applied coercions, defaults, and transforms. + * + * Used to drive the return type of `ConfigService.get()`. + * + * @example + * ```typescript + * type MyConfig = ConfigOutput; + * // โ†’ { PORT: number; DATABASE_URL: string; NODE_ENV: 'development' | 'production' } + * ``` + */ +type ConfigOutput> = z.output; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// ConfigService +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Injectable service that provides typed access to validated environment config. + * + * The generic parameter `TDef` carries the `ConfigDefinition` type so that + * `.get()` returns the exact Zod output type for each key โ€” no casting needed. + * + * **Important**: The generic is a TypeScript-only hint. At runtime all instances + * of `ConfigService` are the same class; NestJS DI works as normal. + * + * @typeParam TDef - The `ConfigDefinition` returned by `defineConfig()`. + * Use `typeof myConfig` to pass it: + * `ConfigService`. + * + * @example + * ```typescript + * // app.config.ts + * export const appConfig = defineConfig(z.object({ + * PORT: z.coerce.number().default(3000), + * DATABASE_URL: z.string().url(), + * })); + * + * // some.service.ts + * @Injectable() + * export class SomeService { + * constructor(private config: ConfigService) {} + * + * getPort(): number { + * return this.config.get('PORT'); // return type is `number`, not `string | undefined` + * } + * } + * ``` + */ +@Injectable() +export class ConfigService< + TDef extends ConfigDefinition = ConfigDefinition, +> { + /** + * The fully-typed, frozen config object produced by `definition.parse()`. + * Stored as `Record` internally; typed externally via generics. + */ + private readonly _config: Record; + + constructor( + // NestJS injects the parsed config object stored under CONFIG_VALUES_TOKEN + @Inject(CONFIG_VALUES_TOKEN) + config: Record, + ) { + // Store the config reference โ€” it is already frozen by ConfigDefinition.parse() + this._config = config; + } + + /** + * Retrieves the value of an environment variable by key. + * + * The return type is inferred from the Zod schema โ€” no `as` casts required. + * If you access a key that doesn't exist in the schema, TypeScript will error + * at compile time rather than returning `undefined` at runtime. + * + * @param key - A key that exists in the Zod schema passed to `defineConfig()`. + * @returns The fully-typed, validated value. Never `undefined` unless the + * schema explicitly allows it (e.g. `z.string().optional()`). + * + * @example + * ```typescript + * const port = this.config.get('PORT'); // โ†’ number + * const dbUrl = this.config.get('DATABASE_URL'); // โ†’ string + * ``` + */ + get>(key: K): ConfigOutput[K] { + // Cast is safe: the config was produced by Zod's safeParse which guarantees + // the shape matches ConfigOutput exactly + return this._config[key as string] as ConfigOutput[K]; + } + + /** + * Returns the entire validated config object as a readonly snapshot. + * + * Useful for: + * - Passing the full config to a third-party initialiser that expects a plain object + * - Debug logging (e.g. `console.log(config.getAll())`) + * - Spreading into another object when all keys are needed + * + * The returned object is the same frozen reference produced by `definition.parse()` โ€” + * mutating it will throw in strict mode. + * + * @returns The complete, fully-typed validated config object. + * + * @example + * ```typescript + * const all = this.config.getAll(); + * // all.PORT โ†’ number + * // all.DATABASE_URL โ†’ string + * ``` + */ + getAll(): Readonly> { + // Cast is safe โ€” same guarantee as get() + return this._config as unknown as Readonly>; + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..5e64284 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,67 @@ +/** + * @file constants.ts + * @description + * Dependency injection tokens used internally by ConfigKit. + * + * These tokens are the "glue" between ConfigModule providers and the + * services that consume them. They are NOT part of the public API โ€” + * consumers should never inject these tokens directly; they should use + * ConfigService or @InjectConfig() instead. + * + * Contents: + * - CONFIG_VALUES_TOKEN โ€” token for the root parsed config object + * - getNamespaceToken() โ€” generates a unique DI token per namespace + * - NAMESPACE_REGISTRY_TOKEN โ€” token for the set of registered namespace names + */ + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Root config token +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Injection token for the parsed and validated root config object. + * + * ConfigModule registers the result of `definition.parse(process.env)` under + * this token. ConfigService then injects it to serve typed `get()` calls. + * + * Consumers should never inject this token directly โ€” always use ConfigService. + */ +export const CONFIG_VALUES_TOKEN = "CONFIG_KIT_VALUES" as const; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Namespace tokens +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Generates a unique, stable DI token for a given namespace string. + * + * Each namespace gets its own token so that NestJS can inject the correct + * validated config slice into each feature module independently. + * + * The token is a plain string prefixed with `CONFIG_KIT_NS:` to avoid any + * accidental collision with other DI tokens in the consuming app. + * + * @param namespace - The namespace name passed to `defineNamespace()`. + * @returns A unique DI token string for that namespace. + * + * @example + * ```typescript + * getNamespaceToken('database') // โ†’ 'CONFIG_KIT_NS:database' + * getNamespaceToken('auth') // โ†’ 'CONFIG_KIT_NS:auth' + * ``` + */ +export function getNamespaceToken(namespace: string): string { + return `CONFIG_KIT_NS:${namespace}`; +} + +/** + * Injection token for the namespace registry. + * + * ConfigModule stores a `Set` of all registered namespace names under + * this token at startup. When a new NamespacedConfig is added, it checks this + * registry and throws if the namespace has already been registered โ€” preventing + * silent duplicate registrations that would produce unpredictable behavior. + * + * This token is internal; consumers never interact with it directly. + */ +export const NAMESPACE_REGISTRY_TOKEN = "CONFIG_KIT_NS_REGISTRY" as const; diff --git a/src/controllers/example.controller.ts b/src/controllers/example.controller.ts deleted file mode 100644 index 90cea1b..0000000 --- a/src/controllers/example.controller.ts +++ /dev/null @@ -1,46 +0,0 @@ -// import { ExampleService } from '@services/example.service'; -import { CreateExampleDto } from "@dtos/create-example.dto"; -import { Controller, Get, Post, Body, Param } from "@nestjs/common"; - -/** - * Example Controller - * - * HTTP endpoints for the Example Kit module. - * Controllers are optional - only include if your module provides HTTP endpoints. - * - * @example - * ```typescript - * // In your app's module - * @Module({ - * imports: [ExampleKitModule.forRoot()], - * controllers: [], // Controllers are auto-registered by the module - * }) - * export class AppModule {} - * ``` - */ -@Controller("example") -export class ExampleController { - // constructor(private readonly exampleService: ExampleService) {} - - /** - * Create a new example resource - * @param dto - Data transfer object - * @returns Created resource - */ - @Post() - async create(@Body() dto: CreateExampleDto) { - // return this.exampleService.doSomething(dto.name); - return { message: "Example created", data: dto }; - } - - /** - * Get resource by ID - * @param id - Resource identifier - * @returns Resource data - */ - @Get(":id") - async findOne(@Param("id") id: string) { - // return this.exampleService.findById(id); - return { id, data: "example" }; - } -} diff --git a/src/decorators/example.decorator.ts b/src/decorators/example.decorator.ts deleted file mode 100644 index bfde98f..0000000 --- a/src/decorators/example.decorator.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { createParamDecorator } from "@nestjs/common"; -import type { ExecutionContext } from "@nestjs/common"; - -/** - * Example Decorator - * - * Custom parameter decorator to extract data from the request. - * Useful for extracting user info, custom headers, or other request data. - * - * @example - * ```typescript - * @Controller('example') - * export class ExampleController { - * @Get() - * async findAll(@ExampleData() data: any) { - * // data is extracted from request - * return data; - * } - * } - * ``` - */ -export const ExampleData = createParamDecorator((data: unknown, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - - // Extract and return data from request - // Example: return request.user; - // Example: return request.headers['x-custom-header']; - - return request.user || null; -}); - -/** - * Example Decorator with parameter - * - * @example - * ```typescript - * @Get() - * async findOne(@ExampleParam('id') id: string) { - * // id is extracted from request params - * return id; - * } - * ``` - */ -export const ExampleParam = createParamDecorator((param: string, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - return request.params?.[param]; -}); diff --git a/src/decorators/inject-config.decorator.ts b/src/decorators/inject-config.decorator.ts new file mode 100644 index 0000000..c1d1288 --- /dev/null +++ b/src/decorators/inject-config.decorator.ts @@ -0,0 +1,72 @@ +/** + * @file inject-config.decorator.ts + * @description + * Parameter decorator for injecting a typed namespace config slice into + * NestJS constructors. + * + * Usage: + * ```typescript + * constructor( + * @InjectConfig('auth') private cfg: z.output + * ) {} + * ``` + * + * Under the hood it is just `@Inject(getNamespaceToken(namespace))` โ€” a thin + * wrapper so consumers never have to know about the internal token format. + * + * Contents: + * - InjectConfig() โ€” parameter decorator factory + */ + +import { Inject } from "@nestjs/common"; + +import { getNamespaceToken } from "@/constants"; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// @InjectConfig +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Parameter decorator that injects the validated config slice for a namespace. + * + * Must be used in constructors of NestJS providers (services, controllers, etc.) + * inside a module that has imported ConfigModule and added the corresponding + * `NamespacedConfig.asProvider()` to its `providers` array. + * + * The injected value is a frozen, fully-typed object โ€” the Zod output of the + * schema passed to `defineNamespace()`. No `string | undefined` values. + * + * @param namespace - The namespace name used in `defineNamespace(namespace, schema)`. + * Must match exactly (case-sensitive). + * @returns A NestJS `@Inject()` parameter decorator bound to the namespace token. + * + * @example + * ```typescript + * // auth/auth.config.ts + * export const authConfig = defineNamespace('auth', z.object({ + * JWT_SECRET: z.string().min(32), + * JWT_EXPIRES_IN: z.string().default('7d'), + * })); + * + * // auth/auth.module.ts + * @Module({ providers: [authConfig.asProvider(), AuthService] }) + * export class AuthModule {} + * + * // auth/auth.service.ts + * @Injectable() + * export class AuthService { + * constructor( + * // Injects the validated { JWT_SECRET: string, JWT_EXPIRES_IN: string } object + * @InjectConfig('auth') private cfg: z.output + * ) {} + * + * getSecret(): string { + * return this.cfg.JWT_SECRET; // string โ€” never undefined + * } + * } + * ``` + */ +export function InjectConfig(namespace: string): ParameterDecorator { + // Resolve the namespace to its unique DI token and delegate to NestJS @Inject + return Inject(getNamespaceToken(namespace)); +} diff --git a/src/define-config.spec.ts b/src/define-config.spec.ts new file mode 100644 index 0000000..8b36d3d --- /dev/null +++ b/src/define-config.spec.ts @@ -0,0 +1,256 @@ +/** + * @file define-config.spec.ts + * @description + * Unit tests for defineConfig() and ConfigDefinition. + * + * Covers: + * - defineConfig() returns a ConfigDefinition instance + * - parse() succeeds with a valid env and returns a frozen typed object + * - parse() applies Zod default() values when env vars are absent + * - parse() coerces types (z.coerce.number, z.coerce.boolean) + * - parse() throws ConfigValidationError when required fields are missing + * - parse() throws ConfigValidationError listing ALL failing fields at once + * - parse() accepts a custom env record instead of process.env + * - The returned config object is frozen (cannot be mutated) + */ + +import { z } from "zod"; + +import { ConfigDefinition, defineConfig } from "@/define-config"; +import type { InferConfig } from "@/define-config"; +import { ConfigValidationError } from "@/errors/config-validation.error"; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Shared test schema +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// A representative schema covering the most common Zod primitives +const testSchema = z.object({ + APP_NAME: z.string(), + PORT: z.coerce.number().default(3000), + DEBUG: z.coerce.boolean().default(false), + DATABASE_URL: z.string().url(), +}); + +describe("defineConfig()", () => { + // โ”€โ”€ Factory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("should return a ConfigDefinition instance", () => { + // defineConfig() must wrap the schema in a ConfigDefinition + const result = defineConfig(testSchema); + expect(result).toBeInstanceOf(ConfigDefinition); + }); + + it("should expose the original schema on the returned definition", () => { + // Consumers rely on definition.schema for type inference in ConfigService + const result = defineConfig(testSchema); + expect(result.schema).toBe(testSchema); + }); + + it("should NOT run validation at call time (no side effects)", () => { + // defineConfig() is lazy โ€” calling it with an empty process.env must not throw + expect(() => defineConfig(testSchema)).not.toThrow(); + }); +}); + +describe("ConfigDefinition.parse()", () => { + // โ”€โ”€ Success path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("should return a fully-typed config object when all required fields are present", () => { + const definition = defineConfig(testSchema); + + // Provide a minimal valid env โ€” PORT and DEBUG omitted to test defaults + const result = definition.parse({ + APP_NAME: "my-app", + DATABASE_URL: "https://db.example.com/mydb", + }); + + // All fields should be present with coerced/defaulted values + expect(result.APP_NAME).toBe("my-app"); + expect(result.PORT).toBe(3000); // default applied + expect(result.DEBUG).toBe(false); // default applied + expect(result.DATABASE_URL).toBe("https://db.example.com/mydb"); + }); + + it("should apply .default() values when env vars are absent", () => { + // Only provide the required fields; defaults should fill the rest + const definition = defineConfig(testSchema); + const result = definition.parse({ + APP_NAME: "app", + DATABASE_URL: "https://db.example.com/db", + }); + + expect(result.PORT).toBe(3000); + expect(result.DEBUG).toBe(false); + }); + + it("should override defaults when env vars are explicitly provided", () => { + const definition = defineConfig(testSchema); + const result = definition.parse({ + APP_NAME: "app", + PORT: "8080", + DEBUG: "true", + DATABASE_URL: "https://db.example.com/db", + }); + + // z.coerce.number() converts "8080" โ†’ 8080; z.coerce.boolean() converts "true" โ†’ true + expect(result.PORT).toBe(8080); + expect(result.DEBUG).toBe(true); + }); + + it("should coerce string '3000' to number when z.coerce.number() is used", () => { + const schema = z.object({ PORT: z.coerce.number() }); + const definition = defineConfig(schema); + const result = definition.parse({ PORT: "3000" }); + + // Zod coerces the string to a number + expect(result.PORT).toBe(3000); + expect(typeof result.PORT).toBe("number"); + }); + + it("should return a frozen (immutable) object", () => { + const definition = defineConfig(testSchema); + const result = definition.parse({ + APP_NAME: "app", + DATABASE_URL: "https://db.example.com/db", + }); + + // Object.isFrozen returns true if the object cannot be mutated + expect(Object.isFrozen(result)).toBe(true); + }); + + it("should use process.env by default when no env argument is passed", () => { + // Temporarily set a known env var to verify the default argument works + const original = process.env.APP_NAME; + process.env.APP_NAME = "from-process-env"; + process.env.DATABASE_URL = "https://db.example.com/db"; + + const definition = defineConfig(testSchema); + + try { + const result = definition.parse(); + expect(result.APP_NAME).toBe("from-process-env"); + } finally { + // Restore original values to avoid polluting other tests + if (original === undefined) { + delete process.env.APP_NAME; + } else { + process.env.APP_NAME = original; + } + delete process.env.DATABASE_URL; + } + }); + + // โ”€โ”€ Failure path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("should throw ConfigValidationError when a required field is missing", () => { + const definition = defineConfig(testSchema); + + // DATABASE_URL is required and has no default โ€” omitting it must throw + expect(() => + definition.parse({ + APP_NAME: "app", + // DATABASE_URL intentionally omitted + }), + ).toThrow(ConfigValidationError); + }); + + it("should throw ConfigValidationError listing the missing field", () => { + const definition = defineConfig(testSchema); + + try { + definition.parse({ APP_NAME: "app" }); // DATABASE_URL missing + fail("Expected ConfigValidationError to be thrown"); + } catch (err) { + // Error must be the correct type + expect(err).toBeInstanceOf(ConfigValidationError); + + const validationErr = err as ConfigValidationError; + + // Must list the failing field by path + const paths = validationErr.fields.map((f) => f.path.join(".")); + expect(paths).toContain("DATABASE_URL"); + } + }); + + it("should list ALL failing fields in a single error (not just the first)", () => { + const definition = defineConfig(testSchema); + + try { + // Both required fields are missing + definition.parse({}); + fail("Expected ConfigValidationError to be thrown"); + } catch (err) { + const validationErr = err as ConfigValidationError; + + // Both APP_NAME and DATABASE_URL must appear in the fields list + const paths = validationErr.fields.map((f) => f.path.join(".")); + expect(paths).toContain("APP_NAME"); + expect(paths).toContain("DATABASE_URL"); + } + }); + + it("should throw ConfigValidationError when a field fails a Zod refinement", () => { + const definition = defineConfig(testSchema); + + expect(() => + definition.parse({ + APP_NAME: "app", + DATABASE_URL: "not-a-valid-url", // fails z.string().url() + }), + ).toThrow(ConfigValidationError); + }); + + it("should support all Zod enum values", () => { + const schema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + }); + const definition = defineConfig(schema); + + // Valid value + const result = definition.parse({ NODE_ENV: "production" }); + expect(result.NODE_ENV).toBe("production"); + + // Default applied when absent + const defaultResult = definition.parse({}); + expect(defaultResult.NODE_ENV).toBe("development"); + + // Invalid value throws + expect(() => definition.parse({ NODE_ENV: "invalid" })).toThrow(ConfigValidationError); + }); +}); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// InferConfig type helper +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("InferConfig", () => { + it("should produce the correct shape at runtime (smoke test via parse output)", () => { + const def = defineConfig( + z.object({ HOST: z.string().default("localhost"), PORT: z.coerce.number().default(8080) }), + ); + + // InferConfig is a compile-time type โ€” we can verify it at runtime by + // checking that the parsed result matches the expected shape + const result: InferConfig = def.parse({}); + + expect(result.HOST).toBe("localhost"); + expect(result.PORT).toBe(8080); + }); + + it("should carry through all Zod output types (number, string, enum)", () => { + const schema = z.object({ + PORT: z.coerce.number().default(3000), + NODE_ENV: z.enum(["development", "production"]).default("development"), + NAME: z.string().default("app"), + }); + const def = defineConfig(schema); + + const result: InferConfig = def.parse({}); + + // TypeScript types are correct at compile time; verify values at runtime + expect(typeof result.PORT).toBe("number"); + expect(typeof result.NODE_ENV).toBe("string"); + expect(typeof result.NAME).toBe("string"); + }); +}); diff --git a/src/define-config.ts b/src/define-config.ts new file mode 100644 index 0000000..dd68fd7 --- /dev/null +++ b/src/define-config.ts @@ -0,0 +1,180 @@ +/** + * @file define-config.ts + * @description + * Core validation engine for ConfigKit. + * + * Consumers call `defineConfig(zodSchema)` to declare the shape of their + * environment variables. The returned `ConfigDefinition` is passed to + * `ConfigModule.register()` which calls `.parse()` synchronously during + * NestJS module initialization โ€” before any provider resolves. + * + * Contents: + * - ConfigDefinition โ€” opaque wrapper that holds the schema + parse logic + * - defineConfig() โ€” factory function; the primary public API + */ + +// Only type references to `z` are used in this file (ZodObject, ZodRawShape, z.output) +// so `import type` satisfies the @typescript-eslint/consistent-type-imports rule +import type { z } from "zod"; + +import { ConfigValidationError } from "@/errors/config-validation.error"; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Types +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Constraint for the Zod schema accepted by defineConfig. + * + * Must be a ZodObject so we can key into its shape for typed `.get()` calls + * inside ConfigService. + */ +type AnyZodObject = z.ZodObject; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// ConfigDefinition +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Opaque wrapper returned by `defineConfig()`. + * + * Holds the Zod schema and exposes a `parse()` method that ConfigModule calls + * at startup to validate `process.env`. Do not instantiate this class + * directly โ€” always use `defineConfig()`. + * + * The generic parameter `T` carries the full Zod schema type so that + * `ConfigService` can later infer the exact output type of each key. + * + * @example + * ```typescript + * const appConfig = defineConfig(z.object({ PORT: z.string().default('3000') })); + * // typeof appConfig โ†’ ConfigDefinition }>> + * ``` + */ +export class ConfigDefinition { + /** + * The Zod schema provided by the consumer. + * ConfigModule stores this to produce injection tokens with correct types. + */ + public readonly schema: T; + + constructor(schema: T) { + // Store the schema for later use by ConfigModule + this.schema = schema; + } + + /** + * Validates the given environment record against `this.schema`. + * + * Called **synchronously** by ConfigModule during NestJS module + * initialization. If validation fails the app never finishes booting. + * + * @param env - The environment record to validate. + * Defaults to `process.env` so consumers never need to pass it. + * @returns A deep-frozen, fully-typed config object with no `string | undefined` values. + * Zod coerces types and fills in `.default()` values automatically. + * @throws {ConfigValidationError} When one or more fields fail validation. + * The error lists every failing ZodIssue so developers can fix all + * problems in a single restart cycle. + * + * @example + * ```typescript + * // Called internally by ConfigModule โ€” you rarely call this yourself + * const config = appConfig.parse(process.env); + * config.PORT; // string (never undefined) + * config.DATABASE_URL; // string + * ``` + */ + parse(env: Record = process.env): Readonly> { + // Run a safe (non-throwing) parse so we can collect ALL issues at once + const result = this.schema.safeParse(env); + + // If validation failed, throw with the full ZodIssue list + if (!result.success) { + throw new ConfigValidationError(result.error.issues); + } + + // Freeze the resulting object so consumers cannot mutate config at runtime + return Object.freeze(result.data) as Readonly>; + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// defineConfig +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Declares the shape of your application's environment variables using a Zod schema. + * + * Returns a `ConfigDefinition` that you pass to `ConfigModule.register()` or + * `ConfigModule.forRoot()`. ConfigModule will call `.parse(process.env)` at + * startup and make the result available through `ConfigService`. + * + * **No validation happens at call time** โ€” validation is deferred to module + * initialization so that tests can import and compose schemas without a + * fully-populated environment. + * + * @param schema - A `z.object(...)` describing every env variable the module needs. + * Use `.default()` for optional variables and `.transform()` for + * type coercion (e.g. `z.coerce.number()` for port numbers). + * @returns `ConfigDefinition` โ€” pass this to `ConfigModule.register(definition)`. + * + * @example + * ```typescript + * // config/app.config.ts + * import { defineConfig } from '@ciscode/config-kit'; + * import { z } from 'zod'; + * + * export const appConfig = defineConfig( + * z.object({ + * PORT: z.coerce.number().default(3000), + * DATABASE_URL: z.string().url(), + * JWT_SECRET: z.string().min(32), + * NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + * }), + * ); + * + * // app.module.ts + * @Module({ imports: [ConfigModule.forRoot(appConfig)] }) + * export class AppModule {} + * + * // some.service.ts + * constructor(private config: ConfigService) {} + * // config.get('PORT') โ†’ number (not string | undefined) + * ``` + */ +export function defineConfig(schema: T): ConfigDefinition { + // Wrap the schema in a ConfigDefinition; no side effects at definition time + return new ConfigDefinition(schema); +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// InferConfig +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Extracts the fully-resolved config type from a `ConfigDefinition`. + * + * Saves consumers from writing the verbose `z.output` + * pattern. Use it to type variables, function parameters, or return types + * that need to reference the shape of a config without importing zod directly. + * + * @typeParam TDef - The `ConfigDefinition` returned by `defineConfig()`. + * + * @example + * ```typescript + * // app.config.ts + * export const appConfig = defineConfig(z.object({ + * PORT: z.coerce.number().default(3000), + * DATABASE_URL: z.string().url(), + * })); + * + * // Instead of: + * type AppConfig = z.output; + * + * // Write: + * type AppConfig = InferConfig; + * // โ†’ { PORT: number; DATABASE_URL: string } + * ``` + */ +export type InferConfig> = z.output; diff --git a/src/define-namespace.spec.ts b/src/define-namespace.spec.ts new file mode 100644 index 0000000..58fec21 --- /dev/null +++ b/src/define-namespace.spec.ts @@ -0,0 +1,211 @@ +/** + * @file define-namespace.spec.ts + * @description + * Unit and integration tests for defineNamespace(), NamespacedConfig, + * DuplicateNamespaceError, and the @InjectConfig() decorator. + * + * Covers: + * - defineNamespace() returns a NamespacedConfig with correct namespace + schema + * - NamespacedConfig.asProvider() returns a NestJS Provider with the correct token + * - The injected value is the validated, frozen config slice (correct type) + * - Duplicate namespace registration throws DuplicateNamespaceError + * - @InjectConfig(namespace) resolves the correct slice in a NestJS module + * - Namespaced configs are validated against process.env at module init + */ + +import { Injectable, Module } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { z } from "zod"; + +import { ConfigModule } from "@/config.module"; +import { NAMESPACE_REGISTRY_TOKEN, getNamespaceToken } from "@/constants"; +import { InjectConfig } from "@/decorators/inject-config.decorator"; +import { defineConfig } from "@/define-config"; +import { DuplicateNamespaceError, NamespacedConfig, defineNamespace } from "@/define-namespace"; +import { ConfigValidationError } from "@/errors/config-validation.error"; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Shared schemas & fixtures +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// Root schema for ConfigModule (keeps tests hermetic) +const rootSchema = z.object({ APP_PORT: z.coerce.number().default(3000) }); +const rootDef = defineConfig(rootSchema); + +// A sample per-feature namespace schema +const authSchema = z.object({ + JWT_SECRET: z.string().min(1).default("test-secret"), + JWT_EXPIRES_IN: z.string().default("7d"), +}); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// defineNamespace() +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("defineNamespace()", () => { + it("should return a NamespacedConfig instance", () => { + const result = defineNamespace("auth", authSchema); + expect(result).toBeInstanceOf(NamespacedConfig); + }); + + it("should store the namespace name", () => { + const result = defineNamespace("payments", authSchema); + expect(result.namespace).toBe("payments"); + }); + + it("should wrap the schema in a ConfigDefinition", () => { + const result = defineNamespace("auth", authSchema); + // The definition's schema should be the same object we passed in + expect(result.definition.schema).toBe(authSchema); + }); + + it("should NOT run validation at call time", () => { + // defineNamespace() must be side-effect free + expect(() => defineNamespace("lazy", z.object({ MISSING: z.string() }))).not.toThrow(); + }); +}); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// NamespacedConfig.asProvider() +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("NamespacedConfig.asProvider()", () => { + it("should return a provider with the correct DI token", () => { + const ns = defineNamespace("auth", authSchema); + const provider = ns.asProvider(); + + // The token must match getNamespaceToken(namespace) + expect((provider as { provide: string }).provide).toBe(getNamespaceToken("auth")); + }); + + it("should produce a validated frozen config slice when the factory runs", () => { + const ns = defineNamespace("auth", authSchema); + const provider = ns.asProvider() as { useFactory: (registry: Set) => unknown }; + + // Simulate NestJS calling the factory with a fresh registry + const registry = new Set(); + const result = provider.useFactory(registry) as Record; + + // Result should contain Zod default values + expect(result.JWT_SECRET).toBe("test-secret"); + expect(result.JWT_EXPIRES_IN).toBe("7d"); + // Result must be frozen + expect(Object.isFrozen(result)).toBe(true); + }); + + it("should add the namespace to the registry after first registration", () => { + const ns = defineNamespace("billing", authSchema); + const provider = ns.asProvider() as { useFactory: (registry: Set) => unknown }; + + const registry = new Set(); + provider.useFactory(registry); + + // Registry must now contain the namespace name + expect(registry.has("billing")).toBe(true); + }); + + it("should throw DuplicateNamespaceError when the namespace is already in the registry", () => { + const ns = defineNamespace("duplicate", authSchema); + const provider = ns.asProvider() as { useFactory: (registry: Set) => unknown }; + + // Pre-populate the registry as if the namespace was already registered + const registry = new Set(["duplicate"]); + + expect(() => provider.useFactory(registry)).toThrow(DuplicateNamespaceError); + }); + + it("should inject NAMESPACE_REGISTRY_TOKEN", () => { + const ns = defineNamespace("auth", authSchema); + const provider = ns.asProvider() as { inject: string[] }; + + // The inject array must reference the registry token + expect(provider.inject).toContain(NAMESPACE_REGISTRY_TOKEN); + }); +}); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// DuplicateNamespaceError +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("DuplicateNamespaceError", () => { + it("should be an instance of Error", () => { + const err = new DuplicateNamespaceError("auth"); + expect(err).toBeInstanceOf(Error); + }); + + it("should be an instance of DuplicateNamespaceError", () => { + const err = new DuplicateNamespaceError("auth"); + expect(err).toBeInstanceOf(DuplicateNamespaceError); + }); + + it("should have name set to 'DuplicateNamespaceError'", () => { + const err = new DuplicateNamespaceError("auth"); + expect(err.name).toBe("DuplicateNamespaceError"); + }); + + it("should include the namespace name in the message", () => { + const err = new DuplicateNamespaceError("my-feature"); + expect(err.message).toContain("my-feature"); + }); +}); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// @InjectConfig() โ€” integration with NestJS DI +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("@InjectConfig() integration", () => { + it("should inject the correct typed config slice into a service", async () => { + // Define the namespace config + const emailConfig = defineNamespace( + "email", + z.object({ + SMTP_HOST: z.string().default("localhost"), + SMTP_PORT: z.coerce.number().default(587), + }), + ); + + // A feature service that uses @InjectConfig to receive the email config slice + @Injectable() + class EmailService { + constructor( + @InjectConfig("email") + public readonly cfg: z.output, + ) {} + } + + // Feature module that adds the namespace provider + @Module({ + imports: [ConfigModule.register(rootDef)], + providers: [emailConfig.asProvider(), EmailService], + }) + class EmailModule {} + + const moduleRef = await Test.createTestingModule({ + imports: [EmailModule], + }).compile(); + + const service = moduleRef.get(EmailService); + + // The injected value must be the validated config slice with default values + expect(service.cfg.SMTP_HOST).toBe("localhost"); + expect(service.cfg.SMTP_PORT).toBe(587); + expect(typeof service.cfg.SMTP_PORT).toBe("number"); + }); + + it("should throw ConfigValidationError when a required namespace var is missing", () => { + // Schema with a required field that has no default and won't be in env + const ns = defineNamespace("strict-ns", z.object({ REQUIRED_SECRET: z.string() })); + const provider = ns.asProvider() as { useFactory: (registry: Set) => unknown }; + + // Remove the env var if it exists so validation truly fails + const saved = process.env.REQUIRED_SECRET; + delete process.env.REQUIRED_SECRET; + + try { + const registry = new Set(); + expect(() => provider.useFactory(registry)).toThrow(ConfigValidationError); + } finally { + if (saved !== undefined) process.env.REQUIRED_SECRET = saved; + } + }); +}); diff --git a/src/define-namespace.ts b/src/define-namespace.ts new file mode 100644 index 0000000..b28b17d --- /dev/null +++ b/src/define-namespace.ts @@ -0,0 +1,246 @@ +/** + * @file define-namespace.ts + * @description + * Per-module scoped configuration for ConfigKit. + * + * Each NestJS feature module that needs config defines its own slice using + * `defineNamespace(name, schema)`. The returned `NamespacedConfig` object + * is self-contained โ€” it knows its name, its Zod schema, and how to register + * itself as a NestJS provider. + * + * How it fits into the system: + * 1. A feature module calls `defineNamespace('db', z.object({ ... }))`. + * 2. It adds `NamespacedConfig.asProvider()` to the `providers` array of its + * NestJS module. + * 3. ConfigModule (via forRoot / register) parses ALL namespaced schemas as + * part of the same validation pass, so misconfigured slices also prevent + * the app from booting. + * 4. Feature constructors inject the typed slice with `@InjectConfig('db')`. + * + * Contents: + * - DuplicateNamespaceError โ€” thrown when the same namespace is registered twice + * - NamespacedConfig โ€” holds the namespace name, schema, and asProvider() + * - defineNamespace() โ€” public factory function + * + * Acceptance criteria covered: + * - AC1: defineNamespace(namespace, schema) returns NamespacedConfig + * - AC2: NamespacedConfig registers its own injection token + * - AC3: Feature modules use NamespacedConfig.asProvider() + * - AC6: Duplicate namespace registration throws at module init + */ + +import { type Provider } from "@nestjs/common"; +import type { z } from "zod"; + +import { getNamespaceToken, NAMESPACE_REGISTRY_TOKEN } from "@/constants"; +import type { ConfigDefinition } from "@/define-config"; +import { defineConfig } from "@/define-config"; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Internal type helpers +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Constrains Zod schemas to ZodObject so keys can be accessed. */ +type AnyZodObject = z.ZodObject; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Errors +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Thrown synchronously during NestJS module initialization when the same + * namespace name is registered more than once. + * + * Duplicate registrations would silently overwrite each other's DI providers, + * causing one feature module to receive another's config โ€” this error prevents + * that from ever happening. + * + * @example + * ```typescript + * // Two feature modules both call defineNamespace('auth', ...) + * // โ†’ DuplicateNamespaceError: Namespace "auth" is already registered. + * ``` + */ +export class DuplicateNamespaceError extends Error { + constructor(namespace: string) { + super( + `Namespace "${namespace}" is already registered. ` + + `Each namespace must be unique across the entire application. ` + + `Check that no two modules call defineNamespace('${namespace}', ...).`, + ); + // Ensure instanceof checks work correctly after TypeScript transpilation + Object.setPrototypeOf(this, new.target.prototype); + this.name = "DuplicateNamespaceError"; + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// NamespacedConfig +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Holds a named, scoped config slice for a single feature module. + * + * Created by `defineNamespace()` โ€” do not instantiate directly. + * + * The generic `T` carries the Zod schema type so that `@InjectConfig(namespace)` + * can infer the exact output type when used in TypeScript constructors. + * + * @typeParam T - The Zod ZodObject schema for this namespace. + * + * @example + * ```typescript + * // database/database.config.ts + * export const dbConfig = defineNamespace('database', + * z.object({ + * DATABASE_URL: z.string().url(), + * DB_POOL_SIZE: z.coerce.number().default(5), + * }), + * ); + * + * // database/database.module.ts + * @Module({ + * providers: [dbConfig.asProvider(), DatabaseService], + * }) + * export class DatabaseModule {} + * + * // database/database.service.ts + * constructor(@InjectConfig('database') private cfg: typeof dbConfig.schema._output) {} + * ``` + */ +export class NamespacedConfig { + /** + * The unique name for this config slice. + * Used as the key in `@InjectConfig(namespace)` and to generate the DI token. + */ + public readonly namespace: string; + + /** + * The internal ConfigDefinition wrapping the Zod schema. + * Used by `asProvider()` to parse process.env at module init time. + */ + public readonly definition: ConfigDefinition; + + constructor(namespace: string, schema: T) { + // Store the namespace name for token lookups and duplicate detection + this.namespace = namespace; + // Wrap the raw Zod schema in a ConfigDefinition to reuse its parse() logic + this.definition = defineConfig(schema); + } + + /** + * Produces a NestJS `Provider` that parses and validates this namespace's + * slice of `process.env` at module initialization time. + * + * **How to use**: Add the result to the `providers` array of your feature + * module. NestJS will run the factory synchronously before any provider + * that depends on this namespace's token resolves. + * + * **Duplicate detection**: The factory injects the namespace registry (a + * `Set` populated by ConfigModule) and throws + * `DuplicateNamespaceError` if this namespace has already been registered. + * + * **Validation**: After the duplicate check, `definition.parse(process.env)` + * runs โ€” if any env vars in this slice are invalid, `ConfigValidationError` + * is thrown and the app never finishes booting. + * + * @returns A NestJS `Provider` that provides the validated config slice under + * the namespace-specific DI token. + * + * @example + * ```typescript + * @Module({ + * providers: [ + * dbConfig.asProvider(), // โ† add this + * DatabaseService, + * ], + * }) + * export class DatabaseModule {} + * ``` + */ + asProvider(): Provider { + const { namespace, definition } = this; + // The unique token for this namespace โ€” same token @InjectConfig() uses + const token = getNamespaceToken(namespace); + + return { + provide: token, + useFactory: (registry: Set) => { + // โ”€โ”€ Duplicate detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // registry is the Set populated by ConfigModule at startup. + // If this namespace is already there, two modules registered the same + // name โ€” throw immediately so the misconfiguration is obvious. + if (registry.has(namespace)) { + throw new DuplicateNamespaceError(namespace); + } + + // Mark this namespace as registered so subsequent registrations fail + registry.add(namespace); + + // โ”€โ”€ Validation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Parse this namespace's schema against process.env. + // Throws ConfigValidationError if any required vars are missing/invalid. + return definition.parse(process.env); + }, + // Inject the shared namespace registry from ConfigModule + inject: [NAMESPACE_REGISTRY_TOKEN], + }; + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// defineNamespace +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Declares a scoped, named config slice for a single feature module. + * + * Returns a `NamespacedConfig` that the feature module uses to: + * - Register a validated provider via `.asProvider()` + * - Type the injected value via `@InjectConfig(namespace)` + * + * The namespace name must be unique across the entire application โ€” attempting + * to call `asProvider()` on two configs with the same name will throw + * `DuplicateNamespaceError` at module init. + * + * @param namespace - A unique string identifier for this config slice. + * Use a domain-meaningful name like `'database'`, `'auth'`, + * `'email'`. Must be the same string passed to + * `@InjectConfig(namespace)`. + * @param schema - A `z.object(...)` describing the env vars this module needs. + * @returns `NamespacedConfig` โ€” call `.asProvider()` in your module's providers. + * + * @example + * ```typescript + * // auth/auth.config.ts + * import { defineNamespace } from '@ciscode/config-kit'; + * import { z } from 'zod'; + * + * export const authConfig = defineNamespace('auth', + * z.object({ + * JWT_SECRET: z.string().min(32), + * JWT_EXPIRES_IN: z.string().default('7d'), + * }), + * ); + * + * // auth/auth.module.ts + * @Module({ providers: [authConfig.asProvider(), AuthService] }) + * export class AuthModule {} + * + * // auth/auth.service.ts + * constructor( + * @InjectConfig('auth') private cfg: z.output + * ) {} + * + * getSecret(): string { + * return this.cfg.JWT_SECRET; // string โ€” never undefined + * } + * ``` + */ +export function defineNamespace( + namespace: string, + schema: T, +): NamespacedConfig { + // No side effects at definition time โ€” validation is deferred to asProvider() + return new NamespacedConfig(namespace, schema); +} diff --git a/src/dto/create-example.dto.ts b/src/dto/create-example.dto.ts deleted file mode 100644 index 094c25c..0000000 --- a/src/dto/create-example.dto.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { IsString, IsNotEmpty, MinLength, MaxLength, IsOptional } from "class-validator"; - -/** - * DTO for creating a new example resource - * - * DTOs define the shape of data for API requests and provide validation. - * Always use class-validator decorators for input validation. - * - * @example - * ```typescript - * const dto: CreateExampleDto = { - * name: 'Test Example', - * description: 'Optional description', - * }; - * ``` - */ -export class CreateExampleDto { - /** - * Name of the example resource - * @example "My Example" - */ - @IsString() - @IsNotEmpty() - @MinLength(3) - @MaxLength(100) - name!: string; - - /** - * Optional description - * @example "This is an example description" - */ - @IsString() - @IsOptional() - @MaxLength(500) - description?: string; -} diff --git a/src/dto/update-example.dto.ts b/src/dto/update-example.dto.ts deleted file mode 100644 index 2f865bf..0000000 --- a/src/dto/update-example.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { PartialType } from "@nestjs/mapped-types"; - -import { CreateExampleDto } from "./create-example.dto"; - -/** - * DTO for updating an existing example resource - * - * Uses PartialType to make all fields from CreateExampleDto optional. - * This follows NestJS best practices for update DTOs. - * - * @example - * ```typescript - * const dto: UpdateExampleDto = { - * name: 'Updated Name', // Only update name - * }; - * ``` - */ -export class UpdateExampleDto extends PartialType(CreateExampleDto) {} diff --git a/src/entities/example.entity.ts b/src/entities/example.entity.ts deleted file mode 100644 index 69811b2..0000000 --- a/src/entities/example.entity.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Example Entity - * - * Represents the domain model for the Example resource. - * - * For Mongoose: - * ```typescript - * import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; - * import { Document } from 'mongoose'; - * - * @Schema({ timestamps: true }) - * export class Example extends Document { - * @Prop({ required: true }) - * name: string; - * - * @Prop() - * description?: string; - * } - * - * export const ExampleSchema = SchemaFactory.createForClass(Example); - * ``` - * - * For TypeORM: - * ```typescript - * import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; - * - * @Entity('examples') - * export class Example { - * @PrimaryGeneratedColumn('uuid') - * id: string; - * - * @Column() - * name: string; - * - * @Column({ nullable: true }) - * description?: string; - * - * @CreateDateColumn() - * createdAt: Date; - * - * @UpdateDateColumn() - * updatedAt: Date; - * } - * ``` - * - * NOTE: Entities are NEVER exported from the module's public API. - * They are internal implementation details. - */ - -export class Example { - id!: string; - name!: string; - description?: string; - createdAt!: Date; - updatedAt!: Date; -} diff --git a/src/errors/config-validation.error.spec.ts b/src/errors/config-validation.error.spec.ts new file mode 100644 index 0000000..6bb6c31 --- /dev/null +++ b/src/errors/config-validation.error.spec.ts @@ -0,0 +1,115 @@ +/** + * @file config-validation.error.spec.ts + * @description + * Unit tests for ConfigValidationError. + * + * Covers: + * - Extends Error with correct name + * - instanceof checks work after TypeScript/ES5 prototype fix + * - fields array is populated with the provided ZodIssues + * - Message includes all field paths and Zod messages + * - Root-level issues (empty path) are displayed as "(root)" + */ + +import type { ZodIssue } from "zod"; + +import { ConfigValidationError } from "@/errors/config-validation.error"; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Helpers +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Builds a minimal ZodIssue stub for testing without invoking Zod's parser */ +function makeIssue(path: string[], message: string): ZodIssue { + return { + code: "custom", + path, + message, + } as ZodIssue; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Tests +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("ConfigValidationError", () => { + // โ”€โ”€ Identity โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("should be an instance of Error", () => { + const err = new ConfigValidationError([makeIssue(["PORT"], "Required")]); + expect(err).toBeInstanceOf(Error); + }); + + it("should be an instance of ConfigValidationError", () => { + // Verifies the Object.setPrototypeOf fix works correctly + const err = new ConfigValidationError([makeIssue(["PORT"], "Required")]); + expect(err).toBeInstanceOf(ConfigValidationError); + }); + + it("should have name set to 'ConfigValidationError'", () => { + const err = new ConfigValidationError([makeIssue(["PORT"], "Required")]); + expect(err.name).toBe("ConfigValidationError"); + }); + + // โ”€โ”€ fields โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("should store the provided ZodIssue array on .fields", () => { + const issues = [makeIssue(["PORT"], "Expected number"), makeIssue(["DB_URL"], "Invalid url")]; + const err = new ConfigValidationError(issues); + + // fields must be the exact same array reference or equal value + expect(err.fields).toEqual(issues); + }); + + it("should have an empty fields array when no issues are provided", () => { + const err = new ConfigValidationError([]); + expect(err.fields).toHaveLength(0); + }); + + it("should expose fields as readonly (frozen array behavior)", () => { + const issues = [makeIssue(["PORT"], "Required")]; + const err = new ConfigValidationError(issues); + + // fields is readonly in the type system โ€” verify the value is returned as-is + expect(Array.isArray(err.fields)).toBe(true); + }); + + // โ”€โ”€ message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + it("should include the field path in the error message", () => { + const err = new ConfigValidationError([makeIssue(["DATABASE_URL"], "Invalid url")]); + expect(err.message).toContain("DATABASE_URL"); + }); + + it("should include the ZodIssue message in the error message", () => { + const err = new ConfigValidationError([ + makeIssue(["PORT"], "Expected number, received string"), + ]); + expect(err.message).toContain("Expected number, received string"); + }); + + it("should include all failing fields in the message", () => { + const err = new ConfigValidationError([ + makeIssue(["PORT"], "Required"), + makeIssue(["DATABASE_URL"], "Invalid url"), + makeIssue(["JWT_SECRET"], "Too short"), + ]); + + // All three field names must appear in the formatted message + expect(err.message).toContain("PORT"); + expect(err.message).toContain("DATABASE_URL"); + expect(err.message).toContain("JWT_SECRET"); + }); + + it("should display '(root)' for issues with an empty path", () => { + // Root-level issues happen when the top-level object itself is invalid + const err = new ConfigValidationError([makeIssue([], "Expected object, received string")]); + expect(err.message).toContain("(root)"); + }); + + it("should display nested paths joined with '.'", () => { + // Nested paths like ['database', 'host'] should appear as "database.host" + const err = new ConfigValidationError([makeIssue(["database", "host"], "Required")]); + expect(err.message).toContain("database.host"); + }); +}); diff --git a/src/errors/config-validation.error.ts b/src/errors/config-validation.error.ts new file mode 100644 index 0000000..8d3f9e1 --- /dev/null +++ b/src/errors/config-validation.error.ts @@ -0,0 +1,64 @@ +/** + * @file config-validation.error.ts + * @description + * Custom error class thrown when process.env fails validation against a + * consumer-provided Zod schema. + * + * Thrown synchronously during ConfigModule initialization so the NestJS app + * never boots in a misconfigured state. + * + * Contents: + * - ConfigValidationError โ€” extends Error, carries the full ZodIssue list + */ + +import type { ZodIssue } from "zod"; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Error class +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Thrown when one or more environment variables fail Zod schema validation. + * + * The `fields` array contains every ZodIssue that failed โ€” callers can inspect + * it programmatically or let the built-in message surface them all at once. + * + * @example + * ```typescript + * try { + * defineConfig(schema).parse(process.env); + * } catch (err) { + * if (err instanceof ConfigValidationError) { + * console.error(err.fields); // ZodIssue[] + * } + * } + * ``` + */ +export class ConfigValidationError extends Error { + /** + * All ZodIssue objects that caused validation to fail. + * Each issue contains the field path, the failing code, and a human-readable message. + */ + public readonly fields: ZodIssue[]; + + constructor(fields: ZodIssue[]) { + // Build a multi-line message so developers immediately see what's wrong in logs + const lines = fields.map( + (issue) => + // Format each issue as " โ€ข FIELD_PATH: error message" + ` โ€ข ${issue.path.join(".") || "(root)"}: ${issue.message}`, + ); + + super(`Config validation failed:\n${lines.join("\n")}`); + + // Set the prototype explicitly so `instanceof ConfigValidationError` works + // correctly after TypeScript compiles to ES5 / CommonJS targets + Object.setPrototypeOf(this, new.target.prototype); + + // Give the error a recognisable name in stack traces + this.name = "ConfigValidationError"; + + // Store the raw ZodIssue list for programmatic inspection + this.fields = fields; + } +} diff --git a/src/example-kit.module.ts b/src/example-kit.module.ts deleted file mode 100644 index d0cb216..0000000 --- a/src/example-kit.module.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Module, DynamicModule, Provider } from "@nestjs/common"; -import { ExampleService } from "@services/example.service"; - -/** - * Options for configuring the Example Kit module - */ -export interface ExampleKitOptions { - /** - * Enable debug mode - * @default false - */ - debug?: boolean; - - /** - * Custom configuration options - */ - // Add your configuration options here -} - -/** - * Async options for dynamic module configuration - */ -export interface ExampleKitAsyncOptions { - useFactory: () => Promise | ExampleKitOptions; - inject?: any[]; -} - -/** - * Example Kit Module - * - * A reusable NestJS module template demonstrating best practices - * for building npm packages. - * - * @example - * ```typescript - * // Synchronous configuration - * @Module({ - * imports: [ - * ExampleKitModule.forRoot({ - * debug: true, - * }), - * ], - * }) - * export class AppModule {} - * - * // Asynchronous configuration - * @Module({ - * imports: [ - * ExampleKitModule.forRootAsync({ - * useFactory: (config: ConfigService) => ({ - * debug: config.get('DEBUG') === 'true', - * }), - * inject: [ConfigService], - * }), - * ], - * }) - * export class AppModule {} - * ``` - */ -@Module({}) -export class ExampleKitModule { - /** - * Register the module with synchronous configuration - * @param options - Configuration options - * @returns Dynamic module - */ - static forRoot(options: ExampleKitOptions = {}): DynamicModule { - const providers: Provider[] = [ - { - provide: "EXAMPLE_KIT_OPTIONS", - useValue: options, - }, - ExampleService, - ]; - - return { - module: ExampleKitModule, - providers, - exports: [ExampleService], - global: false, - }; - } - - /** - * Register the module with asynchronous configuration - * @param options - Async configuration options - * @returns Dynamic module - */ - static forRootAsync(options: ExampleKitAsyncOptions): DynamicModule { - const providers: Provider[] = [ - { - provide: "EXAMPLE_KIT_OPTIONS", - useFactory: options.useFactory, - inject: options.inject || [], - }, - ExampleService, - ]; - - return { - module: ExampleKitModule, - providers, - exports: [ExampleService], - global: false, - }; - } -} diff --git a/src/guards/example.guard.ts b/src/guards/example.guard.ts deleted file mode 100644 index 7f5898c..0000000 --- a/src/guards/example.guard.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; -import { Observable } from "rxjs"; - -/** - * Example Guard - * - * Guards determine whether a request should be handled by the route handler. - * Use guards for authentication, authorization, and request validation. - * - * @example - * ```typescript - * // Apply to controller - * @Controller('example') - * @UseGuards(ExampleGuard) - * export class ExampleController {} - * - * // Apply to specific route - * @Get() - * @UseGuards(ExampleGuard) - * async findAll() {} - * - * // Apply globally - * app.useGlobalGuards(new ExampleGuard()); - * ``` - */ -@Injectable() -export class ExampleGuard implements CanActivate { - /** - * Determines if the request should be allowed - * @param context - Execution context - * @returns True if request is allowed, false otherwise - */ - canActivate(_context: ExecutionContext): boolean | Promise | Observable { - // const request = _context.switchToHttp().getRequest(); - - // Implement your guard logic here - // Example: Check if user is authenticated - // return !!request.user; - - return true; - } -} diff --git a/src/index.ts b/src/index.ts index 3026198..6b5ac56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,60 +1,57 @@ import "reflect-metadata"; // ============================================================================ -// PUBLIC API EXPORTS +// PUBLIC API EXPORTS โ€” @ciscode/config-kit // ============================================================================ -// This file defines what consumers of your module can import. -// ONLY export what is necessary for external use. -// Keep entities, repositories, and internal implementation details private. - -// ============================================================================ -// MODULE +// Only export what consumers need. +// Internal wiring (module tokens, provider factories) stays private. // ============================================================================ -export { ExampleKitModule } from "./example-kit.module"; -export type { ExampleKitOptions, ExampleKitAsyncOptions } from "./example-kit.module"; // ============================================================================ -// SERVICES (Main API) +// VALIDATION ENGINE (COMPT-50) // ============================================================================ -// Export services that consumers will interact with -export { ExampleService } from "./services/example.service"; +// defineConfig() โ€” declare your env shape with a Zod schema +// ConfigDefinition โ€” the wrapper returned by defineConfig(); passed to ConfigModule +// InferConfig โ€” utility type: extracts the resolved config type from a ConfigDefinition +// ============================================================================ +export { defineConfig, ConfigDefinition } from "./define-config"; +export type { InferConfig } from "./define-config"; // ============================================================================ -// DTOs (Public Contracts) +// NESTJS MODULE & SERVICE (COMPT-51) +// ============================================================================ +// ConfigModule โ€” dynamic module with register / registerAsync / forRoot +// ConfigService โ€” injectable service with typed .get(key) and .getAll() methods +// ConfigModuleAsyncOptions โ€” options shape for registerAsync (useFactory / useClass / useExisting) +// ConfigModuleOptionsFactory โ€” interface to implement for useClass / useExisting patterns // ============================================================================ -// DTOs are the public interface for your API -// Consumers depend on these, so they must be stable -export { CreateExampleDto } from "./dto/create-example.dto"; -export { UpdateExampleDto } from "./dto/update-example.dto"; +export { ConfigModule } from "./config.module"; +export type { ConfigModuleAsyncOptions, ConfigModuleOptionsFactory } from "./config.module"; +export { ConfigService } from "./config.service"; // ============================================================================ -// GUARDS (For Route Protection) +// NAMESPACED CONFIG (COMPT-52) // ============================================================================ -// Export guards so consumers can use them in their apps -export { ExampleGuard } from "./guards/example.guard"; +// defineNamespace() โ€” declare a scoped config slice for a feature module +// NamespacedConfig โ€” holds namespace name, schema, and asProvider() +// InjectConfig() โ€” @InjectConfig('namespace') parameter decorator +// DuplicateNamespaceError โ€” thrown when same namespace is registered twice +// ============================================================================ +export { defineNamespace, NamespacedConfig, DuplicateNamespaceError } from "./define-namespace"; +export { InjectConfig } from "./decorators/inject-config.decorator"; // ============================================================================ -// DECORATORS (For Dependency Injection & Metadata) +// ERRORS +// ============================================================================ +// ConfigValidationError โ€” thrown at startup when process.env fails the schema. +// Consumers can catch this in bootstrap() for custom error formatting. // ============================================================================ -// Export decorators for use in consumer controllers/services -export { ExampleData, ExampleParam } from "./decorators/example.decorator"; +export { ConfigValidationError } from "./errors/config-validation.error"; // ============================================================================ -// TYPES & INTERFACES (For TypeScript Typing) +// โŒ NEVER EXPORT (Internal implementation details) // ============================================================================ -// Export types and interfaces for TypeScript consumers -// export type { YourCustomType } from './types'; - +// - CONFIG_VALUES_TOKEN and other raw DI tokens +// - Internal parse helpers +// - Namespace registry internals (COMPT-52) // ============================================================================ -// โŒ NEVER EXPORT (Internal Implementation) -// ============================================================================ -// These should NEVER be exported from a module: -// - Entities (internal domain models) -// - Repositories (infrastructure details) -// -// Example of what NOT to export: -// โŒ export { Example } from './entities/example.entity'; -// โŒ export { ExampleRepository } from './repositories/example.repository'; -// -// Why? These are internal implementation details that can change. -// Consumers should only work with DTOs and Services. diff --git a/src/repositories/example.repository.ts b/src/repositories/example.repository.ts deleted file mode 100644 index a31f940..0000000 --- a/src/repositories/example.repository.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Example } from "@entities/example.entity"; -import { Injectable } from "@nestjs/common"; - -/** - * Example Repository - * - * Handles data access for Example entities. - * Repositories encapsulate database operations and provide a clean interface - * for services to interact with the database. - * - * For Mongoose: - * ```typescript - * import { Injectable } from '@nestjs/common'; - * import { InjectModel } from '@nestjs/mongoose'; - * import { Model } from 'mongoose'; - * import { Example } from '@entities/example.entity'; - * - * @Injectable() - * export class ExampleRepository { - * constructor( - * @InjectModel(Example.name) - * private readonly model: Model, - * ) {} - * - * async create(data: Partial): Promise { - * const created = new this.model(data); - * return created.save(); - * } - * - * async findById(id: string): Promise { - * return this.model.findById(id).lean().exec(); - * } - * } - * ``` - * - * For TypeORM: - * ```typescript - * import { Injectable } from '@nestjs/common'; - * import { InjectRepository } from '@nestjs/typeorm'; - * import { Repository } from 'typeorm'; - * import { Example } from '@entities/example.entity'; - * - * @Injectable() - * export class ExampleRepository { - * constructor( - * @InjectRepository(Example) - * private readonly repo: Repository, - * ) {} - * - * async create(data: Partial): Promise { - * const entity = this.repo.create(data); - * return this.repo.save(entity); - * } - * - * async findById(id: string): Promise { - * return this.repo.findOne({ where: { id } }); - * } - * } - * ``` - * - * NOTE: Repositories are NEVER exported from the module's public API. - * Services use repositories internally, but consumers only interact with services. - */ - -@Injectable() -export class ExampleRepository { - /** - * Create a new example - * @param data - Partial example data - * @returns Created example - */ - async create(data: Partial): Promise { - // Implement your database logic here - return { - id: "generated-id", - ...data, - createdAt: new Date(), - updatedAt: new Date(), - } as Example; - } - - /** - * Find example by ID - * @param id - Example identifier - * @returns Example or null if not found - */ - async findById(id: string): Promise { - // Implement your database logic here - console.log("Finding example by id:", id); - return null; - } - - /** - * Find all examples - * @returns Array of examples - */ - async findAll(): Promise { - // Implement your database logic here - return []; - } - - /** - * Update example - * @param id - Example identifier - * @param data - Partial data to update - * @returns Updated example or null if not found - */ - async update(id: string, data: Partial): Promise { - // Implement your database logic here - console.log("Updating example:", id, data); - return null; - } - - /** - * Delete example - * @param id - Example identifier - * @returns True if deleted, false if not found - */ - async delete(id: string): Promise { - // Implement your database logic here - console.log("Deleting example:", id); - return false; - } -} diff --git a/src/services/example.service.ts b/src/services/example.service.ts deleted file mode 100644 index 96e3691..0000000 --- a/src/services/example.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; - -import { ExampleKitOptions } from "../example-kit.module"; - -/** - * Example Service - * - * Main service providing the core functionality of the module. - * This is what consumers of your module will primarily interact with. - * - * @example - * ```typescript - * constructor(private readonly exampleService: ExampleService) {} - * - * async someMethod() { - * const result = await this.exampleService.doSomething('data'); - * return result; - * } - * ``` - */ -@Injectable() -export class ExampleService { - constructor( - @Inject("EXAMPLE_KIT_OPTIONS") - private readonly _options: ExampleKitOptions, - ) {} - - /** - * Example method demonstrating service functionality - * @param data - Input data to process - * @returns Processed result - * @example - * ```typescript - * const result = await service.doSomething('test'); - * // Returns: "Processed: test" - * ``` - */ - async doSomething(data: string): Promise { - if (this._options.debug) { - console.log("[ExampleService] Processing:", data); - } - return `Processed: ${data}`; - } - - /** - * Example method for retrieving data - * @param id - Unique identifier - * @returns Retrieved data or null - */ - async findById(id: string): Promise { - // Implement your logic here - return { id, data: "example" }; - } -} diff --git a/tsconfig.json b/tsconfig.json index e92d316..21aceb2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "esModuleInterop": true, "resolveJsonModule": true, "skipLibCheck": true, - "types": ["jest"], + "types": ["jest", "node"], "baseUrl": ".", "experimentalDecorators": true, "emitDecoratorMetadata": true,