From 5c21b57bfe1dc95cb5a0a43d415deb31c4802c3d Mon Sep 17 00:00:00 2001 From: Romain Lenzotti Date: Sun, 28 Aug 2022 19:16:53 +0200 Subject: [PATCH] feat(di): allow registering multiple providers for the same token Closes: #1693 --- docs/docs/providers.md | 55 +++++ packages/di/coverage.json | 8 +- packages/di/src/decorators/inject.spec.ts | 193 +++++++++++------- packages/di/src/decorators/inject.ts | 44 ++-- packages/di/src/domain/Provider.ts | 5 +- packages/di/src/interfaces/ProviderOpts.ts | 4 +- .../di/src/services/InjectorService.spec.ts | 39 +--- packages/di/src/services/InjectorService.ts | 40 ++-- 8 files changed, 228 insertions(+), 160 deletions(-) diff --git a/docs/docs/providers.md b/docs/docs/providers.md index b2f73ce92f6..a9d9871ef74 100644 --- a/docs/docs/providers.md +++ b/docs/docs/providers.md @@ -133,6 +133,61 @@ This is possible with the combination of @@Opts@@ and @@UseOpts@@ decorators. to `ProviderScope.INSTANCE`. ::: +## Inject many provider + +This feature lets to simplify the dependency management while working on multiple implementations of the same interface with type code. + +If users use the same token when registering providers, the IoC container should exchange a token for a list of instances. Let's consider the following real example: + +```typescript +interface Bar { + type: string; +} + +const Bar: unique symbol = Symbol("Bar"); + +@Injectable({type: Bar}) +class Foo implements Bar { + private readonly type = "foo"; +} + +@Injectable({type: Bar}) +class Baz implements Bar { + private readonly type = "baz"; +} +``` + +Now as a user, I would like to create a [registry](https://www.martinfowler.com/eaaCatalog/registry.html) and retrieve an appropriate instance by type: + +```typescript +@Controller("/some") +export class SomeController { + constructor(@Inject(Bar) private readonly bars: Bar[]) {} + + @Post() + async create(@Body("type") type: "baz" | "foo") { + const bar: Bar | undefined = this.bars.find((x) => x.type === type); + } +} +``` + +or in the following way as well: + +```typescript +@Controller("/some") +export class SomeController { + constructor(private readonly injector: InjectorService) {} + + @Post() + async create(@Body("type") type: "baz" | "foo") { + const bars: Bar[] = this.injector.getAll(Bar); + const bar: Bar | undefined = bars.find((x) => x.type === type); + + // your code + } +} +``` + ## Override an injection token By default, the `@Injectable()` are register a class provider with an injection token obtained from the metadata generated by Typescript. diff --git a/packages/di/coverage.json b/packages/di/coverage.json index acd2c2746ea..fc6b48084a6 100644 --- a/packages/di/coverage.json +++ b/packages/di/coverage.json @@ -1,6 +1,6 @@ { - "statements": 99.37, - "branches": 93.54, - "functions": 99.22, - "lines": 99.48 + "statements": 99, + "branches": 93.12, + "lines": 99.1, + "functions": 98.46 } diff --git a/packages/di/src/decorators/inject.spec.ts b/packages/di/src/decorators/inject.spec.ts index 00fcbe9bc2c..d857725f466 100644 --- a/packages/di/src/decorators/inject.spec.ts +++ b/packages/di/src/decorators/inject.spec.ts @@ -1,7 +1,5 @@ -import {catchError, descriptorOf, Metadata, Store} from "@tsed/core"; -import {Required} from "@tsed/schema"; -import {Inject, Injectable, InjectorService} from "../../src"; -import {INJECTABLE_PROP} from "../constants/constants"; +import {descriptorOf} from "@tsed/core"; +import {Inject, Injectable, InjectorService, registerProvider} from "../../src"; describe("@Inject()", () => { describe("used on unsupported decorator type", () => { @@ -24,29 +22,8 @@ describe("@Inject()", () => { }); }); - describe("used on method", () => { - it("should store metadata", () => { - // GIVEN - class Test { - test() {} - } - - // WHEN - Inject()(Test.prototype, "test", descriptorOf(Test, "test")); - - // THEN - const store = Store.from(Test).get(INJECTABLE_PROP); - expect(store).toEqual({ - test: { - bindingType: "method", - propertyKey: "test" - } - }); - }); - }); - - describe("used on property", () => { - it("should store metadata from inferred type", async () => { + describe("@property", () => { + it("should inject service", async () => { // GIVEN @Injectable() class Test { @@ -60,7 +37,7 @@ describe("@Inject()", () => { expect(instance).toBeInstanceOf(Test); expect(instance.test).toBeInstanceOf(InjectorService); }); - it("should store metadata from given type", async () => { + it("should inject service with the given type", async () => { // GIVEN @Injectable() class Test { @@ -74,67 +51,135 @@ describe("@Inject()", () => { expect(instance).toBeInstanceOf(Test); expect(instance.test).toBeInstanceOf(InjectorService); }); - it("should catch error when an object is given as token provider", async () => { - // GIVEN - @Injectable() - class Test { - @Required() - test: Object; + it("should inject many services", async () => { + const TOKEN_GROUPS = Symbol.for("groups:1"); + + interface InterfaceGroup { + type: string; } - const error = catchError(() => { - Inject()(Test.prototype, "test"); - }); + @Injectable({ + type: TOKEN_GROUPS + }) + class MyService1 implements InterfaceGroup { + readonly type: string = "service1"; - expect(error?.message).toMatchSnapshot(); - }); - }); + constructor(@Inject(InjectorService) readonly injector: InjectorService) {} + } - describe("used on constructor/params", () => { - beforeAll(() => { - jest.spyOn(Metadata, "getParamTypes").mockReturnValue([]); - jest.spyOn(Metadata, "setParamTypes").mockReturnValue(undefined); - }); - afterAll(() => { - jest.resetAllMocks(); - }); + @Injectable({ + type: TOKEN_GROUPS + }) + class MyService2 implements InterfaceGroup { + readonly type: string = "service2"; - it("should call Metadata.getParamTypes()", () => { - // GIVEN - class Test { - test() {} + constructor(@Inject(InjectorService) readonly injector: InjectorService) {} } - // WHEN - Inject(String)(Test.prototype, undefined, 0); + const TokenAsync = Symbol.for("MyService2"); - // THEN - expect(Metadata.getParamTypes).toBeCalledWith(Test.prototype, undefined); - expect(Metadata.setParamTypes).toBeCalledWith(Test.prototype, undefined, [String]); + registerProvider({ + provide: TokenAsync, + type: TOKEN_GROUPS, + deps: [], + async useAsyncFactory() { + return { + type: "async" + }; + } + }); + + @Injectable() + class MyInjectable { + @Inject(TOKEN_GROUPS) + instances: InterfaceGroup[]; + } + + const injector = new InjectorService(); + + await injector.load(); + + const instance = await injector.invoke(MyInjectable); + + expect(instance.instances).toBeInstanceOf(Array); + expect(instance.instances).toHaveLength(3); + expect(instance.instances[0].type).toEqual("service1"); + expect(instance.instances[1].type).toEqual("service2"); + expect(instance.instances[2].type).toEqual("async"); }); }); - describe("used on method/params", () => { - beforeAll(() => { - jest.spyOn(Metadata, "getParamTypes").mockReturnValue([]); - jest.spyOn(Metadata, "setParamTypes").mockReturnValue(undefined); - }); - afterAll(() => { - jest.resetAllMocks(); + describe("@constructorParameters", () => { + describe("when token is given on constructor", () => { + it("should inject the expected provider", async () => { + @Injectable() + class MyInjectable { + constructor(@Inject(InjectorService) readonly injector: InjectorService) {} + } + + const injector = new InjectorService(); + const instance = await injector.invoke(MyInjectable); + + expect(instance.injector).toBeInstanceOf(InjectorService); + }); }); - it("should call Metadata.getParamTypes()", () => { - // GIVEN - class Test { - test() {} - } + describe("when a group token is given on constructor", () => { + it("should inject the expected provider", async () => { + const TOKEN_GROUPS = Symbol.for("groups:2"); - // WHEN - Inject(String)(Test.prototype, "propertyKey", 0); + interface InterfaceGroup { + type: string; + } - // THEN - expect(Metadata.getParamTypes).toBeCalledWith(Test.prototype, "propertyKey"); - expect(Metadata.setParamTypes).toBeCalledWith(Test.prototype, "propertyKey", [String]); + @Injectable({ + type: TOKEN_GROUPS + }) + class MyService1 implements InterfaceGroup { + readonly type: string = "service1"; + + constructor(@Inject(InjectorService) readonly injector: InjectorService) {} + } + + @Injectable({ + type: TOKEN_GROUPS + }) + class MyService2 implements InterfaceGroup { + readonly type: string = "service2"; + + constructor(@Inject(InjectorService) readonly injector: InjectorService) {} + } + + const TokenAsync = Symbol.for("MyService1"); + + registerProvider({ + provide: TokenAsync, + type: TOKEN_GROUPS, + deps: [], + async useAsyncFactory() { + return { + type: "async" + }; + } + }); + + @Injectable() + class MyInjectable { + constructor(@Inject(TOKEN_GROUPS) readonly instances: InterfaceGroup[]) {} + } + + const injector = new InjectorService(); + + await injector.load(); + + const instance = await injector.invoke(MyInjectable); + + expect(instance.instances).toBeInstanceOf(Array); + expect(instance.instances).toHaveLength(3); + expect(instance.instances[0].type).toEqual("service1"); + expect(instance.instances[1].type).toEqual("service2"); + expect(instance.instances[2].type).toEqual("async"); + }); }); }); }); diff --git a/packages/di/src/decorators/inject.ts b/packages/di/src/decorators/inject.ts index d0f9738e3a7..438a9a0d93b 100644 --- a/packages/di/src/decorators/inject.ts +++ b/packages/di/src/decorators/inject.ts @@ -2,6 +2,7 @@ import {decoratorTypeOf, DecoratorTypes, isPromise, Metadata, Store, Unsupported import {DI_PARAM_OPTIONS, INJECTABLE_PROP} from "../constants/constants"; import {InvalidPropertyTokenError} from "../errors/InvalidPropertyTokenError"; import type {InjectablePropertyOptions} from "../interfaces/InjectableProperties"; +import {TokenProvider} from "../interfaces/TokenProvider"; import {getContext} from "../utils/asyncHookContext"; export function injectProperty(target: any, propertyKey: string, options: Partial) { @@ -27,28 +28,30 @@ export function injectProperty(target: any, propertyKey: string, options: Partia * } * ``` * - * @param symbol + * @param token A token provider or token provider group * @param onGet Use the given name method to inject * @returns {Function} * @decorator */ -export function Inject(symbol?: any, onGet = (bean: any) => bean): Function { +export function Inject(token?: TokenProvider | (() => TokenProvider), onGet = (bean: any) => bean): Function { return (target: any, propertyKey: string, descriptor: TypedPropertyDescriptor | number): any | void => { const bindingType = decoratorTypeOf([target, propertyKey, descriptor]); switch (bindingType) { - case DecoratorTypes.PARAM: case DecoratorTypes.PARAM_CTOR: - if (symbol) { + if (token) { const paramTypes = Metadata.getParamTypes(target, propertyKey); + const type = paramTypes[descriptor as number]; + + paramTypes[descriptor as number] = type === Array ? [token] : token; - paramTypes[descriptor as number] = symbol; Metadata.setParamTypes(target, propertyKey, paramTypes); } break; case DecoratorTypes.PROP: - const useType = symbol || Metadata.getType(target, propertyKey); + const useType = token || Metadata.getType(target, propertyKey); + const originalType = Metadata.getType(target, propertyKey); if (useType === Object) { throw new InvalidPropertyTokenError(target, propertyKey); @@ -58,6 +61,25 @@ export function Inject(symbol?: any, onGet = (bean: any) => bean): Function { resolver(injector, locals, {options, ...invokeOptions}) { locals.set(DI_PARAM_OPTIONS, {...options}); + if (originalType === Array) { + let bean: any[] | undefined; + + if (!bean) { + bean = injector.getMany(token, locals, invokeOptions); + locals.delete(DI_PARAM_OPTIONS); + } + + bean.forEach((instance: any, index) => { + if (isPromise(bean)) { + instance.then((result: any) => { + bean![index] = result; + }); + } + }); + + return () => onGet(bean); + } + let bean: any; if (!bean) { @@ -76,16 +98,6 @@ export function Inject(symbol?: any, onGet = (bean: any) => bean): Function { }); break; - case DecoratorTypes.METHOD: - Store.from(target).merge(INJECTABLE_PROP, { - [propertyKey]: { - bindingType, - propertyKey - } - }); - - return descriptor; - default: throw new UnsupportedDecoratorType(Inject, [target, propertyKey, descriptor]); } diff --git a/packages/di/src/domain/Provider.ts b/packages/di/src/domain/Provider.ts index 6b96536ce02..2299d998a75 100644 --- a/packages/di/src/domain/Provider.ts +++ b/packages/di/src/domain/Provider.ts @@ -7,7 +7,10 @@ import {ProviderType} from "./ProviderType"; export type ProviderHookCallback = (instance: T, ...args: any[]) => Promise | void; export class Provider implements ProviderOpts { - public type: ProviderType | any = ProviderType.PROVIDER; + /** + * Token group provider to retrieve all provider from the same type + */ + public type: TokenProvider | ProviderType = ProviderType.PROVIDER; public deps: TokenProvider[]; public imports: any[]; public useFactory: Function; diff --git a/packages/di/src/interfaces/ProviderOpts.ts b/packages/di/src/interfaces/ProviderOpts.ts index 7abb551b7b9..3a475708dd9 100644 --- a/packages/di/src/interfaces/ProviderOpts.ts +++ b/packages/di/src/interfaces/ProviderOpts.ts @@ -1,7 +1,7 @@ import type {Type} from "@tsed/core"; +import type {ProviderType} from "../domain/ProviderType"; import type {DIResolver} from "./DIResolver"; import type {ProviderScope} from "../domain/ProviderScope"; -import type {ProviderType} from "../domain/ProviderType"; import type {TokenProvider} from "./TokenProvider"; export interface ProviderOpts { @@ -12,7 +12,7 @@ export interface ProviderOpts { /** * Provider type */ - type?: ProviderType | string; + type?: TokenProvider | ProviderType; /** * Instance build by the injector */ diff --git a/packages/di/src/services/InjectorService.spec.ts b/packages/di/src/services/InjectorService.spec.ts index 59c90095582..70fddbfbeab 100644 --- a/packages/di/src/services/InjectorService.spec.ts +++ b/packages/di/src/services/InjectorService.spec.ts @@ -23,15 +23,6 @@ class Test { constructor() {} - @Inject() - test(injectorService: InjectorService) { - return injectorService; - } - - test2(@Inject() injectorService: InjectorService) { - return injectorService; - } - test3(o: any) { return o + " called "; } @@ -57,7 +48,7 @@ describe("InjectorService", () => { expect(new InjectorService().get(Test)).toBeUndefined(); }); }); - describe("getAll()", () => { + describe("getMany()", () => { it("should return all instance", () => { const injector = new InjectorService(); injector.addProvider("token", { @@ -65,8 +56,8 @@ describe("InjectorService", () => { useValue: 1 }); - expect(!!injector.getAll(ProviderType.VALUE).length).toEqual(true); - expect(!!injector.getAll(ProviderType.FACTORY).length).toEqual(false); + expect(!!injector.getMany(ProviderType.VALUE).length).toEqual(true); + expect(!!injector.getMany(ProviderType.FACTORY).length).toEqual(false); }); }); @@ -609,7 +600,6 @@ describe("InjectorService", () => { const injector = new InjectorService(); const instance = new TestBind(); - jest.spyOn(injector as any, "bindMethod").mockReturnValue(undefined); jest.spyOn(injector as any, "bindProperty").mockReturnValue(undefined); jest.spyOn(injector as any, "bindConstant").mockReturnValue(undefined); jest.spyOn(injector as any, "bindValue").mockReturnValue(undefined); @@ -639,7 +629,6 @@ describe("InjectorService", () => { injector.bindInjectableProperties(instance, new LocalsContainer(), {}); // THEN - expect(injector.bindMethod).toBeCalledWith(instance, injectableProperties.testMethod); expect(injector.bindProperty).toBeCalledWith(instance, injectableProperties.testProp, new LocalsContainer(), {}); expect(injector.bindConstant).toBeCalledWith(instance, injectableProperties.testConst); expect(injector.bindValue).toBeCalledWith(instance, injectableProperties.testValue); @@ -647,26 +636,6 @@ describe("InjectorService", () => { }); }); - describe("bindMethod()", () => { - it("should bind the method", () => { - // GIVEN - const injector = new InjectorService(); - const instance = new Test(); - - const spyTest2 = jest.spyOn(instance, "test2"); - jest.spyOn(injector, "get"); - - // WHEN - injector.bindMethod(instance, {bindingType: "method", propertyKey: "test2"} as any); - const result = (instance as any).test2(); - - // THEN - expect(spyTest2).toBeCalledWith(injector); - expect(injector.get).toBeCalledWith(InjectorService); - expect(result).toEqual(injector); - }); - }); - describe("bindProperty()", () => { it("should bind the method", () => { // GIVEN @@ -771,7 +740,6 @@ describe("InjectorService", () => { await injector.load(container); const instance = new Test(); - const originalMethod = instance["test"]; jest.spyOn(injector, "get"); @@ -785,7 +753,6 @@ describe("InjectorService", () => { const result = (instance as any).test3("test"); // THEN - expect(expect(originalMethod)).not.toEqual(instance.test3); expect(injector.get).toBeCalledWith(InterceptorTest); expect(result).toEqual("test called intercepted"); diff --git a/packages/di/src/services/InjectorService.ts b/packages/di/src/services/InjectorService.ts index 17eedcb1eea..9e347781aaa 100644 --- a/packages/di/src/services/InjectorService.ts +++ b/packages/di/src/services/InjectorService.ts @@ -11,7 +11,8 @@ import { Metadata, nameOf, prototypeOf, - Store + Store, + isArray } from "@tsed/core"; import {DI_PARAM_OPTIONS, INJECTABLE_PROP} from "../constants/constants"; import {Configuration} from "../decorators/configuration"; @@ -138,11 +139,11 @@ export class InjectorService extends Container { /** * Return all instance of the same provider type * @param type + * @param locals + * @param options */ - getAll(type: string) { - return this.getProviders(type).map((provider) => { - return this.get(provider.token); - }); + getMany(type: string, locals?: LocalsContainer, options?: Partial>): Type[] { + return this.getProviders(type).map((provider) => this.invoke(provider.token, locals, options)!); } /** @@ -366,9 +367,6 @@ export class InjectorService extends Container { Object.values(properties).forEach((definition) => { switch (definition.bindingType) { - case InjectablePropertyType.METHOD: - this.bindMethod(instance, definition); - break; case InjectablePropertyType.PROPERTY: this.bindProperty(instance, definition, locals, options); break; @@ -385,22 +383,6 @@ export class InjectorService extends Container { }); } - /** - * @param instance - * @param {string} propertyKey - */ - public bindMethod(instance: any, {propertyKey}: InjectablePropertyOptions) { - const target = classOf(instance); - const originalMethod = instance[propertyKey]; - const deps = Metadata.getParamTypes(prototypeOf(target), propertyKey); - - instance[propertyKey] = () => { - const services = deps.map((dependency: any) => this.get(dependency)); - - return originalMethod.call(instance, ...services); - }; - } - /** * Create an injectable property. * @@ -608,7 +590,7 @@ export class InjectorService extends Container { try { const invokeDependency = (parent?: any) => - (token: any, index: number): any => { + (token: TokenProvider | [TokenProvider], index: number): any => { currentDependency = {token, index, deps}; if (token !== DI_PARAM_OPTIONS) { @@ -617,6 +599,10 @@ export class InjectorService extends Container { locals.set(DI_PARAM_OPTIONS, options || {}); } + if (isArray(token)) { + return this.getMany(token[0], locals, options); + } + return isInheritedFrom(token, Provider, 1) ? provider : this.invoke(token, locals, {parent}); }; @@ -686,9 +672,9 @@ export class InjectorService extends Container { if (provider.useValue !== undefined) { construct = () => (isFunction(provider.useValue) ? provider.useValue() : provider.useValue); } else if (provider.useFactory) { - construct = (deps: TokenProvider[]) => provider.useFactory(...deps); + construct = (deps: any[]) => provider.useFactory(...deps); } else if (provider.useAsyncFactory) { - construct = async (deps: TokenProvider[]) => { + construct = async (deps: any[]) => { deps = await Promise.all(deps); return provider.useAsyncFactory(...deps); };