Skip to content

Commit

Permalink
v1.1.0 - switched to asyncLocalStorage solution
Browse files Browse the repository at this point in the history
  • Loading branch information
Helveg committed Jan 16, 2025
1 parent 1a3da2a commit 095b82c
Show file tree
Hide file tree
Showing 9 changed files with 2,273 additions and 165 deletions.
4 changes: 2 additions & 2 deletions lib/decorators/inject-transform.decorator.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { InjectTransformOptions } from "../interfaces/index.mjs";
import { InjectTransformFn } from "../interfaces/inject-transform-fn.interface.mjs";
import { InjectTransformer } from "../interfaces/inject-transformer.interface.mjs";
import { Type } from "@nestjs/common";
import { isClass } from "../util/is-class.js";
import { isClass } from "../util/is-class.mjs";

export function InjectTransform(
transformer: InjectTransformFn,
Expand All @@ -20,7 +20,7 @@ export function InjectTransform(
options: InjectTransformOptions = {}
) {
return Transform((params) => {
const injector = InjectTransformModule.getInjectTransformContainer(options);
const injector = InjectTransformModule.getInjectTransformContainer();
const providers = options.inject ?? [];

// Unify transformFn <-> transformer
Expand Down
19 changes: 0 additions & 19 deletions lib/decorators/inject-transform.decorator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,6 @@ class TestSubject {
@InjectTransform(TestTransformer)
xx: number;

@InjectTransform(
(params, tokenValue: number, service: TestService) =>
service.transform(params.value, tokenValue),
{
inject: [TEST_TOKEN, TestService],
ignoreInjectLifecycle: true,
}
)
y: number;

@InjectTransform((params) => 0, { inject: [MISSING_TOKEN] })
z: number;
}
Expand Down Expand Up @@ -94,15 +84,6 @@ describe("InjectTransform", () => {
);
});

it("should optionally ignore lifecycle errors", async () => {
const app = await NestFactory.createApplicationContext(TestAppModule);
await app.init();
await app.close();

// TestSubject.y is marked with ignoreLifecycleErrors
expect(plainToInstance(TestSubject, { y: 3 }).y).toEqual(15);
});

it("should error on missing providers", async () => {
const app = await NestFactory.createApplicationContext(TestAppModule);
await app.init();
Expand Down
6 changes: 2 additions & 4 deletions lib/decorators/inject-type.decorator.mts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import "reflect-metadata";
import { InjectTransformModule } from "../inject-transform.module.mjs";
import { Type } from "class-transformer";
import { InjectTypeOptions } from "../interfaces/inject-type-options.interface.mjs";
import { TypeInjector } from "../interfaces/type-injector.interface.mjs";
import { InjectTypeOptions, TypeInjector } from "../interfaces/index.mjs";
import { Type as NestType } from "@nestjs/common";

export function InjectType(
Expand All @@ -11,8 +10,7 @@ export function InjectType(
): PropertyDecorator {
return (target, propertyKey) =>
Type((type?) => {
const injector =
InjectTransformModule.getInjectTransformContainer(options);
const injector = InjectTransformModule.getInjectTransformContainer();

return injector.get<TypeInjector>(typeInjector).inject(type);
}, options)(target, propertyKey);
Expand Down
51 changes: 20 additions & 31 deletions lib/inject-transform.module.mts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@ import {
Inject,
InjectionToken,
Module,
OnModuleDestroy,
OnModuleInit,
Optional,
} from "@nestjs/common";
import { ModuleRef } from "@nestjs/core";
import { AppStateService } from "./app-state.service.mjs";
import { InjectTransformContainerOptions } from "./interfaces/index.mjs";
import { InjectTransformModuleOptions } from "./interfaces/index.mjs";
import { InjectLifecycleError } from "./exceptions.mjs";
import { InjectTransformModuleOptions } from "./interfaces/inject-transform-module-options.interface.mjs";
import { INJECT_TRANSFORM_MODULE_OPTIONS } from "./symbols.mjs";
import { AsyncLocalStorage } from "node:async_hooks";

@Module({ providers: [AppStateService] })
export class InjectTransformModule {
private static injectTransformModule: InjectTransformModule;
export class InjectTransformModule implements OnModuleInit, OnModuleDestroy {
private static storage = new AsyncLocalStorage<ModuleRef>();

public static forRoot(
options: InjectTransformModuleOptions = {}
Expand All @@ -27,46 +29,33 @@ export class InjectTransformModule {
};
}

public static getInjectTransformContainer(
options?: InjectTransformContainerOptions
): {
public static getInjectTransformContainer(): {
get: ModuleRef["get"];
} {
// Retrieve reference to currently running global module
const { moduleRef, moduleOptions } =
InjectTransformModule.injectTransformModule ??
({} as InjectTransformModule);
options = { ...moduleOptions, ...options };

const appRunning = moduleRef?.get(AppStateService).isRunning ?? false;
if (!appRunning && !options.ignoreInjectLifecycle) {
const moduleRef = InjectTransformModule.storage.getStore();
if (!moduleRef)
throw new InjectLifecycleError(
"Dependency injection in class-transformer unavailable outside of module context." +
(!InjectTransformModule.injectTransformModule
? " Did you forget to import the InjectTransformModule?"
: "")
`Dependency injection in class-transformer only available during app lifecycle.` +
` Did you forget to import the InjectTransformModule?`
);
}

return {
get: (token: InjectionToken) => moduleRef?.get(token, { strict: false }),
get: (token: InjectionToken) => moduleRef.get(token, { strict: false }),
};
}

public static getInjectTransformModule() {
return this.injectTransformModule;
}

public static clearInjectTransformModule() {
this.injectTransformModule = undefined;
}

constructor(
private readonly moduleRef: ModuleRef,
@Inject(INJECT_TRANSFORM_MODULE_OPTIONS)
@Optional()
private readonly moduleOptions: InjectTransformModuleOptions
) {
InjectTransformModule.injectTransformModule = this;
) {}

onModuleInit() {
InjectTransformModule.storage.enterWith(this.moduleRef);
}

onModuleDestroy() {
InjectTransformModule.storage.disable(); // Clean up the storage
}
}
87 changes: 34 additions & 53 deletions lib/inject-transform.module.spec.ts
Original file line number Diff line number Diff line change
@@ -1,74 +1,55 @@
import { InjectTransformModule } from "./inject-transform.module.mjs";
import { Module } from "@nestjs/common";
import { Body, Controller, Module, Post } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { InjectTransform } from "./decorators/index.mjs";
import { plainToInstance } from "class-transformer";
import { InjectLifecycleError } from "./exceptions.mjs";
import { default as supertest } from "supertest";

@Module({ imports: [InjectTransformModule] })
class TestAppModuleWithImport {}
const x = Symbol("eh");

class TestSubject {
@InjectTransform((params, tokenValue: number) => params.value + tokenValue, {
inject: [x],
})
x: number;
}

@Module({ imports: [] })
class TestAppModuleWithoutImport {}
@Controller()
class TestController {
@Post("/")
returnInject(@Body() subject: TestSubject) {
return plainToInstance(TestSubject, subject);
}
}

@Module({
imports: [InjectTransformModule.forRoot({ ignoreInjectLifecycle: true })],
imports: [InjectTransformModule],
controllers: [TestController],
providers: [{ provide: x, useValue: 10 }],
})
class TestAppModuleWithOptions {}
class TestAppModuleWithImport {}

describe("InjectTransformModule", () => {
beforeEach(() => InjectTransformModule.clearInjectTransformModule());

it("should set the global module when injected", async () => {
await NestFactory.createApplicationContext(TestAppModuleWithImport);
expect(
InjectTransformModule.getInjectTransformModule()
).not.toBeUndefined();
});

it("should not have a global module when not injected", async () => {
await NestFactory.createApplicationContext(TestAppModuleWithoutImport);
expect(InjectTransformModule.getInjectTransformModule()).toBeUndefined();
});

it("should hint at import when using transform container without global module", () => {
it("should hint at import when no context is found", () => {
expect(() => InjectTransformModule.getInjectTransformContainer()).toThrow(
"Did you forget to import the InjectTransformModule?"
);
});

describe("Module options", () => {
it("should consider module options", async () => {
const app = await NestFactory.createApplicationContext(
TestAppModuleWithOptions
);
await app.close();
it("should inject during controller requests", async () => {
const app = await NestFactory.create(TestAppModuleWithImport);
await app.init();

// Accessing the inject transform container when the app is closed should throw an error,
// unless it has taken the `ignoreLifecycleErrors` module option into account, which this
// test wants to assert.
expect(
InjectTransformModule.getInjectTransformContainer()
).not.toBeUndefined();
});
await supertest(app.getHttpServer())
.post("/")
.send({ x: 5 })
.expect(201)
.expect({ x: 15 });

it("should override module options with decorator options", async () => {
const app = await NestFactory.createApplicationContext(
TestAppModuleWithOptions
);
await app.close();

class Test {
@InjectTransform(() => 5, { ignoreInjectLifecycle: false })
x: number;
}
await app.close();
});

// Accessing the inject transform container when the app is closed should throw an error,
// unless it has taken the `ignoreLifecycleErrors` module option into account, which this
// test wants to assert to be overridden by the decorator options.
expect(() => plainToInstance(Test, { x: 3 })).toThrow(
InjectLifecycleError
);
});
it("should error without an active application", async () => {
expect(() => plainToInstance(TestSubject, { x: 5 })).toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
export interface InjectTransformContainerOptions {
ignoreInjectLifecycle?: boolean;
}
export interface InjectTransformContainerOptions {}
File renamed without changes.
Loading

0 comments on commit 095b82c

Please sign in to comment.