Skip to content

Commit

Permalink
✨ adds dependency injection tracing, circular dependency detection an…
Browse files Browse the repository at this point in the history
…d middleware
  • Loading branch information
palle-k committed Nov 11, 2022
1 parent 7baf768 commit 34f99f1
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 18 deletions.
6 changes: 3 additions & 3 deletions framework/src/ComponentTreeNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class ComponentTreeNode<COMPONENT extends BaseComponent = BaseComponent>
handler = BuiltInHandler.Start,
callArgs,
}: ExecuteHandlerOptions<COMPONENT, HANDLER, ARGS>): Promise<void> {
const componentInstance = this.instantiateComponent(jovo);
const componentInstance = await this.instantiateComponent(jovo);
try {
if (!componentInstance[handler as keyof COMPONENT]) {
throw new HandlerNotFoundError(componentInstance.constructor.name, handler.toString());
Expand All @@ -88,8 +88,8 @@ export class ComponentTreeNode<COMPONENT extends BaseComponent = BaseComponent>
}
}

private instantiateComponent(jovo: Jovo): COMPONENT {
return DependencyInjector.instantiateClass(
private async instantiateComponent(jovo: Jovo): Promise<COMPONENT> {
return await DependencyInjector.instantiateClass(
jovo,
this.metadata.target as ComponentConstructor<COMPONENT>,
jovo,
Expand Down
107 changes: 94 additions & 13 deletions framework/src/DependencyInjector.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import { MetadataStorage } from './metadata/MetadataStorage';
import { Jovo } from './Jovo';
import { AnyObject, Constructor } from '@jovotech/common';
import { AnyObject, Constructor, ArrayElement } from '@jovotech/common';
import { InjectionToken, Provider } from './metadata/InjectableMetadata';
import { CircularDependencyError } from './errors/CircularDependencyError';

const INSTANTIATE_DEPENDENCY_MIDDLEWARE = 'event.DependencyInjector.instantiateDependency';

export interface DependencyTree<Node> {
token: InjectionToken;
resolvedValue: Node;
children: DependencyTree<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Node extends Constructor<any, infer Args> ? ArrayElement<Args> : unknown
>[];
}

export class DependencyInjector {
private static resolveInjectionToken<TYPE extends AnyObject>(
jovo: Jovo,
token: InjectionToken,
): TYPE | undefined {
dependencyPath: InjectionToken[],
): DependencyTree<TYPE | undefined> {
if (dependencyPath.includes(token)) {
throw new CircularDependencyError(dependencyPath);
}
const updatedPath = [...dependencyPath, token];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const providers: Provider<any>[] = [
...jovo.$app.providers,
Expand All @@ -24,30 +41,70 @@ export class DependencyInjector {
}
}) as Provider<TYPE> | undefined;
if (!injection) {
return undefined;
return {
token,
resolvedValue: undefined,
children: [],
};
}

if (typeof injection === 'function') {
return DependencyInjector.instantiateClass(jovo, injection);
return DependencyInjector.instantiateClassWithTracing(jovo, injection, updatedPath);
} else if ('useValue' in injection) {
return injection.useValue;
return {
token,
resolvedValue: injection.useValue,
children: [],
};
} else if ('useFactory' in injection) {
return injection.useFactory(jovo);
const value = injection.useFactory(jovo);
return {
token,
resolvedValue: value,
children: [],
};
} else if ('useClass' in injection) {
return DependencyInjector.instantiateClass(jovo, injection.useClass);
const tree = DependencyInjector.instantiateClassWithTracing(
jovo,
injection.useClass,
updatedPath,
);
// insert proper token
return {
...tree,
token,
};
} else if ('useExisting' in injection) {
return DependencyInjector.resolveInjectionToken(jovo, injection.useExisting);
const tree = DependencyInjector.resolveInjectionToken(
jovo,
injection.useExisting,
updatedPath,
);
return {
token,
resolvedValue: tree?.resolvedValue as TYPE,
children: tree?.children ?? [],
};
} else {
return {
token,
resolvedValue: undefined,
children: [],
};
}
}

static instantiateClass<TYPE, ARGS extends unknown[] = []>(
private static instantiateClassWithTracing<TYPE, ARGS extends unknown[] = []>(
jovo: Jovo,
clazz: Constructor<TYPE>,
dependencyPath: InjectionToken[],
...predefinedArgs: ARGS
): TYPE {
): DependencyTree<TYPE> {
const injectedArgs = [...predefinedArgs];
const storage = MetadataStorage.getInstance();
const injectMetadata = storage.getMergedInjectMetadata(clazz);
const argTypes = Reflect.getMetadata('design:paramtypes', clazz) ?? [];
const children: DependencyTree<any>[] = [];
for (let i = predefinedArgs.length; i < argTypes.length; i++) {
const injectMetadataForArg = injectMetadata.find((metadata) => metadata.index === i);
let injectionToken: InjectionToken;
Expand All @@ -60,10 +117,34 @@ export class DependencyInjector {
injectedArgs.push(undefined);
continue;
}
const injectedValue = DependencyInjector.resolveInjectionToken(jovo, injectionToken);
injectedArgs.push(injectedValue);
const childNode = DependencyInjector.resolveInjectionToken(
jovo,
injectionToken,
dependencyPath,
);
injectedArgs.push(childNode?.resolvedValue);
children.push(childNode);
}

return new clazz(...injectedArgs);
const instance = new clazz(...injectedArgs);
return {
token: clazz,
resolvedValue: instance,
children,
};
}

static async instantiateClass<TYPE, ARGS extends unknown[] = []>(
jovo: Jovo,
clazz: Constructor<TYPE>,
...predefinedArgs: ARGS
): Promise<TYPE> {
const tree = this.instantiateClassWithTracing(jovo, clazz, [], ...predefinedArgs);
await jovo.$handleRequest.middlewareCollection.run(
INSTANTIATE_DEPENDENCY_MIDDLEWARE,
jovo,
tree,
);
return tree.resolvedValue;
}
}
2 changes: 1 addition & 1 deletion framework/src/Jovo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export abstract class Jovo<
): Promise<void> {
let newOutput: OutputTemplate | OutputTemplate[];
if (typeof outputConstructorOrTemplateOrMessage === 'function') {
const outputInstance = DependencyInjector.instantiateClass(
const outputInstance = await DependencyInjector.instantiateClass(
this,
outputConstructorOrTemplateOrMessage,
this,
Expand Down
12 changes: 12 additions & 0 deletions framework/src/errors/CircularDependencyError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { InjectionToken } from '../metadata/InjectableMetadata';
import { Constructor, JovoError } from '@jovotech/common';

export class CircularDependencyError extends JovoError {
constructor(readonly dependencyPath: InjectionToken[]) {
super({
message: `Circular dependency detected: ${dependencyPath
.map((x) => String((x as Constructor).name ?? x))
.join(' -> ')}.`,
});
}
}
2 changes: 2 additions & 0 deletions framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export * from './BaseOutput';
export * from './ComponentPlugin';
export * from './ComponentTree';
export * from './ComponentTreeNode';
export * from './DependencyInjector';
export * from './Extensible';
export * from './HandleRequest';
export * from './I18Next';
Expand Down Expand Up @@ -86,6 +87,7 @@ export * from './decorators/PrioritizedOverUnhandled';
export * from './decorators/SubState';
export * from './decorators/Types';

export * from './errors/CircularDependencyError';
export * from './errors/ComponentNotFoundError';
export * from './errors/DuplicateChildComponentsError';
export * from './errors/DuplicateGlobalIntentsError';
Expand Down
113 changes: 112 additions & 1 deletion framework/test/DependencyInjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
DeepPartial,
OutputOptions,
Inject,
isSameProvide,
CircularDependencyError,
DependencyTree,
} from '../src';
import { ExamplePlatform, ExampleServer } from './utilities';

Expand Down Expand Up @@ -322,3 +323,113 @@ describe('dependency overrides', () => {
]);
});
});

describe('circular dependency detection', () => {
test('circular dependency', async () => {
interface SecondServiceInterface {}
const SecondServiceToken = Symbol('SecondService');

@Injectable()
class FirstService {
constructor(@Inject(SecondServiceToken) readonly secondService: SecondServiceInterface) {}
}

@Injectable()
class SecondService {
constructor(readonly firstService: FirstService) {}
}

@Global()
@Component()
class ComponentA extends BaseComponent {
constructor(
jovo: Jovo,
options: ComponentOptions<UnknownObject> | undefined,
readonly firstService: FirstService,
) {
super(jovo, options);
}

@Intents('IntentA')
handleIntentA() {
return this.$send('IntentA');
}
}

const app = new App({
plugins: [new ExamplePlatform()],
providers: [FirstService, { provide: SecondServiceToken, useClass: SecondService }],
components: [ComponentA],
});

const onError = jest.fn();
app.onError(onError);

await app.initialize();

const server = new ExampleServer({
input: {
type: InputType.Intent,
intent: 'IntentA',
},
});
await app.handle(server);
expect(server.response.output).toEqual([]);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0]).toBeInstanceOf(CircularDependencyError);
});
});

describe('dependency injection middleware', () => {
test('middleware arguments', async () => {
@Injectable()
class ExampleService {}

@Global()
@Component()
class ComponentA extends BaseComponent {
constructor(
jovo: Jovo,
options: ComponentOptions<UnknownObject> | undefined,
readonly exampleService: ExampleService,
) {
super(jovo, options);
}

@Intents('IntentA')
handleIntentA() {
return this.$send('IntentA');
}
}

const app = new App({
plugins: [new ExamplePlatform()],
providers: [ExampleService],
components: [ComponentA],
});

const middlewareFunction = jest.fn();
app.middlewareCollection.use(
'event.DependencyInjector.instantiateDependency',
middlewareFunction,
);
await app.initialize();

const server = new ExampleServer({
input: {
type: InputType.Intent,
intent: 'IntentA',
},
});
await app.handle(server);
expect(middlewareFunction).toHaveBeenCalledTimes(1);
const dependencyTree: DependencyTree<ComponentA> = middlewareFunction.mock.calls[0][1];

expect(dependencyTree.token).toEqual(ComponentA);
expect(dependencyTree.resolvedValue).toBeInstanceOf(ComponentA);
expect(dependencyTree.children.length).toEqual(1);
expect(dependencyTree.children[0].token).toEqual(ExampleService);
expect(dependencyTree.children[0].resolvedValue).toBeInstanceOf(ExampleService);
expect(dependencyTree.children[0].children.length).toEqual(0);
});
});

0 comments on commit 34f99f1

Please sign in to comment.