diff --git a/README.md b/README.md index 90bbf27..115ad10 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ container.set( // Asynchronous configuration container.set( - DatabaseModule.configAsync(async () => ({ + await DatabaseModule.configAsync(async () => ({ host: process.env.DB_HOST, port: parseInt(process.env.DB_PORT), database: process.env.DB_NAME, diff --git a/docs/docs/modules/dynamic-modules.md b/docs/docs/modules/dynamic-modules.md index 3eb3815..c2780f7 100644 --- a/docs/docs/modules/dynamic-modules.md +++ b/docs/docs/modules/dynamic-modules.md @@ -6,8 +6,6 @@ sidebar_position: 4 Dynamic modules allow you to configure modules at runtime with different settings for different environments or use cases. -> **Note**: Dynamic module configuration patterns are planned for future releases. The current implementation supports basic module registration with `container.set()`. - ## Overview Dynamic modules enable: @@ -17,15 +15,18 @@ Dynamic modules enable: - **Runtime configuration** (user preferences, A/B testing) - **Validation** of configuration at registration time -## Planned Implementation +## New Pattern: Type-Safe Dynamic Module Configuration + +> **As of v0.4.0+, module authors define their own static `config`/`configAsync` methods using the provided helper `createModuleConfig`.** +> This gives consumers full type inference and strict errors, with no need for generics or helpers at the call site. **You only need `createModuleConfig` for both sync and async configuration.** -
-⚠️ Planned Feature - Currently Non-Functional +### How It Works -Dynamic module configuration with `.config()` and `.configAsync()` methods is planned for future releases. This will allow runtime configuration of modules. +1. **Module authors** define static `config`/`configAsync` methods using the single helper from `@nexusdi/core`: ```typescript -// Planned API - Not yet implemented +import { Module, Token, createModuleConfig } from '@nexusdi/core'; + interface DatabaseConfig { host: string; port: number; @@ -34,17 +35,31 @@ interface DatabaseConfig { password?: string; } -@Module({ - providers: [DatabaseService, { token: DATABASE_CONFIG, useValue: {} }], -}) -class DatabaseModule extends DynamicModule { - protected readonly configToken = DATABASE_CONFIG; - protected readonly moduleConfig = { - providers: [DatabaseService, { token: DATABASE_CONFIG, useValue: {} }], - }; +const DATABASE_CONFIG = Token('DATABASE_CONFIG'); + +@Module({ providers: [DatabaseService] }) +export class DatabaseModule { + static configToken = DATABASE_CONFIG; + + static config(config: DatabaseConfig | ProviderConfigObject) { + // Optionally validate config here + return createModuleConfig(DatabaseModule, config); + } + + static configAsync( + config: + | Promise + | ProviderConfigObject> + ) { + // Optionally validate config here (after resolving if needed) + return createModuleConfig(DatabaseModule, config); + } } +``` -// Usage +2. **Consumers** get full type inference and strict errors: + +```typescript const container = new Nexus(); // Synchronous configuration @@ -57,27 +72,29 @@ container.set( ); // Asynchronous configuration -container.set( - DatabaseModule.configAsync(async () => ({ - host: process.env.DB_HOST, - port: parseInt(process.env.DB_PORT), - database: process.env.DB_NAME, +const config = await DatabaseModule.configAsync( + Promise.resolve({ + host: process.env.DB_HOST!, + port: parseInt(process.env.DB_PORT!), + database: process.env.DB_NAME!, username: process.env.DB_USER, password: process.env.DB_PASS, - })) + }) ); +container.set(config); ``` -
+- **Type errors** are shown for missing, extra, or wrong-typed properties. +- No need for generics or helper functions at the call site. +- **Note:** The return type of `createModuleConfig` (and thus your static config methods) is `ModuleConfig` for sync input, or `Promise` for async input. -## Current Implementation +--- -For now, you can achieve similar functionality using the current module system: +## Environment-Specific Modules -### Environment-Specific Modules +You can still use separate modules for different environments if you prefer: ```typescript -// Development module @Module({ providers: [ DatabaseService, @@ -93,25 +110,22 @@ For now, you can achieve similar functionality using the current module system: }) class DevelopmentDatabaseModule {} -// Production module @Module({ providers: [ DatabaseService, { token: DATABASE_CONFIG, useValue: { - host: process.env.DB_HOST, - port: parseInt(process.env.DB_PORT), - database: process.env.DB_NAME, + host: process.env.DB_HOST!, + port: parseInt(process.env.DB_PORT!), + database: process.env.DB_NAME!, }, }, ], }) class ProductionDatabaseModule {} -// Usage const container = new Nexus(); - if (process.env.NODE_ENV === 'production') { container.set(ProductionDatabaseModule); } else { @@ -119,80 +133,11 @@ if (process.env.NODE_ENV === 'production') { } ``` -### Configuration Injection in Services - -Services within the module can inject the configuration: - -```typescript -@Service(DATABASE_SERVICE) -class DatabaseService { - constructor(@Inject(DATABASE_CONFIG) private config: DatabaseConfig) {} - - async connect() { - console.log( - `Connecting to ${this.config.host}:${this.config.port}/${this.config.database}` - ); - // Connection logic - } -} -``` - -## Advanced Configuration Patterns - -### Environment-Specific Configuration - -
-⚠️ Planned Feature - Currently Non-Functional - -This pattern will be supported with dynamic module configuration in future releases. - -```typescript -@Module({ - providers: [LoggerService, { token: LOG_CONFIG, useValue: {} }], -}) -class LoggingModule extends DynamicModule { - protected readonly configToken = LOG_CONFIG; - protected readonly moduleConfig = { - providers: [LoggerService, { token: LOG_CONFIG, useValue: {} }], - }; -} - -// Usage based on environment -const container = new Nexus(); - -// Development configuration -container.set( - LoggingModule.config({ - level: 'debug', - format: 'detailed', - }) -); - -// Production configuration -container.set( - LoggingModule.config({ - level: 'info', - format: 'json', - }) -); - -// Testing configuration -container.set( - LoggingModule.config({ - level: 'error', - format: 'minimal', - }) -); -``` - -
- -### Feature-Based Configuration +--- -
-⚠️ Planned Feature - Currently Non-Functional +## Feature-Based and Composite Configuration -This pattern will be supported with dynamic module configuration in future releases. +You can use the same pattern for feature-based or composite configuration: ```typescript interface EmailConfig { @@ -205,20 +150,18 @@ interface EmailConfig { }; } -@Module({ - providers: [EmailService, { token: EMAIL_CONFIG, useValue: {} }], -}) -class EmailModule extends DynamicModule { - protected readonly configToken = EMAIL_CONFIG; - protected readonly moduleConfig = { - providers: [EmailService, { token: EMAIL_CONFIG, useValue: {} }], - }; +const EMAIL_CONFIG = Symbol('EMAIL_CONFIG'); + +@Module({ providers: [EmailService] }) +export class EmailModule { + static configToken = EMAIL_CONFIG; + static config(config: EmailConfig | ProviderConfigObject) { + return createModuleConfig(EmailModule, config); + } } // Usage const container = new Nexus(); - -// Choose email provider based on configuration if (process.env.EMAIL_PROVIDER === 'sendgrid') { container.set( EmailModule.config({ @@ -238,8 +181,8 @@ if (process.env.EMAIL_PROVIDER === 'sendgrid') { EmailModule.config({ provider: 'smtp', smtpConfig: { - host: process.env.SMTP_HOST, - port: parseInt(process.env.SMTP_PORT), + host: process.env.SMTP_HOST!, + port: parseInt(process.env.SMTP_PORT!), secure: process.env.SMTP_SECURE === 'true', }, }) @@ -247,48 +190,28 @@ if (process.env.EMAIL_PROVIDER === 'sendgrid') { } ``` -
- -### Composite Configuration - -
-⚠️ Planned Feature - Currently Non-Functional +--- -This pattern will be supported with dynamic module configuration in future releases. +## Composite Modules ```typescript -@Module({ - providers: [AppService, { token: APP_CONFIG, useValue: {} }], -}) -class AppModule extends DynamicModule<{ - database: DatabaseConfig; - email: EmailConfig; - logging: LogConfig; -}> { - protected readonly configToken = APP_CONFIG; - protected readonly moduleConfig = { - providers: [AppService, { token: APP_CONFIG, useValue: {} }], - imports: [ - DatabaseModule.config({} as DatabaseConfig), - EmailModule.config({} as EmailConfig), - LoggingModule.config({} as LogConfig), - ], - }; +@Module({ providers: [AppService] }) +export class AppModule { + static configToken = Symbol('APP_CONFIG'); + static config(config: { + database: DatabaseConfig; + email: EmailConfig; + logging: LogConfig; + }) { + return createModuleConfig(AppModule, config); + } } -// Usage const container = new Nexus(); container.set( AppModule.config({ - database: { - host: 'localhost', - port: 5432, - database: 'myapp', - }, - email: { - provider: 'sendgrid', - apiKey: process.env.SENDGRID_API_KEY, - }, + database: { host: 'localhost', port: 5432, database: 'myapp' }, + email: { provider: 'sendgrid', apiKey: process.env.SENDGRID_API_KEY }, logging: { level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', }, @@ -296,50 +219,31 @@ container.set( ); ``` -
+--- ## Configuration Validation -
-⚠️ Planned Feature - Currently Non-Functional - -Configuration validation will be supported with dynamic module configuration in future releases. +You can add validation logic in your static config method before calling the helper: ```typescript -@Module({ - providers: [DatabaseService, { token: DATABASE_CONFIG, useValue: {} }], -}) -class DatabaseModule extends DynamicModule { - protected readonly configToken = DATABASE_CONFIG; - protected readonly moduleConfig = { - providers: [DatabaseService, { token: DATABASE_CONFIG, useValue: {} }], - }; - +@Module({ providers: [DatabaseService] }) +export class DatabaseModule { + static configToken = Symbol('DATABASE_CONFIG'); static config(config: DatabaseConfig) { - // Validate configuration - if (!config.host) { - throw new Error('Database host is required'); - } - if (!config.port || config.port < 1 || config.port > 65535) { + if (!config.host) throw new Error('Database host is required'); + if (!config.port || config.port < 1 || config.port > 65535) throw new Error('Database port must be between 1 and 65535'); - } - if (!config.database) { - throw new Error('Database name is required'); - } - - return super.config(config); + if (!config.database) throw new Error('Database name is required'); + return createModuleConfig(DatabaseModule, config); } } ``` -
+--- ## Testing with Dynamic Modules -
-⚠️ Planned Feature - Currently Non-Functional - -Testing with dynamic module configuration will be supported in future releases. +You can test modules with different configurations by calling the static config method: ```typescript describe('DatabaseModule', () => { @@ -352,7 +256,6 @@ describe('DatabaseModule', () => { database: 'test_db', }) ); - const databaseService = container.get(DATABASE_SERVICE); expect(databaseService).toBeInstanceOf(DatabaseService); }); @@ -372,50 +275,9 @@ describe('DatabaseModule', () => { }); ``` -
- -## Current Testing Approach - -For now, you can test modules using the current approach: - -```typescript -describe('DatabaseModule', () => { - it('should work with test configuration', () => { - const container = new Nexus(); - - // Use a test-specific module - @Module({ - providers: [ - DatabaseService, - { - token: DATABASE_CONFIG, - useValue: { - host: 'localhost', - port: 5432, - database: 'test_db', - }, - }, - ], - }) - class TestDatabaseModule {} - - container.set(TestDatabaseModule); - - const databaseService = container.get(DATABASE_SERVICE); - expect(databaseService).toBeInstanceOf(DatabaseService); - }); -}); -``` - -## Next Steps - -- **[Module Basics](module-basics.md)** - Learn the fundamentals of modules -- **[Module Patterns](module-patterns.md)** - Explore common module patterns -- **[Advanced Providers & Factories](advanced/advanced-providers-and-factories.md)** - Advanced provider configuration - -Dynamic modules will provide powerful runtime configuration capabilities in future releases! 🚀 +--- -## 🚀 Async Dynamic Module Registration +## Async Dynamic Module Registration Sometimes, your modules need to fetch secrets, load configs, or call APIs before they're ready to join the party. That's where `configAsync()` comes in! @@ -430,4 +292,21 @@ container.set(MyModule.configAsync(options)); // Not supported > **Heads up:** Always `await` the result of `configAsync()` before passing it to `set`. The container expects a fully-baked config, not a promise. -With this pattern, your modules will be ready to serve—no half-baked configs allowed. For more on dynamic modules, check out the rest of this guide! +--- + +## Rationale & Benefits + +- **Type safety for consumers**: No need for generics or helpers at the call site; errors are caught at compile time. +- **Low boilerplate for module authors**: Just call the helper in your static method. +- **Consistent logic**: All modules use the same config creation logic, reducing bugs and copy-paste errors. +- **Extensible**: You can add validation, async config, and more advanced patterns as needed. + +--- + +## Next Steps + +- **[Module Basics](module-basics.md)** - Learn the fundamentals of modules +- **[Module Patterns](module-patterns.md)** - Explore common module patterns +- **[Advanced Providers & Factories](advanced/advanced-providers-and-factories.md)** - Advanced provider configuration + +Dynamic modules will provide powerful runtime configuration capabilities in future releases! 🚀 diff --git a/libs/core/src/decorators/inject.test.ts b/libs/core/src/decorators/inject.test.ts index adaea34..e6671df 100644 --- a/libs/core/src/decorators/inject.test.ts +++ b/libs/core/src/decorators/inject.test.ts @@ -3,6 +3,9 @@ import { Inject } from './inject'; import { Token } from '../token'; import { getMetadata } from '../helpers'; import { METADATA_KEYS } from '../constants'; +import { Service } from './provider'; +import { Nexus } from '../container'; +import { NoProvider } from '../exceptions'; describe('@Inject', () => { it('should add injection metadata for constructor parameter', () => { @@ -32,4 +35,72 @@ describe('@Inject', () => { expect(indices).toContain(0); expect(indices).toContain(1); }); + describe('constructor injection', () => { + it('should inject a value', () => { + const TEST_VALUE = 123; + const TEST_TOKEN = new Token('TEST'); + @Service(TEST_TOKEN) + class TestService { + constructor( + @Inject(TEST_TOKEN) private dependency: typeof TEST_VALUE + ) {} + + test() { + return this.dependency; + } + } + + const container = new Nexus(); + container.set(TEST_TOKEN, { useValue: TEST_VALUE }); + const service = container.resolve(TestService); + + expect(service.test()).toBe(TEST_VALUE); + }); + + it('should throw an error if the token is not found', () => { + const TEST_TOKEN = new Token('TEST'); + + @Service() + class TestService { + constructor(@Inject(TEST_TOKEN) dependency: any) {} + } + const container = new Nexus(); + expect(() => container.resolve(TestService)).toThrow(NoProvider); + }); + }); + + describe('property injection', () => { + it('should inject a value', () => { + const TEST_VALUE = 123; + const TEST_TOKEN = new Token('TEST'); + @Service(TEST_TOKEN) + class TestService { + @Inject(TEST_TOKEN) + private dependency!: typeof TEST_VALUE; + + test() { + return this.dependency; + } + } + + const container = new Nexus(); + container.set(TEST_TOKEN, { useValue: TEST_VALUE }); + const service = container.resolve(TestService); + + expect(service.test()).toBe(TEST_VALUE); + }); + + it('should throw an error if the token is not found', () => { + const TEST_VALUE = 123; + const TEST_TOKEN = new Token('TEST'); + + @Service(TEST_TOKEN) + class TestService { + @Inject(TEST_TOKEN) + private dependency!: typeof TEST_VALUE; + } + const container = new Nexus(); + expect(() => container.resolve(TestService)).toThrow(NoProvider); + }); + }); }); diff --git a/libs/core/src/decorators/module.test.ts b/libs/core/src/decorators/module.test.ts index ab974aa..5d8bbc8 100644 --- a/libs/core/src/decorators/module.test.ts +++ b/libs/core/src/decorators/module.test.ts @@ -3,7 +3,9 @@ import { Module } from './module'; import { Token } from '../token'; import { getMetadata } from '../helpers'; import { METADATA_KEYS } from '../constants'; +import { Provider, Service } from './provider'; +// --- Basic metadata attachment --- describe('@Module', () => { beforeEach(() => { if ((class TestModule {} as any)[(Symbol as any).metadata]) @@ -23,30 +25,109 @@ describe('@Module', () => { }); }); - it('should handle module with services', () => { - class TestService {} - @Module({ providers: [TestService] }) - class TestModule {} - const metadata = getMetadata(TestModule, METADATA_KEYS.MODULE_METADATA); - expect(metadata.providers).toEqual([TestService]); - }); - it('should handle module with providers', () => { const TEST_TOKEN = new Token('TEST'); - @Module({ - providers: [{ token: TEST_TOKEN, useClass: class TestProvider {} }], - }) + class TestProvider {} + + @Module({ providers: [{ token: TEST_TOKEN, useClass: TestProvider }] }) class TestModule {} + const metadata = getMetadata(TestModule, METADATA_KEYS.MODULE_METADATA); expect(metadata.providers).toHaveLength(1); expect(metadata.providers[0].token).toBe(TEST_TOKEN); }); it('should handle module with imports', () => { + @Module({ providers: [], exports: [] }) class ImportedModule {} + @Module({ imports: [ImportedModule] }) class TestModule {} const metadata = getMetadata(TestModule, METADATA_KEYS.MODULE_METADATA); expect(metadata.imports).toEqual([ImportedModule]); }); + + it('should attach metadata to a decorated class (robust)', () => { + @Provider() + class ProviderA { + foo = 'bar'; + } + + @Service() + class ServiceA { + constructor(private provider: ProviderA) {} + + test() { + return this.provider.foo; + } + } + + @Module({ + providers: [ProviderA, ServiceA], + imports: [], + exports: [], + }) + class BasicModule {} + + const metadata = getMetadata(BasicModule, METADATA_KEYS.MODULE_METADATA); + expect(metadata).toBeDefined(); + expect(metadata.providers).toBeInstanceOf(Array); + expect(metadata.imports).toBeInstanceOf(Array); + expect(metadata.exports).toBeInstanceOf(Array); + }); + + it('should support providers with class and symbol tokens', () => { + const SymbolToken = Symbol('SYMBOL_TOKEN'); + class ClassToken {} + @Module({ + providers: [ + { token: SymbolToken, useValue: 2 }, + { token: ClassToken, useValue: 3 }, + ], + }) + class TokenModule {} + const metadata = getMetadata(TokenModule, METADATA_KEYS.MODULE_METADATA); + expect(metadata.providers.some((p: any) => p.token === SymbolToken)).toBe( + true + ); + expect(metadata.providers.some((p: any) => p.token === ClassToken)).toBe( + true + ); + }); + + it('should handle modules with empty or missing arrays', () => { + @Module({}) + class EmptyModule {} + const metadata = getMetadata(EmptyModule, METADATA_KEYS.MODULE_METADATA); + expect(metadata).toBeDefined(); + expect(metadata.providers ?? []).toBeInstanceOf(Array); + expect(metadata.imports ?? []).toBeInstanceOf(Array); + expect(metadata.exports ?? []).toBeInstanceOf(Array); + }); +}); + +// --- Inheritance --- +describe('Module inheritance', () => { + it('should require @Module on subclass to be treated as a module', () => { + class ProviderB {} + @Module({ providers: [ProviderB] }) + class ParentModule {} + class SubModule extends ParentModule {} + // Should not have its own Symbol.metadata property + expect( + Object.prototype.hasOwnProperty.call(SubModule, (Symbol as any).metadata) + ).toBe(false); + // Should inherit parent's MODULE_METADATA value + const parentMeta = getMetadata(ParentModule, METADATA_KEYS.MODULE_METADATA); + const subMeta = getMetadata(SubModule, METADATA_KEYS.MODULE_METADATA); + expect(subMeta).toEqual(parentMeta); + }); + + it('should have metadata on parent if decorated', () => { + @Module({ providers: [class ProviderB {}] }) + class ParentModule {} + const metadata = getMetadata(ParentModule, METADATA_KEYS.MODULE_METADATA); + expect(metadata).toBeDefined(); + expect(metadata.providers).toBeInstanceOf(Array); + }); }); diff --git a/libs/core/src/dynamic-module-examples.test.ts b/libs/core/src/dynamic-module-examples.test.ts new file mode 100644 index 0000000..be1e5ab --- /dev/null +++ b/libs/core/src/dynamic-module-examples.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect } from 'vitest'; +import { Module } from './decorators/module'; +import { DynamicModule, createModuleConfig } from './dynamic-module'; +import { Nexus } from './container'; +import type { ModuleConfig } from './types'; +import { Token } from './token'; + +/** + * Real-world Dynamic Module Examples + * + * These examples demonstrate practical usage patterns for dynamic modules + * that users can follow in their own applications. + */ + +describe('Real-world Dynamic Module Usage Examples', () => { + // Example 1: Simple Configuration Module + describe('Example 1: Database Configuration', () => { + interface DatabaseConfig { + host: string; + port: number; + database: string; + } + + const DB_CONFIG_TOKEN = new Token('DB_CONFIG'); + + @Module({}) + class DatabaseModule extends DynamicModule { + static override configToken = DB_CONFIG_TOKEN; + + static config(config: DatabaseConfig): ModuleConfig { + return createModuleConfig(this, config); + } + + static async configAsync( + configPromise: Promise + ): Promise { + return createModuleConfig(this, configPromise); + } + } + + it('should create sync configuration', () => { + const config: DatabaseConfig = { + host: 'localhost', + port: 5432, + database: 'myapp', + }; + + const moduleConfig = DatabaseModule.config(config); + + // The module config should contain a provider for the config + expect(moduleConfig.providers).toHaveLength(1); + + const configProvider = moduleConfig.providers?.[0] as any; + expect(configProvider.token).toBe(DB_CONFIG_TOKEN); + expect(configProvider.useValue).toEqual(config); + }); + + it('should create async configuration', async () => { + const config: DatabaseConfig = { + host: 'remote-db.com', + port: 5432, + database: 'production', + }; + + const moduleConfig = await DatabaseModule.configAsync( + Promise.resolve(config) + ); + + expect(moduleConfig.providers).toHaveLength(1); + + const configProvider = moduleConfig.providers?.[0] as any; + expect(configProvider.token).toBe(DB_CONFIG_TOKEN); + expect(configProvider.useValue).toEqual(config); + }); + + it('should work with container', () => { + const config: DatabaseConfig = { + host: 'localhost', + port: 5432, + database: 'test', + }; + + const moduleConfig = DatabaseModule.config(config); + const container = new Nexus(); + + // Register the module configuration + container.set(moduleConfig); + + // Get the config from the container + const resolvedConfig = container.get(DB_CONFIG_TOKEN); + expect(resolvedConfig).toEqual(config); + }); + }); + + // Example 2: Environment-based Configuration + describe('Example 2: Environment Configurations', () => { + interface ApiConfig { + baseUrl: string; + timeout: number; + apiKey?: string; + } + + const API_CONFIG_TOKEN = new Token('API_CONFIG'); + + @Module({}) + class ApiModule extends DynamicModule { + static override configToken = API_CONFIG_TOKEN; + + static config(config: ApiConfig): ModuleConfig { + return createModuleConfig(this, config); + } + + // Environment-specific factory methods + static forDevelopment(): ModuleConfig { + return this.config({ + baseUrl: 'http://localhost:3000/api', + timeout: 5000, + }); + } + + static forProduction(apiKey: string): ModuleConfig { + return this.config({ + baseUrl: 'https://api.myapp.com', + timeout: 10000, + apiKey, + }); + } + + static forTesting(): ModuleConfig { + return this.config({ + baseUrl: 'https://mock.api.com', + timeout: 1000, + }); + } + } + + it('should provide development configuration', () => { + const moduleConfig = ApiModule.forDevelopment(); + const container = new Nexus(); + container.set(moduleConfig); + + const config = container.get(API_CONFIG_TOKEN); + expect(config.baseUrl).toBe('http://localhost:3000/api'); + expect(config.timeout).toBe(5000); + expect(config.apiKey).toBeUndefined(); + }); + + it('should provide production configuration', () => { + const apiKey = 'prod-api-key-123'; + const moduleConfig = ApiModule.forProduction(apiKey); + const container = new Nexus(); + container.set(moduleConfig); + + const config = container.get(API_CONFIG_TOKEN); + expect(config.baseUrl).toBe('https://api.myapp.com'); + expect(config.timeout).toBe(10000); + expect(config.apiKey).toBe(apiKey); + }); + + it('should provide testing configuration', () => { + const moduleConfig = ApiModule.forTesting(); + const container = new Nexus(); + container.set(moduleConfig); + + const config = container.get(API_CONFIG_TOKEN); + expect(config.baseUrl).toBe('https://mock.api.com'); + expect(config.timeout).toBe(1000); + }); + }); + + // Example 3: Factory-based Configuration + describe('Example 3: Factory Configuration', () => { + interface LoggerConfig { + level: 'debug' | 'info' | 'warn' | 'error'; + format: 'json' | 'text'; + outputs: string[]; + } + + const LOGGER_CONFIG_TOKEN = new Token('LOGGER_CONFIG'); + + @Module({}) + class LoggerModule extends DynamicModule { + static override configToken = LOGGER_CONFIG_TOKEN; + + static config(config: LoggerConfig): ModuleConfig { + return createModuleConfig(this, config); + } + + static withFactory(factory: () => LoggerConfig): ModuleConfig { + return createModuleConfig(this, { useFactory: factory }); + } + + static async configAsync( + factory: () => Promise + ): Promise { + return createModuleConfig(this, { useFactory: factory }); + } + } + + it('should work with sync factory', () => { + const moduleConfig = LoggerModule.withFactory(() => ({ + level: 'info', + format: 'json', + outputs: ['console', 'file'], + })); + + expect(moduleConfig.providers).toHaveLength(1); + + const configProvider = moduleConfig.providers?.[0] as any; + expect(configProvider.token).toBe(LOGGER_CONFIG_TOKEN); + expect(configProvider.useFactory).toBeDefined(); + expect(typeof configProvider.useFactory).toBe('function'); + }); + + it('should work with async factory', async () => { + const moduleConfig = await LoggerModule.configAsync(async () => { + // Simulate loading config from external source + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + level: 'debug' as const, + format: 'text' as const, + outputs: ['console'], + }; + }); + + expect(moduleConfig.providers).toHaveLength(1); + + const configProvider = moduleConfig.providers?.[0] as any; + expect(configProvider.token).toBe(LOGGER_CONFIG_TOKEN); + expect(configProvider.useFactory).toBeDefined(); + }); + + it('should resolve factory in container', () => { + const moduleConfig = LoggerModule.withFactory(() => ({ + level: 'warn', + format: 'json', + outputs: ['file'], + })); + + const container = new Nexus(); + container.set(moduleConfig); + + const config = container.get(LOGGER_CONFIG_TOKEN); + expect(config.level).toBe('warn'); + expect(config.format).toBe('json'); + expect(config.outputs).toEqual(['file']); + }); + }); + + // Example 4: Conditional Module Registration + describe('Example 4: Conditional Registration', () => { + interface FeatureConfig { + enabled: boolean; + options: Record; + } + + const FEATURE_CONFIG_TOKEN = new Token('FEATURE_CONFIG'); + + @Module({}) + class FeatureModule extends DynamicModule { + static override configToken = FEATURE_CONFIG_TOKEN; + + static config(config: FeatureConfig): ModuleConfig { + return createModuleConfig(this, config); + } + + static enable(options: Record = {}): ModuleConfig { + return this.config({ enabled: true, options }); + } + + static disable(): ModuleConfig { + return this.config({ enabled: false, options: {} }); + } + + static conditional( + condition: boolean, + options: Record = {} + ): ModuleConfig { + return condition ? this.enable(options) : this.disable(); + } + } + + it('should enable feature with options', () => { + const options = { maxRetries: 3, timeout: 5000 }; + const moduleConfig = FeatureModule.enable(options); + + const container = new Nexus(); + container.set(moduleConfig); + + const config = container.get(FEATURE_CONFIG_TOKEN); + expect(config.enabled).toBe(true); + expect(config.options).toEqual(options); + }); + + it('should disable feature', () => { + const moduleConfig = FeatureModule.disable(); + + const container = new Nexus(); + container.set(moduleConfig); + + const config = container.get(FEATURE_CONFIG_TOKEN); + expect(config.enabled).toBe(false); + expect(config.options).toEqual({}); + }); + + it('should conditionally register based on environment', () => { + const isProduction = false; + const moduleConfig = FeatureModule.conditional(isProduction, { + debugMode: true, + }); + + const container = new Nexus(); + container.set(moduleConfig); + + const config = container.get(FEATURE_CONFIG_TOKEN); + expect(config.enabled).toBe(false); // because isProduction is false + }); + }); + + // Example 5: Multiple Dynamic Modules + describe('Example 5: Combining Multiple Dynamic Modules', () => { + // Define separate config tokens and modules + const DATABASE_TOKEN = new Token<{ host: string; port: number }>( + 'DATABASE' + ); + const CACHE_TOKEN = new Token<{ maxSize: number; ttl: number }>('CACHE'); + const METRICS_TOKEN = new Token<{ enabled: boolean; endpoint: string }>( + 'METRICS' + ); + + @Module({}) + class DatabaseModule extends DynamicModule { + static override configToken = DATABASE_TOKEN; + static config(config: { host: string; port: number }) { + return createModuleConfig(this, config); + } + } + + @Module({}) + class CacheModule extends DynamicModule { + static override configToken = CACHE_TOKEN; + static config(config: { maxSize: number; ttl: number }) { + return createModuleConfig(this, config); + } + } + + @Module({}) + class MetricsModule extends DynamicModule { + static override configToken = METRICS_TOKEN; + static config(config: { enabled: boolean; endpoint: string }) { + return createModuleConfig(this, config); + } + } + + it('should register multiple dynamic modules', () => { + const dbConfig = DatabaseModule.config({ + host: 'localhost', + port: 5432, + }); + + const cacheConfig = CacheModule.config({ + maxSize: 1000, + ttl: 3600, + }); + + const metricsConfig = MetricsModule.config({ + enabled: true, + endpoint: '/metrics', + }); + + const container = new Nexus(); + + // Register all modules + container.set(dbConfig); + container.set(cacheConfig); + container.set(metricsConfig); + + // All configurations should be available + const db = container.get(DATABASE_TOKEN); + const cache = container.get(CACHE_TOKEN); + const metrics = container.get(METRICS_TOKEN); + + expect(db).toEqual({ host: 'localhost', port: 5432 }); + expect(cache).toEqual({ maxSize: 1000, ttl: 3600 }); + expect(metrics).toEqual({ enabled: true, endpoint: '/metrics' }); + }); + }); + + // Example 6: Using with Class Providers + describe('Example 6: Class-based Configuration', () => { + interface AppConfig { + version: string; + environment: string; + } + + const APP_CONFIG_TOKEN = new Token('APP_CONFIG'); + + class DevelopmentConfig implements AppConfig { + version = '1.0.0-dev'; + environment = 'development'; + } + + class ProductionConfig implements AppConfig { + version = '1.0.0'; + environment = 'production'; + } + + @Module({}) + class AppModule extends DynamicModule { + static override configToken = APP_CONFIG_TOKEN; + + static config(config: AppConfig): ModuleConfig { + return createModuleConfig(this, config); + } + + static withClass AppConfig>( + configClass: T + ): ModuleConfig { + return createModuleConfig(this, { useClass: configClass }); + } + } + + it('should work with class provider', () => { + const moduleConfig = AppModule.withClass(DevelopmentConfig); + + const container = new Nexus(); + container.set(moduleConfig); + + const config = container.get(APP_CONFIG_TOKEN); + expect(config.version).toBe('1.0.0-dev'); + expect(config.environment).toBe('development'); + }); + + it('should work with different class providers', () => { + const moduleConfig = AppModule.withClass(ProductionConfig); + + const container = new Nexus(); + container.set(moduleConfig); + + const config = container.get(APP_CONFIG_TOKEN); + expect(config.version).toBe('1.0.0'); + expect(config.environment).toBe('production'); + }); + }); +}); diff --git a/libs/core/src/dynamic-module.test.ts b/libs/core/src/dynamic-module.test.ts new file mode 100644 index 0000000..673f8c6 --- /dev/null +++ b/libs/core/src/dynamic-module.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect } from 'vitest'; +import { Module } from './decorators/module'; +import { DynamicModule, createModuleConfig } from './dynamic-module'; +import { InvalidModule } from './exceptions/invalid-module.exception'; +import type { ProviderConfigObject, ModuleConfig } from './types'; + +// Test config type +type TestConfig = { + foo: string; + quz: number; +}; + +// Valid config class for testing +class ValidConfigClass { + foo = 'ok'; + quz = 42; +} + +// Invalid config class for testing (wrong types) +class InvalidConfigClass { + foo = 'bad'; + quz = 'not-a-number'; // Should be number +} + +// Dummy config token for testing +const TEST_CONFIG_TOKEN = Symbol('TEST_CONFIG'); + +// Test dynamic module +@Module({ + providers: [], +}) +class TestDynamicModule extends DynamicModule { + static override configToken = TEST_CONFIG_TOKEN; + + static config(config: TestConfig | ProviderConfigObject) { + return createModuleConfig(this, config); + } + + static configAsync( + config: Promise | ProviderConfigObject> + ) { + return createModuleConfig(this, config); + } +} + +// Module without @Module decorator for error testing +class NotDecorated extends DynamicModule { + protected readonly configToken = Symbol('NOT_DECORATED'); +} + +describe('DynamicModule', () => { + it('should read @Module metadata from subclasses', () => { + const metadata = TestDynamicModule.getModuleConfig(); + expect(metadata).toBeDefined(); + expect(metadata.providers).toEqual([]); + }); + + it('should return the correct configToken from subclass', () => { + expect(TestDynamicModule.getConfigToken()).toBe(TEST_CONFIG_TOKEN); + }); + + it('should create a config module with config() using useValue', () => { + const result = TestDynamicModule.config({ + useValue: { foo: 'bar', quz: 123 }, + }); + // ^? + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useValue' in p + ); + expect(provider).toBeDefined(); + expect((provider as any).useValue.foo).toBe('bar'); + expect((provider as any).useValue.quz).toBe(123); + }); + + it('should create a config module with config() using useClass', () => { + class MyConfigClass { + foo = 'classy'; + quz = 42; + } + const result = TestDynamicModule.config({ + useClass: MyConfigClass, + }); + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useClass' in p + ); + expect(provider).toBeDefined(); + expect((provider as any).useClass).toBe(MyConfigClass); + }); + + it('should create a config module with config() using useFactory', () => { + const result = TestDynamicModule.config({ + useFactory: () => ({ foo: 'factory', quz: 1 }), + }); + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useFactory' in p + ); + expect(provider).toBeDefined(); + expect(typeof (provider as any).useFactory).toBe('function'); + expect((provider as any).useFactory().foo).toBe('factory'); + expect((provider as any).useFactory().quz).toBe(1); + }); + + it('should create a config module with config() using a plain value', () => { + const result = TestDynamicModule.config({ + useValue: { foo: 'plain', quz: 123 }, + }); + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useValue' in p + ); + expect(provider).toBeDefined(); + expect((provider as any).useValue.foo).toBe('plain'); + expect((provider as any).useValue.quz).toBe(123); + }); + + it('should create a config module with configAsync() using async useFactory', async () => { + const result = await TestDynamicModule.configAsync({ + useFactory: async () => ({ foo: 'baz', quz: 2 }), + }); + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useFactory' in p + ); + expect(provider).toBeDefined(); + expect(typeof (provider as any).useFactory).toBe('function'); + // Test that the factory can be called and returns the expected result + const factoryResult = await (provider as any).useFactory(); + expect(factoryResult.foo).toBe('baz'); + expect(factoryResult.quz).toBe(2); + }); + + it('should create a config module with configAsync() using async useValue', async () => { + const result = await TestDynamicModule.configAsync( + Promise.resolve({ foo: 'asyncValue', quz: 3 }) + ); + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useValue' in p + ); + expect(provider).toBeDefined(); + const value = await (provider as any).useValue; + expect(value.foo).toBe('asyncValue'); + expect(value.quz).toBe(3); + }); + + it('should inherit configToken from parent if not overridden', () => { + class ParentDynamicModule extends DynamicModule { + static override configToken = Symbol('PARENT_TOKEN'); + } + @Module({ providers: [] }) + class ChildDynamicModule extends ParentDynamicModule {} + expect(ChildDynamicModule.getConfigToken()).toBe( + ParentDynamicModule.configToken + ); + }); + + it('should allow subclass to override configToken', () => { + class ParentDynamicModule extends DynamicModule { + static override configToken = Symbol('PARENT_TOKEN'); + } + const CHILD_TOKEN = Symbol('CHILD_TOKEN'); + @Module({ providers: [] }) + class ChildDynamicModule extends ParentDynamicModule { + static override configToken = CHILD_TOKEN; + } + expect(ChildDynamicModule.getConfigToken()).toBe(CHILD_TOKEN); + expect(ChildDynamicModule.getConfigToken()).not.toBe( + ParentDynamicModule.configToken + ); + }); + + it('should return undefined for configToken if not set on any class', () => { + @Module({ providers: [] }) + class NoTokenDynamicModule extends DynamicModule {} + expect(NoTokenDynamicModule.getConfigToken()).toBeUndefined(); + }); + + it('should ensure configToken is unique per subclass', () => { + const TOKEN_A = Symbol('TOKEN_A'); + const TOKEN_B = Symbol('TOKEN_B'); + @Module({ providers: [] }) + class ModuleA extends DynamicModule { + static override configToken = TOKEN_A; + } + @Module({ providers: [] }) + class ModuleB extends DynamicModule { + static override configToken = TOKEN_B; + } + expect(ModuleA.getConfigToken()).toBe(TOKEN_A); + expect(ModuleB.getConfigToken()).toBe(TOKEN_B); + expect(ModuleA.getConfigToken()).not.toBe(ModuleB.getConfigToken()); + }); + + it('should throw if not decorated with @Module', () => { + expect(() => NotDecorated.getModuleConfig()).toThrowError(InvalidModule); + }); +}); + +describe('createModuleConfig (strict type safety)', () => { + it('accepts a valid config object', () => { + const result = TestDynamicModule.config({ foo: 'bar', quz: 123 }); + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useValue' in p + ); + expect(provider).toBeDefined(); + expect((provider as any).useValue.foo).toBe('bar'); + expect((provider as any).useValue.quz).toBe(123); + }); + + it('accepts a valid useClass', () => { + const result = TestDynamicModule.config({ useClass: ValidConfigClass }); + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useClass' in p + ); + expect(provider).toBeDefined(); + expect((provider as any).useClass).toBe(ValidConfigClass); + }); + + it('rejects an invalid useClass', () => { + // This should error at compile time + // TestDynamicModule.config({ useClass: InvalidConfigClass }); + expect(true).toBe(true); + }); + + it('accepts a valid useFactory', () => { + const result = TestDynamicModule.config({ + useFactory: () => ({ foo: 'factory', quz: 1 }), + }); + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useFactory' in p + ); + expect(provider).toBeDefined(); + expect(typeof (provider as any).useFactory).toBe('function'); + expect((provider as any).useFactory().foo).toBe('factory'); + expect((provider as any).useFactory().quz).toBe(1); + }); + + it('rejects a useFactory with wrong return type', () => { + // This should error at compile time + // TestDynamicModule.config({ useFactory: () => ({ foo: 'bad', quz: 'not-a-number' }) }); + expect(true).toBe(true); + }); + + it('rejects extra properties in config object', () => { + // This should error at compile time + // TestDynamicModule.config({ foo: 'bar', quz: 1, baz: 'extra' }); + expect(true).toBe(true); + }); +}); + +describe('createModuleConfigAsync', () => { + it('accepts a valid async useFactory', async () => { + const result = await TestDynamicModule.configAsync({ + useFactory: async () => ({ foo: 'baz', quz: 2 }), + }); + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useFactory' in p + ); + expect(provider).toBeDefined(); + expect(typeof (provider as any).useFactory).toBe('function'); + // Test that the factory can be called and returns the expected result + const factoryResult = await (provider as any).useFactory(); + expect(factoryResult.foo).toBe('baz'); + expect(factoryResult.quz).toBe(2); + }); + + it('accepts a valid async useValue', async () => { + const result = await TestDynamicModule.configAsync( + Promise.resolve({ foo: 'asyncValue', quz: 3 }) + ); + const provider = (result.providers ?? []).find( + (p: any) => typeof p === 'object' && p !== null && 'useValue' in p + ); + expect(provider).toBeDefined(); + const value = await (provider as any).useValue; + expect(value.foo).toBe('asyncValue'); + expect(value.quz).toBe(3); + }); +}); + +describe('Module error handling', () => { + it('should throw if not decorated with @Module', () => { + expect(() => NotDecorated.getModuleConfig()).toThrowError(InvalidModule); + }); +}); diff --git a/libs/core/src/dynamic-module.ts b/libs/core/src/dynamic-module.ts new file mode 100644 index 0000000..c903abc --- /dev/null +++ b/libs/core/src/dynamic-module.ts @@ -0,0 +1,131 @@ +// DynamicModule and related types moved from module.ts + +import type { ModuleConfig, TokenType, ProviderConfigObject } from './types'; +import { METADATA_KEYS } from './constants'; +import { getMetadata } from './helpers'; +import { InvalidModule } from './exceptions/invalid-module.exception'; +import { isProvider, isFactory, isPromise } from './guards'; + +/** + * Represents a dynamic module, allowing for runtime configuration of providers and imports. + * + * @example + * import { DynamicModule } from '@nexusdi/core'; + * const dynamic = DynamicModule.forRoot({ providers: [LoggerService] }); + * @see https://nexus.js.org/docs/modules/dynamic-modules + */ +export abstract class DynamicModule { + static configToken: TokenType; + + /** + * Gets the module configuration from the decorator metadata + */ + static getModuleConfig( + this: T + ): ModuleConfig { + const moduleConfig = getMetadata(this, METADATA_KEYS.MODULE_METADATA); + if (!moduleConfig) { + throw new InvalidModule(this); + } + return moduleConfig; + } + + /** + * Returns the config token for the module + */ + static getConfigToken( + this: T + ): TokenType { + return this.configToken; + } +} + +/** + * Creates a ModuleConfig for a dynamic module from a config object, provider config, or async variant. + * + * @param moduleClass The module class (should have a static configToken property) + * @param config The config object, provider config, promise, or provider config with promise + * @returns ModuleConfig or Promise + */ +// Overload for sync configs (non-promise values) +export function createModuleConfig( + moduleClass: { configToken: TokenType }, + config: T | ProviderConfigObject +): ModuleConfig; + +// Overload for async configs (promises) +export function createModuleConfig( + moduleClass: { configToken: TokenType }, + config: Promise | ProviderConfigObject> +): Promise; + +// Implementation +export function createModuleConfig( + moduleClass: { configToken: TokenType }, + config: + | T + | ProviderConfigObject + | Promise + | ProviderConfigObject> +): ModuleConfig | Promise { + // If config is a factory provider + if (isFactory(config)) { + // Don't execute the factory here - let the DI container handle it + // Just pass the factory configuration through with the token + return { + providers: [ + { + ...config, + token: moduleClass.configToken, + }, + ], + }; + } + + // If config is a provider config (but not a factory) + if (isProvider(config)) { + // If useValue is a promise + if ('useValue' in config && isPromise(config.useValue)) { + return Promise.resolve(config.useValue).then((resolved) => ({ + providers: [ + { + ...config, + useValue: resolved, + token: moduleClass.configToken, + }, + ], + })); + } + // Otherwise, sync provider config + return { + providers: [ + { + ...config, + token: moduleClass.configToken, + }, + ], + }; + } + + // If config is a Promise + if (isPromise(config)) { + return Promise.resolve(config).then((resolved) => ({ + providers: [ + { + useValue: resolved, + token: moduleClass.configToken, + }, + ], + })); + } + + // Otherwise, it's a plain config object + return { + providers: [ + { + useValue: config, + token: moduleClass.configToken, + }, + ], + }; +} diff --git a/libs/core/src/guards.ts b/libs/core/src/guards.ts index a66b0c4..5cada64 100644 --- a/libs/core/src/guards.ts +++ b/libs/core/src/guards.ts @@ -101,3 +101,7 @@ export function isModuleConfig( !!obj && typeof obj === 'object' && ('providers' in obj || 'imports' in obj) ); } + +export function isPromise(val: any): val is Promise { + return !!val && typeof val.then === 'function'; +} diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 83e2aca..838d969 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -29,7 +29,7 @@ export { } from './exceptions'; // Dynamic Module -export { DynamicModule } from './module'; +export { DynamicModule } from './dynamic-module'; // Types export type { diff --git a/libs/core/src/module.test.ts b/libs/core/src/module.test.ts deleted file mode 100644 index 96a3ec8..0000000 --- a/libs/core/src/module.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { Module } from './decorators'; -import { DynamicModule } from './module'; -import { getMetadata } from './helpers'; -import { METADATA_KEYS } from './constants'; -import { InvalidModule } from './exceptions/invalid-module.exception'; - -// Dummy config token for testing -const TEST_CONFIG_TOKEN = Symbol('TEST_CONFIG'); - -/** - * TestDynamicModule: Ensures that subclasses of DynamicModule decorated with @Module - * are recognized correctly and their metadata is accessible. This prevents regressions - * where subclass metadata was not read due to static method context issues. - */ -@Module({ - providers: [], -}) -class TestDynamicModule extends DynamicModule { - protected readonly configToken = TEST_CONFIG_TOKEN; -} - -/** - * BasicModule: Tests that the @Module decorator attaches metadata to a class and - * that all properties (providers, services, imports, exports) are reflected correctly. - * This ensures the decorator works for standard modules, not just DynamicModule. - */ -@Module({ - providers: [class ProviderA {}, class ServiceA {}], - imports: [], - exports: [], -}) -class BasicModule {} - -/** - * InheritedModule: Tests that a subclass of a module can override or inherit metadata. - * This ensures that module inheritance does not break metadata reflection. - */ -@Module({ providers: [class ProviderB {}] }) -class ParentModule {} - -/** - * NotDecorated: Used to test error handling when getModuleConfig is called on a class - * that is not decorated with @Module. This ensures clear error messages for misconfiguration. - */ -class NotDecorated extends DynamicModule { - protected readonly configToken = Symbol('NOT_DECORATED'); -} - -describe('DynamicModule', () => { - /** - * Ensures that subclasses of DynamicModule decorated with @Module are recognized - * and their metadata is accessible. Prevents regressions of the metadata bug. - */ - it('should read @Module metadata from subclasses', () => { - const config = TestDynamicModule.getModuleConfig(); - expect(config).toBeDefined(); - expect(config.providers).toBeInstanceOf(Array); - }); - - /** - * Ensures that the configToken is correctly returned from the subclass. - * This is important for dynamic module configuration and DI token management. - */ - it('should return the correct configToken from subclass', () => { - expect(TestDynamicModule.getConfigToken()).toBe(TEST_CONFIG_TOKEN); - }); - - /** - * Ensures that config() creates a module config with the provided config value. - * This is important for passing runtime configuration to modules. - */ - it('should create a config module with config()', () => { - const result = TestDynamicModule.config({ foo: 'bar' }); - const hasConfigProvider = result.providers.some( - (p) => - typeof p === 'object' && - p !== null && - 'useValue' in p && - (p as any).useValue && - (p as any).useValue.foo === 'bar' - ); - expect(hasConfigProvider).toBe(true); - }); - - /** - * Ensures that configAsync() creates a module config with an async factory. - * This is important for supporting async configuration (e.g., from env or remote sources). - */ - it('should create a config module with configAsync()', async () => { - const result = TestDynamicModule.configAsync(async () => ({ foo: 'baz' })); - const provider = result.providers.find( - (p) => - typeof p === 'object' && - p !== null && - 'useFactory' in p && - typeof (p as any).useFactory === 'function' - ); - expect(provider).toBeDefined(); - const value = await (provider as any).useFactory(); - expect(value.foo).toBe('baz'); - }); -}); - -describe('Module decorator', () => { - /** - * Tests that the @Module decorator attaches metadata to a decorated class and - * that all properties are reflected correctly. This ensures the decorator works - * for standard modules, not just DynamicModule. - */ - it('should attach metadata to a decorated class', () => { - const metadata = getMetadata(BasicModule, METADATA_KEYS.MODULE_METADATA); - expect(metadata).toBeDefined(); - expect(metadata.providers).toBeInstanceOf(Array); - expect(metadata.providers).toBeInstanceOf(Array); - expect(metadata.imports).toBeInstanceOf(Array); - expect(metadata.exports).toBeInstanceOf(Array); - }); -}); - -describe('Module inheritance', () => { - /** - * Test: Subclass without @Module is not a valid module - * Validates: Subclasses must be decorated with @Module to be treated as modules - * Value: Ensures that only explicitly decorated classes are treated as modules, preventing accidental inheritance of module status. - */ - it('should require @Module on subclass to be treated as a module', () => { - class ProviderB {} - @Module({ providers: [ProviderB] }) - class ParentModule {} - class SubModule extends ParentModule {} - - // Should not have its own Symbol.metadata property - expect( - Object.prototype.hasOwnProperty.call(SubModule, (Symbol as any).metadata) - ).toBe(false); - - // Should inherit parent's MODULE_METADATA value - const parentMeta = getMetadata(ParentModule, METADATA_KEYS.MODULE_METADATA); - const subMeta = getMetadata(SubModule, METADATA_KEYS.MODULE_METADATA); - expect(subMeta).toEqual(parentMeta); - }); - - it('should have metadata on parent if decorated', () => { - const metadata = getMetadata(ParentModule, METADATA_KEYS.MODULE_METADATA); - expect(metadata).toBeDefined(); - expect(metadata.providers).toBeInstanceOf(Array); - }); -}); - -describe('Module error handling', () => { - /** - * Tests that calling getModuleConfig on a class not decorated with @Module throws an error. - * This ensures clear error messages for misconfiguration and helps developers debug quickly. - */ - it('should throw if not decorated with @Module', () => { - expect(() => NotDecorated.getModuleConfig()).toThrowError(InvalidModule); - }); -}); - -describe('Module token handling', () => { - /** - * Tests that providers with different token types (class, string, symbol) are handled correctly. - * This ensures the DI system is robust to various token types. - */ - it('should support providers with class and symbol tokens', () => { - const SymbolToken = Symbol('SYMBOL_TOKEN'); - class ClassToken {} - @Module({ - providers: [ - { token: SymbolToken, useValue: 2 }, - { token: ClassToken, useValue: 3 }, - ], - }) - class TokenModule {} - const metadata = getMetadata(TokenModule, METADATA_KEYS.MODULE_METADATA); - expect(metadata.providers.some((p: any) => p.token === SymbolToken)).toBe( - true - ); - expect(metadata.providers.some((p: any) => p.token === ClassToken)).toBe( - true - ); - }); -}); - -describe('Module edge cases', () => { - /** - * Tests that modules with empty or missing arrays for providers, services, etc., do not break. - * This ensures the module system is robust to minimal or incomplete configs. - */ - it('should handle modules with empty or missing arrays', () => { - @Module({}) - class EmptyModule {} - const metadata = getMetadata(EmptyModule, METADATA_KEYS.MODULE_METADATA); - expect(metadata).toBeDefined(); - expect(metadata.providers ?? []).toBeInstanceOf(Array); - expect(metadata.providers ?? []).toBeInstanceOf(Array); - expect(metadata.imports ?? []).toBeInstanceOf(Array); - expect(metadata.exports ?? []).toBeInstanceOf(Array); - }); -}); diff --git a/libs/core/src/module.ts b/libs/core/src/module.ts deleted file mode 100644 index 9b8a24c..0000000 --- a/libs/core/src/module.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Module } from './decorators'; -import type { ModuleConfig, TokenType } from './types'; -import { METADATA_KEYS } from './constants'; -import type { Token } from './token'; -import { setMetadata, getMetadata } from './helpers'; -import { InvalidModule } from './exceptions/invalid-module.exception'; - -/** - * Represents a dynamic module, allowing for runtime configuration of providers and imports. - * - * @example - * import { DynamicModule } from '@nexusdi/core'; - * const dynamic = DynamicModule.forRoot({ providers: [LoggerService] }); - * @see https://nexus.js.org/docs/modules/dynamic-modules - */ -export abstract class DynamicModule { - protected abstract readonly configToken: Token | string | symbol; - - /** - * Gets the module configuration from the decorator metadata - */ - static getModuleConfig( - this: T - ): ModuleConfig { - const moduleConfig = getMetadata(this, METADATA_KEYS.MODULE_METADATA); - if (!moduleConfig) { - throw new InvalidModule(this); - } - return moduleConfig; - } - - /** - * Returns the config token for the module - */ - static getConfigToken(this: T): TokenType { - // Cast to a constructor type that has a parameterless constructor and configToken property - type WithConfigToken = { new (): { configToken: TokenType } }; - return new (this as unknown as WithConfigToken)().configToken; - } - - /** - * Creates a dynamic module configuration with the provided config - */ - static config( - this: T, - config: TConfig - ) { - const moduleConfig = this.getModuleConfig(); - const configToken = this.getConfigToken(); - return { - ...moduleConfig, - providers: [ - ...(moduleConfig.providers || []), - { token: configToken, useValue: config }, - ], - }; - } - - /** - * Creates a dynamic module configuration with an async config factory - */ - static configAsync( - this: T, - configFactory: () => TConfig | Promise - ) { - const moduleConfig = this.getModuleConfig(); - const configToken = this.getConfigToken(); - return { - ...moduleConfig, - providers: [ - ...(moduleConfig.providers || []), - { token: configToken, useFactory: configFactory }, - ], - }; - } - - /** - * Create a dynamic module with the given configuration. - * - * @param config Module configuration - * @returns A dynamic module class - * @example - * import { DynamicModule } from '@nexusdi/core'; - * const MyDynamic = DynamicModule.forRoot({ providers: [LoggerService] }); - * @see https://nexus.js.org/docs/modules/dynamic-modules - */ - static forRoot(config: any): any { - class RuntimeDynamicModule {} - setMetadata(RuntimeDynamicModule, METADATA_KEYS.MODULE_METADATA, config); - return RuntimeDynamicModule; - } -} - -// Re-export the Module decorator for convenience -export { Module }; diff --git a/libs/core/src/types.ts b/libs/core/src/types.ts index 071f7fe..abb81fe 100644 --- a/libs/core/src/types.ts +++ b/libs/core/src/types.ts @@ -174,6 +174,12 @@ export type InjectionMetadata = { optional?: boolean; }; +export type DynamicModuleConfig = + | Config + | ProviderConfigObject; +export type DynamicModuleConfigAsync = + | Promise + | ProviderConfigObject>; // Export internal types for container use only (not public API) export type { InternalClassProvider,