Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(core): add durable providers feature #9697

Merged
merged 2 commits into from
May 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
76 changes: 76 additions & 0 deletions integration/scopes/e2e/durable-providers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { INestApplication } from '@nestjs/common';
import { ContextIdFactory } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import { expect } from 'chai';
import * as request from 'supertest';
import { DurableContextIdStrategy } from '../src/durable/durable-context-id.strategy';
import { DurableModule } from '../src/durable/durable.module';

describe('Durable providers', () => {
let server: any;
let app: INestApplication;

before(async () => {
const moduleRef = await Test.createTestingModule({
imports: [DurableModule],
}).compile();

app = moduleRef.createNestApplication();
server = app.getHttpServer();
await app.init();

ContextIdFactory.apply(new DurableContextIdStrategy());
});

describe('when service is durable', () => {
const performHttpCall = (tenantId: number, end: (err?: any) => void) =>
request(server)
.get('/durable')
.set({ ['x-tenant-id']: tenantId })
.end((err, res) => {
if (err) return end(err);
end(res);
});

it(`should share durable providers per tenant`, async () => {
let result: request.Response;
result = await new Promise<request.Response>(resolve =>
performHttpCall(1, resolve),
);
expect(result.text).equal('Hello world! Counter: 1');

result = await new Promise<request.Response>(resolve =>
performHttpCall(1, resolve),
);
expect(result.text).equal('Hello world! Counter: 2');

result = await new Promise<request.Response>(resolve =>
performHttpCall(1, resolve),
);
expect(result.text).equal('Hello world! Counter: 3');
});

it(`should create per-tenant DI sub-tree`, async () => {
let result: request.Response;
result = await new Promise<request.Response>(resolve =>
performHttpCall(4, resolve),
);
expect(result.text).equal('Hello world! Counter: 1');

result = await new Promise<request.Response>(resolve =>
performHttpCall(5, resolve),
);
expect(result.text).equal('Hello world! Counter: 1');

result = await new Promise<request.Response>(resolve =>
performHttpCall(6, resolve),
);
expect(result.text).equal('Hello world! Counter: 1');
});
});

after(async () => {
ContextIdFactory['strategy'] = undefined;
await app.close();
});
});
21 changes: 21 additions & 0 deletions integration/scopes/src/durable/durable-context-id.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ChildContextIdInfo, ContextId, ContextIdStrategy } from '@nestjs/core';
import { Request } from 'express';

const tenants = new Map<string, ContextId>();

export class DurableContextIdStrategy implements ContextIdStrategy {
attach(contextId: ContextId, request: Request) {
const tenantId = request.headers['x-tenant-id'] as string;
let tenantSubTreeId: ContextId;

if (tenants.has(tenantId)) {
tenantSubTreeId = tenants.get(tenantId);
} else {
tenantSubTreeId = { id: +tenantId } as ContextId;
tenants.set(tenantId, tenantSubTreeId);
}

return (info: ChildContextIdInfo) =>
info.isTreeDurable ? tenantSubTreeId : contextId;
}
}
12 changes: 12 additions & 0 deletions integration/scopes/src/durable/durable.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { DurableService } from './durable.service';

@Controller('durable')
export class DurableController {
constructor(private readonly durableService: DurableService) {}

@Get()
greeting(): string {
return this.durableService.greeting();
}
}
9 changes: 9 additions & 0 deletions integration/scopes/src/durable/durable.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DurableController } from './durable.controller';
import { DurableService } from './durable.service';

