Skip to content

Commit

Permalink
Merge pull request #9697 from nestjs/feat/durable-providers
Browse files Browse the repository at this point in the history
feat(core): add durable providers feature
  • Loading branch information
kamilmysliwiec committed May 31, 2022
2 parents 5de7913 + ac5df55 commit 6cb1fba
Show file tree
Hide file tree
Showing 18 changed files with 482 additions and 51 deletions.
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;
}
}

0 comments on commit 6cb1fba

Please sign in to comment.