diff --git a/src/container-instance.class.ts b/src/container-instance.class.ts index 9ed9df7f..b0005f25 100644 --- a/src/container-instance.class.ts +++ b/src/container-instance.class.ts @@ -5,6 +5,8 @@ import { Token } from './token.class'; import { Constructable } from './types/constructable.type'; import { ServiceIdentifier } from './types/service-identifier.type'; import { ServiceMetadata } from './interfaces/service-metadata.interface.'; +import { AsyncInitializedService } from './types/AsyncInitializedService'; +import { MissingInitializedPromiseError } from './error/MissingInitializedPromiseError'; /** * TypeDI can have multiple containers. @@ -115,6 +117,74 @@ export class ContainerInstance { return this.getServiceValue(identifier, service); } + /** + * Like get, but returns a promise of a service that recursively resolves async properties. + * Used when service defined with asyncInitialization: true flag. + */ + getAsync(type: Constructable): Promise; + + /** + * Like get, but returns a promise of a service that recursively resolves async properties. + * Used when service defined with asyncInitialization: true flag. + */ + getAsync(id: string): Promise; + + /** + * Like get, but returns a promise of a service that recursively resolves async properties. + * Used when service defined with asyncInitialization: true flag. + */ + getAsync(id: Token): Promise; + + /** + * Like get, but returns a promise of a service that recursively resolves async properties. + * Used when service defined with asyncInitialization: true flag. + */ + getAsync(id: { service: T }): Promise; + + /** + * Like get, but returns a promise of a service that recursively resolves async properties. + * Used when service defined with asyncInitialization: true flag. + */ + getAsync(identifier: ServiceIdentifier): Promise { + const globalContainer = Container.of(undefined); + const service = globalContainer.findService(identifier); + const scopedService = this.findService(identifier); + + if (service && service.global === true) return this.getServiceValueAsync(identifier, service); + + if (scopedService) return this.getServiceValueAsync(identifier, scopedService); + + if (service && this !== globalContainer) { + const clonedService = Object.assign({}, service); + clonedService.value = undefined; + const value = this.getServiceValueAsync(identifier, clonedService); + this.set(identifier, value); + return value; + } + + return this.getServiceValueAsync(identifier, service); + } + + /** + * Like getMany, but returns a promise that recursively resolves async properties on all services. + * Used when services defined with multiple: true and asyncInitialization: true flags. + */ + getManyAsync(id: string): T[]; + + /** + * Like getMany, but returns a promise that recursively resolves async properties on all services. + * Used when services defined with multiple: true and asyncInitialization: true flags. + */ + getManyAsync(id: Token): T[]; + + /** + * Like getMany, but returns a promise that recursively resolves async properties on all services. + * Used when services defined with multiple: true and asyncInitialization: true flags. + */ + getManyAsync(id: string | Token): Promise[] { + return this.filterServices(id).map(service => this.getServiceValueAsync(id, service)); + } + /** * Gets all instances registered in the container of the given service identifier. * Used when service defined with multiple: true flag. @@ -344,6 +414,100 @@ export class ContainerInstance { return value; } + /** + * Gets a promise of an initialized AsyncService value. + */ + private async getServiceValueAsync( + identifier: ServiceIdentifier, + service: ServiceMetadata | undefined + ): Promise { + // find if instance of this object already initialized in the container and return it if it is + if (service && service.value !== undefined) return service.value; + + // if named service was requested and its instance was not found plus there is not type to know what to initialize, + // this means service was not pre-registered and we throw an exception + if ( + (!service || !service.type) && + (!service || !service.factory) && + (typeof identifier === 'string' || identifier instanceof Token) + ) + throw new ServiceNotFoundError(identifier); + + // at this point we either have type in service registered, either identifier is a target type + let type = undefined; + if (service && service.type) { + type = service.type; + } else if (service && service.id instanceof Function) { + type = service.id; + } else if (identifier instanceof Function) { + type = identifier; + + // } else if (identifier instanceof Object && (identifier as { service: Token }).service instanceof Token) { + // type = (identifier as { service: Token }).service; + } + + // if service was not found then create a new one and register it + if (!service) { + if (!type) throw new MissingProvidedServiceTypeError(identifier); + + service = { type: type }; + this.services.push(service); + } + + // setup constructor parameters for a newly initialized service + const paramTypes = + type && Reflect && (Reflect as any).getMetadata + ? (Reflect as any).getMetadata('design:paramtypes', type) + : undefined; + let params: any[] = paramTypes ? await Promise.all(this.initializeParamsAsync(type, paramTypes)) : []; + + // if factory is set then use it to create service instance + let value: any; + if (service.factory) { + // filter out non-service parameters from created service constructor + // non-service parameters can be, lets say Car(name: string, isNew: boolean, engine: Engine) + // where name and isNew are non-service parameters and engine is a service parameter + params = params.filter(param => param !== undefined); + + if (service.factory instanceof Array) { + // use special [Type, "create"] syntax to allow factory services + // in this case Type instance will be obtained from Container and its method "create" will be called + value = (await this.getAsync(service.factory[0]))[service.factory[1]](...params); + } else { + // regular factory function + value = service.factory(...params, this); + } + } else { + // otherwise simply create a new object instance + if (!type) throw new MissingProvidedServiceTypeError(identifier); + + params.unshift(null); + + // "extra feature" - always pass container instance as the last argument to the service function + // this allows us to support javascript where we don't have decorators and emitted metadata about dependencies + // need to be injected, and user can use provided container to get instances he needs + params.push(this); + + // eslint-disable-next-line prefer-spread + value = new (type.bind.apply(type, params))(); + } + + if (service && !service.transient && value) service.value = value; + + if (type) this.applyPropertyHandlers(type, value); + + if (value instanceof AsyncInitializedService || service.asyncInitialization) { + return new Promise(resolve => { + if (!(value.initialized instanceof Promise) && service.asyncInitialization) { + throw new MissingInitializedPromiseError(service.value); + } + + value.initialized.then(() => resolve(value)); + }); + } + return Promise.resolve(value); + } + /** * Initializes all parameter types for a given target service class. */ @@ -360,6 +524,22 @@ export class ContainerInstance { }); } + /** + * Returns array of promises for all initialized parameter types for a given target service class. + */ + private initializeParamsAsync(type: Function, paramTypes: any[]): Array | undefined> { + return paramTypes.map((paramType, index) => { + const paramHandler = Container.handlers.find(handler => handler.object === type && handler.index === index); + if (paramHandler) return Promise.resolve(paramHandler.value(this)); + + if (paramType && paramType.name && !this.isTypePrimitive(paramType.name)) { + return this.getAsync(paramType); + } + + return undefined; + }); + } + /** * Checks if given type is primitive (e.g. string, boolean, number, object). */ diff --git a/src/container.class.ts b/src/container.class.ts index 9d135efd..3bbe4ce5 100644 --- a/src/container.class.ts +++ b/src/container.class.ts @@ -105,6 +105,38 @@ export class Container { return this.globalInstance.get(identifier as any); } + /** + * Like get, but returns a promise of a service that recursively resolves async properties. + * Used when service defined with asyncInitialization: true flag. + */ + static getAsync(type: Constructable): Promise; + + /** + * Like get, but returns a promise of a service that recursively resolves async properties. + * Used when service defined with asyncInitialization: true flag. + */ + static getAsync(id: string): Promise; + + /** + * Like get, but returns a promise of a service that recursively resolves async properties. + * Used when service defined with asyncInitialization: true flag. + */ + static getAsync(id: Token): Promise; + + /** + * Like get, but returns a promise of a service that recursively resolves async properties. + * Used when service defined with asyncInitialization: true flag. + */ + static getAsync(service: { service: T }): Promise; + + /** + * Like get, but returns a promise of a service that recursively resolves async properties. + * Used when service defined with asyncInitialization: true flag. + */ + static getAsync(identifier: ServiceIdentifier): Promise { + return this.globalInstance.getAsync(identifier as any); + } + /** * Gets all instances registered in the container of the given service identifier. * Used when service defined with multiple: true flag. @@ -125,6 +157,26 @@ export class Container { return this.globalInstance.getMany(id as any); } + /** + * Like getMany, but returns a promise that recursively resolves async properties on all services. + * Used when services defined with multiple: true and asyncInitialization: true flags. + */ + static getManyAsync(id: string): T[]; + + /** + * Like getMany, but returns a promise that recursively resolves async properties on all services. + * Used when services defined with multiple: true and asyncInitialization: true flags. + */ + static getManyAsync(id: Token): T[]; + + /** + * Like getMany, but returns a promise that recursively resolves async properties on all services. + * Used when services defined with multiple: true and asyncInitialization: true flags. + */ + static getManyAsync(id: string | Token): Promise[] { + return this.globalInstance.getManyAsync(id as any); + } + /** * Sets a value for the given type or service name in the container. */ diff --git a/src/decorators/service.decorator.ts b/src/decorators/service.decorator.ts index feaddfc8..d395caef 100644 --- a/src/decorators/service.decorator.ts +++ b/src/decorators/service.decorator.ts @@ -173,6 +173,7 @@ export function Service( service.multiple = (optionsOrServiceName as ServiceOptions).multiple; service.global = (optionsOrServiceName as ServiceOptions).global || false; service.transient = (optionsOrServiceName as ServiceOptions).transient; + service.asyncInitialization = (optionsOrServiceName as ServiceOptions).asyncInitialization; } else if (optionsOrServiceName) { // ServiceOptions service.id = (optionsOrServiceName as ServiceOptions).id; @@ -180,6 +181,7 @@ export function Service( service.multiple = (optionsOrServiceName as ServiceOptions).multiple; service.global = (optionsOrServiceName as ServiceOptions).global || false; service.transient = (optionsOrServiceName as ServiceOptions).transient; + service.asyncInitialization = (optionsOrServiceName as ServiceOptions).asyncInitialization; } Container.set(service); diff --git a/src/error/MissingInitializedPromiseError.ts b/src/error/MissingInitializedPromiseError.ts new file mode 100644 index 00000000..7bd95428 --- /dev/null +++ b/src/error/MissingInitializedPromiseError.ts @@ -0,0 +1,19 @@ +/** + * Thrown when user improperly uses the asyncInitialization Service option. + */ +export class MissingInitializedPromiseError extends Error { + name = 'MissingInitializedPromiseError'; + + // TODO: User proper type + constructor(value: { name: string; initialized: boolean }) { + super( + (value.initialized + ? `asyncInitialization: true was used, but ${value.name}#initialized is not a Promise. ` + : `asyncInitialization: true was used, but ${value.name}#initialized is undefined. `) + + `You will need to either extend the abstract AsyncInitializedService class, or assign ` + + `${value.name}#initialized to a Promise in your class' constructor that resolves when all required ` + + `initialization is complete.` + ); + Object.setPrototypeOf(this, MissingInitializedPromiseError.prototype); + } +} diff --git a/src/interfaces/service-metadata.interface..ts b/src/interfaces/service-metadata.interface..ts index 5deb5b38..5606d220 100644 --- a/src/interfaces/service-metadata.interface..ts +++ b/src/interfaces/service-metadata.interface..ts @@ -40,6 +40,11 @@ export interface ServiceMetadata { */ factory?: [Constructable, K] | ((...params: any[]) => any); + /** + * Will call instance's #initialize method and resolve the promise it returns when getting with Container.getAsync() and Container.getManyAsync(). + */ + asyncInitialization?: boolean; + /** * Instance of the target class. */ diff --git a/src/interfaces/service-options.interface.ts b/src/interfaces/service-options.interface.ts index 108e7e88..72e0d85e 100644 --- a/src/interfaces/service-options.interface.ts +++ b/src/interfaces/service-options.interface.ts @@ -31,4 +31,9 @@ export interface ServiceOptions { * Factory used to produce this service. */ factory?: [Constructable, K] | ((...params: any[]) => any); + + /** + * Will call instance's #initialize method and resolve the promise it returns when getting with Container.getAsync() and Container.getManyAsync(). + */ + asyncInitialization?: boolean; } diff --git a/src/types/AsyncInitializedService.ts b/src/types/AsyncInitializedService.ts new file mode 100644 index 00000000..d4ad4b51 --- /dev/null +++ b/src/types/AsyncInitializedService.ts @@ -0,0 +1,12 @@ +/** + * Extend when declaring a service with asyncInitialization: true flag. + */ +export abstract class AsyncInitializedService { + public initialized: Promise; + + constructor() { + this.initialized = this.initialize(); + } + + protected abstract initialize(): Promise; +} diff --git a/test/decorators/Service.spec.ts b/test/decorators/Service.spec.ts index ff449730..e09a6568 100644 --- a/test/decorators/Service.spec.ts +++ b/test/decorators/Service.spec.ts @@ -173,4 +173,34 @@ describe('Service Decorator', function () { expect(Container.get(TestService)).toBe('TEST_STRING'); }); + + it('should support services with asynchronous initialization', async function () { + @Service({ asyncInitialization: true }) + class Engine { + ignition: string = 'off'; + initialized: Promise; + + constructor() { + this.initialized = this.initialize(); + } + + protected initialize() { + return new Promise(resolve => { + setTimeout(() => { + this.ignition = 'running'; + resolve(); + }, 300); + }); + } + } + + @Service() + class Car { + constructor(public engine: Engine) {} + } + + const car = await Container.getAsync(Car); + + expect(car.engine.ignition).toEqual('running'); + }); });