@Module({
controllers: [DurableController],
providers: [DurableService],
})
export class DurableModule {}
11 changes: 11 additions & 0 deletions integration/scopes/src/durable/durable.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST, durable: true })
export class DurableService {
public instanceCounter = 0;

greeting() {
++this.instanceCounter;
return `Hello world! Counter: ${this.instanceCounter}`;
}
}
2 changes: 1 addition & 1 deletion packages/common/decorators/core/controller.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function Controller(
: [
prefixOrOptions.path || defaultPath,
prefixOrOptions.host,
{ scope: prefixOrOptions.scope },
{ scope: prefixOrOptions.scope, durable: prefixOrOptions.durable },
Array.isArray(prefixOrOptions.version)
? Array.from(new Set(prefixOrOptions.version))
: prefixOrOptions.version,
Expand Down
14 changes: 14 additions & 0 deletions packages/common/interfaces/modules/provider.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export interface ClassProvider<T = any> {
* @see [Use factory](https://docs.nestjs.com/fundamentals/custom-providers#factory-providers-usefactory)
*/
inject?: never;
/**
* Flags provider as durable. This flag can be used in combination with custom context id
* factory strategy to construct lazy DI subtrees.
*
* This flag can be used only in conjunction with scope = Scope.REQUEST.
*/
durable?: boolean;
}

/**
Expand Down Expand Up @@ -123,6 +130,13 @@ export interface FactoryProvider<T = any> {
* Optional enum defining lifetime of the provider that is returned by the Factory function.
*/
scope?: Scope;
/**
* Flags provider as durable. This flag can be used in combination with custom context id
* factory strategy to construct lazy DI subtrees.
*
* This flag can be used only in conjunction with scope = Scope.REQUEST.
*/
durable?: boolean;
}

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/common/interfaces/scope-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,11 @@ export interface ScopeOptions {
* Specifies the lifetime of an injected Provider or Controller.
*/
scope?: Scope;
/**
* Flags provider as durable. This flag can be used in combination with custom context id
* factory strategy to construct lazy DI subtrees.
*
* This flag can be used only in conjunction with scope = Scope.REQUEST.
*/
durable?: boolean;
}
42 changes: 37 additions & 5 deletions packages/core/helpers/context-id-factory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ContextId } from '../injector/instance-wrapper';
import { ChildContextIdInfo, ContextId } from '../injector/instance-wrapper';
import { REQUEST_CONTEXT_ID } from '../router/request/request-constants';

export function createContextId(): ContextId {
Expand All @@ -13,7 +13,22 @@ export function createContextId(): ContextId {
return { id: Math.random() };
}

export interface ContextIdStrategy<T = any> {
/**
* Allows to attach a parent context id to the existing child context id.
* This lets you construct durable DI sub-trees that can be shared between contexts.
* @param contextId auto-generated child context id
* @param request request object
*/
attach(
contextId: ContextId,
request: T,
): ((info: ChildContextIdInfo) => ContextId) | undefined;
}

export class ContextIdFactory {
private static strategy?: ContextIdStrategy;

/**
* Generates a context identifier based on the request object.
*/
Expand All @@ -27,16 +42,33 @@ export class ContextIdFactory {
*/
public static getByRequest<T extends Record<any, any> = any>(
request: T,
propsToInspect: string[] = ['raw'],
): ContextId {
if (!request) {
return createContextId();
return ContextIdFactory.create();
}
if (request[REQUEST_CONTEXT_ID as any]) {
return request[REQUEST_CONTEXT_ID as any];
}
if (request.raw && request.raw[REQUEST_CONTEXT_ID]) {
return request.raw[REQUEST_CONTEXT_ID];
for (const key of propsToInspect) {
if (request[key]?.[REQUEST_CONTEXT_ID]) {
return request[key][REQUEST_CONTEXT_ID];
}
}
return createContextId();
if (!this.strategy) {
return ContextIdFactory.create();
}
const contextId = createContextId();
contextId.getParent = this.strategy.attach(contextId, request);
return contextId;
}

/**
* Registers a custom context id strategy that lets you attach
* a parent context id to the existing context id object.
* @param strategy strategy instance
*/
public static apply(strategy: ContextIdStrategy) {
this.strategy = strategy;
}
}
7 changes: 7 additions & 0 deletions packages/core/helpers/is-durable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { SCOPE_OPTIONS_METADATA } from '@nestjs/common/constants';
import { Type } from '@nestjs/common/interfaces/type.interface';

export function isDurable(provider: Type<unknown>): boolean | undefined {
const metadata = Reflect.getMetadata(SCOPE_OPTIONS_METADATA, provider);
return metadata && metadata.durable;
}
2 changes: 1 addition & 1 deletion packages/core/injector/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * from './container';
export * from './inquirer';
export { ContextId } from './instance-wrapper';
export { ChildContextIdInfo, ContextId } from './instance-wrapper';
export * from './lazy-module-loader';
export * from './module-ref';
export * from './modules-container';
45 changes: 35 additions & 10 deletions packages/core/injector/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ export class Injector {
inquirer?: InstanceWrapper,
) {
const inquirerId = this.getInquirerId(inquirer);
const instanceHost = wrapper.getInstanceByContextId(contextId, inquirerId);
const instanceHost = wrapper.getInstanceByContextId(
this.getContextId(contextId, wrapper),
inquirerId,
);
if (instanceHost.isPending) {
return instanceHost.donePromise;
}
Expand Down Expand Up @@ -278,7 +281,7 @@ export class Injector {
index,
);
const instanceHost = paramWrapper.getInstanceByContextId(
contextId,
this.getContextId(contextId, paramWrapper),
inquirerId,
);
if (!instanceHost.isResolved && !paramWrapper.forwardRef) {
Expand Down Expand Up @@ -434,7 +437,7 @@ export class Injector {
): Promise<InstanceWrapper> {
const inquirerId = this.getInquirerId(inquirer);
const instanceHost = instanceWrapper.getInstanceByContextId(
contextId,
this.getContextId(contextId, instanceWrapper),
inquirerId,
);
if (!instanceHost.isResolved && !instanceWrapper.forwardRef) {
Expand Down Expand Up @@ -463,7 +466,7 @@ export class Injector {
}
if (instanceWrapper.async) {
const host = instanceWrapper.getInstanceByContextId(
contextId,
this.getContextId(contextId, instanceWrapper),
inquirerId,
);
host.instance = await host.instance;
Expand Down Expand Up @@ -584,7 +587,7 @@ export class Injector {

const inquirerId = this.getInquirerId(inquirer);
const instanceHost = instanceWrapperRef.getInstanceByContextId(
contextId,
this.getContextId(contextId, instanceWrapperRef),
inquirerId,
);
if (!instanceHost.isResolved && !instanceWrapperRef.forwardRef) {
Expand Down Expand Up @@ -640,7 +643,7 @@ export class Injector {
}
const inquirerId = this.getInquirerId(inquirer);
const instanceHost = paramWrapper.getInstanceByContextId(
contextId,
this.getContextId(contextId, paramWrapper),
inquirerId,
);
return instanceHost.instance;
Expand Down Expand Up @@ -692,7 +695,7 @@ export class Injector {
const { metatype, inject } = wrapper;
const inquirerId = this.getInquirerId(inquirer);
const instanceHost = targetMetatype.getInstanceByContextId(
contextId,
this.getContextId(contextId, targetMetatype),
inquirerId,
);
const isInContext =
Expand Down Expand Up @@ -732,7 +735,10 @@ export class Injector {
await this.loadInstance(wrapper, collection, moduleRef, ctx, wrapper);
await this.loadEnhancersPerContext(wrapper, ctx, wrapper);

const host = wrapper.getInstanceByContextId(ctx, wrapper.id);
const host = wrapper.getInstanceByContextId(
this.getContextId(ctx, wrapper),
wrapper.id,
);
return host && (host.instance as T);
}

Expand Down Expand Up @@ -773,7 +779,11 @@ export class Injector {
);
const inquirerId = this.getInquirerId(inquirer);
return hosts.map(
item => item.getInstanceByContextId(contextId, inquirerId).instance,
item =>
item.getInstanceByContextId(
this.getContextId(contextId, item),
inquirerId,
).instance,
);
}

Expand All @@ -797,7 +807,10 @@ export class Injector {
return dependenciesHosts.map(({ key, host }) => ({
key,
name: key,
instance: host.getInstanceByContextId(contextId, inquirerId).instance,
instance: host.getInstanceByContextId(
this.getContextId(contextId, host),
inquirerId,
).instance,
}));
}

Expand Down Expand Up @@ -899,4 +912,16 @@ export class Injector {
private isDebugMode(): boolean {
return !!process.env.NEST_DEBUG;
}

private getContextId(
contextId: ContextId,
instanceWrapper: InstanceWrapper,
): ContextId {
return contextId.getParent
? contextId.getParent({
token: instanceWrapper.token,
isTreeDurable: instanceWrapper.isDependencyTreeDurable(),
})
: contextId;
}
}