diff --git a/packages/di/package.json b/packages/di/package.json index 21c1bc178c2..7dce8904da7 100644 --- a/packages/di/package.json +++ b/packages/di/package.json @@ -26,7 +26,8 @@ "test:ci": "vitest run --coverage.thresholds.autoUpdate=true" }, "dependencies": { - "tslib": "2.6.2" + "tslib": "2.6.2", + "uuid": "9.0.1" }, "devDependencies": { "@tsed/barrels": "workspace:*", diff --git a/packages/di/src/common/constants/constants.ts b/packages/di/src/common/constants/constants.ts index b0732aaea65..13625ae188d 100644 --- a/packages/di/src/common/constants/constants.ts +++ b/packages/di/src/common/constants/constants.ts @@ -1,3 +1,5 @@ -export const INJECTABLE_PROP = "DI:INJECTABLE_PROP"; -export const DI_PARAMS = "DI:PARAMS"; -export const DI_PARAM_OPTIONS = "DI:PARAM:OPTIONS"; +export const DI_INVOKE_OPTIONS = Symbol("DI_INVOKE_OPTIONS"); +export const DI_INJECTABLE_PROPS = Symbol("DI_INJECTABLE_PROPS"); +export const DI_USE_OPTIONS = "DI_USE_OPTIONS"; +export const DI_USE_PARAM_OPTIONS = "DI_USE_PARAM_OPTIONS"; +export const DI_INTERCEPTOR_OPTIONS = "DI_INTERCEPTOR_OPTIONS"; diff --git a/packages/di/src/common/decorators/autoInjectable.spec.ts b/packages/di/src/common/decorators/autoInjectable.spec.ts index 8bd1f8ba032..21cb69697bd 100644 --- a/packages/di/src/common/decorators/autoInjectable.spec.ts +++ b/packages/di/src/common/decorators/autoInjectable.spec.ts @@ -106,14 +106,15 @@ describe("AutoInjectable", () => { logger: Logger; private value: string; + instances?: InterfaceGroup[]; constructor(initialValue: string, @Inject(TOKEN_GROUPS) instances?: InterfaceGroup[]) { this.value = initialValue; - expect(instances).toHaveLength(3); + this.instances = instances; } } - new Test("test"); + expect(new Test("test").instances).toHaveLength(3); }); it("should return a class that extends the original class (with 3 arguments)", () => { @AutoInjectable() diff --git a/packages/di/src/common/decorators/autoInjectable.ts b/packages/di/src/common/decorators/autoInjectable.ts index 519e6f55a52..5363f2f6955 100644 --- a/packages/di/src/common/decorators/autoInjectable.ts +++ b/packages/di/src/common/decorators/autoInjectable.ts @@ -1,13 +1,37 @@ +import {isArray, type Type} from "@tsed/core"; import {LocalsContainer} from "../domain/LocalsContainer.js"; +import type {TokenProvider} from "../interfaces/TokenProvider.js"; import {InjectorService} from "../services/InjectorService.js"; +import {getConstructorDependencies} from "../utils/getConstructorDependencies.js"; + +function resolveAutoInjectableArgs(token: Type, args: unknown[]) { + const locals = new LocalsContainer(); + const injector = InjectorService.getInstance(); + const deps: TokenProvider[] = getConstructorDependencies(token); + const list: any[] = []; + const length = Math.max(deps.length, args.length); + + for (let i = 0; i < length; i++) { + if (args[i] !== undefined) { + list.push(args[i]); + } else { + const value = deps[i]; + const instance = isArray(value) + ? injector!.getMany(value[0], locals, {parent: token}) + : injector!.invoke(value, locals, {parent: token}); + + list.push(instance); + } + } + + return list; +} export function AutoInjectable() { return }>(constr: T): T => { return class AutoInjectable extends constr { constructor(...args: any[]) { - const locals = new LocalsContainer(); - super(...InjectorService.resolveAutoInjectableArgs(constr, locals, args)); - InjectorService.bind(this, locals); + super(...resolveAutoInjectableArgs(constr, args)); } } as unknown as T; }; diff --git a/packages/di/src/common/decorators/constant.spec.ts b/packages/di/src/common/decorators/constant.spec.ts index 2caff743424..7a9b435040e 100644 --- a/packages/di/src/common/decorators/constant.spec.ts +++ b/packages/di/src/common/decorators/constant.spec.ts @@ -1,24 +1,95 @@ -import {Store} from "@tsed/core"; -import {INJECTABLE_PROP} from "../constants/constants.js"; -import {Constant} from "./constant.js"; - -class Test {} +import {DITest} from "../../node/index.js"; +import {constant, Constant} from "./constant.js"; describe("@Constant()", () => { - it("should store metadata", () => { - // WHEN - Constant("expression")(Test, "test"); - - // THEN - const store = Store.from(Test).get(INJECTABLE_PROP); - - expect(store).toEqual({ - test: { - bindingType: "constant", - propertyKey: "test", - expression: "expression", - defaultValue: undefined + beforeEach(() => + DITest.create({ + logger: { + level: "off" + } + }) + ); + afterEach(() => DITest.reset()); + describe("when decorator is used as property decorator", () => { + it("should create a getter", async () => { + // WHEN + class Test { + @Constant("logger.level", "default value") + test: string; + } + + // THEN + + const test = await DITest.invoke(Test); + + expect(test.test).toEqual("off"); + }); + it("should create a getter with default value", async () => { + // WHEN + class Test { + @Constant("logger.test", "default value") + test: string; + } + + // THEN + + const test = await DITest.invoke(Test); + + expect(test.test).toEqual("default value"); + }); + it("shouldn't be possible to modify injected value from injector.settings", async () => { + // WHEN + class Test { + @Constant("logger.level") + test: string; + } + + // THEN + + const test = await DITest.invoke(Test); + + test.test = "new value"; + + expect(test.test).toEqual("off"); + }); + it("should create a getter with native default value", async () => { + // WHEN + class Test { + @Constant("logger.test") + test: string = "default prop"; + } + + // THEN + + const test = await DITest.invoke(Test); + + expect(test.test).toEqual("default prop"); + }); + }); + describe("when constant is used as default value initializer", () => { + it("should inject constant to the property", async () => { + // WHEN + class Test { + test: string = constant("logger.level", "default value"); } + + // THEN + + const test = await DITest.invoke(Test); + + expect(test.test).toEqual("off"); + }); + it("should return the default value if expression is undefined", async () => { + // WHEN + class Test { + test: string = constant("logger.test", "default value"); + } + + // THEN + + const test = await DITest.invoke(Test); + + expect(test.test).toEqual("default value"); }); }); }); diff --git a/packages/di/src/common/decorators/constant.ts b/packages/di/src/common/decorators/constant.ts index b2b84105853..0e1022d5aa3 100644 --- a/packages/di/src/common/decorators/constant.ts +++ b/packages/di/src/common/decorators/constant.ts @@ -1,7 +1,39 @@ -import {Store} from "@tsed/core"; -import {INJECTABLE_PROP} from "../constants/constants.js"; -import type {InjectableProperties} from "../interfaces/InjectableProperties.js"; -import {InjectablePropertyType} from "../domain/InjectablePropertyType.js"; +import {catchError, deepClone} from "@tsed/core"; +import {InjectorService} from "../services/InjectorService.js"; + +export function constant(expression: string): Type | undefined; +export function constant(expression: string, defaultValue: Type | undefined): Type; +export function constant(expression: string, defaultValue?: Type | undefined): Type | undefined { + return InjectorService.getInstance().settings.get(expression, defaultValue); +} + +export function bindConstant(target: any, propertyKey: string | symbol, expression: string, defaultValue?: any) { + const symbol = Symbol(); + + catchError(() => Reflect.deleteProperty(target, propertyKey)); + Reflect.defineProperty(target, propertyKey, { + get() { + if (this[symbol] !== undefined) { + return this[symbol]; + } + + const value = constant(expression, defaultValue); + + this[symbol] = Object.freeze(deepClone(value)); + + return this[symbol]; + }, + set(value: unknown) { + const bean = constant(expression, defaultValue) || this[symbol]; + + if (bean === undefined && value !== undefined) { + this[symbol] = value; + } + }, + enumerable: true, + configurable: true + }); +} /** * Return value from Configuration. @@ -38,15 +70,8 @@ import {InjectablePropertyType} from "../domain/InjectablePropertyType.js"; * @returns {(targetClass: any, attributeName: string) => any} * @decorator */ -export function Constant(expression: string, defaultValue?: any): any { - return (target: any, propertyKey: string) => { - Store.from(target).merge(INJECTABLE_PROP, { - [propertyKey]: { - bindingType: InjectablePropertyType.CONSTANT, - propertyKey, - expression, - defaultValue - } - } as InjectableProperties); +export function Constant(expression: string, defaultValue?: Type): PropertyDecorator { + return (target, propertyKey) => { + return bindConstant(target, propertyKey, expression, defaultValue); }; } diff --git a/packages/di/src/common/decorators/inject.spec.ts b/packages/di/src/common/decorators/inject.spec.ts index 358fa4bba71..ab1779c1769 100644 --- a/packages/di/src/common/decorators/inject.spec.ts +++ b/packages/di/src/common/decorators/inject.spec.ts @@ -1,186 +1,122 @@ -import {descriptorOf} from "@tsed/core"; +import {catchAsyncError, catchError} from "@tsed/core"; +import {DITest} from "../../node/index.js"; import {registerProvider} from "../registries/ProviderRegistry.js"; import {InjectorService} from "../services/InjectorService.js"; -import {Inject} from "./inject.js"; +import {inject, Inject} from "./inject.js"; import {Injectable} from "./injectable.js"; -describe("@Inject()", () => { - describe("used on unsupported decorator type", () => { - it("should store metadata", () => { - // GIVEN - class Test { - test() {} - } - - // WHEN - let actualError; - try { - Inject()(Test, "test", descriptorOf(Test, "test")); - } catch (er) { - actualError = er; - } - - // THEN - expect(actualError.message).toEqual("Inject cannot be used as method.static decorator on Test.test"); - }); - }); +describe("inject", () => { + beforeEach(() => DITest.create()); + afterEach(() => DITest.reset()); + describe("inject()", () => { + describe("when inject function is used on a property", () => { + it("should inject the expected provider", async () => { + class Nested {} - describe("@property", () => { - it("should inject service", async () => { - // GIVEN - @Injectable() - class Test { - @Inject() - test: InjectorService; - } + class Test { + readonly injector = inject(InjectorService); + readonly nested = inject(Nested); - const injector = new InjectorService(); - const instance = await injector.invoke(Test); + constructor() { + expect(this.injector).toBeInstanceOf(InjectorService); + expect(this.nested).not.toBeInstanceOf(Nested); + } + } - expect(instance).toBeInstanceOf(Test); - expect(instance.test).toBeInstanceOf(InjectorService); + await DITest.invoke(Test, [ + { + token: Nested, + use: {} + } + ]); + }); }); - it("should inject service w/ async factory", async () => { - // GIVEN - class Test { - constructor(public type: string) {} - } - - const TokenAsync = Symbol.for("MyService"); - - registerProvider({ - provide: TokenAsync, - type: "test:async", - deps: [], - useAsyncFactory() { - return Promise.resolve(new Test("async")); + }); + describe("@Inject()", () => { + describe("when the decorator used on property", () => { + it("should inject service", async () => { + // GIVEN + @Injectable() + class Test { + @Inject() + test: InjectorService; } - }); - - @Injectable() - class Parent1 { - @Inject(TokenAsync) - test: Test; - } - @Injectable() - class Parent2 { - @Inject(TokenAsync) - test: Test; - } + const injector = new InjectorService(); + const instance = await injector.invoke(Test); - const injector = new InjectorService(); + expect(instance).toBeInstanceOf(Test); + expect(instance.test).toBeInstanceOf(InjectorService); + }); + it("should inject service and async factory", async () => { + // GIVEN + class Test { + constructor(public type: string) {} + } - await injector.load(); + const TokenAsync = Symbol.for("MyService"); - const parent1 = await injector.invoke(Parent1); - const parent2 = await injector.invoke(Parent2); + registerProvider({ + provide: TokenAsync, + type: "test:async", + deps: [], + useAsyncFactory() { + return Promise.resolve(new Test("async")); + } + }); - expect(parent1.test).toBeInstanceOf(Test); - expect(parent2.test).toBeInstanceOf(Test); - }); - it("should inject service with the given type", async () => { - // GIVEN - @Injectable() - class Test { - @Inject(InjectorService, (bean: any) => bean.get(InjectorService)) - test: InjectorService; - } - - const injector = new InjectorService(); - const instance = await injector.invoke(Test); - - expect(instance).toBeInstanceOf(Test); - expect(instance.test).toBeInstanceOf(InjectorService); - }); - it("should inject many services", async () => { - const TOKEN_GROUPS = Symbol.for("groups:1"); - - interface InterfaceGroup { - type: string; - } - - @Injectable({ - type: TOKEN_GROUPS - }) - class MyService1 implements InterfaceGroup { - readonly type: string = "service1"; - - constructor(@Inject(InjectorService) readonly injector: any) {} - } - - @Injectable({ - type: TOKEN_GROUPS - }) - class MyService2 implements InterfaceGroup { - readonly type: string = "service2"; - - constructor(@Inject(InjectorService) readonly injector: any) {} - } - - const TokenAsync = Symbol.for("MyService2"); - - registerProvider({ - provide: TokenAsync, - type: TOKEN_GROUPS, - deps: [], - useAsyncFactory() { - return Promise.resolve({ - type: "async" - }); + @Injectable() + class Parent1 { + @Inject(TokenAsync) + test: Test; } - }); - @Injectable() - class MyInjectable { - @Inject(TOKEN_GROUPS) - instances: InterfaceGroup[]; - } + @Injectable() + class Parent2 { + @Inject(TokenAsync) + test: Test; + } - const injector = new InjectorService(); + const injector = new InjectorService(); - await injector.load(); + await injector.load(); - const instance = await injector.invoke(MyInjectable); + const parent1 = await injector.invoke(Parent1); + const parent2 = await injector.invoke(Parent2); - 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"); - }); - it("should throw error", () => { - try { + expect(parent1.test).toBeInstanceOf(Test); + expect(parent2.test).toBeInstanceOf(Test); + }); + it("should inject service and use onGet option to transform injected service", async () => { // GIVEN @Injectable() class Test { - @Inject() - test: Object; + @Inject(InjectorService, {transform: (instance) => instance.get(InjectorService)}) + test: InjectorService; } - } catch (er) { - expect(er.message).toContain("Object isn't a valid token. Please check the token set on Test.test"); - } - }); - }); - describe("@constructorParameters", () => { - describe("when token is given on constructor", () => { - it("should inject the expected provider", async () => { + const injector = new InjectorService(); + const instance = await injector.invoke(Test); + + expect(instance).toBeInstanceOf(Test); + expect(instance.test).toBeInstanceOf(InjectorService); + }); + it("should inject service and use onGet option to transform injected service (legacy)", async () => { + // GIVEN @Injectable() - class MyInjectable { - constructor(@Inject(InjectorService) readonly injector: InjectorService) {} + class Test { + @Inject(InjectorService, (instance) => instance.get(InjectorService)) + test: InjectorService; } const injector = new InjectorService(); - const instance = await injector.invoke(MyInjectable); + const instance = await injector.invoke(Test); - expect(instance.injector).toBeInstanceOf(InjectorService); + expect(instance).toBeInstanceOf(Test); + expect(instance.test).toBeInstanceOf(InjectorService); }); - }); - - describe("when a group token is given on constructor", () => { - it("should inject the expected provider", async () => { - const TOKEN_GROUPS = Symbol.for("groups:2"); + it("should inject many services", async () => { + const TOKEN_GROUPS = Symbol.for("groups:1"); interface InterfaceGroup { type: string; @@ -192,7 +128,7 @@ describe("@Inject()", () => { class MyService1 implements InterfaceGroup { readonly type: string = "service1"; - constructor(@Inject(InjectorService) readonly injector: InjectorService) {} + constructor(@Inject(InjectorService) readonly injector: any) {} } @Injectable({ @@ -201,10 +137,10 @@ describe("@Inject()", () => { class MyService2 implements InterfaceGroup { readonly type: string = "service2"; - constructor(@Inject(InjectorService) readonly injector: InjectorService) {} + constructor(@Inject(InjectorService) readonly injector: any) {} } - const TokenAsync = Symbol.for("MyService1"); + const TokenAsync = Symbol.for("MyService2"); registerProvider({ provide: TokenAsync, @@ -219,7 +155,8 @@ describe("@Inject()", () => { @Injectable() class MyInjectable { - constructor(@Inject(TOKEN_GROUPS) readonly instances: InterfaceGroup[]) {} + @Inject(TOKEN_GROUPS) + instances: InterfaceGroup[]; } const injector = new InjectorService(); @@ -234,6 +171,135 @@ describe("@Inject()", () => { expect(instance.instances[1].type).toEqual("service2"); expect(instance.instances[2].type).toEqual("async"); }); + it("should throw error", () => { + try { + // GIVEN + @Injectable() + class Test { + @Inject() + test: Object; + } + } catch (er) { + expect(er.message).toContain("Object isn't a valid token. Please check the token set on Test.test"); + } + }); + it("should inject service and use mock", async () => { + @Injectable() + class Nested { + get cache() { + return true; + } + } + + @Injectable() + class Test { + @Inject() + nested: Nested; + } + + const instance = await DITest.invoke(Test, [ + { + token: Nested, + use: { + cache: false + } + } + ]); + + expect(instance.nested.cache).toEqual(false); + + const instance2 = await DITest.invoke(Test, []); + expect(instance2.nested.cache).toEqual(true); + }); + }); + describe("when the decorator is used on constructor parameter", () => { + 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); + }); + }); + + describe("when a group token is given on constructor", () => { + it("should inject the expected provider", async () => { + const TOKEN_GROUPS = Symbol.for("groups:2"); + + interface InterfaceGroup { + type: 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: [], + useAsyncFactory() { + return Promise.resolve({ + 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"); + }); + }); + }); + describe("when token is Object", () => { + it("should throw error", async () => { + class Test { + @Inject() + test: any; + } + + const error = await catchAsyncError(async () => { + const instance = await DITest.invoke(Test); + + return instance.test; + }); + + expect(error?.message).toContain("Object isn't a valid token. Please check the token set on Test.test"); + }); }); }); }); diff --git a/packages/di/src/common/decorators/inject.ts b/packages/di/src/common/decorators/inject.ts index 92b475030dc..2516ec86082 100644 --- a/packages/di/src/common/decorators/inject.ts +++ b/packages/di/src/common/decorators/inject.ts @@ -1,16 +1,106 @@ -import {decoratorTypeOf, DecoratorTypes, isPromise, Metadata, Store, UnsupportedDecoratorType} from "@tsed/core"; -import {DI_PARAM_OPTIONS, INJECTABLE_PROP} from "../constants/constants.js"; +import {catchError, decoratorTypeOf, DecoratorTypes, isPromise, Metadata, Store, type Type} from "@tsed/core"; +import {DI_USE_OPTIONS, DI_INJECTABLE_PROPS, DI_INVOKE_OPTIONS} from "../constants/constants.js"; import {InvalidPropertyTokenError} from "../errors/InvalidPropertyTokenError.js"; -import type {InjectablePropertyOptions} from "../interfaces/InjectableProperties.js"; +import type {InvokeOptions} from "../interfaces/InvokeOptions.js"; import {TokenProvider} from "../interfaces/TokenProvider.js"; +import {InjectorService} from "../services/InjectorService.js"; import {getConstructorDependencies, setConstructorDependencies} from "../utils/getConstructorDependencies.js"; -export function injectProperty(target: any, propertyKey: string, options: Partial) { - Store.from(target).merge(INJECTABLE_PROP, { - [propertyKey]: { - bindingType: DecoratorTypes.PROP, - propertyKey, - ...options +export function inject(token: TokenProvider, opts?: Partial>): T { + return InjectorService.getInstance().invoke(token, opts?.locals || InjectorService.getLocals(), opts); +} + +export function injectMany(token: string | symbol, opts?: Partial>): T[] { + return InjectorService.getInstance().getMany(token, opts?.locals || InjectorService.getLocals(), opts); +} + +function setToken( + token: TokenProvider, + { + target, + propertyKey, + parameterIndex + }: { + target: any; + propertyKey: string | symbol | undefined; + parameterIndex: number; + } +) { + const paramTypes = getConstructorDependencies(target, propertyKey); + const type = paramTypes[parameterIndex]; + + paramTypes[parameterIndex] = type === Array ? [token] : token; + + Metadata.setParamTypes(target, propertyKey!, paramTypes); + setConstructorDependencies(target, paramTypes); +} + +function getTokenType(token: TokenProvider | (() => TokenProvider) | undefined, target: any, propertyKey: string | symbol) { + const useType = token || Metadata.getType(target, propertyKey); + + if (useType === Object) { + throw new InvalidPropertyTokenError(target, String(propertyKey)); + } + + return useType; +} + +export type TransformInjectedProviderCB = ( + instance: T, + {target, propertyKey}: {self: Klass; target: Type; propertyKey: symbol | string} +) => unknown; + +export interface BindInjectablePropertyOpts { + token?: TokenProvider; + useOpts?: Record; + transform?: TransformInjectedProviderCB; +} + +function bindInjectableProperty( + target: any, + propertyKey: string | symbol, + {token, transform = (o) => o, useOpts}: BindInjectablePropertyOpts +) { + const symbol = Symbol(); + + if (!target[DI_INJECTABLE_PROPS]) { + Reflect.defineProperty(target, DI_INJECTABLE_PROPS, { + value: new Set(), + enumerable: false, + configurable: false + }); + } + + target[DI_INJECTABLE_PROPS].add(propertyKey); + + catchError(() => Reflect.deleteProperty(target, propertyKey)); + Reflect.defineProperty(target, propertyKey, { + get() { + const useType = getTokenType(token, target, propertyKey!); + const originalType = Metadata.getType(target, propertyKey); + + const invokeOpts: Partial = { + rebuild: !!this[DI_INVOKE_OPTIONS]?.rebuild, + locals: this[DI_INVOKE_OPTIONS]?.locals, + useOpts: useOpts || Store.from(target, propertyKey).get(DI_USE_OPTIONS) + }; + + if (this[symbol] === undefined) { + this[symbol] = originalType === Array ? injectMany(token as string, invokeOpts) : inject(useType, invokeOpts); + } + + [].concat(this[symbol]).forEach((instance: any, index) => { + if (isPromise(this[symbol])) { + instance.then((result: any) => { + this[symbol]![index] = result; + if (originalType !== Array) { + this[symbol] = result; + } + }); + } + }); + + return transform(this[symbol], {self: this, target, propertyKey}); } }); } @@ -29,78 +119,32 @@ export function injectProperty(target: any, propertyKey: string, options: Partia * ``` * * @param token A token provider or token provider group - * @param onGet Use the given name method to inject + * @param transform * @returns {Function} * @decorator */ -export function Inject(token?: TokenProvider | (() => TokenProvider), onGet = (bean: any) => bean): Function { - return (target: any, propertyKey: string | symbol | undefined, descriptor: TypedPropertyDescriptor | number): any | void => { - const bindingType = decoratorTypeOf([target, propertyKey, descriptor]); +export function Inject(token?: TokenProvider | (() => TokenProvider), transform?: TransformInjectedProviderCB): any; +export function Inject( + token?: TokenProvider | (() => TokenProvider), + opts?: Partial, "token">> +): any; +export function Inject( + token?: TokenProvider | (() => TokenProvider), + opts: TransformInjectedProviderCB | Partial, "token">> = {} +) { + opts = typeof opts === "function" ? {transform: opts} : opts; + + return (target: any, propertyKey: string | symbol | undefined, index?: number) => { + const bindingType = decoratorTypeOf([target, propertyKey, index]); switch (bindingType) { case DecoratorTypes.PARAM_CTOR: if (token) { - const paramTypes = getConstructorDependencies(target, propertyKey); - const type = paramTypes[descriptor as number]; - - paramTypes[descriptor as number] = type === Array ? [token] : token; - - Metadata.setParamTypes(target, propertyKey!, paramTypes); - setConstructorDependencies(target, paramTypes); + setToken(token, {target, propertyKey, parameterIndex: index!}); } break; - case DecoratorTypes.PROP: - const useType = token || Metadata.getType(target, propertyKey); - - if (useType === Object) { - throw new InvalidPropertyTokenError(target, String(propertyKey)); - } - - injectProperty(target, String(propertyKey), { - resolver(injector, locals, {options, ...invokeOptions}) { - const originalType = Metadata.getType(target, propertyKey); - 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) { - bean = injector.invoke(useType, locals, invokeOptions); - locals.delete(DI_PARAM_OPTIONS); - } - - if (isPromise(bean)) { - bean.then((result: any) => { - bean = result; - }); - } - - return () => onGet(bean); - } - }); - break; - - default: - throw new UnsupportedDecoratorType(Inject, [target, propertyKey, descriptor]); + bindInjectableProperty(target, propertyKey!, {...opts, token}); } }; } diff --git a/packages/di/src/common/decorators/intercept.spec.ts b/packages/di/src/common/decorators/intercept.spec.ts index 87c44cdce57..111a0cd4376 100644 --- a/packages/di/src/common/decorators/intercept.spec.ts +++ b/packages/di/src/common/decorators/intercept.spec.ts @@ -1,51 +1,106 @@ -import {Store} from "@tsed/core"; -import {INJECTABLE_PROP} from "../constants/constants.js"; -import {InjectablePropertyType} from "../domain/InjectablePropertyType.js"; +import {catchError} from "@tsed/core"; +import {DITest} from "../../node/index.js"; import {InterceptorContext} from "../interfaces/InterceptorContext.js"; import {InterceptorMethods} from "../interfaces/InterceptorMethods.js"; -import {Intercept} from "./intercept.js"; +import {InjectorService} from "../services/InjectorService.js"; +import {getInterceptorOptions, Intercept} from "./intercept.js"; +import {Interceptor} from "./interceptor.js"; +import {Service} from "./service.js"; + +@Interceptor() +class MyInterceptor implements InterceptorMethods { + constructor(injSrv: InjectorService) { + // do some logic + } + + intercept(context: InterceptorContext) { + const r = typeof context.args[0] === "string" ? undefined : new Error(`Error message`); + const retValue = context.next(r); + + return `${retValue} - ${context.options || ""} - intercepted`; + } +} + +@Service() +class ServiceTest { + @Intercept(MyInterceptor, "options data") + exec(param: string) { + return `Original data - ${param}`; + } +} + +@Service() +@Intercept(MyInterceptor, "options data") +class ServiceTest2 { + exec(param: string) { + return `Original data - ${param}`; + } +} describe("@Intercept", () => { - it("should add interceptor on method", () => { - // GIVEN - class TestInterceptor implements InterceptorMethods { - intercept(ctx: InterceptorContext) { - return ""; - } - } - - // WHEN - class TestService { - @Intercept(TestInterceptor, {options: "options"}) - test() {} - } - - // THEN - const injectableProperties = Store.from(TestService).get(INJECTABLE_PROP); - expect(injectableProperties.test.bindingType).toEqual(InjectablePropertyType.INTERCEPTOR); - expect(injectableProperties.test.useType).toEqual(TestInterceptor); - expect(injectableProperties.test.options).toEqual({options: "options"}); - expect(injectableProperties.test.propertyKey).toEqual("test"); + beforeEach(() => DITest.create()); + afterEach(() => DITest.reset()); + + describe("when the decorator is used on a method", () => { + it("should intercept the method", async () => { + // GIVEN + const serviceTest = await DITest.invoke(ServiceTest)!; + + // WHEN + const result = serviceTest.exec("param data"); + + expect(getInterceptorOptions(ServiceTest, "exec")).toEqual("options data"); + + // THEN + expect(result).toEqual("Original data - param data - options data - intercepted"); + }); + it("should intercept the method and mock interceptor", async () => { + // GIVEN + const serviceTest = await DITest.invoke(ServiceTest, [ + { + token: MyInterceptor, + use: { + intercept: vi.fn().mockReturnValue("intercepted") + } + } + ]); + + // WHEN + const result = serviceTest.exec("param data"); + + // THEN + expect(result).toEqual("intercepted"); + }); + it("should intercept the method and throw error", async () => { + // GIVEN + const serviceTest = await DITest.invoke(ServiceTest)!; + + // WHEN + let actualError = catchError(() => serviceTest.exec({} as any)); + + // THEN + expect(actualError?.message).toEqual("Error message"); + }); }); - it("should add interceptor on class and decorate all methods", () => { - // GIVEN - class TestInterceptor implements InterceptorMethods { - intercept(ctx: InterceptorContext) { - return ""; - } - } - - // WHEN - @Intercept(TestInterceptor, {options: "options"}) - class TestService { - test2() {} - } - - // THEN - const injectableProperties = Store.from(TestService).get(INJECTABLE_PROP); - expect(injectableProperties.test2.bindingType).toEqual(InjectablePropertyType.INTERCEPTOR); - expect(injectableProperties.test2.useType).toEqual(TestInterceptor); - expect(injectableProperties.test2.options).toEqual({options: "options"}); - expect(injectableProperties.test2.propertyKey).toEqual("test2"); + describe("when the decorator is used on a class", () => { + it("should intercept the method", async () => { + // GIVEN + const serviceTest = await DITest.invoke(ServiceTest2)!; + + // WHEN + const result = serviceTest.exec("param data"); + + // THEN + expect(result).toEqual("Original data - param data - options data - intercepted"); + }); + it("should intercept the method and throw error", async () => { + // GIVEN + const serviceTest = await DITest.invoke(ServiceTest2)!; + + // WHEN + let actualError = catchError(() => serviceTest.exec({} as any)); + // THEN + expect(actualError?.message).toEqual("Error message"); + }); }); }); diff --git a/packages/di/src/common/decorators/intercept.ts b/packages/di/src/common/decorators/intercept.ts index d40a5e4ccc1..f32b1dc36cd 100644 --- a/packages/di/src/common/decorators/intercept.ts +++ b/packages/di/src/common/decorators/intercept.ts @@ -1,8 +1,58 @@ -import {decorateMethodsOf, DecoratorParameters, decoratorTypeOf, DecoratorTypes, Store, Type} from "@tsed/core"; -import {INJECTABLE_PROP} from "../constants/constants.js"; -import {InjectablePropertyType} from "../domain/InjectablePropertyType.js"; +import {classOf, decorateMethodsOf, DecoratorParameters, decoratorTypeOf, DecoratorTypes, Store, Type} from "@tsed/core"; +import {DI_INTERCEPTOR_OPTIONS, DI_INVOKE_OPTIONS} from "../constants/constants.js"; +import type {InterceptorContext} from "../interfaces/InterceptorContext.js"; import type {InterceptorMethods} from "../interfaces/InterceptorMethods.js"; -import type {InjectableProperties} from "../interfaces/InjectableProperties.js"; +import type {InvokeOptions} from "../interfaces/InvokeOptions.js"; +import type {TokenProvider} from "../interfaces/TokenProvider.js"; +import {inject} from "./inject.js"; + +export function getInterceptorOptions(target: Type, propertyKey: string | symbol) { + return Store.fromMethod(target, propertyKey).get(DI_INTERCEPTOR_OPTIONS) as T; +} + +export function bindIntercept(target: any, propertyKey: string | symbol, token: TokenProvider, options: Record) { + const klass = classOf(target); + const descriptor = Reflect.getOwnPropertyDescriptor(klass.prototype, propertyKey); + const originalMethod = descriptor!.value; + const symbol = Symbol(); + + Store.fromMethod(klass, propertyKey).set(DI_INTERCEPTOR_OPTIONS, options); + + descriptor!.value = function (...args: any[]) { + const next = (err?: Error) => { + if (!err) { + return originalMethod.apply(this, args); + } + + throw err; + }; + + const context: InterceptorContext = { + target, + propertyKey, + args, + options, + next + }; + + const invokeOpts: Partial = { + rebuild: !!this[DI_INVOKE_OPTIONS]?.rebuild, + locals: this[DI_INVOKE_OPTIONS]?.locals + }; + + this[symbol] = this[symbol] || inject(token, invokeOpts)!; + + return this[symbol].intercept!( + { + ...context, + options + }, + next + ); + }; + + return descriptor; +} /** * Attaches interceptor to method call and executes the before and after methods @@ -13,23 +63,14 @@ import type {InjectableProperties} from "../interfaces/InjectableProperties.js"; */ export function Intercept(interceptor: Type, options?: any): any { return (...args: DecoratorParameters) => { - const [target, propertyKey, descriptor] = args; + const [target, propertyKey] = args; const type = decoratorTypeOf(args); switch (type) { case DecoratorTypes.CLASS: decorateMethodsOf(target, Intercept(interceptor, options)); break; case DecoratorTypes.METHOD: - Store.from(target).merge(INJECTABLE_PROP, { - [propertyKey]: { - options, - propertyKey, - useType: interceptor, - bindingType: InjectablePropertyType.INTERCEPTOR - } - } as InjectableProperties); - - return descriptor; + return bindIntercept(target, propertyKey, interceptor, options); } }; } diff --git a/packages/di/src/common/decorators/lazyInject.ts b/packages/di/src/common/decorators/lazyInject.ts index fe768abad50..ec58efc5081 100644 --- a/packages/di/src/common/decorators/lazyInject.ts +++ b/packages/di/src/common/decorators/lazyInject.ts @@ -1,5 +1,5 @@ -import {importPackage} from "@tsed/core"; -import {injectProperty} from "./inject.js"; +import {catchError, importPackage} from "@tsed/core"; +import {InjectorService} from "../services/InjectorService.js"; /** * Lazy load a provider from his package and invoke only when the provider is used @@ -26,26 +26,26 @@ export function LazyInject( resolver: () => any, {optional = false, packageName = resolver.toString()}: {optional?: boolean; packageName?: string} = {} ): PropertyDecorator { - return (target: any, propertyKey: string): any | void => { + return (target: any, propertyKey: string | symbol): any | void => { let bean: any, token: any; - injectProperty(target, propertyKey, { - resolver(injector) { - return async () => { - if (!token) { - const exports = await importPackage(packageName, resolver, optional); - token = exports[key]; - if (!token) { - if (!optional) { - throw new Error(`Unable to lazy load the "${key}". The token isn\'t a valid token provider.`); - } + catchError(() => Reflect.deleteProperty(target, propertyKey)); + Reflect.defineProperty(target, propertyKey, { + async get() { + if (!token) { + const injector = InjectorService.getInstance(); + const exports = await importPackage(packageName, resolver, optional); + token = exports[key]; + if (!token) { + if (!optional) { + throw new Error(`Unable to lazy load the "${key}". The token isn\'t a valid token provider.`); } - - bean = token ? await injector.lazyInvoke(token) : {}; } - return bean; - }; + bean = token ? await injector.lazyInvoke(token) : {}; + } + + return bean; } }); }; diff --git a/packages/di/src/common/decorators/opts.ts b/packages/di/src/common/decorators/opts.ts index 994abe3acaa..93ed44f197d 100644 --- a/packages/di/src/common/decorators/opts.ts +++ b/packages/di/src/common/decorators/opts.ts @@ -1,5 +1,5 @@ import {classOf} from "@tsed/core"; -import {DI_PARAM_OPTIONS} from "../constants/constants.js"; +import {DI_USE_PARAM_OPTIONS} from "../constants/constants.js"; import {ProviderScope} from "../domain/ProviderScope.js"; import {Inject} from "./inject.js"; import {Scope} from "./scope.js"; @@ -46,5 +46,5 @@ import {Scope} from "./scope.js"; */ export function Opts(target: any, propertyKey: string | symbol | undefined, index: number) { Scope(ProviderScope.INSTANCE)(classOf(target)); - Inject(DI_PARAM_OPTIONS)(target, propertyKey, index); + Inject(DI_USE_PARAM_OPTIONS)(target, propertyKey, index); } diff --git a/packages/di/src/common/decorators/overrideProvider.spec.ts b/packages/di/src/common/decorators/overrideProvider.spec.ts index 0091ef34991..b17d8a60843 100644 --- a/packages/di/src/common/decorators/overrideProvider.spec.ts +++ b/packages/di/src/common/decorators/overrideProvider.spec.ts @@ -1,4 +1,5 @@ import {Provider} from "../domain/Provider.js"; +import type {TokenProvider} from "../interfaces/TokenProvider.js"; import {GlobalProviders} from "../registries/GlobalProviders.js"; import {OverrideProvider} from "./overrideProvider.js"; @@ -14,7 +15,7 @@ describe("OverrideProvider", () => { // GIVEN const provider = new Provider(Test); - vi.mocked(GlobalProviders.get).mockImplementation((token: object) => { + vi.mocked(GlobalProviders.get).mockImplementation((token: TokenProvider) => { if (token === Test) { return provider; } diff --git a/packages/di/src/common/decorators/useOpts.ts b/packages/di/src/common/decorators/useOpts.ts index ffd823a7b47..9545300b476 100644 --- a/packages/di/src/common/decorators/useOpts.ts +++ b/packages/di/src/common/decorators/useOpts.ts @@ -1,5 +1,5 @@ import {decoratorTypeOf, DecoratorTypes, Store, UnsupportedDecoratorType} from "@tsed/core"; -import {DI_PARAM_OPTIONS, INJECTABLE_PROP} from "../constants/constants.js"; +import {DI_USE_PARAM_OPTIONS, DI_USE_OPTIONS} from "../constants/constants.js"; /** * Add options to invoke the Service. @@ -46,15 +46,11 @@ export function UseOpts(options: {[key: string]: any}): Function { switch (bindingType) { case DecoratorTypes.PARAM_CTOR: - Store.from(target).merge(`${DI_PARAM_OPTIONS}:${index}`, options); + Store.from(target).merge(`${DI_USE_PARAM_OPTIONS}:${index}`, options); break; case DecoratorTypes.PROP: - Store.from(target).merge(INJECTABLE_PROP, { - [propertyKey as string]: { - options - } - }); + Store.from(target, propertyKey).set(DI_USE_OPTIONS, options); break; default: diff --git a/packages/di/src/common/decorators/value.spec.ts b/packages/di/src/common/decorators/value.spec.ts index e924d47dc73..43c1072ac87 100644 --- a/packages/di/src/common/decorators/value.spec.ts +++ b/packages/di/src/common/decorators/value.spec.ts @@ -1,23 +1,58 @@ -import {Store} from "@tsed/core"; +import {DITest} from "../../node/index.js"; import {Value} from "./value.js"; -import {INJECTABLE_PROP} from "../constants/constants.js"; describe("@Value()", () => { - it("should store metadata", () => { - // GIVEN - class Test {} - - // WHEN - Value("expression")(Test, "test"); - - // THEN - expect(Store.from(Test).get(INJECTABLE_PROP)).toEqual({ - test: { - bindingType: "value", - propertyKey: "test", - expression: "expression", - defaultValue: undefined + beforeEach(() => + DITest.create({ + logger: { + level: "off" } + }) + ); + afterEach(() => DITest.reset()); + describe("when decorator is used as property decorator", () => { + it("should create a getter", async () => { + // WHEN + class Test { + @Value("logger.level", "default value") + test: string; + } + + // THEN + + const test = await DITest.invoke(Test); + + expect(test.test).toEqual("off"); + }); + it("should create a getter with default value", async () => { + expect(DITest.injector.settings.get("logger.test")).toEqual(undefined); + + // WHEN + class Test { + @Value("logger.test", "default value") + test: string; + } + + // THEN + + const test = await DITest.invoke(Test); + + expect(test.test).toEqual("default value"); + expect(DITest.injector.settings.get("logger.test")).toEqual(undefined); + }); + it("should create a getter with native default value", async () => { + // WHEN + class Test { + @Value("logger.test") + test: string = "default prop"; + } + + // THEN + + const test = await DITest.invoke(Test); + + expect(test.test).toEqual("default prop"); + expect(DITest.injector.settings.get("logger.test")).toEqual("default prop"); }); }); }); diff --git a/packages/di/src/common/decorators/value.ts b/packages/di/src/common/decorators/value.ts index 183335eac7e..40b56e27744 100644 --- a/packages/di/src/common/decorators/value.ts +++ b/packages/di/src/common/decorators/value.ts @@ -1,7 +1,21 @@ -import {Store} from "@tsed/core"; -import {INJECTABLE_PROP} from "../constants/constants.js"; -import type {InjectableProperties} from "../interfaces/InjectableProperties.js"; -import {InjectablePropertyType} from "../domain/InjectablePropertyType.js"; +import {catchError} from "@tsed/core"; +import {InjectorService} from "../services/InjectorService.js"; + +export function bindValue(target: any, propertyKey: string | symbol, expression: string, defaultValue?: any) { + const descriptor = { + get() { + return InjectorService.getInstance().settings.get(expression, defaultValue); + }, + set(value: unknown) { + InjectorService.getInstance().settings.set(expression, value); + }, + enumerable: true, + configurable: true + }; + + catchError(() => Reflect.deleteProperty(target, propertyKey)); + catchError(() => Reflect.defineProperty(target, propertyKey, descriptor)); +} /** * Return value from Configuration. @@ -38,15 +52,8 @@ import {InjectablePropertyType} from "../domain/InjectablePropertyType.js"; * @returns {(targetClass: any, attributeName: string) => any} * @decorator */ -export function Value(expression: any, defaultValue?: any) { - return (target: any, propertyKey: string) => { - Store.from(target).merge(INJECTABLE_PROP, { - [propertyKey]: { - bindingType: InjectablePropertyType.VALUE, - propertyKey, - expression, - defaultValue - } - } as InjectableProperties); +export function Value(expression: string, defaultValue?: unknown): PropertyDecorator { + return (target, propertyKey) => { + return bindValue(target, propertyKey, expression, defaultValue); }; } diff --git a/packages/di/src/common/domain/Container.ts b/packages/di/src/common/domain/Container.ts index 7ee6ee3659c..ffbb7e7e89d 100644 --- a/packages/di/src/common/domain/Container.ts +++ b/packages/di/src/common/domain/Container.ts @@ -49,21 +49,21 @@ export class Container extends Map { * @returns {T} Returns the element associated with the specified key or undefined if the key can't be found in the Map object. * @param token */ - public getProvider(token: TokenProvider): T | undefined { - return super.get(token) as T; + public getProvider(token: TokenProvider | undefined): T | undefined { + return super.get(token!) as T; } /** * Get all providers registered in the injector container. * - * @param {ProviderType} type Filter the list by the given ProviderType. + * @param type Filter the list by the given ProviderType. * @returns {[TokenProvider , Provider][]} */ - public getProviders(type?: ProviderType | string | string[]): Provider[] { - const types = ([] as (string | ProviderType)[]).concat(type as never); + public getProviders(type?: TokenProvider | ProviderType | string | string[]): Provider[] { + const types = ([] as (string | ProviderType)[]).concat(type as never).map(String); return [...this].reduce((providers, [_, provider]) => { - if (types.includes(provider.type) || !type) { + if (types.includes(String(provider.type)) || !type) { return [...providers, provider]; } return providers; diff --git a/packages/di/src/common/domain/Provider.ts b/packages/di/src/common/domain/Provider.ts index d684231b2f0..035adbd03be 100644 --- a/packages/di/src/common/domain/Provider.ts +++ b/packages/di/src/common/domain/Provider.ts @@ -1,4 +1,4 @@ -import {classOf, getClassOrSymbol, isClass, methodsOf, nameOf, Store, Type} from "@tsed/core"; +import {type AbstractType, classOf, getClassOrSymbol, isClass, methodsOf, nameOf, Store, Type} from "@tsed/core"; import type {ProviderOpts} from "../interfaces/ProviderOpts.js"; import type {TokenProvider} from "../interfaces/TokenProvider.js"; import {ProviderScope} from "./ProviderScope.js"; @@ -10,7 +10,7 @@ export class Provider implements ProviderOpts { /** * Token group provider to retrieve all provider from the same type */ - public type: TokenProvider | ProviderType = ProviderType.PROVIDER; + public type: ProviderType | TokenProvider = ProviderType.PROVIDER; public deps: TokenProvider[]; public imports: (TokenProvider | [TokenProvider])[]; public alias?: string; @@ -25,9 +25,9 @@ export class Provider implements ProviderOpts { [key: string]: any; - constructor(token: TokenProvider, options: Partial = {}) { + constructor(token: TokenProvider, options: Partial = {}) { this.provide = token; - this.useClass = token; + this.useClass = token as Type; Object.assign(this, options); } @@ -52,10 +52,10 @@ export class Provider implements ProviderOpts { } /** - * Create a new store if the given value is a class. Otherwise the value is ignored. + * Create a new store if the given value is a class. Otherwise, the value is ignored. * @param value */ - set useClass(value: Type) { + set useClass(value: Type | AbstractType) { if (isClass(value)) { this._useClass = classOf(value); this._store = Store.from(value); diff --git a/packages/di/src/common/errors/UndefinedTokenError.ts b/packages/di/src/common/errors/UndefinedTokenError.ts deleted file mode 100644 index 8ae8646d1f4..00000000000 --- a/packages/di/src/common/errors/UndefinedTokenError.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class UndefinedTokenError extends Error { - name = "UNDEFINED_TOKEN_ERROR"; - - constructor() { - super( - "Given token is undefined. Have you enabled emitDecoratorMetadata in your tsconfig.json or decorated your class with @Injectable, @Service, ... decorator ?" - ); - } -} diff --git a/packages/di/src/common/index.ts b/packages/di/src/common/index.ts index 227b5ba9cc1..c3245ae1bb2 100644 --- a/packages/di/src/common/index.ts +++ b/packages/di/src/common/index.ts @@ -27,13 +27,11 @@ export * from "./domain/ProviderScope.js"; export * from "./domain/ProviderType.js"; export * from "./errors/InjectionError.js"; export * from "./errors/InvalidPropertyTokenError.js"; -export * from "./errors/UndefinedTokenError.js"; export * from "./interfaces/DIConfigurationOptions.js"; export * from "./interfaces/DILogger.js"; export * from "./interfaces/DILoggerOptions.js"; export * from "./interfaces/DIResolver.js"; export * from "./interfaces/ImportTokenProviderOpts.js"; -export * from "./interfaces/InjectableProperties.js"; export * from "./interfaces/InterceptorContext.js"; export * from "./interfaces/InterceptorMethods.js"; export * from "./interfaces/InvokeOptions.js"; diff --git a/packages/di/src/common/integration/interceptor.spec.ts b/packages/di/src/common/integration/interceptor.spec.ts deleted file mode 100644 index 76c3d219aa6..00000000000 --- a/packages/di/src/common/integration/interceptor.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import {Intercept} from "../decorators/intercept.js"; -import {Interceptor} from "../decorators/interceptor.js"; -import {Service} from "../decorators/service.js"; -import {Container} from "../domain/Container.js"; -import {InterceptorContext} from "../interfaces/InterceptorContext.js"; -import {InterceptorMethods} from "../interfaces/InterceptorMethods.js"; -import {GlobalProviders} from "../registries/GlobalProviders.js"; -import {InjectorService} from "../services/InjectorService.js"; - -describe("DI Interceptor", () => { - @Interceptor() - class MyInterceptor implements InterceptorMethods { - constructor(injSrv: InjectorService) { - // do some logic - } - - intercept(context: InterceptorContext) { - const r = typeof context.args[0] === "string" ? undefined : new Error(`Error message`); - const retValue = context.next(r); - - return `${retValue} - ${context.options || ""} - intercepted`; - } - } - - @Service() - class ServiceTest { - @Intercept(MyInterceptor, "options data") - exec(param: string) { - return `Original data - ${param}`; - } - } - - afterAll(() => { - GlobalProviders.delete(MyInterceptor); - GlobalProviders.delete(ServiceTest); - }); - - it("should intercept the method", async () => { - // GIVEN - const injector = new InjectorService(); - const container = new Container(); - container.addProvider(MyInterceptor); - container.addProvider(ServiceTest); - - await injector.load(container); - - const serviceTest = injector.invoke(ServiceTest)!; - - // WHEN - const result = serviceTest.exec("param data"); - - // THEN - expect(result).toEqual("Original data - param data - options data - intercepted"); - }); - - it("should intercept the method and throw error", async () => { - // GIVEN - const injector = new InjectorService(); - const container = new Container(); - container.addProvider(MyInterceptor); - container.addProvider(ServiceTest); - - await injector.load(container); - - const serviceTest = await injector.invoke(ServiceTest)!; - - // WHEN - let actualError; - try { - serviceTest.exec({} as any); - } catch (er) { - actualError = er; - } - // THEN - expect(actualError.message).toEqual("Error message"); - }); -}); diff --git a/packages/di/src/common/interfaces/ImportTokenProviderOpts.ts b/packages/di/src/common/interfaces/ImportTokenProviderOpts.ts index b267f0ba9ce..2374a7c1e68 100644 --- a/packages/di/src/common/interfaces/ImportTokenProviderOpts.ts +++ b/packages/di/src/common/interfaces/ImportTokenProviderOpts.ts @@ -1,4 +1,4 @@ -import type {Type} from "@tsed/core"; +import type {AbstractType, Type} from "@tsed/core"; import type {TokenProvider} from "./TokenProvider.js"; export type UseImportTokenProviderOpts = { @@ -8,7 +8,7 @@ export type UseImportTokenProviderOpts = { export type UseClassImportTokenProviderOpts = { token: TokenProvider; - useClass: Type | Function; + useClass: Type | AbstractType; }; export type UseFactoryImportTokenProviderOpts = { diff --git a/packages/di/src/common/interfaces/InjectableProperties.ts b/packages/di/src/common/interfaces/InjectableProperties.ts deleted file mode 100644 index 537bc82f941..00000000000 --- a/packages/di/src/common/interfaces/InjectableProperties.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type {InjectablePropertyType} from "../domain/InjectablePropertyType.js"; -import type {LocalsContainer} from "../domain/LocalsContainer.js"; -import type {TokenProvider} from "./TokenProvider.js"; -import type {InjectorService} from "../services/InjectorService.js"; -import type {InvokeOptions} from "./InvokeOptions.js"; - -export interface InjectableProperty { - propertyKey: string; -} - -export interface InjectablePropertyOptions extends InjectableProperty { - bindingType: InjectablePropertyType.METHOD | InjectablePropertyType.PROPERTY | InjectablePropertyType.INTERCEPTOR; - propertyType: string; - useType?: TokenProvider; - resolver: (injector: InjectorService, locals: LocalsContainer, options: Partial & {options: any}) => () => any; - options?: any; -} - -export interface InjectablePropertyValue extends InjectableProperty { - bindingType: InjectablePropertyType.CONSTANT | InjectablePropertyType.VALUE; - expression: string; - defaultValue?: any; -} - -export interface InjectableProperties { - [key: string]: InjectablePropertyOptions | InjectablePropertyValue; -} diff --git a/packages/di/src/common/interfaces/InterceptorContext.ts b/packages/di/src/common/interfaces/InterceptorContext.ts index 6d611368e16..211a5ef4cef 100644 --- a/packages/di/src/common/interfaces/InterceptorContext.ts +++ b/packages/di/src/common/interfaces/InterceptorContext.ts @@ -6,7 +6,7 @@ export interface InterceptorNext { export interface InterceptorContext { target: Klass; - propertyKey: string; + propertyKey: string | symbol; args: any[]; next: InterceptorNext; options?: Opts; diff --git a/packages/di/src/common/interfaces/InvokeOptions.ts b/packages/di/src/common/interfaces/InvokeOptions.ts index 9c0ca320e1e..f292e5644fd 100644 --- a/packages/di/src/common/interfaces/InvokeOptions.ts +++ b/packages/di/src/common/interfaces/InvokeOptions.ts @@ -1,3 +1,4 @@ +import type {LocalsContainer} from "../domain/LocalsContainer.js"; import {ProviderScope} from "../domain/ProviderScope.js"; import {TokenProvider} from "./TokenProvider.js"; @@ -5,11 +6,11 @@ export interface InvokeOptions { /** * Define dependencies to build the provider and inject them in the constructor. */ - deps: unknown[]; + deps: TokenProvider[]; /** * List of imports to be created before the provider. Imports list aren't injected directly in the provider constructor. */ - imports: unknown[]; + imports: TokenProvider[]; /** * Parent provider. */ @@ -22,4 +23,10 @@ export interface InvokeOptions { * If true, the injector will rebuild the instance. */ rebuild?: boolean; + /** + * Option given to injectable props or parameter constructor (UseOpts). + */ + useOpts?: Record; + + locals?: LocalsContainer; } diff --git a/packages/di/src/common/interfaces/ProviderOpts.ts b/packages/di/src/common/interfaces/ProviderOpts.ts index b4cfb048f0e..9ea743878d3 100644 --- a/packages/di/src/common/interfaces/ProviderOpts.ts +++ b/packages/di/src/common/interfaces/ProviderOpts.ts @@ -8,7 +8,7 @@ export interface ProviderOpts { /** * An injection token. (Typically an instance of `Type` or `InjectionToken`, but can be `any`). */ - provide: TokenProvider; + provide: TokenProvider; /** * Create alias token to retrieve the instance provider. */ diff --git a/packages/di/src/common/interfaces/RegistrySettings.ts b/packages/di/src/common/interfaces/RegistrySettings.ts index ed79865caa8..975e064432b 100644 --- a/packages/di/src/common/interfaces/RegistrySettings.ts +++ b/packages/di/src/common/interfaces/RegistrySettings.ts @@ -1,4 +1,5 @@ import {Type} from "@tsed/core"; +import type {LocalsContainer} from "../domain/LocalsContainer.js"; import type {Provider} from "../domain/Provider.js"; import type {InjectorService} from "../services/InjectorService.js"; import type {ResolvedInvokeOptions} from "./ResolvedInvokeOptions.js"; @@ -16,5 +17,5 @@ export interface RegistrySettings { * @param {Map} locals * @param options */ - onInvoke?(provider: Provider, locals: Map, options: ResolvedInvokeOptions & {injector: InjectorService}): void; + onInvoke?(provider: Provider, locals: LocalsContainer, options: ResolvedInvokeOptions & {injector: InjectorService}): void; } diff --git a/packages/di/src/common/interfaces/ResolvedInvokeOptions.ts b/packages/di/src/common/interfaces/ResolvedInvokeOptions.ts index 1bd069c2aa1..93181c0f961 100644 --- a/packages/di/src/common/interfaces/ResolvedInvokeOptions.ts +++ b/packages/di/src/common/interfaces/ResolvedInvokeOptions.ts @@ -7,7 +7,7 @@ export interface ResolvedInvokeOptions { parent?: TokenProvider; scope: ProviderScope; deps: TokenProvider[]; - imports: TokenProvider[]; + imports: (TokenProvider | [TokenProvider])[]; provider: Provider; construct(deps: TokenProvider[]): any; diff --git a/packages/di/src/common/interfaces/TokenProvider.ts b/packages/di/src/common/interfaces/TokenProvider.ts index 538e8f569c6..7e651fd5b6c 100644 --- a/packages/di/src/common/interfaces/TokenProvider.ts +++ b/packages/di/src/common/interfaces/TokenProvider.ts @@ -1,8 +1,3 @@ -import type {Type} from "@tsed/core"; +import type {Type, AbstractType} from "@tsed/core"; -export type TokenProvider = string | symbol | Type | Function | any; - -export interface TokenProviderOpts { - token?: TokenProvider; - use: any; -} +export type TokenProvider = string | symbol | Type | AbstractType | Function; diff --git a/packages/di/src/common/registries/GlobalProviders.spec.ts b/packages/di/src/common/registries/GlobalProviders.spec.ts index 615fbeacc28..b83e79e3952 100644 --- a/packages/di/src/common/registries/GlobalProviders.spec.ts +++ b/packages/di/src/common/registries/GlobalProviders.spec.ts @@ -1,3 +1,5 @@ +import {s} from "vite/dist/node/types.d-aGj9QkWt.js"; +import {a} from "vitest/dist/suite-CRLAhsm0.js"; import {LocalsContainer} from "../domain/LocalsContainer.js"; import {Provider} from "../domain/Provider.js"; import {ProviderType} from "../domain/ProviderType.js"; @@ -45,7 +47,7 @@ describe("GlobalProviderRegistry", () => { const provider = new Provider("token"); provider.type = ProviderType.PROVIDER; // WHEN - const settings = providers.getRegistrySettings(provider); + const settings = providers.getRegistrySettings(provider as never); // THEN expect(settings).toEqual({ diff --git a/packages/di/src/common/registries/GlobalProviders.ts b/packages/di/src/common/registries/GlobalProviders.ts index 6359e0eba28..b6096d03b31 100644 --- a/packages/di/src/common/registries/GlobalProviders.ts +++ b/packages/di/src/common/registries/GlobalProviders.ts @@ -9,7 +9,7 @@ import {TokenProvider} from "../interfaces/TokenProvider.js"; import type {InjectorService} from "../services/InjectorService.js"; export class GlobalProviderRegistry extends Map { - #settings: Map = new Map(); + #settings: Map = new Map(); /** * The get() method returns a specified element from a Map object. @@ -49,7 +49,12 @@ export class GlobalProviderRegistry extends Map { const meta = this.createIfNotExists(target, options); Object.keys(options).forEach((key) => { - meta[key] = (options as any)[key]; + let value = (options as never)[key]; + // if (key === "type") { + // value = String(value); + // } + + meta[key] = value; }); this.set(target, meta); @@ -66,7 +71,7 @@ export class GlobalProviderRegistry extends Map { return super.delete(getClassOrSymbol(key)); } - createRegistry(type: string, model: Type, options: Partial = {}) { + createRegistry(type: string | symbol, model: Type, options: Partial = {}) { const defaultOptions = this.getRegistrySettings(type); options = Object.assign(defaultOptions, { @@ -87,8 +92,8 @@ export class GlobalProviderRegistry extends Map { } } - getRegistrySettings(target: string | TokenProvider): RegistrySettings { - let type: string = "provider"; + getRegistrySettings(target: TokenProvider): RegistrySettings { + let type: TokenProvider | ProviderType = ProviderType.PROVIDER; if (typeof target === "string") { type = target; diff --git a/packages/di/src/common/services/DIConfiguration.ts b/packages/di/src/common/services/DIConfiguration.ts index 17a19ae8d01..c0921c89d84 100644 --- a/packages/di/src/common/services/DIConfiguration.ts +++ b/packages/di/src/common/services/DIConfiguration.ts @@ -30,7 +30,7 @@ export class DIConfiguration { } get version() { - return this.get("version"); + return this.get("version")!; } set version(v: string) { @@ -38,7 +38,7 @@ export class DIConfiguration { } get rootDir() { - return this.get("rootDir"); + return this.get("rootDir")!; } set rootDir(value: string) { @@ -70,7 +70,7 @@ export class DIConfiguration { } get imports(): (TokenProvider | ImportTokenProviderOpts)[] { - return this.get("imports"); + return this.get("imports")!; } set imports(imports: (TokenProvider | ImportTokenProviderOpts)[]) { @@ -78,7 +78,7 @@ export class DIConfiguration { } get routes(): TokenRoute[] { - return this.get("routes"); + return this.get("routes")!; } set routes(routes: TokenRoute[]) { @@ -86,7 +86,7 @@ export class DIConfiguration { } get logger(): Partial { - return this.get("logger"); + return this.get("logger")!; } set logger(value: Partial) { diff --git a/packages/di/src/common/services/DILogger.spec.ts b/packages/di/src/common/services/DILogger.spec.ts index 5a9daf1ad6e..2eddde47253 100644 --- a/packages/di/src/common/services/DILogger.spec.ts +++ b/packages/di/src/common/services/DILogger.spec.ts @@ -15,7 +15,7 @@ describe("DILogger", () => { container.add(MyService); await injector.load(container); - const logger = injector.get(MyService).logger; + const logger = injector.get(MyService)!.logger; expect(logger).toEqual(injector.logger); }); diff --git a/packages/di/src/common/services/InjectorService.spec.ts b/packages/di/src/common/services/InjectorService.spec.ts index 6a74910a46e..f4740bd1fc2 100644 --- a/packages/di/src/common/services/InjectorService.spec.ts +++ b/packages/di/src/common/services/InjectorService.spec.ts @@ -1,5 +1,5 @@ import {Store} from "@tsed/core"; -import {INJECTABLE_PROP} from "../constants/constants.js"; +import {DI_USE_OPTIONS} from "../constants/constants.js"; import {Configuration} from "../decorators/configuration.js"; import {Inject} from "../decorators/inject.js"; import {Injectable} from "../decorators/injectable.js"; @@ -423,7 +423,7 @@ describe("InjectorService", () => { const provider3 = new Provider(token3); provider3.scope = ProviderScope.SINGLETON; - provider3.deps = [undefined]; + provider3.deps = [undefined] as never; const injector = new InjectorService(); injector.set(token2, provider2); @@ -439,7 +439,7 @@ describe("InjectorService", () => { // THEN expect(actualError.message).toContain( - "Injection failed on Test\nOrigin: Unable to inject dependency. Given token is undefined. Have you enabled emitDecoratorMetadata in your tsconfig.json or decorated your class with @Injectable, @Service, ... decorator ?" + "Injection failed on Test\nOrigin: Unable to inject dependency. Given token is undefined. Could mean a circular dependency problem. Try to use @Inject(() => Token) to solve it." ); }); it("should throw InjectionError > Object", () => { @@ -602,173 +602,6 @@ describe("InjectorService", () => { }); }); - describe("bindInjectableProperties()", () => { - class TestBind {} - - it("should bind all properties", () => { - // GIVEN - const injector = new InjectorService(); - const instance = new TestBind(); - - vi.spyOn(injector as any, "bindProperty").mockReturnValue(undefined); - vi.spyOn(injector as any, "bindConstant").mockReturnValue(undefined); - vi.spyOn(injector as any, "bindValue").mockReturnValue(undefined); - vi.spyOn(injector as any, "bindInterceptor").mockReturnValue(undefined); - - const injectableProperties = { - testMethod: { - bindingType: "method" - }, - testProp: { - bindingType: "property" - }, - testConst: { - bindingType: "constant" - }, - testValue: { - bindingType: "value" - }, - testInterceptor: { - bindingType: "interceptor" - } - }; - - Store.from(TestBind).set(INJECTABLE_PROP, injectableProperties); - - // WHEN - injector.bindInjectableProperties(instance, new LocalsContainer(), {}); - - // THEN - expect(injector.bindProperty).toBeCalledWith(instance, injectableProperties.testProp, new LocalsContainer(), {}); - expect(injector.bindConstant).toBeCalledWith(instance, injectableProperties.testConst); - expect(injector.bindValue).toBeCalledWith(instance, injectableProperties.testValue); - expect(injector.bindInterceptor).toBeCalledWith(instance, injectableProperties.testInterceptor); - }); - }); - - describe("bindProperty()", () => { - it("should bind the method", () => { - // GIVEN - const injector = new InjectorService(); - const instance = new Test(); - - // WHEN - injector.bindProperty( - instance, - { - bindingType: "property", - propertyKey: "prop", - resolver: (injector: InjectorService) => () => injector.get(InjectorService) - } as any, - new LocalsContainer(), - {} - ); - - // THEN - expect(instance.prop).toEqual(injector); - }); - }); - - describe("bindValue()", () => { - it("should bind a property with a value (1)", () => { - // GIVEN - const injector = new InjectorService(); - const instance = new Test(); - - // WHEN - injector.bindValue(instance, {propertyKey: "value", expression: "expression"} as any); - - instance.value = "test"; - // THEN - expect(instance.value).toEqual("test"); - }); - - it("should bind a property with a value (2)", () => { - // GIVEN - const injector = new InjectorService(); - const instance = new Test(); - - // WHEN - injector.bindValue(instance, {propertyKey: "value", expression: "UNKNOW", defaultValue: "test2"} as any); - - // THEN - expect(instance.value).toEqual("test2"); - }); - }); - - describe("bindConstant()", () => { - it("should bind a property with a value (1)", () => { - // GIVEN - const injector = new InjectorService(); - const instance = new Test(); - - injector.settings.set("expression", "constant"); - - // WHEN - injector.bindConstant(instance, {propertyKey: "constant", expression: "expression"} as any); - - // THEN - expect(instance.constant).toEqual("constant"); - // should be the same - expect(instance.constant).toEqual("constant"); - - let actualError: any; - try { - instance.constant = "test"; - } catch (er) { - actualError = er; - } - expect(!!actualError).toEqual(true); - }); - - it("should bind a property with a value (2)", () => { - // GIVEN - const injector = new InjectorService(); - const instance = new Test(); - - // WHEN - injector.bindConstant(instance, {propertyKey: "constant", expression: "UNKNOW", defaultValue: "test"} as any); - - // THEN - expect(instance.constant).toEqual("test"); - }); - }); - - describe("bindInterceptor()", () => { - it("should bind the method with intercept", async () => { - // GIVEN - class InterceptorTest { - intercept(ctx: any) { - return ctx.next() + " intercepted"; - } - } - - const injector = new InjectorService(); - const container = new Container(); - container.addProvider(InterceptorTest); - - await injector.load(container); - - const instance = new Test(); - - vi.spyOn(injector, "get"); - - // WHEN - injector.bindInterceptor(instance, { - bindingType: "interceptor", - propertyKey: "test3", - useType: InterceptorTest - } as any); - - const result = (instance as any).test3("test"); - - // THEN - expect(injector.get).toBeCalledWith(InterceptorTest); - - expect(result).toEqual("test called intercepted"); - }); - }); - describe("resolveConfiguration()", () => { it("should load configuration from each providers", () => { // GIVEN diff --git a/packages/di/src/common/services/InjectorService.ts b/packages/di/src/common/services/InjectorService.ts index 98b16326235..e962fd5de29 100644 --- a/packages/di/src/common/services/InjectorService.ts +++ b/packages/di/src/common/services/InjectorService.ts @@ -1,6 +1,4 @@ import { - ancestorsOf, - catchError, classOf, deepClone, deepMerge, @@ -9,25 +7,21 @@ import { isClass, isFunction, isInheritedFrom, + isObject, isPromise, nameOf, - Store, - type Type + Store } from "@tsed/core"; -import {DI_PARAM_OPTIONS, INJECTABLE_PROP} from "../constants/constants.js"; +import {DI_INVOKE_OPTIONS, DI_USE_PARAM_OPTIONS} from "../constants/constants.js"; import {Configuration} from "../decorators/configuration.js"; import {Injectable} from "../decorators/injectable.js"; import {Container} from "../domain/Container.js"; -import {InjectablePropertyType} from "../domain/InjectablePropertyType.js"; import {LocalsContainer} from "../domain/LocalsContainer.js"; import {Provider} from "../domain/Provider.js"; import {ProviderScope} from "../domain/ProviderScope.js"; import {InjectionError} from "../errors/InjectionError.js"; -import {UndefinedTokenError} from "../errors/UndefinedTokenError.js"; import type {DILogger} from "../interfaces/DILogger.js"; -import {InjectableProperties, InjectablePropertyOptions, InjectablePropertyValue} from "../interfaces/InjectableProperties.js"; -import type {InterceptorContext} from "../interfaces/InterceptorContext.js"; -import type {InterceptorMethods} from "../interfaces/InterceptorMethods.js"; +import type {ImportTokenProviderOpts} from "../interfaces/ImportTokenProviderOpts.js"; import type {InvokeOptions} from "../interfaces/InvokeOptions.js"; import type {ResolvedInvokeOptions} from "../interfaces/ResolvedInvokeOptions.js"; import type {TokenProvider} from "../interfaces/TokenProvider.js"; @@ -38,6 +32,8 @@ import {resolveControllers} from "../utils/resolveControllers.js"; import {DIConfiguration} from "./DIConfiguration.js"; let globalInjector: InjectorService | undefined; +let globalLocals: LocalsContainer | undefined; + /** * This service contain all services collected by `@Service` or services declared manually with `InjectorService.factory()` or `InjectorService.service()`. * @@ -83,34 +79,28 @@ export class InjectorService extends Container { return this.settings.scopes || {}; } - static resolveAutoInjectableArgs(token: Type, locals: LocalsContainer, args: unknown[]) { + /** + * Return the current injector service. + */ + static getInstance() { if (!globalInjector) { throw new Error("InjectorService instance is not created yet."); } + return globalInjector; + } - const deps: unknown[] = getConstructorDependencies(token); - const list: any[] = []; - const length = Math.max(deps.length, args.length); - - for (let i = 0; i < length; i++) { - if (args[i] !== undefined) { - list.push(args[i]); - } else { - const value = deps[i]; - - const instance = isArray(value) - ? globalInjector!.getMany(value[0], locals, {parent: token}) - : globalInjector!.invoke(value, locals, {parent: token}); - - list.push(instance); - } - } - - return list; + /** + * Get the locals container initiated by DITest or .bootstrap() method. + */ + static getLocals() { + return globalLocals || (globalLocals = new LocalsContainer()); } - static bind(instance: any, locals: LocalsContainer) { - globalInjector!.bindInjectableProperties(instance, locals, {}); + /** + * Reset the locals container. + */ + static unsetLocals() { + globalLocals = undefined; } /** @@ -118,7 +108,7 @@ export class InjectorService extends Container { * @param provider */ public scopeOf(provider: Provider) { - return provider.scope || this.scopes[provider.type] || ProviderScope.SINGLETON; + return provider.scope || this.scopes[String(provider.type)] || ProviderScope.SINGLETON; } /** @@ -146,9 +136,8 @@ export class InjectorService extends Container { * * @param token The class or symbol registered in InjectorService. * @param options - * @returns {boolean} */ - get(token: TokenProvider, options: any = {}): T | undefined { + get(token: TokenProvider, options: Record = {}): T | undefined { const instance = this.getInstance(token); if (instance !== undefined) { @@ -168,23 +157,23 @@ export class InjectorService extends Container { /** * Return all instance of the same provider type - * @param type - * @param locals - * @param options */ getMany(type: any, locals?: LocalsContainer, options?: Partial): Type[] { - return this.getProviders(type).map((provider) => this.invoke(provider.token, locals, options)!); + return this.getProviders(type).map((provider) => { + return this.invoke(provider.token, locals, options)!; + }); } /** * The has() method returns a boolean indicating whether an element with the specified key exists or not. - * @returns {boolean} - * @param token */ has(token: TokenProvider): boolean { return this.#cache.get(token) !== undefined; } + /** + * Declare an alias for a given token. + */ alias(token: TokenProvider, alias: TokenProvider) { this.#cache.set(alias, this.#cache.get(token)); @@ -212,7 +201,7 @@ export class InjectorService extends Container { * @param options * @returns {Type} The class constructed. */ - public invoke(token: TokenProvider, locals?: LocalsContainer, options: Partial = {}): Type { + public invoke(token: TokenProvider, locals?: LocalsContainer, options: Partial = {}): Type { let instance: any = locals ? locals.get(token) : undefined; if (instance !== undefined) { @@ -229,8 +218,8 @@ export class InjectorService extends Container { return instance; } - if (token === DI_PARAM_OPTIONS) { - return {} as Type; + if (token === DI_USE_PARAM_OPTIONS) { + return options.useOpts as Type; } const provider = this.ensureProvider(token); @@ -260,6 +249,7 @@ export class InjectorService extends Container { if (!provider.isAsync() || !isPromise(instance)) { set(instance); + // locals?.delete(DI_USE_PARAM_OPTIONS); return instance; } @@ -271,7 +261,7 @@ export class InjectorService extends Container { return instance; }); - + // locals?.delete(DI_USE_PARAM_OPTIONS); return instance; case ProviderScope.REQUEST: @@ -283,6 +273,8 @@ export class InjectorService extends Container { } } + // locals?.delete(DI_USE_PARAM_OPTIONS); + return instance; } @@ -300,6 +292,9 @@ export class InjectorService extends Container { } } + /** + * Build only providers which are synchronous. + */ loadSync() { for (const [, provider] of this) { if (!this.has(provider.token) && this.scopeOf(provider) === ProviderScope.SINGLETON) { @@ -393,159 +388,6 @@ export class InjectorService extends Container { this.resolvedConfiguration = true; } - /** - * - * @param instance - * @param locals - * @param options - */ - public bindInjectableProperties(instance: any, locals: LocalsContainer, options: Partial) { - const properties: InjectableProperties = ancestorsOf(classOf(instance)).reduce((properties: any, target: any) => { - const store = Store.from(target); - - return { - ...properties, - ...(store.get(INJECTABLE_PROP) || {}) - }; - }, {}); - - Object.values(properties).forEach((definition) => { - switch (definition.bindingType) { - case InjectablePropertyType.PROPERTY: - this.bindProperty(instance, definition, locals, options); - break; - case InjectablePropertyType.CONSTANT: - this.bindConstant(instance, definition); - break; - case InjectablePropertyType.VALUE: - this.bindValue(instance, definition); - break; - case InjectablePropertyType.INTERCEPTOR: - this.bindInterceptor(instance, definition); - break; - } - }); - } - - /** - * Create an injectable property. - * - * @param instance - * @param {string} propertyKey - * @param {any} useType - * @param resolver - * @param options - * @param locals - * @param invokeOptions - */ - public bindProperty( - instance: any, - {propertyKey, resolver, options = {}}: InjectablePropertyOptions, - locals: LocalsContainer, - invokeOptions: Partial - ) { - let get: () => any; - - get = resolver(this, locals, {...invokeOptions, options}); - - catchError(() => Reflect.deleteProperty(instance, propertyKey)); - catchError(() => - Reflect.defineProperty(instance, propertyKey, { - get, - enumerable: true, - configurable: true - }) - ); - } - - /** - * - * @param instance - * @param {string} propertyKey - * @param {any} useType - */ - public bindValue(instance: any, {propertyKey, expression, defaultValue}: InjectablePropertyValue) { - const descriptor = { - get: () => this.settings.get(expression) || defaultValue, - set: (value: any) => this.settings.set(expression, value), - enumerable: true, - configurable: true - }; - - catchError(() => Reflect.deleteProperty(instance, propertyKey)); - catchError(() => Reflect.defineProperty(instance, propertyKey, descriptor)); - } - - /** - * - * @param instance - * @param {string} propertyKey - * @param {any} useType - */ - public bindConstant(instance: any, {propertyKey, expression, defaultValue}: InjectablePropertyValue) { - let bean: any; - - const get = () => { - if (bean !== undefined) { - return bean; - } - - const value = this.settings.get(expression, defaultValue); - bean = Object.freeze(deepClone(value)); - - return bean; - }; - - const descriptor = { - get, - enumerable: true, - configurable: true - }; - - catchError(() => Reflect.deleteProperty(instance, propertyKey)); - catchError(() => Object.defineProperty(instance, propertyKey, descriptor)); - } - - /** - * - * @param instance - * @param propertyKey - * @param useType - * @param options - */ - public bindInterceptor(instance: any, {propertyKey, useType, options}: InjectablePropertyOptions) { - const target = classOf(instance); - const originalMethod = instance[propertyKey]; - - instance[propertyKey] = (...args: any[]) => { - const next = (err?: Error) => { - if (!err) { - return originalMethod.apply(instance, args); - } - - throw err; - }; - - const context: InterceptorContext = { - target, - propertyKey, - args, - options, - next - }; - - const interceptor = this.get(useType)!; - - return interceptor.intercept!( - { - ...context, - options - }, - next - ); - }; - } - async lazyInvoke(token: TokenProvider) { let instance = this.getInstance(token); @@ -564,13 +406,14 @@ export class InjectorService extends Container { * Emit an event to all service. See service [lifecycle hooks](/docs/services.md#lifecycle-hooks). * @param eventName The event name to emit at all services. * @param args List of the parameters to give to each service. - * @returns {Promise} A list of promises. + * @returns A list of promises. */ - public emit(eventName: string, ...args: any[]) { + public emit(eventName: string, ...args: any[]): Promise { return this.#hooks.asyncEmit(eventName, args); } /** + * Alter value attached to an event. * @param eventName * @param value * @param args @@ -580,6 +423,7 @@ export class InjectorService extends Container { } /** + * Alter value attached to an event asynchronously. * @param eventName * @param value * @param args @@ -588,17 +432,21 @@ export class InjectorService extends Container { return this.#hooks.asyncAlter(eventName, value, args); } + /** + * Destroy the injector and all services. + */ async destroy() { await this.emit("$onDestroy"); globalInjector = undefined; } + /** + * Ensure that a provider is added to the container. + * @protected + */ protected ensureProvider(token: TokenProvider, force: true): Provider; - protected ensureProvider(token: TokenProvider, force: false): Provider | undefined; - protected ensureProvider(token: TokenProvider): Provider | undefined; - protected ensureProvider(token: TokenProvider, force = false): Provider | undefined { if (!this.hasProvider(token) && (GlobalProviders.has(token) || force)) { this.addProvider(token); @@ -654,17 +502,13 @@ export class InjectorService extends Container { (token: TokenProvider | [TokenProvider], index: number): any => { currentDependency = {token, index, deps}; - if (token !== DI_PARAM_OPTIONS) { - const options = provider?.store?.get(`${DI_PARAM_OPTIONS}:${index}`); - - 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}); + const useOpts = provider?.store?.get(`${DI_USE_PARAM_OPTIONS}:${index}`) || options.useOpts; + + return isInheritedFrom(token, Provider, 1) ? provider : this.invoke(token, locals, {parent, useOpts}); }; // Invoke manually imported providers @@ -688,7 +532,10 @@ export class InjectorService extends Container { } if (instance && isClass(classOf(instance))) { - this.bindInjectableProperties(instance, locals, options); + Reflect.defineProperty(instance, DI_INVOKE_OPTIONS, { + get: () => ({rebuild: options.rebuild, locals}) + }); + // TODO add a way to notify DI consumer when a class instance is build } return instance; @@ -696,9 +543,9 @@ export class InjectorService extends Container { private resolveImportsProviders() { this.settings.imports = this.settings.imports - ?.filter((meta) => meta.token !== InjectorService) + ?.filter((meta) => isObject(meta) && "token" in meta && meta.token !== InjectorService) .map((meta) => { - if ("token" in meta) { + if (isObject(meta) && "token" in meta) { const {token, ...props} = meta; const provider = this.ensureProvider(token, true); @@ -714,12 +561,12 @@ export class InjectorService extends Container { } if ("useFactory" in props) { - provider.useFactory = props.useFactory; + provider.useFactory = props.useFactory as never; return; } if ("useAsyncFactory" in props) { - provider.useAsyncFactory = props.useAsyncFactory; + provider.useAsyncFactory = props.useAsyncFactory as never; return; } @@ -732,7 +579,7 @@ export class InjectorService extends Container { return meta; }) - .filter(Boolean); + .filter(Boolean) as unknown as (TokenProvider | ImportTokenProviderOpts)[]; } /** @@ -746,13 +593,13 @@ export class InjectorService extends Container { locals: Map, options: Partial ): ResolvedInvokeOptions | false { - let imports: TokenProvider[] | undefined = options.imports; + let imports: (TokenProvider | [TokenProvider])[] | undefined = options.imports; let deps: TokenProvider[] | undefined = options.deps; let scope = options.scope; let construct; if (!token || token === Object) { - throw new UndefinedTokenError(); + throw new Error("Given token is undefined. Could mean a circular dependency problem. Try to use @Inject(() => Token) to solve it."); } let provider: Provider; @@ -761,7 +608,7 @@ export class InjectorService extends Container { provider = new Provider(token); this.resolvers.forEach((resolver) => { - const result = resolver.get(token, locals.get(DI_PARAM_OPTIONS)); + const result = resolver.get(token, locals.get(DI_USE_PARAM_OPTIONS)); if (result !== undefined) { provider.useFactory = () => result; diff --git a/packages/di/src/node/decorators/injectContext.spec.ts b/packages/di/src/node/decorators/injectContext.spec.ts new file mode 100644 index 00000000000..ae32c237e12 --- /dev/null +++ b/packages/di/src/node/decorators/injectContext.spec.ts @@ -0,0 +1,36 @@ +import {afterEach, beforeEach, expect} from "vitest"; +import {Injectable} from "../../common/index.js"; +import {DIContext} from "../domain/DIContext.js"; +import {DITest} from "../services/DITest.js"; +import {runInContext} from "../utils/asyncHookContext.js"; +import {InjectContext} from "./injectContext.js"; + +describe("InjectContext", () => { + beforeEach(() => DITest.create()); + afterEach(() => DITest.reset()); + + it("should inject a context", async () => { + @Injectable() + class MyService { + @InjectContext() + ctx: DIContext; + } + + const $ctx = new DIContext({ + id: "test", + logger: DITest.injector.logger, + injector: DITest.injector, + maxStackSize: 0 + }); + + const myService = await DITest.invoke(MyService); + + await runInContext($ctx, () => { + expect(myService.ctx).toBeInstanceOf(DIContext); + expect(myService.ctx).toEqual($ctx); + }); + + expect(myService.ctx).toBeInstanceOf(DIContext); + expect(myService.ctx).not.toEqual($ctx); + }); +}); diff --git a/packages/di/src/node/decorators/injectContext.ts b/packages/di/src/node/decorators/injectContext.ts index 9bb7c6849d1..c8a02a8f161 100644 --- a/packages/di/src/node/decorators/injectContext.ts +++ b/packages/di/src/node/decorators/injectContext.ts @@ -1,4 +1,6 @@ -import {injectProperty} from "../../common/index.js"; +import {catchError} from "@tsed/core"; +import {InjectorService} from "../../common/index.js"; +import {DIContext} from "../domain/DIContext.js"; import {getContext} from "../utils/asyncHookContext.js"; /** @@ -17,9 +19,18 @@ import {getContext} from "../utils/asyncHookContext.js"; */ export function InjectContext(): PropertyDecorator { return (target: any, propertyKey: string): any | void => { - injectProperty(target, propertyKey, { - resolver() { - return () => getContext(); + catchError(() => Reflect.deleteProperty(target, propertyKey)); + Reflect.defineProperty(target, propertyKey, { + get() { + return ( + getContext() || + new DIContext({ + id: "", + logger: InjectorService.getInstance().logger, + injector: InjectorService.getInstance(), + maxStackSize: 0 + }) + ); } }); }; diff --git a/packages/di/src/node/services/DILogger.spec.ts b/packages/di/src/node/services/DILogger.spec.ts index 7504fdcecff..a847cbe7eb7 100644 --- a/packages/di/src/node/services/DILogger.spec.ts +++ b/packages/di/src/node/services/DILogger.spec.ts @@ -15,7 +15,7 @@ describe("DILogger", () => { container.add(MyService); await injector.load(container); - const logger = injector.get(MyService).logger; + const logger = injector.get(MyService)!.logger; expect(logger).toEqual(injector.logger); }); diff --git a/packages/di/src/node/services/DITest.ts b/packages/di/src/node/services/DITest.ts index 45fdc4a9d16..144ed1fc9e2 100644 --- a/packages/di/src/node/services/DITest.ts +++ b/packages/di/src/node/services/DITest.ts @@ -1,10 +1,10 @@ -import {Env, getValue, isClass, isPromise, setValue} from "@tsed/core"; +import {Env, getValue, isClass, isObject, isPromise, setValue} from "@tsed/core"; import {$log} from "@tsed/logger"; import { createContainer, + DI_INJECTABLE_PROPS, InjectorService, - LocalsContainer, - OnInit, + type OnInit, TokenProvider, type UseImportTokenProviderOpts } from "../../common/index.js"; @@ -82,6 +82,7 @@ export class DITest { static async reset() { if (DITest.hasInjector()) { await DITest.injector.destroy(); + InjectorService.unsetLocals(); DITest._injector = null; } } @@ -91,17 +92,18 @@ export class DITest { * @param target * @param providers */ - static async invoke(target: TokenProvider, providers: UseImportTokenProviderOpts[] = []): Promise { - const locals = new LocalsContainer(); + static async invoke(target: TokenProvider, providers: UseImportTokenProviderOpts[] = []): Promise { + const locals = InjectorService.getLocals(); + providers.forEach((p) => { locals.set(p.token, p.use); }); locals.set(InjectorService, DITest.injector); - const instance: OnInit = DITest.injector.invoke(target, locals, {rebuild: true}); + const instance: T & OnInit = DITest.injector.invoke(target, locals, {rebuild: true}); - if (instance && instance.$onInit) { + if (isObject(instance) && "$onInit" in instance) { const result = instance.$onInit(); if (result instanceof Promise) { @@ -114,9 +116,15 @@ export class DITest { } if (isClass(instance)) { - await Promise.all(Object.values(instance).filter(isPromise)); + const keys = (instance as any)[DI_INJECTABLE_PROPS]; + + if (keys) { + await Promise.all([...keys.keys()].map((key: string) => (instance as any)[key])); + } } + InjectorService.unsetLocals(); + return instance as any; } diff --git a/packages/di/vitest.config.mts b/packages/di/vitest.config.mts index 5da402fb625..c5a894a9cb6 100644 --- a/packages/di/vitest.config.mts +++ b/packages/di/vitest.config.mts @@ -10,12 +10,12 @@ export default defineConfig( coverage: { ...presets.test.coverage, thresholds: { - statements: 98.92, - branches: 97.36, - functions: 99.08, - lines: 98.92 + statements: 98.75, + branches: 97.2, + functions: 98.67, + lines: 98.75 } } } } -); \ No newline at end of file +); diff --git a/yarn.lock b/yarn.lock index 5ad0f6e7496..69054491486 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6568,6 +6568,7 @@ __metadata: eslint: "npm:^8.57.0" tslib: "npm:2.6.2" typescript: "npm:4.9.5" + uuid: "npm:9.0.1" vitest: "npm:2.0.4" webpack: "npm:^5.75.0" peerDependencies: