Skip to content

Commit

Permalink
feat(di): add AutoInjectable decorator
Browse files Browse the repository at this point in the history
  • Loading branch information
Romakita committed Aug 31, 2024
1 parent 12450a2 commit ed65e02
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 20 deletions.
43 changes: 40 additions & 3 deletions docs/docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Using @@Opts@@ decorator on a constructor parameter changes the scope of the pro
to `ProviderScope.INSTANCE`.
:::

## Inject many provider <Badge text="6.129.0+"/>
## Inject many provider

This feature simplifies dependency management when working with multiple implementations of the same interface using type code.

Expand Down Expand Up @@ -196,7 +196,7 @@ export class SomeController {
}
```

## Override an injection token <Badge text="6.93.0+"/>
## Override an injection token

By default, the `@Injectable()` decorator registers a class provider using an injection token obtained from the metadata generated by TypeScript.
That means that you have to use a concrete class as a token to resolve a provider.
Expand Down Expand Up @@ -249,7 +249,44 @@ class ProdTimeslotsRepository implements TimeslotsRepository {
export class Server {}
```

## Lazy load provider <Badge text="6.81.0+"/>
## AutoInjectable <Badge text="7.82.0+" />

AutoInjectable decorator factory that replaces the decorated class' constructor with a parameterless constructor that has dependencies auto-resolved.

```ts
import {AutoInjectable, Inject} from "@tsed/di";

@AutoInjectable()
class Foo {
constructor(
private options: {collection: string},
@Inject(Database) readonly database?: Database
) {
console.log(this.options);
}
}

// In other service
@Injectable()
class Bar {
doSomething() {
const foo = new Foo({collection: "test"});

foo.database?.connect();
}
}
```

::: tip
Notice how in order to allow the use of the empty constructor `new Foo()`, we need to make the parameters optional, e.g. `database?: Database`.
:::

::: warning
An AutoInjectable class cannot be created outside the DI context. You muse use the class inside an injectable class.
Also, AutoInjectable doesn't add the class to the container registry. So the class cannot be injected using `@Inject` or through the constructor arguments.
:::

## Lazy load provider

By default, modules are eagerly loaded, which means that as soon as the application loads, so do all the modules,
whether or not they are immediately necessary. While this is fine for most applications,
Expand Down
137 changes: 137 additions & 0 deletions packages/di/src/common/decorators/autoInjectable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {catchError} from "@tsed/core";
import {Logger} from "@tsed/logger";
import {beforeEach} from "vitest";
import {DITest} from "../../node/index.js";
import {registerProvider} from "../registries/ProviderRegistry.js";
import {InjectorService} from "../services/InjectorService.js";
import {AutoInjectable} from "./autoInjectable.js";
import {Inject} from "./inject.js";
import {Injectable} from "./injectable.js";

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"
});
}
});

describe("AutoInjectable", () => {
describe("when the instance is created during an injection context", () => {
beforeEach(() => DITest.create());
afterEach(() => DITest.reset());
it("should return a class that extends the original class", () => {
@AutoInjectable()
class Test {
@Inject(Logger)
logger: Logger;

foo() {
this.logger.info("test");
}
}

const test = new Test();
const test2 = new Test();

expect(test).toBeInstanceOf(Test);
expect(test2).toBeInstanceOf(Test);
expect(test).not.toBe(test2);

vi.spyOn(test.logger, "info").mockResolvedValue(undefined as never);

test.foo();

expect(test.logger.info).toHaveBeenCalledWith("test");
});
it("should return a class that extends the original class (with additional args)", () => {
@AutoInjectable()
class Test {
@Inject(Logger)
logger: Logger;

private value: string;

constructor(initialValue: string, @Inject(InjectorService) injector?: InjectorService) {
this.value = initialValue;
expect(injector).toBeInstanceOf(InjectorService);
}

foo() {
this.logger.info("test_" + this.value);
}
}

const test = new Test("test");

vi.spyOn(test.logger, "info").mockResolvedValue(undefined as never);

test.foo();

expect(test.logger.info).toHaveBeenCalledWith("test_test");
});
it("should return a class that extends the original class (with inject many)", () => {
@AutoInjectable()
class Test {
@Inject(Logger)
logger: Logger;

private value: string;

constructor(initialValue: string, @Inject(TOKEN_GROUPS) instances?: InterfaceGroup[]) {
this.value = initialValue;
expect(instances).toHaveLength(3);
}
}

new Test("test");
});
});
describe("when the instance is created outside of an injection context", () => {
it("should throw an error", () => {
@AutoInjectable()
class Test {
@Inject(Logger)
logger: Logger;

foo() {
this.logger.info("test");
}
}

const error = catchError(() => new Test());

expect(error).toBeInstanceOf(Error);
expect(error?.message).toEqual("InjectorService instance is not created yet.");
});
});
});
14 changes: 14 additions & 0 deletions packages/di/src/common/decorators/autoInjectable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {LocalsContainer} from "../domain/LocalsContainer.js";
import {InjectorService} from "../services/InjectorService.js";

export function AutoInjectable() {
return <T extends {new (...args: any[]): NonNullable<unknown>}>(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);
}
} as unknown as T;
};
}
1 change: 1 addition & 0 deletions packages/di/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @file Automatically generated by @tsed/barrels.
*/
export * from "./constants/constants.js";
export * from "./decorators/autoInjectable.js";
export * from "./decorators/configuration.js";
export * from "./decorators/constant.js";
export * from "./decorators/controller.js";
Expand Down
22 changes: 18 additions & 4 deletions packages/di/src/common/interfaces/InvokeOptions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import {ProviderScope} from "../domain/ProviderScope.js";
import {TokenProvider} from "./TokenProvider.js";

export interface InvokeOptions<T = any> {
deps: any[];
imports: any[];
export interface InvokeOptions {
/**
* Define dependencies to build the provider and inject them in the constructor.
*/
deps: unknown[];
/**
* List of imports to be created before the provider. Imports list aren't injected directly in the provider constructor.
*/
imports: unknown[];
/**
* Parent provider.
*/
parent?: TokenProvider;
/**
* Scope used by the injector to build the provider.
*/
scope: ProviderScope;
useScope: boolean;
/**
* If true, the injector will rebuild the instance.
*/
rebuild?: boolean;
}
Loading

0 comments on commit ed65e02

Please sign in to comment.