diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e49dc9..9b6bd07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: Build on: push: @@ -34,33 +34,4 @@ jobs: run: yarn build - name: Run tests - run: yarn test - - -# publish: -# needs: build-and-test -# if: github.event_name == 'push' && github.ref == 'refs/heads/main' -# runs-on: ubuntu-latest -# -# steps: -# - uses: actions/checkout@v5 -# -# - name: Enable Corepack -# run: corepack enable -# -# - name: Use Node.js -# uses: actions/setup-node@v5 -# with: -# node-version: '20.x' -# registry-url: 'https://registry.npmjs.org' -# -# - name: Install dependencies -# run: yarn install --immutable -# -# - name: Build packages -# run: yarn build -# -# - name: Publish to NPM -# run: yarn publish:packages -# env: -# NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: yarn test \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1b7dcea --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,31 @@ +name: publish.yml +on: + release: + types: [ published ] + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Enable Corepack + run: corepack enable + + - name: Use Node.js + uses: actions/setup-node@v5 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: yarn install --immutable + + - name: Build packages + run: yarn build + + - name: Publish to NPM + run: yarn publish:packages + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} \ No newline at end of file diff --git a/.yarnrc.yml b/.yarnrc.yml index 3186f3f..c6e883c 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1,2 @@ nodeLinker: node-modules + diff --git a/README.md b/README.md index 60c9871..8502dd5 100644 --- a/README.md +++ b/README.md @@ -504,6 +504,6 @@ MIT Β© Ganesan Arunachalam ## Support - πŸ“š [Documentation](./packages/core/README.md) -- πŸ’¬ [GitHub Issues](https://github.com/snow-tzu/type-config/issues) +- πŸ’¬ [GitHub Issues](https://github.com/ganesanarun/type-config/issues) - πŸ“§ [Email Support](mailto:support@example.com) - πŸ’‘ [Examples](./examples) diff --git a/examples/README.md b/examples/README.md index 4ff93f1..1627653 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,7 +12,6 @@ Basic Express.js application using Type Config. - Express middleware integration - Profile-based configuration -- Hot reload support - Type-safe configuration classes **Start:** `cd express-basic && yarn dev` @@ -27,7 +26,6 @@ Basic Fastify application using Type Config. - Fastify plugin integration - Profile-based configuration -- Hot reload support - Type-safe configuration classes - Async/await support @@ -44,7 +42,6 @@ Basic NestJS application using Type Config. - NestJS module integration - Dependency injection - Profile-based configuration -- Hot reload support - Type-safe configuration classes **Start:** `cd nestjs-basic && yarn dev` @@ -57,8 +54,7 @@ NestJS application with remote configuration server support. **Features:** -- Remote Spring Cloud Config Server integration -- Automatic config refresh with polling +- Remote Consul integration - Fallback to local configuration - Bearer token authentication - Manual refresh endpoint @@ -85,7 +81,6 @@ Pure Node.js application (no framework) using Type Config Core. - Direct ConfigManager API usage - Built-in HTTP server - Profile-based configuration -- Hot reload support - Graceful shutdown handling - No framework dependencies @@ -93,6 +88,32 @@ Pure Node.js application (no framework) using Type Config Core. --- +### 6. Nested Configuration Classes (`nested-basic/`) + +NestJS application demonstrating nested configuration classes with full decorator support. + +**Features:** + +- **Single-level nesting**: Configuration classes containing other configuration classes +- **Multi-level nesting**: Configuration classes nested multiple levels deep (e.g., `app.server.ssl`) +- **@DefaultValue decorator**: Default values on nested class properties +- **@Required decorator**: Required validation on nested class properties +- **@Validate() decorator**: class-validator integration on nested classes +- **Optional @ConfigProperty**: Properties bind without @ConfigProperty when names match +- **Profile-specific configuration**: Different values for development and production + +**Start:** `cd nested-basic && yarn dev` + +**Production:** `NODE_ENV=production yarn dev` + +**Key Concepts:** +- Nested classes provide modularity and type safety for complex configurations +- All decorators work recursively at all nesting levels +- No @ConfigProperty needed when property names match configuration keys +- Validation errors include full property paths for easy debugging + +--- + ## Quick Start ### Install All Dependencies @@ -210,13 +231,6 @@ Each example uses a specific package: ## Testing Configuration Changes -### Hot Reload (Development) - -1. Start any example with `yarn dev` -2. Modify the config file (e.g., `config/application.yml`) -3. Watch the console for reload message -4. Visit the `/config` endpoint to see changes - ### Profile Switching ```bash diff --git a/examples/nested-basic/.gitignore b/examples/nested-basic/.gitignore new file mode 100644 index 0000000..e97095c --- /dev/null +++ b/examples/nested-basic/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.DS_Store +*.tsbuildinfo diff --git a/examples/nested-basic/README.md b/examples/nested-basic/README.md new file mode 100644 index 0000000..bb3ca93 --- /dev/null +++ b/examples/nested-basic/README.md @@ -0,0 +1,185 @@ +# Nested Configuration Classes Example + +This example demonstrates the use of nested configuration classes with full decorator support in Type Config. + +## Features Demonstrated + +- **Single-level nesting**: Configuration classes containing other configuration classes +- **Multi-level nesting**: Configuration classes nested multiple levels deep +- **@DefaultValue decorator**: Default values on nested class properties +- **@Required decorator**: Required validation on nested class properties +- **@Validate() decorator**: class-validator integration on nested classes +- **Optional @ConfigProperty**: Properties bind without @ConfigProperty when names match +- **Profile-specific configuration**: Different values for development and production + +## Configuration Structure + +``` +AppConfig (root) +β”œβ”€β”€ server: ServerConfig +β”‚ └── ssl: SslConfig (2-level nesting) +β”œβ”€β”€ database: DatabaseConfig +β”‚ └── pool: PoolConfig (2-level nesting) +└── services: ServicesConfig + β”œβ”€β”€ api: ApiConfig + └── cache: CacheConfig +``` + +## Running the Example + +### Development Mode + +```bash +# Install dependencies (from workspace root) +yarn install + +# Run in development mode +cd examples/nested-basic +yarn dev +``` + +### Production Mode + +```bash +# Build the application +yarn build + +# Run in production mode +yarn start:prod +``` + +## Configuration Files + +- `config/application.yml` - Base configuration with defaults +- `config/application-production.yml` - Production overrides + +## Key Concepts + +### 1. Nested Classes Without @ConfigProperty + +When property names match configuration keys, @ConfigProperty is optional: + +```typescript +@ConfigurationProperties('app') +class AppConfig { + server: ServerConfig; // No @ConfigProperty needed! + database: DatabaseConfig; +} +``` + +### 2. Decorators Work at All Levels + +All decorators (@DefaultValue, @Required, @Validate) work on nested classes: + +```typescript +class PoolConfig { + @DefaultValue(10) + maxConnections: number; // Default applied even though it's nested +} + +class DatabaseConfig { + pool: PoolConfig; // Nested class with decorators +} +``` + +### 3. Multi-Level Nesting + +Nest as deeply as needed: + +```typescript +class SslConfig { + @DefaultValue(false) + enabled: boolean; +} + +class ServerConfig { + ssl: SslConfig; // Level 2 +} + +class AppConfig { + server: ServerConfig; // Level 1 +} +``` + +### 4. Validation on Nested Classes + +Use @Validate() on nested classes for comprehensive validation: + +```typescript +@Validate() +class ApiConfig { + @IsUrl() + @Required() + endpoint: string; + + @IsNumber() + @Min(1000) + @DefaultValue(5000) + timeout: number; +} +``` + +## Expected Output + +When you run the example, you'll see: + +``` +πŸš€ Application Configuration Loaded +πŸ“ Profile: development + +=== Server Configuration === +Host: localhost +Port: 3000 +SSL Enabled: false +SSL Cert Path: ./certs/cert.pem + +=== Database Configuration === +Host: localhost +Port: 5432 +Username: dev_user +Max Connections: 10 +Min Connections: 2 + +=== Services Configuration === +API Endpoint: https://api.example.com +API Timeout: 5000ms +Cache Host: localhost +Cache Port: 6379 +Cache TTL: 3600s + +πŸš€ Server running on http://localhost:3000 +``` + +## Migration Example + +This example also shows how to migrate from plain objects to nested classes: + +**Before** (plain object): +```typescript +@ConfigurationProperties('app') +class AppConfig { + @ConfigProperty('database') + database: any; // Plain object, no type safety or decorators +} +``` + +**After** (nested class): +```typescript +class DatabaseConfig { + @Required() + host: string; + + @DefaultValue(5432) + port: number; +} + +@ConfigurationProperties('app') +class AppConfig { + database: DatabaseConfig; // Fully typed with decorator support! +} +``` + +## Learn More + +- [Type Config Documentation](../../packages/core/README.md) +- [Configuration File Management](../../packages/core/CONFIG_FILES.md) diff --git a/examples/nested-basic/config/application-production.yml b/examples/nested-basic/config/application-production.yml new file mode 100644 index 0000000..e99c85f --- /dev/null +++ b/examples/nested-basic/config/application-production.yml @@ -0,0 +1,25 @@ +app: + server: + host: 0.0.0.0 + port: 8080 + ssl: + enabled: true + certPath: /etc/ssl/certs/app-cert.pem + + database: + host: prod-db.example.com + port: 5432 + username: prod_user + password: prod_secure_pass + pool: + maxConnections: 50 + minConnections: 5 + + services: + api: + timeout: 10000 + + cache: + host: prod-cache.example.com + port: 6379 + ttl: 7200 diff --git a/examples/nested-basic/config/application.yml b/examples/nested-basic/config/application.yml new file mode 100644 index 0000000..259c43b --- /dev/null +++ b/examples/nested-basic/config/application.yml @@ -0,0 +1,3 @@ +app: + database: + host: ${DB_HOST:localhost} \ No newline at end of file diff --git a/examples/nested-basic/config/temporary.yml b/examples/nested-basic/config/temporary.yml new file mode 100644 index 0000000..a55346c --- /dev/null +++ b/examples/nested-basic/config/temporary.yml @@ -0,0 +1,22 @@ +app: + server: + host: localhost + port: 3000 + ssl: + enabled: false + certPath: ./certs/cert.pem + + database: + host: localhost + port: 5432 + username: dev_user + password: dev_pass + pool: + maxConnections: 10 + minConnections: 2 + + services: + cache: + host: localhost + port: 6379 + ttl: 3600 diff --git a/examples/nested-basic/package.json b/examples/nested-basic/package.json new file mode 100644 index 0000000..63ca6af --- /dev/null +++ b/examples/nested-basic/package.json @@ -0,0 +1,29 @@ +{ + "name": "nested-config-example", + "version": "1.0.0", + "private": true, + "description": "Example demonstrating nested configuration classes with decorators", + "scripts": { + "dev": "ts-node src/main.ts", + "start": "cd dist && node main.js", + "build": "tsc && npm run copy:config", + "copy:config": "mkdir -p dist && cp -r config dist/", + "start:prod": "cd dist && NODE_ENV=production node main.js" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@snow-tzu/type-config-nestjs": "workspace:*", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@types/node": "^20.10.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.0" + } +} diff --git a/examples/nested-basic/src/app.controller.ts b/examples/nested-basic/src/app.controller.ts new file mode 100644 index 0000000..e017a2c --- /dev/null +++ b/examples/nested-basic/src/app.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getConfig(): any { + return this.appService.getConfigSummary(); + } + + @Get('/health') + health(): { status: string } { + return { status: 'ok' }; + } +} diff --git a/examples/nested-basic/src/app.module.ts b/examples/nested-basic/src/app.module.ts new file mode 100644 index 0000000..4e92b4d --- /dev/null +++ b/examples/nested-basic/src/app.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; +import { TypeConfigModule, CONFIG_MANAGER_TOKEN } from '@snow-tzu/type-config-nestjs'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { AppConfig } from './config/app.config'; +import { ConfigManager } from '@snow-tzu/type-config'; +import * as path from 'path'; + +@Module({ + imports: [ + TypeConfigModule.forRoot({ + profile: process.env.NODE_ENV || 'development', + configDir: path.join(__dirname, '../config'), + enableHotReload: true, + validateOnBind: true, + isGlobal: true, + }), + ], + controllers: [AppController], + providers: [ + AppService, + { + provide: AppConfig, + useFactory: (configManager: ConfigManager) => { + console.log(configManager.getAll()) + return configManager.bind(AppConfig); + }, + inject: [CONFIG_MANAGER_TOKEN], + }, + ], +}) +export class AppModule {} diff --git a/examples/nested-basic/src/app.service.ts b/examples/nested-basic/src/app.service.ts new file mode 100644 index 0000000..694ec3b --- /dev/null +++ b/examples/nested-basic/src/app.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { AppConfig } from './config/app.config'; + +@Injectable() +export class AppService { + constructor(private readonly appConfig: AppConfig) {} + + getConfigSummary(): any { + console.log(this.appConfig) + return { + server: { + host: this.appConfig.server.host, + port: this.appConfig.server.port, + ssl: { + enabled: this.appConfig.server.ssl.enabled, + certPath: this.appConfig.server.ssl.certPath, + }, + }, + database: { + host: this.appConfig.database.host, + port: this.appConfig.database.port, + username: this.appConfig.database.username, + pool: { + maxConnections: this.appConfig.database.pool.maxConnections, + minConnections: this.appConfig.database.pool.minConnections, + }, + }, + services: { + api: { + endpoint: this.appConfig.services.api.endpoint, + timeout: this.appConfig.services.api.timeout, + }, + cache: { + host: this.appConfig.services.cache.host, + port: this.appConfig.services.cache.port, + ttl: this.appConfig.services.cache.ttl, + }, + }, + }; + } + + printConfiguration(): void { + console.log('\n=== Server Configuration ==='); + console.log(this.appConfig) + + console.log(`Host: ${this.appConfig.server.host}`); + console.log(`Port: ${this.appConfig.server.port}`); + console.log(`SSL Enabled: ${this.appConfig.server.ssl.enabled}`); + console.log(`SSL Cert Path: ${this.appConfig.server.ssl.certPath}`); + + console.log('\n=== Database Configuration ==='); + console.log(`Host: ${this.appConfig.database.host}`); + console.log(`Port: ${this.appConfig.database.port}`); + console.log(`Username: ${this.appConfig.database.username}`); + console.log(`Max Connections: ${this.appConfig.database.pool.maxConnections}`); + console.log(`Min Connections: ${this.appConfig.database.pool.minConnections}`); + + console.log('\n=== Services Configuration ==='); + console.log(`API Endpoint: ${this.appConfig.services.api.endpoint}`); + console.log(`API Timeout: ${this.appConfig.services.api.timeout}ms`); + console.log(`Cache Host: ${this.appConfig.services.cache.host}`); + console.log(`Cache Port: ${this.appConfig.services.cache.port}`); + console.log(`Cache TTL: ${this.appConfig.services.cache.ttl}s`); + } +} diff --git a/examples/nested-basic/src/config/api.config.ts b/examples/nested-basic/src/config/api.config.ts new file mode 100644 index 0000000..74f04a6 --- /dev/null +++ b/examples/nested-basic/src/config/api.config.ts @@ -0,0 +1,24 @@ +import { DefaultValue, ConfigProperty, Validate } from '@snow-tzu/type-config-nestjs'; +import { IsUrl, IsNumber, Min, Max } from 'class-validator'; + +/** + * API Service Configuration + * Demonstrates: + * - @Required with @IsUrl validation + * - @DefaultValue with range validation + * - @Validate() for class-validator integration + */ +@Validate() +export class ApiConfig { + @ConfigProperty() + @IsUrl() + @DefaultValue('https://api.example.com') + endpoint!: string; + + @ConfigProperty() + @DefaultValue(5000) + @IsNumber() + @Min(1000) + @Max(30000) + timeout!: number; +} diff --git a/examples/nested-basic/src/config/app.config.ts b/examples/nested-basic/src/config/app.config.ts new file mode 100644 index 0000000..fdb3b68 --- /dev/null +++ b/examples/nested-basic/src/config/app.config.ts @@ -0,0 +1,37 @@ +import { ConfigurationProperties, ConfigProperty } from '@snow-tzu/type-config-nestjs'; +import { ServerConfig } from './server.config'; +import { DatabaseConfig } from './database.config'; +import { ServicesConfig } from './services.config'; +import { Validate } from '@snow-tzu/type-config'; +import { ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * Root Application Configuration + * Demonstrates: + * - @ConfigurationProperties with prefix + * - Multiple nested configuration classes + * - No @ConfigProperty needed when property names match config keys + * - Multi-level nesting (server.ssl, database.pool) + * - @ValidateNested() and @Type() for nested class validation + */ +@ConfigurationProperties('app') +@Validate() +export class AppConfig { + // Nested classes - @ConfigProperty optional but helps with discovery + // When using @Validate() on parent class, nested classes need @ValidateNested() and @Type() + @ConfigProperty() + @ValidateNested() + @Type(() => ServerConfig) + server!: ServerConfig; + + @ConfigProperty() + @ValidateNested() + @Type(() => DatabaseConfig) + database!: DatabaseConfig; + + @ConfigProperty() + @ValidateNested() + @Type(() => ServicesConfig) + services!: ServicesConfig; +} diff --git a/examples/nested-basic/src/config/cache.config.ts b/examples/nested-basic/src/config/cache.config.ts new file mode 100644 index 0000000..4b0621d --- /dev/null +++ b/examples/nested-basic/src/config/cache.config.ts @@ -0,0 +1,30 @@ +import { Required, DefaultValue, ConfigProperty, Validate } from '@snow-tzu/type-config-nestjs'; +import { IsString, IsNumber, Min, Max } from 'class-validator'; + +/** + * Cache Service Configuration + * Demonstrates: + * - Mix of @Required and @DefaultValue + * - @Validate() for validation + */ +@Validate() +export class CacheConfig { + @ConfigProperty() + @Required() + @IsString() + @DefaultValue('localhost') + host!: string; + + @ConfigProperty() + @DefaultValue(6379) + @IsNumber() + @Min(1) + @Max(65535) + port!: number; + + @ConfigProperty() + @DefaultValue(3600) + @IsNumber() + @Min(60) + ttl!: number; +} diff --git a/examples/nested-basic/src/config/database.config.ts b/examples/nested-basic/src/config/database.config.ts new file mode 100644 index 0000000..683cb45 --- /dev/null +++ b/examples/nested-basic/src/config/database.config.ts @@ -0,0 +1,42 @@ +import { Required, DefaultValue, ConfigProperty, Validate } from '@snow-tzu/type-config-nestjs'; +import { IsString, IsNumber, Min, Max, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { PoolConfig } from './pool.config'; + +/** + * Database Configuration (Level 1 nesting) + * Demonstrates: + * - Mix of @Required and @DefaultValue + * - Multi-level nesting (contains PoolConfig) + * - @Validate() for comprehensive validation + */ +@Validate() +export class DatabaseConfig { + @Required() + @IsString() + // @DefaultValue('localhost') + host!: string; + + @DefaultValue(5432) + @IsNumber() + @Min(1) + @Max(65535) + port!: number; + + @Required() + @IsString() + @DefaultValue('localhost') + username!: string; + + @Required() + @IsString() + @DefaultValue('password') + password!: string; + + // Nested pool configuration + // When using @Validate() on parent class, nested classes need @ValidateNested() and @Type() + @ConfigProperty() + @ValidateNested() + @Type(() => PoolConfig) + pool!: PoolConfig; +} diff --git a/examples/nested-basic/src/config/pool.config.ts b/examples/nested-basic/src/config/pool.config.ts new file mode 100644 index 0000000..8a09590 --- /dev/null +++ b/examples/nested-basic/src/config/pool.config.ts @@ -0,0 +1,19 @@ +import { DefaultValue, Validate } from '@snow-tzu/type-config-nestjs'; +import { IsNumber, Min } from 'class-validator'; + +/** + * Database Pool Configuration (Level 2 nesting) + * Demonstrates @DefaultValue with validation decorators + */ +@Validate() +export class PoolConfig { + @DefaultValue(10) + @IsNumber() + @Min(1) + maxConnections!: number; + + @DefaultValue(1) + @IsNumber() + @Min(1) + minConnections!: number; +} diff --git a/examples/nested-basic/src/config/server.config.ts b/examples/nested-basic/src/config/server.config.ts new file mode 100644 index 0000000..3ca88c4 --- /dev/null +++ b/examples/nested-basic/src/config/server.config.ts @@ -0,0 +1,35 @@ +import { Required, ConfigProperty, Validate } from '@snow-tzu/type-config-nestjs'; +import { IsString, IsNumber, Min, Max, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { SslConfig } from './ssl.config'; +import { DefaultValue } from '@snow-tzu/type-config'; + +/** + * Server Configuration (Level 1 nesting) + * Demonstrates: + * - @Required on nested class properties + * - @DefaultValue on nested class properties + * - Multi-level nesting (contains SslConfig) + * - @Validate() for class-validator integration + */ +@Validate() +export class ServerConfig { + @Required() + @IsString() + @DefaultValue('localhost') + host!: string; + + @Required() + @IsNumber() + @Min(1) + @Max(65535) + @DefaultValue(8080) + port!: number; + + // Nested class - @ConfigProperty helps with discovery + // When using @Validate() on parent class, nested classes need @ValidateNested() and @Type() + @ConfigProperty('ssl') + @ValidateNested() + @Type(() => SslConfig) + ssl!: SslConfig; +} diff --git a/examples/nested-basic/src/config/services.config.ts b/examples/nested-basic/src/config/services.config.ts new file mode 100644 index 0000000..bb59072 --- /dev/null +++ b/examples/nested-basic/src/config/services.config.ts @@ -0,0 +1,24 @@ +import { ConfigProperty, Validate } from '@snow-tzu/type-config-nestjs'; +import { ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiConfig } from './api.config'; +import { CacheConfig } from './cache.config'; + +/** + * Services Configuration (Level 1 nesting) + * Demonstrates multiple nested classes at the same level + */ +@Validate() +export class ServicesConfig { + // Multiple nested classes + // When using @Validate() on parent class, nested classes need @ValidateNested() and @Type() + @ConfigProperty('api') + @ValidateNested() + @Type(() => ApiConfig) + api!: ApiConfig; + + @ConfigProperty('cache') + @ValidateNested() + @Type(() => CacheConfig) + cache!: CacheConfig; +} diff --git a/examples/nested-basic/src/config/ssl.config.ts b/examples/nested-basic/src/config/ssl.config.ts new file mode 100644 index 0000000..fa6d7bc --- /dev/null +++ b/examples/nested-basic/src/config/ssl.config.ts @@ -0,0 +1,17 @@ +import { DefaultValue, Validate } from '@snow-tzu/type-config-nestjs'; +import { IsBoolean, IsString } from 'class-validator'; + +/** + * SSL Configuration (Level 2 nesting) + * Demonstrates @DefaultValue on nested class properties + */ +@Validate() +export class SslConfig { + @DefaultValue(false) + @IsBoolean() + enabled!: boolean; + + @DefaultValue('./certs/cert.pem') + @IsString() + certPath!: string; +} diff --git a/examples/nested-basic/src/main.ts b/examples/nested-basic/src/main.ts new file mode 100644 index 0000000..acfa21a --- /dev/null +++ b/examples/nested-basic/src/main.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { ConfigManager, CONFIG_MANAGER_TOKEN } from '@snow-tzu/type-config-nestjs'; +import { AppConfig } from './config/app.config'; +import { AppService } from './app.service'; + +async function bootstrap() { + try { + console.log('πŸš€ Application Configuration Loaded'); + + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn'], + }); + + // Get ConfigManager and AppConfig from DI container + const configManager = app.get(CONFIG_MANAGER_TOKEN); + const appService = app.get(AppService); + + console.log(`πŸ“ Profile: ${configManager.getProfile()}`); + + // Print configuration to demonstrate nested class binding + appService.printConfiguration(); + + // Get server config for startup + const appConfig = configManager.bind(AppConfig); + + // Register onChange listener for hot reload + configManager.onChange((_newConfig) => { + console.log('\n⚑ Configuration reloaded'); + appService.printConfiguration(); + }); + + // Start the application + await app.listen(appConfig.server.port, appConfig.server.host); + + console.log(`\nπŸš€ Server running on http://${appConfig.server.host}:${appConfig.server.port}`); + console.log(`πŸ“Š GET / - View configuration summary`); + console.log(`πŸ’š GET /health - Health check`); + } catch (error) { + console.error('❌ Failed to start application:', error); + process.exit(1); + } +} + +bootstrap(); diff --git a/examples/nested-basic/tsconfig.json b/examples/nested-basic/tsconfig.json new file mode 100644 index 0000000..b591e1c --- /dev/null +++ b/examples/nested-basic/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json index 7a33e7c..435f6ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/config-monorepo", - "version": "0.0.1", + "version": "0.0.2", "private": true, "description": "Spring Boot-inspired configuration management for Node.js", "workspaces": [ @@ -13,7 +13,11 @@ "test": "yarn workspaces foreach -Apt run test", "lint": "yarn workspaces foreach -Apt run lint", "clean": "yarn workspaces foreach -Apt run clean && find . -name 'tsconfig.tsbuildinfo' -delete && rm -rf node_modules", - "publish:packages": "yarn workspaces foreach -Apt --no-private yarn publish --access public" + "version:patch": "yarn workspaces foreach -A --no-private version patch", + "version:minor": "yarn workspaces foreach -A --no-private version minor", + "version:major": "yarn workspaces foreach -A --no-private version major", + "publish:packages": "yarn workspaces foreach -A --no-private npm publish --access public", + "publish:package": "yarn workspace" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/packages/core/CONFIG_FILES.md b/packages/core/CONFIG_FILES.md index 1021315..90f6832 100644 --- a/packages/core/CONFIG_FILES.md +++ b/packages/core/CONFIG_FILES.md @@ -1,6 +1,7 @@ # Configuration File Management Guide -This guide explains how to properly manage configuration files (YAML, JSON) in your application, especially when using TypeScript compilation and build processes. +This guide explains how to properly manage configuration files (YAML, JSON) in your application, especially when using +TypeScript compilation and build processes. ## Table of Contents @@ -10,11 +11,13 @@ This guide explains how to properly manage configuration files (YAML, JSON) in y - [Solutions by Framework](#solutions-by-framework) - [Configuration Directory Resolution](#configuration-directory-resolution) - [Profile-Based Loading](#profile-based-loading) +- [Advanced Configuration Features](#advanced-configuration-features) - [Troubleshooting](#troubleshooting) ## Overview -Type Config loads configuration from files (YAML, JSON) at runtime. However, **TypeScript compilation and build tools often delete or don't copy these files to the output directory**, causing runtime errors. +Type Config loads configuration from files (YAML, JSON) at runtime. However, **TypeScript compilation and build tools +often delete or don't copy these files to the output directory**, causing runtime errors. **This is critical**: Your application will fail to start if configuration files are not available at runtime. @@ -56,14 +59,13 @@ Error: ENOENT: no such file or directory, open 'dist/src/config/application.yml' Error: Required configuration property 'database.host' is missing ``` - ## Solutions by Framework ### NestJS #### Solution 1: Configure nest-cli.json (Recommended) -Add assets configuration to automatically copy config files during build: +Add asset configuration to automatically copy config files during build: ```json { @@ -116,7 +118,8 @@ import * as path from 'path'; }), ] }) -export class AppModule {} +export class AppModule { +} ``` ### Express @@ -210,18 +213,17 @@ const { configManager, container } = await new ConfigurationBuilder() .build(); ``` - ## Configuration Directory Resolution ### Understanding __dirname The `__dirname` variable points to different locations depending on execution context: -| Context | __dirname Value | Config Location | -|---------|----------------|-----------------| -| Development (ts-node) | `src/` | `src/config/` | -| Development (nest start) | `dist/src/` | `dist/src/config/` | -| Production (compiled) | `dist/` or `dist/src/` | `dist/config/` or `dist/src/config/` | +| Context | __dirname Value | Config Location | +|--------------------------|------------------------|--------------------------------------| +| Development (ts-node) | `src/` | `src/config/` | +| Development (nest start) | `dist/src/` | `dist/src/config/` | +| Production (compiled) | `dist/` or `dist/src/` | `dist/config/` or `dist/src/config/` | ### Recommended Patterns @@ -265,7 +267,7 @@ const configDir = process.env.NODE_ENV === 'production' ### Best Practice -Use Pattern 1 with proper build configuration: +Use Pattern 1 with a proper build configuration: ```typescript // Always use this pattern @@ -294,7 +296,9 @@ server: database: host: localhost port: 5432 +``` +```yaml # 2. application-production.yml (overrides base) server: host: 0.0.0.0 @@ -307,6 +311,7 @@ database: ``` **Final merged configuration**: + ```yaml server: host: 0.0.0.0 # from production profile @@ -317,12 +322,225 @@ database: password: secret123 # from environment variable ``` +## Advanced Configuration Features + +### Environment Variable Placeholders + +Type Config supports `${VAR_NAME:fallback}` syntax in YAML/JSON files for referencing environment variables with +optional fallback values. + +#### Syntax + +```yaml +database: + host: ${DB_HOST:localhost} # With fallback + port: ${DB_PORT:5432} # With fallback + username: ${DB_USER:postgres} # With fallback + password: ${DB_PASSWORD} # No fallback - undefined if not set + +api: + # Multiple placeholders in one value + url: ${API_PROTOCOL:https}://${API_HOST:api.example.com}:${API_PORT:443} + +message: + # Escape with backslash for literal ${TEXT} + template: \${USER} logged in +``` + +#### Resolution Behavior + +1. **Environment variable exists**: Uses the environment variable value +2. **Environment variable missing with fallback**: Uses the fallback value +3. **Environment variable missing without fallback**: Field becomes `undefined` + +#### Precedence Rules + +Configuration values are resolved in this order (the highest to lowest priority): + +1. **Explicit placeholder in profile-specific file** (e.g., `${PROD_VAR:fallback}`) +2. **Explicit placeholder in base file** (e.g., `${BASE_VAR:fallback}`) +3. **Underscore-based ENV variable** (e.g., `DATABASE_HOST` β†’ `database.host`) +4. **Literal value from files** +5. **Default value from @DefaultValue decorator** + +**Critical**: Explicit placeholders take absolute precedence. If you use `${VAR}` in your config, underscore-based ENV +resolution will NOT be applied to that field, even if the placeholder fails to resolve. + +#### Example with Profiles + +```yaml +# application.yml +database: + host: localhost + username: ${DB_USER:postgres} + password: ${DB_PASSWORD:defaultpass} +``` + +```yaml +# application-production.yml +database: + host: prod-db.example.com + username: ${PROD_DB_USER:postgres} # Overrides DB_USER placeholder + password: ${PROD_DB_PASSWORD} # Overrides DB_PASSWORD placeholder +``` + +With `NODE_ENV=production`, `PROD_DB_USER=prod_user`, and `PROD_DB_PASSWORD` not set: + +```json5 +{ + database: { + host: 'prod-db.example.com', + // Literal from production profile + username: 'prod_user', + // From PROD_DB_USER env var + password: undefined + // PROD_DB_PASSWORD not set, no fallback + } +} +``` + +#### Disabling Placeholder Resolution + +```typescript +const { configManager } = await new ConfigurationBuilder() + .withOptions({ enablePlaceholderResolution: false }) + .build(); +``` + +### Map and Record Configuration + +Type Config supports binding configuration to `Map` or `Record` properties for dynamic key-value +structures. + +#### Map-Based Configuration + +Use `Map` for true Map semantics with `.get()`, `.set()`, `.has()` methods: + +```typescript +import { ConfigurationProperties, ConfigProperty } from '@snow-tzu/type-config'; + +class DatabaseConnection { + host: string; + port: number; + username: string; + password: string; +} + +@ConfigurationProperties('databases') +class DatabasesConfig { + @ConfigProperty('connections') + connections: Map; +} +``` + +```yaml +# config/application.yml +databases: + connections: + primary: + host: localhost + port: 5432 + username: postgres + password: secret + analytics: + host: analytics-db.example.com + port: 5432 + username: analytics_user + password: analytics_pass +``` + +**Usage**: + +```typescript +const dbConfig = container.get(DatabasesConfig); +const primary = dbConfig.connections.get('primary'); +console.log(`Primary DB: ${primary.host}:${primary.port}`); +``` + +#### Record-Based Configuration + +Use `Record` as an alternative to Map with plain object syntax: + +```typescript +import { ConfigurationProperties, ConfigProperty, Required } from '@snow-tzu/type-config'; + +class DatabaseConnection { + host: string; + port: number; + username: string; + password: string; +} + +@ConfigurationProperties('databases') +class DatabasesRecordConfig { + @ConfigProperty('connections') + @Required() + connections: Record; +} +``` + +**Usage**: + +```typescript +const dbConfig = container.get(DatabasesRecordConfig); +const primary = dbConfig.connections['primary']; +// or +const primary = dbConfig.connections.primary; +console.log(`Primary DB: ${primary.host}:${primary.port}`); +``` + +#### Map vs Record + +| Feature | Map | Record | +|--------------|-------------------------------------|---------------------------------| +| **Type** | True Map instance | Plain JavaScript object | +| **Syntax** | `map.get('key')` | `record['key']` or `record.key` | +| **Use Case** | Need Map methods (.get, .set, .has) | Prefer plain object syntax | + +#### Combining with Placeholders + +```yaml +databases: + connections: + primary: + host: ${PRIMARY_DB_HOST:localhost} + port: ${PRIMARY_DB_PORT:5432} + username: ${PRIMARY_DB_USER:postgres} + password: ${PRIMARY_DB_PASSWORD} + analytics: + host: ${ANALYTICS_DB_HOST:localhost} + port: 5432 + username: analytics_user + password: ${ANALYTICS_DB_PASSWORD} +``` + +This combines Map/Record binding with environment variable placeholders for maximum flexibility! + +#### Accessing Map/Record Values via ConfigManager + +```typescript +// Deep path access (works with both Map and Record) +const primaryHost = configManager.get('databases.connections.primary.host'); + +// Get entire entry +const primaryConfig = configManager.get('databases.connections.primary'); + +// Get entire map/record as object +const allConnections = configManager.get('databases.connections'); + +// With default value +const cacheHost = configManager.get('databases.connections.cache.host', 'localhost'); +``` + +**Note**: When using `configManager.get()` with Map-based configuration, the Map is returned as a plain object for +easier access. ## Troubleshooting ### Error: "ENOENT: no such file or directory" **Symptom**: + ``` Error: ENOENT: no such file or directory, open '/path/to/dist/config/application.yml' ``` @@ -330,23 +548,24 @@ Error: ENOENT: no such file or directory, open '/path/to/dist/config/application **Causes & Solutions**: 1. **Config files not copied to dist/** - - βœ… Add copy script to package.json - - βœ… Configure build tool to copy assets (nest-cli.json for NestJS) - - βœ… Verify files exist: `ls dist/src/config/` or `ls dist/config/` + - βœ… Add a copy script to package.json + - βœ… Configure a build tool to copy assets (nest-cli.json for NestJS) + - βœ… Verify files exist: `ls dist/src/config/` or `ls dist/config/` 2. **Wrong configDir path** - - βœ… Use `path.join(__dirname, 'config')` instead of relative paths - - βœ… Check where __dirname points in your environment - - βœ… Add debug logging: `console.log('Config dir:', configDir)` + - βœ… Use `path.join(__dirname, 'config')` instead of relative paths + - βœ… Check where __dirname points in your environment + - βœ… Add debug logging: `console.log('Config dir:', configDir)` 3. **Build process deletes files** - - βœ… Ensure copy happens AFTER build - - βœ… For NestJS: use nest-cli.json assets configuration - - βœ… For others: `"build": "tsc && npm run copy:config"` + - βœ… Ensure copy happens AFTER build + - βœ… For NestJS: use nest-cli.json assets configuration + - βœ… For others: `"build": "tsc && npm run copy:config"` ### Error: "Required configuration property 'xxx' is missing" **Symptom**: + ``` Error: Required configuration property 'database.host' is missing ``` @@ -354,26 +573,26 @@ Error: Required configuration property 'database.host' is missing **Causes & Solutions**: 1. **Configuration file is empty or not parsed** - - βœ… Verify YAML/JSON syntax is valid - - βœ… Check file has content: `cat dist/src/config/application.yml` - - βœ… Ensure file extension is correct (.yml, .yaml, or .json) + - βœ… Verify YAML/JSON syntax is valid + - βœ… Check file has content: `cat dist/src/config/application.yml` + - βœ… Ensure file extension is correct (.yml, .yaml, or .json) 2. **Wrong profile loaded** - - βœ… Check NODE_ENV value: `echo $NODE_ENV` - - βœ… Verify profile-specific file exists - - βœ… Add logging to see which files are loaded + - βœ… Check NODE_ENV value: `echo $NODE_ENV` + - βœ… Verify profile-specific file exists + - βœ… Add logging to see which files are loaded 3. **Property path mismatch** - - βœ… Ensure @ConfigurationProperties prefix matches YAML structure - - βœ… Check @ConfigProperty names match YAML keys - - βœ… Example: - ```typescript - @ConfigurationProperties('database') // Must match YAML - class DatabaseConfig { - @ConfigProperty('host') // Must match: database.host - host: string; - } - ``` + - βœ… Ensure @ConfigurationProperties prefix matches YAML structure + - βœ… Check @ConfigProperty names match YAML keys + - βœ… Example: + ```typescript + @ConfigurationProperties('database') // Must match YAML + class DatabaseConfig { + @ConfigProperty('host') // Must match: database.host + host: string; + } + ``` ### Configuration not updating in development @@ -382,42 +601,119 @@ Error: Required configuration property 'database.host' is missing **Causes & Solutions**: 1. **Watch mode doesn't copy files** - - βœ… Run copy script before starting: `npm run copy:config && npm run dev` - - βœ… Or use hot reload: `enableHotReload: true` + - βœ… Run copy script before starting: `npm run copy:config && npm run dev` + - βœ… Or use hot reload: `enableHotReload: true` 2. **Cached configuration** - - βœ… Restart the application - - βœ… Clear dist folder: `rm -rf dist && npm run build` + - βœ… Restart the application + - βœ… Clear dist folder: `rm -rf dist && npm run build` 3. **Wrong file being read** - - βœ… Add debug logging to see which file is loaded - - βœ… Check if profile-specific file overrides your changes + - βœ… Add debug logging to see which file is loaded + - βœ… Check if a profile-specific file overrides your changes ### Files copied but still not found -**Symptom**: Files exist in dist/ but application can't find them +**Symptom**: Files exist in dist/ but the application can't find them **Causes & Solutions**: 1. **Working directory mismatch** - - βœ… Check current working directory: `console.log(process.cwd())` - - βœ… Use absolute paths: `path.join(__dirname, 'config')` - - βœ… Don't rely on relative paths like `./config` + - βœ… Check current working directory: `console.log(process.cwd())` + - βœ… Use absolute paths: `path.join(__dirname, 'config')` + - βœ… Don't rely on relative paths like `./config` 2. **Incorrect path in configDir** - - βœ… Verify: `console.log('Looking for config at:', configDir)` - - βœ… Check file exists: `fs.existsSync(path.join(configDir, 'application.yml'))` + - βœ… Verify: `console.log('Looking for config at:', configDir)` + - βœ… Check file exists: `fs.existsSync(path.join(configDir, 'application.yml'))` 3. **Symlink or Docker volume issues** - - βœ… Use absolute paths - - βœ… Verify files are actually copied (not just linked) - - βœ… Check Docker volume mounts + - βœ… Use absolute paths + - βœ… Verify files are actually copied (not just linked) + - βœ… Check Docker volume mounts + +### Placeholder is not resolving + +**Symptom**: `${VAR:fallback}` appears literally in configuration or resolves incorrectly + +**Causes & Solutions**: + +1. **Placeholder resolution disabled** + - βœ… Check if `enablePlaceholderResolution: false` is set + - βœ… Default is `true`, so ensure you haven't explicitly disabled it + +2. **Malformed placeholder syntax** + - βœ… Correct: `${VAR_NAME:fallback}` or `${VAR_NAME}` + - βœ… Incorrect: `$VAR_NAME`, `${VAR_NAME:}` (empty fallback is valid though) + - βœ… Ensure no spaces: `${ VAR }` won't work + +3. **Environment variable name mismatch** + - βœ… Check exact variable name: `echo $VAR_NAME` + - βœ… Variable names are case-sensitive + - βœ… Add debug: `console.log('ENV:', process.env.VAR_NAME)` + +4. **Escaped placeholder** + - βœ… `\${TEXT}` produces literal `${TEXT}` - this is intentional + - βœ… Remove backslash if you want resolution + +### Map/Record binding is not working + +**Symptom**: Map property is undefined or not a Map instance + +**Causes & Solutions**: + +1. **TypeScript metadata not emitted** + - βœ… Ensure `"emitDecoratorMetadata": true` in tsconfig.json + - βœ… Ensure `"experimentalDecorators": true` in tsconfig.json + - βœ… Import `reflect-metadata` at application entry point + +2. **Configuration structure mismatch** + - βœ… YAML must have object structure for Map/Record binding + - βœ… Example: + ```yaml + connections: + key1: { host: localhost } + key2: { host: remote } + ``` + - βœ… Not: `connections: "string"` or `connections: [array]` + +3. **Wrong property type annotation** + - βœ… Use `Map` not `Map` + - βœ… Use `Record` not just `object` + - βœ… Ensure value type `T` is properly defined + +4. **Map not being created** + - βœ… Verify the property is typed as `Map` (not just `any` or `object`) + - βœ… Check that configuration data exists at the specified path + - βœ… Add debug logging: `console.log(configManager.get('your.path'))` + +### Precedence is not working as expected + +**Symptom**: Wrong value is used when multiple sources provide the same property + +**Causes & Solutions**: + +1. **Explicit placeholder vs underscore-based ENV** + - βœ… Explicit placeholder `${VAR}` ALWAYS takes precedence + - βœ… Even if the placeholder fails, underscore-based ENV won't be used + - βœ… Example: `password: ${DB_PASS}` with `DATABASE_PASSWORD=secret` + - Result: `undefined` (not "secret") if DB_PASS not set + +2. **Profile-specific not overriding base** + - βœ… Verify profile is set: `console.log(configManager.getProfile())` + - βœ… Check profile file exists: `application-{profile}.yml` + - βœ… Ensure the profile file is loaded after a base file + +3. **Environment variable not overriding file** + - βœ… Check ENV var is actually set: `echo $VAR_NAME` + - βœ… Verify underscore-based naming: `DATABASE_HOST` β†’ `database.host` + - βœ… For kebab-case: `databases.connections.my-db.host` β†’ `DATABASES_CONNECTIONS_MY_DB_HOST` ## Quick Checklist Before deploying or running your application: -- [ ] Configuration files exist in source (`src/config/` or `config/`) +- [ ] Configuration files exist in a source (`src/config/` or `config/`) - [ ] Build script copies config files to dist - [ ] Verify files in dist: `ls dist/src/config/` or `ls dist/config/` - [ ] configDir path uses `path.join(__dirname, 'config')` @@ -440,7 +736,7 @@ Before deploying or running your application: If you're still having issues: 1. Enable debug logging in your application -2. Check the [GitHub Issues](https://github.com/snow-tzu/type-config/issues) +2. Check the [GitHub Issues](https://github.com/ganesanarun/type-config/issues) 3. Review the [examples directory](../../examples/) for working configurations 4. Create a minimal reproduction case diff --git a/packages/core/README.md b/packages/core/README.md index 679f9af..8cb7d82 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -13,7 +13,6 @@ - **Type-safe**: Decorator-based config classes with full TypeScript support - **Multi-source**: Merge YAML, JSON, .env, environment variables, and remote sources - **Profile support**: Spring-style profiles for dev, prod, staging, etc. -- **Hot reload**: Watch and reload config changes instantly - **Encryption**: Secure secrets with built-in AES-256 encryption - **Validation**: class-validator integration for robust config - **Lightning fast**: See benchmarks below @@ -45,6 +44,10 @@ - [Installation](#installation) - [Quick Start](#quick-start) - [Configuration Files](#configuration-files) +- [Advanced Features](#advanced-features) + - [Environment Variable Placeholders](#environment-variable-placeholders) + - [Map-Based Configuration](#map-based-configuration) + - [Record-Based Configuration](#record-based-configuration) - [API](#api) - [Decorators](#decorators) - [ConfigurationBuilder](#configurationbuilder) @@ -63,6 +66,8 @@ - πŸ” **Encryption** - Built-in support for encrypted values - βœ… **Validation** - Integration with class-validator - πŸ’‰ **DI Container** - Simple dependency injection system +- πŸ—ΊοΈ **Map & Record binding** - Bind configuration to Map or Record types for dynamic key-value structures +- πŸ”§ **Environment variable placeholders** - Use `${VAR:fallback}` syntax in YAML/JSON with fallback values ## Installation @@ -117,11 +122,13 @@ console.log(`Connecting to ${dbConfig.host}:${dbConfig.port}`); ### ⚠️ CRITICAL: Configuration File Management -**TypeScript compilation doesn't copy YAML/JSON files to the output directory.** Your application will fail at runtime if configuration files are not properly managed. +**TypeScript compilation doesn't copy YAML/JSON files to the output directory.** Your application will fail at runtime +if configuration files are not properly managed. πŸ“– **[Read the Complete Configuration File Management Guide](./CONFIG_FILES.md)** This comprehensive guide is **essential reading** and covers: + - **Why configuration files disappear** during builds - **Solutions for all frameworks** (NestJS, Express, Fastify, vanilla Node.js) - **Configuration directory resolution** patterns @@ -149,7 +156,650 @@ database: password: ENC(iv:encrypted-password) ``` -**Important**: Ensure these files are copied to your `dist/` folder during build. See the [Configuration File Management Guide](./CONFIG_FILES.md) for details. +**Important**: Ensure these files are copied to your `dist/` folder during build. See +the [Configuration File Management Guide](./CONFIG_FILES.md) for details. + +## Advanced Features + +### Environment Variable Placeholders + +Use `${VAR_NAME:fallback}` syntax in your YAML/JSON configuration files to reference environment variables with optional +fallback values. + +#### Basic Syntax + +```yaml +database: + host: ${DB_HOST:localhost} + port: ${DB_PORT:5432} + username: ${DB_USER:postgres} + password: ${DB_PASSWORD} # No fallback - will be undefined if not set +``` + +#### How It Works + +1. **Environment variable exists**: Uses the environment variable value + ```bash + DB_HOST=prod-db.example.com + # Result: host = "prod-db.example.com" + ``` + +2. **Environment variable missing with fallback**: Uses the fallback value + ```bash + # DB_HOST not set + # Result: host = "localhost" + ``` + +3. **Environment variable missing without fallback**: Field becomes `undefined` + ```bash + # DB_PASSWORD not set + # Result: password = undefined (validation will fail if @Required) + ``` + +#### Advanced Usage + +**Multiple placeholders in one value**: + +```yaml +api: + url: ${API_PROTOCOL:https}://${API_HOST:api.example.com}:${API_PORT:443} + # Result: "https://api.example.com:443" +``` + +**Escaping placeholders**: + +```yaml +message: \${USER} logged in # Literal "${USER} logged in" +``` + +#### Precedence Rules + +Configuration values are resolved in this order (the highest to lowest priority): + +1. **Explicit placeholder in profile-specific file** (e.g., `${PROD_VAR:fallback}`) +2. **Explicit placeholder in base file** (e.g., `${BASE_VAR:fallback}`) +3. **Underscore-based ENV variable** (e.g., `DATABASE_HOST` β†’ `database.host`) +4. **Literal value from files** +5. **Default value from @DefaultValue decorator** + +**Important**: Explicit placeholders take absolute precedence. If you use `${VAR}` in your config, the underscore-based +ENV resolution will NOT be applied to that field, even if the placeholder fails to resolve. + +#### Example with Profiles + +```yaml +# application.yml +database: + host: localhost + username: ${DB_USER:postgres} + password: ${DB_PASSWORD:defaultpass} +``` + +```yaml +# application-production.yml +database: + host: prod-db.example.com + username: ${PROD_DB_USER:postgres} # Overrides DB_USER + password: ${PROD_DB_PASSWORD} # Overrides DB_PASSWORD +``` + +With `NODE_ENV=production` and `PROD_DB_USER=prod_user`: + +```json5 +{ + database: { + host: 'prod-db.example.com', // Literal from production profile + username: 'prod_user', // From PROD_DB_USER env var + password: undefined // PROD_DB_PASSWORD not set, no fallback + } +} +``` + +#### Disabling Placeholder Resolution + +```typescript +const { configManager } = await new ConfigurationBuilder() + .withProfile('production') + .withConfigDir('./config') + .withOptions({ enablePlaceholderResolution: false }) // Disable + .build(); +``` + +### Map-Based Configuration + +Bind configuration to `Map` properties for dynamic key-value structures like multiple database connections or +service endpoints. + +#### Basic Example + +```typescript +import { ConfigurationProperties, ConfigProperty } from '@snow-tzu/type-config'; + +class DatabaseConnection { + host: string; + port: number; + username: string; + password: string; +} + +@ConfigurationProperties('databases') +class DatabasesConfig { + @ConfigProperty('connections') + connections: Map; +} +``` + +```yaml +# config/application.yml +databases: + connections: + primary: + host: localhost + port: 5432 + username: postgres + password: secret + analytics: + host: analytics-db.example.com + port: 5432 + username: analytics_user + password: analytics_pass +``` + +#### Using Map Configuration + +```typescript +const dbConfig = container.get(DatabasesConfig); + +// Access using Map methods +const primary = dbConfig.connections.get('primary'); +console.log(`Primary DB: ${primary.host}:${primary.port}`); + +// Check if connection exists +if (dbConfig.connections.has('analytics')) { + const analytics = dbConfig.connections.get('analytics'); + // Use analytics connection +} + +// Iterate over all connections +for (const [name, connection] of dbConfig.connections.entries()) { + console.log(`${name}: ${connection.host}`); +} +``` + +#### Accessing Map Values via ConfigManager + +```typescript +// Deep path access +const primaryHost = configManager.get('databases.connections.primary.host'); +// Result: "localhost" + +// Get entire map entry +const primaryConfig = configManager.get('databases.connections.primary'); +// Result: { host: 'localhost', port: 5432, ... } + +// Get entire map as object +const allConnections = configManager.get('databases.connections'); +// Result: { primary: {...}, analytics: {...} } + +// With default value +const cacheHost = configManager.get('databases.connections.cache.host', 'localhost'); +``` + +### Record-Based Configuration + +Use `Record` as an alternative to Map. Records are plain objects with string keys, offering simpler syntax +with bracket notation. + +#### Basic Example + +```typescript +import { ConfigurationProperties, ConfigProperty, Required } from '@snow-tzu/type-config'; + +class DatabaseConnection { + host: string; + port: number; + username: string; + password: string; +} + +@ConfigurationProperties('databases') +class DatabasesRecordConfig { + @ConfigProperty('connections') + @Required() + connections: Record; +} +``` + +#### Using Record Configuration + +```typescript +const dbConfig = container.get(DatabasesRecordConfig); + +// Access using bracket notation or dot notation +const primary = dbConfig.connections['primary']; +// or +const primary = dbConfig.connections.primary; +console.log(`Primary DB: ${primary.host}:${primary.port}`); + +// Check if connection exists +if ('analytics' in dbConfig.connections) { + const analytics = dbConfig.connections['analytics']; + // Use analytics connection +} + +// Iterate over all connections +for (const [name, connection] of Object.entries(dbConfig.connections)) { + console.log(`${name}: ${connection.host}`); +} +``` + +#### Map vs Record: Choosing Between Them + +| Feature | Map | Record | +|------------------------|-----------------------------------------------|--------------------------------------------------------| +| **Type** | True Map instance | Plain JavaScript object | +| **Access Syntax** | `map.get('key')` | `record['key']` or `record.key` | +| **Map Methods** | `.get()`, `.set()`, `.has()`, `.delete()` | Standard object operations | +| **Iteration** | `map.entries()`, `map.keys()`, `map.values()` | `Object.entries()`, `Object.keys()`, `Object.values()` | +| **JSON Serialization** | Requires conversion to object | Works directly | +| **Use Case** | Need Map semantics and methods | Prefer plain object syntax | + +**Recommendation**: + +- Use **Map** when you need true Map semantics with `.get()`, `.set()`, `.has()` methods +- Use **Record** when you prefer plain object syntax with bracket/dot notation + +**Note**: Both Map and Record support the same configuration binding. The choice is purely based on your preferred API +style. + +#### Complete Example with Placeholders + +```yaml +# config/application.yml +databases: + connections: + primary: + host: ${PRIMARY_DB_HOST:localhost} + port: ${PRIMARY_DB_PORT:5432} + username: ${PRIMARY_DB_USER:postgres} + password: ${PRIMARY_DB_PASSWORD:secret} + analytics: + host: ${ANALYTICS_DB_HOST:localhost} + port: 5432 + username: analytics_user + password: ${ANALYTICS_DB_PASSWORD} +``` + +This combines both features: Map/Record binding with environment variable placeholders! + +### Nested Configuration Classes + +Organize complex configuration using nested configuration classes with full decorator support. Decorators like +`@DefaultValue`, `@Required`, and `@Validate()` work seamlessly on nested classes, enabling modular, type-safe +configuration structures. + +#### Why Use Nested Classes? + +- **Modularity**: Break complex configuration into logical, reusable components +- **Type Safety**: Full TypeScript support with IntelliSense for nested structures +- **Decorator Support**: `@DefaultValue`, `@Required`, and `@Validate()` work at all nesting levels +- **Validation**: Validate nested structures with class-validator decorators +- **Maintainability**: Easier to understand and maintain than flat configuration objects + +#### Basic Example + +```typescript +import { + ConfigurationProperties, + ConfigProperty, + DefaultValue, + Required, + Validate +} from '@snow-tzu/type-config'; +import { IsNumber, Min, Max, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +// Nested configuration class +@Validate() +class CircuitBreakerOptions { + @DefaultValue(10000) + @IsNumber() + @Min(1000) + timeout: number; + + @DefaultValue(50) + @IsNumber() + @Min(1) + @Max(100) + errorThresholdPercentage: number; + + @Required() + @IsNumber() + volumeThreshold: number; +} + +// Parent configuration class +@ConfigurationProperties('clients.sample') +@Validate() +class SampleClientConfig { + @Required() + @ConfigProperty('baseUrl') + baseURL: string; + + @Required() + @ValidateNested() // Required when parent has @Validate() + @Type(() => CircuitBreakerOptions) // Required when parent has @Validate() + circuitBreaker: CircuitBreakerOptions; +} +``` + +```yaml +# config/application.yml +clients: + sample: + baseUrl: https://api.example.com + circuitBreaker: + volumeThreshold: 10 + # timeout and errorThresholdPercentage will use defaults +``` + +```typescript +const clientConfig = container.get(SampleClientConfig); +console.log(clientConfig.circuitBreaker.timeout); // 10000 (default) +console.log(clientConfig.circuitBreaker.volumeThreshold); // 10 (from config) +``` + +#### Key Features + +**1. @ConfigProperty is Optional** + +When the property name matches the configuration key, you don't need `@ConfigProperty`: + +```typescript +class ServerConfig { + @Required() + host: string; // Binds to 'host' automatically + + @ConfigProperty('portNumber') + port: number; // Custom path when names differ +} +``` + +**2. Decorators Work at All Levels** + +All decorators are processed recursively: + +```typescript +class PoolConfig { + @DefaultValue(10) + maxConnections: number; + + @DefaultValue(1) + minConnections: number; +} + +class DatabaseConfig { + @Required() + host: string; + + @DefaultValue(5432) + port: number; + + pool: PoolConfig; // Nested class with its own decorators +} + +@ConfigurationProperties('app') +class AppConfig { + database: DatabaseConfig; // Multi-level nesting +} +``` + +**3. Validation with class-validator** + +Use `@Validate()` on nested classes for comprehensive validation. **Important**: When using `@Validate()` on a parent +class with nested class properties, you must add `@ValidateNested()` and `@Type()` decorators: + +```typescript +import { IsUrl, IsNumber, Min, Max, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +@Validate() +class ApiConfig { + @IsUrl() + @Required() + endpoint: string; + + @IsNumber() + @Min(1000) + @Max(30000) + @DefaultValue(5000) + timeout: number; +} + +@ConfigurationProperties('services') +@Validate() +class ServicesConfig { + @ValidateNested() // Required for validation + @Type(() => ApiConfig) // Required for validation + api: ApiConfig; +} +``` + +**Why these decorators are required:** + +- `@ValidateNested()` tells class-validator to validate the nested object +- `@Type(() => ApiConfig)` tells class-transformer what type to instantiate +- Without these, you'll get an error: "an unknown value was passed to the validate function" + +If validation fails, you'll get detailed error messages: + +``` +Validation failed for ApiConfig at path 'services.api': + - timeout: must be a number conforming to the specified constraints + - endpoint: must be a URL address +``` + +**Note**: If you don't use `@Validate()` on the parent class, you don't need `@ValidateNested()` and `@Type()`. The +nested class will still be instantiated and bound correctly. + +**4. Required Properties in Nested Classes** + +`@Required()` works in nested classes with clear error messages: + +```typescript +class DatabaseConfig { + @Required() + host: string; + + @Required() + password: string; +} + +@ConfigurationProperties('app') +class AppConfig { + database: DatabaseConfig; +} +``` + +If `password` is missing: + +``` +Required configuration property 'app.database.password' is missing +``` + +**5. DefaultValue Satisfies Required** + +When both decorators are present, the default value satisfies the required constraint: + +```typescript +class CacheConfig { + @Required() + @DefaultValue('localhost') + host: string; // No error even if not in config file + + @Required() + @DefaultValue(6379) + port: number; +} +``` + +#### Multi-Level Nesting + +Nest as deeply as needed - decorators work at all levels: + +```typescript +import { ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +@Validate() +class SslConfig { + @DefaultValue(false) + enabled: boolean; + + @DefaultValue('./certs/cert.pem') + certPath: string; +} + +@Validate() +class ConnectionConfig { + @Required() + host: string; + + @DefaultValue(5432) + port: number; + + @ValidateNested() + @Type(() => SslConfig) + ssl: SslConfig; // Level 3 +} + +@Validate() +class DatabaseConfig { + @ValidateNested() + @Type(() => ConnectionConfig) + primary: ConnectionConfig; // Level 2 + + @ValidateNested() + @Type(() => ConnectionConfig) + replica: ConnectionConfig; +} + +@ConfigurationProperties('app') +@Validate() +class AppConfig { + @ValidateNested() + @Type(() => DatabaseConfig) + database: DatabaseConfig; // Level 1 +} +``` + +```yaml +# config/application.yml +app: + database: + primary: + host: primary-db.example.com + ssl: + enabled: true + replica: + host: replica-db.example.com + # Uses defaults for port and ssl +``` + +#### Mixing Nested Classes with Other Features + +Combine nested classes with placeholders, profiles, and Map/Record types: + +```typescript +class RetryConfig { + @DefaultValue(3) + maxAttempts: number; + + @DefaultValue(1000) + backoffMs: number; +} + +@ConfigurationProperties('services') +class ServicesConfig { + @ConfigProperty('endpoints') + endpoints: Map; // Map binding + + retry: RetryConfig; // Nested class +} +``` + +```yaml +# config/application.yml +services: + endpoints: + api: ${API_URL:https://api.example.com} + auth: ${AUTH_URL:https://auth.example.com} + retry: + maxAttempts: ${MAX_RETRIES:5} +``` + +**Before** (plain objects, no decorator support): + +```typescript + +@ConfigurationProperties('clients.sample') +class SampleClientConfig { + @Required() + @ConfigProperty('baseUrl') + baseURL: string; + + @Required() + @ConfigProperty('circuitBreaker') + circuitBreaker: any; // Plain object - decorators don't work +} +``` + +**After** (nested classes with full decorator support): + +```typescript +// Step 1: Create a class for the nested configuration +@Validate() +class CircuitBreakerOptions { + @DefaultValue(10000) + timeout: number; + + @DefaultValue(50) + errorThresholdPercentage: number; + + @Required() + volumeThreshold: number; +} + +// Step 2: Use the class as the property type +@ConfigurationProperties('clients.sample') +@Validate() +class SampleClientConfig { + @Required() + @ConfigProperty('baseUrl') + baseURL: string; + + @Required() + circuitBreaker: CircuitBreakerOptions; // Now fully typed with decorators! +} +``` + +**Benefits of Migration**: + +- βœ… Type safety with IntelliSense +- βœ… Default values on nested properties +- βœ… Required validation on nested properties +- βœ… class-validator integration +- βœ… Better code organization and reusability + +#### Complete Working Example + +See the [Nested Configuration Example](../../examples/nested-basic/) for a full working demonstration including: + +- Single-level and multi-level nesting +- All decorator types (`@DefaultValue`, `@Required`, `@Validate()`) +- Profile-specific configuration +- Validation with class-validator +- Integration with Express/NestJS ## API @@ -226,17 +876,18 @@ database: ## Comparison -| Feature | type-config (@snow-tzu/type-config) | node-config | dotenv | @nestjs/config | -|-------------------|:-----------------------------------:|:-----------:|:------:|:--------------:| -| Type safety | βœ… Decorators, TS classes | ❌ | ❌ | ⚠️ Partial | -| Multi-source | βœ… YAML, env, remote, etc. | βœ… | ❌ | ⚠️ Limited | -| Profile support | βœ… Spring-style | βœ… | ❌ | ❌ | -| Hot reload | βœ… Built-in | ❌ | ❌ | ❌ | -| Encryption | βœ… Built-in AES-256 | ❌ | ❌ | ❌ | -| Validation | βœ… class-validator | ❌ | ❌ | ⚠️ Manual | -| DI integration | βœ… All frameworks | ❌ | ❌ | βœ… (NestJS) | -| Remote sources | βœ… AWS, Consul, etcd | ❌ | ❌ | ❌ | -| Framework support | βœ… Express, Fastify, NestJS | ❌ | ❌ | βœ… (NestJS) | +| Feature | type-config (@snow-tzu/type-config) | node-config | dotenv | @nestjs/config | +|--------------------|:-----------------------------------:|:-----------:|:--------:|:--------------:| +| Type safety | βœ… Decorators, TS classes | ❌ | ❌ | ⚠️ Partial | +| Multi-source | βœ… YAML, env, remote, etc. | βœ… | ❌ | ⚠️ Limited | +| Profile support | βœ… Spring-style | βœ… | ❌ | ❌ | +| Encryption | βœ… Built-in AES-256 | ❌ | ❌ | ❌ | +| Validation | βœ… class-validator | ❌ | ❌ | ⚠️ Manual | +| DI integration | βœ… All frameworks | ❌ | ❌ | βœ… (NestJS) | +| Remote sources | βœ… AWS, Consul, etcd | ❌ | ❌ | ❌ | +| Framework support | βœ… Express, Fastify, NestJS | ❌ | ❌ | βœ… (NestJS) | +| Map/Record binding | βœ… Dynamic key-value structures | ❌ | ❌ | ❌ | +| ENV placeholders | βœ… ${VAR:fallback} syntax | ❌ | ⚠️ Basic | ❌ | ## License diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js new file mode 100644 index 0000000..92fcdd2 --- /dev/null +++ b/packages/core/jest.config.js @@ -0,0 +1,28 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.spec.ts', '**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts', + ], + coverageDirectory: 'coverage', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + target: 'ES2020', + module: 'commonjs', + lib: ['ES2020'], + esModuleInterop: true, + experimentalDecorators: true, + emitDecoratorMetadata: true, + moduleResolution: 'node', + }, + }, + ], + }, +}; diff --git a/packages/core/package.json b/packages/core/package.json index 163aadc..c473c00 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,13 +1,13 @@ { "name": "@snow-tzu/type-config", - "version": "0.0.1", + "version": "0.0.5", "description": "Core configuration management system with Spring Boot-like features", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "clean": "rm -rf dist tsconfig.tsbuildinfo", - "test": "jest --config ../../jest.config.js --passWithNoTests", + "test": "jest --passWithNoTests", "benchmark": "yarn build && node dist/benchmark/index.js", "benchmark:loading": "yarn build && node dist/benchmark/config-loading.bench.js", "benchmark:memory": "yarn build && node --expose-gc dist/benchmark/memory.bench.js", diff --git a/packages/core/src/config-manager.ts b/packages/core/src/config-manager.ts index 19ef40f..942f4b4 100644 --- a/packages/core/src/config-manager.ts +++ b/packages/core/src/config-manager.ts @@ -9,6 +9,8 @@ import { VALIDATE_KEY, } from './decorators'; import { ConfigSource, EncryptionHelper, EnvConfigSource, FileConfigSource } from './sources'; +import { PlaceholderResolver } from './placeholder-resolver'; +import { MapBinder } from './map-binder'; export interface ConfigManagerOptions { profile?: string; @@ -18,6 +20,7 @@ export interface ConfigManagerOptions { enableHotReload?: boolean; encryptionKey?: string; validateOnBind?: boolean; + enablePlaceholderResolution?: boolean; } export type ConfigChangeListener = (newConfig: Record) => void; @@ -35,10 +38,16 @@ export class ConfigManager { private changeListeners: ConfigChangeListener[] = []; private encryptionHelper?: EncryptionHelper; private validateOnBind: boolean; + private placeholderResolver: PlaceholderResolver; + private enablePlaceholderResolution: boolean; + private mapBinder: MapBinder; constructor(private options: ConfigManagerOptions = {}) { this.profile = options.profile || process.env.NODE_ENV || 'development'; this.validateOnBind = options.validateOnBind ?? true; + this.enablePlaceholderResolution = options.enablePlaceholderResolution ?? true; + this.placeholderResolver = new PlaceholderResolver(); + this.mapBinder = new MapBinder(); if (options.encryptionKey) { this.encryptionHelper = new EncryptionHelper(options.encryptionKey); @@ -52,7 +61,7 @@ export class ConfigManager { if (this.initialized) return; const configDir = this.options.configDir || './config'; - console.log(`[ConfigManager] Initializing with configDir: ${configDir}, profile: ${this.profile}`); + // console.log(`[ConfigManager] Initializing with configDir: ${configDir}, profile: ${this.profile}`); // Add default sources with priority this.sources.push( @@ -63,7 +72,7 @@ export class ConfigManager { new EnvConfigSource(this.options.envPrefix, 200) ); - console.log(`[ConfigManager] Added ${this.sources.length} config sources`); + // console.log(`[ConfigManager] Added ${this.sources.length} config sources`); // Add additional sources if (this.options.additionalSources) { @@ -76,7 +85,7 @@ export class ConfigManager { // Load all sources and merge await this.reload(); - // Setup hot reload if enabled + // Set up hot reload if enabled if (this.options.enableHotReload && this.options.configDir) { this.setupHotReload(); } @@ -90,31 +99,52 @@ export class ConfigManager { private async reload(): Promise { const newConfig: Record = {}; + // Step 1: Load and merge all sources (including EnvConfigSource with underscore-based resolution) for (const source of this.sources) { try { const data = await source.load(); - console.log(`[ConfigManager] Loading source: ${source.name}, data:`, JSON.stringify(data, null, 2)); + // console.log(`[ConfigManager] Loading source: ${source.name}, data:`, JSON.stringify(data, null, 2)); this.deepMerge(newConfig, data); - console.log(`[ConfigManager] After merge, config:`, JSON.stringify(newConfig, null, 2)); + // console.log(`[ConfigManager] After merge, config:`, JSON.stringify(newConfig, null, 2)); } catch (err) { console.warn(`Failed to load config source ${source.name}:`, err); } } - // Decrypt encrypted values if encryption is enabled + // Step 2: Resolve explicit environment variable placeholders if enabled + // This happens AFTER merging all sources (including underscore-based ENV resolution) + // Underscore-based ENV vars (from EnvConfigSource) take precedence over file values + // Then explicit placeholders are resolved, which can reference any ENV var + let resolvedConfig = newConfig; + if (this.enablePlaceholderResolution) { + // console.log('[ConfigManager] Resolving explicit placeholders...'); + resolvedConfig = this.resolveEnvironmentVariables(newConfig); + // console.log('[ConfigManager] After placeholder resolution:', JSON.stringify(resolvedConfig, null, 2)); + } + + // Step 3: Decrypt encrypted values if encryption is enabled if (this.encryptionHelper) { - this.config = this.encryptionHelper.decryptObject(newConfig); + this.config = this.encryptionHelper.decryptObject(resolvedConfig); } else { - this.config = newConfig; + this.config = resolvedConfig; } - // Clear cached instances to force rebinding + // Clearly cached instances to force rebinding this.configInstances.clear(); // Notify listeners this.notifyListeners(); } + /** + * Resolve environment variable placeholders in configuration + * @param config - Configuration object with potential placeholders + * @returns Configuration with placeholders resolved + */ + private resolveEnvironmentVariables(config: Record): Record { + return this.placeholderResolver.resolveObject(config); + } + /** * Setup file watcher for hot reload */ @@ -195,6 +225,16 @@ export class ConfigManager { /** * Bind configuration to a class instance (Spring-like @ConfigurationProperties) + * + * Supports: + * - Nested configuration classes with full decorator support + * - @DefaultValue, @Required, and @Validate() decorators at all nesting levels + * - Optional @ConfigProperty when property names match configuration keys + * - Map and Record types for dynamic key-value structures + * + * @param ConfigClass - The configuration class constructor + * @returns Bound and validated configuration instance + * @throws Error if required properties are missing or validation fails */ bind(ConfigClass: new () => T): T { // Check if already instantiated (singleton) @@ -208,19 +248,26 @@ export class ConfigManager { } const instance = new ConfigClass(); - const properties = Reflect.getMetadata(CONFIG_PROPERTIES_KEY, ConfigClass) || {}; const requiredProps = Reflect.getMetadata(REQUIRED_PROPS_KEY, ConfigClass) || []; const defaults = Reflect.getMetadata(DEFAULTS_KEY, ConfigClass) || {}; const shouldValidate = Reflect.getMetadata(VALIDATE_KEY, ConfigClass); + // Get all properties (decorated and undecorated) + const allProperties = this.getAllProperties(instance, ConfigClass); + // Bind properties - for (const [propertyKey, propertyPath] of Object.entries(properties)) { + for (const [propertyKey, propertyPath] of allProperties) { const fullPath = `${prefix}.${propertyPath}`; const defaultVal = defaults[propertyKey]; const value = this.get(fullPath, defaultVal); - if (value !== undefined) { - (instance as any)[propertyKey] = this.convertType(value, instance, propertyKey); + // Always call convertAndBindType for nested configuration classes + // even if value is undefined, so they can be instantiated with defaults + const type = Reflect.getMetadata('design:type', instance as any, propertyKey); + const isNestedClass = type && this.isConfigurationClass(type); + + if (value !== undefined || isNestedClass) { + (instance as any)[propertyKey] = this.convertAndBindType(value, instance, propertyKey); } } @@ -255,23 +302,255 @@ export class ConfigManager { /** * Format validation errors for display */ - private formatValidationErrors(errors: ValidationError[]): string { + private formatValidationErrors(errors: ValidationError[], prefix = ''): string { return errors .map(error => { const constraints = error.constraints ? Object.values(error.constraints).join(', ') : ''; - return ` - ${error.property}: ${constraints}`; + let message = ` ${prefix}- ${error.property}: ${constraints}`; + + // Handle nested validation errors + if (error.children && error.children.length > 0) { + const childMessages = this.formatValidationErrors(error.children, prefix + ' '); + message += '\n' + childMessages; + } + + return message; }) .join('\n'); } /** - * Convert value to appropriate type based on property type + * Check if a type is a configuration class that should be recursively bound + * @param type - The type to check + * @returns true if the type is a configuration class with decorators + */ + private isConfigurationClass(type: any): boolean { + if (!type || typeof type !== 'function') { + return false; + } + + // Check if it has any configuration-related metadata + const hasDefaults = Reflect.hasMetadata(DEFAULTS_KEY, type); + const hasRequired = Reflect.hasMetadata(REQUIRED_PROPS_KEY, type); + const hasValidate = Reflect.hasMetadata(VALIDATE_KEY, type); + const hasConfigProps = Reflect.hasMetadata(CONFIG_PROPERTIES_KEY, type); + + return hasDefaults || hasRequired || hasValidate || hasConfigProps; + } + + /** + * Get all properties to bind for a configuration class + * Includes both decorated properties and properties with defaults or required metadata + * @param instance - The class instance + * @param ConfigClass - The class constructor + * @returns Map of property keys to configuration paths + */ + private getAllProperties(instance: any, ConfigClass: any): Map { + const properties = Reflect.getMetadata(CONFIG_PROPERTIES_KEY, ConfigClass) || {}; + const result = new Map(); + + // Add explicitly decorated properties + for (const [key, path] of Object.entries(properties)) { + result.set(key, path as string); + } + + // For nested classes, also check for properties that might not have @ConfigProperty + // but have other decorators like @DefaultValue or @Required + const defaults = Reflect.getMetadata(DEFAULTS_KEY, ConfigClass) || {}; + const requiredProps = Reflect.getMetadata(REQUIRED_PROPS_KEY, ConfigClass) || []; + + for (const key of Object.keys(defaults)) { + if (!result.has(key)) { + result.set(key, key); // Use property name as path + } + } + + for (const key of requiredProps) { + if (!result.has(key)) { + result.set(key, key); + } + } + + return result; + } + + /** + * Get all properties for a nested class during binding + * Includes decorated properties, properties with defaults/required, and properties present in config + * @param instance - The nested class instance + * @param NestedClass - The nested class constructor + * @param configValue - The configuration value object + * @returns Map of property keys to configuration paths + */ + private getAllPropertiesForNestedClass( + instance: any, + NestedClass: any, + configValue: any + ): Map { + const properties = Reflect.getMetadata(CONFIG_PROPERTIES_KEY, NestedClass) || {}; + const result = new Map(); + + // Add explicitly decorated properties + for (const [key, path] of Object.entries(properties)) { + result.set(key, path as string); + } + + // Add properties with @DefaultValue + const defaults = Reflect.getMetadata(DEFAULTS_KEY, NestedClass) || {}; + for (const key of Object.keys(defaults)) { + if (!result.has(key)) { + result.set(key, key); + } + } + + // Add properties with @Required + const requiredProps = Reflect.getMetadata(REQUIRED_PROPS_KEY, NestedClass) || []; + for (const key of requiredProps) { + if (!result.has(key)) { + result.set(key, key); + } + } + + // Add properties present in the configuration value + if (configValue && typeof configValue === 'object' && !Array.isArray(configValue)) { + for (const key of Object.keys(configValue)) { + if (!result.has(key)) { + result.set(key, key); + } + } + } + + return result; + } + + /** + * Bind a nested configuration class recursively + * @param value - The configuration value object + * @param NestedClass - The nested class constructor + * @param propertyPath - The property path for error messages + * @returns The bound nested class instance */ - private convertType(value: any, instance: any, propertyKey: string): any { + private bindNestedClass(value: any, NestedClass: new () => any, propertyPath: string): any { + // If value is null/undefined, check if we should create an instance with defaults + // We create an instance if: + // 1. Validation is enabled (validateOnBind) AND the nested class has @Validate() + // 2. The nested class has @DefaultValue decorators (to apply defaults) + // Otherwise return the value as-is for backward compatibility + if (!value || typeof value !== 'object' || Array.isArray(value)) { + const shouldValidate = Reflect.getMetadata(VALIDATE_KEY, NestedClass); + const defaults = Reflect.getMetadata(DEFAULTS_KEY, NestedClass) || {}; + const hasDefaults = Object.keys(defaults).length > 0; + + // Create an instance if: + // - Validation is enabled AND nested class has @Validate() (to avoid validation errors) + // - OR nested class has defaults (to apply them) + // This handles cases where: + // - YAML has "api:" with no properties (becomes null) + // - YAML is missing the key entirely (becomes undefined) + const shouldCreateInstance = + (this.validateOnBind && shouldValidate) || + hasDefaults; + + if (shouldCreateInstance && (value === null || value === undefined)) { + value = {}; // Create empty object to trigger instance creation + } else { + return value; // Return as-is for backward compatibility + } + } + + const instance = new NestedClass(); + const defaults = Reflect.getMetadata(DEFAULTS_KEY, NestedClass) || {}; + + // Get all properties (decorated and undecorated) + const allProps = this.getAllPropertiesForNestedClass(instance, NestedClass, value); + + // Bind each property + for (const [propKey, propPath] of allProps) { + const defaultVal = defaults[propKey]; + const propValue = value[propPath] !== undefined ? value[propPath] : defaultVal; + + // Always call convertAndBindType for nested configuration classes + // even if propValue is undefined, so they can be instantiated with defaults + const type = Reflect.getMetadata('design:type', instance, propKey); + const isNestedClass = type && this.isConfigurationClass(type); + + if (propValue !== undefined || isNestedClass) { + // Recursively convert and bind the property + (instance as any)[propKey] = this.convertAndBindType(propValue, instance, propKey); + } + } + + // Validate required properties + const requiredProps = Reflect.getMetadata(REQUIRED_PROPS_KEY, NestedClass) || []; + for (const prop of requiredProps) { + if ((instance as any)[prop] === undefined || (instance as any)[prop] === null) { + throw new Error(`Required configuration property '${propertyPath}.${prop}' is missing`); + } + } + + // Validate with class-validator if needed + const shouldValidate = Reflect.getMetadata(VALIDATE_KEY, NestedClass); + if (this.validateOnBind && shouldValidate) { + const errors = validateSync(instance); + if (errors.length > 0) { + const messages = this.formatValidationErrors(errors); + throw new Error(`Validation failed for ${NestedClass.name} at path '${propertyPath}':\n${messages}`); + } + } + + return instance; + } + + /** + * Convert value to appropriate type based on property type and bind nested classes + * + * Handles: + * - Primitive types (Number, Boolean, String, Array) + * - Map types (converted from plain objects) + * - Record types (kept as plain objects) + * - Nested configuration classes (recursively bound with decorator support) + * + * @param value - The configuration value to convert + * @param instance - The parent class instance + * @param propertyKey - The property key being bound + * @returns The converted and bound value + */ + private convertAndBindType(value: any, instance: any, propertyKey: string): any { const type = Reflect.getMetadata('design:type', instance, propertyKey); if (!type) return value; + // Handle Map type + if (type === Map) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Cannot bind primitive value to Map for property '${propertyKey}'`); + } + + // Convert object to Map + const map = this.mapBinder.objectToMap(value); + + // Note: Automatic validation of Map entries is NOT supported. + // This is a limitation of class-validator, which requires known properties + // at compile time. Map entries must be validated manually if needed. + + return map; + } + + // Handle Record type (design:type will be Object) + // Record types are left as plain objects (not converted to Map) + // Note: Automatic validation of Record entries is NOT supported. + // This is a limitation of class-validator, which requires known properties + // at compile time. Record entries must be validated manually if needed. + if (type === Object && this.mapBinder.isRecordProperty(instance, propertyKey)) { + // Keep as plain object + return value; + } + + // Handle nested configuration class + if (this.isConfigurationClass(type)) { + return this.bindNestedClass(value, type, propertyKey); + } + switch (type.name) { case 'Number': return Number(value); diff --git a/packages/core/src/decorators.ts b/packages/core/src/decorators.ts index 67faf43..ef8a563 100644 --- a/packages/core/src/decorators.ts +++ b/packages/core/src/decorators.ts @@ -7,6 +7,7 @@ export const DEFAULTS_KEY = Symbol('defaults'); export const VALIDATE_KEY = Symbol('validate'); export const INJECTABLE_KEY = Symbol('injectable'); export const INJECT_KEY = Symbol('inject'); +export const RECORD_TYPE_KEY = 'custom:record'; /** * Decorator to mark a class as a configuration properties class @@ -85,3 +86,36 @@ export function Inject(token: any) { Reflect.defineMetadata(INJECT_KEY, existingInjections, target); }; } + +/** + * Decorator to mark a property as a Record type + * This ensures the property is not converted to a Map and remains a plain object. + * + * Usage: + * ```typescript + * @ConfigurationProperties('databases') + * class DatabasesConfig { + * @ConfigProperty('connections') + * @RecordType() + * connections: Record; + * } + * ``` + * + * **IMPORTANT**: Automatic validation of Record entries is NOT supported. + * This is a limitation of class-validator, which requires known properties at + * compile time. Record types have dynamic keys, so validation must be done manually. + * + * For validation, implement manual checks in your application code: + * ```typescript + * const config = manager.bind(DatabasesConfig); + * for (const [name, conn] of Object.entries(config.connections)) { + * if (!conn.host) throw new Error(`${name} missing host`); + * // ... more validation + * } + * ``` + */ +export function RecordType() { + return function (target: any, propertyKey: string) { + Reflect.defineMetadata(RECORD_TYPE_KEY, true, target, propertyKey); + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b3fa791..e685185 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,8 @@ export * from './sources'; export * from './config-manager'; export * from './container'; export * from './builder'; +export * from './placeholder-resolver'; +export * from './map-binder'; // Re-export commonly used types export type { diff --git a/packages/core/src/map-binder.ts b/packages/core/src/map-binder.ts new file mode 100644 index 0000000..5616522 --- /dev/null +++ b/packages/core/src/map-binder.ts @@ -0,0 +1,68 @@ +import 'reflect-metadata'; +import { RECORD_TYPE_KEY } from './decorators'; + +/** + * Utility class for detecting and binding Map-typed properties + * + * Note: Automatic validation of Map/Record entries is NOT supported. + * This is a limitation of class-validator, which requires known properties + * at compile time. Use manual validation for Map/Record entries. + */ +export class MapBinder { + /** + * Check if a property should be bound as a Map + * @param target - Class instance + * @param propertyKey - Property name + * @returns true if property is Map-typed + */ + isMapProperty(target: any, propertyKey: string): boolean { + const type = Reflect.getMetadata('design:type', target, propertyKey); + return type === Map; + } + + /** + * Check if a property is a Record type (object with string keys) + * Record types have design:type of Object but should not be converted to Map + * @param target - Class instance + * @param propertyKey - Property name + * @returns true if property is Record-typed + */ + isRecordProperty(target: any, propertyKey: string): boolean { + const type = Reflect.getMetadata('design:type', target, propertyKey); + // Record types appear as Object in reflect-metadata + // We need to distinguish them from other Object types + // Check if the property has been explicitly marked as a Record type + const isRecord = Reflect.getMetadata(RECORD_TYPE_KEY, target, propertyKey); + return type === Object && isRecord === true; + } + + /** + * Convert a plain object to a Map instance + * @param obj - Plain object from configuration + * @param valueType - Optional type constructor for map values + * @returns Map instance + */ + objectToMap(obj: Record, valueType?: new () => T): Map { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { + throw new Error('Expected object for Map binding'); + } + + const map = new Map(); + + for (const [key, value] of Object.entries(obj)) { + // If valueType is provided, instantiate and populate it + if (valueType) { + const instance = new valueType(); + // Copy properties from value to instance + Object.assign(instance as object, value); + map.set(key, instance); + } else { + // Otherwise, use the value as-is (preserving nested structures) + map.set(key, value as T); + } + } + + return map; + } + +} diff --git a/packages/core/src/placeholder-resolver.ts b/packages/core/src/placeholder-resolver.ts new file mode 100644 index 0000000..2f8eacf --- /dev/null +++ b/packages/core/src/placeholder-resolver.ts @@ -0,0 +1,141 @@ +/** + * PlaceholderResolver - Resolves environment variable placeholders in configuration values + * + * Supports syntax: ${ENV_VAR_NAME:fallback} + * - ${VAR} - resolves to environment variable VAR, undefined if not set + * - ${VAR:fallback} - resolves to environment variable VAR, or fallback if not set + * - \${VAR} - escapes to literal ${VAR} (no resolution) + */ +export class PlaceholderResolver { + // Regex pattern to match ${VAR_NAME:fallback} or ${VAR_NAME} + // Captures: group 1 = VAR_NAME, group 2 = fallback (optional) + private readonly placeholderPattern = /\$\{([^}:]+)(?::([^}]*))?\}/g; + + // Regex pattern to detect escaped placeholders \${...} + private readonly escapedPattern = /\\\$\{([^}]+)\}/g; + + /** + * Check if a string contains placeholder syntax + * @param value - String to check + * @returns true if value contains ${...} pattern + */ + hasPlaceholder(value: string): boolean { + if (typeof value !== 'string') { + return false; + } + + // Reset regex state + this.placeholderPattern.lastIndex = 0; + + return this.placeholderPattern.test(value); + } + + /** + * Resolve all placeholders in a string value + * @param value - String that may contain ${VAR:fallback} patterns + * @param envProvider - Function to get environment variables (defaults to process.env) + * @returns Resolved string with all placeholders replaced, or undefined if resolution fails + */ + resolve( + value: string, + envProvider: (key: string) => string | undefined = (key) => process.env[key] + ): string | undefined { + if (typeof value !== 'string') { + return value; + } + + // First, handle escaped placeholders by temporarily replacing them + const escapedPlaceholders: string[] = []; + const tempValue = value.replace(this.escapedPattern, (match, content) => { + const placeholder = `__ESCAPED_${escapedPlaceholders.length}__`; + escapedPlaceholders.push(`\${${content}}`); + return placeholder; + }); + + // Track if we found any placeholders + let foundPlaceholder = false; + let allResolved = true; + + // Reset regex state + this.placeholderPattern.lastIndex = 0; + + // Resolve actual placeholders + const resolved = tempValue.replace( + this.placeholderPattern, + (match, varName, fallback) => { + foundPlaceholder = true; + const envValue = envProvider(varName.trim()); + + if (envValue !== undefined) { + // Environment variable exists, use its value + return envValue; + } else if (fallback !== undefined) { + // Environment variable doesn't exist, use fallback (can be empty string) + return fallback; + } else { + // No environment variable and no fallback - mark as unresolved + allResolved = false; + return match; // Keep the original placeholder + } + } + ); + + // If we found placeholders but couldn't resolve all of them, return undefined + if (foundPlaceholder && !allResolved) { + return undefined; + } + + // Restore escaped placeholders (remove backslash) + let finalValue = resolved; + escapedPlaceholders.forEach((escaped, index) => { + finalValue = finalValue.replace(`__ESCAPED_${index}__`, escaped); + }); + + return finalValue; + } + + /** + * Recursively resolve placeholders in an entire configuration object + * @param config - Configuration object with potential placeholders + * @param envProvider - Function to get environment variables + * @returns New object with all placeholders resolved + */ + resolveObject( + config: Record, + envProvider: (key: string) => string | undefined = (key) => process.env[key] + ): Record { + if (!config || typeof config !== 'object') { + return config; + } + + if (Array.isArray(config)) { + return config.map((item) => { + if (typeof item === 'string') { + return this.resolve(item, envProvider); + } else if (typeof item === 'object' && item !== null) { + return this.resolveObject(item, envProvider); + } + return item; + }); + } + + const resolved: Record = {}; + + for (const [key, value] of Object.entries(config)) { + if (typeof value === 'string') { + const resolvedValue = this.resolve(value, envProvider); + // Only set the property if resolution succeeded + // If resolvedValue is undefined, the property will be omitted + if (resolvedValue !== undefined) { + resolved[key] = resolvedValue; + } + } else if (typeof value === 'object' && value !== null) { + resolved[key] = this.resolveObject(value, envProvider); + } else { + resolved[key] = value; + } + } + + return resolved; + } +} diff --git a/packages/core/src/sources.ts b/packages/core/src/sources.ts index c53f8a2..f19b8d8 100644 --- a/packages/core/src/sources.ts +++ b/packages/core/src/sources.ts @@ -158,7 +158,7 @@ export class EncryptionHelper { * Check if a value is encrypted */ isEncrypted(value: string): boolean { - return typeof value === 'string' && /^ENC\([^:]+:.+\)$/.test(value); + return /^ENC\([^:]+:.+\)$/.test(value); } /** diff --git a/packages/core/test/builder.spec.ts b/packages/core/test/builder.spec.ts index 12bc99c..17987a6 100644 --- a/packages/core/test/builder.spec.ts +++ b/packages/core/test/builder.spec.ts @@ -1,7 +1,10 @@ import 'reflect-metadata'; -import { ConfigurationBuilder } from '../src/builder'; -import { InMemoryConfigSource } from '../src/sources'; -import { ConfigurationProperties, ConfigProperty } from '../src/decorators'; +import { + ConfigProperty, + ConfigurationBuilder, + ConfigurationProperties, + InMemoryConfigSource, +} from '../src'; describe('ConfigurationBuilder', () => { describe('withProfile', () => { @@ -148,7 +151,9 @@ describe('ConfigurationBuilder', () => { } const builder = new ConfigurationBuilder(); - builder.addSource(new InMemoryConfigSource({ config1: { value: 'v1' }, config2: { value: 'v2' } }, 100)); + builder.addSource( + new InMemoryConfigSource({ config1: { value: 'v1' }, config2: { value: 'v2' } }, 100) + ); builder.registerConfigs([Config1, Config2]); const { container } = await builder.build(); diff --git a/packages/core/test/config-manager.spec.ts b/packages/core/test/config-manager.spec.ts index 0de48b6..68a517d 100644 --- a/packages/core/test/config-manager.spec.ts +++ b/packages/core/test/config-manager.spec.ts @@ -2,10 +2,16 @@ import 'reflect-metadata'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { ConfigManager, ConfigManagerOptions } from '../src/config-manager'; -import { InMemoryConfigSource } from '../src/sources'; -import { ConfigurationProperties, ConfigProperty, Required, DefaultValue, Validate } from '../src/decorators'; -import { IsString, IsNumber, IsEmail, MinLength, MaxLength, Min, Max, IsUrl } from 'class-validator'; +import { + ConfigManager, + ConfigProperty, + ConfigurationProperties, + DefaultValue, + InMemoryConfigSource, + Required, + Validate, +} from '../src'; +import { IsEmail, IsNumber, IsString, IsUrl, Max, Min, MinLength } from 'class-validator'; describe('ConfigManager', () => { let tempDir: string; @@ -103,7 +109,9 @@ describe('ConfigManager', () => { describe('get', () => { it('should retrieve nested config value', async () => { - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource({ a: { b: { c: 'value' } } }, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource({ a: { b: { c: 'value' } } }, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -133,7 +141,9 @@ describe('ConfigManager', () => { }); it('should handle arrays', async () => { - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource({ items: ['a', 'b', 'c'] }, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource({ items: ['a', 'b', 'c'] }, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -146,7 +156,9 @@ describe('ConfigManager', () => { describe('getAll', () => { it('should return all configuration', async () => { const config = { database: { host: 'localhost' }, server: { port: 3000 } }; - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -156,7 +168,9 @@ describe('ConfigManager', () => { }); it('should return copy of config', async () => { - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource({ test: 'value' }, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource({ test: 'value' }, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -179,7 +193,9 @@ describe('ConfigManager', () => { } const config = { database: { host: 'localhost', port: 5432 } }; - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -223,7 +239,9 @@ describe('ConfigManager', () => { createdManagers.push(manager); await manager.initialize(); - expect(() => manager.bind(DatabaseConfig)).toThrow("Required configuration property 'database.host' is missing"); + expect(() => manager.bind(DatabaseConfig)).toThrow( + "Required configuration property 'database.host' is missing" + ); }); it('should return cached instance', async () => { @@ -234,7 +252,9 @@ describe('ConfigManager', () => { } const config = { test: { value: 'original' } }; - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -251,7 +271,9 @@ describe('ConfigManager', () => { createdManagers.push(manager); await manager.initialize(); - expect(() => manager.bind(PlainClass)).toThrow('must be decorated with @ConfigurationProperties'); + expect(() => manager.bind(PlainClass)).toThrow( + 'must be decorated with @ConfigurationProperties' + ); }); it('should convert types correctly', async () => { @@ -270,8 +292,12 @@ describe('ConfigManager', () => { arrayValue!: string[]; } - const config = { test: { stringValue: 123, numberValue: '456', booleanValue: 'true', arrayValue: 'single' } }; - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const config = { + test: { stringValue: 123, numberValue: '456', booleanValue: 'true', arrayValue: 'single' }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -286,7 +312,9 @@ describe('ConfigManager', () => { describe('onChange', () => { it('should notify listeners on config change', async () => { - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource({ test: 'value' }, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource({ test: 'value' }, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -361,6 +389,455 @@ describe('ConfigManager', () => { }); }); + describe('Map binding', () => { + it('should bind configuration to Map property', async () => { + @ConfigurationProperties('databases') + class DatabasesConfig { + @ConfigProperty('connections') + connections!: Map; + } + + const config = { + databases: { + connections: { + db1: { host: 'localhost', port: 5432 }, + db2: { host: 'remote', port: 5433 }, + }, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + const instance = manager.bind(DatabasesConfig); + + expect(instance.connections).toBeInstanceOf(Map); + expect(instance.connections.size).toBe(2); + expect(instance.connections.get('db1')).toEqual({ host: 'localhost', port: 5432 }); + expect(instance.connections.get('db2')).toEqual({ host: 'remote', port: 5433 }); + }); + + it('should preserve nested structures in map values', async () => { + @ConfigurationProperties('config') + class TestConfig { + @ConfigProperty('items') + items!: Map; + } + + const config = { + config: { + items: { + item1: { + name: 'Test', + nested: { + deep: { + value: 'nested-value', + }, + }, + }, + }, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + const instance = manager.bind(TestConfig); + + expect(instance.items.get('item1')).toEqual({ + name: 'Test', + nested: { + deep: { + value: 'nested-value', + }, + }, + }); + }); + + it('should throw error when binding non-object to Map', async () => { + @ConfigurationProperties('test') + class TestConfig { + @ConfigProperty('map') + map!: Map; + } + + const config = { test: { map: 'not-an-object' } }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(() => manager.bind(TestConfig)).toThrow( + "Cannot bind primitive value to Map for property 'map'" + ); + }); + + it('should throw error when binding array to Map', async () => { + @ConfigurationProperties('test') + class TestConfig { + @ConfigProperty('map') + map!: Map; + } + + const config = { test: { map: ['array', 'values'] } }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(() => manager.bind(TestConfig)).toThrow( + "Cannot bind primitive value to Map for property 'map'" + ); + }); + + it('should handle empty map', async () => { + @ConfigurationProperties('test') + class TestConfig { + @ConfigProperty('map') + map!: Map; + } + + const config = { test: { map: {} } }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + const instance = manager.bind(TestConfig); + + expect(instance.map).toBeInstanceOf(Map); + expect(instance.map.size).toBe(0); + }); + + it('should validate required Map property', async () => { + @ConfigurationProperties('test') + class TestConfig { + @Required() + @ConfigProperty('map') + map!: Map; + } + + const config = { test: {} }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(() => manager.bind(TestConfig)).toThrow( + "Required configuration property 'test.map' is missing" + ); + }); + + it('should work with Map and default values', async () => { + @ConfigurationProperties('test') + class TestConfig { + @DefaultValue({}) + @ConfigProperty('map') + map!: Map; + } + + const config = { test: {} }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + const instance = manager.bind(TestConfig); + + expect(instance.map).toBeInstanceOf(Map); + expect(instance.map.size).toBe(0); + }); + }); + + describe('Map path access', () => { + it('should access deep path into maps', async () => { + const config = { + databases: { + connections: { + 'serhafen-us': { + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'secret', + database: 'serhafen_common', + schema: 'us', + }, + 'serhafen-ag': { + host: 'remote-host', + port: 5433, + username: 'admin', + password: 'admin-secret', + database: 'serhafen_ag', + schema: 'ag', + }, + }, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Test deep path access to specific nested values + expect(manager.get('databases.connections.serhafen-us.host')).toBe('localhost'); + expect(manager.get('databases.connections.serhafen-us.port')).toBe(5432); + expect(manager.get('databases.connections.serhafen-us.username')).toBe('postgres'); + expect(manager.get('databases.connections.serhafen-ag.host')).toBe('remote-host'); + expect(manager.get('databases.connections.serhafen-ag.schema')).toBe('ag'); + }); + + it('should access partial path to return entire map entry', async () => { + const config = { + databases: { + connections: { + 'serhafen-us': { + host: 'localhost', + port: 5432, + username: 'postgres', + }, + 'serhafen-ag': { + host: 'remote-host', + port: 5433, + username: 'admin', + }, + }, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Test partial path access to get entire map entry + const usEntry = manager.get('databases.connections.serhafen-us'); + expect(usEntry).toEqual({ + host: 'localhost', + port: 5432, + username: 'postgres', + }); + + const agEntry = manager.get('databases.connections.serhafen-ag'); + expect(agEntry).toEqual({ + host: 'remote-host', + port: 5433, + username: 'admin', + }); + }); + + it('should access top-level map to return entire map as object', async () => { + const config = { + databases: { + connections: { + db1: { host: 'host1', port: 5432 }, + db2: { host: 'host2', port: 5433 }, + db3: { host: 'host3', port: 5434 }, + }, + pool: { + min: 2, + max: 10, + }, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Test top-level map access + const connections = manager.get('databases.connections'); + expect(connections).toEqual({ + db1: { host: 'host1', port: 5432 }, + db2: { host: 'host2', port: 5433 }, + db3: { host: 'host3', port: 5434 }, + }); + + // Verify it's an object with all keys + expect(Object.keys(connections)).toEqual(['db1', 'db2', 'db3']); + }); + + it('should return default value for missing map key', async () => { + const config = { + databases: { + connections: { + db1: { host: 'host1', port: 5432 }, + }, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Test default value for non-existent map key + const result = manager.get('databases.connections.non-existent', { + host: 'default-host', + port: 9999, + }); + expect(result).toEqual({ host: 'default-host', port: 9999 }); + + // Test default value for non-existent nested property + const nestedResult = manager.get('databases.connections.non-existent.host', 'fallback-host'); + expect(nestedResult).toBe('fallback-host'); + }); + + it('should handle kebab-case keys in map paths', async () => { + const config = { + services: { + endpoints: { + 'user-service': { url: 'http://user-service:8080' }, + 'order-service': { url: 'http://order-service:8081' }, + 'payment-service': { url: 'http://payment-service:8082' }, + }, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Test kebab-case keys work correctly + expect(manager.get('services.endpoints.user-service.url')).toBe('http://user-service:8080'); + expect(manager.get('services.endpoints.order-service.url')).toBe('http://order-service:8081'); + expect(manager.get('services.endpoints.payment-service.url')).toBe( + 'http://payment-service:8082' + ); + }); + + it('should handle deeply nested structures within map values', async () => { + const config = { + applications: { + configs: { + app1: { + server: { + host: 'localhost', + port: 3000, + ssl: { + enabled: true, + cert: '/path/to/cert', + key: '/path/to/key', + }, + }, + }, + }, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Test deeply nested path access + expect(manager.get('applications.configs.app1.server.host')).toBe('localhost'); + expect(manager.get('applications.configs.app1.server.ssl.enabled')).toBe(true); + expect(manager.get('applications.configs.app1.server.ssl.cert')).toBe('/path/to/cert'); + + // Test partial access to nested object + const ssl = manager.get('applications.configs.app1.server.ssl'); + expect(ssl).toEqual({ + enabled: true, + cert: '/path/to/cert', + key: '/path/to/key', + }); + }); + + it('should return undefined for missing nested map keys without default', async () => { + const config = { + databases: { + connections: { + db1: { host: 'host1' }, + }, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Test undefined for missing keys + expect(manager.get('databases.connections.missing-db')).toBeUndefined(); + expect(manager.get('databases.connections.missing-db.host')).toBeUndefined(); + expect(manager.get('databases.missing-section.db1')).toBeUndefined(); + }); + + it('should handle empty map structures', async () => { + const config = { + databases: { + connections: {}, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Test empty map access + const connections = manager.get('databases.connections'); + expect(connections).toEqual({}); + expect(Object.keys(connections)).toHaveLength(0); + + // Test accessing key in empty map + expect(manager.get('databases.connections.any-key')).toBeUndefined(); + expect(manager.get('databases.connections.any-key', 'default')).toBe('default'); + }); + + it('should work with map paths across profile merging', async () => { + const basePath = path.join(tempDir, 'application.json'); + const prodPath = path.join(tempDir, 'application-production.json'); + + fs.writeFileSync( + basePath, + JSON.stringify({ + databases: { + connections: { + db1: { host: 'localhost', port: 5432 }, + db2: { host: 'localhost', port: 5433 }, + }, + }, + }) + ); + + fs.writeFileSync( + prodPath, + JSON.stringify({ + databases: { + connections: { + db1: { host: 'prod-host', port: 5432 }, + }, + }, + }) + ); + + const manager = new ConfigManager({ configDir: tempDir, profile: 'production' }); + createdManagers.push(manager); + await manager.initialize(); + + // Test that profile-specific values override base + expect(manager.get('databases.connections.db1.host')).toBe('prod-host'); + expect(manager.get('databases.connections.db1.port')).toBe(5432); + + // Test that base values remain for non-overridden entries + expect(manager.get('databases.connections.db2.host')).toBe('localhost'); + expect(manager.get('databases.connections.db2.port')).toBe(5433); + }); + }); + describe('validation', () => { it('should validate config with @Validate decorator when validation passes', async () => { @Validate() @@ -376,7 +853,9 @@ describe('ConfigManager', () => { } const config = { server: { host: 'localhost', port: 3000 } }; - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -396,7 +875,9 @@ describe('ConfigManager', () => { } const config = { server: { email: 'not-an-email' } }; // Invalid: not an email format - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -415,7 +896,9 @@ describe('ConfigManager', () => { } const config = { server: { port: 100 } }; // Invalid: below minimum - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -442,7 +925,9 @@ describe('ConfigManager', () => { } const config = { database: { host: 'not-a-url', port: 100, username: 'ab' } }; // All invalid - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -480,7 +965,9 @@ describe('ConfigManager', () => { } const config = { server: { email: 'not-an-email' } }; // Invalid but no @Validate decorator - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -505,7 +992,9 @@ describe('ConfigManager', () => { } const config = {}; // Empty config, will use defaults - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -526,7 +1015,9 @@ describe('ConfigManager', () => { } const config = {}; // Empty config, will use invalid default - const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)] }); + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); createdManagers.push(manager); await manager.initialize(); @@ -550,15 +1041,21 @@ describe('ConfigManager', () => { // Test missing required property const config1 = { database: { port: 5432 } }; // Missing required host - const manager1 = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config1, 100)] }); + const manager1 = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config1, 100)], + }); createdManagers.push(manager1); await manager1.initialize(); - expect(() => manager1.bind(DatabaseConfig)).toThrow("Required configuration property 'database.host' is missing"); + expect(() => manager1.bind(DatabaseConfig)).toThrow( + "Required configuration property 'database.host' is missing" + ); // Test invalid type for required property const config2 = { database: { host: 'not-a-url', port: 5432 } }; // Invalid URL - const manager2 = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config2, 100)] }); + const manager2 = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config2, 100)], + }); createdManagers.push(manager2); await manager2.initialize(); diff --git a/packages/core/test/cross-format-consistency.spec.ts b/packages/core/test/cross-format-consistency.spec.ts new file mode 100644 index 0000000..9e8f780 --- /dev/null +++ b/packages/core/test/cross-format-consistency.spec.ts @@ -0,0 +1,468 @@ +import 'reflect-metadata'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ConfigManager, InMemoryConfigSource } from '../src'; + +describe('Cross-Format and Cross-Source Consistency', () => { + let originalEnv: NodeJS.ProcessEnv; + const createdManagers: ConfigManager[] = []; + let tempDir: string; + + beforeEach(() => { + originalEnv = { ...process.env }; + // Create a temporary directory for test config files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'config-test-')); + }); + + afterEach(async () => { + process.env = originalEnv; + for (const manager of createdManagers) { + await manager.dispose(); + } + createdManagers.length = 0; + + // Clean up temp directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('Cross-format consistency (JSON vs YAML)', () => { + it('should resolve placeholders identically in JSON and YAML files', async () => { + process.env.DB_HOST = 'prod-server'; + process.env.DB_PORT = '5432'; + + // Create JSON config + const jsonConfig = { + database: { + host: '${DB_HOST}', + port: '${DB_PORT:3306}', + username: '${DB_USER:postgres}', + password: '${DB_PASSWORD}', + }, + }; + fs.writeFileSync(path.join(tempDir, 'application.json'), JSON.stringify(jsonConfig, null, 2)); + + // Create YAML config with identical structure + const yamlConfig = `database: + host: '\${DB_HOST}' + port: '\${DB_PORT:3306}' + username: '\${DB_USER:postgres}' + password: '\${DB_PASSWORD}' +`; + fs.writeFileSync(path.join(tempDir, 'application.yml'), yamlConfig); + + // Load JSON config + const jsonManager = new ConfigManager({ configDir: tempDir }); + createdManagers.push(jsonManager); + await jsonManager.initialize(); + + // Load YAML config (create new temp dir to avoid merging) + const tempDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'config-test-')); + fs.writeFileSync(path.join(tempDir2, 'application.yml'), yamlConfig); + const yamlManager = new ConfigManager({ configDir: tempDir2 }); + createdManagers.push(yamlManager); + await yamlManager.initialize(); + + // Both should resolve identically + expect(jsonManager.get('database.host')).toBe('prod-server'); + expect(yamlManager.get('database.host')).toBe('prod-server'); + + expect(jsonManager.get('database.port')).toBe('5432'); + expect(yamlManager.get('database.port')).toBe('5432'); + + expect(jsonManager.get('database.username')).toBe('postgres'); + expect(yamlManager.get('database.username')).toBe('postgres'); + + expect(jsonManager.get('database.password')).toBeUndefined(); + expect(yamlManager.get('database.password')).toBeUndefined(); + + // Clean up second temp dir + fs.rmSync(tempDir2, { recursive: true, force: true }); + }); + + it('should resolve multiple placeholders identically in JSON and YAML', async () => { + process.env.DB_USER = 'admin'; + process.env.DB_NAME = 'mydb'; + + // JSON config + const jsonConfig = { + database: { + url: 'postgres://${DB_USER}@${DB_HOST:localhost}/${DB_NAME}', + }, + }; + fs.writeFileSync(path.join(tempDir, 'application.json'), JSON.stringify(jsonConfig, null, 2)); + + // YAML config + const yamlConfig = `database: + url: 'postgres://\${DB_USER}@\${DB_HOST:localhost}/\${DB_NAME}' +`; + const tempDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'config-test-')); + fs.writeFileSync(path.join(tempDir2, 'application.yml'), yamlConfig); + + const jsonManager = new ConfigManager({ configDir: tempDir }); + createdManagers.push(jsonManager); + await jsonManager.initialize(); + + const yamlManager = new ConfigManager({ configDir: tempDir2 }); + createdManagers.push(yamlManager); + await yamlManager.initialize(); + + const expectedUrl = 'postgres://admin@localhost/mydb'; + expect(jsonManager.get('database.url')).toBe(expectedUrl); + expect(yamlManager.get('database.url')).toBe(expectedUrl); + + fs.rmSync(tempDir2, { recursive: true, force: true }); + }); + + it('should handle nested structures identically in JSON and YAML', async () => { + process.env.PRIMARY_HOST = 'primary-server'; + process.env.SECONDARY_HOST = 'secondary-server'; + + // JSON config + const jsonConfig = { + databases: { + connections: { + primary: { + host: '${PRIMARY_HOST}', + port: '${PRIMARY_PORT:5432}', + }, + secondary: { + host: '${SECONDARY_HOST}', + port: '${SECONDARY_PORT:5433}', + }, + }, + }, + }; + fs.writeFileSync(path.join(tempDir, 'application.json'), JSON.stringify(jsonConfig, null, 2)); + + // YAML config + const yamlConfig = `databases: + connections: + primary: + host: '\${PRIMARY_HOST}' + port: '\${PRIMARY_PORT:5432}' + secondary: + host: '\${SECONDARY_HOST}' + port: '\${SECONDARY_PORT:5433}' +`; + const tempDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'config-test-')); + fs.writeFileSync(path.join(tempDir2, 'application.yml'), yamlConfig); + + const jsonManager = new ConfigManager({ configDir: tempDir }); + createdManagers.push(jsonManager); + await jsonManager.initialize(); + + const yamlManager = new ConfigManager({ configDir: tempDir2 }); + createdManagers.push(yamlManager); + await yamlManager.initialize(); + + expect(jsonManager.get('databases.connections.primary.host')).toBe('primary-server'); + expect(yamlManager.get('databases.connections.primary.host')).toBe('primary-server'); + + expect(jsonManager.get('databases.connections.primary.port')).toBe('5432'); + expect(yamlManager.get('databases.connections.primary.port')).toBe('5432'); + + expect(jsonManager.get('databases.connections.secondary.host')).toBe('secondary-server'); + expect(yamlManager.get('databases.connections.secondary.host')).toBe('secondary-server'); + + expect(jsonManager.get('databases.connections.secondary.port')).toBe('5433'); + expect(yamlManager.get('databases.connections.secondary.port')).toBe('5433'); + + fs.rmSync(tempDir2, { recursive: true, force: true }); + }); + }); + + describe('Custom source consistency (InMemoryConfigSource)', () => { + it('should resolve placeholders from InMemoryConfigSource', async () => { + process.env.API_KEY = 'secret-key-123'; + process.env.API_URL = 'https://api.example.com'; + + const config = { + api: { + key: '${API_KEY}', + url: '${API_URL}', + timeout: '${API_TIMEOUT:5000}', + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('api.key')).toBe('secret-key-123'); + expect(manager.get('api.url')).toBe('https://api.example.com'); + expect(manager.get('api.timeout')).toBe('5000'); + }); + + it('should resolve placeholders consistently across file and custom sources', async () => { + process.env.DB_HOST = 'prod-server'; + process.env.CACHE_HOST = 'redis-server'; + + // File config + const fileConfig = { + database: { + host: '${DB_HOST}', + port: '${DB_PORT:5432}', + }, + }; + fs.writeFileSync(path.join(tempDir, 'application.json'), JSON.stringify(fileConfig, null, 2)); + + // Custom source config + const customConfig = { + cache: { + host: '${CACHE_HOST}', + port: '${CACHE_PORT:6379}', + }, + }; + + const manager = new ConfigManager({ + configDir: tempDir, + additionalSources: [new InMemoryConfigSource(customConfig, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Both sources should resolve placeholders identically + expect(manager.get('database.host')).toBe('prod-server'); + expect(manager.get('database.port')).toBe('5432'); + expect(manager.get('cache.host')).toBe('redis-server'); + expect(manager.get('cache.port')).toBe('6379'); + }); + + it('should handle custom sources with complex nested placeholders', async () => { + process.env.SERVICE_NAME = 'my-service'; + process.env.REGION = 'us-east-1'; + + const config = { + services: { + primary: { + name: '${SERVICE_NAME}', + endpoint: 'https://${SERVICE_NAME}.${REGION}.example.com', + config: { + region: '${REGION}', + timeout: '${TIMEOUT:30000}', + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('services.primary.name')).toBe('my-service'); + expect(manager.get('services.primary.endpoint')).toBe( + 'https://my-service.us-east-1.example.com' + ); + expect(manager.get('services.primary.config.region')).toBe('us-east-1'); + expect(manager.get('services.primary.config.timeout')).toBe('30000'); + }); + }); + + describe('Consistent multi-reference resolution', () => { + it('should resolve the same environment variable consistently across multiple references', async () => { + process.env.SHARED_HOST = 'shared-server'; + + const config = { + service1: { + host: '${SHARED_HOST}', + }, + service2: { + host: '${SHARED_HOST}', + }, + service3: { + endpoint: 'https://${SHARED_HOST}/api', + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + const host1 = manager.get('service1.host'); + const host2 = manager.get('service2.host'); + const endpoint = manager.get('service3.endpoint'); + + expect(host1).toBe('shared-server'); + expect(host2).toBe('shared-server'); + expect(endpoint).toBe('https://shared-server/api'); + + // All references should be identical + expect(host1).toBe(host2); + }); + + it('should resolve the same placeholder with fallback consistently', async () => { + // Don't set DEFAULT_PORT in environment + + const config = { + service1: { + port: '${DEFAULT_PORT:8080}', + }, + service2: { + port: '${DEFAULT_PORT:8080}', + }, + service3: { + url: 'http://localhost:${DEFAULT_PORT:8080}', + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + const port1 = manager.get('service1.port'); + const port2 = manager.get('service2.port'); + const url = manager.get('service3.url'); + + expect(port1).toBe('8080'); + expect(port2).toBe('8080'); + expect(url).toBe('http://localhost:8080'); + + // All should use the same fallback value + expect(port1).toBe(port2); + }); + + it('should resolve consistently when environment variable changes', async () => { + process.env.DYNAMIC_VALUE = 'initial-value'; + + const config = { + field1: '${DYNAMIC_VALUE}', + field2: '${DYNAMIC_VALUE}', + field3: 'prefix-${DYNAMIC_VALUE}-suffix', + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('field1')).toBe('initial-value'); + expect(manager.get('field2')).toBe('initial-value'); + expect(manager.get('field3')).toBe('prefix-initial-value-suffix'); + + // All references should be consistent + expect(manager.get('field1')).toBe(manager.get('field2')); + }); + }); + + describe('Cross-source with arrays', () => { + it('should resolve placeholders in arrays from custom sources', async () => { + process.env.HOST1 = 'server1.example.com'; + process.env.HOST2 = 'server2.example.com'; + + const config = { + servers: ['${HOST1}', '${HOST2}', '${HOST3:server3.example.com}'], + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('servers')).toEqual([ + 'server1.example.com', + 'server2.example.com', + 'server3.example.com', + ]); + }); + + it('should resolve placeholders in arrays identically across JSON and YAML', async () => { + process.env.ENDPOINT1 = 'https://api1.example.com'; + process.env.ENDPOINT2 = 'https://api2.example.com'; + + // JSON config + const jsonConfig = { + endpoints: ['${ENDPOINT1}', '${ENDPOINT2}', '${ENDPOINT3:https://api3.example.com}'], + }; + fs.writeFileSync(path.join(tempDir, 'application.json'), JSON.stringify(jsonConfig, null, 2)); + + // YAML config + const yamlConfig = `endpoints: + - '\${ENDPOINT1}' + - '\${ENDPOINT2}' + - '\${ENDPOINT3:https://api3.example.com}' +`; + const tempDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'config-test-')); + fs.writeFileSync(path.join(tempDir2, 'application.yml'), yamlConfig); + + const jsonManager = new ConfigManager({ configDir: tempDir }); + createdManagers.push(jsonManager); + await jsonManager.initialize(); + + const yamlManager = new ConfigManager({ configDir: tempDir2 }); + createdManagers.push(yamlManager); + await yamlManager.initialize(); + + const expectedEndpoints = [ + 'https://api1.example.com', + 'https://api2.example.com', + 'https://api3.example.com', + ]; + + expect(jsonManager.get('endpoints')).toEqual(expectedEndpoints); + expect(yamlManager.get('endpoints')).toEqual(expectedEndpoints); + + fs.rmSync(tempDir2, { recursive: true, force: true }); + }); + }); + + describe('Undefined on resolution failure', () => { + it('should return undefined when placeholder has no fallback and env var not set', async () => { + const config = { + database: { + host: '${DB_HOST}', + port: '${DB_PORT:5432}', + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('database.host')).toBeUndefined(); + expect(manager.get('database.port')).toBe('5432'); + }); + + it('should handle undefined consistently across all source types', async () => { + // File source + const fileConfig = { + file: { + value: '${MISSING_VAR}', + }, + }; + fs.writeFileSync(path.join(tempDir, 'application.json'), JSON.stringify(fileConfig, null, 2)); + + // Custom source + const customConfig = { + custom: { + value: '${MISSING_VAR}', + }, + }; + + const manager = new ConfigManager({ + configDir: tempDir, + additionalSources: [new InMemoryConfigSource(customConfig, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Both should be undefined + expect(manager.get('file.value')).toBeUndefined(); + expect(manager.get('custom.value')).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/test/helper-methods.spec.ts b/packages/core/test/helper-methods.spec.ts new file mode 100644 index 0000000..1ddb0ac --- /dev/null +++ b/packages/core/test/helper-methods.spec.ts @@ -0,0 +1,309 @@ +import 'reflect-metadata'; +import { ConfigManager } from '../src/config-manager'; +import { + ConfigurationProperties, + ConfigProperty, + DefaultValue, + Required, + Validate, +} from '../src/decorators'; + +describe('ConfigManager Helper Methods', () => { + let manager: ConfigManager; + + beforeEach(async () => { + manager = new ConfigManager({ + configDir: './test/fixtures', + profile: 'test', + }); + await manager.initialize(); + }); + + afterEach(async () => { + await manager.dispose(); + }); + + describe('isConfigurationClass', () => { + it('should return true for class with @DefaultValue', () => { + class TestClass { + @DefaultValue(10) + timeout!: number; + } + + // Access private method via type assertion + const result = (manager as any).isConfigurationClass(TestClass); + expect(result).toBe(true); + }); + + it('should return true for class with @Required', () => { + class TestClass { + @Required() + host!: string; + } + + const result = (manager as any).isConfigurationClass(TestClass); + expect(result).toBe(true); + }); + + it('should return true for class with @Validate', () => { + @Validate() + class TestClass { + host!: string; + } + + const result = (manager as any).isConfigurationClass(TestClass); + expect(result).toBe(true); + }); + + it('should return true for class with @ConfigProperty', () => { + class TestClass { + @ConfigProperty('host') + hostname!: string; + } + + const result = (manager as any).isConfigurationClass(TestClass); + expect(result).toBe(true); + }); + + it('should return false for plain class without decorators', () => { + class PlainClass { + value!: string; + } + + const result = (manager as any).isConfigurationClass(PlainClass); + expect(result).toBe(false); + }); + + it('should return false for non-function types', () => { + expect((manager as any).isConfigurationClass(null)).toBe(false); + expect((manager as any).isConfigurationClass(undefined)).toBe(false); + expect((manager as any).isConfigurationClass({})).toBe(false); + expect((manager as any).isConfigurationClass('string')).toBe(false); + expect((manager as any).isConfigurationClass(123)).toBe(false); + }); + }); + + describe('getAllProperties', () => { + it('should return properties with @ConfigProperty', () => { + class TestClass { + @ConfigProperty('host') + hostname!: string; + + @ConfigProperty('port') + portNumber!: number; + } + + const instance = new TestClass(); + const result = (manager as any).getAllProperties(instance, TestClass); + + expect(result.size).toBe(2); + expect(result.get('hostname')).toBe('host'); + expect(result.get('portNumber')).toBe('port'); + }); + + it('should include properties with @DefaultValue but no @ConfigProperty', () => { + class TestClass { + @ConfigProperty('host') + hostname!: string; + + @DefaultValue(5432) + port!: number; + } + + const instance = new TestClass(); + const result = (manager as any).getAllProperties(instance, TestClass); + + expect(result.size).toBe(2); + expect(result.get('hostname')).toBe('host'); + expect(result.get('port')).toBe('port'); // Uses property name + }); + + it('should include properties with @Required but no @ConfigProperty', () => { + class TestClass { + @ConfigProperty('host') + hostname!: string; + + @Required() + database!: string; + } + + const instance = new TestClass(); + const result = (manager as any).getAllProperties(instance, TestClass); + + expect(result.size).toBe(2); + expect(result.get('hostname')).toBe('host'); + expect(result.get('database')).toBe('database'); // Uses property name + }); + + it('should not duplicate properties', () => { + class TestClass { + @ConfigProperty('host') + @DefaultValue('localhost') + @Required() + hostname!: string; + } + + const instance = new TestClass(); + const result = (manager as any).getAllProperties(instance, TestClass); + + expect(result.size).toBe(1); + expect(result.get('hostname')).toBe('host'); + }); + + it('should handle class with no decorated properties', () => { + class TestClass { + value!: string; + } + + const instance = new TestClass(); + const result = (manager as any).getAllProperties(instance, TestClass); + + expect(result.size).toBe(0); + }); + }); + + describe('getAllPropertiesForNestedClass', () => { + it('should return properties with @ConfigProperty', () => { + class NestedClass { + @ConfigProperty('timeout') + timeoutMs!: number; + } + + const instance = new NestedClass(); + const configValue = { timeout: 5000 }; + const result = (manager as any).getAllPropertiesForNestedClass( + instance, + NestedClass, + configValue + ); + + // Should include both the decorated property and the config value property + expect(result.size).toBe(2); + expect(result.get('timeoutMs')).toBe('timeout'); + expect(result.get('timeout')).toBe('timeout'); + }); + + it('should include properties with @DefaultValue', () => { + class NestedClass { + @DefaultValue(10000) + timeout!: number; + } + + const instance = new NestedClass(); + const configValue = {}; + const result = (manager as any).getAllPropertiesForNestedClass( + instance, + NestedClass, + configValue + ); + + expect(result.size).toBe(1); + expect(result.get('timeout')).toBe('timeout'); + }); + + it('should include properties with @Required', () => { + class NestedClass { + @Required() + host!: string; + } + + const instance = new NestedClass(); + const configValue = { host: 'localhost' }; + const result = (manager as any).getAllPropertiesForNestedClass( + instance, + NestedClass, + configValue + ); + + expect(result.size).toBe(1); + expect(result.get('host')).toBe('host'); + }); + + it('should include properties present in config value', () => { + class NestedClass { + @DefaultValue(5432) + port!: number; + } + + const instance = new NestedClass(); + const configValue = { port: 3000, host: 'localhost', database: 'mydb' }; + const result = (manager as any).getAllPropertiesForNestedClass( + instance, + NestedClass, + configValue + ); + + expect(result.size).toBe(3); + expect(result.get('port')).toBe('port'); + expect(result.get('host')).toBe('host'); + expect(result.get('database')).toBe('database'); + }); + + it('should not duplicate properties', () => { + class NestedClass { + @ConfigProperty('timeout') + @DefaultValue(10000) + @Required() + timeoutMs!: number; + } + + const instance = new NestedClass(); + const configValue = { timeout: 5000 }; + const result = (manager as any).getAllPropertiesForNestedClass( + instance, + NestedClass, + configValue + ); + + // Should include both the decorated property and the config value property + // but not duplicate the 'timeoutMs' key + expect(result.size).toBe(2); + expect(result.get('timeoutMs')).toBe('timeout'); + expect(result.get('timeout')).toBe('timeout'); + }); + + it('should handle null or undefined config value', () => { + class NestedClass { + @DefaultValue(10) + value!: number; + } + + const instance = new NestedClass(); + + const resultNull = (manager as any).getAllPropertiesForNestedClass( + instance, + NestedClass, + null + ); + expect(resultNull.size).toBe(1); + expect(resultNull.get('value')).toBe('value'); + + const resultUndefined = (manager as any).getAllPropertiesForNestedClass( + instance, + NestedClass, + undefined + ); + expect(resultUndefined.size).toBe(1); + expect(resultUndefined.get('value')).toBe('value'); + }); + + it('should handle array config value', () => { + class NestedClass { + @DefaultValue(10) + value!: number; + } + + const instance = new NestedClass(); + const configValue = [1, 2, 3]; + const result = (manager as any).getAllPropertiesForNestedClass( + instance, + NestedClass, + configValue + ); + + // Arrays should not add their indices as properties + expect(result.size).toBe(1); + expect(result.get('value')).toBe('value'); + }); + }); +}); diff --git a/packages/core/test/map-type.spec.ts b/packages/core/test/map-type.spec.ts new file mode 100644 index 0000000..63fcee1 --- /dev/null +++ b/packages/core/test/map-type.spec.ts @@ -0,0 +1,787 @@ +import 'reflect-metadata'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + ConfigManager, + ConfigProperty, + ConfigurationProperties, + InMemoryConfigSource, + Required, + DefaultValue, +} from '../src'; +import { MapBinder } from '../src/map-binder'; + +/** + * Test configuration class for database connections + */ +class DatabaseConnection { + host!: string; + port!: number; + username!: string; + password!: string; + database!: string; + schema!: string; +} + +/** + * Configuration class using Map type + */ +@ConfigurationProperties('databases') +class DatabasesMapConfig { + @ConfigProperty('connections') + connections!: Map; +} + +/** + * Configuration class with required Map property + */ +@ConfigurationProperties('databases') +class DatabasesRequiredMapConfig { + @Required() + @ConfigProperty('connections') + connections!: Map; +} + +/** + * Configuration class with default Map value + */ +@ConfigurationProperties('databases') +class DatabasesDefaultMapConfig { + @DefaultValue({}) + @ConfigProperty('connections') + connections!: Map; +} + +/** + * Configuration class with nested Map structures + */ +@ConfigurationProperties('config') +class NestedMapConfig { + @ConfigProperty('items') + items!: Map; +} + +describe('Map Type Functionality', () => { + let tempDir: string; + const createdManagers: ConfigManager[] = []; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'map-test-')); + }); + + afterEach(async () => { + fs.rmSync(tempDir, { recursive: true, force: true }); + for (const manager of createdManagers) { + await manager.dispose(); + } + createdManagers.length = 0; + }); + + describe('Map binding preserves all key-value pairs from YAML', () => { + it('should preserve all entries when binding from YAML', async () => { + const yamlContent = ` +databases: + connections: + db1: + host: host1 + port: 5432 + username: user1 + password: pass1 + database: database1 + schema: schema1 + db2: + host: host2 + port: 5433 + username: user2 + password: pass2 + database: database2 + schema: schema2 + db3: + host: host3 + port: 5434 + username: user3 + password: pass3 + database: database3 + schema: schema3 +`; + + const configPath = path.join(tempDir, 'application.yml'); + fs.writeFileSync(configPath, yamlContent); + + const manager = new ConfigManager({ configDir: tempDir, validateOnBind: false }); + createdManagers.push(manager); + await manager.initialize(); + + const config = manager.bind(DatabasesMapConfig); + + // Should preserve all 3 entries + expect(config.connections).toBeInstanceOf(Map); + expect(config.connections.size).toBe(3); + + // Verify all keys are present + expect(config.connections.has('db1')).toBe(true); + expect(config.connections.has('db2')).toBe(true); + expect(config.connections.has('db3')).toBe(true); + + // Verify all values are preserved + expect(config.connections.get('db1')).toEqual({ + host: 'host1', + port: 5432, + username: 'user1', + password: 'pass1', + database: 'database1', + schema: 'schema1', + }); + expect(config.connections.get('db2')).toEqual({ + host: 'host2', + port: 5433, + username: 'user2', + password: 'pass2', + database: 'database2', + schema: 'schema2', + }); + expect(config.connections.get('db3')).toEqual({ + host: 'host3', + port: 5434, + username: 'user3', + password: 'pass3', + database: 'database3', + schema: 'schema3', + }); + }); + + it('should preserve all entries when binding from JSON', async () => { + const jsonContent = { + databases: { + connections: { + conn1: { host: 'h1', port: 1111 }, + conn2: { host: 'h2', port: 2222 }, + conn3: { host: 'h3', port: 3333 }, + conn4: { host: 'h4', port: 4444 }, + }, + }, + }; + + const configPath = path.join(tempDir, 'application.json'); + fs.writeFileSync(configPath, JSON.stringify(jsonContent)); + + const manager = new ConfigManager({ configDir: tempDir, validateOnBind: false }); + createdManagers.push(manager); + await manager.initialize(); + + const config = manager.bind(DatabasesMapConfig); + + expect(config.connections.size).toBe(4); + expect(config.connections.get('conn1')?.host).toBe('h1'); + expect(config.connections.get('conn2')?.port).toBe(2222); + expect(config.connections.get('conn3')?.host).toBe('h3'); + expect(config.connections.get('conn4')?.port).toBe(4444); + }); + }); + + describe('Map type detection using reflect-metadata', () => { + it('should detect Map type using MapBinder.isMapProperty', () => { + const instance = new DatabasesMapConfig(); + const binder = new MapBinder(); + + const isMap = binder.isMapProperty(instance, 'connections'); + + expect(isMap).toBe(true); + }); + + it('should not detect non-Map properties as Map', () => { + @ConfigurationProperties('test') + class NonMapConfig { + @ConfigProperty('value') + value!: string; + + @ConfigProperty('obj') + obj!: object; + + @ConfigProperty('arr') + arr!: any[]; + } + + const instance = new NonMapConfig(); + const binder = new MapBinder(); + + expect(binder.isMapProperty(instance, 'value')).toBe(false); + expect(binder.isMapProperty(instance, 'obj')).toBe(false); + expect(binder.isMapProperty(instance, 'arr')).toBe(false); + }); + + it('should automatically convert object to Map during binding', async () => { + const config = { + databases: { + connections: { + auto1: { host: 'autohost1', port: 1000 }, + auto2: { host: 'autohost2', port: 2000 }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesMapConfig); + + // Should automatically detect and convert to Map + expect(boundConfig.connections).toBeInstanceOf(Map); + expect(boundConfig.connections.size).toBe(2); + }); + }); + + describe('Map with nested object structures', () => { + it('should preserve deeply nested structures in map values', async () => { + const config = { + config: { + items: { + item1: { + name: 'Item 1', + metadata: { + tags: ['tag1', 'tag2'], + properties: { + color: 'red', + size: 'large', + nested: { + deep: { + value: 'deeply-nested-value', + }, + }, + }, + }, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(NestedMapConfig); + + const item1 = boundConfig.items.get('item1'); + expect(item1).toBeDefined(); + expect(item1.name).toBe('Item 1'); + expect(item1.metadata.tags).toEqual(['tag1', 'tag2']); + expect(item1.metadata.properties.color).toBe('red'); + expect(item1.metadata.properties.nested.deep.value).toBe('deeply-nested-value'); + }); + + it('should handle multiple levels of nested objects', async () => { + const config = { + config: { + items: { + complex: { + level1: { + level2: { + level3: { + level4: { + value: 'deep-value', + }, + }, + }, + }, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(NestedMapConfig); + + const complex = boundConfig.items.get('complex'); + expect(complex.level1.level2.level3.level4.value).toBe('deep-value'); + }); + + it('should preserve arrays within nested structures', async () => { + const config = { + config: { + items: { + withArrays: { + simpleArray: [1, 2, 3], + objectArray: [ + { id: 1, name: 'first' }, + { id: 2, name: 'second' }, + ], + nestedArrays: [[1, 2], [3, 4]], + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(NestedMapConfig); + + const withArrays = boundConfig.items.get('withArrays'); + expect(withArrays.simpleArray).toEqual([1, 2, 3]); + expect(withArrays.objectArray).toEqual([ + { id: 1, name: 'first' }, + { id: 2, name: 'second' }, + ]); + expect(withArrays.nestedArrays).toEqual([[1, 2], [3, 4]]); + }); + }); + + describe('Map.get(), Map.has(), Map.size methods work correctly', () => { + it('should support Map.get() method', async () => { + const config = { + databases: { + connections: { + primary: { host: 'primary-host', port: 5432 }, + secondary: { host: 'secondary-host', port: 5433 }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesMapConfig); + + // Test .get() method + const primary = boundConfig.connections.get('primary'); + expect(primary).toBeDefined(); + expect(primary?.host).toBe('primary-host'); + expect(primary?.port).toBe(5432); + + const secondary = boundConfig.connections.get('secondary'); + expect(secondary).toBeDefined(); + expect(secondary?.host).toBe('secondary-host'); + + // Non-existent key should return undefined + const nonExistent = boundConfig.connections.get('non-existent'); + expect(nonExistent).toBeUndefined(); + }); + + it('should support Map.has() method', async () => { + const config = { + databases: { + connections: { + db1: { host: 'host1', port: 1111 }, + db2: { host: 'host2', port: 2222 }, + db3: { host: 'host3', port: 3333 }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesMapConfig); + + // Test .has() method + expect(boundConfig.connections.has('db1')).toBe(true); + expect(boundConfig.connections.has('db2')).toBe(true); + expect(boundConfig.connections.has('db3')).toBe(true); + expect(boundConfig.connections.has('db4')).toBe(false); + expect(boundConfig.connections.has('non-existent')).toBe(false); + }); + + it('should support Map.size property', async () => { + const config = { + databases: { + connections: { + conn1: { host: 'h1', port: 1 }, + conn2: { host: 'h2', port: 2 }, + conn3: { host: 'h3', port: 3 }, + conn4: { host: 'h4', port: 4 }, + conn5: { host: 'h5', port: 5 }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesMapConfig); + + // Test .size property + expect(boundConfig.connections.size).toBe(5); + }); + + it('should support Map iteration methods', async () => { + const config = { + databases: { + connections: { + db1: { host: 'host1', port: 1111 }, + db2: { host: 'host2', port: 2222 }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesMapConfig); + + // Test .keys() + const keys = Array.from(boundConfig.connections.keys()); + expect(keys).toEqual(['db1', 'db2']); + + // Test .values() + const values = Array.from(boundConfig.connections.values()); + expect(values).toHaveLength(2); + expect(values[0].host).toBe('host1'); + expect(values[1].host).toBe('host2'); + + // Test .entries() + const entries = Array.from(boundConfig.connections.entries()); + expect(entries).toHaveLength(2); + expect(entries[0][0]).toBe('db1'); + expect(entries[0][1].port).toBe(1111); + expect(entries[1][0]).toBe('db2'); + expect(entries[1][1].port).toBe(2222); + + // Test for...of iteration + const iteratedKeys: string[] = []; + for (const [key] of boundConfig.connections) { + iteratedKeys.push(key); + } + expect(iteratedKeys).toEqual(['db1', 'db2']); + }); + }); + + describe('Map with required properties throws validation error when missing', () => { + it('should throw error when required Map property is missing', async () => { + const config = { + databases: { + // Missing connections property + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: true, + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(() => manager.bind(DatabasesRequiredMapConfig)).toThrow( + "Required configuration property 'databases.connections' is missing" + ); + }); + + it('should not throw when required Map property is present', async () => { + const config = { + databases: { + connections: { + db1: { host: 'host1', port: 5432 }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: true, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesRequiredMapConfig); + expect(boundConfig.connections).toBeInstanceOf(Map); + expect(boundConfig.connections.size).toBe(1); + }); + + it('should throw error when required Map property is null', async () => { + const config = { + databases: { + connections: null, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: true, + }); + createdManagers.push(manager); + await manager.initialize(); + + // When null is provided, type conversion happens first and throws + // because null cannot be converted to Map + expect(() => manager.bind(DatabasesRequiredMapConfig)).toThrow( + "Cannot bind primitive value to Map for property 'connections'" + ); + }); + }); + + describe('Map validation limitations', () => { + it('should NOT validate Map entries automatically', () => { + const binder = new MapBinder(); + + // MapBinder is for type detection and conversion, not validation + expect(binder.isMapProperty).toBeDefined(); + expect(binder.objectToMap).toBeDefined(); + + // validateMapEntries method has been removed - validation must be manual + expect((binder as any).validateMapEntries).toBeUndefined(); + }); + }); + + describe('Map with complex nested values (objects within objects)', () => { + it('should handle complex nested structures with multiple levels', async () => { + const config = { + config: { + items: { + complexItem: { + server: { + host: 'localhost', + port: 3000, + ssl: { + enabled: true, + cert: '/path/to/cert', + key: '/path/to/key', + options: { + minVersion: 'TLSv1.2', + ciphers: ['AES256', 'AES128'], + }, + }, + }, + database: { + primary: { + host: 'db-primary', + port: 5432, + pool: { + min: 2, + max: 10, + idle: 10000, + }, + }, + replica: { + host: 'db-replica', + port: 5432, + pool: { + min: 1, + max: 5, + idle: 5000, + }, + }, + }, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(NestedMapConfig); + + const item = boundConfig.items.get('complexItem'); + expect(item).toBeDefined(); + + // Verify server config + expect(item.server.host).toBe('localhost'); + expect(item.server.ssl.enabled).toBe(true); + expect(item.server.ssl.options.minVersion).toBe('TLSv1.2'); + expect(item.server.ssl.options.ciphers).toEqual(['AES256', 'AES128']); + + // Verify database config + expect(item.database.primary.host).toBe('db-primary'); + expect(item.database.primary.pool.max).toBe(10); + expect(item.database.replica.host).toBe('db-replica'); + expect(item.database.replica.pool.min).toBe(1); + }); + + it('should preserve mixed types in nested structures', async () => { + const config = { + config: { + items: { + mixedTypes: { + stringValue: 'text', + numberValue: 42, + booleanValue: true, + nullValue: null, + arrayValue: [1, 'two', { three: 3 }], + objectValue: { + nested: { + deeply: { + value: 'deep', + }, + }, + }, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(NestedMapConfig); + + const item = boundConfig.items.get('mixedTypes'); + expect(item.stringValue).toBe('text'); + expect(item.numberValue).toBe(42); + expect(item.booleanValue).toBe(true); + expect(item.nullValue).toBeNull(); + expect(item.arrayValue).toEqual([1, 'two', { three: 3 }]); + expect(item.objectValue.nested.deeply.value).toBe('deep'); + }); + }); + + describe('ConfigManager.bind() returns proper Map instance', () => { + it('should return instance with Map property', async () => { + const config = { + databases: { + connections: { + test: { host: 'testhost', port: 9999 }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesMapConfig); + + // Should return proper instance + expect(boundConfig).toBeInstanceOf(DatabasesMapConfig); + expect(boundConfig.connections).toBeInstanceOf(Map); + }); + + it('should cache and return same instance on multiple bind calls', async () => { + const config = { + databases: { + connections: { + cached: { host: 'cachehost', port: 7777 }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const instance1 = manager.bind(DatabasesMapConfig); + const instance2 = manager.bind(DatabasesMapConfig); + + // Should return same cached instance + expect(instance1).toBe(instance2); + expect(instance1.connections).toBe(instance2.connections); + }); + + it('should work with default values for Map properties', async () => { + const config = { + databases: { + // connections not provided, should use default + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesDefaultMapConfig); + + // Should use default empty object and convert to Map + expect(boundConfig.connections).toBeInstanceOf(Map); + expect(boundConfig.connections.size).toBe(0); + }); + + it('should throw error when binding non-object to Map', async () => { + @ConfigurationProperties('test') + class InvalidMapConfig { + @ConfigProperty('map') + map!: Map; + } + + const config = { + test: { + map: 'not-an-object', // Invalid: string instead of object + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(() => manager.bind(InvalidMapConfig)).toThrow( + "Cannot bind primitive value to Map for property 'map'" + ); + }); + + it('should throw error when binding array to Map', async () => { + @ConfigurationProperties('test') + class InvalidMapConfig { + @ConfigProperty('map') + map!: Map; + } + + const config = { + test: { + map: ['array', 'values'], // Invalid: array instead of object + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(() => manager.bind(InvalidMapConfig)).toThrow( + "Cannot bind primitive value to Map for property 'map'" + ); + }); + }); +}); diff --git a/packages/core/test/map-vs-record.spec.ts b/packages/core/test/map-vs-record.spec.ts new file mode 100644 index 0000000..c9dccfc --- /dev/null +++ b/packages/core/test/map-vs-record.spec.ts @@ -0,0 +1,975 @@ +import 'reflect-metadata'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + ConfigManager, + ConfigProperty, + ConfigurationProperties, + InMemoryConfigSource, + RecordType, + Required, + Validate, +} from '../src'; +import { IsBoolean, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { MapBinder } from '../src/map-binder'; + +/** + * Test configuration class for database connections with validation + */ +class DatabaseConnectionValidated { + @IsString() + host!: string; + + @IsNumber() + @Min(1) + @Max(65535) + port!: number; + + @IsString() + username!: string; + + @IsString() + password!: string; + + @IsString() + database!: string; + + @IsString() + schema!: string; + + @IsBoolean() + ssl!: boolean; +} + +/** + * Configuration class using Map type + */ +@ConfigurationProperties('databases') +class DatabasesMapConfig { + @ConfigProperty('connections') + @Required() + connections!: Map; +} + +/** + * Configuration class using Record type + * Note: Not using @RecordType() decorator to allow automatic validation + */ +@ConfigurationProperties('databases') +@Validate() +class DatabasesRecordConfig { + @ConfigProperty('connections') + @Required() + @ValidateNested({ each: true }) + @Type(() => DatabaseConnectionValidated) + connections!: Record; +} + +describe('Map vs Record Behavior Comparison', () => { + let tempDir: string; + const createdManagers: ConfigManager[] = []; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'map-vs-record-test-')); + }); + + afterEach(async () => { + fs.rmSync(tempDir, { recursive: true, force: true }); + for (const manager of createdManagers) { + await manager.dispose(); + } + createdManagers.length = 0; + }); + + describe('Same YAML config binds correctly to both Map and Record classes', () => { + it('should bind same YAML config to both Map and Record with identical data', async () => { + const yamlContent = ` +databases: + connections: + serhafen-us: + host: localhost + port: 5432 + username: postgres + password: secret + database: serhafen_common + schema: us + ssl: false + serhafen-ag: + host: remotehost + port: 3306 + username: admin + password: pass123 + database: serhafen_ag + schema: ag + ssl: true +`; + + const configPath = path.join(tempDir, 'application.yml'); + fs.writeFileSync(configPath, yamlContent); + + const manager = new ConfigManager({ configDir: tempDir, validateOnBind: false }); + createdManagers.push(manager); + await manager.initialize(); + + const mapConfig = manager.bind(DatabasesMapConfig); + const recordConfig = manager.bind(DatabasesRecordConfig); + + // Both should have same number of entries + expect(mapConfig.connections.size).toBe(2); + expect(Object.keys(recordConfig.connections)).toHaveLength(2); + + // Verify Map data + expect(mapConfig.connections.get('serhafen-us')?.host).toBe('localhost'); + expect(mapConfig.connections.get('serhafen-us')?.port).toBe(5432); + expect(mapConfig.connections.get('serhafen-us')?.ssl).toBe(false); + expect(mapConfig.connections.get('serhafen-ag')?.host).toBe('remotehost'); + expect(mapConfig.connections.get('serhafen-ag')?.port).toBe(3306); + expect(mapConfig.connections.get('serhafen-ag')?.ssl).toBe(true); + + // Verify Record data (same values) + expect(recordConfig.connections['serhafen-us'].host).toBe('localhost'); + expect(recordConfig.connections['serhafen-us'].port).toBe(5432); + expect(recordConfig.connections['serhafen-us'].ssl).toBe(false); + expect(recordConfig.connections['serhafen-ag'].host).toBe('remotehost'); + expect(recordConfig.connections['serhafen-ag'].port).toBe(3306); + expect(recordConfig.connections['serhafen-ag'].ssl).toBe(true); + }); + + it('should bind same JSON config to both Map and Record with identical data', async () => { + const jsonContent = { + databases: { + connections: { + 'db-primary': { + host: 'primary.example.com', + port: 5432, + username: 'primary_user', + password: 'primary_pass', + database: 'primary_db', + schema: 'public', + ssl: true, + }, + 'db-replica': { + host: 'replica.example.com', + port: 5433, + username: 'replica_user', + password: 'replica_pass', + database: 'replica_db', + schema: 'public', + ssl: false, + }, + }, + }, + }; + + const configPath = path.join(tempDir, 'application.json'); + fs.writeFileSync(configPath, JSON.stringify(jsonContent)); + + const manager = new ConfigManager({ configDir: tempDir, validateOnBind: false }); + createdManagers.push(manager); + await manager.initialize(); + + const mapConfig = manager.bind(DatabasesMapConfig); + const recordConfig = manager.bind(DatabasesRecordConfig); + + // Both should have same data + expect(mapConfig.connections.size).toBe(2); + expect(Object.keys(recordConfig.connections)).toHaveLength(2); + + // Verify identical data + expect(mapConfig.connections.get('db-primary')?.host).toBe( + recordConfig.connections['db-primary'].host + ); + expect(mapConfig.connections.get('db-replica')?.ssl).toBe( + recordConfig.connections['db-replica'].ssl + ); + }); + }); + + describe('Map instance has .get(), .set(), .has(), .delete() methods', () => { + it('should have .get() method that retrieves values', async () => { + const config = { + databases: { + connections: { + testdb: { + host: 'testhost', + port: 5432, + username: 'testuser', + password: 'testpass', + database: 'testdb', + schema: 'public', + ssl: false, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const mapConfig = manager.bind(DatabasesMapConfig); + + // Test .get() method + expect(typeof mapConfig.connections.get).toBe('function'); + const entry = mapConfig.connections.get('testdb'); + expect(entry).toBeDefined(); + expect(entry?.host).toBe('testhost'); + expect(mapConfig.connections.get('nonexistent')).toBeUndefined(); + }); + + it('should have .set() method that adds/updates entries', async () => { + const config = { + databases: { + connections: { + existing: { + host: 'existinghost', + port: 5432, + username: 'user', + password: 'pass', + database: 'db', + schema: 'schema', + ssl: false, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const mapConfig = manager.bind(DatabasesMapConfig); + + // Test .set() method + expect(typeof mapConfig.connections.set).toBe('function'); + + const newEntry = { + host: 'newhost', + port: 3306, + username: 'newuser', + password: 'newpass', + database: 'newdb', + schema: 'newschema', + ssl: true, + }; + + mapConfig.connections.set('newdb', newEntry as any); + expect(mapConfig.connections.size).toBe(2); + expect(mapConfig.connections.get('newdb')?.host).toBe('newhost'); + }); + + it('should have .has() method that checks for key existence', async () => { + const config = { + databases: { + connections: { + db1: { + host: 'host1', + port: 5432, + username: 'user1', + password: 'pass1', + database: 'db1', + schema: 'schema1', + ssl: false, + }, + db2: { + host: 'host2', + port: 5433, + username: 'user2', + password: 'pass2', + database: 'db2', + schema: 'schema2', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const mapConfig = manager.bind(DatabasesMapConfig); + + // Test .has() method + expect(typeof mapConfig.connections.has).toBe('function'); + expect(mapConfig.connections.has('db1')).toBe(true); + expect(mapConfig.connections.has('db2')).toBe(true); + expect(mapConfig.connections.has('db3')).toBe(false); + expect(mapConfig.connections.has('nonexistent')).toBe(false); + }); + + it('should have .delete() method that removes entries', async () => { + const config = { + databases: { + connections: { + db1: { + host: 'host1', + port: 5432, + username: 'user1', + password: 'pass1', + database: 'db1', + schema: 'schema1', + ssl: false, + }, + db2: { + host: 'host2', + port: 5433, + username: 'user2', + password: 'pass2', + database: 'db2', + schema: 'schema2', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const mapConfig = manager.bind(DatabasesMapConfig); + + // Test .delete() method + expect(typeof mapConfig.connections.delete).toBe('function'); + expect(mapConfig.connections.size).toBe(2); + + const deleted = mapConfig.connections.delete('db1'); + expect(deleted).toBe(true); + expect(mapConfig.connections.size).toBe(1); + expect(mapConfig.connections.has('db1')).toBe(false); + expect(mapConfig.connections.has('db2')).toBe(true); + + const deletedAgain = mapConfig.connections.delete('nonexistent'); + expect(deletedAgain).toBe(false); + }); + }); + + describe('Record does not have Map methods (is plain object)', () => { + it('should not have Map methods', async () => { + const config = { + databases: { + connections: { + testdb: { + host: 'testhost', + port: 5432, + username: 'testuser', + password: 'testpass', + database: 'testdb', + schema: 'public', + ssl: false, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const recordConfig = manager.bind(DatabasesRecordConfig); + + // Record should not have Map methods + expect((recordConfig.connections as any).get).toBeUndefined(); + expect((recordConfig.connections as any).set).toBeUndefined(); + expect((recordConfig.connections as any).has).toBeUndefined(); + expect((recordConfig.connections as any).delete).toBeUndefined(); + expect((recordConfig.connections as any).size).toBeUndefined(); + }); + + it('should be a plain object', async () => { + const config = { + databases: { + connections: { + testdb: { + host: 'testhost', + port: 5432, + username: 'testuser', + password: 'testpass', + database: 'testdb', + schema: 'public', + ssl: false, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const recordConfig = manager.bind(DatabasesRecordConfig); + + // Should be plain object, not Map + expect(recordConfig.connections).not.toBeInstanceOf(Map); + expect(typeof recordConfig.connections).toBe('object'); + expect(recordConfig.connections.constructor).toBe(Object); + }); + + it('should use bracket notation for access', async () => { + const config = { + databases: { + connections: { + 'my-db': { + host: 'myhost', + port: 5432, + username: 'myuser', + password: 'mypass', + database: 'mydb', + schema: 'myschema', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const recordConfig = manager.bind(DatabasesRecordConfig); + + // Should use bracket notation + expect(recordConfig.connections['my-db']).toBeDefined(); + expect(recordConfig.connections['my-db'].host).toBe('myhost'); + expect(recordConfig.connections['my-db'].ssl).toBe(true); + }); + }); + + describe('Record validation happens automatically during bind()', () => { + it('should validate Record entries automatically when validateOnBind is true', async () => { + const config = { + databases: { + connections: { + 'valid-db': { + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'secret', + database: 'testdb', + schema: 'public', + ssl: true, + }, + 'invalid-db': { + host: 'localhost', + port: 99999, // Invalid: exceeds max of 65535 + username: 'postgres', + password: 'secret', + database: 'testdb', + schema: 'public', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: true, + }); + createdManagers.push(manager); + await manager.initialize(); + + // Should throw validation error automatically + expect(() => manager.bind(DatabasesRecordConfig)).toThrow(); + }); + + it('should validate all Record entries with nested validation', async () => { + const config = { + databases: { + connections: { + 'test-db': { + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'secret', + database: 'testdb', + schema: 'public', + ssl: 'not-a-boolean', // Invalid: should be boolean + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: true, + }); + createdManagers.push(manager); + await manager.initialize(); + + // Should throw validation error for invalid nested property + expect(() => manager.bind(DatabasesRecordConfig)).toThrow(); + }); + + it('should not validate Record entries with validateOnBind when using plain Record', async () => { + const config = { + databases: { + connections: { + 'db1': { + host: 'localhost', + port: 5432, + username: 'user1', + password: 'pass1', + database: 'db1', + schema: 'schema1', + ssl: true, + }, + 'db2': { + host: 'remotehost', + port: 3306, + username: 'user2', + password: 'pass2', + database: 'db2', + schema: 'schema2', + ssl: false, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, // Record validation doesn't work automatically + }); + createdManagers.push(manager); + await manager.initialize(); + + // Should bind successfully without validation + const recordConfig = manager.bind(DatabasesRecordConfig); + expect(recordConfig.connections).toBeDefined(); + expect(Object.keys(recordConfig.connections)).toHaveLength(2); + }); + }); + + describe('Map validation requires manual MapBinder.validateMapEntries() call', () => { + it('should not validate Map entries automatically during bind()', async () => { + const config = { + databases: { + connections: { + 'invalid-db': { + host: 'localhost', + port: 99999, // Invalid: exceeds max + username: 'postgres', + password: 'secret', + database: 'testdb', + schema: 'public', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, // Map requires validateOnBind: false + }); + createdManagers.push(manager); + await manager.initialize(); + + // Should NOT throw during bind - Map doesn't validate automatically + const mapConfig = manager.bind(DatabasesMapConfig); + expect(mapConfig.connections).toBeInstanceOf(Map); + expect(mapConfig.connections.size).toBe(1); + }); + + it('should require manual validation (no automatic validation available)', async () => { + const config = { + databases: { + connections: { + 'test-db': { + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'secret', + database: 'testdb', + schema: 'public', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const mapConfig = manager.bind(DatabasesMapConfig); + + // Manual validation must be implemented by the user + // MapBinder.validateMapEntries() has been removed as it didn't work properly + for (const [name, conn] of mapConfig.connections) { + expect(conn.host).toBeDefined(); + expect(conn.port).toBeGreaterThan(0); + expect(conn.port).toBeLessThanOrEqual(65535); + } + }); + + it('should allow binding invalid data without automatic validation', async () => { + const config = { + databases: { + connections: { + 'bad-port': { + host: 'localhost', + port: -1, // Invalid + username: 'user', + password: 'pass', + database: 'db', + schema: 'schema', + ssl: 'invalid', // Invalid + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + // Should bind successfully without validation + const mapConfig = manager.bind(DatabasesMapConfig); + expect(mapConfig.connections.size).toBe(1); + expect(mapConfig.connections.get('bad-port')?.port).toBe(-1); + }); + }); + + describe('Map requires validateOnBind: false to avoid validation errors', () => { + it('should work with validateOnBind: true but not validate Map entries', async () => { + const config = { + databases: { + connections: { + 'test-db': { + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'secret', + database: 'testdb', + schema: 'public', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: true, + }); + createdManagers.push(manager); + await manager.initialize(); + + // Map with validateOnBind: true works but doesn't validate entries automatically + // Only validates required properties, not the Map entries themselves + const mapConfig = manager.bind(DatabasesMapConfig); + expect(mapConfig.connections).toBeInstanceOf(Map); + expect(mapConfig.connections.size).toBe(1); + }); + + it('should work correctly when validateOnBind is false for Map', async () => { + const config = { + databases: { + connections: { + 'test-db': { + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'secret', + database: 'testdb', + schema: 'public', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, // Required for Map + }); + createdManagers.push(manager); + await manager.initialize(); + + // Should work fine with validateOnBind: false + const mapConfig = manager.bind(DatabasesMapConfig); + expect(mapConfig.connections).toBeInstanceOf(Map); + expect(mapConfig.connections.size).toBe(1); + }); + }); + + describe('Record works with validateOnBind: true', () => { + it('should validate required properties but not entries with validateOnBind: true', async () => { + const config = { + databases: { + connections: { + 'db1': { + host: 'localhost', + port: 5432, + username: 'user1', + password: 'pass1', + database: 'db1', + schema: 'schema1', + ssl: true, + }, + 'db2': { + host: 'remotehost', + port: 3306, + username: 'user2', + password: 'pass2', + database: 'db2', + schema: 'schema2', + ssl: false, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, // Record doesn't support automatic entry validation + }); + createdManagers.push(manager); + await manager.initialize(); + + // Should work with validateOnBind: false + const recordConfig = manager.bind(DatabasesRecordConfig); + expect(recordConfig.connections).toBeDefined(); + expect(Object.keys(recordConfig.connections)).toHaveLength(2); + }); + + it('should validate and catch errors with validateOnBind: true', async () => { + const config = { + databases: { + connections: { + 'invalid-db': { + host: 'localhost', + port: 99999, // Invalid + username: 'user', + password: 'pass', + database: 'db', + schema: 'schema', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: true, + }); + createdManagers.push(manager); + await manager.initialize(); + + // Should throw validation error + expect(() => manager.bind(DatabasesRecordConfig)).toThrow(); + }); + }); + + describe('Iteration: Map.entries() vs Object.entries(record)', () => { + it('should iterate Map using Map.entries()', async () => { + const config = { + databases: { + connections: { + db1: { + host: 'host1', + port: 5432, + username: 'user1', + password: 'pass1', + database: 'db1', + schema: 'schema1', + ssl: false, + }, + db2: { + host: 'host2', + port: 5433, + username: 'user2', + password: 'pass2', + database: 'db2', + schema: 'schema2', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const mapConfig = manager.bind(DatabasesMapConfig); + + // Iterate using Map.entries() + const entries = Array.from(mapConfig.connections.entries()); + expect(entries).toHaveLength(2); + expect(entries[0][0]).toBe('db1'); + expect(entries[0][1].host).toBe('host1'); + expect(entries[1][0]).toBe('db2'); + expect(entries[1][1].host).toBe('host2'); + + // Iterate using for...of + const keys: string[] = []; + for (const [key, value] of mapConfig.connections) { + keys.push(key); + expect(value.host).toBeDefined(); + } + expect(keys).toEqual(['db1', 'db2']); + }); + + it('should iterate Record using Object.entries()', async () => { + const config = { + databases: { + connections: { + db1: { + host: 'host1', + port: 5432, + username: 'user1', + password: 'pass1', + database: 'db1', + schema: 'schema1', + ssl: false, + }, + db2: { + host: 'host2', + port: 5433, + username: 'user2', + password: 'pass2', + database: 'db2', + schema: 'schema2', + ssl: true, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const recordConfig = manager.bind(DatabasesRecordConfig); + + // Iterate using Object.entries() + const entries = Object.entries(recordConfig.connections); + expect(entries).toHaveLength(2); + expect(entries[0][0]).toBe('db1'); + expect(entries[0][1].host).toBe('host1'); + expect(entries[1][0]).toBe('db2'); + expect(entries[1][1].host).toBe('host2'); + + // Iterate using for...in + const keys: string[] = []; + for (const key in recordConfig.connections) { + keys.push(key); + expect(recordConfig.connections[key].host).toBeDefined(); + } + expect(keys).toEqual(['db1', 'db2']); + }); + + it('should compare iteration patterns between Map and Record', async () => { + const config = { + databases: { + connections: { + conn1: { + host: 'host1', + port: 1111, + username: 'user1', + password: 'pass1', + database: 'db1', + schema: 'schema1', + ssl: false, + }, + conn2: { + host: 'host2', + port: 2222, + username: 'user2', + password: 'pass2', + database: 'db2', + schema: 'schema2', + ssl: true, + }, + conn3: { + host: 'host3', + port: 3333, + username: 'user3', + password: 'pass3', + database: 'db3', + schema: 'schema3', + ssl: false, + }, + }, + }, + }; + + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + validateOnBind: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + const mapConfig = manager.bind(DatabasesMapConfig); + const recordConfig = manager.bind(DatabasesRecordConfig); + + // Map iteration + const mapKeys = Array.from(mapConfig.connections.keys()); + const mapValues = Array.from(mapConfig.connections.values()); + const mapEntries = Array.from(mapConfig.connections.entries()); + + // Record iteration + const recordKeys = Object.keys(recordConfig.connections); + const recordValues = Object.values(recordConfig.connections); + const recordEntries = Object.entries(recordConfig.connections); + + // Both should have same keys + expect(mapKeys).toEqual(recordKeys); + expect(mapKeys).toEqual(['conn1', 'conn2', 'conn3']); + + // Both should have same number of values + expect(mapValues).toHaveLength(3); + expect(recordValues).toHaveLength(3); + + // Both should have same number of entries + expect(mapEntries).toHaveLength(3); + expect(recordEntries).toHaveLength(3); + + // Verify data is identical + expect(mapValues[0].host).toBe(recordValues[0].host); + expect(mapValues[1].port).toBe(recordValues[1].port); + expect(mapValues[2].ssl).toBe(recordValues[2].ssl); + }); + }); +}); diff --git a/packages/core/test/nested-class-binding.spec.ts b/packages/core/test/nested-class-binding.spec.ts new file mode 100644 index 0000000..4f6f347 --- /dev/null +++ b/packages/core/test/nested-class-binding.spec.ts @@ -0,0 +1,980 @@ +import 'reflect-metadata'; +import { ConfigManager } from '../src/config-manager'; +import { + ConfigurationProperties, + ConfigProperty, + DefaultValue, + Required, + Validate, +} from '../src/decorators'; +import { IsNumber, Min, Max } from 'class-validator'; + +describe('ConfigManager - Nested Class Binding', () => { + let manager: ConfigManager; + + beforeEach(() => { + manager = new ConfigManager({ + validateOnBind: true, + }); + }); + + afterEach(async () => { + await manager.dispose(); + }); + + describe('bindNestedClass', () => { + it('should bind a simple nested class with default values', () => { + class CircuitBreakerOptions { + @DefaultValue(10000) + timeout!: number; + + @DefaultValue(50) + errorThresholdPercentage!: number; + } + + const configValue = { timeout: 5000 }; + const result = (manager as any).bindNestedClass( + configValue, + CircuitBreakerOptions, + 'circuitBreaker' + ); + + expect(result).toBeInstanceOf(CircuitBreakerOptions); + expect(result.timeout).toBe(5000); + expect(result.errorThresholdPercentage).toBe(50); // Default value + }); + + it('should apply all default values when config is empty', () => { + class CircuitBreakerOptions { + @DefaultValue(10000) + timeout!: number; + + @DefaultValue(50) + errorThresholdPercentage!: number; + + @DefaultValue(10) + volumeThreshold!: number; + } + + const configValue = {}; + const result = (manager as any).bindNestedClass( + configValue, + CircuitBreakerOptions, + 'circuitBreaker' + ); + + expect(result).toBeInstanceOf(CircuitBreakerOptions); + expect(result.timeout).toBe(10000); + expect(result.errorThresholdPercentage).toBe(50); + expect(result.volumeThreshold).toBe(10); + }); + + it('should validate required properties in nested class', () => { + class CircuitBreakerOptions { + @DefaultValue(10000) + timeout!: number; + + @Required() + volumeThreshold!: number; + } + + const configValue = { timeout: 5000 }; + + expect(() => { + (manager as any).bindNestedClass( + configValue, + CircuitBreakerOptions, + 'circuitBreaker' + ); + }).toThrow("Required configuration property 'circuitBreaker.volumeThreshold' is missing"); + }); + + it('should satisfy required constraint with default value', () => { + class CircuitBreakerOptions { + @Required() + @DefaultValue(10) + volumeThreshold!: number; + } + + const configValue = {}; + const result = (manager as any).bindNestedClass( + configValue, + CircuitBreakerOptions, + 'circuitBreaker' + ); + + expect(result).toBeInstanceOf(CircuitBreakerOptions); + expect(result.volumeThreshold).toBe(10); + }); + + it('should validate nested class with class-validator', () => { + @Validate() + class CircuitBreakerOptions { + @IsNumber() + @Min(1) + @Max(100000) + timeout!: number; + } + + const configValue = { timeout: 'invalid' }; + + expect(() => { + (manager as any).bindNestedClass( + configValue, + CircuitBreakerOptions, + 'circuitBreaker' + ); + }).toThrow(/Validation failed for CircuitBreakerOptions at path 'circuitBreaker'/); + }); + + it('should skip validation when validateOnBind is false', () => { + const managerNoValidation = new ConfigManager({ + validateOnBind: false, + }); + + @Validate() + class CircuitBreakerOptions { + @IsNumber() + @Min(1) + timeout!: number; + } + + const configValue = { timeout: 'invalid' }; + const result = (managerNoValidation as any).bindNestedClass( + configValue, + CircuitBreakerOptions, + 'circuitBreaker' + ); + + expect(result).toBeInstanceOf(CircuitBreakerOptions); + // Type conversion still happens (string 'invalid' -> NaN), but validation is skipped + expect(isNaN(result.timeout)).toBe(true); + }); + + it('should recursively bind nested classes', () => { + class PoolConfig { + @DefaultValue(10) + maxConnections!: number; + + @DefaultValue(1) + minConnections!: number; + } + + class DatabaseConfig { + @Required() + host!: string; + + @DefaultValue(5432) + port!: number; + + // Need to explicitly set the type for TypeScript reflection + @ConfigProperty('pool') + pool!: PoolConfig; + } + + const configValue = { + host: 'localhost', + pool: { + maxConnections: 20, + }, + }; + + const result = (manager as any).bindNestedClass( + configValue, + DatabaseConfig, + 'database' + ); + + expect(result).toBeInstanceOf(DatabaseConfig); + expect(result.host).toBe('localhost'); + expect(result.port).toBe(5432); + expect(result.pool).toBeInstanceOf(PoolConfig); + expect(result.pool.maxConnections).toBe(20); + expect(result.pool.minConnections).toBe(1); // Default value + }); + + it('should handle properties without @ConfigProperty decorator', () => { + class CircuitBreakerOptions { + @DefaultValue(10000) + timeout!: number; + + @Required() + volumeThreshold!: number; + } + + const configValue = { + timeout: 5000, + volumeThreshold: 10, + }; + + const result = (manager as any).bindNestedClass( + configValue, + CircuitBreakerOptions, + 'circuitBreaker' + ); + + expect(result).toBeInstanceOf(CircuitBreakerOptions); + expect(result.timeout).toBe(5000); + expect(result.volumeThreshold).toBe(10); + }); + + it('should create instance for null/undefined when class has defaults, return primitives as-is', () => { + class CircuitBreakerOptions { + @DefaultValue(10000) + timeout!: number; + } + + // With @DefaultValue, null/undefined should create instances + const nullResult = (manager as any).bindNestedClass(null, CircuitBreakerOptions, 'test'); + expect(nullResult).toBeInstanceOf(CircuitBreakerOptions); + expect(nullResult.timeout).toBe(10000); + + const undefinedResult = (manager as any).bindNestedClass(undefined, CircuitBreakerOptions, 'test'); + expect(undefinedResult).toBeInstanceOf(CircuitBreakerOptions); + expect(undefinedResult.timeout).toBe(10000); + + // Primitives and arrays should still return as-is + expect((manager as any).bindNestedClass('string', CircuitBreakerOptions, 'test')).toBe('string'); + expect((manager as any).bindNestedClass(123, CircuitBreakerOptions, 'test')).toBe(123); + expect((manager as any).bindNestedClass([1, 2, 3], CircuitBreakerOptions, 'test')).toEqual([1, 2, 3]); + }); + + it('should handle custom property paths with @ConfigProperty', () => { + class CircuitBreakerOptions { + @ConfigProperty('timeout_ms') + @DefaultValue(10000) + timeout!: number; + } + + const configValue = { + timeout_ms: 5000, + }; + + const result = (manager as any).bindNestedClass( + configValue, + CircuitBreakerOptions, + 'circuitBreaker' + ); + + expect(result).toBeInstanceOf(CircuitBreakerOptions); + expect(result.timeout).toBe(5000); + }); + }); + + describe('Single-level nested classes', () => { + it('should bind nested class with @DefaultValue decorators', () => { + class RetryConfig { + @DefaultValue(3) + maxRetries!: number; + + @DefaultValue(1000) + retryDelay!: number; + + @DefaultValue(2) + backoffMultiplier!: number; + } + + @ConfigurationProperties('service') + class ServiceConfig { + @ConfigProperty('retry') + retryConfig!: RetryConfig; + } + + (manager as any).config = { + service: { + retry: { + maxRetries: 5, + }, + }, + }; + + const config = manager.bind(ServiceConfig); + + expect(config.retryConfig).toBeInstanceOf(RetryConfig); + expect(config.retryConfig.maxRetries).toBe(5); + expect(config.retryConfig.retryDelay).toBe(1000); // Default + expect(config.retryConfig.backoffMultiplier).toBe(2); // Default + }); + + it('should bind nested class with @Required decorators', () => { + class AuthConfig { + @Required() + apiKey!: string; + + @Required() + secretKey!: string; + + @DefaultValue('https://auth.example.com') + authUrl!: string; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty('auth') + authConfig!: AuthConfig; + } + + (manager as any).config = { + app: { + auth: { + apiKey: 'test-api-key', + secretKey: 'test-secret-key', + }, + }, + }; + + const config = manager.bind(AppConfig); + + expect(config.authConfig).toBeInstanceOf(AuthConfig); + expect(config.authConfig.apiKey).toBe('test-api-key'); + expect(config.authConfig.secretKey).toBe('test-secret-key'); + expect(config.authConfig.authUrl).toBe('https://auth.example.com'); + }); + + it('should bind nested class with @Validate() decorators', () => { + @Validate() + class RateLimitConfig { + @IsNumber() + @Min(1) + @Max(1000) + requestsPerMinute!: number; + + @IsNumber() + @Min(1) + @Max(3600) + windowSeconds!: number; + } + + @ConfigurationProperties('api') + class ApiConfig { + @ConfigProperty('rateLimit') + rateLimitConfig!: RateLimitConfig; + } + + (manager as any).config = { + api: { + rateLimit: { + requestsPerMinute: 100, + windowSeconds: 60, + }, + }, + }; + + const config = manager.bind(ApiConfig); + + expect(config.rateLimitConfig).toBeInstanceOf(RateLimitConfig); + expect(config.rateLimitConfig.requestsPerMinute).toBe(100); + expect(config.rateLimitConfig.windowSeconds).toBe(60); + }); + + it('should bind nested class with mixed decorators', () => { + @Validate() + class CacheConfig { + @Required() + @IsNumber() + @Min(1) + ttl!: number; + + @DefaultValue(100) + @IsNumber() + @Max(10000) + maxSize!: number; + + @DefaultValue('memory') + cacheType!: string; + + @Required() + enabled!: boolean; + } + + @ConfigurationProperties('cache') + class CacheSettings { + @ConfigProperty('config') + cacheConfig!: CacheConfig; + } + + (manager as any).config = { + cache: { + config: { + ttl: 3600, + enabled: true, + maxSize: 500, + }, + }, + }; + + const config = manager.bind(CacheSettings); + + expect(config.cacheConfig).toBeInstanceOf(CacheConfig); + expect(config.cacheConfig.ttl).toBe(3600); + expect(config.cacheConfig.maxSize).toBe(500); + expect(config.cacheConfig.cacheType).toBe('memory'); // Default + expect(config.cacheConfig.enabled).toBe(true); + }); + + it('should throw error when required property is missing in nested class', () => { + class DatabaseConfig { + @Required() + host!: string; + + @Required() + port!: number; + + @DefaultValue('mydb') + database!: string; + } + + @ConfigurationProperties('db') + class DbSettings { + @ConfigProperty('connection') + dbConfig!: DatabaseConfig; + } + + (manager as any).config = { + db: { + connection: { + host: 'localhost', + // Missing port + }, + }, + }; + + expect(() => { + manager.bind(DbSettings); + }).toThrow("Required configuration property 'dbConfig.port' is missing"); + }); + + it('should throw validation error for invalid nested class data', () => { + @Validate() + class TimeoutConfig { + @IsNumber() + @Min(100) + @Max(30000) + connectionTimeout!: number; + + @IsNumber() + @Min(100) + requestTimeout!: number; + } + + @ConfigurationProperties('http') + class HttpConfig { + @ConfigProperty('timeouts') + timeoutConfig!: TimeoutConfig; + } + + (manager as any).config = { + http: { + timeouts: { + connectionTimeout: 50, // Invalid: < 100 + requestTimeout: 5000, + }, + }, + }; + + expect(() => { + manager.bind(HttpConfig); + }).toThrow(/Validation failed for TimeoutConfig/); + }); + }); + + describe('Multi-level nested classes', () => { + it('should bind 2-level nested classes', () => { + class SslConfig { + @DefaultValue(true) + enabled!: boolean; + + @DefaultValue(false) + rejectUnauthorized!: boolean; + } + + class ConnectionConfig { + @Required() + host!: string; + + @DefaultValue(443) + port!: number; + + @ConfigProperty('ssl') + sslConfig!: SslConfig; + } + + @ConfigurationProperties('server') + class ServerConfig { + @ConfigProperty('connection') + connectionConfig!: ConnectionConfig; + } + + (manager as any).config = { + server: { + connection: { + host: 'api.example.com', + ssl: { + rejectUnauthorized: true, + }, + }, + }, + }; + + const config = manager.bind(ServerConfig); + + expect(config.connectionConfig).toBeInstanceOf(ConnectionConfig); + expect(config.connectionConfig.host).toBe('api.example.com'); + expect(config.connectionConfig.port).toBe(443); // Default + expect(config.connectionConfig.sslConfig).toBeInstanceOf(SslConfig); + expect(config.connectionConfig.sslConfig.enabled).toBe(true); // Default + expect(config.connectionConfig.sslConfig.rejectUnauthorized).toBe(true); + }); + + it('should bind 3+ level nested classes', () => { + class LogFormatConfig { + @DefaultValue('json') + format!: string; + + @DefaultValue(true) + prettyPrint!: boolean; + } + + class LogLevelConfig { + @DefaultValue('info') + default!: string; + + @ConfigProperty('format') + formatConfig!: LogFormatConfig; + } + + class LoggingConfig { + @DefaultValue(true) + enabled!: boolean; + + @ConfigProperty('level') + levelConfig!: LogLevelConfig; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty('logging') + loggingConfig!: LoggingConfig; + } + + (manager as any).config = { + app: { + logging: { + level: { + default: 'debug', + format: { + format: 'text', + }, + }, + }, + }, + }; + + const config = manager.bind(AppConfig); + + expect(config.loggingConfig).toBeInstanceOf(LoggingConfig); + expect(config.loggingConfig.enabled).toBe(true); // Default + expect(config.loggingConfig.levelConfig).toBeInstanceOf(LogLevelConfig); + expect(config.loggingConfig.levelConfig.default).toBe('debug'); + expect(config.loggingConfig.levelConfig.formatConfig).toBeInstanceOf(LogFormatConfig); + expect(config.loggingConfig.levelConfig.formatConfig.format).toBe('text'); + expect(config.loggingConfig.levelConfig.formatConfig.prettyPrint).toBe(true); // Default + }); + + it('should apply decorators at all levels of nesting', () => { + @Validate() + class HealthCheckConfig { + @Required() + @IsNumber() + @Min(1000) + interval!: number; + + @DefaultValue(3) + @IsNumber() + @Max(10) + retries!: number; + } + + class MonitoringConfig { + @Required() + enabled!: boolean; + + @DefaultValue('/health') + endpoint!: string; + + @ConfigProperty('healthCheck') + healthCheckConfig!: HealthCheckConfig; + } + + @ConfigurationProperties('service') + class ServiceConfig { + @Required() + name!: string; + + @ConfigProperty('monitoring') + monitoringConfig!: MonitoringConfig; + } + + (manager as any).config = { + service: { + name: 'my-service', + monitoring: { + enabled: true, + healthCheck: { + interval: 5000, + }, + }, + }, + }; + + const config = manager.bind(ServiceConfig); + + expect(config).toBeInstanceOf(ServiceConfig); + expect(config.name).toBe('my-service'); + expect(config.monitoringConfig).toBeInstanceOf(MonitoringConfig); + expect(config.monitoringConfig.enabled).toBe(true); + expect(config.monitoringConfig.endpoint).toBe('/health'); // Default + expect(config.monitoringConfig.healthCheckConfig).toBeInstanceOf(HealthCheckConfig); + expect(config.monitoringConfig.healthCheckConfig.interval).toBe(5000); + expect(config.monitoringConfig.healthCheckConfig.retries).toBe(3); // Default + }); + }); + + describe('Error scenarios', () => { + it('should throw descriptive error for missing required property in nested class', () => { + class ApiConfig { + @Required() + endpoint!: string; + + @Required() + apiKey!: string; + } + + @ConfigurationProperties('service') + class ServiceConfig { + @ConfigProperty('api') + apiConfig!: ApiConfig; + } + + (manager as any).config = { + service: { + api: { + endpoint: 'https://api.example.com', + // Missing apiKey + }, + }, + }; + + expect(() => { + manager.bind(ServiceConfig); + }).toThrow("Required configuration property 'apiConfig.apiKey' is missing"); + }); + + it('should throw descriptive error for missing required property in deeply nested class', () => { + class CredentialsConfig { + @Required() + username!: string; + + @Required() + password!: string; + } + + class AuthConfig { + @ConfigProperty('credentials') + credentialsConfig!: CredentialsConfig; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty('auth') + authConfig!: AuthConfig; + } + + (manager as any).config = { + app: { + auth: { + credentials: { + username: 'admin', + // Missing password + }, + }, + }, + }; + + expect(() => { + manager.bind(AppConfig); + }).toThrow("Required configuration property 'credentialsConfig.password' is missing"); + }); + + it('should throw descriptive validation error for nested class', () => { + @Validate() + class PortConfig { + @IsNumber() + @Min(1) + @Max(65535) + port!: number; + } + + @ConfigurationProperties('server') + class ServerConfig { + @ConfigProperty('portConfig') + portConfig!: PortConfig; + } + + (manager as any).config = { + server: { + portConfig: { + port: 70000, // Invalid: > 65535 + }, + }, + }; + + expect(() => { + manager.bind(ServerConfig); + }).toThrow(/Validation failed for PortConfig at path 'portConfig'/); + expect(() => { + manager.bind(ServerConfig); + }).toThrow(/port must not be greater than 65535/); + }); + + it('should throw descriptive validation error for deeply nested class', () => { + @Validate() + class RangeConfig { + @IsNumber() + @Min(0) + min!: number; + + @IsNumber() + @Max(100) + max!: number; + } + + class LimitsConfig { + @ConfigProperty('range') + rangeConfig!: RangeConfig; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty('limits') + limitsConfig!: LimitsConfig; + } + + (manager as any).config = { + app: { + limits: { + range: { + min: 0, + max: 150, // Invalid: > 100 + }, + }, + }, + }; + + expect(() => { + manager.bind(AppConfig); + }).toThrow(/Validation failed for RangeConfig at path 'rangeConfig'/); + }); + + it('should handle invalid nested class types gracefully', () => { + class NestedConfig { + @DefaultValue(10) + value!: number; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty('nested') + nestedConfig!: NestedConfig; + } + + // Provide a non-object value where an object is expected + (manager as any).config = { + app: { + nested: 'invalid-string', + }, + }; + + const config = manager.bind(AppConfig); + + // Should return the value as-is when it's not an object + expect(config.nestedConfig).toBe('invalid-string'); + }); + }); + + describe('Edge cases', () => { + it('should bind nested class with no decorators', () => { + class PlainNestedConfig { + value!: string; + count!: number; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty('plain') + plainConfig!: PlainNestedConfig; + } + + (manager as any).config = { + app: { + plain: { + value: 'test', + count: 42, + }, + }, + }; + + const config = manager.bind(AppConfig); + + // Without decorators, it should not be treated as a configuration class + // and should be assigned as a plain object + expect(typeof config.plainConfig).toBe('object'); + expect(config.plainConfig.value).toBe('test'); + expect(config.plainConfig.count).toBe(42); + }); + + it('should bind nested class with only some properties decorated', () => { + class PartiallyDecoratedConfig { + @DefaultValue('default-value') + decoratedProp!: string; + + undecoratedProp!: string; + + @Required() + requiredProp!: number; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty('partial') + partialConfig!: PartiallyDecoratedConfig; + } + + (manager as any).config = { + app: { + partial: { + undecoratedProp: 'undecorated', + requiredProp: 123, + }, + }, + }; + + const config = manager.bind(AppConfig); + + expect(config.partialConfig).toBeInstanceOf(PartiallyDecoratedConfig); + expect(config.partialConfig.decoratedProp).toBe('default-value'); // Default + expect(config.partialConfig.undecoratedProp).toBe('undecorated'); + expect(config.partialConfig.requiredProp).toBe(123); + }); + + it('should handle nested class with @ConfigProperty custom paths', () => { + class CustomPathConfig { + @ConfigProperty('custom_timeout') + @DefaultValue(5000) + timeout!: number; + + @ConfigProperty('custom_retries') + @Required() + retries!: number; + } + + @ConfigurationProperties('service') + class ServiceConfig { + @ConfigProperty('config') + customConfig!: CustomPathConfig; + } + + (manager as any).config = { + service: { + config: { + custom_timeout: 10000, + custom_retries: 3, + }, + }, + }; + + const config = manager.bind(ServiceConfig); + + expect(config.customConfig).toBeInstanceOf(CustomPathConfig); + expect(config.customConfig.timeout).toBe(10000); + expect(config.customConfig.retries).toBe(3); + }); + + it('should create instance with defaults when nested property is null and has @DefaultValue', () => { + class NestedConfig { + @DefaultValue(10) + value!: number; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty('nested') + nestedConfig!: NestedConfig; + } + + (manager as any).config = { + app: { + nested: null, + }, + }; + + const config = manager.bind(AppConfig); + + // With @DefaultValue, null/undefined should create an instance with defaults + expect(config.nestedConfig).toBeInstanceOf(NestedConfig); + expect(config.nestedConfig.value).toBe(10); + }); + + it('should create instance with defaults when nested property is undefined and has @DefaultValue', () => { + class NestedConfig { + @DefaultValue(10) + value!: number; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty('nested') + nestedConfig!: NestedConfig; + } + + (manager as any).config = { + app: { + // nested is undefined + }, + }; + + const config = manager.bind(AppConfig); + + // With @DefaultValue, null/undefined should create an instance with defaults + expect(config.nestedConfig).toBeInstanceOf(NestedConfig); + expect(config.nestedConfig.value).toBe(10); + }); + + it('should create instance when nested class has defaults even if parent has @DefaultValue(undefined)', () => { + class NestedConfig { + @DefaultValue(100) + value!: number; + } + + @ConfigurationProperties('app') + class AppConfig { + @DefaultValue(undefined) + @ConfigProperty('nested') + nestedConfig!: NestedConfig; + } + + (manager as any).config = { + app: { + // nested is missing + }, + }; + + const config = manager.bind(AppConfig); + + // When nested class has @DefaultValue, it should create an instance + // even if parent has @DefaultValue(undefined) + expect(config.nestedConfig).toBeInstanceOf(NestedConfig); + expect(config.nestedConfig.value).toBe(100); + }); + }); +}); diff --git a/packages/core/test/nested-class-integration.spec.ts b/packages/core/test/nested-class-integration.spec.ts new file mode 100644 index 0000000..ccc2409 --- /dev/null +++ b/packages/core/test/nested-class-integration.spec.ts @@ -0,0 +1,228 @@ +import 'reflect-metadata'; +import { ConfigManager } from '../src/config-manager'; +import { + ConfigurationProperties, + ConfigProperty, + DefaultValue, + Required, + Validate, +} from '../src/decorators'; +import { IsNumber, Min, Max, IsString } from 'class-validator'; + +describe('ConfigManager - Nested Class Integration', () => { + let manager: ConfigManager; + + beforeEach(() => { + // Create a manager with in-memory config + manager = new ConfigManager({ + validateOnBind: true, + }); + }); + + afterEach(async () => { + await manager.dispose(); + }); + + it('should bind nested configuration classes through bind() method', async () => { + // Define nested class + class CircuitBreakerOptions { + @DefaultValue(10000) + timeout!: number; + + @DefaultValue(50) + errorThresholdPercentage!: number; + + @Required() + volumeThreshold!: number; + } + + // Define parent class + @ConfigurationProperties('clients.sample') + class SampleClientConfig { + @Required() + @ConfigProperty('baseUrl') + baseURL!: string; + + @ConfigProperty('circuitBreaker') + circuitBreaker!: CircuitBreakerOptions; + } + + // Set up config manually + (manager as any).config = { + clients: { + sample: { + baseUrl: 'https://api.example.com', + circuitBreaker: { + volumeThreshold: 10, + timeout: 5000, + }, + }, + }, + }; + + const config = manager.bind(SampleClientConfig); + + expect(config).toBeInstanceOf(SampleClientConfig); + expect(config.baseURL).toBe('https://api.example.com'); + expect(config.circuitBreaker).toBeInstanceOf(CircuitBreakerOptions); + expect(config.circuitBreaker.timeout).toBe(5000); + expect(config.circuitBreaker.errorThresholdPercentage).toBe(50); // Default + expect(config.circuitBreaker.volumeThreshold).toBe(10); + }); + + it('should handle multi-level nested classes', async () => { + class PoolConfig { + @DefaultValue(10) + maxConnections!: number; + + @DefaultValue(1) + minConnections!: number; + } + + class DatabaseConfig { + @Required() + host!: string; + + @DefaultValue(5432) + port!: number; + + @ConfigProperty('pool') + pool!: PoolConfig; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty('database') + database!: DatabaseConfig; + } + + (manager as any).config = { + app: { + database: { + host: 'localhost', + pool: { + maxConnections: 20, + }, + }, + }, + }; + + const config = manager.bind(AppConfig); + + expect(config).toBeInstanceOf(AppConfig); + expect(config.database).toBeInstanceOf(DatabaseConfig); + expect(config.database.host).toBe('localhost'); + expect(config.database.port).toBe(5432); // Default + expect(config.database.pool).toBeInstanceOf(PoolConfig); + expect(config.database.pool.maxConnections).toBe(20); + expect(config.database.pool.minConnections).toBe(1); // Default + }); + + it('should bind nested classes with @DefaultValue decorator', async () => { + // Note: Nested classes need at least one of our decorators (@DefaultValue, @Required, @Validate, @ConfigProperty) + // to be detected as configuration classes. For validation tests, see nested-class-validation.spec.ts + class CircuitBreakerOptions { + @DefaultValue(10000) + timeout!: number; + + @DefaultValue(50) + errorThresholdPercentage!: number; + } + + @ConfigurationProperties('clients.sample') + class SampleClientConfig { + @Required() + @ConfigProperty('baseUrl') + baseURL!: string; + + @ConfigProperty('circuitBreaker') + circuitBreaker!: CircuitBreakerOptions; + } + + (manager as any).config = { + clients: { + sample: { + baseUrl: 'https://api.example.com', + circuitBreaker: { + timeout: 5000, + // errorThresholdPercentage missing - should use default + }, + }, + }, + }; + + const config = manager.bind(SampleClientConfig); + + expect(config).toBeInstanceOf(SampleClientConfig); + expect(config.circuitBreaker).toBeInstanceOf(CircuitBreakerOptions); + expect(config.circuitBreaker.timeout).toBe(5000); + expect(config.circuitBreaker.errorThresholdPercentage).toBe(50); // Default applied + }); + + it('should throw error for missing required property in nested class', async () => { + class CircuitBreakerOptions { + @Required() + volumeThreshold!: number; + } + + @ConfigurationProperties('clients.sample') + class SampleClientConfig { + @Required() + @ConfigProperty('baseUrl') + baseURL!: string; + + @ConfigProperty('circuitBreaker') + circuitBreaker!: CircuitBreakerOptions; + } + + (manager as any).config = { + clients: { + sample: { + baseUrl: 'https://api.example.com', + circuitBreaker: { + // Missing volumeThreshold + }, + }, + }, + }; + + expect(() => { + manager.bind(SampleClientConfig); + }).toThrow("Required configuration property 'circuitBreaker.volumeThreshold' is missing"); + }); + + it('should work without @ConfigProperty when property names match', async () => { + class CircuitBreakerOptions { + @DefaultValue(10000) + timeout!: number; + } + + @ConfigurationProperties('clients.sample') + class SampleClientConfig { + @Required() + baseUrl!: string; // No @ConfigProperty, uses property name + + // Note: At least one decorator is needed for TypeScript to emit type metadata + @DefaultValue(undefined) + circuitBreaker!: CircuitBreakerOptions; // No @ConfigProperty, uses property name + } + + (manager as any).config = { + clients: { + sample: { + baseUrl: 'https://api.example.com', + circuitBreaker: { + timeout: 5000, + }, + }, + }, + }; + + const config = manager.bind(SampleClientConfig); + + expect(config).toBeInstanceOf(SampleClientConfig); + expect(config.baseUrl).toBe('https://api.example.com'); + expect(config.circuitBreaker).toBeInstanceOf(CircuitBreakerOptions); + expect(config.circuitBreaker.timeout).toBe(5000); + }); +}); diff --git a/packages/core/test/nested-class-validation.spec.ts b/packages/core/test/nested-class-validation.spec.ts new file mode 100644 index 0000000..92a3760 --- /dev/null +++ b/packages/core/test/nested-class-validation.spec.ts @@ -0,0 +1,564 @@ +import 'reflect-metadata'; +import { ConfigManager } from '../src/config-manager'; +import { + ConfigurationProperties, + ConfigProperty, + DefaultValue, + Required, + Validate, +} from '../src/decorators'; +import { IsString, IsNumber, Min, Max, IsUrl, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +describe('ConfigManager - Nested Class Validation', () => { + let manager: ConfigManager; + + beforeEach(async () => { + manager = new ConfigManager({ + configDir: './test-config', + validateOnBind: true, + }); + await manager.initialize(); + }); + + afterEach(async () => { + await manager.dispose(); + }); + + describe('@Validate() with @ValidateNested() and @Type()', () => { + it('should validate nested classes with @ValidateNested() and @Type() decorators', async () => { + // Arrange + @Validate() + class DatabaseConfig { + @IsString() + @Required() + host!: string; + + @IsNumber() + @Min(1) + @Max(65535) + @DefaultValue(5432) + port!: number; + } + + @ConfigurationProperties('app') + @Validate() + class AppConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => DatabaseConfig) + database!: DatabaseConfig; + } + + // Mock config data + (manager as any).config = { + app: { + database: { + host: 'localhost', + port: 5432, + }, + }, + }; + + // Act + const config = manager.bind(AppConfig); + + // Assert + expect(config.database).toBeInstanceOf(DatabaseConfig); + expect(config.database.host).toBe('localhost'); + expect(config.database.port).toBe(5432); + }); + + it('should apply @DefaultValue in nested classes when value is missing', async () => { + // Arrange + @Validate() + class ApiConfig { + @IsUrl() + @DefaultValue('https://api.example.com') + endpoint!: string; + + @IsNumber() + @Min(1000) + @Max(30000) + @DefaultValue(5000) + timeout!: number; + } + + @ConfigurationProperties('services') + @Validate() + class ServicesConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => ApiConfig) + api!: ApiConfig; + } + + // Mock config data - missing endpoint + (manager as any).config = { + services: { + api: { + timeout: 10000, + }, + }, + }; + + // Act + const config = manager.bind(ServicesConfig); + + // Assert + expect(config.api.endpoint).toBe('https://api.example.com'); // Default applied + expect(config.api.timeout).toBe(10000); // Provided value used + }); + + it('should validate nested classes and throw error for invalid data', async () => { + // Arrange + @Validate() + class PortConfig { + @IsNumber() + @Min(1) + @Max(65535) + port!: number; + } + + @ConfigurationProperties('server') + @Validate() + class ServerConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => PortConfig) + config!: PortConfig; + } + + // Mock config data with invalid port + (manager as any).config = { + server: { + config: { + port: 99999, // Invalid - exceeds max + }, + }, + }; + + // Act & Assert + expect(() => manager.bind(ServerConfig)).toThrow(/Validation failed/); + }); + + it('should validate multi-level nested classes', async () => { + // Arrange + @Validate() + class SslConfig { + @DefaultValue(false) + enabled!: boolean; + + @IsString() + @DefaultValue('./certs/cert.pem') + certPath!: string; + } + + @Validate() + class ServerConfig { + @IsString() + @Required() + host!: string; + + @IsNumber() + @Min(1) + @Max(65535) + @Required() + port!: number; + + @ConfigProperty() + @ValidateNested() + @Type(() => SslConfig) + ssl!: SslConfig; + } + + @ConfigurationProperties('app') + @Validate() + class AppConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => ServerConfig) + server!: ServerConfig; + } + + // Mock config data + (manager as any).config = { + app: { + server: { + host: 'localhost', + port: 3000, + ssl: { + enabled: true, + certPath: '/etc/ssl/cert.pem', + }, + }, + }, + }; + + // Act + const config = manager.bind(AppConfig); + + // Assert + expect(config.server).toBeInstanceOf(ServerConfig); + expect(config.server.ssl).toBeInstanceOf(SslConfig); + expect(config.server.host).toBe('localhost'); + expect(config.server.port).toBe(3000); + expect(config.server.ssl.enabled).toBe(true); + expect(config.server.ssl.certPath).toBe('/etc/ssl/cert.pem'); + }); + + it('should apply defaults at all nesting levels', async () => { + // Arrange + @Validate() + class PoolConfig { + @IsNumber() + @Min(1) + @DefaultValue(10) + maxConnections!: number; + + @IsNumber() + @Min(1) + @DefaultValue(1) + minConnections!: number; + } + + @Validate() + class DatabaseConfig { + @IsString() + @Required() + host!: string; + + @IsNumber() + @DefaultValue(5432) + port!: number; + + @ConfigProperty() + @ValidateNested() + @Type(() => PoolConfig) + pool!: PoolConfig; + } + + @ConfigurationProperties('app') + @Validate() + class AppConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => DatabaseConfig) + database!: DatabaseConfig; + } + + // Mock config data - missing pool config entirely + (manager as any).config = { + app: { + database: { + host: 'localhost', + // port missing - should use default + pool: { + // maxConnections missing - should use default + minConnections: 2, + }, + }, + }, + }; + + // Act + const config = manager.bind(AppConfig); + + // Assert + expect(config.database.port).toBe(5432); // Default at level 1 + expect(config.database.pool.maxConnections).toBe(10); // Default at level 2 + expect(config.database.pool.minConnections).toBe(2); // Provided value + }); + + it('should validate @Required properties in nested classes', async () => { + // Arrange + @Validate() + class DatabaseConfig { + @IsString() + @Required() + host!: string; + + @IsString() + @Required() + username!: string; + + @IsString() + @Required() + password!: string; + } + + @ConfigurationProperties('app') + @Validate() + class AppConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => DatabaseConfig) + database!: DatabaseConfig; + } + + // Mock config data - missing required password + (manager as any).config = { + app: { + database: { + host: 'localhost', + username: 'admin', + // password missing + }, + }, + }; + + // Act & Assert + expect(() => manager.bind(AppConfig)).toThrow(/Required configuration property.*password.*is missing/); + }); + + it('should validate multiple nested classes at same level', async () => { + // Arrange + @Validate() + class ApiConfig { + @IsUrl() + @DefaultValue('https://api.example.com') + endpoint!: string; + } + + @Validate() + class CacheConfig { + @IsString() + @Required() + host!: string; + + @IsNumber() + @DefaultValue(6379) + port!: number; + } + + @Validate() + class ServicesConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => ApiConfig) + api!: ApiConfig; + + @ConfigProperty() + @ValidateNested() + @Type(() => CacheConfig) + cache!: CacheConfig; + } + + @ConfigurationProperties('app') + @Validate() + class AppConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => ServicesConfig) + services!: ServicesConfig; + } + + // Mock config data + (manager as any).config = { + app: { + services: { + api: { + // endpoint missing - should use default + }, + cache: { + host: 'redis.local', + // port missing - should use default + }, + }, + }, + }; + + // Act + const config = manager.bind(AppConfig); + + // Assert + expect(config.services.api.endpoint).toBe('https://api.example.com'); + expect(config.services.cache.host).toBe('redis.local'); + expect(config.services.cache.port).toBe(6379); + }); + + it('should throw error when @ValidateNested() is missing on nested class property', async () => { + // Arrange + @Validate() + class DatabaseConfig { + @IsString() + host!: string; + } + + @ConfigurationProperties('app') + @Validate() + class AppConfig { + @ConfigProperty() + // Missing @ValidateNested() and @Type() + database!: DatabaseConfig; + } + + // Mock config data + (manager as any).config = { + app: { + database: { + host: 'localhost', + }, + }, + }; + + // Act & Assert + expect(() => manager.bind(AppConfig)).toThrow(/an unknown value was passed to the validate function/); + }); + + it('should skip validation when validateOnBind is false', async () => { + // Arrange + const managerNoValidation = new ConfigManager({ + configDir: './test-config', + validateOnBind: false, + }); + await managerNoValidation.initialize(); + + @Validate() + class DatabaseConfig { + @IsNumber() + @Min(1) + @Max(65535) + port!: number; + } + + @ConfigurationProperties('app') + @Validate() + class AppConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => DatabaseConfig) + database!: DatabaseConfig; + } + + // Mock config data with invalid port + (managerNoValidation as any).config = { + app: { + database: { + port: 99999, // Invalid but should not throw + }, + }, + }; + + // Act + const config = managerNoValidation.bind(AppConfig); + + // Assert - should not throw, validation is disabled + expect(config.database.port).toBe(99999); + + await managerNoValidation.dispose(); + }); + + it('should validate nested classes with @ConfigProperty when names match', async () => { + // Arrange + @Validate() + class LoggingConfig { + @IsString() + @DefaultValue('info') + level!: string; + } + + @ConfigurationProperties('app') + @Validate() + class AppConfig { + // @ConfigProperty with matching name + @ConfigProperty() + @ValidateNested() + @Type(() => LoggingConfig) + logging!: LoggingConfig; + } + + // Mock config data + (manager as any).config = { + app: { + logging: { + level: 'debug', + }, + }, + }; + + // Act + const config = manager.bind(AppConfig); + + // Assert + expect(config.logging).toBeInstanceOf(LoggingConfig); + expect(config.logging.level).toBe('debug'); + }); + }); + + describe('Validation error messages', () => { + it('should provide clear error messages for nested validation failures', async () => { + // Arrange + @Validate() + class PortConfig { + @IsNumber() + @Min(1) + @Max(65535) + port!: number; + } + + @ConfigurationProperties('server') + @Validate() + class ServerConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => PortConfig) + config!: PortConfig; + } + + // Mock config data with invalid port + (manager as any).config = { + server: { + config: { + port: 'invalid', // String instead of number + }, + }, + }; + + // Act & Assert + try { + manager.bind(ServerConfig); + fail('Should have thrown validation error'); + } catch (error: any) { + expect(error.message).toContain('Validation failed'); + expect(error.message).toContain('port'); + } + }); + + it('should provide clear error messages for missing required nested properties', async () => { + // Arrange + @Validate() + class DatabaseConfig { + @IsString() + @Required() + host!: string; + } + + @ConfigurationProperties('app') + @Validate() + class AppConfig { + @ConfigProperty() + @ValidateNested() + @Type(() => DatabaseConfig) + database!: DatabaseConfig; + } + + // Mock config data - missing required host + (manager as any).config = { + app: { + database: {}, + }, + }; + + // Act & Assert + try { + manager.bind(AppConfig); + fail('Should have thrown validation error'); + } catch (error: any) { + expect(error.message).toContain('Required configuration property'); + expect(error.message).toContain('database.host'); + expect(error.message).toContain('is missing'); + } + }); + }); +}); diff --git a/packages/core/test/placeholder-integration.spec.ts b/packages/core/test/placeholder-integration.spec.ts new file mode 100644 index 0000000..b249c6b --- /dev/null +++ b/packages/core/test/placeholder-integration.spec.ts @@ -0,0 +1,210 @@ +import 'reflect-metadata'; +import { ConfigManager, InMemoryConfigSource } from '../src'; + +describe('PlaceholderResolver Integration', () => { + let originalEnv: NodeJS.ProcessEnv; + const createdManagers: ConfigManager[] = []; + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(async () => { + process.env = originalEnv; + for (const manager of createdManagers) { + await manager.dispose(); + } + createdManagers.length = 0; + }); + + describe('basic placeholder resolution', () => { + it('should resolve placeholder with environment variable', async () => { + process.env.DB_HOST = 'prod-server'; + + const config = { database: { host: '${DB_HOST}' } }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('database.host')).toBe('prod-server'); + }); + + it('should use fallback when environment variable is not set', async () => { + const config = { database: { host: '${DB_HOST:localhost}' } }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('database.host')).toBe('localhost'); + }); + + it('should use environment variable over fallback', async () => { + process.env.DB_HOST = 'prod-server'; + + const config = { database: { host: '${DB_HOST:localhost}' } }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('database.host')).toBe('prod-server'); + }); + + it('should return undefined when placeholder has no fallback and env var not set', async () => { + const config = { database: { host: '${DB_HOST}' } }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('database.host')).toBeUndefined(); + }); + }); + + describe('multiple placeholders', () => { + it('should resolve multiple placeholders in a single value', async () => { + process.env.DB_USER = 'admin'; + process.env.DB_NAME = 'mydb'; + + const config = { database: { url: 'postgres://${DB_USER}@localhost/${DB_NAME}' } }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('database.url')).toBe('postgres://admin@localhost/mydb'); + }); + + it('should resolve multiple placeholders with fallbacks', async () => { + process.env.DB_USER = 'admin'; + + const config = { + database: { url: 'postgres://${DB_USER}@${DB_HOST:localhost}/${DB_NAME:testdb}' }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('database.url')).toBe('postgres://admin@localhost/testdb'); + }); + }); + + describe('placeholder escaping', () => { + it('should escape placeholder syntax with backslash', async () => { + const config = { message: '\\${USER} logged in' }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('message')).toBe('${USER} logged in'); + }); + }); + + describe('disabling placeholder resolution', () => { + it('should not resolve placeholders when disabled', async () => { + process.env.DB_HOST = 'prod-server'; + + const config = { database: { host: '${DB_HOST:localhost}' } }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + enablePlaceholderResolution: false, + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('database.host')).toBe('${DB_HOST:localhost}'); + }); + }); + + describe('precedence with underscore-based ENV resolution', () => { + it('should allow underscore-based ENV to override file values', async () => { + process.env.DATABASE_HOST = 'env-server'; + + const config = { database: { host: 'file-server' } }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // EnvConfigSource has priority 200, InMemoryConfigSource has priority 100 + // So underscore-based ENV should override + expect(manager.get('database.host')).toBe('env-server'); + }); + + it('should resolve placeholders in values set by underscore-based ENV', async () => { + process.env.DATABASE_URL = 'postgres://${DB_USER:admin}@localhost/mydb'; + process.env.DB_USER = 'root'; + + const config = {}; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + // Underscore-based ENV sets database.url to '${DB_USER:admin}@localhost/mydb' + // Then placeholder resolution resolves ${DB_USER:admin} to 'root' + expect(manager.get('database.url')).toBe('postgres://root@localhost/mydb'); + }); + }); + + describe('nested objects', () => { + it('should resolve placeholders in nested objects', async () => { + process.env.DB_HOST = 'prod-server'; + process.env.DB_PORT = '5432'; + + const config = { + databases: { + primary: { + host: '${DB_HOST}', + port: '${DB_PORT:3306}', + }, + secondary: { + host: '${DB_HOST_2:localhost}', + port: '${DB_PORT}', + }, + }, + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('databases.primary.host')).toBe('prod-server'); + expect(manager.get('databases.primary.port')).toBe('5432'); + expect(manager.get('databases.secondary.host')).toBe('localhost'); + expect(manager.get('databases.secondary.port')).toBe('5432'); + }); + }); + + describe('arrays', () => { + it('should resolve placeholders in arrays', async () => { + process.env.HOST1 = 'server1'; + process.env.HOST2 = 'server2'; + + const config = { + servers: ['${HOST1}', '${HOST2}', '${HOST3:server3}'], + }; + const manager = new ConfigManager({ + additionalSources: [new InMemoryConfigSource(config, 100)], + }); + createdManagers.push(manager); + await manager.initialize(); + + expect(manager.get('servers')).toEqual(['server1', 'server2', 'server3']); + }); + }); +}); diff --git a/packages/core/test/placeholder-resolver.spec.ts b/packages/core/test/placeholder-resolver.spec.ts new file mode 100644 index 0000000..5eca8fc --- /dev/null +++ b/packages/core/test/placeholder-resolver.spec.ts @@ -0,0 +1,216 @@ +import { PlaceholderResolver } from '../src'; + +describe('PlaceholderResolver', () => { + let resolver: PlaceholderResolver; + + beforeEach(() => { + resolver = new PlaceholderResolver(); + }); + + describe('hasPlaceholder', () => { + it('should detect placeholder syntax', () => { + expect(resolver.hasPlaceholder('${VAR}')).toBe(true); + expect(resolver.hasPlaceholder('${VAR:fallback}')).toBe(true); + expect(resolver.hasPlaceholder('prefix ${VAR} suffix')).toBe(true); + }); + + it('should return false for strings without placeholders', () => { + expect(resolver.hasPlaceholder('plain text')).toBe(false); + expect(resolver.hasPlaceholder('$VAR')).toBe(false); + expect(resolver.hasPlaceholder('{VAR}')).toBe(false); + }); + + it('should return false for non-string values', () => { + expect(resolver.hasPlaceholder(123 as any)).toBe(false); + expect(resolver.hasPlaceholder(null as any)).toBe(false); + expect(resolver.hasPlaceholder(undefined as any)).toBe(false); + }); + }); + + describe('resolve', () => { + it('should resolve basic placeholder with environment variable', () => { + const envProvider = (key: string) => (key === 'VAR' ? 'value' : undefined); + const result = resolver.resolve('${VAR}', envProvider); + expect(result).toBe('value'); + }); + + it('should use fallback when environment variable is not set', () => { + const envProvider = () => undefined; + const result = resolver.resolve('${VAR:fallback}', envProvider); + expect(result).toBe('fallback'); + }); + + it('should use environment variable over fallback when set', () => { + const envProvider = (key: string) => (key === 'VAR' ? 'env_value' : undefined); + const result = resolver.resolve('${VAR:fallback}', envProvider); + expect(result).toBe('env_value'); + }); + + it('should return undefined when no fallback and variable not set', () => { + const envProvider = () => undefined; + const result = resolver.resolve('${VAR}', envProvider); + expect(result).toBeUndefined(); + }); + + it('should resolve placeholder in partial string', () => { + const envProvider = (key: string) => (key === 'HOST' ? 'localhost' : undefined); + const result = resolver.resolve('http://${HOST}:8080', envProvider); + expect(result).toBe('http://localhost:8080'); + }); + + it('should resolve multiple placeholders', () => { + const envProvider = (key: string) => { + const vars: Record = { HOST: 'localhost', PORT: '8080' }; + return vars[key]; + }; + const result = resolver.resolve('${HOST}:${PORT}', envProvider); + expect(result).toBe('localhost:8080'); + }); + + it('should handle empty fallback value', () => { + const envProvider = () => undefined; + const result = resolver.resolve('${VAR:}', envProvider); + expect(result).toBe(''); + }); + + it('should handle escaped placeholders', () => { + const envProvider = (key: string) => (key === 'VAR' ? 'value' : undefined); + const result = resolver.resolve('\\${VAR}', envProvider); + expect(result).toBe('${VAR}'); + }); + + it('should handle mix of escaped and real placeholders', () => { + const envProvider = (key: string) => (key === 'REAL' ? 'resolved' : undefined); + const result = resolver.resolve('\\${ESCAPED} and ${REAL}', envProvider); + expect(result).toBe('${ESCAPED} and resolved'); + }); + + it('should return original value if not a string', () => { + const result = resolver.resolve(123 as any); + expect(result).toBe(123); + }); + + it('should preserve surrounding text with multiple placeholders', () => { + const envProvider = (key: string) => { + const vars: Record = { USER: 'admin', PASS: 'secret' }; + return vars[key]; + }; + const result = resolver.resolve('user=${USER}&pass=${PASS}', envProvider); + expect(result).toBe('user=admin&pass=secret'); + }); + }); + + describe('resolveObject', () => { + it('should resolve placeholders in nested objects', () => { + const envProvider = (key: string) => { + const vars: Record = { HOST: 'localhost', PORT: '5432' }; + return vars[key]; + }; + + const config = { + database: { + host: '${HOST}', + port: '${PORT}', + }, + }; + + const result = resolver.resolveObject(config, envProvider); + expect(result).toEqual({ + database: { + host: 'localhost', + port: '5432', + }, + }); + }); + + it('should preserve non-string values', () => { + const envProvider = () => undefined; + const config = { + number: 123, + boolean: true, + null: null, + nested: { + value: 456, + }, + }; + + const result = resolver.resolveObject(config, envProvider); + expect(result).toEqual(config); + }); + + it('should handle arrays with placeholders', () => { + const envProvider = (key: string) => (key === 'VAR' ? 'value' : undefined); + const config = { + items: ['${VAR}', 'plain', '${VAR:fallback}'], + }; + + const result = resolver.resolveObject(config, envProvider); + expect(result).toEqual({ + items: ['value', 'plain', 'value'], + }); + }); + + it('should omit properties when placeholder resolution fails', () => { + const envProvider = () => undefined; + const config = { + required: '${MISSING}', + optional: '${MISSING:fallback}', + literal: 'value', + }; + + const result = resolver.resolveObject(config, envProvider); + expect(result).toEqual({ + optional: 'fallback', + literal: 'value', + }); + expect(result).not.toHaveProperty('required'); + }); + + it('should handle deeply nested structures', () => { + const envProvider = (key: string) => (key === 'SECRET' ? 'password' : undefined); + const config = { + level1: { + level2: { + level3: { + password: '${SECRET}', + }, + }, + }, + }; + + const result = resolver.resolveObject(config, envProvider); + expect(result).toEqual({ + level1: { + level2: { + level3: { + password: 'password', + }, + }, + }, + }); + }); + + it('should handle arrays of objects', () => { + const envProvider = (key: string) => (key === 'HOST' ? 'localhost' : undefined); + const config = { + servers: [ + { host: '${HOST}', port: 8080 }, + { host: '${HOST}', port: 8081 }, + ], + }; + + const result = resolver.resolveObject(config, envProvider); + expect(result).toEqual({ + servers: [ + { host: 'localhost', port: 8080 }, + { host: 'localhost', port: 8081 }, + ], + }); + }); + + it('should return original value for non-object input', () => { + const result = resolver.resolveObject(null as any); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/core/test/placeholder-undefined.spec.ts b/packages/core/test/placeholder-undefined.spec.ts new file mode 100644 index 0000000..3bfc071 --- /dev/null +++ b/packages/core/test/placeholder-undefined.spec.ts @@ -0,0 +1,116 @@ +import 'reflect-metadata'; +import { ConfigManager } from '../src/config-manager'; +import { ConfigurationProperties, ConfigProperty, DefaultValue } from '../src/decorators'; + +describe('Placeholder Resolution - Undefined Values', () => { + let manager: ConfigManager; + + beforeEach(async () => { + // Clear any existing env vars + delete process.env.NONEXISTENT_VAR; + + manager = new ConfigManager({ + configDir: './test-config', + enablePlaceholderResolution: true, + }); + await manager.initialize(); + }); + + afterEach(async () => { + await manager.dispose(); + }); + + it('should resolve placeholder without fallback to undefined', async () => { + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty() + value!: string; + } + + // Mock config with placeholder that has no fallback and env var doesn't exist + (manager as any).config = { + app: { + value: undefined, // This is what ${NONEXISTENT_VAR} resolves to + }, + }; + + const config = manager.bind(AppConfig); + + // Should be undefined since the env var doesn't exist and there's no fallback + expect(config.value).toBeUndefined(); + }); + + it('should use @DefaultValue when placeholder resolves to undefined', async () => { + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty() + @DefaultValue('default-value') + value!: string; + } + + // Mock config where placeholder resolved to undefined + (manager as any).config = { + app: { + // value is missing (placeholder resolved to undefined) + }, + }; + + const config = manager.bind(AppConfig); + + // Should use the @DefaultValue since the placeholder resolved to undefined + expect(config.value).toBe('default-value'); + }); + + it('should not create nested class instance when value is explicitly undefined from placeholder', async () => { + class NestedConfig { + @ConfigProperty() + prop!: string; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty() + nested!: NestedConfig; + } + + // Mock config where placeholder resolved to undefined + (manager as any).config = { + app: { + nested: undefined, // Explicitly undefined from placeholder resolution + }, + }; + + const config = manager.bind(AppConfig); + + // Should be undefined, not a NestedConfig instance + // because the nested class has no @DefaultValue or @Validate() + expect(config.nested).toBeUndefined(); + }); + + it('should create nested class instance with defaults when placeholder resolves to undefined', async () => { + class NestedConfig { + @ConfigProperty() + @DefaultValue('default-prop') + prop!: string; + } + + @ConfigurationProperties('app') + class AppConfig { + @ConfigProperty() + nested!: NestedConfig; + } + + // Mock config where placeholder resolved to undefined + (manager as any).config = { + app: { + // nested is missing (placeholder resolved to undefined) + }, + }; + + const config = manager.bind(AppConfig); + + // Should create instance because NestedConfig has @DefaultValue + expect(config.nested).toBeInstanceOf(NestedConfig); + expect(config.nested.prop).toBe('default-prop'); + }); +}); diff --git a/packages/core/test/record-type.spec.ts b/packages/core/test/record-type.spec.ts new file mode 100644 index 0000000..c9ecef7 --- /dev/null +++ b/packages/core/test/record-type.spec.ts @@ -0,0 +1,458 @@ +import 'reflect-metadata'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + ConfigManager, + ConfigProperty, + ConfigurationProperties, + InMemoryConfigSource, + RecordType, + Required, +} from '../src'; +import { IsBoolean, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; + +/** + * Test configuration class for database connections with validation + */ +class DatabaseConnectionValidated { + @IsString() + host!: string; + + @IsNumber() + @Min(1) + @Max(65535) + port!: number; + + @IsString() + username!: string; + + @IsString() + password!: string; + + @IsString() + database!: string; + + @IsString() + schema!: string; + + @IsBoolean() + ssl!: boolean; +} + +/** + * Configuration class using Record type + */ +@ConfigurationProperties('databases') +class DatabasesRecordConfig { + @ConfigProperty('connections') + @Required() + @RecordType() + connections!: Record; +} + +/** + * Configuration class using Map type for comparison + */ +@ConfigurationProperties('databases') +class DatabasesMapConfig { + @ConfigProperty('connections') + @Required() + connections!: Map; +} + +describe('Record Type Support', () => { + let tempDir: string; + const createdManagers: ConfigManager[] = []; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'record-test-')); + }); + + afterEach(async () => { + fs.rmSync(tempDir, { recursive: true, force: true }); + for (const manager of createdManagers) { + await manager.dispose(); + } + createdManagers.length = 0; + }); + + describe('Record type binding', () => { + it('should bind configuration to Record type without converting to Map', async () => { + const config = { + databases: { + connections: { + 'serhafen-us': { + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'secret', + database: 'serhafen_common', + schema: 'us', + ssl: false, + }, + 'serhafen-ag': { + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'secret', + database: 'serhafen_ag', + schema: 'ag', + ssl: true, + }, + }, + }, + }; + + const source = new InMemoryConfigSource(config); + // Disable validation to test binding only + const manager = new ConfigManager({ additionalSources: [source], validateOnBind: false }); + createdManagers.push(manager); + + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesRecordConfig); + + // Should be a plain object, not a Map + expect(boundConfig.connections).not.toBeInstanceOf(Map); + expect(typeof boundConfig.connections).toBe('object'); + + // Should have all entries + expect(Object.keys(boundConfig.connections)).toHaveLength(2); + expect(boundConfig.connections['serhafen-us']).toBeDefined(); + expect(boundConfig.connections['serhafen-ag']).toBeDefined(); + + // Should preserve structure + expect(boundConfig.connections['serhafen-us'].host).toBe('localhost'); + expect(boundConfig.connections['serhafen-us'].port).toBe(5432); + expect(boundConfig.connections['serhafen-ag'].ssl).toBe(true); + }); + + it('should distinguish Record from Map types', async () => { + const config = { + databases: { + connections: { + 'test-db': { + host: 'localhost', + port: 5432, + username: 'user', + password: 'pass', + database: 'testdb', + schema: 'public', + ssl: false, + }, + }, + }, + }; + + const source = new InMemoryConfigSource(config); + // Disable validation to test binding only + const manager = new ConfigManager({ additionalSources: [source], validateOnBind: false }); + createdManagers.push(manager); + + await manager.initialize(); + + const recordConfig = manager.bind(DatabasesRecordConfig); + const mapConfig = manager.bind(DatabasesMapConfig); + + // Record should be plain object + expect(recordConfig.connections).not.toBeInstanceOf(Map); + expect(typeof recordConfig.connections).toBe('object'); + + // Map should be Map instance + expect(mapConfig.connections).toBeInstanceOf(Map); + }); + + it('should support bracket notation access for Record', async () => { + const config = { + databases: { + connections: { + 'my-db': { + host: 'example.com', + port: 3306, + username: 'admin', + password: 'secret', + database: 'mydb', + schema: 'main', + ssl: true, + }, + }, + }, + }; + + const source = new InMemoryConfigSource(config); + // Disable validation to test binding only + const manager = new ConfigManager({ additionalSources: [source], validateOnBind: false }); + createdManagers.push(manager); + + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesRecordConfig); + + // Should support bracket notation + expect(boundConfig.connections['my-db']).toBeDefined(); + expect(boundConfig.connections['my-db'].host).toBe('example.com'); + }); + + it('should work with Object.keys() and Object.entries()', async () => { + const config = { + databases: { + connections: { + db1: { + host: 'host1', + port: 5432, + username: 'user1', + password: 'pass1', + database: 'db1', + schema: 'schema1', + ssl: false, + }, + db2: { + host: 'host2', + port: 5433, + username: 'user2', + password: 'pass2', + database: 'db2', + schema: 'schema2', + ssl: true, + }, + }, + }, + }; + + const source = new InMemoryConfigSource(config); + // Disable validation to test binding only + const manager = new ConfigManager({ additionalSources: [source], validateOnBind: false }); + createdManagers.push(manager); + + await manager.initialize(); + + const boundConfig = manager.bind(DatabasesRecordConfig); + + // Should work with Object.keys() + const keys = Object.keys(boundConfig.connections); + expect(keys).toHaveLength(2); + expect(keys).toContain('db1'); + expect(keys).toContain('db2'); + + // Should work with Object.entries() + const entries = Object.entries(boundConfig.connections); + expect(entries).toHaveLength(2); + expect(entries[0][0]).toBe('db1'); + expect(entries[0][1].host).toBe('host1'); + }); + }); + + describe('Record type validation limitations', () => { + it('should validate @Required properties but not entry contents', async () => { + const config = { + databases: { + // Missing connections property + }, + }; + + const source = new InMemoryConfigSource(config); + const manager = new ConfigManager({ additionalSources: [source], validateOnBind: true }); + createdManagers.push(manager); + + await manager.initialize(); + + // @Required validation works + expect(() => manager.bind(DatabasesRecordConfig)).toThrow( + "Required configuration property 'databases.connections' is missing" + ); + }); + + it('should NOT validate Record entry contents automatically', async () => { + const config = { + databases: { + connections: { + 'invalid-db': { + host: 'localhost', + port: 99999, // Invalid: exceeds max, but won't be caught + username: 'postgres', + password: 'secret', + database: 'testdb', + schema: 'public', + ssl: 'not-a-boolean', // Invalid: wrong type, but won't be caught + }, + }, + }, + }; + + const source = new InMemoryConfigSource(config); + const manager = new ConfigManager({ additionalSources: [source], validateOnBind: false }); + createdManagers.push(manager); + + await manager.initialize(); + + // Invalid data passes through - validation doesn't work for Record entries + const boundConfig = manager.bind(DatabasesRecordConfig); + expect(boundConfig.connections['invalid-db'].port).toBe(99999); + expect(boundConfig.connections['invalid-db'].ssl).toBe('not-a-boolean'); + }); + + it('should work without validation when validateOnBind is false', async () => { + const config = { + databases: { + connections: { + 'db1': { + host: 'localhost', + port: 5432, + username: 'user1', + password: 'pass1', + database: 'db1', + schema: 'schema1', + ssl: true, + }, + 'db2': { + host: 'remotehost', + port: 3306, + username: 'user2', + password: 'pass2', + database: 'db2', + schema: 'schema2', + ssl: false, + }, + }, + }, + }; + + const source = new InMemoryConfigSource(config); + // Disable validation - Record types work best without automatic validation + const manager = new ConfigManager({ additionalSources: [source], validateOnBind: false }); + createdManagers.push(manager); + + await manager.initialize(); + + // Should not throw + const boundConfig = manager.bind(DatabasesRecordConfig); + expect(boundConfig.connections).toBeDefined(); + expect(Object.keys(boundConfig.connections)).toHaveLength(2); + }); + }); + + describe('Record vs Map comparison', () => { + it('should show Record does not have Map methods', async () => { + const config = { + databases: { + connections: { + 'test-db': { + host: 'localhost', + port: 5432, + username: 'user', + password: 'pass', + database: 'testdb', + schema: 'public', + ssl: false, + }, + }, + }, + }; + + const source = new InMemoryConfigSource(config); + // Disable validation to test binding only + const manager = new ConfigManager({ additionalSources: [source], validateOnBind: false }); + createdManagers.push(manager); + + await manager.initialize(); + + const recordConfig = manager.bind(DatabasesRecordConfig); + const mapConfig = manager.bind(DatabasesMapConfig); + + // Record should not have Map methods + expect(typeof (recordConfig.connections as any).get).toBe('undefined'); + expect(typeof (recordConfig.connections as any).set).toBe('undefined'); + expect(typeof (recordConfig.connections as any).has).toBe('undefined'); + expect(typeof (recordConfig.connections as any).delete).toBe('undefined'); + + // Map should have Map methods + expect(typeof mapConfig.connections.get).toBe('function'); + expect(typeof mapConfig.connections.set).toBe('function'); + expect(typeof mapConfig.connections.has).toBe('function'); + expect(typeof mapConfig.connections.delete).toBe('function'); + }); + + it('should bind same YAML config to both Map and Record correctly', async () => { + const yamlContent = ` +databases: + connections: + serhafen-us: + host: localhost + port: 5432 + username: postgres + password: secret + database: serhafen_common + schema: us + ssl: false + serhafen-ag: + host: localhost + port: 5432 + username: postgres + password: secret + database: serhafen_ag + schema: ag + ssl: true +`; + + const configPath = path.join(tempDir, 'application.yml'); + fs.writeFileSync(configPath, yamlContent); + + // Disable validation to test binding only + const manager = new ConfigManager({ configDir: tempDir, validateOnBind: false }); + createdManagers.push(manager); + + await manager.initialize(); + + const recordConfig = manager.bind(DatabasesRecordConfig); + const mapConfig = manager.bind(DatabasesMapConfig); + + // Both should have the same data + expect(Object.keys(recordConfig.connections)).toHaveLength(2); + expect(mapConfig.connections.size).toBe(2); + + // Record uses bracket notation + expect(recordConfig.connections['serhafen-us'].host).toBe('localhost'); + expect(recordConfig.connections['serhafen-ag'].ssl).toBe(true); + + // Map uses .get() + expect(mapConfig.connections.get('serhafen-us')?.host).toBe('localhost'); + expect(mapConfig.connections.get('serhafen-ag')?.ssl).toBe(true); + }); + }); + + describe('MapBinder methods', () => { + it('should correctly identify Record properties', () => { + const instance = new DatabasesRecordConfig(); + const { MapBinder } = require('../src/map-binder'); + const binder = new MapBinder(); + + const isRecord = binder.isRecordProperty(instance, 'connections'); + expect(isRecord).toBe(true); + }); + + it('should correctly identify Map properties', () => { + const instance = new DatabasesMapConfig(); + const { MapBinder } = require('../src/map-binder'); + const binder = new MapBinder(); + + const isMap = binder.isMapProperty(instance, 'connections'); + expect(isMap).toBe(true); + }); + + it('should distinguish between Map and Record properties', () => { + const recordInstance = new DatabasesRecordConfig(); + const mapInstance = new DatabasesMapConfig(); + const { MapBinder } = require('../src/map-binder'); + const binder = new MapBinder(); + + // Record property should not be identified as Map + expect(binder.isMapProperty(recordInstance, 'connections')).toBe(false); + expect(binder.isRecordProperty(recordInstance, 'connections')).toBe(true); + + // Map property should not be identified as Record + expect(binder.isMapProperty(mapInstance, 'connections')).toBe(true); + expect(binder.isRecordProperty(mapInstance, 'connections')).toBe(false); + }); + }); +}); diff --git a/packages/express/README.md b/packages/express/README.md index b456de5..9c126a5 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -12,7 +12,6 @@ - **Type-safe**: Decorator-based config classes with TypeScript - **Profile support**: Spring-style profiles for dev, prod, etc. -- **Hot reload**: Watch and reload config changes instantly - **Multi-source**: Merge YAML, JSON, .env, env vars, remote - **Express middleware**: Config and DI in every request - **Encryption**: Secure secrets with built-in AES-256 @@ -39,6 +38,8 @@ - Type-safe configuration classes - Encrypted values - Validation with class-validator +- Map & Record binding for dynamic key-value structures +- Environment variable placeholders with `${VAR:fallback}` syntax ## Installation @@ -94,11 +95,13 @@ app.listen(serverConfig.port, () => { ### ⚠️ CRITICAL: Configuration File Management -**TypeScript compilation doesn't copy YAML/JSON files**, which means your configuration files will be lost unless properly configured. +**TypeScript compilation doesn't copy YAML/JSON files**, which means your configuration files will be lost unless +properly configured. -πŸ“– **[Read the Complete Configuration File Management Guide](../core/CONFIG_FILES.md)** +πŸ“– **[Read the Complete Configuration File Management Guide](https://github.com/ganesanarun/type-config/blob/main/packages/core/CONFIG_FILES.md)** This comprehensive guide covers: + - Why configuration files disappear during builds - Solutions for Express, NestJS, Fastify, and vanilla Node.js - Configuration directory resolution patterns @@ -165,6 +168,90 @@ database: host: prod-db.example.com ``` +## Advanced Features + +### Environment Variable Placeholders + +Use `${VAR:fallback}` syntax in your YAML/JSON files: + +```yaml +# config/application.yml +server: + host: ${SERVER_HOST:localhost} + port: ${SERVER_PORT:3000} + +database: + host: ${DB_HOST:localhost} + port: ${DB_PORT:5432} + username: ${DB_USER:postgres} + password: ${DB_PASSWORD} # No fallback - required +``` + +See the [core package documentation](../core/README.md#environment-variable-placeholders) for complete details. + +### Map-Based Configuration + +Bind configuration to `Map` for dynamic collections: + +```typescript +class ServiceEndpoint { + url: string; + timeout: number; + retries: number; +} + +@ConfigurationProperties('services') +class ServicesConfig { + @ConfigProperty('endpoints') + endpoints: Map; +} +``` + +```yaml +# config/application.yml +services: + endpoints: + auth: + url: http://localhost:8001 + timeout: 5000 + retries: 3 + payment: + url: http://localhost:8002 + timeout: 10000 + retries: 5 +``` + +**Usage in routes:** + +```typescript +app.get('/api/services/:name', (req, res) => { + const servicesConfig = req.container!.get(ServicesConfig); + const endpoint = servicesConfig.endpoints.get(req.params.name); + + if (!endpoint) { + return res.status(404).json({ error: 'Service not found' }); + } + + res.json(endpoint); +}); +``` + +**Alternative: Record type** for plain object syntax: + +```typescript + +@ConfigurationProperties('services') +class ServicesConfig { + @ConfigProperty('endpoints') + endpoints: Record; +} + +// Access with bracket notation +const auth = servicesConfig.endpoints['auth']; +``` + +See the [core package documentation](../core/README.md#map-based-configuration) for complete details. + ## API - `createTypeConfig(options)` - Create a new Express config instance @@ -180,15 +267,16 @@ database: ## Comparison -| Feature | type-config/express | express-config | dotenv | node-config | -|-----------------|:-------------------:|:--------------:|:------:|:-----------:| -| Type safety | βœ… Decorators, TS | ❌ | ❌ | ❌ | -| Multi-source | βœ… YAML, env, etc. | ⚠️ Partial | ❌ | βœ… | -| Profile support | βœ… Spring-style | ❌ | ❌ | βœ… | -| Hot reload | βœ… Built-in | ❌ | ❌ | ❌ | -| Encryption | βœ… Built-in | ❌ | ❌ | ❌ | -| Validation | βœ… class-validator | ❌ | ❌ | ❌ | -| DI integration | βœ… Per-request | ❌ | ❌ | ❌ | +| Feature | type-config/express | express-config | dotenv | node-config | +|--------------------|:--------------------:|:--------------:|:--------:|:-----------:| +| Type safety | βœ… Decorators, TS | ❌ | ❌ | ❌ | +| Multi-source | βœ… YAML, env, etc. | ⚠️ Partial | ❌ | βœ… | +| Profile support | βœ… Spring-style | ❌ | ❌ | βœ… | +| Encryption | βœ… Built-in | ❌ | ❌ | ❌ | +| Validation | βœ… class-validator | ❌ | ❌ | ❌ | +| DI integration | βœ… Per-request | ❌ | ❌ | ❌ | +| Map/Record binding | βœ… Dynamic structures | ❌ | ❌ | ❌ | +| ENV placeholders | βœ… ${VAR:fallback} | ❌ | ⚠️ Basic | ❌ | ## License diff --git a/packages/express/jest.config.js b/packages/express/jest.config.js new file mode 100644 index 0000000..92fcdd2 --- /dev/null +++ b/packages/express/jest.config.js @@ -0,0 +1,28 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.spec.ts', '**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts', + ], + coverageDirectory: 'coverage', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + target: 'ES2020', + module: 'commonjs', + lib: ['ES2020'], + esModuleInterop: true, + experimentalDecorators: true, + emitDecoratorMetadata: true, + moduleResolution: 'node', + }, + }, + ], + }, +}; diff --git a/packages/express/package.json b/packages/express/package.json index 574f4b5..7cd3a29 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,13 +1,13 @@ { "name": "@snow-tzu/type-config-express", - "version": "0.0.1", + "version": "0.0.5", "description": "Type Config integration for Express.js", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "clean": "rm -rf dist", - "test": "jest --config ../../jest.config.js --passWithNoTests" + "test": "jest --passWithNoTests" }, "keywords": [ "spring-config", diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 69780f2..b45d947 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -9,6 +9,8 @@ export { Validate, Injectable, Inject, + RecordType, ConfigManager, Container, + MapBinder, } from '@snow-tzu/type-config'; diff --git a/packages/fastify/README.md b/packages/fastify/README.md index 24b85fd..acd3bc4 100644 --- a/packages/fastify/README.md +++ b/packages/fastify/README.md @@ -2,9 +2,9 @@ > **Type-safe, multi-source, hot-reloadable configuration for Fastify** -[![npm version](https://img.shields.io/npm/v/@snow-tzu/config-fastify.svg)](https://www.npmjs.com/package/@snow-tzu/config-fastify) -[![license](https://img.shields.io/npm/l/@snow-tzu/config-fastify.svg)](LICENSE) -[![downloads](https://img.shields.io/npm/dm/@snow-tzu/config-fastify.svg)](https://www.npmjs.com/package/@snow-tzu/config-fastify) +[![npm version](https://img.shields.io/npm/v/@snow-tzu/type-config-fastify.svg)](https://www.npmjs.com/package/@snow-tzu/type-config-fastify) +[![license](https://img.shields.io/npm/l/@snow-tzu/type-config-fastify.svg)](LICENSE) +[![downloads](https://img.shields.io/npm/dm/@snow-tzu/type-config-fastify.svg)](https://www.npmjs.com/package/@snow-tzu/type-config-fastify) --- @@ -12,7 +12,6 @@ - **Type-safe**: Decorator-based config classes with TypeScript - **Profile support**: Spring-style profiles for dev, prod, etc. -- **Hot reload**: Watch and reload config changes instantly - **Multi-source**: Merge YAML, JSON, .env, env vars, remote - **Fastify plugin**: Config and DI in every request - **Encryption**: Secure secrets with built-in AES-256 @@ -39,6 +38,8 @@ - Type-safe configuration classes - Encrypted values - Validation with class-validator +- Map & Record binding for dynamic key-value structures +- Environment variable placeholders with `${VAR:fallback}` syntax ## Installation @@ -93,11 +94,15 @@ await fastify.listen({ port: serverConfig.port, host: serverConfig.host }); ### ⚠️ CRITICAL: Configuration File Management -**TypeScript compilation doesn't copy YAML/JSON files**, which means your configuration files will be lost unless properly configured. +**TypeScript compilation doesn't copy YAML/JSON files**, which means your configuration files will be lost unless +properly configured. -πŸ“– **[Read the Complete Configuration File Management Guide](../core/CONFIG_FILES.md)** +πŸ“– * +*[Read the Complete Configuration File Management Guide](https://github.com/ganesanarun/type-config/blob/main/packages/core/CONFIG_FILES.md) +** This comprehensive guide covers: + - Why configuration files disappear during builds - Solutions for Fastify, Express, NestJS, and vanilla Node.js - Configuration directory resolution patterns @@ -164,6 +169,90 @@ database: host: prod-db.example.com ``` +## Advanced Features + +### Environment Variable Placeholders + +Use `${VAR:fallback}` syntax in your YAML/JSON files: + +```yaml +# config/application.yml +server: + host: ${SERVER_HOST:localhost} + port: ${SERVER_PORT:3000} + +database: + host: ${DB_HOST:localhost} + port: ${DB_PORT:5432} + username: ${DB_USER:postgres} + password: ${DB_PASSWORD} # No fallback - required +``` + +See the [core package documentation](../core/README.md#environment-variable-placeholders) for complete details. + +### Map-Based Configuration + +Bind configuration to `Map` for dynamic collections: + +```typescript +class ServiceEndpoint { + url: string; + timeout: number; + retries: number; +} + +@ConfigurationProperties('services') +class ServicesConfig { + @ConfigProperty('endpoints') + endpoints: Map; +} +``` + +```yaml +# config/application.yml +services: + endpoints: + auth: + url: http://localhost:8001 + timeout: 5000 + retries: 3 + payment: + url: http://localhost:8002 + timeout: 10000 + retries: 5 +``` + +**Usage in routes:** + +```typescript +fastify.get('/api/services/:name', async (request, reply) => { + const servicesConfig = request.container.get(ServicesConfig); + const endpoint = servicesConfig.endpoints.get(request.params.name); + + if (!endpoint) { + return reply.code(404).send({ error: 'Service not found' }); + } + + return endpoint; +}); +``` + +**Alternative: Record type** for plain object syntax: + +```typescript + +@ConfigurationProperties('services') +class ServicesConfig { + @ConfigProperty('endpoints') + endpoints: Record; +} + +// Access with bracket notation +const auth = servicesConfig.endpoints['auth']; +``` + +See the [core package documentation](../core/README.md#map-based-configuration) for complete details. + ## API - `fastifyTypeConfig(options)` - Register Fastify plugin @@ -178,15 +267,17 @@ database: ## Comparison -| Feature | type-config/fastify | fastify-config | dotenv | node-config | -|-----------------|:-------------------:|:--------------:|:------:|:-----------:| -| Type safety | βœ… Decorators, TS | ❌ | ❌ | ❌ | -| Multi-source | βœ… YAML, env, etc. | ⚠️ Partial | ❌ | βœ… | -| Profile support | βœ… Spring-style | ❌ | ❌ | βœ… | -| Hot reload | βœ… Built-in | ❌ | ❌ | ❌ | -| Encryption | βœ… Built-in | ❌ | ❌ | ❌ | -| Validation | βœ… class-validator | ❌ | ❌ | ❌ | -| DI integration | βœ… Per-request | ❌ | ❌ | ❌ | +| Feature | type-config/fastify | fastify-config | dotenv | node-config | +|--------------------|:--------------------:|:--------------:|:--------:|:-----------:| +| Type safety | βœ… Decorators, TS | ❌ | ❌ | ❌ | +| Multi-source | βœ… YAML, env, etc. | ⚠️ Partial | ❌ | βœ… | +| Profile support | βœ… Spring-style | ❌ | ❌ | βœ… | +| Hot reload | βœ… Built-in | ❌ | ❌ | ❌ | +| Encryption | βœ… Built-in | ❌ | ❌ | ❌ | +| Validation | βœ… class-validator | ❌ | ❌ | ❌ | +| DI integration | βœ… Per-request | ❌ | ❌ | ❌ | +| Map/Record binding | βœ… Dynamic structures | ❌ | ❌ | ❌ | +| ENV placeholders | βœ… ${VAR:fallback} | ❌ | ⚠️ Basic | ❌ | ## License diff --git a/packages/fastify/jest.config.js b/packages/fastify/jest.config.js new file mode 100644 index 0000000..92fcdd2 --- /dev/null +++ b/packages/fastify/jest.config.js @@ -0,0 +1,28 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.spec.ts', '**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts', + ], + coverageDirectory: 'coverage', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + target: 'ES2020', + module: 'commonjs', + lib: ['ES2020'], + esModuleInterop: true, + experimentalDecorators: true, + emitDecoratorMetadata: true, + moduleResolution: 'node', + }, + }, + ], + }, +}; diff --git a/packages/fastify/package.json b/packages/fastify/package.json index b8a4e8a..106afe4 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,13 +1,13 @@ { "name": "@snow-tzu/type-config-fastify", - "version": "0.0.1", + "version": "0.0.5", "description": "Type Config plugin for Fastify", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "clean": "rm -rf dist", - "test": "jest --config ../../jest.config.js --passWithNoTests" + "test": "jest --passWithNoTests" }, "keywords": [ "spring-config", diff --git a/packages/fastify/src/index.ts b/packages/fastify/src/index.ts index cc026ab..8cfe970 100644 --- a/packages/fastify/src/index.ts +++ b/packages/fastify/src/index.ts @@ -9,6 +9,8 @@ export { Validate, Injectable, Inject, + RecordType, ConfigManager, Container, + MapBinder, } from '@snow-tzu/type-config'; diff --git a/packages/nestjs/README.md b/packages/nestjs/README.md index 6b06b72..09d21cc 100644 --- a/packages/nestjs/README.md +++ b/packages/nestjs/README.md @@ -12,7 +12,6 @@ - **Type-safe**: Decorator-based config classes with TypeScript - **Profile support**: Spring-style profiles for dev, prod, etc. -- **Hot reload**: Watch and reload config changes instantly - **Multi-source**: Merge YAML, JSON, .env, env vars, remote - **NestJS DI**: Config classes are injectable anywhere - **Encryption**: Secure secrets with built-in AES-256 @@ -40,6 +39,8 @@ - Hot reload support - Encrypted values - Validation with class-validator +- Map & Record binding for dynamic key-value structures +- Environment variable placeholders with `${VAR:fallback}` syntax ## Installation @@ -88,7 +89,7 @@ export class AppModule {} **NestJS deletes the `dist` folder during compilation, which can remove your YAML/JSON configuration files unless properly managed.** -See the [Configuration File Management Guide](../core/CONFIG_FILES.md) for: +See the [Configuration File Management Guide](https://github.com/ganesanarun/type-config/blob/main/packages/core/CONFIG_FILES.md) for: - Why configuration files disappear during builds - Solutions for NestJS, Express, Fastify, and vanilla Node.js - Configuration directory resolution patterns @@ -145,14 +146,96 @@ TypeConfigModule.forRoot({ server: host: localhost port: 3000 +``` # application-production.yml +```yaml server: host: 0.0.0.0 port: 8080 ``` -For more, see the [Configuration File Management Guide](../core/CONFIG_FILES.md). +For more, see the [Configuration File Management Guide](https://github.com/ganesanarun/type-config/blob/main/packages/core/CONFIG_FILES.md). + +## Advanced Features + +### Environment Variable Placeholders + +Use `${VAR:fallback}` syntax in your YAML/JSON files: + +```yaml +# config/application.yml +database: + host: ${DB_HOST:localhost} + port: ${DB_PORT:5432} + username: ${DB_USER:postgres} + password: ${DB_PASSWORD} # No fallback - required in production +``` + +See the [core package documentation](https://github.com/ganesanarun/type-config/blob/main/packages/core/README.md#environment-variable-placeholders) for complete details on placeholder syntax and precedence rules. + +### Map-Based Configuration + +Bind configuration to `Map` for dynamic collections: + +```typescript +class DatabaseConnection { + host: string; + port: number; + username: string; + password: string; +} + +@ConfigurationProperties('databases') +export class DatabasesConfig { + @ConfigProperty('connections') + connections: Map; +} +``` + +```yaml +# config/application.yml +databases: + connections: + primary: + host: localhost + port: 5432 + username: postgres + password: secret + analytics: + host: analytics-db.example.com + port: 5432 + username: analytics_user + password: analytics_pass +``` + +**Usage in services:** + +```typescript +@Injectable() +export class DatabaseService { + constructor(private readonly dbConfig: DatabasesConfig) {} + + getConnection(name: string) { + return this.dbConfig.connections.get(name); + } +} +``` + +**Alternative: Record type** for plain object syntax: + +```typescript +@ConfigurationProperties('databases') +export class DatabasesConfig { + @ConfigProperty('connections') + connections: Record; +} + +// Access with bracket notation +const primary = this.dbConfig.connections['primary']; +``` + +See the [core package documentation](../core/README.md#map-based-configuration) for complete details and the [Map and Placeholders Example](../../../../../../Library/Application%20Support/JetBrains/IntelliJIdea2025.2/scratches/map-and-placeholders/) for a full working NestJS application. ## Usage Patterns @@ -223,10 +306,10 @@ export class EmailModule {} #### When to use each pattern -| Pattern | Use When | -|----------------|------------------------------------------------------| -| ConfigModule | Large apps, dynamic modules, better organization | -| Global | Small apps, simple config, no dynamic module injection| +| Pattern | Use When | +|--------------|--------------------------------------------------------| +| ConfigModule | Large apps, dynamic modules, better organization | +| Global | Small apps, simple config, no dynamic module injection | ### Using Configuration in Providers, Controllers, and Dynamic Modules @@ -305,7 +388,7 @@ You can call `forFeature` in multiple modules as long as `forRoot` was called so - NestJS developers who want type-safe, robust, and maintainable configuration - Teams migrating from dotenv, node-config, or @nestjs/config -- Projects needing multi-source, profile-based, or encrypted config +- Projects need multi-source, profile-based, or encrypted config ## Comparison @@ -318,6 +401,8 @@ You can call `forFeature` in multiple modules as long as `forRoot` was called so | Encryption | βœ… Built-in | ❌ | ❌ | ❌ | | Validation | βœ… class-validator | ⚠️ Manual | ❌ | ❌ | | DI integration | βœ… Native | βœ… | ❌ | ❌ | +| Map/Record binding | βœ… Dynamic structures | ❌ | ❌ | ❌ | +| ENV placeholders | βœ… ${VAR:fallback} | ❌ | ⚠️ Basic | ❌ | ## License diff --git a/packages/nestjs/jest.config.js b/packages/nestjs/jest.config.js new file mode 100644 index 0000000..92fcdd2 --- /dev/null +++ b/packages/nestjs/jest.config.js @@ -0,0 +1,28 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.spec.ts', '**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts', + ], + coverageDirectory: 'coverage', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + target: 'ES2020', + module: 'commonjs', + lib: ['ES2020'], + esModuleInterop: true, + experimentalDecorators: true, + emitDecoratorMetadata: true, + moduleResolution: 'node', + }, + }, + ], + }, +}; diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index a3e0018..c8e7a42 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,13 +1,13 @@ { "name": "@snow-tzu/type-config-nestjs", - "version": "0.0.1", + "version": "0.0.5", "description": "Type Config integration for NestJS with native DI support", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "clean": "rm -rf dist", - "test": "jest --config ../../jest.config.js --passWithNoTests" + "test": "jest --passWithNoTests" }, "keywords": [ "spring-config", diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index ef561a7..1967b93 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -15,4 +15,5 @@ export { Required, DefaultValue, Validate, + RecordType, } from '@snow-tzu/type-config'; diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts index d6e8559..b3418f3 100644 --- a/packages/nestjs/src/index.ts +++ b/packages/nestjs/src/index.ts @@ -2,4 +2,4 @@ export * from './type-config.module'; export * from './decorators'; // Re-export core types -export { ConfigManager, ConfigSource } from '@snow-tzu/type-config'; +export { ConfigManager, ConfigSource, MapBinder } from '@snow-tzu/type-config'; diff --git a/packages/remote/README.md b/packages/remote/README.md index 8d61710..8916370 100644 --- a/packages/remote/README.md +++ b/packages/remote/README.md @@ -43,7 +43,7 @@ ## Installation ```bash -npm install @snow-tzu/config-remote @snow-tzu/type-config +npm install @snow-tzu/type-config-remote @snow-tzu/type-config # or yarn add @snow-tzu/type-config-remote @snow-tzu/type-config @@ -320,7 +320,7 @@ try { ## Complete Example -See the **[NestJS Remote Example](../../examples/nestjs-remote)** for a fully working application demonstrating remote +See the **[NestJS Remote Example](https://github.com/ganesanarun/type-config/blob/main/examples/nestjs-remote)** for a fully working application demonstrating remote configuration: ### Features Demonstrated @@ -344,7 +344,7 @@ import { RemoteConfigSource } from '@snow-tzu/config-remote'; @Module({ imports: [ - SpringConfigModule.forRootAsync({ + TypeConfigModule.forRootAsync({ useFactory: async () => ({ profile: process.env.NODE_ENV || 'development', configDir: './config', // Local fallback @@ -357,8 +357,7 @@ import { RemoteConfigSource } from '@snow-tzu/config-remote'; pollInterval: 30000, // Auto-refresh every 30 seconds priority: 350, // Higher than local files }) - ], - enableHotReload: true, + ] }), isGlobal: true, }), @@ -387,7 +386,6 @@ yarn dev - `GET /` - Welcome message - `GET /config` - View current configuration (local + remote merged) - `GET /health` - Health check -- `GET /refresh` - Manually trigger config refresh from remote server ### Mock Config Server Response @@ -418,7 +416,7 @@ If you're building your own config server, here's the expected response format: ### Priority Merging Example -With the remote example, configuration is merged in this order: +With the remote example, the configuration is merged in this order: 1. **Local base** (`application.yml`) - Priority 100 2. **Local profile** (`application-production.yml`) - Priority 120 @@ -507,7 +505,7 @@ const source = new EtcdSource({ ## Who is this for? - Node.js/TypeScript developers who want type-safe, robust, and maintainable remote configuration -- Teams needing to merge cloud, local, and environment config +- Teams needing to merge are cloud, local, and environment config - Projects needing secure, profile-based, or encrypted config ## Comparison diff --git a/packages/remote/jest.config.js b/packages/remote/jest.config.js new file mode 100644 index 0000000..92fcdd2 --- /dev/null +++ b/packages/remote/jest.config.js @@ -0,0 +1,28 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.spec.ts', '**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts', + ], + coverageDirectory: 'coverage', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + target: 'ES2020', + module: 'commonjs', + lib: ['ES2020'], + esModuleInterop: true, + experimentalDecorators: true, + emitDecoratorMetadata: true, + moduleResolution: 'node', + }, + }, + ], + }, +}; diff --git a/packages/remote/package.json b/packages/remote/package.json index ded7921..29e6098 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -1,13 +1,13 @@ { "name": "@snow-tzu/type-config-remote", - "version": "0.0.1", + "version": "0.0.5", "description": "Remote configuration sources for Type Config (AWS Parameter Store, Consul, etcd)", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "clean": "rm -rf dist", - "test": "jest --config ../../jest.config.js --passWithNoTests" + "test": "jest --passWithNoTests" }, "keywords": [ "spring-config", diff --git a/packages/testing/jest.config.js b/packages/testing/jest.config.js new file mode 100644 index 0000000..92fcdd2 --- /dev/null +++ b/packages/testing/jest.config.js @@ -0,0 +1,28 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.spec.ts', '**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts', + ], + coverageDirectory: 'coverage', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + target: 'ES2020', + module: 'commonjs', + lib: ['ES2020'], + esModuleInterop: true, + experimentalDecorators: true, + emitDecoratorMetadata: true, + moduleResolution: 'node', + }, + }, + ], + }, +}; diff --git a/packages/testing/package.json b/packages/testing/package.json index 5edcbf9..dddea33 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,13 +1,13 @@ { "name": "@snow-tzu/type-config-testing", - "version": "0.0.1", + "version": "0.0.5", "description": "Testing utilities for Type Config", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "clean": "rm -rf dist", - "test": "jest --config ../../jest.config.js --passWithNoTests" + "test": "jest --passWithNoTests" }, "keywords": [ "spring-config", diff --git a/yarn.lock b/yarn.lock index 96e7cf7..386eed8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7563,6 +7563,25 @@ __metadata: languageName: node linkType: hard +"nested-config-example@workspace:examples/nested-basic": + version: 0.0.0-use.local + resolution: "nested-config-example@workspace:examples/nested-basic" + dependencies: + "@nestjs/cli": "npm:^10.0.0" + "@nestjs/common": "npm:^10.0.0" + "@nestjs/core": "npm:^10.0.0" + "@nestjs/platform-express": "npm:^10.0.0" + "@snow-tzu/type-config-nestjs": "workspace:*" + "@types/node": "npm:^20.10.0" + class-transformer: "npm:^0.5.1" + class-validator: "npm:^0.14.0" + reflect-metadata: "npm:^0.2.1" + rxjs: "npm:^7.8.1" + ts-node: "npm:^10.9.2" + typescript: "npm:^5.3.0" + languageName: unknown + linkType: soft + "nestjs-basic-example@workspace:examples/nestjs-basic": version: 0.0.0-use.local resolution: "nestjs-basic-example@workspace:examples/nestjs-basic"