From 6855b680499a3cc9e1c6c55e57befe8492e7b722 Mon Sep 17 00:00:00 2001 From: Ganesan Arunachalam Date: Wed, 26 Nov 2025 20:27:44 +0530 Subject: [PATCH 1/4] feat: introduce custom env variable placeholder resolver and simple map, record support --- README.md | 2 +- examples/README.md | 25 + examples/map-and-placeholders/.env.example | 72 ++ examples/map-and-placeholders/.gitignore | 5 + examples/map-and-placeholders/CHANGES.md | 149 +++ .../map-and-placeholders/MAP_VS_RECORD.md | 186 ++++ .../MINIMAL_ANNOTATIONS.md | 242 +++++ examples/map-and-placeholders/QUICKSTART.md | 271 +++++ examples/map-and-placeholders/README.md | 379 +++++++ .../VALIDATION_LIMITATIONS.md | 258 +++++ .../config/application-production.yml | 58 ++ .../config/application.yml | 68 ++ examples/map-and-placeholders/package.json | 29 + .../src/app.controller.ts | 27 + .../map-and-placeholders/src/app.module.ts | 19 + .../map-and-placeholders/src/app.service.ts | 65 ++ .../src/config/database-record.config.ts | 93 ++ .../src/config/database.config.ts | 63 ++ .../src/config/features.config.ts | 24 + .../src/config/server.config.ts | 25 + .../src/config/services.config.ts | 43 + examples/map-and-placeholders/src/main.ts | 169 +++ examples/map-and-placeholders/tsconfig.json | 24 + package.json | 8 +- packages/core/CONFIG_FILES.md | 282 ++++- packages/core/README.md | 279 +++++ packages/core/jest.config.js | 28 + packages/core/package.json | 4 +- packages/core/src/config-manager.ts | 87 +- packages/core/src/decorators.ts | 34 + packages/core/src/index.ts | 2 + packages/core/src/map-binder.ts | 68 ++ packages/core/src/placeholder-resolver.ts | 141 +++ packages/core/src/sources.ts | 2 +- packages/core/test/builder.spec.ts | 13 +- packages/core/test/config-manager.spec.ts | 547 +++++++++- .../test/cross-format-consistency.spec.ts | 468 +++++++++ packages/core/test/map-type.spec.ts | 787 ++++++++++++++ packages/core/test/map-vs-record.spec.ts | 975 ++++++++++++++++++ .../core/test/placeholder-integration.spec.ts | 210 ++++ .../core/test/placeholder-resolver.spec.ts | 216 ++++ packages/core/test/record-type.spec.ts | 458 ++++++++ packages/express/README.md | 87 ++ packages/express/jest.config.js | 28 + packages/express/package.json | 4 +- packages/express/src/index.ts | 2 + packages/fastify/README.md | 87 ++ packages/fastify/jest.config.js | 28 + packages/fastify/package.json | 4 +- packages/fastify/src/index.ts | 2 + packages/nestjs/README.md | 84 ++ packages/nestjs/jest.config.js | 28 + packages/nestjs/package.json | 4 +- packages/nestjs/src/decorators.ts | 1 + packages/nestjs/src/index.ts | 2 +- packages/remote/jest.config.js | 28 + packages/remote/package.json | 4 +- packages/testing/jest.config.js | 28 + packages/testing/package.json | 4 +- yarn.lock | 19 + 60 files changed, 7291 insertions(+), 58 deletions(-) create mode 100644 examples/map-and-placeholders/.env.example create mode 100644 examples/map-and-placeholders/.gitignore create mode 100644 examples/map-and-placeholders/CHANGES.md create mode 100644 examples/map-and-placeholders/MAP_VS_RECORD.md create mode 100644 examples/map-and-placeholders/MINIMAL_ANNOTATIONS.md create mode 100644 examples/map-and-placeholders/QUICKSTART.md create mode 100644 examples/map-and-placeholders/README.md create mode 100644 examples/map-and-placeholders/VALIDATION_LIMITATIONS.md create mode 100644 examples/map-and-placeholders/config/application-production.yml create mode 100644 examples/map-and-placeholders/config/application.yml create mode 100644 examples/map-and-placeholders/package.json create mode 100644 examples/map-and-placeholders/src/app.controller.ts create mode 100644 examples/map-and-placeholders/src/app.module.ts create mode 100644 examples/map-and-placeholders/src/app.service.ts create mode 100644 examples/map-and-placeholders/src/config/database-record.config.ts create mode 100644 examples/map-and-placeholders/src/config/database.config.ts create mode 100644 examples/map-and-placeholders/src/config/features.config.ts create mode 100644 examples/map-and-placeholders/src/config/server.config.ts create mode 100644 examples/map-and-placeholders/src/config/services.config.ts create mode 100644 examples/map-and-placeholders/src/main.ts create mode 100644 examples/map-and-placeholders/tsconfig.json create mode 100644 packages/core/jest.config.js create mode 100644 packages/core/src/map-binder.ts create mode 100644 packages/core/src/placeholder-resolver.ts create mode 100644 packages/core/test/cross-format-consistency.spec.ts create mode 100644 packages/core/test/map-type.spec.ts create mode 100644 packages/core/test/map-vs-record.spec.ts create mode 100644 packages/core/test/placeholder-integration.spec.ts create mode 100644 packages/core/test/placeholder-resolver.spec.ts create mode 100644 packages/core/test/record-type.spec.ts create mode 100644 packages/express/jest.config.js create mode 100644 packages/fastify/jest.config.js create mode 100644 packages/nestjs/jest.config.js create mode 100644 packages/remote/jest.config.js create mode 100644 packages/testing/jest.config.js 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..9769867 100644 --- a/examples/README.md +++ b/examples/README.md @@ -93,6 +93,31 @@ Pure Node.js application (no framework) using Type Config Core. --- +### 6. Map and Placeholders (`map-and-placeholders/`) + +NestJS application demonstrating advanced Type Config features: Map-based configuration and placeholder resolution. + +**Features:** + +- **Map-based configuration binding**: Use `Map` for collections (databases, services) +- **Placeholder resolution**: `${VAR:fallback}` syntax with environment variables +- **Profile-specific placeholders**: Different ENV vars per environment +- **Precedence rules**: Explicit placeholders vs underscore-based ENV resolution +- **Nested structures**: Complex objects as map values +- **Comprehensive validation**: class-validator integration for map entries + +**Start:** `cd map-and-placeholders && yarn dev` + +**Production:** `NODE_ENV=production yarn dev` + +**Key Concepts:** +- Map binding eliminates repetitive property definitions +- Placeholders support fallback values for development +- Profile-specific configs can override which ENV vars are used +- Both explicit placeholders and underscore-based ENV resolution work together + +--- + ## Quick Start ### Install All Dependencies diff --git a/examples/map-and-placeholders/.env.example b/examples/map-and-placeholders/.env.example new file mode 100644 index 0000000..9459da5 --- /dev/null +++ b/examples/map-and-placeholders/.env.example @@ -0,0 +1,72 @@ +# Server Configuration +SERVER_HOST=localhost +SERVER_PORT=3000 +APP_NAME=map-and-placeholders-app + +# Database Connections - US Region +DB_US_HOST=localhost +DB_US_PORT=5432 +DB_US_USERNAME=postgres +DB_US_PASSWORD=dev_password + +# Database Connections - AG Region +DB_AG_HOST=localhost +DB_AG_PORT=5432 +DB_AG_USERNAME=postgres +DB_AG_PASSWORD=dev_password + +# Database Connections - Analytics +DB_ANALYTICS_HOST=localhost +DB_ANALYTICS_PORT=5432 +DB_ANALYTICS_USERNAME=analytics_user +DB_ANALYTICS_PASSWORD=analytics_pass + +# Connection Pool Settings +DB_POOL_MIN=2 +DB_POOL_MAX=10 +DB_POOL_IDLE=10000 + +# Service Endpoints +AUTH_SERVICE_URL=http://localhost:8001 +AUTH_SERVICE_TIMEOUT=5000 +PAYMENT_SERVICE_URL=http://localhost:8002 +PAYMENT_SERVICE_TIMEOUT=10000 +NOTIFICATION_SERVICE_URL=http://localhost:8003 +NOTIFICATION_SERVICE_TIMEOUT=3000 + +# Feature Flags +FEATURE_NEW_UI=false +FEATURE_BETA=false +MAINTENANCE_MODE=false + +# Production Environment Variables (when NODE_ENV=production) +# PROD_PORT=8080 +# PROD_APP_NAME=map-and-placeholders-prod +# PROD_DB_US_HOST=prod-db-us.example.com +# PROD_DB_US_USERNAME=prod_user +# PROD_DB_US_PASSWORD=secret123 +# PROD_DB_AG_HOST=prod-db-ag.example.com +# PROD_DB_AG_USERNAME=prod_user +# PROD_DB_AG_PASSWORD=secret456 +# PROD_DB_ANALYTICS_HOST=prod-analytics.example.com +# PROD_DB_ANALYTICS_USERNAME=analytics_prod +# PROD_DB_ANALYTICS_PASSWORD=secret789 +# PROD_DB_POOL_MIN=5 +# PROD_DB_POOL_MAX=50 +# PROD_DB_POOL_IDLE=30000 +# PROD_AUTH_SERVICE_URL=https://auth.example.com +# PROD_AUTH_SERVICE_TIMEOUT=3000 +# PROD_PAYMENT_SERVICE_URL=https://payment.example.com +# PROD_PAYMENT_SERVICE_TIMEOUT=15000 +# PROD_NOTIFICATION_SERVICE_URL=https://notifications.example.com +# PROD_NOTIFICATION_SERVICE_TIMEOUT=5000 +# PROD_FEATURE_NEW_UI=true +# PROD_FEATURE_BETA=false +# PROD_MAINTENANCE_MODE=false + +# Underscore-based ENV Resolution Examples +# These will override configuration values (lower precedence than explicit placeholders) +# DATABASES_POOL_MIN=5 +# DATABASES_POOL_MAX=20 +# DATABASES_CONNECTIONS_SERHAFEN_US_HOST=override-host +# SERVICES_ENDPOINTS_AUTH_SERVICE_URL=http://custom-auth:8001 diff --git a/examples/map-and-placeholders/.gitignore b/examples/map-and-placeholders/.gitignore new file mode 100644 index 0000000..85075a6 --- /dev/null +++ b/examples/map-and-placeholders/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +*.log +.env +.DS_Store diff --git a/examples/map-and-placeholders/CHANGES.md b/examples/map-and-placeholders/CHANGES.md new file mode 100644 index 0000000..5a4accd --- /dev/null +++ b/examples/map-and-placeholders/CHANGES.md @@ -0,0 +1,149 @@ +# Changes Made to Fix Issues + +## Issues Fixed + +### 1. Validation Error +**Problem**: `Validation failed for DatabasesConfig: undefined: an unknown value was passed to the validate function` + +**Root Cause**: The `@Validate()` decorator was trying to validate Map properties, but class-validator doesn't support validating Map types directly. + +**Solution**: +- Removed `@Validate()` decorator from config classes with Map properties +- Set `validateOnBind: false` in TypeConfigModule configuration +- Removed validation decorators from nested classes +- The config classes still use `@Required()` and `@DefaultValue()` for basic configuration management + +### 2. Precedence Documentation +**Problem**: Documentation incorrectly stated that explicit placeholders take precedence over underscore-based ENV resolution. + +**Root Cause**: Misunderstanding of the actual implementation. According to `PLACEHOLDER_RESOLUTION.md`, the resolution order is: +1. Load and merge all sources (underscore-based ENV has priority 200) +2. Resolve explicit placeholders in the merged result +3. Decrypt if needed + +**Solution**: Updated all documentation to reflect the correct precedence: +- **Underscore-based ENV variables** (priority 200) override file values +- **Profile-specific files** (priority 150) override base files +- **Base files** (priority 100) +- **Placeholder resolution** happens after merging +- **Default values** from decorators (lowest priority) + +## Files Modified + +### Configuration Classes (Simplified) +- `src/config/database.config.ts` - Removed `@Validate()` decorator and validation decorators +- `src/config/services.config.ts` - Removed `@Validate()` decorator and validation decorators +- `src/config/server.config.ts` - Removed unnecessary validation decorators +- `src/config/features.config.ts` - Removed unnecessary validation decorators +- `src/app.module.ts` - Set `validateOnBind: false` + +### Documentation (Corrected Precedence) +- `README.md` - Updated precedence rules and examples +- `QUICKSTART.md` - Updated precedence scenarios and examples + +## How It Works Now + +### Configuration Resolution Flow + +1. **Load Sources**: + - `application.yml` (priority 100) + - `application-production.yml` (priority 150, if NODE_ENV=production) + - EnvConfigSource (priority 200) - converts `DATABASE_HOST` โ†’ `database.host` + +2. **Merge by Priority**: + - Higher priority sources override lower priority + - EnvConfigSource (200) overrides profile files (150) which override base files (100) + +3. **Resolve Placeholders**: + - Scan merged config for `${VAR:fallback}` patterns + - Resolve each placeholder by looking up ENV var + - If ENV var exists: use its value + - If ENV var doesn't exist and fallback provided: use fallback + - If ENV var doesn't exist and no fallback: field becomes `undefined` + +4. **Bind to Classes**: + - Convert plain objects to Map instances where needed + - Apply default values from decorators + - Validate required fields + +### Example Scenarios + +#### Scenario 1: Underscore-Based Override +```yaml +# application.yml +database: + host: localhost +``` + +```bash +DATABASE_HOST=prod-server yarn dev +``` + +**Result**: `database.host = "prod-server"` (underscore-based ENV overrides file) + +#### Scenario 2: Placeholder Resolution +```yaml +# application.yml +database: + host: ${DB_HOST:localhost} +``` + +```bash +DB_HOST=prod-server yarn dev +``` + +**Result**: `database.host = "prod-server"` (placeholder resolves to ENV var) + +#### Scenario 3: Underscore-Based Sets Value with Placeholder +```yaml +# application.yml +database: + url: file-value +``` + +```bash +DATABASE_URL='postgres://${DB_USER:admin}@localhost/mydb' DB_USER=root yarn dev +``` + +**Result**: `database.url = "postgres://root@localhost/mydb"` + +**Explanation**: +1. EnvConfigSource sets `database.url = "postgres://${DB_USER:admin}@localhost/mydb"` (overrides file) +2. Placeholder resolution resolves `${DB_USER:admin}` to `root` + +## Testing + +The example should now run without validation errors: + +```bash +cd examples/map-and-placeholders +yarn dev +``` + +You should see output showing all database connections, service endpoints, and feature flags with their resolved values. + +## Key Takeaways + +1. **Underscore-based ENV resolution happens first** (priority 200) and can override file values +2. **Placeholder resolution happens after merging** and resolves `${VAR:fallback}` in the merged config +3. **Both mechanisms work together**: Underscore-based ENV can set values that contain placeholders +4. **Nested classes in Maps don't need validation decorators** - they're plain data classes +5. **Top-level config classes use `@Required()` and `@DefaultValue()`** for basic validation +6. **Map validation limitation**: `@Required()` only validates that the Map exists, not its contents +7. **Manual validation recommended**: For production, add custom validation logic for Map entries + +## Known Limitations + +### Map Entry Validation + +The `@Required()` decorator on the `connections` property only ensures the Map itself exists. It does NOT validate: +- Whether map entries have all required fields +- Whether field types are correct +- Whether field values are valid + +**Example**: If you remove `port` from a database connection in the YAML, the application will still start. The `port` field will be `undefined` at runtime. + +**Workaround**: The example includes manual validation in `main.ts` that checks each map entry and logs warnings for missing fields. For production use, you should: +1. Implement custom validation logic +2. Throw errors for invalid configurations +3. Or use a different structure if strict validation is critical diff --git a/examples/map-and-placeholders/MAP_VS_RECORD.md b/examples/map-and-placeholders/MAP_VS_RECORD.md new file mode 100644 index 0000000..158668e --- /dev/null +++ b/examples/map-and-placeholders/MAP_VS_RECORD.md @@ -0,0 +1,186 @@ +# Map vs Record: Choosing the Right Approach + +This example demonstrates two approaches for map-based configuration: `Map` and `Record`. Each has trade-offs. + +## Comparison + +| Feature | Map | Record | +|---------|----------------|-------------------| +| **Validation** | โŒ Doesn't work with class-validator | โš ๏ธ API exists but not fully implemented yet | +| **Type Safety** | โœ… True Map type | โœ… Object with string keys | +| **Map Methods** | โœ… .get(), .set(), .has(), .delete() | โŒ Must use bracket notation | +| **Iteration** | โœ… for...of with .entries() | โœ… Object.keys(), Object.entries() | +| **JSON Serialization** | โŒ Requires conversion | โœ… Works directly | +| **Spec Compliance** | โœ… Matches spec requirements | โš ๏ธ Alternative approach | + +## Map Approach (Current Example) + +### Configuration Class + +```typescript +@ConfigurationProperties('databases') +export class DatabasesConfig { + @ConfigProperty('connections') + @Required() + connections: Map; +} +``` + +### Pros +- True Map type with all Map methods +- Cleaner API: `config.connections.get('serhafen-us')` +- Better for dynamic keys +- Matches the spec requirements + +### Cons +- **Validation doesn't work** - class-validator can't validate Map entries +- Must set `validateOnBind: false` +- Need manual validation for map entries +- More complex to serialize to JSON + +### Usage + +```typescript +const databasesConfig = configManager.bind(DatabasesConfig); + +// Access with Map methods +const usDb = databasesConfig.connections.get('serhafen-us'); + +// Iterate +for (const [name, conn] of databasesConfig.connections) { + console.log(`${name}: ${conn.host}`); +} + +// Manual validation required +for (const [name, conn] of databasesConfig.connections) { + if (!conn.host || !conn.port) { + throw new Error(`Invalid connection: ${name}`); + } +} +``` + +## Record Approach (Alternative) + +### Configuration Class + +```typescript +@ConfigurationProperties('databases') +@Validate() +export class DatabasesRecordConfig { + @ConfigProperty('connections') + @Required() + @RecordType() // Important: Prevents conversion to Map + @ValidateNested({ each: true }) + @Type(() => DatabaseConnectionValidated) + connections: Record; +} +``` + +**Important**: The `@RecordType()` decorator is required to tell the system to keep this as a plain object and not convert it to a Map. + +### Pros +- Cleaner validation API with `@Validate()` decorator +- Would validate each entry automatically (when implemented) +- Would show which entry failed (when implemented) +- Simpler JSON serialization +- No need to convert Map for HTTP responses + +### Cons +- **Validation not yet fully implemented** - @ValidateNested() doesn't work yet +- Not a true Map (no Map methods) +- Must use bracket notation: `connections['serhafen-us']` +- Less type-safe for dynamic keys +- Doesn't match spec requirements exactly + +### Usage + +```typescript +const databasesConfig = configManager.bind(DatabasesRecordConfig); + +// Access with bracket notation +const usDb = databasesConfig.connections['serhafen-us']; + +// Iterate +for (const [name, conn] of Object.entries(databasesConfig.connections)) { + console.log(`${name}: ${conn.host}`); +} + +// Validation happens automatically - no manual checks needed! +``` + +## Testing the Record Approach + +**Note**: Record validation is not yet fully implemented in the core library. The example shows the intended API, but validation won't work until the implementation is completed. + +When implemented, you would test it like this: + +1. **Update `app.module.ts`**: +```typescript +TypeConfigModule.forRoot({ + configDir: './config', + profile: process.env.NODE_ENV || 'development', + enableHotReload: false, + validateOnBind: true, // Enable validation +}) +``` + +2. **Use the Record config class**: +```typescript +import { DatabasesRecordConfig } from './config/database-record.config'; + +const databasesConfig = configManager.bind(DatabasesRecordConfig); +``` + +3. **Test validation** by removing a required field - this would throw an error when implemented: +```yaml +# config/application.yml +databases: + connections: + serhafen-us: + host: localhost + # Remove port to test validation + username: postgres + password: dev_password +``` + +Expected error (when implemented): +``` +Validation failed for DatabasesRecordConfig: +- connections.serhafen-us.port must be a number +``` + +## Recommendation + +### Use Map (Current Recommendation): +- โœ… Fully implemented and working +- โœ… True Map semantics (get, set, has, delete) +- โœ… Matches the spec requirements +- โœ… Dynamic key operations work well +- โš ๏ธ Requires manual validation +- โš ๏ธ Needs `Object.fromEntries()` for JSON serialization + +### Use Record (Future): +- โš ๏ธ Validation not yet fully implemented +- โœ… Would have automatic validation (when implemented) +- โœ… Simpler JSON serialization +- โŒ Not a true Map (no Map methods) +- โŒ Doesn't match spec exactly + +**For now, use Map with manual validation** until Record validation is fully implemented in the core library. + +## Future Enhancement + +Ideally, the core library would support validation for both Map and Record types. This would require: + +1. Detecting Map vs Record types +2. For Map: Converting to Record, validating, then converting back +3. For Record: Using existing class-validator support + +This would give users the best of both worlds: Map semantics with automatic validation. + +## Example Files + +- **Map approach**: `src/config/database.config.ts` (current example) +- **Record approach**: `src/config/database-record.config.ts` (alternative) + +Try both and choose what works best for your use case! diff --git a/examples/map-and-placeholders/MINIMAL_ANNOTATIONS.md b/examples/map-and-placeholders/MINIMAL_ANNOTATIONS.md new file mode 100644 index 0000000..d1b6baf --- /dev/null +++ b/examples/map-and-placeholders/MINIMAL_ANNOTATIONS.md @@ -0,0 +1,242 @@ +# Minimal Annotations Guide + +## What You Actually Need + +Here's what decorators are actually required vs optional for Map/Record types: + +### For Record Type + +```typescript +@ConfigurationProperties('databases') // โœ… Required - marks as config class +export class DatabasesRecordConfig { + + @ConfigProperty('connections') // โœ… Required - maps to YAML path + @Required() // โœ… Optional but useful - validates property exists + @RecordType() // โœ… Required - prevents Map conversion + connections: Record; +} +``` + +**That's it!** Only 3-4 decorators needed. + +### For Map Type + +```typescript +@ConfigurationProperties('databases') // โœ… Required - marks as config class +export class DatabasesMapConfig { + + @ConfigProperty('connections') // โœ… Required - maps to YAML path + @Required() // โœ… Optional but useful - validates property exists + connections: Map; +} +``` + +**Even simpler!** Only 2-3 decorators needed (no @RecordType needed for Map). + +## What You DON'T Need + +### โŒ Remove These (They Don't Work) + +```typescript +@Validate() // โŒ Remove - only works for simple properties +@ValidateNested({ each: true }) // โŒ Remove - doesn't work for dynamic keys +@Type(() => DatabaseConnection) // โŒ Remove - doesn't work for dynamic keys +``` + +### โŒ Remove These from Entry Classes Too + +```typescript +// In DatabaseConnection class +export class DatabaseConnection { + @IsString() // โŒ Remove - doesn't validate in Map/Record + host: string; + + @IsNumber() // โŒ Remove - doesn't validate in Map/Record + @Min(1) // โŒ Remove - doesn't validate in Map/Record + @Max(65535) // โŒ Remove - doesn't validate in Map/Record + port: number; +} +``` + +**Keep them only for documentation**, but understand they don't provide validation. + +## Comparison: Before vs After + +### โŒ Before (Misleading) + +```typescript +import { ValidateNested, IsString, IsNumber } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class DatabaseConnection { + @IsString() + host: string; + + @IsNumber() + @Min(1) + @Max(65535) + port: number; +} + +@ConfigurationProperties('databases') +@Validate() // Doesn't work +export class DatabasesConfig { + @ConfigProperty('connections') + @Required() + @RecordType() + @ValidateNested({ each: true }) // Doesn't work + @Type(() => DatabaseConnection) // Doesn't work + connections: Record; +} +``` + +**Problems**: +- Suggests validation works (it doesn't) +- Extra imports needed +- Confusing for users + +### โœ… After (Honest) + +```typescript +// No class-validator imports needed! + +export class DatabaseConnection { + host: string; + port: number; + username: string; + password: string; +} + +@ConfigurationProperties('databases') +export class DatabasesConfig { + @ConfigProperty('connections') + @Required() + @RecordType() + connections: Record; +} +``` + +**Benefits**: +- Clear and simple +- No false expectations +- Fewer imports +- Honest about limitations + +## When to Use Each Decorator + +### @ConfigurationProperties(prefix) +**Always required** - Marks the class as a configuration class and sets the YAML path prefix. + +```typescript +@ConfigurationProperties('databases') // Maps to 'databases' in YAML +class DatabasesConfig { } +``` + +### @ConfigProperty(path) +**Always required** - Maps a property to a specific path in the configuration. + +```typescript +@ConfigProperty('connections') // Maps to 'databases.connections' in YAML +connections: Record; +``` + +### @Required() +**Optional but recommended** - Validates that the property exists (not the contents). + +```typescript +@Required() // Throws error if 'connections' is missing +connections: Record; +``` + +**What it checks**: Property exists +**What it doesn't check**: Entry contents, required fields in entries + +### @RecordType() +**Required for Record, not needed for Map** - Tells the system to keep as plain object. + +```typescript +@RecordType() // Keeps as plain object (don't convert to Map) +connections: Record; +``` + +Without this, the system might try to convert to Map. + +### @DefaultValue(value) +**Optional** - Provides a default value if the property is missing. + +```typescript +@DefaultValue({}) // Use empty object if missing +connections: Record; +``` + +## Complete Minimal Example + +```typescript +import { + ConfigurationProperties, + ConfigProperty, + Required, + RecordType, +} from '@snow-tzu/type-config-nestjs'; + +// Simple interface - no decorators needed +export interface DatabaseConnection { + host: string; + port: number; + username: string; + password: string; + database: string; + schema: string; + ssl: boolean; +} + +// Minimal config class +@ConfigurationProperties('databases') +export class DatabasesConfig { + @ConfigProperty('connections') + @Required() + @RecordType() + connections: Record; + + @ConfigProperty('pool') + @Required() + pool: { + min: number; + max: number; + idle: number; + }; +} + +// Manual validation (required) +function validateDatabaseConnections(config: DatabasesConfig): void { + for (const [name, conn] of Object.entries(config.connections)) { + if (!conn.host) throw new Error(`${name}: missing host`); + if (!conn.port) throw new Error(`${name}: missing port`); + if (conn.port < 1 || conn.port > 65535) { + throw new Error(`${name}: invalid port ${conn.port}`); + } + // ... more validation + } +} +``` + +## Summary + +**Minimal annotations for Record**: +1. `@ConfigurationProperties(prefix)` - Required +2. `@ConfigProperty(path)` - Required +3. `@Required()` - Optional but useful +4. `@RecordType()` - Required for Record + +**Minimal annotations for Map**: +1. `@ConfigurationProperties(prefix)` - Required +2. `@ConfigProperty(path)` - Required +3. `@Required()` - Optional but useful + +**Don't use**: +- `@Validate()` - Doesn't work for Map/Record +- `@ValidateNested()` - Doesn't work for dynamic keys +- `@Type()` - Doesn't work for dynamic keys +- class-validator decorators on entry classes - Don't validate + +**Remember**: Manual validation is required for Map/Record entries! diff --git a/examples/map-and-placeholders/QUICKSTART.md b/examples/map-and-placeholders/QUICKSTART.md new file mode 100644 index 0000000..bf22a7b --- /dev/null +++ b/examples/map-and-placeholders/QUICKSTART.md @@ -0,0 +1,271 @@ +# Quick Start Guide + +This guide will help you quickly test the Map and Placeholders example. + +## Prerequisites + +Make sure you're in the example directory: + +```bash +cd examples/map-and-placeholders +``` + +## 1. Basic Run (Development Mode) + +Run with default configuration and fallback values: + +```bash +yarn dev +``` + +You should see output showing: +- 3 database connections (serhafen-us, serhafen-ag, analytics) +- 3 service endpoints (auth-service, payment-service, notification-service) +- Feature flags +- All using fallback values from the YAML files + +## 2. Test with Environment Variables + +### Override a Single Value + +```bash +DB_US_HOST=custom-database.com yarn dev +``` + +Notice that only the `serhafen-us` host changes to `custom-database.com`. + +### Override Multiple Values + +```bash +DB_US_HOST=db1.example.com \ +DB_AG_HOST=db2.example.com \ +AUTH_SERVICE_URL=http://auth.example.com:9000 \ +yarn dev +``` + +### Test Underscore-Based ENV Resolution + +Type Config also supports underscore-based environment variable resolution (priority 200): + +```bash +DATABASES_POOL_MIN=5 \ +DATABASES_POOL_MAX=20 \ +yarn dev +``` + +Notice the pool settings change. Underscore-based ENV vars override file values. + +**โš ๏ธ Known Limitation with Kebab-Case Keys**: + +Underscore-based ENV resolution doesn't work correctly with kebab-case map keys: + +```bash +# โŒ This DOESN'T work - creates wrong path (databases.connections.serhafen.us.host) +DATABASES_CONNECTIONS_SERHAFEN_US_HOST=override-host + +# โœ… Use explicit placeholder instead +DB_US_HOST=override-host # Requires: host: ${DB_US_HOST:localhost} in YAML +``` + +For map keys with hyphens, always use explicit placeholders in your YAML. + +If the file has a placeholder, the underscore-based ENV can set the value that gets resolved: + +```bash +# File has: username: ${DB_US_USERNAME:postgres} +# This sets the value that the placeholder resolves to +DB_US_USERNAME=myuser \ +yarn dev +``` + +## 3. Test Production Profile + +Run with production profile to see different placeholders: + +```bash +NODE_ENV=production yarn dev +``` + +**Note**: This will fail because production requires certain ENV vars without fallbacks: +- `PROD_DB_US_PASSWORD` +- `PROD_DB_AG_PASSWORD` +- `PROD_DB_ANALYTICS_PASSWORD` + +### Run Production with Required Variables + +```bash +PROD_DB_US_PASSWORD=secret1 \ +PROD_DB_AG_PASSWORD=secret2 \ +PROD_DB_ANALYTICS_PASSWORD=secret3 \ +NODE_ENV=production yarn dev +``` + +Now it works! Notice: +- Different hosts (prod-db-us.example.com instead of localhost) +- SSL enabled +- Different pool settings +- Different service URLs + +## 4. Test the API + +Once the server is running, open another terminal and test the endpoints: + +### View All Configuration + +```bash +curl http://localhost:3000/config | jq +``` + +### Get Specific Database Connection + +```bash +curl http://localhost:3000/database/serhafen-us | jq +curl http://localhost:3000/database/serhafen-ag | jq +curl http://localhost:3000/database/analytics | jq +``` + +### Get Specific Service Endpoint + +```bash +curl http://localhost:3000/service/auth-service | jq +curl http://localhost:3000/service/payment-service | jq +curl http://localhost:3000/service/notification-service | jq +``` + +### Test Non-Existent Keys + +```bash +curl http://localhost:3000/database/nonexistent | jq +# Returns: {"error": "Database connection 'nonexistent' not found"} +``` + +## 5. Experiment with Placeholders + +### Test Fallback Values + +Create a test with missing ENV var: + +```bash +# DB_MISSING is not set, so fallback "fallback-value" is used +# Edit application.yml temporarily to add: test: ${DB_MISSING:fallback-value} +``` + +### Test Without Fallback + +```bash +# This will make the field undefined (validation may fail if required) +# Edit application.yml temporarily to add: test: ${DB_MISSING} +``` + +### Test Multiple Placeholders in One Value + +Edit `application.yml` temporarily: + +```yaml +server: + name: ${APP_NAME:myapp}-${ENV:dev}-${VERSION:1.0} +``` + +Then run: + +```bash +APP_NAME=testapp ENV=staging VERSION=2.0 yarn dev +# Server name will be: testapp-staging-2.0 +``` + +## 6. Understanding Precedence + +### Scenario 1: Underscore-Based ENV Override + +Base config: `host: localhost` +ENV vars: `DATABASES_CONNECTIONS_SERHAFEN_US_HOST=prod-server` + +Result: `host: "prod-server"` (underscore-based ENV overrides file) + +### Scenario 2: Placeholder Resolution + +Base config: `username: ${DB_USERNAME:postgres}` +ENV vars: `DB_USERNAME=myuser` + +Result: `username: "myuser"` (placeholder resolves to ENV var) + +### Scenario 3: Profile Override with Placeholder + +Base config: `username: ${DB_USERNAME:postgres}` +Production config: `username: ${PROD_DB_USERNAME:prod_user}` +ENV vars: `PROD_DB_USERNAME=actual_prod` + +Result in production: `username: "actual_prod"` (profile overrides base, then placeholder resolves) + +### Scenario 4: Underscore-Based with Placeholder + +Base config: `url: ${API_URL:http://localhost}` +ENV vars: `DATABASES_CONNECTIONS_SERHAFEN_US_URL=http://prod`, `API_URL=http://staging` + +Result: `url: "http://prod"` (underscore-based ENV sets the value, which contains no placeholder) + +## 7. Common Use Cases + +### Multi-Region Databases + +```bash +DB_US_HOST=us-east-1.rds.amazonaws.com \ +DB_AG_HOST=eu-central-1.rds.amazonaws.com \ +DB_ANALYTICS_HOST=analytics.internal.com \ +yarn dev +``` + +### Service Discovery + +```bash +AUTH_SERVICE_URL=http://auth-service:8001 \ +PAYMENT_SERVICE_URL=http://payment-service:8002 \ +NOTIFICATION_SERVICE_URL=http://notification-service:8003 \ +yarn dev +``` + +### Feature Flags + +```bash +FEATURE_NEW_UI=true \ +FEATURE_BETA=true \ +yarn dev +``` + +## 8. Troubleshooting + +### Missing Fields in Map Entries + +If you remove a field from a map entry (e.g., remove `port` from a database connection), the application will still start but you'll see a warning: + +``` +โš ๏ธ Database 'serhafen-ag' missing fields: port +``` + +This is because `validateOnBind: false` is set. The manual validation in `main.ts` catches these issues and logs warnings. + +### "Required configuration property is missing" + +This means the entire Map is missing (e.g., no `connections` property at all). The `@Required()` decorator only validates that the Map exists, not its contents. + +### Port already in use + +```bash +SERVER_PORT=3001 yarn dev +``` + +## Next Steps + +1. Read the full [README.md](./README.md) for detailed documentation +2. **Compare Map vs Record** in [MAP_VS_RECORD.md](./MAP_VS_RECORD.md) to choose the best approach +3. Explore the configuration classes in `src/config/` +4. Modify the YAML files to test different scenarios +5. Check out the [Type Config documentation](../../README.md) + +## Tips + +- Use `.env` files for local development (copy from `.env.example`) +- Use explicit placeholders for important configuration +- Use underscore-based ENV for convenience overrides +- Always provide fallbacks for development, omit them for production secrets +- Use Map-based config for collections of similar entities diff --git a/examples/map-and-placeholders/README.md b/examples/map-and-placeholders/README.md new file mode 100644 index 0000000..62f92b6 --- /dev/null +++ b/examples/map-and-placeholders/README.md @@ -0,0 +1,379 @@ +# Map and Placeholders Example + +This example demonstrates two powerful features of Type Config: + +1. **Map-based Configuration Binding**: Bind configuration to `Map` properties for managing collections of similar entities +2. **Advanced Environment Variable Resolution**: Use `${VAR:fallback}` syntax with profile-aware precedence + +## โš ๏ธ Important Limitations + +**Automatic validation of Map/Record entries is NOT supported.** + +This is a fundamental limitation of class-validator, which requires known properties at compile time. Map and Record types have dynamic keys, so: + +- โœ… **What works**: Binding YAML/JSON to Map/Record, placeholder resolution, `@Required()` (checks if property exists) +- โŒ **What doesn't work**: Automatic validation of entry contents (port ranges, required fields within entries, etc.) +- โœ… **Solution**: Manual validation (see `main.ts` for example) + +If strict validation is critical, consider using individual properties instead of Map/Record. + +## Features Demonstrated + +### 1. Map-Based Configuration + +The example shows how to use `Map` for: +- **Multiple database connections** (`databases.connections`) +- **Service endpoints** (`services.endpoints`) + +This eliminates the need to define individual properties for each connection or service. + +### 2. Placeholder Resolution + +The example demonstrates: +- **Basic placeholders**: `${SERVER_HOST:localhost}` +- **Placeholders without fallbacks**: `${PROD_DB_US_PASSWORD}` (required in production) +- **Profile-specific overrides**: Different placeholders in `application-production.yml` +- **Multiple placeholders in one value**: Supported throughout +- **Nested structures**: Placeholders work in map values + +### 3. Precedence Rules + +The example shows the configuration resolution order: +1. **Underscore-based ENV variables** (priority 200, e.g., `DATABASES_POOL_MIN`) +2. **Profile-specific file values** (priority 150, can contain placeholders or literals) +3. **Base file values** (priority 100, can contain placeholders or literals) +4. **Placeholder resolution** (happens after merging, resolves `${VAR:fallback}`) +5. **Default values from decorators** (lowest priority) + +**Key Point**: Underscore-based ENV vars override file values, then placeholders in the merged result are resolved. + +## Project Structure + +``` +examples/map-and-placeholders/ +โ”œโ”€โ”€ config/ +โ”‚ โ”œโ”€โ”€ application.yml # Base configuration +โ”‚ โ””โ”€โ”€ application-production.yml # Production overrides +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ config/ +โ”‚ โ”‚ โ”œโ”€โ”€ database.config.ts # Map-based database config +โ”‚ โ”‚ โ”œโ”€โ”€ services.config.ts # Map-based services config +โ”‚ โ”‚ โ”œโ”€โ”€ server.config.ts # Server config with placeholders +โ”‚ โ”‚ โ””โ”€โ”€ features.config.ts # Feature flags with placeholders +โ”‚ โ”œโ”€โ”€ app.module.ts +โ”‚ โ”œโ”€โ”€ app.controller.ts +โ”‚ โ”œโ”€โ”€ app.service.ts +โ”‚ โ””โ”€โ”€ main.ts +โ”œโ”€โ”€ package.json +โ”œโ”€โ”€ tsconfig.json +โ””โ”€โ”€ README.md +``` + +## Configuration Files + +### application.yml (Base) + +```yaml +databases: + connections: + serhafen-us: + host: ${DB_US_HOST:localhost} + username: ${DB_US_USERNAME:postgres} + password: ${DB_US_PASSWORD:dev_password} + # ... more properties +``` + +### application-production.yml (Profile Override) + +```yaml +databases: + connections: + serhafen-us: + host: ${PROD_DB_US_HOST:prod-db-us.example.com} + username: ${PROD_DB_US_USERNAME:prod_user} + password: ${PROD_DB_US_PASSWORD} # No fallback - must be set! +``` + +## Running the Example + +### Development Mode (Default Profile) + +```bash +# Install dependencies +yarn install + +# Run with default configuration +yarn dev + +# Or with specific environment variables +DB_US_HOST=custom-host yarn dev +``` + +### Production Mode + +```bash +# Set required environment variables +export PROD_DB_US_PASSWORD=secret123 +export PROD_DB_AG_PASSWORD=secret456 +export PROD_DB_ANALYTICS_PASSWORD=secret789 + +# Run in production mode +NODE_ENV=production yarn dev +``` + +### Using Underscore-Based ENV Resolution + +Type Config also supports underscore-based environment variable resolution: + +```bash +# These will override configuration values +export DATABASES_POOL_MIN=5 +export DATABASES_POOL_MAX=20 +export DATABASES_CONNECTIONS_SERHAFEN_US_HOST=override-host + +yarn dev +``` + +**Note**: Underscore-based ENV variables have priority 200 and override file values. Placeholders are resolved after all sources are merged. + +**Known Limitation**: Underscore-based ENV resolution doesn't work correctly with kebab-case keys in maps (e.g., `serhafen-us`). Use explicit placeholders instead for map entries with hyphens in their keys. + +## API Endpoints + +Once running, you can access: + +- `GET /` - Welcome message +- `GET /config` - View all configuration (with masked passwords) +- `GET /database/:name` - Get specific database connection (e.g., `/database/serhafen-us`) +- `GET /service/:name` - Get specific service endpoint (e.g., `/service/auth-service`) + +## Example Output + +``` +=== Map and Placeholders Example === + +๐Ÿš€ Server: map-and-placeholders-app +๐Ÿ“ Profile: development +๐ŸŒ Host: localhost:3000 + +--- Database Connections (Map-based) --- + ๐Ÿ“Š serhafen-us: + Host: localhost:5432 + Database: serhafen_common (schema: us) + Username: postgres + SSL: false + ๐Ÿ“Š serhafen-ag: + Host: localhost:5432 + Database: serhafen_ag (schema: ag) + Username: postgres + SSL: false + ๐Ÿ“Š analytics: + Host: localhost:5432 + Database: analytics_db (schema: public) + Username: analytics_user + SSL: false + +--- Connection Pool Settings --- + Min: 2 + Max: 10 + Idle: 10000ms + +--- Service Endpoints (Map-based) --- + ๐Ÿ”— auth-service: + URL: http://localhost:8001 + Timeout: 5000ms + Retries: 3 + ๐Ÿ”— payment-service: + URL: http://localhost:8002 + Timeout: 10000ms + Retries: 5 + ๐Ÿ”— notification-service: + URL: http://localhost:8003 + Timeout: 3000ms + Retries: 2 + +--- Feature Flags (Placeholder-based) --- + ๐ŸŽจ New UI: false + ๐Ÿงช Beta Features: false + ๐Ÿ”ง Maintenance Mode: false +``` + +## Key Concepts + +### Map-Based Configuration + +```typescript +@ConfigurationProperties('databases') +export class DatabasesConfig { + @ConfigProperty('connections') + connections: Map; + + getConnection(name: string): DatabaseConnection | undefined { + return this.connections.get(name); + } +} +``` + +The `connections` property is automatically bound as a `Map` from the YAML structure. + +### Placeholder Syntax + +- `${VAR}` - Use environment variable, undefined if not set +- `${VAR:fallback}` - Use environment variable, or fallback if not set +- `${VAR:}` - Use environment variable, or empty string if not set + +### Profile-Specific Overrides + +When using `NODE_ENV=production`: +1. Base `application.yml` is loaded +2. Profile-specific `application-production.yml` is loaded and merged +3. Profile-specific placeholders override base placeholders +4. All placeholders are resolved after merging +5. Underscore-based ENV resolution is applied last + +### Precedence Example + +**Example 1: Profile override with placeholder** +- Base config: `username: ${DB_USERNAME:postgres}` +- Production config: `username: ${PROD_DB_USERNAME:prod_user}` +- ENV vars: `PROD_DB_USERNAME=actual_prod` + +Result in production: `username: "actual_prod"` (profile file overrides base, then placeholder resolves) + +**Example 2: Underscore-based ENV override** +- Base config: `host: localhost` +- ENV vars: `DATABASES_CONNECTIONS_SERHAFEN_US_HOST=prod-server` + +Result: `host: "prod-server"` (underscore-based ENV overrides file value) + +## Validation + +### Simple Properties (Works) + +For simple configuration properties, validation works as expected: + +```typescript +@ConfigurationProperties('server') +export class ServerConfig { + @ConfigProperty() + @Required() + host: string; + + @ConfigProperty() + @DefaultValue(3000) + port: number = 3000; +} +``` + +### Map/Record Properties (Manual Validation Required) + +**This example uses `validateOnBind: false`** because automatic validation doesn't work for Map/Record types. + +The `@Required()` decorator only validates that the Map/Record property exists, NOT the contents: + +```typescript +@ConfigProperty('connections') +@Required() // โœ… Checks if 'connections' exists +connections: Map; // โŒ Doesn't validate entries +``` + +**What this means**: +- Missing fields in entries (e.g., no `port`) won't cause errors +- Invalid values (e.g., `port: 99999`) won't be caught +- You'll get `undefined` or invalid data at runtime + +**Manual Validation Pattern** (see `main.ts`): + +```typescript +const databasesConfig = configManager.bind(DatabasesConfig); + +// Validate each entry manually +for (const [name, conn] of databasesConfig.connections) { + if (!conn.host) { + throw new Error(`Database '${name}' missing host`); + } + if (!conn.port || conn.port < 1 || conn.port > 65535) { + throw new Error(`Database '${name}' invalid port: ${conn.port}`); + } + // ... more validation +} +``` + +**Alternative**: If validation is critical, use individual properties instead of Map/Record: + +```typescript +@ConfigurationProperties('databases') +class DatabasesConfig { + @ValidateNested() + @Type(() => DatabaseConnection) + primary: DatabaseConnection; // โœ… Validates automatically + + @ValidateNested() + @Type(() => DatabaseConnection) + replica: DatabaseConnection; // โœ… Validates automatically +} +``` + +## Map vs Record + +This example uses `Map`. An alternative is `Record` (plain object). + +**See [MAP_VS_RECORD.md](./MAP_VS_RECORD.md) for a detailed comparison.** + +Quick summary: +- **Map**: True Map type with `.get()`, `.set()` methods - no automatic validation +- **Record**: Plain object with bracket notation - no automatic validation either + +**Both require manual validation** due to class-validator limitations with dynamic keys. + +## Known Limitations + +### 1. Map/Record Entry Validation + +**Automatic validation of Map/Record entries is NOT supported.** + +This is a limitation of class-validator, which requires known properties at compile time. Since Map/Record have dynamic keys, validation must be done manually. + +**Impact**: +- Missing fields in entries won't cause startup errors +- Invalid values won't be caught automatically +- Runtime errors may occur if you don't validate manually + +**Solution**: See the manual validation example in `main.ts` + +### 2. Type Safety at Runtime + +TypeScript provides compile-time type safety, but runtime validation requires manual checks or a different structure (individual properties instead of Map/Record). + +## Error Handling + +### Missing Required Environment Variables + +If a placeholder has no fallback and the ENV var is not set: + +```yaml +password: ${PROD_DB_PASSWORD} # No fallback +``` + +The field becomes `undefined`. Since validation is disabled in this example, you'll need to check for undefined values manually. + +### Invalid Map Structures + +If configuration doesn't match the expected structure (e.g., wrong types), the binding may fail or produce unexpected results. Use TypeScript types and validation decorators to catch these issues early. + +## Best Practices + +1. **Use fallbacks for development**: `${VAR:dev_value}` +2. **Omit fallbacks for production secrets**: `${PROD_SECRET}` +3. **Use Map for collections**: Instead of `db1`, `db2`, `db3` properties +4. **Profile-specific placeholders**: Override which ENV vars are used per environment +5. **Understand precedence**: Underscore-based ENV (priority 200) overrides file values, then placeholders resolve +6. **Use placeholders for custom ENV var names**: `${CUSTOM_VAR}` instead of relying on `PATH_TO_PROPERTY` convention +7. **Use underscore-based for quick overrides**: Set `DATABASE_HOST` to override `database.host` without changing files + +## Learn More + +- [Map vs Record Comparison](./MAP_VS_RECORD.md) - Choose the right approach for your needs +- [Type Config Documentation](../../README.md) +- [Placeholder Resolution Guide](../../packages/core/PLACEHOLDER_RESOLUTION.md) +- [Configuration Files Guide](../../packages/core/CONFIG_FILES.md) diff --git a/examples/map-and-placeholders/VALIDATION_LIMITATIONS.md b/examples/map-and-placeholders/VALIDATION_LIMITATIONS.md new file mode 100644 index 0000000..76928ef --- /dev/null +++ b/examples/map-and-placeholders/VALIDATION_LIMITATIONS.md @@ -0,0 +1,258 @@ +# Map/Record Validation Limitations + +## Summary + +**Automatic validation of Map/Record entries is NOT supported in Type Config.** + +This is a fundamental limitation of class-validator, not a bug or missing feature. This document explains why and provides solutions. + +## Why Validation Doesn't Work + +### The Problem + +class-validator requires **known properties at compile time**. When you write: + +```typescript +class ServerConfig { + @IsString() + host: string; // โœ… Known property - validation works +} +``` + +class-validator knows to check the `host` property. + +But with Map/Record: + +```typescript +class DatabasesConfig { + @ValidateNested({ each: true }) + connections: Map; // โŒ Unknown keys - validation fails +} +``` + +class-validator doesn't know what keys will exist at runtime (`serhafen-us`, `serhafen-ag`, etc.), so it can't validate them. + +### What Actually Happens + +When you try to use `validateOnBind: true` with Map/Record: + +```typescript +const manager = new ConfigManager({ validateOnBind: true }); +const config = manager.bind(DatabasesConfig); +// โŒ Throws: "undefined: an unknown value was passed to the validate function" +``` + +This error means class-validator encountered something it doesn't understand (the Map/Record with dynamic keys). + +## What Works vs What Doesn't + +### โœ… What Works + +1. **Binding**: YAML/JSON โ†’ Map/Record conversion works perfectly +2. **Placeholder resolution**: `${VAR:fallback}` works in map values +3. **@Required()**: Validates that the Map/Record property exists +4. **Type conversion**: Objects are converted to Map correctly +5. **Access patterns**: `.get()` for Map, bracket notation for Record + +### โŒ What Doesn't Work + +1. **Entry validation**: `@ValidateNested({ each: true })` doesn't work +2. **Field validation**: `@IsString()`, `@IsNumber()` on entry fields +3. **Range validation**: `@Min()`, `@Max()` on entry fields +4. **Required fields**: Missing fields in entries won't cause errors +5. **Type checking**: Invalid types in entries won't be caught + +## Solutions + +### Solution 1: Manual Validation (Recommended) + +Validate entries manually in your application code: + +```typescript +const config = manager.bind(DatabasesConfig); + +// Validate each entry +for (const [name, conn] of config.connections) { + // Check required fields + if (!conn.host) { + throw new Error(`Database '${name}' missing host`); + } + + // Check types + if (typeof conn.port !== 'number') { + throw new Error(`Database '${name}' port must be a number`); + } + + // Check ranges + if (conn.port < 1 || conn.port > 65535) { + throw new Error(`Database '${name}' invalid port: ${conn.port}`); + } + + // Check patterns + if (!conn.host.match(/^[a-z0-9.-]+$/)) { + throw new Error(`Database '${name}' invalid host format`); + } +} +``` + +### Solution 2: Helper Function + +Create a reusable validation function: + +```typescript +function validateDatabaseConnection(name: string, conn: DatabaseConnection): void { + const errors: string[] = []; + + if (!conn.host) errors.push('missing host'); + if (!conn.port) errors.push('missing port'); + if (conn.port < 1 || conn.port > 65535) errors.push('invalid port range'); + if (!conn.username) errors.push('missing username'); + if (!conn.password) errors.push('missing password'); + + if (errors.length > 0) { + throw new Error(`Database '${name}' validation failed: ${errors.join(', ')}`); + } +} + +// Use it +for (const [name, conn] of config.connections) { + validateDatabaseConnection(name, conn); +} +``` + +### Solution 3: Use Individual Properties + +If validation is critical and you have a fixed set of connections: + +```typescript +@ConfigurationProperties('databases') +class DatabasesConfig { + @ValidateNested() + @Type(() => DatabaseConnection) + @IsNotEmpty() + primary: DatabaseConnection; // โœ… Validates automatically + + @ValidateNested() + @Type(() => DatabaseConnection) + @IsNotEmpty() + replica: DatabaseConnection; // โœ… Validates automatically + + @ValidateNested() + @Type(() => DatabaseConnection) + @IsOptional() + analytics?: DatabaseConnection; // โœ… Validates if present +} +``` + +This works because the properties are known at compile time. + +### Solution 4: Use a Different Validation Library + +Consider using a validation library that supports dynamic keys: + +- **Zod**: Supports `z.record()` for dynamic keys +- **Yup**: Supports dynamic object validation +- **Joi**: Supports pattern-based validation + +Example with Zod: + +```typescript +import { z } from 'zod'; + +const DatabaseConnectionSchema = z.object({ + host: z.string().min(1), + port: z.number().min(1).max(65535), + username: z.string().min(1), + password: z.string().min(1), + database: z.string().min(1), + schema: z.string().min(1), + ssl: z.boolean(), +}); + +const DatabasesConfigSchema = z.object({ + connections: z.record(DatabaseConnectionSchema), // โœ… Validates dynamic keys! +}); + +// Validate +const config = manager.bind(DatabasesConfig); +DatabasesConfigSchema.parse(config); // Throws if invalid +``` + +## Best Practices + +1. **Always use `validateOnBind: false`** with Map/Record types +2. **Implement manual validation** in your bootstrap/startup code +3. **Fail fast**: Validate at startup, not at runtime +4. **Provide clear error messages**: Include the entry name in errors +5. **Document validation requirements**: Make it clear what's expected +6. **Consider alternatives**: If validation is critical, use individual properties + +## Example: Complete Validation Pattern + +```typescript +// 1. Configuration class (no validation decorators on Map/Record) +@ConfigurationProperties('databases') +class DatabasesConfig { + @ConfigProperty('connections') + @Required() // โœ… Only validates that property exists + connections: Map; +} + +// 2. Validation function +function validateDatabaseConnections(config: DatabasesConfig): void { + if (config.connections.size === 0) { + throw new Error('At least one database connection is required'); + } + + for (const [name, conn] of config.connections) { + // Validate each field + if (!conn.host) throw new Error(`Database '${name}' missing host`); + if (!conn.port) throw new Error(`Database '${name}' missing port`); + if (conn.port < 1 || conn.port > 65535) { + throw new Error(`Database '${name}' invalid port: ${conn.port}`); + } + if (!conn.username) throw new Error(`Database '${name}' missing username`); + if (!conn.password) throw new Error(`Database '${name}' missing password`); + if (!conn.database) throw new Error(`Database '${name}' missing database`); + if (!conn.schema) throw new Error(`Database '${name}' missing schema`); + if (typeof conn.ssl !== 'boolean') { + throw new Error(`Database '${name}' ssl must be boolean`); + } + } +} + +// 3. Bootstrap with validation +async function bootstrap() { + const manager = new ConfigManager({ + validateOnBind: false, // โœ… Disable automatic validation + }); + + await manager.initialize(); + + const config = manager.bind(DatabasesConfig); + + // โœ… Manual validation + validateDatabaseConnections(config); + + // Now safe to use + const app = await NestFactory.create(AppModule); + await app.listen(3000); +} +``` + +## Why We Removed validateMapEntries() + +The `MapBinder.validateMapEntries()` method was removed because: + +1. **It didn't work properly**: It threw confusing errors +2. **It was misleading**: Suggested validation was supported when it wasn't +3. **It was inconsistent**: Worked differently than class-validator +4. **Manual validation is clearer**: Explicit is better than implicit + +## Conclusion + +Map/Record types are excellent for **structure and binding**, but require **manual validation**. + +This is not a limitation of Type Config - it's a fundamental limitation of class-validator's design. The library now clearly documents this limitation and provides patterns for manual validation. + +**Key Takeaway**: Use `validateOnBind: false` with Map/Record and implement manual validation in your application code. diff --git a/examples/map-and-placeholders/config/application-production.yml b/examples/map-and-placeholders/config/application-production.yml new file mode 100644 index 0000000..aec14e4 --- /dev/null +++ b/examples/map-and-placeholders/config/application-production.yml @@ -0,0 +1,58 @@ +# Production profile overrides +server: + host: 0.0.0.0 + port: ${PROD_PORT:8080} + name: ${PROD_APP_NAME:map-and-placeholders-prod} + +# Production database connections - override with different placeholders +databases: + connections: + serhafen-us: + host: ${PROD_DB_US_HOST:prod-db-us.example.com} + port: 5432 + username: ${PROD_DB_US_USERNAME:prod_user} +# password: ${PROD_DB_US_PASSWORD} # No fallback - must be set in production + ssl: true + + serhafen-ag: + host: ${PROD_DB_AG_HOST:prod-db-ag.example.com} + port: 5432 + username: ${PROD_DB_AG_USERNAME:prod_user} +# password: ${PROD_DB_AG_PASSWORD} # No fallback - must be set in production + ssl: true + + analytics: + host: ${PROD_DB_ANALYTICS_HOST:prod-analytics.example.com} + port: 5432 + username: ${PROD_DB_ANALYTICS_USERNAME:analytics_prod} +# password: ${PROD_DB_ANALYTICS_PASSWORD} # No fallback - must be set in production + ssl: true + + pool: + min: ${PROD_DB_POOL_MIN:5} + max: ${PROD_DB_POOL_MAX:50} + idle: ${PROD_DB_POOL_IDLE:30000} + +# Production service endpoints +services: + endpoints: + auth-service: + url: ${PROD_AUTH_SERVICE_URL:https://auth.example.com} + timeout: ${PROD_AUTH_SERVICE_TIMEOUT:3000} + retries: 5 + + payment-service: + url: ${PROD_PAYMENT_SERVICE_URL:https://payment.example.com} + timeout: ${PROD_PAYMENT_SERVICE_TIMEOUT:15000} + retries: 10 + + notification-service: + url: ${PROD_NOTIFICATION_SERVICE_URL:https://notifications.example.com} + timeout: ${PROD_NOTIFICATION_SERVICE_TIMEOUT:5000} + retries: 3 + +# Production feature flags +features: + enableNewUI: ${PROD_FEATURE_NEW_UI:true} + enableBetaFeatures: ${PROD_FEATURE_BETA:false} + maintenanceMode: ${PROD_MAINTENANCE_MODE:false} diff --git a/examples/map-and-placeholders/config/application.yml b/examples/map-and-placeholders/config/application.yml new file mode 100644 index 0000000..7d3abeb --- /dev/null +++ b/examples/map-and-placeholders/config/application.yml @@ -0,0 +1,68 @@ +# Base configuration with placeholders and map structures +server: + host: ${SERVER_HOST:localhost} + port: ${SERVER_PORT:3000} + name: ${APP_NAME:map-and-placeholders-app} + +# Map-based database connections configuration +databases: + connections: + # US region database + serhafen-us: + host: ${DB_US_HOST:localhost} + port: ${DB_US_PORT:5432} + username: ${DB_US_USERNAME:postgres} + password: ${DB_US_PASSWORD:dev_password} + database: serhafen_common + schema: us + ssl: false + + # AG region database + serhafen-ag: + host: ${DB_AG_HOST:localhost} + port: ${DB_AG_PORT:5432} + username: ${DB_AG_USERNAME:postgres} + password: ${DB_AG_PASSWORD:dev_password} + database: serhafen_ag + schema: ag + ssl: false + + # Analytics database + analytics: + host: ${DB_ANALYTICS_HOST:localhost} + port: ${DB_ANALYTICS_PORT:5432} + username: ${DB_ANALYTICS_USERNAME:analytics_user} + password: ${DB_ANALYTICS_PASSWORD:analytics_pass} + database: analytics_db + schema: public + ssl: false + + # Connection pool settings + pool: + min: ${DB_POOL_MIN:2} + max: ${DB_POOL_MAX:10} + idle: ${DB_POOL_IDLE:10000} + +# Map-based service endpoints +services: + endpoints: + auth-service: + url: ${AUTH_SERVICE_URL:http://localhost:8001} + timeout: ${AUTH_SERVICE_TIMEOUT:5000} + retries: 3 + + payment-service: + url: ${PAYMENT_SERVICE_URL:http://localhost:8002} + timeout: ${PAYMENT_SERVICE_TIMEOUT:10000} + retries: 5 + + notification-service: + url: ${NOTIFICATION_SERVICE_URL:http://localhost:8003} + timeout: ${NOTIFICATION_SERVICE_TIMEOUT:3000} + retries: 2 + +# Feature flags with placeholders +features: + enableNewUI: ${FEATURE_NEW_UI:false} + enableBetaFeatures: ${FEATURE_BETA:false} + maintenanceMode: ${MAINTENANCE_MODE:false} diff --git a/examples/map-and-placeholders/package.json b/examples/map-and-placeholders/package.json new file mode 100644 index 0000000..4e13307 --- /dev/null +++ b/examples/map-and-placeholders/package.json @@ -0,0 +1,29 @@ +{ + "name": "map-and-placeholders-example", + "version": "1.0.0", + "private": true, + "description": "Example demonstrating Map-based configuration and placeholder resolution", + "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/map-and-placeholders/src/app.controller.ts b/examples/map-and-placeholders/src/app.controller.ts new file mode 100644 index 0000000..a96826a --- /dev/null +++ b/examples/map-and-placeholders/src/app.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getRoot(): string { + return 'Map and Placeholders Example - Visit /config for configuration info'; + } + + @Get('config') + getConfig(): any { + return this.appService.getConfigInfo(); + } + + @Get('database/:name') + getDatabaseConnection(@Param('name') name: string): any { + return this.appService.getDatabaseConnection(name); + } + + @Get('service/:name') + getServiceEndpoint(@Param('name') name: string): any { + return this.appService.getServiceEndpoint(name); + } +} diff --git a/examples/map-and-placeholders/src/app.module.ts b/examples/map-and-placeholders/src/app.module.ts new file mode 100644 index 0000000..21e7129 --- /dev/null +++ b/examples/map-and-placeholders/src/app.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeConfigModule } from '@snow-tzu/type-config-nestjs'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import * as path from 'path'; + +@Module({ + imports: [ + TypeConfigModule.forRoot({ + configDir: path.join(__dirname, '../config'), + profile: process.env.NODE_ENV || 'development', + enableHotReload: false, + validateOnBind: true, + }), + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/examples/map-and-placeholders/src/app.service.ts b/examples/map-and-placeholders/src/app.service.ts new file mode 100644 index 0000000..68d0c0a --- /dev/null +++ b/examples/map-and-placeholders/src/app.service.ts @@ -0,0 +1,65 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { ConfigManager, CONFIG_MANAGER_TOKEN } from '@snow-tzu/type-config-nestjs'; +import { DatabasesConfig } from './config/database.config'; +import { ServicesConfig } from './config/services.config'; +import { FeaturesConfig } from './config/features.config'; + +@Injectable() +export class AppService { + private databasesConfig: DatabasesConfig; + private servicesConfig: ServicesConfig; + private featuresConfig: FeaturesConfig; + + constructor( + @Inject(CONFIG_MANAGER_TOKEN) private configManager: ConfigManager, + ) { + // Bind configuration classes + this.databasesConfig = this.configManager.bind(DatabasesConfig); + this.servicesConfig = this.configManager.bind(ServicesConfig); + this.featuresConfig = this.configManager.bind(FeaturesConfig); + } + + getConfigInfo(): any { + return { + profile: this.configManager.getProfile(), + databases: { + connectionNames: this.databasesConfig.getConnectionNames(), + connections: Object.fromEntries(this.databasesConfig.connections), + pool: this.databasesConfig.pool, + }, + services: { + serviceNames: this.servicesConfig.getServiceNames(), + endpoints: Object.fromEntries(this.servicesConfig.endpoints), + }, + features: { + enableNewUI: this.featuresConfig.enableNewUI, + enableBetaFeatures: this.featuresConfig.enableBetaFeatures, + maintenanceMode: this.featuresConfig.maintenanceMode, + }, + }; + } + + getDatabaseConnection(name: string): any { + const connection = this.databasesConfig.getConnection(name); + if (!connection) { + return { error: `Database connection '${name}' not found` }; + } + return { + name, + ...connection, + // Mask password for security + password: '***', + }; + } + + getServiceEndpoint(name: string): any { + const endpoint = this.servicesConfig.getEndpoint(name); + if (!endpoint) { + return { error: `Service endpoint '${name}' not found` }; + } + return { + name, + ...endpoint, + }; + } +} diff --git a/examples/map-and-placeholders/src/config/database-record.config.ts b/examples/map-and-placeholders/src/config/database-record.config.ts new file mode 100644 index 0000000..e7c425a --- /dev/null +++ b/examples/map-and-placeholders/src/config/database-record.config.ts @@ -0,0 +1,93 @@ +import { + ConfigurationProperties, + ConfigProperty, + Required, + RecordType, +} from '@snow-tzu/type-config-nestjs'; +import { IsString, IsNumber, IsBoolean, Min, Max } from 'class-validator'; +import { PoolConfig } from './database.config'; + +/** + * Database connection configuration for a single database + * + * Note: The class-validator decorators below are for documentation only. + * They do NOT provide automatic validation when used in a Record type. + * See main.ts for manual validation example. + */ +export 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; +} + +/** + * Alternative approach using Record instead of Map + * + * Trade-offs: + * - โœ… Plain object with bracket notation access + * - โœ… Works with Object.keys(), Object.entries() + * - โœ… JSON serialization works directly + * - โŒ Not a true Map (no Map methods like .get(), .set()) + * - โŒ Must use bracket notation: connections['serhafen-us'] + * - โŒ Automatic validation NOT supported (class-validator limitation) + * + * **IMPORTANT LIMITATION**: + * Automatic validation of Record entries does NOT work. This is a limitation of + * class-validator, which requires known properties at compile time. + * + * You must implement manual validation (see main.ts for example). + */ +@ConfigurationProperties('databases') +export class DatabasesRecordConfig { + /** + * Record of database connections by name + * + * Decorators explained: + * - @ConfigProperty: Maps to 'databases.connections' in YAML + * - @Required: Validates that the 'connections' property exists (not the entries) + * - @RecordType: Keeps this as a plain object (doesn't convert to Map) + * + * Note: Entry validation must be done manually - see main.ts + */ + @ConfigProperty('connections') + @Required() + @RecordType() + connections: Record; + + @ConfigProperty('pool') + @Required() + pool: PoolConfig; + + /** + * Helper method to get a specific database connection + */ + getConnection(name: string): DatabaseConnectionValidated | undefined { + return this.connections[name]; + } + + /** + * Helper method to list all available connection names + */ + getConnectionNames(): string[] { + return Object.keys(this.connections); + } +} diff --git a/examples/map-and-placeholders/src/config/database.config.ts b/examples/map-and-placeholders/src/config/database.config.ts new file mode 100644 index 0000000..57de5a7 --- /dev/null +++ b/examples/map-and-placeholders/src/config/database.config.ts @@ -0,0 +1,63 @@ +import { + ConfigurationProperties, + ConfigProperty, + Required, +} from '@snow-tzu/type-config-nestjs'; + +/** + * Database connection configuration for a single database + */ +export class DatabaseConnection { + host: string; + port: number; + username: string; + password: string; + database: string; + schema: string; + ssl: boolean; +} + +/** + * Connection pool configuration + */ +export class PoolConfig { + min: number; + max: number; + idle: number; +} + +/** + * Main databases configuration with Map-based connections + * Demonstrates map-based configuration binding + */ +@ConfigurationProperties('databases') +export class DatabasesConfig { + /** + * Map of database connections by name + * This demonstrates the Map binding feature + */ + @ConfigProperty('connections') + @Required() + connections: Map; + + /** + * Connection pool settings + */ + @ConfigProperty('pool') + @Required() + pool: PoolConfig; + + /** + * Helper method to get a specific database connection + */ + getConnection(name: string): DatabaseConnection | undefined { + return this.connections.get(name); + } + + /** + * Helper method to list all available connection names + */ + getConnectionNames(): string[] { + return Array.from(this.connections.keys()); + } +} diff --git a/examples/map-and-placeholders/src/config/features.config.ts b/examples/map-and-placeholders/src/config/features.config.ts new file mode 100644 index 0000000..e7ffbea --- /dev/null +++ b/examples/map-and-placeholders/src/config/features.config.ts @@ -0,0 +1,24 @@ +import { + ConfigurationProperties, + ConfigProperty, + DefaultValue, +} from '@snow-tzu/type-config-nestjs'; + +/** + * Feature flags configuration with placeholder resolution + * Demonstrates boolean placeholders with fallback values + */ +@ConfigurationProperties('features') +export class FeaturesConfig { + @ConfigProperty() + @DefaultValue(false) + enableNewUI: boolean = false; + + @ConfigProperty() + @DefaultValue(false) + enableBetaFeatures: boolean = false; + + @ConfigProperty() + @DefaultValue(false) + maintenanceMode: boolean = false; +} diff --git a/examples/map-and-placeholders/src/config/server.config.ts b/examples/map-and-placeholders/src/config/server.config.ts new file mode 100644 index 0000000..2f2d169 --- /dev/null +++ b/examples/map-and-placeholders/src/config/server.config.ts @@ -0,0 +1,25 @@ +import { + ConfigurationProperties, + ConfigProperty, + Required, + DefaultValue, +} from '@snow-tzu/type-config-nestjs'; + +/** + * Server configuration with placeholder resolution + * Demonstrates environment variable placeholders with fallback values + */ +@ConfigurationProperties('server') +export class ServerConfig { + @ConfigProperty() + @Required() + host: string; + + @ConfigProperty() + @DefaultValue(3000) + port: number = 3000; + + @ConfigProperty() + @Required() + name: string; +} diff --git a/examples/map-and-placeholders/src/config/services.config.ts b/examples/map-and-placeholders/src/config/services.config.ts new file mode 100644 index 0000000..3188235 --- /dev/null +++ b/examples/map-and-placeholders/src/config/services.config.ts @@ -0,0 +1,43 @@ +import { + ConfigurationProperties, + ConfigProperty, + Required, +} from '@snow-tzu/type-config-nestjs'; + +/** + * Service endpoint configuration + */ +export class ServiceEndpoint { + url: string; + timeout: number; + retries: number; +} + +/** + * Services configuration with Map-based endpoints + * Demonstrates map-based configuration for service discovery + */ +@ConfigurationProperties('services') +export class ServicesConfig { + /** + * Map of service endpoints by service name + * This demonstrates the Map binding feature for service endpoints + */ + @ConfigProperty('endpoints') + @Required() + endpoints: Map; + + /** + * Helper method to get a specific service endpoint + */ + getEndpoint(serviceName: string): ServiceEndpoint | undefined { + return this.endpoints.get(serviceName); + } + + /** + * Helper method to list all available services + */ + getServiceNames(): string[] { + return Array.from(this.endpoints.keys()); + } +} diff --git a/examples/map-and-placeholders/src/main.ts b/examples/map-and-placeholders/src/main.ts new file mode 100644 index 0000000..9b625cc --- /dev/null +++ b/examples/map-and-placeholders/src/main.ts @@ -0,0 +1,169 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { CONFIG_MANAGER_TOKEN, ConfigManager } from '@snow-tzu/type-config-nestjs'; +import { ServerConfig } from './config/server.config'; +import { ServicesConfig } from './config/services.config'; +import { FeaturesConfig } from './config/features.config'; +import { DatabasesRecordConfig, DatabaseConnectionValidated } from './config/database-record.config'; + +async function bootstrap() { + try { + const app = await NestFactory.create(AppModule); + + // Get ConfigManager from the DI container + const configManager = app.get(CONFIG_MANAGER_TOKEN); + + // Bind all configuration classes + const serverConfig = configManager.bind(ServerConfig); + const databasesConfig = configManager.bind(DatabasesRecordConfig); + const servicesConfig = configManager.bind(ServicesConfig); + const featuresConfig = configManager.bind(FeaturesConfig); + + // Optional: Manual validation for Map entries + // Since class-validator doesn't support Map validation, you can add custom checks + console.log('\n--- Validating Configuration ---'); + console.log('Connections type:', typeof databasesConfig.connections); + console.log('Is Map?', databasesConfig.connections instanceof Map); + console.log('Connections:', databasesConfig.connections); + + let validationErrors = 0; + + // Check if connections is actually a Map + if (!(databasesConfig.connections instanceof Map)) { + console.log('โš ๏ธ Warning: connections is not a Map, converting...'); + // If it's a plain object, convert to Map for iteration + const entries: [string, DatabaseConnectionValidated][] = Object.entries(databasesConfig.connections); + for (const [name, conn] of entries) { + const missing: string[] = []; + if (!conn.host) { + missing.push('host'); + } + if (!conn.port) { + missing.push('port'); + } + if (!conn.username) { + missing.push('username'); + } + if (!conn.password) { + missing.push('password'); + } + if (!conn.database) { + missing.push('database'); + } + if (!conn.schema) { + missing.push('schema'); + } + if (conn.ssl === undefined) { + missing.push('ssl'); + } + + if (missing.length > 0) { + console.log(` โš ๏ธ Database '${name}' missing fields: ${missing.join(', ')}`); + validationErrors++; + } + } + } else { + for (const [name, conn] of databasesConfig.connections) { + const missing: string[] = []; + if (!conn.host) { + missing.push('host'); + } + if (!conn.port) { + missing.push('port'); + } + if (!conn.username) { + missing.push('username'); + } + if (!conn.password) { + missing.push('password'); + } + if (!conn.database) { + missing.push('database'); + } + if (!conn.schema) { + missing.push('schema'); + } + if (conn.ssl === undefined) { + missing.push('ssl'); + } + + if (missing.length > 0) { + console.log(` โš ๏ธ Database '${name}' missing fields: ${missing.join(', ')}`); + validationErrors++; + } + } + + if (validationErrors > 0) { + console.log(`\nโš ๏ธ Found ${validationErrors} validation issue(s) in database connections`); + console.log('Note: This example has validateOnBind: false, so these are warnings only.\n'); + } else { + console.log(' โœ… All database connections have required fields\n'); + } + } + + // Display configuration information + console.log('\n=== Map and Placeholders Example ===\n'); + console.log(`๐Ÿš€ Server: ${serverConfig.name}`); + console.log(`๐Ÿ“ Profile: ${configManager.getProfile()}`); + console.log(`๐ŸŒ Host: ${serverConfig.host}:${serverConfig.port}`); + + console.log('\n--- Database Connections (Map-based) ---'); + const connectionNames = databasesConfig.getConnectionNames(); + connectionNames.forEach(name => { + const conn = databasesConfig.getConnection(name); + console.log(` ๐Ÿ“Š ${name}:`); + console.log(` Host: ${conn.host}:${conn.port}`); + console.log(` Database: ${conn.database} (schema: ${conn.schema})`); + console.log(` Username: ${conn.username}`); + console.log(` SSL: ${conn.ssl}`); + }); + + console.log('\n--- Connection Pool Settings ---'); + console.log(` Min: ${databasesConfig.pool.min}`); + console.log(` Max: ${databasesConfig.pool.max}`); + console.log(` Idle: ${databasesConfig.pool.idle}ms`); + + console.log('\n--- Service Endpoints (Map-based) ---'); + const serviceNames = servicesConfig.getServiceNames(); + serviceNames.forEach(name => { + const endpoint = servicesConfig.getEndpoint(name); + console.log(` ๐Ÿ”— ${name}:`); + console.log(` URL: ${endpoint.url}`); + console.log(` Timeout: ${endpoint.timeout}ms`); + console.log(` Retries: ${endpoint.retries}`); + }); + + console.log('\n--- Feature Flags (Placeholder-based) ---'); + console.log(` ๐ŸŽจ New UI: ${featuresConfig.enableNewUI}`); + console.log(` ๐Ÿงช Beta Features: ${featuresConfig.enableBetaFeatures}`); + console.log(` ๐Ÿ”ง Maintenance Mode: ${featuresConfig.maintenanceMode}`); + + console.log('\n--- Placeholder Resolution Examples ---'); + console.log('This example demonstrates:'); + console.log(' โœ“ ${VAR:fallback} syntax with fallback values'); + console.log(' โœ“ Profile-specific placeholder overrides'); + console.log(' โœ“ Map binding for collections'); + console.log(' โœ“ Nested object structures in maps'); + console.log(' โœ“ Underscore-based ENV resolution (e.g., DATABASES_POOL_MIN)'); + + console.log('\n--- API Endpoints ---'); + console.log(` GET http://${serverConfig.host}:${serverConfig.port}/config`); + console.log(` GET http://${serverConfig.host}:${serverConfig.port}/database/:name`); + console.log(` GET http://${serverConfig.host}:${serverConfig.port}/service/:name`); + + // Register onChange listener + configManager.onChange(_newConfig => { + console.log('\nโšก Configuration reloaded'); + }); + + // Start the application + await app.listen(serverConfig.port, serverConfig.host); + + console.log(`\nโœ… Application started successfully\n`); + } catch (error) { + console.error('โŒ Failed to start application:', error); + process.exit(1); + } +} + +bootstrap(); diff --git a/examples/map-and-placeholders/tsconfig.json b/examples/map-and-placeholders/tsconfig.json new file mode 100644 index 0000000..d2e3bae --- /dev/null +++ b/examples/map-and-placeholders/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": 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..b45b40e 100644 --- a/packages/core/CONFIG_FILES.md +++ b/packages/core/CONFIG_FILES.md @@ -10,6 +10,7 @@ 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 @@ -317,6 +318,208 @@ 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 (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} + +# 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: + +```javascript +{ + 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 @@ -413,6 +616,83 @@ Error: Required configuration property 'database.host' is missing - โœ… Verify files are actually copied (not just linked) - โœ… Check Docker volume mounts +### Placeholder 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 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 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 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 profile file is loaded after 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: @@ -440,7 +720,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..a5e6590 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -45,6 +45,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 +67,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 @@ -151,6 +157,277 @@ database: **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 (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} + +# 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`: +```javascript +{ + 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! + +#### Complete Working Example + +See the [Map and Placeholders Example](../../examples/map-and-placeholders/) for a full working demonstration including: +- Multiple database connections with Map binding +- Service endpoints configuration +- Profile-specific placeholder overrides +- Manual validation patterns +- NestJS integration + ## API ### Decorators @@ -237,6 +514,8 @@ database: | 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..fd13eca 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.3", "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..3af59c5 100644 --- a/packages/core/src/config-manager.ts +++ b/packages/core/src/config-manager.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as chokidar from 'chokidar'; import { validateSync, ValidationError } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; import { CONFIG_PREFIX_KEY, CONFIG_PROPERTIES_KEY, @@ -9,6 +10,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 +21,7 @@ export interface ConfigManagerOptions { enableHotReload?: boolean; encryptionKey?: string; validateOnBind?: boolean; + enablePlaceholderResolution?: boolean; } export type ConfigChangeListener = (newConfig: Record) => void; @@ -35,10 +39,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 +62,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 +73,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 +86,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 +100,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 */ @@ -255,11 +286,19 @@ 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'); } @@ -272,6 +311,32 @@ export class ConfigManager { 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; + } + 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/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/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/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..dfd494d 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -39,6 +39,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 @@ -165,6 +167,89 @@ 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 @@ -189,6 +274,8 @@ database: | 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..4d0578f 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.3", "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..bfe548e 100644 --- a/packages/fastify/README.md +++ b/packages/fastify/README.md @@ -39,6 +39,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 @@ -164,6 +166,89 @@ 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 @@ -187,6 +272,8 @@ database: | 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..40ca8f7 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.3", "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..7bc654e 100644 --- a/packages/nestjs/README.md +++ b/packages/nestjs/README.md @@ -40,6 +40,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 @@ -154,6 +156,86 @@ server: For more, see the [Configuration File Management Guide](../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](../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](../../examples/map-and-placeholders/) for a full working NestJS application. + ## Usage Patterns ### Global and Module-Scoped Configuration @@ -318,6 +400,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..00d0d22 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.3", "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/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..e5015f1 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.3", "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..2a1f711 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.3", "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..2f4912f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7232,6 +7232,25 @@ __metadata: languageName: node linkType: hard +"map-and-placeholders-example@workspace:examples/map-and-placeholders": + version: 0.0.0-use.local + resolution: "map-and-placeholders-example@workspace:examples/map-and-placeholders" + 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 + "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" From 3ed00eeaecc1e387cc12c22301df6903126a5b7c Mon Sep 17 00:00:00 2001 From: Ganesan Arunachalam Date: Mon, 1 Dec 2025 13:07:01 +0530 Subject: [PATCH 2/4] fix: nested config --- .github/workflows/ci.yml | 28 +- .github/workflows/publish.yml | 31 + .yarnrc.yml | 2 + examples/README.md | 26 + examples/nested-basic/.gitignore | 5 + examples/nested-basic/README.md | 185 ++++ .../config/application-production.yml | 25 + examples/nested-basic/config/application.yml | 3 + examples/nested-basic/config/temporary.yml | 22 + examples/nested-basic/package.json | 29 + examples/nested-basic/src/app.controller.ts | 17 + examples/nested-basic/src/app.module.ts | 32 + examples/nested-basic/src/app.service.ts | 65 ++ .../nested-basic/src/config/api.config.ts | 24 + .../nested-basic/src/config/app.config.ts | 37 + .../nested-basic/src/config/cache.config.ts | 30 + .../src/config/database.config.ts | 42 + .../nested-basic/src/config/pool.config.ts | 19 + .../nested-basic/src/config/server.config.ts | 35 + .../src/config/services.config.ts | 24 + .../nested-basic/src/config/ssl.config.ts | 17 + examples/nested-basic/src/main.ts | 46 + examples/nested-basic/tsconfig.json | 22 + packages/core/README.md | 362 +++++++ packages/core/package.json | 2 +- packages/core/src/config-manager.ts | 228 +++- packages/core/test/helper-methods.spec.ts | 309 ++++++ .../core/test/nested-class-binding.spec.ts | 980 ++++++++++++++++++ .../test/nested-class-integration.spec.ts | 228 ++++ .../core/test/nested-class-validation.spec.ts | 564 ++++++++++ .../core/test/placeholder-undefined.spec.ts | 116 +++ packages/express/package.json | 2 +- packages/fastify/package.json | 2 +- packages/nestjs/package.json | 2 +- packages/remote/package.json | 2 +- packages/testing/package.json | 2 +- yarn.lock | 19 + 37 files changed, 3544 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 examples/nested-basic/.gitignore create mode 100644 examples/nested-basic/README.md create mode 100644 examples/nested-basic/config/application-production.yml create mode 100644 examples/nested-basic/config/application.yml create mode 100644 examples/nested-basic/config/temporary.yml create mode 100644 examples/nested-basic/package.json create mode 100644 examples/nested-basic/src/app.controller.ts create mode 100644 examples/nested-basic/src/app.module.ts create mode 100644 examples/nested-basic/src/app.service.ts create mode 100644 examples/nested-basic/src/config/api.config.ts create mode 100644 examples/nested-basic/src/config/app.config.ts create mode 100644 examples/nested-basic/src/config/cache.config.ts create mode 100644 examples/nested-basic/src/config/database.config.ts create mode 100644 examples/nested-basic/src/config/pool.config.ts create mode 100644 examples/nested-basic/src/config/server.config.ts create mode 100644 examples/nested-basic/src/config/services.config.ts create mode 100644 examples/nested-basic/src/config/ssl.config.ts create mode 100644 examples/nested-basic/src/main.ts create mode 100644 examples/nested-basic/tsconfig.json create mode 100644 packages/core/test/helper-methods.spec.ts create mode 100644 packages/core/test/nested-class-binding.spec.ts create mode 100644 packages/core/test/nested-class-integration.spec.ts create mode 100644 packages/core/test/nested-class-validation.spec.ts create mode 100644 packages/core/test/placeholder-undefined.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e49dc9..a83ce16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,30 +37,4 @@ jobs: 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 }} + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..afe5e42 --- /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..6b78a39 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1,3 @@ nodeLinker: node-modules + +npmAuthToken: "${NPM_AUTH_TOKEN}" diff --git a/examples/README.md b/examples/README.md index 9769867..23ba690 100644 --- a/examples/README.md +++ b/examples/README.md @@ -118,6 +118,32 @@ NestJS application demonstrating advanced Type Config features: Map-based config --- +### 7. 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 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/packages/core/README.md b/packages/core/README.md index a5e6590..65bfe5d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -428,6 +428,368 @@ See the [Map and Placeholders Example](../../examples/map-and-placeholders/) for - Manual validation patterns - NestJS integration +### 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 ### Decorators diff --git a/packages/core/package.json b/packages/core/package.json index fd13eca..8653113 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config", - "version": "0.0.3", + "version": "0.0.4", "description": "Core configuration management system with Spring Boot-like features", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/core/src/config-manager.ts b/packages/core/src/config-manager.ts index 3af59c5..942f4b4 100644 --- a/packages/core/src/config-manager.ts +++ b/packages/core/src/config-manager.ts @@ -1,7 +1,6 @@ import * as path from 'path'; import * as chokidar from 'chokidar'; import { validateSync, ValidationError } from 'class-validator'; -import { plainToInstance } from 'class-transformer'; import { CONFIG_PREFIX_KEY, CONFIG_PROPERTIES_KEY, @@ -226,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) @@ -239,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); } } @@ -304,9 +320,202 @@ export class ConfigManager { } /** - * 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 convertType(value: any, instance: any, propertyKey: string): any { + 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 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; @@ -337,6 +546,11 @@ export class ConfigManager { 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/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/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-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/express/package.json b/packages/express/package.json index 4d0578f..606d6bb 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config-express", - "version": "0.0.3", + "version": "0.0.4", "description": "Type Config integration for Express.js", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 40ca8f7..f7e2ab8 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config-fastify", - "version": "0.0.3", + "version": "0.0.4", "description": "Type Config plugin for Fastify", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 00d0d22..3226152 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config-nestjs", - "version": "0.0.3", + "version": "0.0.4", "description": "Type Config integration for NestJS with native DI support", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/remote/package.json b/packages/remote/package.json index e5015f1..1cfd19c 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config-remote", - "version": "0.0.3", + "version": "0.0.4", "description": "Remote configuration sources for Type Config (AWS Parameter Store, Consul, etcd)", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/testing/package.json b/packages/testing/package.json index 2a1f711..00beb06 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config-testing", - "version": "0.0.3", + "version": "0.0.4", "description": "Testing utilities for Type Config", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/yarn.lock b/yarn.lock index 2f4912f..f96db9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7582,6 +7582,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" From d5935112c3fb9b1eca61440d71747ea18d087761 Mon Sep 17 00:00:00 2001 From: Ganesan Arunachalam Date: Mon, 1 Dec 2025 13:08:55 +0530 Subject: [PATCH 3/4] fix it --- .github/workflows/ci.yml | 7 ++----- .github/workflows/publish.yml | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a83ce16..9b6bd07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: Build on: push: @@ -34,7 +34,4 @@ jobs: run: yarn build - name: Run tests - run: yarn test - - - + run: yarn test \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index afe5e42..1b7dcea 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,7 +6,7 @@ on: jobs: publish: runs-on: ubuntu-latest - + steps: - uses: actions/checkout@v5 From fcadfb7a550168297581758e834a16e508357361 Mon Sep 17 00:00:00 2001 From: Ganesan Arunachalam Date: Mon, 1 Dec 2025 14:35:14 +0530 Subject: [PATCH 4/4] fix: documentation --- .yarnrc.yml | 1 - examples/README.md | 41 +- examples/map-and-placeholders/.env.example | 72 ---- examples/map-and-placeholders/.gitignore | 5 - examples/map-and-placeholders/CHANGES.md | 149 ------- .../map-and-placeholders/MAP_VS_RECORD.md | 186 --------- .../MINIMAL_ANNOTATIONS.md | 242 ----------- examples/map-and-placeholders/QUICKSTART.md | 271 ------------- examples/map-and-placeholders/README.md | 379 ------------------ .../VALIDATION_LIMITATIONS.md | 258 ------------ .../config/application-production.yml | 58 --- .../config/application.yml | 68 ---- examples/map-and-placeholders/package.json | 29 -- .../src/app.controller.ts | 27 -- .../map-and-placeholders/src/app.module.ts | 19 - .../map-and-placeholders/src/app.service.ts | 65 --- .../src/config/database-record.config.ts | 93 ----- .../src/config/database.config.ts | 63 --- .../src/config/features.config.ts | 24 -- .../src/config/server.config.ts | 25 -- .../src/config/services.config.ts | 43 -- examples/map-and-placeholders/src/main.ts | 169 -------- examples/map-and-placeholders/tsconfig.json | 24 -- packages/core/CONFIG_FILES.md | 236 ++++++----- packages/core/README.md | 138 ++++--- packages/core/package.json | 2 +- packages/express/README.md | 33 +- packages/express/package.json | 2 +- packages/fastify/README.md | 42 +- packages/fastify/package.json | 2 +- packages/nestjs/README.md | 21 +- packages/nestjs/package.json | 2 +- packages/remote/README.md | 14 +- packages/remote/package.json | 2 +- packages/testing/package.json | 2 +- yarn.lock | 19 - 36 files changed, 265 insertions(+), 2561 deletions(-) delete mode 100644 examples/map-and-placeholders/.env.example delete mode 100644 examples/map-and-placeholders/.gitignore delete mode 100644 examples/map-and-placeholders/CHANGES.md delete mode 100644 examples/map-and-placeholders/MAP_VS_RECORD.md delete mode 100644 examples/map-and-placeholders/MINIMAL_ANNOTATIONS.md delete mode 100644 examples/map-and-placeholders/QUICKSTART.md delete mode 100644 examples/map-and-placeholders/README.md delete mode 100644 examples/map-and-placeholders/VALIDATION_LIMITATIONS.md delete mode 100644 examples/map-and-placeholders/config/application-production.yml delete mode 100644 examples/map-and-placeholders/config/application.yml delete mode 100644 examples/map-and-placeholders/package.json delete mode 100644 examples/map-and-placeholders/src/app.controller.ts delete mode 100644 examples/map-and-placeholders/src/app.module.ts delete mode 100644 examples/map-and-placeholders/src/app.service.ts delete mode 100644 examples/map-and-placeholders/src/config/database-record.config.ts delete mode 100644 examples/map-and-placeholders/src/config/database.config.ts delete mode 100644 examples/map-and-placeholders/src/config/features.config.ts delete mode 100644 examples/map-and-placeholders/src/config/server.config.ts delete mode 100644 examples/map-and-placeholders/src/config/services.config.ts delete mode 100644 examples/map-and-placeholders/src/main.ts delete mode 100644 examples/map-and-placeholders/tsconfig.json diff --git a/.yarnrc.yml b/.yarnrc.yml index 6b78a39..c6e883c 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,3 +1,2 @@ nodeLinker: node-modules -npmAuthToken: "${NPM_AUTH_TOKEN}" diff --git a/examples/README.md b/examples/README.md index 23ba690..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,32 +88,7 @@ Pure Node.js application (no framework) using Type Config Core. --- -### 6. Map and Placeholders (`map-and-placeholders/`) - -NestJS application demonstrating advanced Type Config features: Map-based configuration and placeholder resolution. - -**Features:** - -- **Map-based configuration binding**: Use `Map` for collections (databases, services) -- **Placeholder resolution**: `${VAR:fallback}` syntax with environment variables -- **Profile-specific placeholders**: Different ENV vars per environment -- **Precedence rules**: Explicit placeholders vs underscore-based ENV resolution -- **Nested structures**: Complex objects as map values -- **Comprehensive validation**: class-validator integration for map entries - -**Start:** `cd map-and-placeholders && yarn dev` - -**Production:** `NODE_ENV=production yarn dev` - -**Key Concepts:** -- Map binding eliminates repetitive property definitions -- Placeholders support fallback values for development -- Profile-specific configs can override which ENV vars are used -- Both explicit placeholders and underscore-based ENV resolution work together - ---- - -### 7. Nested Configuration Classes (`nested-basic/`) +### 6. Nested Configuration Classes (`nested-basic/`) NestJS application demonstrating nested configuration classes with full decorator support. @@ -261,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/map-and-placeholders/.env.example b/examples/map-and-placeholders/.env.example deleted file mode 100644 index 9459da5..0000000 --- a/examples/map-and-placeholders/.env.example +++ /dev/null @@ -1,72 +0,0 @@ -# Server Configuration -SERVER_HOST=localhost -SERVER_PORT=3000 -APP_NAME=map-and-placeholders-app - -# Database Connections - US Region -DB_US_HOST=localhost -DB_US_PORT=5432 -DB_US_USERNAME=postgres -DB_US_PASSWORD=dev_password - -# Database Connections - AG Region -DB_AG_HOST=localhost -DB_AG_PORT=5432 -DB_AG_USERNAME=postgres -DB_AG_PASSWORD=dev_password - -# Database Connections - Analytics -DB_ANALYTICS_HOST=localhost -DB_ANALYTICS_PORT=5432 -DB_ANALYTICS_USERNAME=analytics_user -DB_ANALYTICS_PASSWORD=analytics_pass - -# Connection Pool Settings -DB_POOL_MIN=2 -DB_POOL_MAX=10 -DB_POOL_IDLE=10000 - -# Service Endpoints -AUTH_SERVICE_URL=http://localhost:8001 -AUTH_SERVICE_TIMEOUT=5000 -PAYMENT_SERVICE_URL=http://localhost:8002 -PAYMENT_SERVICE_TIMEOUT=10000 -NOTIFICATION_SERVICE_URL=http://localhost:8003 -NOTIFICATION_SERVICE_TIMEOUT=3000 - -# Feature Flags -FEATURE_NEW_UI=false -FEATURE_BETA=false -MAINTENANCE_MODE=false - -# Production Environment Variables (when NODE_ENV=production) -# PROD_PORT=8080 -# PROD_APP_NAME=map-and-placeholders-prod -# PROD_DB_US_HOST=prod-db-us.example.com -# PROD_DB_US_USERNAME=prod_user -# PROD_DB_US_PASSWORD=secret123 -# PROD_DB_AG_HOST=prod-db-ag.example.com -# PROD_DB_AG_USERNAME=prod_user -# PROD_DB_AG_PASSWORD=secret456 -# PROD_DB_ANALYTICS_HOST=prod-analytics.example.com -# PROD_DB_ANALYTICS_USERNAME=analytics_prod -# PROD_DB_ANALYTICS_PASSWORD=secret789 -# PROD_DB_POOL_MIN=5 -# PROD_DB_POOL_MAX=50 -# PROD_DB_POOL_IDLE=30000 -# PROD_AUTH_SERVICE_URL=https://auth.example.com -# PROD_AUTH_SERVICE_TIMEOUT=3000 -# PROD_PAYMENT_SERVICE_URL=https://payment.example.com -# PROD_PAYMENT_SERVICE_TIMEOUT=15000 -# PROD_NOTIFICATION_SERVICE_URL=https://notifications.example.com -# PROD_NOTIFICATION_SERVICE_TIMEOUT=5000 -# PROD_FEATURE_NEW_UI=true -# PROD_FEATURE_BETA=false -# PROD_MAINTENANCE_MODE=false - -# Underscore-based ENV Resolution Examples -# These will override configuration values (lower precedence than explicit placeholders) -# DATABASES_POOL_MIN=5 -# DATABASES_POOL_MAX=20 -# DATABASES_CONNECTIONS_SERHAFEN_US_HOST=override-host -# SERVICES_ENDPOINTS_AUTH_SERVICE_URL=http://custom-auth:8001 diff --git a/examples/map-and-placeholders/.gitignore b/examples/map-and-placeholders/.gitignore deleted file mode 100644 index 85075a6..0000000 --- a/examples/map-and-placeholders/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -dist -*.log -.env -.DS_Store diff --git a/examples/map-and-placeholders/CHANGES.md b/examples/map-and-placeholders/CHANGES.md deleted file mode 100644 index 5a4accd..0000000 --- a/examples/map-and-placeholders/CHANGES.md +++ /dev/null @@ -1,149 +0,0 @@ -# Changes Made to Fix Issues - -## Issues Fixed - -### 1. Validation Error -**Problem**: `Validation failed for DatabasesConfig: undefined: an unknown value was passed to the validate function` - -**Root Cause**: The `@Validate()` decorator was trying to validate Map properties, but class-validator doesn't support validating Map types directly. - -**Solution**: -- Removed `@Validate()` decorator from config classes with Map properties -- Set `validateOnBind: false` in TypeConfigModule configuration -- Removed validation decorators from nested classes -- The config classes still use `@Required()` and `@DefaultValue()` for basic configuration management - -### 2. Precedence Documentation -**Problem**: Documentation incorrectly stated that explicit placeholders take precedence over underscore-based ENV resolution. - -**Root Cause**: Misunderstanding of the actual implementation. According to `PLACEHOLDER_RESOLUTION.md`, the resolution order is: -1. Load and merge all sources (underscore-based ENV has priority 200) -2. Resolve explicit placeholders in the merged result -3. Decrypt if needed - -**Solution**: Updated all documentation to reflect the correct precedence: -- **Underscore-based ENV variables** (priority 200) override file values -- **Profile-specific files** (priority 150) override base files -- **Base files** (priority 100) -- **Placeholder resolution** happens after merging -- **Default values** from decorators (lowest priority) - -## Files Modified - -### Configuration Classes (Simplified) -- `src/config/database.config.ts` - Removed `@Validate()` decorator and validation decorators -- `src/config/services.config.ts` - Removed `@Validate()` decorator and validation decorators -- `src/config/server.config.ts` - Removed unnecessary validation decorators -- `src/config/features.config.ts` - Removed unnecessary validation decorators -- `src/app.module.ts` - Set `validateOnBind: false` - -### Documentation (Corrected Precedence) -- `README.md` - Updated precedence rules and examples -- `QUICKSTART.md` - Updated precedence scenarios and examples - -## How It Works Now - -### Configuration Resolution Flow - -1. **Load Sources**: - - `application.yml` (priority 100) - - `application-production.yml` (priority 150, if NODE_ENV=production) - - EnvConfigSource (priority 200) - converts `DATABASE_HOST` โ†’ `database.host` - -2. **Merge by Priority**: - - Higher priority sources override lower priority - - EnvConfigSource (200) overrides profile files (150) which override base files (100) - -3. **Resolve Placeholders**: - - Scan merged config for `${VAR:fallback}` patterns - - Resolve each placeholder by looking up ENV var - - If ENV var exists: use its value - - If ENV var doesn't exist and fallback provided: use fallback - - If ENV var doesn't exist and no fallback: field becomes `undefined` - -4. **Bind to Classes**: - - Convert plain objects to Map instances where needed - - Apply default values from decorators - - Validate required fields - -### Example Scenarios - -#### Scenario 1: Underscore-Based Override -```yaml -# application.yml -database: - host: localhost -``` - -```bash -DATABASE_HOST=prod-server yarn dev -``` - -**Result**: `database.host = "prod-server"` (underscore-based ENV overrides file) - -#### Scenario 2: Placeholder Resolution -```yaml -# application.yml -database: - host: ${DB_HOST:localhost} -``` - -```bash -DB_HOST=prod-server yarn dev -``` - -**Result**: `database.host = "prod-server"` (placeholder resolves to ENV var) - -#### Scenario 3: Underscore-Based Sets Value with Placeholder -```yaml -# application.yml -database: - url: file-value -``` - -```bash -DATABASE_URL='postgres://${DB_USER:admin}@localhost/mydb' DB_USER=root yarn dev -``` - -**Result**: `database.url = "postgres://root@localhost/mydb"` - -**Explanation**: -1. EnvConfigSource sets `database.url = "postgres://${DB_USER:admin}@localhost/mydb"` (overrides file) -2. Placeholder resolution resolves `${DB_USER:admin}` to `root` - -## Testing - -The example should now run without validation errors: - -```bash -cd examples/map-and-placeholders -yarn dev -``` - -You should see output showing all database connections, service endpoints, and feature flags with their resolved values. - -## Key Takeaways - -1. **Underscore-based ENV resolution happens first** (priority 200) and can override file values -2. **Placeholder resolution happens after merging** and resolves `${VAR:fallback}` in the merged config -3. **Both mechanisms work together**: Underscore-based ENV can set values that contain placeholders -4. **Nested classes in Maps don't need validation decorators** - they're plain data classes -5. **Top-level config classes use `@Required()` and `@DefaultValue()`** for basic validation -6. **Map validation limitation**: `@Required()` only validates that the Map exists, not its contents -7. **Manual validation recommended**: For production, add custom validation logic for Map entries - -## Known Limitations - -### Map Entry Validation - -The `@Required()` decorator on the `connections` property only ensures the Map itself exists. It does NOT validate: -- Whether map entries have all required fields -- Whether field types are correct -- Whether field values are valid - -**Example**: If you remove `port` from a database connection in the YAML, the application will still start. The `port` field will be `undefined` at runtime. - -**Workaround**: The example includes manual validation in `main.ts` that checks each map entry and logs warnings for missing fields. For production use, you should: -1. Implement custom validation logic -2. Throw errors for invalid configurations -3. Or use a different structure if strict validation is critical diff --git a/examples/map-and-placeholders/MAP_VS_RECORD.md b/examples/map-and-placeholders/MAP_VS_RECORD.md deleted file mode 100644 index 158668e..0000000 --- a/examples/map-and-placeholders/MAP_VS_RECORD.md +++ /dev/null @@ -1,186 +0,0 @@ -# Map vs Record: Choosing the Right Approach - -This example demonstrates two approaches for map-based configuration: `Map` and `Record`. Each has trade-offs. - -## Comparison - -| Feature | Map | Record | -|---------|----------------|-------------------| -| **Validation** | โŒ Doesn't work with class-validator | โš ๏ธ API exists but not fully implemented yet | -| **Type Safety** | โœ… True Map type | โœ… Object with string keys | -| **Map Methods** | โœ… .get(), .set(), .has(), .delete() | โŒ Must use bracket notation | -| **Iteration** | โœ… for...of with .entries() | โœ… Object.keys(), Object.entries() | -| **JSON Serialization** | โŒ Requires conversion | โœ… Works directly | -| **Spec Compliance** | โœ… Matches spec requirements | โš ๏ธ Alternative approach | - -## Map Approach (Current Example) - -### Configuration Class - -```typescript -@ConfigurationProperties('databases') -export class DatabasesConfig { - @ConfigProperty('connections') - @Required() - connections: Map; -} -``` - -### Pros -- True Map type with all Map methods -- Cleaner API: `config.connections.get('serhafen-us')` -- Better for dynamic keys -- Matches the spec requirements - -### Cons -- **Validation doesn't work** - class-validator can't validate Map entries -- Must set `validateOnBind: false` -- Need manual validation for map entries -- More complex to serialize to JSON - -### Usage - -```typescript -const databasesConfig = configManager.bind(DatabasesConfig); - -// Access with Map methods -const usDb = databasesConfig.connections.get('serhafen-us'); - -// Iterate -for (const [name, conn] of databasesConfig.connections) { - console.log(`${name}: ${conn.host}`); -} - -// Manual validation required -for (const [name, conn] of databasesConfig.connections) { - if (!conn.host || !conn.port) { - throw new Error(`Invalid connection: ${name}`); - } -} -``` - -## Record Approach (Alternative) - -### Configuration Class - -```typescript -@ConfigurationProperties('databases') -@Validate() -export class DatabasesRecordConfig { - @ConfigProperty('connections') - @Required() - @RecordType() // Important: Prevents conversion to Map - @ValidateNested({ each: true }) - @Type(() => DatabaseConnectionValidated) - connections: Record; -} -``` - -**Important**: The `@RecordType()` decorator is required to tell the system to keep this as a plain object and not convert it to a Map. - -### Pros -- Cleaner validation API with `@Validate()` decorator -- Would validate each entry automatically (when implemented) -- Would show which entry failed (when implemented) -- Simpler JSON serialization -- No need to convert Map for HTTP responses - -### Cons -- **Validation not yet fully implemented** - @ValidateNested() doesn't work yet -- Not a true Map (no Map methods) -- Must use bracket notation: `connections['serhafen-us']` -- Less type-safe for dynamic keys -- Doesn't match spec requirements exactly - -### Usage - -```typescript -const databasesConfig = configManager.bind(DatabasesRecordConfig); - -// Access with bracket notation -const usDb = databasesConfig.connections['serhafen-us']; - -// Iterate -for (const [name, conn] of Object.entries(databasesConfig.connections)) { - console.log(`${name}: ${conn.host}`); -} - -// Validation happens automatically - no manual checks needed! -``` - -## Testing the Record Approach - -**Note**: Record validation is not yet fully implemented in the core library. The example shows the intended API, but validation won't work until the implementation is completed. - -When implemented, you would test it like this: - -1. **Update `app.module.ts`**: -```typescript -TypeConfigModule.forRoot({ - configDir: './config', - profile: process.env.NODE_ENV || 'development', - enableHotReload: false, - validateOnBind: true, // Enable validation -}) -``` - -2. **Use the Record config class**: -```typescript -import { DatabasesRecordConfig } from './config/database-record.config'; - -const databasesConfig = configManager.bind(DatabasesRecordConfig); -``` - -3. **Test validation** by removing a required field - this would throw an error when implemented: -```yaml -# config/application.yml -databases: - connections: - serhafen-us: - host: localhost - # Remove port to test validation - username: postgres - password: dev_password -``` - -Expected error (when implemented): -``` -Validation failed for DatabasesRecordConfig: -- connections.serhafen-us.port must be a number -``` - -## Recommendation - -### Use Map (Current Recommendation): -- โœ… Fully implemented and working -- โœ… True Map semantics (get, set, has, delete) -- โœ… Matches the spec requirements -- โœ… Dynamic key operations work well -- โš ๏ธ Requires manual validation -- โš ๏ธ Needs `Object.fromEntries()` for JSON serialization - -### Use Record (Future): -- โš ๏ธ Validation not yet fully implemented -- โœ… Would have automatic validation (when implemented) -- โœ… Simpler JSON serialization -- โŒ Not a true Map (no Map methods) -- โŒ Doesn't match spec exactly - -**For now, use Map with manual validation** until Record validation is fully implemented in the core library. - -## Future Enhancement - -Ideally, the core library would support validation for both Map and Record types. This would require: - -1. Detecting Map vs Record types -2. For Map: Converting to Record, validating, then converting back -3. For Record: Using existing class-validator support - -This would give users the best of both worlds: Map semantics with automatic validation. - -## Example Files - -- **Map approach**: `src/config/database.config.ts` (current example) -- **Record approach**: `src/config/database-record.config.ts` (alternative) - -Try both and choose what works best for your use case! diff --git a/examples/map-and-placeholders/MINIMAL_ANNOTATIONS.md b/examples/map-and-placeholders/MINIMAL_ANNOTATIONS.md deleted file mode 100644 index d1b6baf..0000000 --- a/examples/map-and-placeholders/MINIMAL_ANNOTATIONS.md +++ /dev/null @@ -1,242 +0,0 @@ -# Minimal Annotations Guide - -## What You Actually Need - -Here's what decorators are actually required vs optional for Map/Record types: - -### For Record Type - -```typescript -@ConfigurationProperties('databases') // โœ… Required - marks as config class -export class DatabasesRecordConfig { - - @ConfigProperty('connections') // โœ… Required - maps to YAML path - @Required() // โœ… Optional but useful - validates property exists - @RecordType() // โœ… Required - prevents Map conversion - connections: Record; -} -``` - -**That's it!** Only 3-4 decorators needed. - -### For Map Type - -```typescript -@ConfigurationProperties('databases') // โœ… Required - marks as config class -export class DatabasesMapConfig { - - @ConfigProperty('connections') // โœ… Required - maps to YAML path - @Required() // โœ… Optional but useful - validates property exists - connections: Map; -} -``` - -**Even simpler!** Only 2-3 decorators needed (no @RecordType needed for Map). - -## What You DON'T Need - -### โŒ Remove These (They Don't Work) - -```typescript -@Validate() // โŒ Remove - only works for simple properties -@ValidateNested({ each: true }) // โŒ Remove - doesn't work for dynamic keys -@Type(() => DatabaseConnection) // โŒ Remove - doesn't work for dynamic keys -``` - -### โŒ Remove These from Entry Classes Too - -```typescript -// In DatabaseConnection class -export class DatabaseConnection { - @IsString() // โŒ Remove - doesn't validate in Map/Record - host: string; - - @IsNumber() // โŒ Remove - doesn't validate in Map/Record - @Min(1) // โŒ Remove - doesn't validate in Map/Record - @Max(65535) // โŒ Remove - doesn't validate in Map/Record - port: number; -} -``` - -**Keep them only for documentation**, but understand they don't provide validation. - -## Comparison: Before vs After - -### โŒ Before (Misleading) - -```typescript -import { ValidateNested, IsString, IsNumber } from 'class-validator'; -import { Type } from 'class-transformer'; - -export class DatabaseConnection { - @IsString() - host: string; - - @IsNumber() - @Min(1) - @Max(65535) - port: number; -} - -@ConfigurationProperties('databases') -@Validate() // Doesn't work -export class DatabasesConfig { - @ConfigProperty('connections') - @Required() - @RecordType() - @ValidateNested({ each: true }) // Doesn't work - @Type(() => DatabaseConnection) // Doesn't work - connections: Record; -} -``` - -**Problems**: -- Suggests validation works (it doesn't) -- Extra imports needed -- Confusing for users - -### โœ… After (Honest) - -```typescript -// No class-validator imports needed! - -export class DatabaseConnection { - host: string; - port: number; - username: string; - password: string; -} - -@ConfigurationProperties('databases') -export class DatabasesConfig { - @ConfigProperty('connections') - @Required() - @RecordType() - connections: Record; -} -``` - -**Benefits**: -- Clear and simple -- No false expectations -- Fewer imports -- Honest about limitations - -## When to Use Each Decorator - -### @ConfigurationProperties(prefix) -**Always required** - Marks the class as a configuration class and sets the YAML path prefix. - -```typescript -@ConfigurationProperties('databases') // Maps to 'databases' in YAML -class DatabasesConfig { } -``` - -### @ConfigProperty(path) -**Always required** - Maps a property to a specific path in the configuration. - -```typescript -@ConfigProperty('connections') // Maps to 'databases.connections' in YAML -connections: Record; -``` - -### @Required() -**Optional but recommended** - Validates that the property exists (not the contents). - -```typescript -@Required() // Throws error if 'connections' is missing -connections: Record; -``` - -**What it checks**: Property exists -**What it doesn't check**: Entry contents, required fields in entries - -### @RecordType() -**Required for Record, not needed for Map** - Tells the system to keep as plain object. - -```typescript -@RecordType() // Keeps as plain object (don't convert to Map) -connections: Record; -``` - -Without this, the system might try to convert to Map. - -### @DefaultValue(value) -**Optional** - Provides a default value if the property is missing. - -```typescript -@DefaultValue({}) // Use empty object if missing -connections: Record; -``` - -## Complete Minimal Example - -```typescript -import { - ConfigurationProperties, - ConfigProperty, - Required, - RecordType, -} from '@snow-tzu/type-config-nestjs'; - -// Simple interface - no decorators needed -export interface DatabaseConnection { - host: string; - port: number; - username: string; - password: string; - database: string; - schema: string; - ssl: boolean; -} - -// Minimal config class -@ConfigurationProperties('databases') -export class DatabasesConfig { - @ConfigProperty('connections') - @Required() - @RecordType() - connections: Record; - - @ConfigProperty('pool') - @Required() - pool: { - min: number; - max: number; - idle: number; - }; -} - -// Manual validation (required) -function validateDatabaseConnections(config: DatabasesConfig): void { - for (const [name, conn] of Object.entries(config.connections)) { - if (!conn.host) throw new Error(`${name}: missing host`); - if (!conn.port) throw new Error(`${name}: missing port`); - if (conn.port < 1 || conn.port > 65535) { - throw new Error(`${name}: invalid port ${conn.port}`); - } - // ... more validation - } -} -``` - -## Summary - -**Minimal annotations for Record**: -1. `@ConfigurationProperties(prefix)` - Required -2. `@ConfigProperty(path)` - Required -3. `@Required()` - Optional but useful -4. `@RecordType()` - Required for Record - -**Minimal annotations for Map**: -1. `@ConfigurationProperties(prefix)` - Required -2. `@ConfigProperty(path)` - Required -3. `@Required()` - Optional but useful - -**Don't use**: -- `@Validate()` - Doesn't work for Map/Record -- `@ValidateNested()` - Doesn't work for dynamic keys -- `@Type()` - Doesn't work for dynamic keys -- class-validator decorators on entry classes - Don't validate - -**Remember**: Manual validation is required for Map/Record entries! diff --git a/examples/map-and-placeholders/QUICKSTART.md b/examples/map-and-placeholders/QUICKSTART.md deleted file mode 100644 index bf22a7b..0000000 --- a/examples/map-and-placeholders/QUICKSTART.md +++ /dev/null @@ -1,271 +0,0 @@ -# Quick Start Guide - -This guide will help you quickly test the Map and Placeholders example. - -## Prerequisites - -Make sure you're in the example directory: - -```bash -cd examples/map-and-placeholders -``` - -## 1. Basic Run (Development Mode) - -Run with default configuration and fallback values: - -```bash -yarn dev -``` - -You should see output showing: -- 3 database connections (serhafen-us, serhafen-ag, analytics) -- 3 service endpoints (auth-service, payment-service, notification-service) -- Feature flags -- All using fallback values from the YAML files - -## 2. Test with Environment Variables - -### Override a Single Value - -```bash -DB_US_HOST=custom-database.com yarn dev -``` - -Notice that only the `serhafen-us` host changes to `custom-database.com`. - -### Override Multiple Values - -```bash -DB_US_HOST=db1.example.com \ -DB_AG_HOST=db2.example.com \ -AUTH_SERVICE_URL=http://auth.example.com:9000 \ -yarn dev -``` - -### Test Underscore-Based ENV Resolution - -Type Config also supports underscore-based environment variable resolution (priority 200): - -```bash -DATABASES_POOL_MIN=5 \ -DATABASES_POOL_MAX=20 \ -yarn dev -``` - -Notice the pool settings change. Underscore-based ENV vars override file values. - -**โš ๏ธ Known Limitation with Kebab-Case Keys**: - -Underscore-based ENV resolution doesn't work correctly with kebab-case map keys: - -```bash -# โŒ This DOESN'T work - creates wrong path (databases.connections.serhafen.us.host) -DATABASES_CONNECTIONS_SERHAFEN_US_HOST=override-host - -# โœ… Use explicit placeholder instead -DB_US_HOST=override-host # Requires: host: ${DB_US_HOST:localhost} in YAML -``` - -For map keys with hyphens, always use explicit placeholders in your YAML. - -If the file has a placeholder, the underscore-based ENV can set the value that gets resolved: - -```bash -# File has: username: ${DB_US_USERNAME:postgres} -# This sets the value that the placeholder resolves to -DB_US_USERNAME=myuser \ -yarn dev -``` - -## 3. Test Production Profile - -Run with production profile to see different placeholders: - -```bash -NODE_ENV=production yarn dev -``` - -**Note**: This will fail because production requires certain ENV vars without fallbacks: -- `PROD_DB_US_PASSWORD` -- `PROD_DB_AG_PASSWORD` -- `PROD_DB_ANALYTICS_PASSWORD` - -### Run Production with Required Variables - -```bash -PROD_DB_US_PASSWORD=secret1 \ -PROD_DB_AG_PASSWORD=secret2 \ -PROD_DB_ANALYTICS_PASSWORD=secret3 \ -NODE_ENV=production yarn dev -``` - -Now it works! Notice: -- Different hosts (prod-db-us.example.com instead of localhost) -- SSL enabled -- Different pool settings -- Different service URLs - -## 4. Test the API - -Once the server is running, open another terminal and test the endpoints: - -### View All Configuration - -```bash -curl http://localhost:3000/config | jq -``` - -### Get Specific Database Connection - -```bash -curl http://localhost:3000/database/serhafen-us | jq -curl http://localhost:3000/database/serhafen-ag | jq -curl http://localhost:3000/database/analytics | jq -``` - -### Get Specific Service Endpoint - -```bash -curl http://localhost:3000/service/auth-service | jq -curl http://localhost:3000/service/payment-service | jq -curl http://localhost:3000/service/notification-service | jq -``` - -### Test Non-Existent Keys - -```bash -curl http://localhost:3000/database/nonexistent | jq -# Returns: {"error": "Database connection 'nonexistent' not found"} -``` - -## 5. Experiment with Placeholders - -### Test Fallback Values - -Create a test with missing ENV var: - -```bash -# DB_MISSING is not set, so fallback "fallback-value" is used -# Edit application.yml temporarily to add: test: ${DB_MISSING:fallback-value} -``` - -### Test Without Fallback - -```bash -# This will make the field undefined (validation may fail if required) -# Edit application.yml temporarily to add: test: ${DB_MISSING} -``` - -### Test Multiple Placeholders in One Value - -Edit `application.yml` temporarily: - -```yaml -server: - name: ${APP_NAME:myapp}-${ENV:dev}-${VERSION:1.0} -``` - -Then run: - -```bash -APP_NAME=testapp ENV=staging VERSION=2.0 yarn dev -# Server name will be: testapp-staging-2.0 -``` - -## 6. Understanding Precedence - -### Scenario 1: Underscore-Based ENV Override - -Base config: `host: localhost` -ENV vars: `DATABASES_CONNECTIONS_SERHAFEN_US_HOST=prod-server` - -Result: `host: "prod-server"` (underscore-based ENV overrides file) - -### Scenario 2: Placeholder Resolution - -Base config: `username: ${DB_USERNAME:postgres}` -ENV vars: `DB_USERNAME=myuser` - -Result: `username: "myuser"` (placeholder resolves to ENV var) - -### Scenario 3: Profile Override with Placeholder - -Base config: `username: ${DB_USERNAME:postgres}` -Production config: `username: ${PROD_DB_USERNAME:prod_user}` -ENV vars: `PROD_DB_USERNAME=actual_prod` - -Result in production: `username: "actual_prod"` (profile overrides base, then placeholder resolves) - -### Scenario 4: Underscore-Based with Placeholder - -Base config: `url: ${API_URL:http://localhost}` -ENV vars: `DATABASES_CONNECTIONS_SERHAFEN_US_URL=http://prod`, `API_URL=http://staging` - -Result: `url: "http://prod"` (underscore-based ENV sets the value, which contains no placeholder) - -## 7. Common Use Cases - -### Multi-Region Databases - -```bash -DB_US_HOST=us-east-1.rds.amazonaws.com \ -DB_AG_HOST=eu-central-1.rds.amazonaws.com \ -DB_ANALYTICS_HOST=analytics.internal.com \ -yarn dev -``` - -### Service Discovery - -```bash -AUTH_SERVICE_URL=http://auth-service:8001 \ -PAYMENT_SERVICE_URL=http://payment-service:8002 \ -NOTIFICATION_SERVICE_URL=http://notification-service:8003 \ -yarn dev -``` - -### Feature Flags - -```bash -FEATURE_NEW_UI=true \ -FEATURE_BETA=true \ -yarn dev -``` - -## 8. Troubleshooting - -### Missing Fields in Map Entries - -If you remove a field from a map entry (e.g., remove `port` from a database connection), the application will still start but you'll see a warning: - -``` -โš ๏ธ Database 'serhafen-ag' missing fields: port -``` - -This is because `validateOnBind: false` is set. The manual validation in `main.ts` catches these issues and logs warnings. - -### "Required configuration property is missing" - -This means the entire Map is missing (e.g., no `connections` property at all). The `@Required()` decorator only validates that the Map exists, not its contents. - -### Port already in use - -```bash -SERVER_PORT=3001 yarn dev -``` - -## Next Steps - -1. Read the full [README.md](./README.md) for detailed documentation -2. **Compare Map vs Record** in [MAP_VS_RECORD.md](./MAP_VS_RECORD.md) to choose the best approach -3. Explore the configuration classes in `src/config/` -4. Modify the YAML files to test different scenarios -5. Check out the [Type Config documentation](../../README.md) - -## Tips - -- Use `.env` files for local development (copy from `.env.example`) -- Use explicit placeholders for important configuration -- Use underscore-based ENV for convenience overrides -- Always provide fallbacks for development, omit them for production secrets -- Use Map-based config for collections of similar entities diff --git a/examples/map-and-placeholders/README.md b/examples/map-and-placeholders/README.md deleted file mode 100644 index 62f92b6..0000000 --- a/examples/map-and-placeholders/README.md +++ /dev/null @@ -1,379 +0,0 @@ -# Map and Placeholders Example - -This example demonstrates two powerful features of Type Config: - -1. **Map-based Configuration Binding**: Bind configuration to `Map` properties for managing collections of similar entities -2. **Advanced Environment Variable Resolution**: Use `${VAR:fallback}` syntax with profile-aware precedence - -## โš ๏ธ Important Limitations - -**Automatic validation of Map/Record entries is NOT supported.** - -This is a fundamental limitation of class-validator, which requires known properties at compile time. Map and Record types have dynamic keys, so: - -- โœ… **What works**: Binding YAML/JSON to Map/Record, placeholder resolution, `@Required()` (checks if property exists) -- โŒ **What doesn't work**: Automatic validation of entry contents (port ranges, required fields within entries, etc.) -- โœ… **Solution**: Manual validation (see `main.ts` for example) - -If strict validation is critical, consider using individual properties instead of Map/Record. - -## Features Demonstrated - -### 1. Map-Based Configuration - -The example shows how to use `Map` for: -- **Multiple database connections** (`databases.connections`) -- **Service endpoints** (`services.endpoints`) - -This eliminates the need to define individual properties for each connection or service. - -### 2. Placeholder Resolution - -The example demonstrates: -- **Basic placeholders**: `${SERVER_HOST:localhost}` -- **Placeholders without fallbacks**: `${PROD_DB_US_PASSWORD}` (required in production) -- **Profile-specific overrides**: Different placeholders in `application-production.yml` -- **Multiple placeholders in one value**: Supported throughout -- **Nested structures**: Placeholders work in map values - -### 3. Precedence Rules - -The example shows the configuration resolution order: -1. **Underscore-based ENV variables** (priority 200, e.g., `DATABASES_POOL_MIN`) -2. **Profile-specific file values** (priority 150, can contain placeholders or literals) -3. **Base file values** (priority 100, can contain placeholders or literals) -4. **Placeholder resolution** (happens after merging, resolves `${VAR:fallback}`) -5. **Default values from decorators** (lowest priority) - -**Key Point**: Underscore-based ENV vars override file values, then placeholders in the merged result are resolved. - -## Project Structure - -``` -examples/map-and-placeholders/ -โ”œโ”€โ”€ config/ -โ”‚ โ”œโ”€โ”€ application.yml # Base configuration -โ”‚ โ””โ”€โ”€ application-production.yml # Production overrides -โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ config/ -โ”‚ โ”‚ โ”œโ”€โ”€ database.config.ts # Map-based database config -โ”‚ โ”‚ โ”œโ”€โ”€ services.config.ts # Map-based services config -โ”‚ โ”‚ โ”œโ”€โ”€ server.config.ts # Server config with placeholders -โ”‚ โ”‚ โ””โ”€โ”€ features.config.ts # Feature flags with placeholders -โ”‚ โ”œโ”€โ”€ app.module.ts -โ”‚ โ”œโ”€โ”€ app.controller.ts -โ”‚ โ”œโ”€โ”€ app.service.ts -โ”‚ โ””โ”€โ”€ main.ts -โ”œโ”€โ”€ package.json -โ”œโ”€โ”€ tsconfig.json -โ””โ”€โ”€ README.md -``` - -## Configuration Files - -### application.yml (Base) - -```yaml -databases: - connections: - serhafen-us: - host: ${DB_US_HOST:localhost} - username: ${DB_US_USERNAME:postgres} - password: ${DB_US_PASSWORD:dev_password} - # ... more properties -``` - -### application-production.yml (Profile Override) - -```yaml -databases: - connections: - serhafen-us: - host: ${PROD_DB_US_HOST:prod-db-us.example.com} - username: ${PROD_DB_US_USERNAME:prod_user} - password: ${PROD_DB_US_PASSWORD} # No fallback - must be set! -``` - -## Running the Example - -### Development Mode (Default Profile) - -```bash -# Install dependencies -yarn install - -# Run with default configuration -yarn dev - -# Or with specific environment variables -DB_US_HOST=custom-host yarn dev -``` - -### Production Mode - -```bash -# Set required environment variables -export PROD_DB_US_PASSWORD=secret123 -export PROD_DB_AG_PASSWORD=secret456 -export PROD_DB_ANALYTICS_PASSWORD=secret789 - -# Run in production mode -NODE_ENV=production yarn dev -``` - -### Using Underscore-Based ENV Resolution - -Type Config also supports underscore-based environment variable resolution: - -```bash -# These will override configuration values -export DATABASES_POOL_MIN=5 -export DATABASES_POOL_MAX=20 -export DATABASES_CONNECTIONS_SERHAFEN_US_HOST=override-host - -yarn dev -``` - -**Note**: Underscore-based ENV variables have priority 200 and override file values. Placeholders are resolved after all sources are merged. - -**Known Limitation**: Underscore-based ENV resolution doesn't work correctly with kebab-case keys in maps (e.g., `serhafen-us`). Use explicit placeholders instead for map entries with hyphens in their keys. - -## API Endpoints - -Once running, you can access: - -- `GET /` - Welcome message -- `GET /config` - View all configuration (with masked passwords) -- `GET /database/:name` - Get specific database connection (e.g., `/database/serhafen-us`) -- `GET /service/:name` - Get specific service endpoint (e.g., `/service/auth-service`) - -## Example Output - -``` -=== Map and Placeholders Example === - -๐Ÿš€ Server: map-and-placeholders-app -๐Ÿ“ Profile: development -๐ŸŒ Host: localhost:3000 - ---- Database Connections (Map-based) --- - ๐Ÿ“Š serhafen-us: - Host: localhost:5432 - Database: serhafen_common (schema: us) - Username: postgres - SSL: false - ๐Ÿ“Š serhafen-ag: - Host: localhost:5432 - Database: serhafen_ag (schema: ag) - Username: postgres - SSL: false - ๐Ÿ“Š analytics: - Host: localhost:5432 - Database: analytics_db (schema: public) - Username: analytics_user - SSL: false - ---- Connection Pool Settings --- - Min: 2 - Max: 10 - Idle: 10000ms - ---- Service Endpoints (Map-based) --- - ๐Ÿ”— auth-service: - URL: http://localhost:8001 - Timeout: 5000ms - Retries: 3 - ๐Ÿ”— payment-service: - URL: http://localhost:8002 - Timeout: 10000ms - Retries: 5 - ๐Ÿ”— notification-service: - URL: http://localhost:8003 - Timeout: 3000ms - Retries: 2 - ---- Feature Flags (Placeholder-based) --- - ๐ŸŽจ New UI: false - ๐Ÿงช Beta Features: false - ๐Ÿ”ง Maintenance Mode: false -``` - -## Key Concepts - -### Map-Based Configuration - -```typescript -@ConfigurationProperties('databases') -export class DatabasesConfig { - @ConfigProperty('connections') - connections: Map; - - getConnection(name: string): DatabaseConnection | undefined { - return this.connections.get(name); - } -} -``` - -The `connections` property is automatically bound as a `Map` from the YAML structure. - -### Placeholder Syntax - -- `${VAR}` - Use environment variable, undefined if not set -- `${VAR:fallback}` - Use environment variable, or fallback if not set -- `${VAR:}` - Use environment variable, or empty string if not set - -### Profile-Specific Overrides - -When using `NODE_ENV=production`: -1. Base `application.yml` is loaded -2. Profile-specific `application-production.yml` is loaded and merged -3. Profile-specific placeholders override base placeholders -4. All placeholders are resolved after merging -5. Underscore-based ENV resolution is applied last - -### Precedence Example - -**Example 1: Profile override with placeholder** -- Base config: `username: ${DB_USERNAME:postgres}` -- Production config: `username: ${PROD_DB_USERNAME:prod_user}` -- ENV vars: `PROD_DB_USERNAME=actual_prod` - -Result in production: `username: "actual_prod"` (profile file overrides base, then placeholder resolves) - -**Example 2: Underscore-based ENV override** -- Base config: `host: localhost` -- ENV vars: `DATABASES_CONNECTIONS_SERHAFEN_US_HOST=prod-server` - -Result: `host: "prod-server"` (underscore-based ENV overrides file value) - -## Validation - -### Simple Properties (Works) - -For simple configuration properties, validation works as expected: - -```typescript -@ConfigurationProperties('server') -export class ServerConfig { - @ConfigProperty() - @Required() - host: string; - - @ConfigProperty() - @DefaultValue(3000) - port: number = 3000; -} -``` - -### Map/Record Properties (Manual Validation Required) - -**This example uses `validateOnBind: false`** because automatic validation doesn't work for Map/Record types. - -The `@Required()` decorator only validates that the Map/Record property exists, NOT the contents: - -```typescript -@ConfigProperty('connections') -@Required() // โœ… Checks if 'connections' exists -connections: Map; // โŒ Doesn't validate entries -``` - -**What this means**: -- Missing fields in entries (e.g., no `port`) won't cause errors -- Invalid values (e.g., `port: 99999`) won't be caught -- You'll get `undefined` or invalid data at runtime - -**Manual Validation Pattern** (see `main.ts`): - -```typescript -const databasesConfig = configManager.bind(DatabasesConfig); - -// Validate each entry manually -for (const [name, conn] of databasesConfig.connections) { - if (!conn.host) { - throw new Error(`Database '${name}' missing host`); - } - if (!conn.port || conn.port < 1 || conn.port > 65535) { - throw new Error(`Database '${name}' invalid port: ${conn.port}`); - } - // ... more validation -} -``` - -**Alternative**: If validation is critical, use individual properties instead of Map/Record: - -```typescript -@ConfigurationProperties('databases') -class DatabasesConfig { - @ValidateNested() - @Type(() => DatabaseConnection) - primary: DatabaseConnection; // โœ… Validates automatically - - @ValidateNested() - @Type(() => DatabaseConnection) - replica: DatabaseConnection; // โœ… Validates automatically -} -``` - -## Map vs Record - -This example uses `Map`. An alternative is `Record` (plain object). - -**See [MAP_VS_RECORD.md](./MAP_VS_RECORD.md) for a detailed comparison.** - -Quick summary: -- **Map**: True Map type with `.get()`, `.set()` methods - no automatic validation -- **Record**: Plain object with bracket notation - no automatic validation either - -**Both require manual validation** due to class-validator limitations with dynamic keys. - -## Known Limitations - -### 1. Map/Record Entry Validation - -**Automatic validation of Map/Record entries is NOT supported.** - -This is a limitation of class-validator, which requires known properties at compile time. Since Map/Record have dynamic keys, validation must be done manually. - -**Impact**: -- Missing fields in entries won't cause startup errors -- Invalid values won't be caught automatically -- Runtime errors may occur if you don't validate manually - -**Solution**: See the manual validation example in `main.ts` - -### 2. Type Safety at Runtime - -TypeScript provides compile-time type safety, but runtime validation requires manual checks or a different structure (individual properties instead of Map/Record). - -## Error Handling - -### Missing Required Environment Variables - -If a placeholder has no fallback and the ENV var is not set: - -```yaml -password: ${PROD_DB_PASSWORD} # No fallback -``` - -The field becomes `undefined`. Since validation is disabled in this example, you'll need to check for undefined values manually. - -### Invalid Map Structures - -If configuration doesn't match the expected structure (e.g., wrong types), the binding may fail or produce unexpected results. Use TypeScript types and validation decorators to catch these issues early. - -## Best Practices - -1. **Use fallbacks for development**: `${VAR:dev_value}` -2. **Omit fallbacks for production secrets**: `${PROD_SECRET}` -3. **Use Map for collections**: Instead of `db1`, `db2`, `db3` properties -4. **Profile-specific placeholders**: Override which ENV vars are used per environment -5. **Understand precedence**: Underscore-based ENV (priority 200) overrides file values, then placeholders resolve -6. **Use placeholders for custom ENV var names**: `${CUSTOM_VAR}` instead of relying on `PATH_TO_PROPERTY` convention -7. **Use underscore-based for quick overrides**: Set `DATABASE_HOST` to override `database.host` without changing files - -## Learn More - -- [Map vs Record Comparison](./MAP_VS_RECORD.md) - Choose the right approach for your needs -- [Type Config Documentation](../../README.md) -- [Placeholder Resolution Guide](../../packages/core/PLACEHOLDER_RESOLUTION.md) -- [Configuration Files Guide](../../packages/core/CONFIG_FILES.md) diff --git a/examples/map-and-placeholders/VALIDATION_LIMITATIONS.md b/examples/map-and-placeholders/VALIDATION_LIMITATIONS.md deleted file mode 100644 index 76928ef..0000000 --- a/examples/map-and-placeholders/VALIDATION_LIMITATIONS.md +++ /dev/null @@ -1,258 +0,0 @@ -# Map/Record Validation Limitations - -## Summary - -**Automatic validation of Map/Record entries is NOT supported in Type Config.** - -This is a fundamental limitation of class-validator, not a bug or missing feature. This document explains why and provides solutions. - -## Why Validation Doesn't Work - -### The Problem - -class-validator requires **known properties at compile time**. When you write: - -```typescript -class ServerConfig { - @IsString() - host: string; // โœ… Known property - validation works -} -``` - -class-validator knows to check the `host` property. - -But with Map/Record: - -```typescript -class DatabasesConfig { - @ValidateNested({ each: true }) - connections: Map; // โŒ Unknown keys - validation fails -} -``` - -class-validator doesn't know what keys will exist at runtime (`serhafen-us`, `serhafen-ag`, etc.), so it can't validate them. - -### What Actually Happens - -When you try to use `validateOnBind: true` with Map/Record: - -```typescript -const manager = new ConfigManager({ validateOnBind: true }); -const config = manager.bind(DatabasesConfig); -// โŒ Throws: "undefined: an unknown value was passed to the validate function" -``` - -This error means class-validator encountered something it doesn't understand (the Map/Record with dynamic keys). - -## What Works vs What Doesn't - -### โœ… What Works - -1. **Binding**: YAML/JSON โ†’ Map/Record conversion works perfectly -2. **Placeholder resolution**: `${VAR:fallback}` works in map values -3. **@Required()**: Validates that the Map/Record property exists -4. **Type conversion**: Objects are converted to Map correctly -5. **Access patterns**: `.get()` for Map, bracket notation for Record - -### โŒ What Doesn't Work - -1. **Entry validation**: `@ValidateNested({ each: true })` doesn't work -2. **Field validation**: `@IsString()`, `@IsNumber()` on entry fields -3. **Range validation**: `@Min()`, `@Max()` on entry fields -4. **Required fields**: Missing fields in entries won't cause errors -5. **Type checking**: Invalid types in entries won't be caught - -## Solutions - -### Solution 1: Manual Validation (Recommended) - -Validate entries manually in your application code: - -```typescript -const config = manager.bind(DatabasesConfig); - -// Validate each entry -for (const [name, conn] of config.connections) { - // Check required fields - if (!conn.host) { - throw new Error(`Database '${name}' missing host`); - } - - // Check types - if (typeof conn.port !== 'number') { - throw new Error(`Database '${name}' port must be a number`); - } - - // Check ranges - if (conn.port < 1 || conn.port > 65535) { - throw new Error(`Database '${name}' invalid port: ${conn.port}`); - } - - // Check patterns - if (!conn.host.match(/^[a-z0-9.-]+$/)) { - throw new Error(`Database '${name}' invalid host format`); - } -} -``` - -### Solution 2: Helper Function - -Create a reusable validation function: - -```typescript -function validateDatabaseConnection(name: string, conn: DatabaseConnection): void { - const errors: string[] = []; - - if (!conn.host) errors.push('missing host'); - if (!conn.port) errors.push('missing port'); - if (conn.port < 1 || conn.port > 65535) errors.push('invalid port range'); - if (!conn.username) errors.push('missing username'); - if (!conn.password) errors.push('missing password'); - - if (errors.length > 0) { - throw new Error(`Database '${name}' validation failed: ${errors.join(', ')}`); - } -} - -// Use it -for (const [name, conn] of config.connections) { - validateDatabaseConnection(name, conn); -} -``` - -### Solution 3: Use Individual Properties - -If validation is critical and you have a fixed set of connections: - -```typescript -@ConfigurationProperties('databases') -class DatabasesConfig { - @ValidateNested() - @Type(() => DatabaseConnection) - @IsNotEmpty() - primary: DatabaseConnection; // โœ… Validates automatically - - @ValidateNested() - @Type(() => DatabaseConnection) - @IsNotEmpty() - replica: DatabaseConnection; // โœ… Validates automatically - - @ValidateNested() - @Type(() => DatabaseConnection) - @IsOptional() - analytics?: DatabaseConnection; // โœ… Validates if present -} -``` - -This works because the properties are known at compile time. - -### Solution 4: Use a Different Validation Library - -Consider using a validation library that supports dynamic keys: - -- **Zod**: Supports `z.record()` for dynamic keys -- **Yup**: Supports dynamic object validation -- **Joi**: Supports pattern-based validation - -Example with Zod: - -```typescript -import { z } from 'zod'; - -const DatabaseConnectionSchema = z.object({ - host: z.string().min(1), - port: z.number().min(1).max(65535), - username: z.string().min(1), - password: z.string().min(1), - database: z.string().min(1), - schema: z.string().min(1), - ssl: z.boolean(), -}); - -const DatabasesConfigSchema = z.object({ - connections: z.record(DatabaseConnectionSchema), // โœ… Validates dynamic keys! -}); - -// Validate -const config = manager.bind(DatabasesConfig); -DatabasesConfigSchema.parse(config); // Throws if invalid -``` - -## Best Practices - -1. **Always use `validateOnBind: false`** with Map/Record types -2. **Implement manual validation** in your bootstrap/startup code -3. **Fail fast**: Validate at startup, not at runtime -4. **Provide clear error messages**: Include the entry name in errors -5. **Document validation requirements**: Make it clear what's expected -6. **Consider alternatives**: If validation is critical, use individual properties - -## Example: Complete Validation Pattern - -```typescript -// 1. Configuration class (no validation decorators on Map/Record) -@ConfigurationProperties('databases') -class DatabasesConfig { - @ConfigProperty('connections') - @Required() // โœ… Only validates that property exists - connections: Map; -} - -// 2. Validation function -function validateDatabaseConnections(config: DatabasesConfig): void { - if (config.connections.size === 0) { - throw new Error('At least one database connection is required'); - } - - for (const [name, conn] of config.connections) { - // Validate each field - if (!conn.host) throw new Error(`Database '${name}' missing host`); - if (!conn.port) throw new Error(`Database '${name}' missing port`); - if (conn.port < 1 || conn.port > 65535) { - throw new Error(`Database '${name}' invalid port: ${conn.port}`); - } - if (!conn.username) throw new Error(`Database '${name}' missing username`); - if (!conn.password) throw new Error(`Database '${name}' missing password`); - if (!conn.database) throw new Error(`Database '${name}' missing database`); - if (!conn.schema) throw new Error(`Database '${name}' missing schema`); - if (typeof conn.ssl !== 'boolean') { - throw new Error(`Database '${name}' ssl must be boolean`); - } - } -} - -// 3. Bootstrap with validation -async function bootstrap() { - const manager = new ConfigManager({ - validateOnBind: false, // โœ… Disable automatic validation - }); - - await manager.initialize(); - - const config = manager.bind(DatabasesConfig); - - // โœ… Manual validation - validateDatabaseConnections(config); - - // Now safe to use - const app = await NestFactory.create(AppModule); - await app.listen(3000); -} -``` - -## Why We Removed validateMapEntries() - -The `MapBinder.validateMapEntries()` method was removed because: - -1. **It didn't work properly**: It threw confusing errors -2. **It was misleading**: Suggested validation was supported when it wasn't -3. **It was inconsistent**: Worked differently than class-validator -4. **Manual validation is clearer**: Explicit is better than implicit - -## Conclusion - -Map/Record types are excellent for **structure and binding**, but require **manual validation**. - -This is not a limitation of Type Config - it's a fundamental limitation of class-validator's design. The library now clearly documents this limitation and provides patterns for manual validation. - -**Key Takeaway**: Use `validateOnBind: false` with Map/Record and implement manual validation in your application code. diff --git a/examples/map-and-placeholders/config/application-production.yml b/examples/map-and-placeholders/config/application-production.yml deleted file mode 100644 index aec14e4..0000000 --- a/examples/map-and-placeholders/config/application-production.yml +++ /dev/null @@ -1,58 +0,0 @@ -# Production profile overrides -server: - host: 0.0.0.0 - port: ${PROD_PORT:8080} - name: ${PROD_APP_NAME:map-and-placeholders-prod} - -# Production database connections - override with different placeholders -databases: - connections: - serhafen-us: - host: ${PROD_DB_US_HOST:prod-db-us.example.com} - port: 5432 - username: ${PROD_DB_US_USERNAME:prod_user} -# password: ${PROD_DB_US_PASSWORD} # No fallback - must be set in production - ssl: true - - serhafen-ag: - host: ${PROD_DB_AG_HOST:prod-db-ag.example.com} - port: 5432 - username: ${PROD_DB_AG_USERNAME:prod_user} -# password: ${PROD_DB_AG_PASSWORD} # No fallback - must be set in production - ssl: true - - analytics: - host: ${PROD_DB_ANALYTICS_HOST:prod-analytics.example.com} - port: 5432 - username: ${PROD_DB_ANALYTICS_USERNAME:analytics_prod} -# password: ${PROD_DB_ANALYTICS_PASSWORD} # No fallback - must be set in production - ssl: true - - pool: - min: ${PROD_DB_POOL_MIN:5} - max: ${PROD_DB_POOL_MAX:50} - idle: ${PROD_DB_POOL_IDLE:30000} - -# Production service endpoints -services: - endpoints: - auth-service: - url: ${PROD_AUTH_SERVICE_URL:https://auth.example.com} - timeout: ${PROD_AUTH_SERVICE_TIMEOUT:3000} - retries: 5 - - payment-service: - url: ${PROD_PAYMENT_SERVICE_URL:https://payment.example.com} - timeout: ${PROD_PAYMENT_SERVICE_TIMEOUT:15000} - retries: 10 - - notification-service: - url: ${PROD_NOTIFICATION_SERVICE_URL:https://notifications.example.com} - timeout: ${PROD_NOTIFICATION_SERVICE_TIMEOUT:5000} - retries: 3 - -# Production feature flags -features: - enableNewUI: ${PROD_FEATURE_NEW_UI:true} - enableBetaFeatures: ${PROD_FEATURE_BETA:false} - maintenanceMode: ${PROD_MAINTENANCE_MODE:false} diff --git a/examples/map-and-placeholders/config/application.yml b/examples/map-and-placeholders/config/application.yml deleted file mode 100644 index 7d3abeb..0000000 --- a/examples/map-and-placeholders/config/application.yml +++ /dev/null @@ -1,68 +0,0 @@ -# Base configuration with placeholders and map structures -server: - host: ${SERVER_HOST:localhost} - port: ${SERVER_PORT:3000} - name: ${APP_NAME:map-and-placeholders-app} - -# Map-based database connections configuration -databases: - connections: - # US region database - serhafen-us: - host: ${DB_US_HOST:localhost} - port: ${DB_US_PORT:5432} - username: ${DB_US_USERNAME:postgres} - password: ${DB_US_PASSWORD:dev_password} - database: serhafen_common - schema: us - ssl: false - - # AG region database - serhafen-ag: - host: ${DB_AG_HOST:localhost} - port: ${DB_AG_PORT:5432} - username: ${DB_AG_USERNAME:postgres} - password: ${DB_AG_PASSWORD:dev_password} - database: serhafen_ag - schema: ag - ssl: false - - # Analytics database - analytics: - host: ${DB_ANALYTICS_HOST:localhost} - port: ${DB_ANALYTICS_PORT:5432} - username: ${DB_ANALYTICS_USERNAME:analytics_user} - password: ${DB_ANALYTICS_PASSWORD:analytics_pass} - database: analytics_db - schema: public - ssl: false - - # Connection pool settings - pool: - min: ${DB_POOL_MIN:2} - max: ${DB_POOL_MAX:10} - idle: ${DB_POOL_IDLE:10000} - -# Map-based service endpoints -services: - endpoints: - auth-service: - url: ${AUTH_SERVICE_URL:http://localhost:8001} - timeout: ${AUTH_SERVICE_TIMEOUT:5000} - retries: 3 - - payment-service: - url: ${PAYMENT_SERVICE_URL:http://localhost:8002} - timeout: ${PAYMENT_SERVICE_TIMEOUT:10000} - retries: 5 - - notification-service: - url: ${NOTIFICATION_SERVICE_URL:http://localhost:8003} - timeout: ${NOTIFICATION_SERVICE_TIMEOUT:3000} - retries: 2 - -# Feature flags with placeholders -features: - enableNewUI: ${FEATURE_NEW_UI:false} - enableBetaFeatures: ${FEATURE_BETA:false} - maintenanceMode: ${MAINTENANCE_MODE:false} diff --git a/examples/map-and-placeholders/package.json b/examples/map-and-placeholders/package.json deleted file mode 100644 index 4e13307..0000000 --- a/examples/map-and-placeholders/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "map-and-placeholders-example", - "version": "1.0.0", - "private": true, - "description": "Example demonstrating Map-based configuration and placeholder resolution", - "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/map-and-placeholders/src/app.controller.ts b/examples/map-and-placeholders/src/app.controller.ts deleted file mode 100644 index a96826a..0000000 --- a/examples/map-and-placeholders/src/app.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Get, Param } from '@nestjs/common'; -import { AppService } from './app.service'; - -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get() - getRoot(): string { - return 'Map and Placeholders Example - Visit /config for configuration info'; - } - - @Get('config') - getConfig(): any { - return this.appService.getConfigInfo(); - } - - @Get('database/:name') - getDatabaseConnection(@Param('name') name: string): any { - return this.appService.getDatabaseConnection(name); - } - - @Get('service/:name') - getServiceEndpoint(@Param('name') name: string): any { - return this.appService.getServiceEndpoint(name); - } -} diff --git a/examples/map-and-placeholders/src/app.module.ts b/examples/map-and-placeholders/src/app.module.ts deleted file mode 100644 index 21e7129..0000000 --- a/examples/map-and-placeholders/src/app.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeConfigModule } from '@snow-tzu/type-config-nestjs'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import * as path from 'path'; - -@Module({ - imports: [ - TypeConfigModule.forRoot({ - configDir: path.join(__dirname, '../config'), - profile: process.env.NODE_ENV || 'development', - enableHotReload: false, - validateOnBind: true, - }), - ], - controllers: [AppController], - providers: [AppService], -}) -export class AppModule {} diff --git a/examples/map-and-placeholders/src/app.service.ts b/examples/map-and-placeholders/src/app.service.ts deleted file mode 100644 index 68d0c0a..0000000 --- a/examples/map-and-placeholders/src/app.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable, Inject } from '@nestjs/common'; -import { ConfigManager, CONFIG_MANAGER_TOKEN } from '@snow-tzu/type-config-nestjs'; -import { DatabasesConfig } from './config/database.config'; -import { ServicesConfig } from './config/services.config'; -import { FeaturesConfig } from './config/features.config'; - -@Injectable() -export class AppService { - private databasesConfig: DatabasesConfig; - private servicesConfig: ServicesConfig; - private featuresConfig: FeaturesConfig; - - constructor( - @Inject(CONFIG_MANAGER_TOKEN) private configManager: ConfigManager, - ) { - // Bind configuration classes - this.databasesConfig = this.configManager.bind(DatabasesConfig); - this.servicesConfig = this.configManager.bind(ServicesConfig); - this.featuresConfig = this.configManager.bind(FeaturesConfig); - } - - getConfigInfo(): any { - return { - profile: this.configManager.getProfile(), - databases: { - connectionNames: this.databasesConfig.getConnectionNames(), - connections: Object.fromEntries(this.databasesConfig.connections), - pool: this.databasesConfig.pool, - }, - services: { - serviceNames: this.servicesConfig.getServiceNames(), - endpoints: Object.fromEntries(this.servicesConfig.endpoints), - }, - features: { - enableNewUI: this.featuresConfig.enableNewUI, - enableBetaFeatures: this.featuresConfig.enableBetaFeatures, - maintenanceMode: this.featuresConfig.maintenanceMode, - }, - }; - } - - getDatabaseConnection(name: string): any { - const connection = this.databasesConfig.getConnection(name); - if (!connection) { - return { error: `Database connection '${name}' not found` }; - } - return { - name, - ...connection, - // Mask password for security - password: '***', - }; - } - - getServiceEndpoint(name: string): any { - const endpoint = this.servicesConfig.getEndpoint(name); - if (!endpoint) { - return { error: `Service endpoint '${name}' not found` }; - } - return { - name, - ...endpoint, - }; - } -} diff --git a/examples/map-and-placeholders/src/config/database-record.config.ts b/examples/map-and-placeholders/src/config/database-record.config.ts deleted file mode 100644 index e7c425a..0000000 --- a/examples/map-and-placeholders/src/config/database-record.config.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - ConfigurationProperties, - ConfigProperty, - Required, - RecordType, -} from '@snow-tzu/type-config-nestjs'; -import { IsString, IsNumber, IsBoolean, Min, Max } from 'class-validator'; -import { PoolConfig } from './database.config'; - -/** - * Database connection configuration for a single database - * - * Note: The class-validator decorators below are for documentation only. - * They do NOT provide automatic validation when used in a Record type. - * See main.ts for manual validation example. - */ -export 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; -} - -/** - * Alternative approach using Record instead of Map - * - * Trade-offs: - * - โœ… Plain object with bracket notation access - * - โœ… Works with Object.keys(), Object.entries() - * - โœ… JSON serialization works directly - * - โŒ Not a true Map (no Map methods like .get(), .set()) - * - โŒ Must use bracket notation: connections['serhafen-us'] - * - โŒ Automatic validation NOT supported (class-validator limitation) - * - * **IMPORTANT LIMITATION**: - * Automatic validation of Record entries does NOT work. This is a limitation of - * class-validator, which requires known properties at compile time. - * - * You must implement manual validation (see main.ts for example). - */ -@ConfigurationProperties('databases') -export class DatabasesRecordConfig { - /** - * Record of database connections by name - * - * Decorators explained: - * - @ConfigProperty: Maps to 'databases.connections' in YAML - * - @Required: Validates that the 'connections' property exists (not the entries) - * - @RecordType: Keeps this as a plain object (doesn't convert to Map) - * - * Note: Entry validation must be done manually - see main.ts - */ - @ConfigProperty('connections') - @Required() - @RecordType() - connections: Record; - - @ConfigProperty('pool') - @Required() - pool: PoolConfig; - - /** - * Helper method to get a specific database connection - */ - getConnection(name: string): DatabaseConnectionValidated | undefined { - return this.connections[name]; - } - - /** - * Helper method to list all available connection names - */ - getConnectionNames(): string[] { - return Object.keys(this.connections); - } -} diff --git a/examples/map-and-placeholders/src/config/database.config.ts b/examples/map-and-placeholders/src/config/database.config.ts deleted file mode 100644 index 57de5a7..0000000 --- a/examples/map-and-placeholders/src/config/database.config.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - ConfigurationProperties, - ConfigProperty, - Required, -} from '@snow-tzu/type-config-nestjs'; - -/** - * Database connection configuration for a single database - */ -export class DatabaseConnection { - host: string; - port: number; - username: string; - password: string; - database: string; - schema: string; - ssl: boolean; -} - -/** - * Connection pool configuration - */ -export class PoolConfig { - min: number; - max: number; - idle: number; -} - -/** - * Main databases configuration with Map-based connections - * Demonstrates map-based configuration binding - */ -@ConfigurationProperties('databases') -export class DatabasesConfig { - /** - * Map of database connections by name - * This demonstrates the Map binding feature - */ - @ConfigProperty('connections') - @Required() - connections: Map; - - /** - * Connection pool settings - */ - @ConfigProperty('pool') - @Required() - pool: PoolConfig; - - /** - * Helper method to get a specific database connection - */ - getConnection(name: string): DatabaseConnection | undefined { - return this.connections.get(name); - } - - /** - * Helper method to list all available connection names - */ - getConnectionNames(): string[] { - return Array.from(this.connections.keys()); - } -} diff --git a/examples/map-and-placeholders/src/config/features.config.ts b/examples/map-and-placeholders/src/config/features.config.ts deleted file mode 100644 index e7ffbea..0000000 --- a/examples/map-and-placeholders/src/config/features.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - ConfigurationProperties, - ConfigProperty, - DefaultValue, -} from '@snow-tzu/type-config-nestjs'; - -/** - * Feature flags configuration with placeholder resolution - * Demonstrates boolean placeholders with fallback values - */ -@ConfigurationProperties('features') -export class FeaturesConfig { - @ConfigProperty() - @DefaultValue(false) - enableNewUI: boolean = false; - - @ConfigProperty() - @DefaultValue(false) - enableBetaFeatures: boolean = false; - - @ConfigProperty() - @DefaultValue(false) - maintenanceMode: boolean = false; -} diff --git a/examples/map-and-placeholders/src/config/server.config.ts b/examples/map-and-placeholders/src/config/server.config.ts deleted file mode 100644 index 2f2d169..0000000 --- a/examples/map-and-placeholders/src/config/server.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - ConfigurationProperties, - ConfigProperty, - Required, - DefaultValue, -} from '@snow-tzu/type-config-nestjs'; - -/** - * Server configuration with placeholder resolution - * Demonstrates environment variable placeholders with fallback values - */ -@ConfigurationProperties('server') -export class ServerConfig { - @ConfigProperty() - @Required() - host: string; - - @ConfigProperty() - @DefaultValue(3000) - port: number = 3000; - - @ConfigProperty() - @Required() - name: string; -} diff --git a/examples/map-and-placeholders/src/config/services.config.ts b/examples/map-and-placeholders/src/config/services.config.ts deleted file mode 100644 index 3188235..0000000 --- a/examples/map-and-placeholders/src/config/services.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - ConfigurationProperties, - ConfigProperty, - Required, -} from '@snow-tzu/type-config-nestjs'; - -/** - * Service endpoint configuration - */ -export class ServiceEndpoint { - url: string; - timeout: number; - retries: number; -} - -/** - * Services configuration with Map-based endpoints - * Demonstrates map-based configuration for service discovery - */ -@ConfigurationProperties('services') -export class ServicesConfig { - /** - * Map of service endpoints by service name - * This demonstrates the Map binding feature for service endpoints - */ - @ConfigProperty('endpoints') - @Required() - endpoints: Map; - - /** - * Helper method to get a specific service endpoint - */ - getEndpoint(serviceName: string): ServiceEndpoint | undefined { - return this.endpoints.get(serviceName); - } - - /** - * Helper method to list all available services - */ - getServiceNames(): string[] { - return Array.from(this.endpoints.keys()); - } -} diff --git a/examples/map-and-placeholders/src/main.ts b/examples/map-and-placeholders/src/main.ts deleted file mode 100644 index 9b625cc..0000000 --- a/examples/map-and-placeholders/src/main.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { CONFIG_MANAGER_TOKEN, ConfigManager } from '@snow-tzu/type-config-nestjs'; -import { ServerConfig } from './config/server.config'; -import { ServicesConfig } from './config/services.config'; -import { FeaturesConfig } from './config/features.config'; -import { DatabasesRecordConfig, DatabaseConnectionValidated } from './config/database-record.config'; - -async function bootstrap() { - try { - const app = await NestFactory.create(AppModule); - - // Get ConfigManager from the DI container - const configManager = app.get(CONFIG_MANAGER_TOKEN); - - // Bind all configuration classes - const serverConfig = configManager.bind(ServerConfig); - const databasesConfig = configManager.bind(DatabasesRecordConfig); - const servicesConfig = configManager.bind(ServicesConfig); - const featuresConfig = configManager.bind(FeaturesConfig); - - // Optional: Manual validation for Map entries - // Since class-validator doesn't support Map validation, you can add custom checks - console.log('\n--- Validating Configuration ---'); - console.log('Connections type:', typeof databasesConfig.connections); - console.log('Is Map?', databasesConfig.connections instanceof Map); - console.log('Connections:', databasesConfig.connections); - - let validationErrors = 0; - - // Check if connections is actually a Map - if (!(databasesConfig.connections instanceof Map)) { - console.log('โš ๏ธ Warning: connections is not a Map, converting...'); - // If it's a plain object, convert to Map for iteration - const entries: [string, DatabaseConnectionValidated][] = Object.entries(databasesConfig.connections); - for (const [name, conn] of entries) { - const missing: string[] = []; - if (!conn.host) { - missing.push('host'); - } - if (!conn.port) { - missing.push('port'); - } - if (!conn.username) { - missing.push('username'); - } - if (!conn.password) { - missing.push('password'); - } - if (!conn.database) { - missing.push('database'); - } - if (!conn.schema) { - missing.push('schema'); - } - if (conn.ssl === undefined) { - missing.push('ssl'); - } - - if (missing.length > 0) { - console.log(` โš ๏ธ Database '${name}' missing fields: ${missing.join(', ')}`); - validationErrors++; - } - } - } else { - for (const [name, conn] of databasesConfig.connections) { - const missing: string[] = []; - if (!conn.host) { - missing.push('host'); - } - if (!conn.port) { - missing.push('port'); - } - if (!conn.username) { - missing.push('username'); - } - if (!conn.password) { - missing.push('password'); - } - if (!conn.database) { - missing.push('database'); - } - if (!conn.schema) { - missing.push('schema'); - } - if (conn.ssl === undefined) { - missing.push('ssl'); - } - - if (missing.length > 0) { - console.log(` โš ๏ธ Database '${name}' missing fields: ${missing.join(', ')}`); - validationErrors++; - } - } - - if (validationErrors > 0) { - console.log(`\nโš ๏ธ Found ${validationErrors} validation issue(s) in database connections`); - console.log('Note: This example has validateOnBind: false, so these are warnings only.\n'); - } else { - console.log(' โœ… All database connections have required fields\n'); - } - } - - // Display configuration information - console.log('\n=== Map and Placeholders Example ===\n'); - console.log(`๐Ÿš€ Server: ${serverConfig.name}`); - console.log(`๐Ÿ“ Profile: ${configManager.getProfile()}`); - console.log(`๐ŸŒ Host: ${serverConfig.host}:${serverConfig.port}`); - - console.log('\n--- Database Connections (Map-based) ---'); - const connectionNames = databasesConfig.getConnectionNames(); - connectionNames.forEach(name => { - const conn = databasesConfig.getConnection(name); - console.log(` ๐Ÿ“Š ${name}:`); - console.log(` Host: ${conn.host}:${conn.port}`); - console.log(` Database: ${conn.database} (schema: ${conn.schema})`); - console.log(` Username: ${conn.username}`); - console.log(` SSL: ${conn.ssl}`); - }); - - console.log('\n--- Connection Pool Settings ---'); - console.log(` Min: ${databasesConfig.pool.min}`); - console.log(` Max: ${databasesConfig.pool.max}`); - console.log(` Idle: ${databasesConfig.pool.idle}ms`); - - console.log('\n--- Service Endpoints (Map-based) ---'); - const serviceNames = servicesConfig.getServiceNames(); - serviceNames.forEach(name => { - const endpoint = servicesConfig.getEndpoint(name); - console.log(` ๐Ÿ”— ${name}:`); - console.log(` URL: ${endpoint.url}`); - console.log(` Timeout: ${endpoint.timeout}ms`); - console.log(` Retries: ${endpoint.retries}`); - }); - - console.log('\n--- Feature Flags (Placeholder-based) ---'); - console.log(` ๐ŸŽจ New UI: ${featuresConfig.enableNewUI}`); - console.log(` ๐Ÿงช Beta Features: ${featuresConfig.enableBetaFeatures}`); - console.log(` ๐Ÿ”ง Maintenance Mode: ${featuresConfig.maintenanceMode}`); - - console.log('\n--- Placeholder Resolution Examples ---'); - console.log('This example demonstrates:'); - console.log(' โœ“ ${VAR:fallback} syntax with fallback values'); - console.log(' โœ“ Profile-specific placeholder overrides'); - console.log(' โœ“ Map binding for collections'); - console.log(' โœ“ Nested object structures in maps'); - console.log(' โœ“ Underscore-based ENV resolution (e.g., DATABASES_POOL_MIN)'); - - console.log('\n--- API Endpoints ---'); - console.log(` GET http://${serverConfig.host}:${serverConfig.port}/config`); - console.log(` GET http://${serverConfig.host}:${serverConfig.port}/database/:name`); - console.log(` GET http://${serverConfig.host}:${serverConfig.port}/service/:name`); - - // Register onChange listener - configManager.onChange(_newConfig => { - console.log('\nโšก Configuration reloaded'); - }); - - // Start the application - await app.listen(serverConfig.port, serverConfig.host); - - console.log(`\nโœ… Application started successfully\n`); - } catch (error) { - console.error('โŒ Failed to start application:', error); - process.exit(1); - } -} - -bootstrap(); diff --git a/examples/map-and-placeholders/tsconfig.json b/examples/map-and-placeholders/tsconfig.json deleted file mode 100644 index d2e3bae..0000000 --- a/examples/map-and-placeholders/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "esModuleInterop": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/core/CONFIG_FILES.md b/packages/core/CONFIG_FILES.md index b45b40e..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 @@ -15,7 +16,8 @@ This guide explains how to properly manage configuration files (YAML, JSON) in y ## 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. @@ -57,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 { @@ -117,7 +118,8 @@ import * as path from 'path'; }), ] }) -export class AppModule {} +export class AppModule { +} ``` ### Express @@ -211,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 @@ -266,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 @@ -295,7 +296,9 @@ server: database: host: localhost port: 5432 +``` +```yaml # 2. application-production.yml (overrides base) server: host: 0.0.0.0 @@ -308,6 +311,7 @@ database: ``` **Final merged configuration**: + ```yaml server: host: 0.0.0.0 # from production profile @@ -322,7 +326,8 @@ database: ### Environment Variable Placeholders -Type Config supports `${VAR_NAME:fallback}` syntax in YAML/JSON files for referencing environment variables with optional fallback values. +Type Config supports `${VAR_NAME:fallback}` syntax in YAML/JSON files for referencing environment variables with +optional fallback values. #### Syntax @@ -332,11 +337,11 @@ database: 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 @@ -350,7 +355,7 @@ message: #### Precedence Rules -Configuration values are resolved in this order (highest to lowest priority): +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}`) @@ -358,7 +363,8 @@ Configuration values are resolved in this order (highest to lowest priority): 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. +**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 @@ -368,7 +374,9 @@ database: host: localhost username: ${DB_USER:postgres} password: ${DB_PASSWORD:defaultpass} +``` +```yaml # application-production.yml database: host: prod-db.example.com @@ -378,12 +386,15 @@ database: With `NODE_ENV=production`, `PROD_DB_USER=prod_user`, and `PROD_DB_PASSWORD` not set: -```javascript +```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 + 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 } } ``` @@ -398,7 +409,8 @@ const { configManager } = await new ConfigurationBuilder() ### Map and Record Configuration -Type Config supports binding configuration to `Map` or `Record` properties for dynamic key-value structures. +Type Config supports binding configuration to `Map` or `Record` properties for dynamic key-value +structures. #### Map-Based Configuration @@ -438,6 +450,7 @@ databases: ``` **Usage**: + ```typescript const dbConfig = container.get(DatabasesConfig); const primary = dbConfig.connections.get('primary'); @@ -467,6 +480,7 @@ class DatabasesRecordConfig { ``` **Usage**: + ```typescript const dbConfig = container.get(DatabasesRecordConfig); const primary = dbConfig.connections['primary']; @@ -477,11 +491,11 @@ 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 | +| 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 @@ -518,14 +532,15 @@ const allConnections = configManager.get('databases.connections'); 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. - +**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' ``` @@ -533,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 ``` @@ -557,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 @@ -585,119 +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 not resolving +### 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 + - โœ… 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 + - โœ… 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)` + - โœ… 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 + - โœ… `\${TEXT}` produces literal `${TEXT}` - this is intentional + - โœ… Remove backslash if you want resolution -### Map/Record binding not working +### 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 + - โœ… 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]` + - โœ… 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 + - โœ… 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'))` + - โœ… 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 not working as expected +### 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 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 + - โœ… 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 profile file is loaded after base file + - โœ… 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` + - โœ… 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')` diff --git a/packages/core/README.md b/packages/core/README.md index 65bfe5d..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 @@ -123,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 @@ -155,13 +156,15 @@ 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. +Use `${VAR_NAME:fallback}` syntax in your YAML/JSON configuration files to reference environment variables with optional +fallback values. #### Basic Syntax @@ -196,6 +199,7 @@ database: #### Advanced Usage **Multiple placeholders in one value**: + ```yaml api: url: ${API_PROTOCOL:https}://${API_HOST:api.example.com}:${API_PORT:443} @@ -203,13 +207,14 @@ api: ``` **Escaping placeholders**: + ```yaml message: \${USER} logged in # Literal "${USER} logged in" ``` #### Precedence Rules -Configuration values are resolved in this order (highest to lowest priority): +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}`) @@ -217,7 +222,8 @@ Configuration values are resolved in this order (highest to lowest priority): 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. +**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 @@ -227,7 +233,9 @@ database: host: localhost username: ${DB_USER:postgres} password: ${DB_PASSWORD:defaultpass} +``` +```yaml # application-production.yml database: host: prod-db.example.com @@ -236,7 +244,8 @@ database: ``` With `NODE_ENV=production` and `PROD_DB_USER=prod_user`: -```javascript + +```json5 { database: { host: 'prod-db.example.com', // Literal from production profile @@ -258,7 +267,8 @@ const { configManager } = await new ConfigurationBuilder() ### Map-Based Configuration -Bind configuration to `Map` properties for dynamic key-value structures like multiple database connections or service endpoints. +Bind configuration to `Map` properties for dynamic key-value structures like multiple database connections or +service endpoints. #### Basic Example @@ -337,7 +347,8 @@ const cacheHost = configManager.get('databases.connections.cache.host', 'localho ### Record-Based Configuration -Use `Record` as an alternative to Map. Records are plain objects with string keys, offering simpler syntax with bracket notation. +Use `Record` as an alternative to Map. Records are plain objects with string keys, offering simpler syntax +with bracket notation. #### Basic Example @@ -384,20 +395,22 @@ for (const [name, connection] of Object.entries(dbConfig.connections)) { #### 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 | +| 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**: -**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. +**Note**: Both Map and Record support the same configuration binding. The choice is purely based on your preferred API +style. #### Complete Example with Placeholders @@ -419,18 +432,11 @@ databases: This combines both features: Map/Record binding with environment variable placeholders! -#### Complete Working Example - -See the [Map and Placeholders Example](../../examples/map-and-placeholders/) for a full working demonstration including: -- Multiple database connections with Map binding -- Service endpoints configuration -- Profile-specific placeholder overrides -- Manual validation patterns -- NestJS integration - ### 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. +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? @@ -443,12 +449,12 @@ Organize complex configuration using nested configuration classes with full deco #### Basic Example ```typescript -import { - ConfigurationProperties, - ConfigProperty, - DefaultValue, +import { + ConfigurationProperties, + ConfigProperty, + DefaultValue, Required, - Validate + Validate } from '@snow-tzu/type-config'; import { IsNumber, Min, Max, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; @@ -513,7 +519,7 @@ When the property name matches the configuration key, you don't need `@ConfigPro class ServerConfig { @Required() host: string; // Binds to 'host' automatically - + @ConfigProperty('portNumber') port: number; // Custom path when names differ } @@ -527,7 +533,7 @@ All decorators are processed recursively: class PoolConfig { @DefaultValue(10) maxConnections: number; - + @DefaultValue(1) minConnections: number; } @@ -535,10 +541,10 @@ class PoolConfig { class DatabaseConfig { @Required() host: string; - + @DefaultValue(5432) port: number; - + pool: PoolConfig; // Nested class with its own decorators } @@ -550,7 +556,8 @@ class AppConfig { **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: +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'; @@ -561,7 +568,7 @@ class ApiConfig { @IsUrl() @Required() endpoint: string; - + @IsNumber() @Min(1000) @Max(30000) @@ -579,6 +586,7 @@ class ServicesConfig { ``` **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" @@ -591,7 +599,8 @@ Validation failed for ApiConfig at path 'services.api': - 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. +**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** @@ -601,7 +610,7 @@ Validation failed for ApiConfig at path 'services.api': class DatabaseConfig { @Required() host: string; - + @Required() password: string; } @@ -613,6 +622,7 @@ class AppConfig { ``` If `password` is missing: + ``` Required configuration property 'app.database.password' is missing ``` @@ -626,7 +636,7 @@ class CacheConfig { @Required() @DefaultValue('localhost') host: string; // No error even if not in config file - + @Required() @DefaultValue(6379) port: number; @@ -645,7 +655,7 @@ import { Type } from 'class-transformer'; class SslConfig { @DefaultValue(false) enabled: boolean; - + @DefaultValue('./certs/cert.pem') certPath: string; } @@ -654,10 +664,10 @@ class SslConfig { class ConnectionConfig { @Required() host: string; - + @DefaultValue(5432) port: number; - + @ValidateNested() @Type(() => SslConfig) ssl: SslConfig; // Level 3 @@ -668,7 +678,7 @@ class DatabaseConfig { @ValidateNested() @Type(() => ConnectionConfig) primary: ConnectionConfig; // Level 2 - + @ValidateNested() @Type(() => ConnectionConfig) replica: ConnectionConfig; @@ -704,7 +714,7 @@ Combine nested classes with placeholders, profiles, and Map/Record types: class RetryConfig { @DefaultValue(3) maxAttempts: number; - + @DefaultValue(1000) backoffMs: number; } @@ -713,7 +723,7 @@ class RetryConfig { class ServicesConfig { @ConfigProperty('endpoints') endpoints: Map; // Map binding - + retry: RetryConfig; // Nested class } ``` @@ -728,10 +738,10 @@ services: maxAttempts: ${MAX_RETRIES:5} ``` - **Before** (plain objects, no decorator support): ```typescript + @ConfigurationProperties('clients.sample') class SampleClientConfig { @Required() @@ -774,6 +784,7 @@ class SampleClientConfig { ``` **Benefits of Migration**: + - โœ… Type safety with IntelliSense - โœ… Default values on nested properties - โœ… Required validation on nested properties @@ -783,13 +794,13 @@ class SampleClientConfig { #### 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 ### Decorators @@ -865,19 +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) | -| Map/Record binding | โœ… Dynamic key-value structures | โŒ | โŒ | โŒ | -| ENV placeholders | โœ… ${VAR:fallback} syntax | โŒ | โš ๏ธ Basic | โŒ | +| 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/package.json b/packages/core/package.json index 8653113..c473c00 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config", - "version": "0.0.4", + "version": "0.0.5", "description": "Core configuration management system with Spring Boot-like features", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/express/README.md b/packages/express/README.md index dfd494d..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 @@ -96,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 @@ -226,11 +227,11 @@ services: 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); }); ``` @@ -238,6 +239,7 @@ app.get('/api/services/:name', (req, res) => { **Alternative: Record type** for plain object syntax: ```typescript + @ConfigurationProperties('services') class ServicesConfig { @ConfigProperty('endpoints') @@ -265,17 +267,16 @@ See the [core package documentation](../core/README.md#map-based-configuration) ## 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 | โŒ | โŒ | โŒ | -| Map/Record binding | โœ… Dynamic structures | โŒ | โŒ | โŒ | -| ENV placeholders | โœ… ${VAR:fallback} | โŒ | โš ๏ธ Basic | โŒ | +| 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/package.json b/packages/express/package.json index 606d6bb..7cd3a29 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config-express", - "version": "0.0.4", + "version": "0.0.5", "description": "Type Config integration for Express.js", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/fastify/README.md b/packages/fastify/README.md index bfe548e..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 @@ -95,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 @@ -225,11 +228,11 @@ services: 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; }); ``` @@ -237,6 +240,7 @@ fastify.get('/api/services/:name', async (request, reply) => { **Alternative: Record type** for plain object syntax: ```typescript + @ConfigurationProperties('services') class ServicesConfig { @ConfigProperty('endpoints') @@ -263,17 +267,17 @@ See the [core package documentation](../core/README.md#map-based-configuration) ## 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 | โŒ | โŒ | โŒ | -| Map/Record binding | โœ… Dynamic structures | โŒ | โŒ | โŒ | -| ENV placeholders | โœ… ${VAR:fallback} | โŒ | โš ๏ธ Basic | โŒ | +| 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/package.json b/packages/fastify/package.json index f7e2ab8..106afe4 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config-fastify", - "version": "0.0.4", + "version": "0.0.5", "description": "Type Config plugin for Fastify", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/nestjs/README.md b/packages/nestjs/README.md index 7bc654e..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 @@ -90,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 @@ -147,14 +146,16 @@ 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 @@ -171,7 +172,7 @@ database: password: ${DB_PASSWORD} # No fallback - required in production ``` -See the [core package documentation](../core/README.md#environment-variable-placeholders) for complete details on placeholder syntax and precedence rules. +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 @@ -234,7 +235,7 @@ export class DatabasesConfig { 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](../../examples/map-and-placeholders/) for a full working NestJS application. +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 @@ -305,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 @@ -387,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 diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 3226152..c8e7a42 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config-nestjs", - "version": "0.0.4", + "version": "0.0.5", "description": "Type Config integration for NestJS with native DI support", "main": "dist/index.js", "types": "dist/index.d.ts", 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/package.json b/packages/remote/package.json index 1cfd19c..29e6098 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config-remote", - "version": "0.0.4", + "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", diff --git a/packages/testing/package.json b/packages/testing/package.json index 00beb06..dddea33 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,6 +1,6 @@ { "name": "@snow-tzu/type-config-testing", - "version": "0.0.4", + "version": "0.0.5", "description": "Testing utilities for Type Config", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/yarn.lock b/yarn.lock index f96db9e..386eed8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7232,25 +7232,6 @@ __metadata: languageName: node linkType: hard -"map-and-placeholders-example@workspace:examples/map-and-placeholders": - version: 0.0.0-use.local - resolution: "map-and-placeholders-example@workspace:examples/map-and-placeholders" - 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 - "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0"