diff --git a/src/index.ts b/src/index.ts index 5656eb5..cac7d08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,4 @@ import "reflect-metadata"; export * from './serviceProvider.js'; export * from './types.js'; export * from './decorators.js'; +export * from './serviceCollection.js'; diff --git a/src/serviceCollection.ts b/src/serviceCollection.ts new file mode 100644 index 0000000..a841a45 --- /dev/null +++ b/src/serviceCollection.ts @@ -0,0 +1,73 @@ +import { ServiceProvider } from './serviceProvider.js'; +import type { + Constructor, + IServiceCollection, + IServiceProvider, + ServiceDescriptor, + ServiceFactory, + ServiceIdentifier, + ServiceLifetime, +} from './types.js'; + +export class ServiceCollection implements IServiceCollection { + private readonly _descriptors: Map, ServiceDescriptor> = + new Map(); + + public addSingleton( + serviceType: ServiceIdentifier, + implementationOrFactory: ServiceFactory | Constructor, + ): IServiceCollection { + return this.add(serviceType, implementationOrFactory, 'singleton'); + } + + public addScoped( + serviceType: ServiceIdentifier, + implementationOrFactory: ServiceFactory | Constructor, + ): IServiceCollection { + return this.add(serviceType, implementationOrFactory, 'scoped'); + } + + public addTransient( + serviceType: ServiceIdentifier, + implementationOrFactory: ServiceFactory | Constructor, + ): IServiceCollection { + return this.add(serviceType, implementationOrFactory, 'transient'); + } + + public build(): IServiceProvider { + return new ServiceProvider(this._descriptors); + } + + private add( + serviceType: ServiceIdentifier, + implementationOrFactory: ServiceFactory | Constructor, + lifetime: ServiceLifetime, + ): IServiceCollection { + if (typeof implementationOrFactory === 'function' && this.isConstructor(implementationOrFactory) === false) { + const descriptor: ServiceDescriptor = { + serviceType, + implementationType: Object as unknown as Constructor, + lifetime, + factory: implementationOrFactory as ServiceFactory, + }; + + this._descriptors.set(serviceType, descriptor); + } else { + const descriptor: ServiceDescriptor = { + serviceType, + implementationType: implementationOrFactory as Constructor, + lifetime, + }; + + this._descriptors.set(serviceType, descriptor); + } + + return this; + } + + private isConstructor(func: unknown): func is Constructor { + return typeof func === 'function' && + !!func.prototype && + func.prototype.constructor === func; + } +} diff --git a/src/serviceProvider.ts b/src/serviceProvider.ts index e8b6a10..025def2 100644 --- a/src/serviceProvider.ts +++ b/src/serviceProvider.ts @@ -19,19 +19,19 @@ export class ServiceScope implements IServiceScope { } export class ServiceProvider implements IServiceProvider { - private descriptors: Map, ServiceDescriptor>; - private singletonInstances: Map, unknown> = new Map(); - private scopedInstances: Map, unknown> = new Map(); + private readonly _descriptors: Map, ServiceDescriptor>; + private readonly _singletonInstances: Map, unknown> = new Map(); + private readonly _scopedInstances: Map, unknown> = new Map(); constructor( descriptors: Map, ServiceDescriptor>, parent?: ServiceProvider, ) { - this.descriptors = descriptors; + this._descriptors = descriptors; if (parent) { - parent.singletonInstances.forEach((value, key) => { - this.singletonInstances.set(key, value); + parent._singletonInstances.forEach((value, key) => { + this._singletonInstances.set(key, value); }); } @@ -43,7 +43,7 @@ export class ServiceProvider implements IServiceProvider { } getService(serviceType: ServiceIdentifier): T { - const descriptor = this.descriptors.get(serviceType); + const descriptor = this._descriptors.get(serviceType); if (!descriptor) { throw new Error(`Service of type ${serviceType.toString()} is not registered.`); @@ -53,12 +53,12 @@ export class ServiceProvider implements IServiceProvider { } createScope(): IServiceScope { - const scopedProvider = new ServiceProvider(this.descriptors, this); + const scopedProvider = new ServiceProvider(this._descriptors, this); return new ServiceScope(scopedProvider); } dispose(): void { - this.scopedInstances.clear(); + this._scopedInstances.clear(); } private resolveService(descriptor: ServiceDescriptor): T { @@ -66,19 +66,19 @@ export class ServiceProvider implements IServiceProvider { switch (lifetime) { case 'singleton': { - if (this.singletonInstances.has(serviceType)) { - return this.singletonInstances.get(serviceType) as T; + if (this._singletonInstances.has(serviceType)) { + return this._singletonInstances.get(serviceType) as T; } const singletonInstance = factory ? factory(this) : this.createInstance(implementationType); - this.singletonInstances.set(serviceType, singletonInstance); + this._singletonInstances.set(serviceType, singletonInstance); return singletonInstance; } case 'scoped': { - if (this.scopedInstances.has(serviceType)) { - return this.scopedInstances.get(serviceType) as T; + if (this._scopedInstances.has(serviceType)) { + return this._scopedInstances.get(serviceType) as T; } const scopedInstance = factory ? factory(this) : this.createInstance(implementationType); - this.scopedInstances.set(serviceType, scopedInstance); + this._scopedInstances.set(serviceType, scopedInstance); return scopedInstance; } case 'transient': diff --git a/src/types.ts b/src/types.ts index e25d132..527d44c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,7 +25,7 @@ export interface IServiceCollection { addScoped(serviceType: ServiceIdentifier, factory: ServiceFactory): IServiceCollection; addTransient(serviceType: ServiceIdentifier, implementationType: Constructor): IServiceCollection; addTransient(serviceType: ServiceIdentifier, factory: ServiceFactory): IServiceCollection; - buildServiceProvider(): IServiceProvider; + build(): IServiceProvider; } export interface IServiceProvider { diff --git a/tests/serviceCollection.spec.ts b/tests/serviceCollection.spec.ts new file mode 100644 index 0000000..85f117a --- /dev/null +++ b/tests/serviceCollection.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { createServiceIdentifier, ServiceCollection } from '../src'; + +describe('ServiceCollection', () => { + it('should add a singleton service', () => { + class TestService {} + const serviceIdentifier = createServiceIdentifier(); + + const collection = new ServiceCollection(); + collection.addSingleton(serviceIdentifier, TestService); + + const provider = collection.build(); + const serviceInstance1 = provider.getService(serviceIdentifier); + const serviceInstance2 = provider.getService(serviceIdentifier); + + expect(serviceInstance1).toBe(serviceInstance2); + }); + + it('should add a scoped service', () => { + class TestService {} + const serviceIdentifier = createServiceIdentifier(); + + const collection = new ServiceCollection(); + collection.addScoped(serviceIdentifier, TestService); + + const provider = collection.build(); + const scope1 = provider.createScope(); + const scope2 = provider.createScope(); + + const serviceInstance1 = scope1.serviceProvider.getService(serviceIdentifier); + const serviceInstance2 = scope2.serviceProvider.getService(serviceIdentifier); + + expect(serviceInstance1).not.toBe(serviceInstance2); + }); + + it('should add a transient service', () => { + class TestService {} + const serviceIdentifier = createServiceIdentifier(); + + const collection = new ServiceCollection(); + collection.addTransient(serviceIdentifier, TestService); + + const provider = collection.build(); + + const serviceInstance1 = provider.getService(serviceIdentifier); + const serviceInstance2 = provider.getService(serviceIdentifier); + + expect(serviceInstance1).not.toBe(serviceInstance2); + }); + + it('should add a single service with a factory', () => { + class TestService {} + const serviceIdentifier = createServiceIdentifier(); + + const collection = new ServiceCollection(); + collection.addSingleton(serviceIdentifier, () => new TestService()); + + const provider = collection.build(); + const serviceInstance1 = provider.getService(serviceIdentifier); + const serviceInstance2 = provider.getService(serviceIdentifier); + + expect(serviceInstance1).toBe(serviceInstance2); + }); + + it('should add a scoped service with a factory', () => { + class TestService {} + const serviceIdentifier = createServiceIdentifier(); + + const collection = new ServiceCollection(); + collection.addScoped(serviceIdentifier, () => new TestService()); + + const provider = collection.build(); + const scope1 = provider.createScope(); + const scope2 = provider.createScope(); + + const serviceInstance1 = scope1.serviceProvider.getService(serviceIdentifier); + const serviceInstance2 = scope2.serviceProvider.getService(serviceIdentifier); + + expect(serviceInstance1).not.toBe(serviceInstance2); + }); + + + it('should add a transient service with a factory', () => { + class TestService {} + const serviceIdentifier = createServiceIdentifier(); + + const collection = new ServiceCollection(); + collection.addTransient(serviceIdentifier, () => new TestService()); + + const provider = collection.build(); + + const serviceInstance1 = provider.getService(serviceIdentifier); + const serviceInstance2 = provider.getService(serviceIdentifier); + + expect(serviceInstance1).not.toBe(serviceInstance2); + }); +}); diff --git a/tests/serviceProvider.spec.ts b/tests/serviceProvider.spec.ts index ecca934..f827ea0 100644 --- a/tests/serviceProvider.spec.ts +++ b/tests/serviceProvider.spec.ts @@ -1,11 +1,11 @@ -import { describe, expect, it, test } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { injectable, ServiceProvider } from '../src'; import { createServiceIdentifier, ServiceLifetime } from '../src/types'; import { randomUUID } from 'crypto'; import { inject } from '../src'; describe('ServiceProvider', () => { - it('it should throw an error when service is not registered', () => { + it('should throw an error when service is not registered', () => { type IService = object; const serviceIdentifier = createServiceIdentifier(); const serviceProvider = new ServiceProvider(new Map()); @@ -15,7 +15,7 @@ describe('ServiceProvider', () => { }).toThrowError(); }); - it('it should resolve singleton service', () => { + it('should resolve singleton service', () => { type IService = object; const serviceIdentifier = createServiceIdentifier(); const serviceProvider = new ServiceProvider( @@ -37,7 +37,7 @@ describe('ServiceProvider', () => { expect(service1).toBe(service2); }); - it('it should resolve singleton service using factory', () => { + it('should resolve singleton service using factory', () => { type IService = object; const serviceIdentifier = createServiceIdentifier(); const serviceProvider = new ServiceProvider( @@ -60,7 +60,7 @@ describe('ServiceProvider', () => { expect(service1).toBe(service2); }); - it('it should resolve scoped service', () => { + it('should resolve scoped service', () => { type IService = object; const serviceIdentifier = createServiceIdentifier(); const serviceProvider = new ServiceProvider( @@ -85,7 +85,7 @@ describe('ServiceProvider', () => { expect(service1).not.toBe(service2); }); - it('it should resolve scoped service using factory', () => { + it('should resolve scoped service using factory', () => { type IService = object; const serviceIdentifier = createServiceIdentifier(); const serviceProvider = new ServiceProvider( @@ -111,7 +111,7 @@ describe('ServiceProvider', () => { expect(service1).not.toBe(service2); }); - it('it should resolve transient service', () => { + it('should resolve transient service', () => { type IService = object; const serviceIdentifier = createServiceIdentifier(); const serviceProvider = new ServiceProvider( @@ -133,7 +133,7 @@ describe('ServiceProvider', () => { expect(service1).not.toBe(service2); }); - it('it should resolve transient service using factory', () => { + it('should resolve transient service using factory', () => { type IService = object; const serviceIdentifier = createServiceIdentifier(); const serviceProvider = new ServiceProvider( @@ -156,7 +156,7 @@ describe('ServiceProvider', () => { expect(service1).not.toBe(service2); }); - it('it should resolve same singleton instance across scopes', () => { + it('should resolve same singleton instance across scopes', () => { type IService = { id: string; }; @@ -193,7 +193,7 @@ describe('ServiceProvider', () => { expect(service1.id).toBe(service2.id); }); - it('it should resolve solve injected dependencies', () => { + it('should resolve solve injected dependencies', () => { interface IUserRepository { getUser(): string; } @@ -253,7 +253,7 @@ describe('ServiceProvider', () => { expect(user).toBe('John Doe'); }); - test('it should throw an error when service has invalid lifetime', () => { + it('should throw an error when service has invalid lifetime', () => { type IService = object; const serviceIdentifier = createServiceIdentifier(); @@ -276,7 +276,7 @@ describe('ServiceProvider', () => { }).toThrowError(); }); - test('it should resolve same scoped instance in the same scope', () => { + it('should resolve same scoped instance in the same scope', () => { type IService = { id: string; }; @@ -312,7 +312,7 @@ describe('ServiceProvider', () => { expect(service1).toBe(service2); }); - test('it should clear the scoped instances on dispose from provider', () => { + it('should clear the scoped instances on dispose from provider', () => { type IService = { id: string; }; @@ -349,7 +349,7 @@ describe('ServiceProvider', () => { expect(initialService).not.toBe(newService); }); - test('it should clear the scoped instances on dispose from a scope', () => { + it('should clear the scoped instances on dispose from a scope', () => { type IService = { id: string; }; @@ -388,7 +388,7 @@ describe('ServiceProvider', () => { expect(initialService).not.toBe(newService); }); - test('it should not clear the scoped instances on dispose from a scope when the provider is disposed', () => { + it('should not clear the scoped instances on dispose from a scope when the provider is disposed', () => { type IService = { id: string; }; @@ -427,7 +427,7 @@ describe('ServiceProvider', () => { expect(initialService).toBe(newService); }); - test("it should not clear the scoped instances on dispose from another scope when one scope is disposed", () => { + it("should not clear the scoped instances on dispose from another scope when one scope is disposed", () => { type IService = { id: string; }; @@ -470,7 +470,7 @@ describe('ServiceProvider', () => { expect(initialService2).toBe(newService2); }); - test('it should throw error when dependency is not registered', () => { + it('should throw error when dependency is not registered', () => { interface IUserRepository { getUser(): string; }