Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support asynchronous initialization of services #126

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
180 changes: 180 additions & 0 deletions src/container-instance.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<T>(type: Constructable<T>): Promise<T>;

/**
* Like get, but returns a promise of a service that recursively resolves async properties.
* Used when service defined with asyncInitialization: true flag.
*/
getAsync<T>(id: string): Promise<T>;

/**
* Like get, but returns a promise of a service that recursively resolves async properties.
* Used when service defined with asyncInitialization: true flag.
*/
getAsync<T>(id: Token<T>): Promise<T>;

/**
* Like get, but returns a promise of a service that recursively resolves async properties.
* Used when service defined with asyncInitialization: true flag.
*/
getAsync<T>(id: { service: T }): Promise<T>;

/**
* Like get, but returns a promise of a service that recursively resolves async properties.
* Used when service defined with asyncInitialization: true flag.
*/
getAsync<T>(identifier: ServiceIdentifier<T>): Promise<T> {
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<T>(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<T>(id: Token<T>): 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<T>(id: string | Token<T>): Promise<T>[] {
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.
Expand Down Expand Up @@ -344,6 +414,100 @@ export class ContainerInstance {
return value;
}

/**
* Gets a promise of an initialized AsyncService value.
*/
private async getServiceValueAsync(
identifier: ServiceIdentifier,
service: ServiceMetadata<any, any> | undefined
): Promise<any> {
// 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<any> }).service instanceof Token) {
// type = (identifier as { service: Token<any> }).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.
*/
Expand All @@ -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<Promise<any> | 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).
*/
Expand Down
52 changes: 52 additions & 0 deletions src/container.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(type: Constructable<T>): Promise<T>;

/**
* Like get, but returns a promise of a service that recursively resolves async properties.
* Used when service defined with asyncInitialization: true flag.
*/
static getAsync<T>(id: string): Promise<T>;

/**
* Like get, but returns a promise of a service that recursively resolves async properties.
* Used when service defined with asyncInitialization: true flag.
*/
static getAsync<T>(id: Token<T>): Promise<T>;

/**
* Like get, but returns a promise of a service that recursively resolves async properties.
* Used when service defined with asyncInitialization: true flag.
*/
static getAsync<T>(service: { service: T }): Promise<T>;

/**
* Like get, but returns a promise of a service that recursively resolves async properties.
* Used when service defined with asyncInitialization: true flag.
*/
static getAsync<T>(identifier: ServiceIdentifier<T>): Promise<T> {
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.
Expand All @@ -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<T>(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<T>(id: Token<T>): 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<T>(id: string | Token<T>): Promise<T>[] {
return this.globalInstance.getManyAsync(id as any);
}

/**
* Sets a value for the given type or service name in the container.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/decorators/service.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,15 @@ export function Service<T, K extends keyof T>(
service.multiple = (optionsOrServiceName as ServiceOptions<T, K>).multiple;
service.global = (optionsOrServiceName as ServiceOptions<T, K>).global || false;
service.transient = (optionsOrServiceName as ServiceOptions<T, K>).transient;
service.asyncInitialization = (optionsOrServiceName as ServiceOptions<T, K>).asyncInitialization;
} else if (optionsOrServiceName) {
// ServiceOptions
service.id = (optionsOrServiceName as ServiceOptions<T, K>).id;
service.factory = (optionsOrServiceName as ServiceOptions<T, K>).factory;
service.multiple = (optionsOrServiceName as ServiceOptions<T, K>).multiple;
service.global = (optionsOrServiceName as ServiceOptions<T, K>).global || false;
service.transient = (optionsOrServiceName as ServiceOptions<T, K>).transient;
service.asyncInitialization = (optionsOrServiceName as ServiceOptions<T, K>).asyncInitialization;
}

Container.set(service);
Expand Down
19 changes: 19 additions & 0 deletions src/error/MissingInitializedPromiseError.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
5 changes: 5 additions & 0 deletions src/interfaces/service-metadata.interface..ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export interface ServiceMetadata<T, K extends keyof T> {
*/
factory?: [Constructable<T>, 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.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/interfaces/service-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ export interface ServiceOptions<T, K extends keyof T> {
* Factory used to produce this service.
*/
factory?: [Constructable<T>, 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;
}
12 changes: 12 additions & 0 deletions src/types/AsyncInitializedService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Extend when declaring a service with asyncInitialization: true flag.
*/
export abstract class AsyncInitializedService {
public initialized: Promise<any>;

constructor() {
this.initialized = this.initialize();
}

protected abstract initialize(): Promise<any>;
}
30 changes: 30 additions & 0 deletions test/decorators/Service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;

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>(Car);

expect(car.engine.ignition).toEqual('running');
});
});