diff --git a/common/src/index.ts b/common/src/index.ts index ec1e585360..d64e4405ff 100644 --- a/common/src/index.ts +++ b/common/src/index.ts @@ -15,6 +15,9 @@ export type DeepPartial = PartialDeep; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Constructor = new (...args: ARGS) => T; +// eslint-disable-next-line @typescript-eslint/ban-types +export type Abstract = Function & { prototype: T }; + // Construct object from properties of T that extend U. export type PickWhere = Pick< T, diff --git a/docs/app-config.md b/docs/app-config.md index 26e8020ecd..17d8f3c985 100644 --- a/docs/app-config.md +++ b/docs/app-config.md @@ -20,6 +20,7 @@ The app configuration in `app.ts` is the place where you can add plugins, compon The app configuration files in the `src` folder are the main entry point of your Jovo apps. They usually include the following elements: - [Components](./components.md) can be registered - [Plugins](./plugins.md) and [Hooks](./hooks.md) can be added to extend the framework functionality +- [Service providers](./service-providers-dependency-injection.md) can be added for dependency injection - Framework configurations, like logging behavior, can be modified Here is an example [`app.ts` file](https://github.com/jovotech/jovo-v4-template/blob/master/src/app.ts): @@ -125,7 +126,7 @@ app.use( ## Configuration Elements -The configuration object that can be passed to both the constructor and the `configure()` method contains [components](#components), [plugins](#plugins), [logging](#logging), and [routing](#routing). +The configuration object that can be passed to both the constructor and the `configure()` method contains [components](#components), [plugins](#plugins), [providers](#providers), [logging](#logging), and [routing](#routing). ```typescript { @@ -135,6 +136,9 @@ The configuration object that can be passed to both the constructor and the `con plugins: [ // ... ], + providers: [ + // ... + ], logging: { // ... }, @@ -209,6 +213,39 @@ app.plugins.SomePlugin This can be helpful if you want to add additional configurations to the default plugin config outside `app.ts`. See [staging](#staging) for more information. +### Providers + +You can add service providers for [dependency injection](./services-providers-dependency-injection.md) like this: + +```typescript +import { OrderService } from './services/OrderService'; +// ... + +{ + // ... + + providers: [ + OrderService, + // ... + ], +} +``` + +It is also possible to use the `provide` option to specify a token, for which you want to inject a dependency, separately from the injected value or type. [Learn more about the different provider types here](./service-providers-dependency-injection.md#types-of-providers). + +```typescript +{ + providers: [ + { + provide: OrderService, + useClass: MockOrderService, + }, + // ... + ] +} +``` + + ### Logging [Logging](./logging.md) is enabled by adding the following to the app config: diff --git a/docs/service-providers-dependency-injection.md b/docs/service-providers-dependency-injection.md new file mode 100644 index 0000000000..85c674b91d --- /dev/null +++ b/docs/service-providers-dependency-injection.md @@ -0,0 +1,491 @@ +--- +title: 'Services, Providers & Dependency Injection' +excerpt: 'Learn how you can add custom providers to your Jovo app using dependency injection.' +url: 'https://www.jovo.tech/docs/services-providers-dependency-injection' +--- + +# Services, Providers & Dependency Injection + +Learn how you can add custom providers to your Jovo app using dependency injection. + +## Introduction + +To separate business logic from dialogue management (which is usually happening in [components](./components.md) and [output classes](./output-classes.md)), we recommend using service classes. For example, a class called `OrderService` could handle everything related to making orders and interact with an order backend or API: + +```typescript +// src/services/OrderService.ts + +class OrderService { + async performOrder() { + // ... + } +} +``` + +All services could be placed in a `services` folder. Component-specific services could also be put in a component subfolder. [Learn more about Jovo project structure](./project-structure.md). + +``` +📦your-project + ┣ 📂src + ┃ ┣ 📂components + ┃ ┣ 📂output + ┃ ┣ 📂services + ┃ ┃ ┣ 📜OrderService.ts + ┃ ┃ ┗ ... + ┃ ┣ 📜app.dev.ts + ┃ ┣ 📜app.ts + ┃ ┗ ... + ┗ ... +``` + +The service can then be instantiated in a component or an output class. Here is an example using the `OrderService` in a handler: + +```typescript +// src/components/OrderPizzaComponent.ts + +import { OrderService } from './services/OrderService'; +// ... + +@Component() +class OrderPizzaComponent extends BaseComponent { + + @Intents('ConfirmOrderIntent') + async confirmOrder() { + try { + const orderService = new OrderService(); + await orderService.performOrder(); + // ... + } catch (e) { + // ... + } + } +} +``` + +The service could also be instantiated in the `constructor()`. This is helpful if it's used across handlers. + +```typescript +import { OrderService } from './services/OrderService'; +// ... + +@Component() +class OrderPizzaComponent extends BaseComponent { + orderService: OrderService; + + constructor(jovo: Jovo, options: UnknownObject) { + super(jovo, options); + this.orderService = new OrderService(/* options could potentially be passed here */); + } + + @Intents('ConfirmOrderIntent') + async confirmOrder() { + try { + await this.orderService.performOrder(); + // ... + } catch (e) { + // ... + } + } +} +``` + +You could import and instantiate the classes wherever needed. However, this comes with a few drawbacks, depending on your use case: +- You would have to create a lot of instances of services that are used in multiple components and/or output classes. +- It makes it a bit difficult to switch providers based on the [stage](./staging.md) you're in, or to mock API calls in [unit tests](#unit-testing). +- If a service needs access to the `jovo` instance, this would need to be passed at every instantiation. + +To solve this, Jovo service providers can be passed to components and output classes using [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection). This feature is inspired by the dependency injection feature of [AngularJS](https://angular.io/guide/dependency-injection) and [NestJS](https://docs.nestjs.com/fundamentals/custom-providers). + +To make it possible to automatically instantiate a class with the dependency injection system, you need to annotate it with the [`Injectable()` decorator](https://github.com/jovotech/jovo-framework/blob/v4/latest/framework/src/decorators/Injectable.ts): + +```typescript +import { Injectable } from '@jovotech/framework'; +// ... + +@Injectable() +class OrderService { + async performOrder() { + // ... + } +} +``` + +You can then add a provider for this type to your [app configuration](./app-config.md): + +```typescript +// src/app.ts + +import { OrderService } from './services/OrderService'; +// ... + +const app = new App({ + providers: [ + OrderService, + // ... + ], +}); +``` + +You can then access your dependency by adding it to the `constructor()` of a component, an output class, or another `@Injectable()` service itself. +The dependency injection system will then instantiate the class for you and pass it to the constructor: + +```typescript +// src/components/OrderPizzaComponent.ts + +@Component() +class OrderPizzaComponent extends BaseComponent { + constructor(jovo: Jovo, options: UnknownObject, private readonly orderService: OrderService) { + super(jovo, options); + } + + @Intents('ConfirmOrderIntent') + async confirmOrder() { + try { + await this.orderService.performOrder(); + // ... + } catch (e) { + // ... + } + } +} +``` + +Learn more in the following sections: +- [Dependency Tokens](#dependency-tokens) +- [Providers](#types-of-providers) +- [Dependency Access](#dependency-access) +- [Unit Testing](#unit-testing) +- [Middlewares](#middlewares) + + +## Dependency Tokens + +The dependency injection system uses tokens to identify dependencies. A token can be a `string`, `Symbol`, or class type. + +When a component, output class or a service like `OrderService` is instantiated, constructor parameters will be populated by the dependency injection system. To determine what to inject for a parameter, the dependency injection will identify the dependency token for this parameter. + +For parameters declared with a class type or abstract class type, this token can be automatically inferred. In the following example, the dependency token for the `otherService` constructor parameter is the `OtherService` class. + +```typescript +class OrderService { + constructor(readonly otherService: OtherService) {} +} +``` + +If you want to have more control over the dependency token or if you want to inject values of non-class types, you can use the [`@Inject` decorator](https://github.com/jovotech/jovo-framework/blob/v4/latest/framework/src/decorators/Inject.ts). In the following example, the dependency token for the `apiClient` constructor parameter is `'api_client'`: + +```typescript +class OrderService { + constructor(@Inject('api_client') readonly apiClient: ApiClient) {} +} +``` + +## Providers + +Now that you have declared `OrderService` with multiple constructor parameters, you can tell the dependency injection system what value to inject for each dependency using a provider. + +A provider consists of two properties: A dependency token and information about what to inject for the token, the latter of which depends on what type of provider you are using. + +Learn more about providers that can be used with Jovo's dependency injection feature. + +- [Class Providers](#class-providers) +- [Value Providers](#value-providers) +- [Factory Providers](#factory-providers) +- [Existing Providers](#existing-providers) + +### Class Providers + +A provider can inject a class, which will be automatically instantiated by the dependency injection system. + +Such a class must be declared with the `@Injectable()` decorator: + +```typescript +@Injectable() +class SomeService { + // ... +} +``` + +In the [app configuration](./app-config.md), you can then pass the class to the `providers` array. + +```typescript +// src/app.ts + +import { SomeService } from './services/SomeService'; +// ... + +const app = new App({ + providers: [ + { + provide: SomeService, + useClass: SomeService, + }, + ], +}); +``` + +Here, `provide` is the dependency token and `useClass` is the concrete class that will be instantiated. +When `provide` and `useClass` are the same, you can also shorten the provider declaration: + +```typescript +{ + providers: [ + SomeService, + ], +} +``` + +The former notation is especially useful for [unit testing](#unit-testing), where you can inject a mock instance: + +```typescript +{ + providers: [ + { + provide: SomeService, + useClass: SomeServiceMock, + }, + ], +} +``` + +Classes instantiated by the dependency injection system are not singletons. This means that if you inject the same class in multiple places or in later requests, you will get a new instance for each injection. + +### Value Providers + +You can also use providers to inject values. For example, this can be helpful if you want to inject configuration options. + +```typescript +// src/config.ts + +export const CONFIG_TOKEN = Symbol('config'); +``` + +```typescript +// src/app.ts + +import { CONFIG_TOKEN } from './config.ts'; + +const app = new App({ + providers: [ + { + provide: CONFIG_TOKEN, + useValue: '' + } + ] +}); +``` + +You can then access the value by using the [`Inject()` decorator](https://github.com/jovotech/jovo-framework/blob/v4/latest/framework/src/decorators/Inject.ts): + +```typescript +// src/components/OrderPizzaComponent.ts + +import { CONFIG_TOKEN } from '../config.ts'; +// ... + +@Component() +class OrderPizzaComponent extends BaseComponent { + constructor( + jovo: Jovo, + options: UnknownObject | undefined, + @Inject(EXAMPLE_TOKEN) private readonly example: string + ) { + super(jovo, options); + } + // ... +} +``` + + +### Factory Providers + +Factory providers can be used to access values of the [Jovo instance](https://www.jovo.tech/docs/jovo-properties). + +Here are two examples: + +```typescript +// src/config.ts + +export const APP_CONFIG_TOKEN = Symbol('AppConfig'); +``` + +```typescript +import { App, Jovo, JovoUser } from '@jovotech/framework'; +import { APP_CONFIG_TOKEN } from './config.ts'; +// ... + +const app = new App({ + providers: [ + { + provide: APP_CONFIG_TOKEN, + useFactory: (jovo: Jovo) => jovo.$config, + }, + { + provide: JovoUser, + useFactory: (jovo: Jovo) => jovo.$user, + } + ] +}); +``` + +In a component or output class `constructor()`, you could then access them like this: + +```typescript +import { JovoUser, Inject, AppConfig } from '@jovotech/framework'; +import { APP_CONFIG_TOKEN } from '../config.ts'; +// ... + +constructor( + @Inject(APP_CONFIG_TOKEN) readonly config: AppConfig, + readonly user: JovoUser +) {} +``` + +The example above shows that you can use both Symbols and abstract classes as dependency tokens. + +Like class providers, factory providers are not cached. This means that the factory method is re-evaluated for each injection. + +### Existing Providers + +Existing providers can be used to create an alias for a dependency token. This can for example be useful in situations where you want to narrow an interface: + +```typescript +export interface OrderConfig { + // ... +} + +export interface SomeOtherConfig { + // ... +} + +export interface AppConfig extends OrderConfig, SomeOtherConfig { + // ... +} + +@Injectable() +class OrderService { + constructor( + @Inject('OrderConfig') readonly config: OrderConfig + ) {} +} + +const app = new App({ + providers: [ + OrderService, + { + provide: 'AppConfig', + useValue: loadAppConfig(), + }, + { + provide: 'OrderConfig', + useExisting: 'AppConfig', + } + ] +}); +``` + +In this case, the order service does not need to know about the `SomeOtherConfig` interface. It only needs to know about the `OrderConfig` interface and dependency token. + + +## Dependency Access + +Dependencies can be accessed using parameters of the `constructor()` in Components, Output Classes and Injectables. + +In components and output classes, parameters after the `Jovo` instance and the component `options` are resolved by the dependency injection system: + +```typescript +// src/components/OrderPizzaComponent.ts + +import { Jovo, BaseComponent } from '@jovotech/framework'; +import { UnknownObject } from '@jovotech/common'; +import { OrderService } from '../services/OrderService.ts'; +// ... + +@Component() +class OrderPizzaComponent extends BaseComponent { + constructor(jovo: Jovo, options: UnknownObject | undefined, private readonly orderService: OrderService) { + super(jovo, options); + } + + @Intents('ConfirmOrderIntent') + async confirmOrder() { + try { + await this.orderService.performOrder(); + // ... + } catch (e) { + // ... + } + } +} +``` + +```typescript +// src/output/ExampleOutput.ts + +import { BaseOutput, Output, Jovo, OutputOptions, OutputTemplate } from '@jovotech/framework'; +import { DeepPartial } from '@jovotech/common'; +import { ExampleService } from '../services/ExampleService.ts'; +// ... + +@Output() +class ExampleOutput extends BaseOutput { + constructor( + jovo: Jovo, + options: DeepPartial | undefined, + readonly exampleService: ExampleService, + ) { + super(jovo, options); + } + + build(): OutputTemplate | OutputTemplate[] { + return { + message: this.exampleService.getMessage(), + }; + } +} +``` + +In Injectables, all parameters are resolved by the dependency injection system. + +Besides all dependencies for which you defined providers, you can also access the `Jovo` instance, which is made accessible through a `systemProvider`: + +```typescript +// src/services/SomeService.ts + +import { Injectable, Jovo } from '@jovotech/framework'; +// ... + +@Injectable() +class SomeService { + constructor(readonly someOtherService: OtherService, jovo: Jovo) {} +} +``` + + +## Unit Testing + +Dependency injection makes it possible to mock services for [unit testing](./unit-testing.md). + +Below is an example how this can be done using [class providers](#class-providers): + +```typescript +const testSuite = new TestSuite(); + +testSuite.app.configure({providers: [{ + provide: OrderService, + useClass: MockOrderService, +}]}) +``` + +## Middlewares + +To understand the dependency resolution process, you can declare an `event.DependencyInjector.instantiateDependency` middleware. + +```typescript +app.middlewareCollection.use( + 'event.DependencyInjector.instantiateDependency', + (jovo: Jovo, dependencyTree: DependencyTree) => { + //... + }, +); +``` diff --git a/framework/src/App.ts b/framework/src/App.ts index 41d96902c4..d8986bd0c0 100644 --- a/framework/src/App.ts +++ b/framework/src/App.ts @@ -5,12 +5,14 @@ import { ComponentTree, I18NextConfig, IntentMap, + isSameProvide, Jovo, Logger, Middleware, MiddlewareFunction, Plugin, PossibleMiddlewareName, + Provider, } from '.'; import { ComponentConstructor, ComponentDeclaration } from './BaseComponent'; import { MatchingPlatformNotFoundError } from './errors/MatchingPlatformNotFoundError'; @@ -67,10 +69,16 @@ export interface AppConfig extends ExtensibleConfig { export type AppInitConfig = ExtensibleInitConfig & { components?: Array; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + providers?: Provider[]; }; export class App extends Extensible { readonly componentTree: ComponentTree; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly systemProviders: Provider[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly providers: Provider[] = []; readonly i18n: I18Next; private initialized = false; private errorListeners: AppErrorListener[] = []; @@ -82,7 +90,7 @@ export class App extends Extensible { cms: UnknownObject = {}; constructor(config?: AppInitConfig) { - super(config ? { ...config, components: undefined } : config); + super(config ? { ...config, components: undefined, providers: undefined } : config); if (typeof this.config.logging === 'object' && this.config.logging.logger) { _merge(Logger.config, this.config.logging.logger); @@ -95,6 +103,14 @@ export class App extends Extensible { this.componentTree = new ComponentTree(...(config?.components || [])); this.i18n = new I18Next(this.config.i18n); + + this.providers = config?.providers || []; + this.systemProviders = [ + { + provide: Jovo, + useFactory: (jovo) => jovo, + }, + ]; } get isInitialized(): boolean { @@ -109,6 +125,16 @@ export class App extends Extensible { _merge(this.config, { ...config, components: undefined, plugins: undefined }); const usables: Usable[] = [...(config?.plugins || []), ...(config?.components || [])]; this.use(...usables); + if (config.providers) { + const mergedProviders = [...config.providers]; + for (const provider of this.providers) { + if (!mergedProviders.find((p) => isSameProvide(p, provider))) { + mergedProviders.push(provider); + } + } + this.providers.length = 0; + this.providers.push(...mergedProviders); + } } onError(listener: AppErrorListener): void { diff --git a/framework/src/BaseComponent.ts b/framework/src/BaseComponent.ts index 6577cdd536..cc441ef192 100644 --- a/framework/src/BaseComponent.ts +++ b/framework/src/BaseComponent.ts @@ -12,10 +12,15 @@ export type ComponentConfig = Exclude< undefined >; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ComponentConstructor = new ( +export type ComponentConstructor< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + COMPONENT extends BaseComponent = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ARGS extends unknown[] = any[], +> = new ( jovo: Jovo, - options?: ComponentOptionsOf, + options: ComponentOptionsOf | undefined, + ...args: ARGS ) => COMPONENT; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -30,7 +35,7 @@ export abstract class BaseComponent< DATA extends ComponentData = ComponentData, CONFIG extends UnknownObject = UnknownObject, > extends JovoProxy { - constructor(jovo: Jovo, readonly options?: ComponentOptions) { + constructor(jovo: Jovo, readonly options: ComponentOptions | undefined) { super(jovo); } diff --git a/framework/src/BaseOutput.ts b/framework/src/BaseOutput.ts index 59a3eee0e5..ad41f699f8 100644 --- a/framework/src/BaseOutput.ts +++ b/framework/src/BaseOutput.ts @@ -10,14 +10,16 @@ export type OutputConstructor< REQUEST extends JovoRequest = JovoRequest, RESPONSE extends JovoResponse = JovoResponse, JOVO extends Jovo = Jovo, -> = new (jovo: JOVO, options?: DeepPartial, ...args: unknown[]) => OUTPUT; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ARGS extends unknown[] = any[], +> = new (jovo: JOVO, options: DeepPartial | undefined, ...args: ARGS) => OUTPUT; export interface OutputOptions extends OutputTemplate {} export abstract class BaseOutput extends JovoProxy { readonly options: OPTIONS; - constructor(jovo: Jovo, options?: DeepPartial) { + constructor(jovo: Jovo, options: DeepPartial | undefined) { super(jovo); const defaultOptions = this.getDefaultOptions(); this.options = options ? _merge(defaultOptions, options) : defaultOptions; diff --git a/framework/src/ComponentTreeNode.ts b/framework/src/ComponentTreeNode.ts index d10580d886..7be9fb5ef1 100644 --- a/framework/src/ComponentTreeNode.ts +++ b/framework/src/ComponentTreeNode.ts @@ -5,6 +5,7 @@ import { BuiltInHandler } from './enums'; import { HandlerNotFoundError } from './errors/HandlerNotFoundError'; import { Jovo } from './Jovo'; import { ComponentMetadata } from './metadata/ComponentMetadata'; +import { DependencyInjector } from './DependencyInjector'; import { ComponentNotAvailableError } from './errors/ComponentNotAvailableError'; export interface ComponentTreeNodeOptions { @@ -69,10 +70,7 @@ export class ComponentTreeNode handler = BuiltInHandler.Start, callArgs, }: ExecuteHandlerOptions): Promise { - const componentInstance = new (this.metadata.target as ComponentConstructor)( - jovo, - this.metadata.options, - ); + const componentInstance = await this.instantiateComponent(jovo); try { if (!componentInstance[handler as keyof COMPONENT]) { throw new HandlerNotFoundError(componentInstance.constructor.name, handler.toString()); @@ -95,6 +93,15 @@ export class ComponentTreeNode } } + private async instantiateComponent(jovo: Jovo): Promise { + return await DependencyInjector.instantiateClass( + jovo, + this.metadata.target as ComponentConstructor, + jovo, + this.metadata.options, + ); + } + toJSON(): Omit, 'parent'> & { parent?: string } { return { ...this, diff --git a/framework/src/DependencyInjector.ts b/framework/src/DependencyInjector.ts new file mode 100644 index 0000000000..acccf1211c --- /dev/null +++ b/framework/src/DependencyInjector.ts @@ -0,0 +1,149 @@ +import { MetadataStorage } from './metadata/MetadataStorage'; +import { Jovo } from './Jovo'; +import { AnyObject, ArrayElement, Constructor } from '@jovotech/common'; +import { InjectionToken, Provider } from './metadata/InjectableMetadata'; +import { CircularDependencyError } from './errors/CircularDependencyError'; +import { UnresolvableDependencyError } from './errors/UnresolvableDependencyError'; +import { InvalidDependencyError } from './errors/InvalidDependencyError'; + +const INSTANTIATE_DEPENDENCY_MIDDLEWARE = 'event.DependencyInjector.instantiateDependency'; + +export interface DependencyTree { + token: InjectionToken; + resolvedValue: Node; + children: DependencyTree< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Node extends Constructor ? ArrayElement : unknown + >[]; +} + +export class DependencyInjector { + private static resolveInjectionToken( + jovo: Jovo, + token: InjectionToken, + dependencyPath: InjectionToken[], + ): DependencyTree | undefined { + if (dependencyPath.includes(token)) { + throw new CircularDependencyError(dependencyPath); + } + const providers = [...jovo.$app.providers, ...jovo.$app.systemProviders]; + + const updatedPath = [...dependencyPath, token]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const injection = providers.find((injection) => { + if (typeof injection === 'function') { + return injection === token; + } else { + return injection.provide === token; + } + }) as Provider | undefined; + if (!injection) { + return undefined; + } + + if (typeof injection === 'function') { + return DependencyInjector.instantiateClassWithTracing(jovo, injection, updatedPath); + } else if ('useValue' in injection) { + return { + token, + resolvedValue: injection.useValue, + children: [], + }; + } else if ('useFactory' in injection) { + const value = injection.useFactory(jovo); + return { + token, + resolvedValue: value, + children: [], + }; + } else if ('useClass' in injection) { + const tree = DependencyInjector.instantiateClassWithTracing( + jovo, + injection.useClass, + updatedPath, + ); + // insert proper token + return { + ...tree, + token, + }; + } else if ('useExisting' in injection) { + const tree = DependencyInjector.resolveInjectionToken( + jovo, + injection.useExisting, + updatedPath, + ); + return { + token, + resolvedValue: tree?.resolvedValue as TYPE, + children: tree?.children ?? [], + }; + } else { + return undefined; + } + } + + private static instantiateClassWithTracing( + jovo: Jovo, + clazz: Constructor, + dependencyPath: InjectionToken[], + ...predefinedArgs: ARGS + ): DependencyTree { + const injectedArgs = [...predefinedArgs]; + const storage = MetadataStorage.getInstance(); + const injectMetadata = storage.getMergedInjectMetadata(clazz); + const argTypes = Reflect.getMetadata('design:paramtypes', clazz) ?? []; + const children: DependencyTree[] = []; + for ( + let argumentIndex = predefinedArgs.length; + argumentIndex < argTypes.length; + argumentIndex++ + ) { + const injectMetadataForArg = injectMetadata.find( + (metadata) => metadata.index === argumentIndex, + ); + let injectionToken: InjectionToken; + if (injectMetadataForArg?.token) { + injectionToken = injectMetadataForArg.token; + } else { + injectionToken = argTypes[argumentIndex]; + } + if (!injectionToken) { + // the argType will usually never be undefined. Even for interfaces or unknown, it will be the Object type. + // Only when there is a circular import, the argType will be undefined. + throw new InvalidDependencyError(clazz, argumentIndex); + } + const childNode = DependencyInjector.resolveInjectionToken( + jovo, + injectionToken, + dependencyPath, + ); + if (!childNode) { + throw new UnresolvableDependencyError(clazz, injectionToken, argumentIndex); + } + injectedArgs.push(childNode.resolvedValue); + children.push(childNode); + } + + const instance = new clazz(...injectedArgs); + return { + token: clazz, + resolvedValue: instance, + children, + }; + } + + static async instantiateClass( + jovo: Jovo, + clazz: Constructor, + ...predefinedArgs: ARGS + ): Promise { + const tree = this.instantiateClassWithTracing(jovo, clazz, [], ...predefinedArgs); + await jovo.$handleRequest.middlewareCollection.run( + INSTANTIATE_DEPENDENCY_MIDDLEWARE, + jovo, + tree, + ); + return tree.resolvedValue; + } +} diff --git a/framework/src/Jovo.ts b/framework/src/Jovo.ts index cdded9b6c5..e2734aea06 100644 --- a/framework/src/Jovo.ts +++ b/framework/src/Jovo.ts @@ -38,6 +38,7 @@ import { JovoUser } from './JovoUser'; import { Platform } from './Platform'; import { JovoRoute } from './plugins/RouterPlugin'; import { forEachDeep } from './utilities'; +import { DependencyInjector } from './DependencyInjector'; const DELEGATE_MIDDLEWARE = 'event.$delegate'; const RESOLVE_MIDDLEWARE = 'event.$resolve'; @@ -263,7 +264,12 @@ export abstract class Jovo< ): Promise { let newOutput: OutputTemplate | OutputTemplate[]; if (typeof outputConstructorOrTemplateOrMessage === 'function') { - const outputInstance = new outputConstructorOrTemplateOrMessage(this, options); + const outputInstance = await DependencyInjector.instantiateClass( + this, + outputConstructorOrTemplateOrMessage, + this, + options, + ); const outputRes = outputInstance.build(); const output = util.types.isPromise(outputRes) ? await outputRes : outputRes; // overwrite reserved properties of the built object i.e. message diff --git a/framework/src/decorators/Inject.ts b/framework/src/decorators/Inject.ts new file mode 100644 index 0000000000..c219f63aa1 --- /dev/null +++ b/framework/src/decorators/Inject.ts @@ -0,0 +1,28 @@ +import { InjectionToken } from '../metadata/InjectableMetadata'; +import { AnyObject, Constructor } from '@jovotech/common'; +import { InjectMetadata } from '../metadata/InjectMetadata'; +import { MetadataStorage } from '../metadata/MetadataStorage'; + +/** + * Decorator to specify a dependency injection token for a constructor parameter. + * + * If `@Inject()` is omitted or used without a token parameter, the type of the parameter is used as the token. + * + * @param token + * @constructor + */ +export function Inject( + token?: InjectionToken, +): (target: Constructor, propertyKey: string, index: number) => void { + return function (target, propertyKey, index) { + let resolvedToken: InjectionToken; + if (token) { + resolvedToken = token; + } else { + resolvedToken = Reflect.getMetadata('design:paramtypes', target)[index]; + } + const metadata = new InjectMetadata(target, index, resolvedToken); + const storage = MetadataStorage.getInstance(); + storage.addInjectMetadata(metadata); + }; +} diff --git a/framework/src/decorators/Injectable.ts b/framework/src/decorators/Injectable.ts new file mode 100644 index 0000000000..d80b812fb6 --- /dev/null +++ b/framework/src/decorators/Injectable.ts @@ -0,0 +1,18 @@ +import { AnyObject, Constructor } from '@jovotech/common'; +import { InjectableMetadata, InjectableOptions } from '../metadata/InjectableMetadata'; +import { MetadataStorage } from '../metadata/MetadataStorage'; + +/** + * Decorator to mark a class as injectable. + * This allows the class to be automatically constructed by the dependency injection system. + * @param options + */ +export function Injectable( + options?: InjectableOptions, +): (target: Constructor) => void { + return function (target) { + const metadata = new InjectableMetadata(target, options); + const storage = MetadataStorage.getInstance(); + storage.addInjectableMetadata(metadata); + }; +} diff --git a/framework/src/errors/CircularDependencyError.ts b/framework/src/errors/CircularDependencyError.ts new file mode 100644 index 0000000000..07ee69193f --- /dev/null +++ b/framework/src/errors/CircularDependencyError.ts @@ -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(' -> ')}.`, + }); + } +} diff --git a/framework/src/errors/InvalidDependencyError.ts b/framework/src/errors/InvalidDependencyError.ts new file mode 100644 index 0000000000..d556965304 --- /dev/null +++ b/framework/src/errors/InvalidDependencyError.ts @@ -0,0 +1,9 @@ +import { JovoError, Constructor } from '@jovotech/common'; + +export class InvalidDependencyError extends JovoError { + constructor(readonly instantiatedType: Constructor, readonly argumentIndex: number) { + super( + `Cannot resolve dependency token of the argument of ${instantiatedType.name} at index ${argumentIndex}. Please ensure that this dependency does not directly or indirectly import the file containing ${instantiatedType.name}.`, + ); + } +} diff --git a/framework/src/errors/UnresolvableDependencyError.ts b/framework/src/errors/UnresolvableDependencyError.ts new file mode 100644 index 0000000000..660dc11a37 --- /dev/null +++ b/framework/src/errors/UnresolvableDependencyError.ts @@ -0,0 +1,16 @@ +import { JovoError, Constructor } from '@jovotech/common'; +import { InjectionToken } from '..'; + +export class UnresolvableDependencyError extends JovoError { + constructor( + readonly instantiatedType: Constructor, + readonly token: InjectionToken | undefined, + readonly argumentIndex: number, + ) { + super( + `Cannot resolve dependency of the argument of ${instantiatedType.name} with token ${String( + (token as Constructor).name ?? token, + )} at index ${argumentIndex}. Please ensure that a provider for this token is registered in the app.`, + ); + } +} diff --git a/framework/src/index.ts b/framework/src/index.ts index d1d320b6a0..bde6af054b 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -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'; @@ -77,6 +78,8 @@ export * from './decorators/Component'; export * from './decorators/Global'; export * from './decorators/Handle'; export * from './decorators/If'; +export * from './decorators/Inject'; +export * from './decorators/Injectable'; export * from './decorators/Intents'; export * from './decorators/Output'; export * from './decorators/Platforms'; @@ -84,21 +87,26 @@ export * from './decorators/PrioritizedOverUnhandled'; export * from './decorators/SubState'; export * from './decorators/Types'; +export * from './errors/CircularDependencyError'; export * from './errors/ComponentNotAvailableError'; export * from './errors/ComponentNotFoundError'; export * from './errors/DuplicateChildComponentsError'; export * from './errors/DuplicateGlobalIntentsError'; export * from './errors/HandlerNotFoundError'; export * from './errors/InvalidComponentTreeBuiltError'; +export * from './errors/InvalidDependencyError'; export * from './errors/InvalidParentError'; export * from './errors/MatchingRouteNotFoundError'; export * from './errors/MatchingPlatformNotFoundError'; +export * from './errors/UnresolvableDependencyError'; export * from './metadata/ClassDecoratorMetadata'; export * from './metadata/ComponentMetadata'; export * from './metadata/ComponentOptionMetadata'; export * from './metadata/HandlerMetadata'; export * from './metadata/HandlerOptionMetadata'; +export * from './metadata/InjectMetadata'; +export * from './metadata/InjectableMetadata'; export * from './metadata/MetadataStorage'; export * from './metadata/MethodDecoratorMetadata'; export * from './metadata/OutputMetadata'; diff --git a/framework/src/metadata/InjectMetadata.ts b/framework/src/metadata/InjectMetadata.ts new file mode 100644 index 0000000000..8bde0245ef --- /dev/null +++ b/framework/src/metadata/InjectMetadata.ts @@ -0,0 +1,14 @@ +import { AnyObject, Constructor } from '@jovotech/common'; +import { ParameterDecoratorMetadata } from './ParameterDecoratorMetadata'; +import { InjectionToken } from './InjectableMetadata'; + +export class InjectMetadata extends ParameterDecoratorMetadata { + constructor( + // eslint-disable-next-line @typescript-eslint/ban-types + readonly target: Constructor | Function, + readonly index: number, + readonly token: InjectionToken, + ) { + super(target, index); + } +} diff --git a/framework/src/metadata/InjectableMetadata.ts b/framework/src/metadata/InjectableMetadata.ts new file mode 100644 index 0000000000..d73e85f41c --- /dev/null +++ b/framework/src/metadata/InjectableMetadata.ts @@ -0,0 +1,40 @@ +import { ClassDecoratorMetadata } from './ClassDecoratorMetadata'; +import { AnyObject, Constructor, Abstract } from '@jovotech/common'; +import { Jovo } from '../Jovo'; + +export interface InjectableOptions {} + +export class InjectableMetadata extends ClassDecoratorMetadata { + constructor(readonly target: Constructor, readonly options?: InjectableOptions) { + super(target); + } +} + +export interface ValueProvider { + provide: InjectionToken; + useValue: PROVIDER; +} + +export interface ClassProvider { + provide: InjectionToken; + useClass: Constructor; +} + +export interface FactoryProvider { + provide: InjectionToken; + useFactory: (jovo: Jovo) => PROVIDER; +} + +export interface ExistingProvider { + provide: InjectionToken; + useExisting: InjectionToken; +} + +// eslint-disable-next-line @typescript-eslint/ban-types,@typescript-eslint/no-explicit-any +export type InjectionToken = string | symbol | Constructor | Abstract | Function; +export type Provider = + | Constructor + | ValueProvider + | ClassProvider + | FactoryProvider + | ExistingProvider; diff --git a/framework/src/metadata/MetadataStorage.ts b/framework/src/metadata/MetadataStorage.ts index fe7edb2db3..1cf7dbf0dc 100644 --- a/framework/src/metadata/MetadataStorage.ts +++ b/framework/src/metadata/MetadataStorage.ts @@ -7,6 +7,9 @@ import { HandlerMetadata } from './HandlerMetadata'; import { HandlerOptionMetadata } from './HandlerOptionMetadata'; import { MethodDecoratorMetadata } from './MethodDecoratorMetadata'; import { OutputMetadata } from './OutputMetadata'; +import { InjectableMetadata } from './InjectableMetadata'; +import { Constructor } from '@jovotech/common'; +import { InjectMetadata } from './InjectMetadata'; export class MetadataStorage { private static instance: MetadataStorage; @@ -15,6 +18,8 @@ export class MetadataStorage { readonly handlerMetadata: HandlerMetadata[]; readonly handlerOptionMetadata: HandlerOptionMetadata[]; readonly outputMetadata: OutputMetadata[]; + readonly injectableMetadata: InjectableMetadata[]; + readonly injectMetadata: InjectMetadata[]; private constructor() { this.componentMetadata = []; @@ -22,6 +27,8 @@ export class MetadataStorage { this.handlerMetadata = []; this.handlerOptionMetadata = []; this.outputMetadata = []; + this.injectableMetadata = []; + this.injectMetadata = []; } static getInstance(): MetadataStorage { @@ -246,10 +253,54 @@ export class MetadataStorage { return this.handlerOptionMetadata.filter((metadata) => metadata.target === target); } + addInjectableMetadata(metadata: InjectableMetadata): void { + if (this.getInjectableMetadata(metadata.target)) { + // for now, just skip (first only counts) + return; + } + this.injectableMetadata.push(metadata as InjectableMetadata); + } + + getInjectableMetadata( + // eslint-disable-next-line @typescript-eslint/ban-types + target: Constructor | Function, + ): InjectableMetadata | undefined { + return this.injectableMetadata.find( + (metadata) => metadata.target === target, + ) as InjectableMetadata; + } + + addInjectMetadata(metadata: InjectMetadata): void { + if (this.getInjectMetadataAtIndex(metadata.target, metadata.index)) { + // for now, just skip (first only counts) + return; + } + this.injectMetadata.push(metadata as InjectMetadata); + } + + getMergedInjectMetadata( + // eslint-disable-next-line @typescript-eslint/ban-types + target: Constructor | Function, + ): InjectMetadata[] { + return this.injectMetadata.filter((metadata) => metadata.target === target); + } + + getInjectMetadataAtIndex( + // eslint-disable-next-line @typescript-eslint/ban-types + target: Constructor | Function, + index: number, + ): InjectMetadata | undefined { + return this.injectMetadata.find( + (metadata) => metadata.target === target && metadata.index === index, + ); + } + clearAll(): void { this.componentMetadata.length = 0; this.handlerMetadata.length = 0; this.handlerOptionMetadata.length = 0; this.outputMetadata.length = 0; + this.injectableMetadata.length = 0; + this.injectMetadata.length = 0; } } diff --git a/framework/src/metadata/ParameterDecoratorMetadata.ts b/framework/src/metadata/ParameterDecoratorMetadata.ts new file mode 100644 index 0000000000..6866b63429 --- /dev/null +++ b/framework/src/metadata/ParameterDecoratorMetadata.ts @@ -0,0 +1,15 @@ +import { AnyObject, Constructor } from '@jovotech/common'; +import { ClassDecoratorMetadata } from './ClassDecoratorMetadata'; + +export abstract class ParameterDecoratorMetadata< + TARGET = AnyObject, +> extends ClassDecoratorMetadata { + // eslint-disable-next-line @typescript-eslint/ban-types + protected constructor(readonly target: Constructor | Function, readonly index: number) { + super(target); + } + + hasSameTargetAs(otherMetadata: ParameterDecoratorMetadata): boolean { + return this.target === otherMetadata.target && this.index === otherMetadata.index; + } +} diff --git a/framework/src/utilities.ts b/framework/src/utilities.ts index f8116309f6..4ff2998317 100644 --- a/framework/src/utilities.ts +++ b/framework/src/utilities.ts @@ -3,6 +3,7 @@ import _get from 'lodash.get'; import _intersection from 'lodash.intersection'; import _set from 'lodash.set'; import _unset from 'lodash.unset'; +import { Provider } from './metadata/InjectableMetadata'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export function forEachDeep( @@ -96,3 +97,23 @@ export function copy( return result as T; } + +/** + * Checks, whether two Providers have the same `provide` value. + * @param a - First Provider to compare + * @param b - Second Provider to compare + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isSameProvide(a: Provider, b: Provider): boolean { + if (typeof a === 'function' && typeof b === 'function') { + return a === b; + } else if (typeof a === 'function' && typeof b !== 'function') { + return a === b.provide; + } else if (typeof a !== 'function' && typeof b === 'function') { + return a.provide === b; + } else if (typeof a !== 'function' && typeof b !== 'function') { + return a.provide === b.provide; + } else { + return false; + } +} diff --git a/framework/test/DependencyInjection.test.ts b/framework/test/DependencyInjection.test.ts new file mode 100644 index 0000000000..0a3b818328 --- /dev/null +++ b/framework/test/DependencyInjection.test.ts @@ -0,0 +1,475 @@ +import { + Global, + Component, + BaseComponent, + InputType, + App, + Intents, + Injectable, + Jovo, + ComponentOptions, + UnknownObject, + Output, + BaseOutput, + OutputTemplate, + DeepPartial, + OutputOptions, + Inject, + CircularDependencyError, + DependencyTree, + UnresolvableDependencyError, +} from '../src'; +import { ExamplePlatform, ExampleServer } from './utilities'; + +describe('dependency injection in components', () => { + test('nested dependency injection', async () => { + @Injectable() + class ExampleService { + constructor(readonly jovo: Jovo) {} + + getExample() { + return `example_${this.jovo.$input.getIntentName()}`; + } + } + + @Injectable() + class WrapperService { + constructor(readonly exampleService: ExampleService) {} + + getExample() { + return this.exampleService.getExample(); + } + } + + @Global() + @Component() + class ComponentA extends BaseComponent { + constructor( + jovo: Jovo, + options: ComponentOptions | undefined, + readonly wrapperService: WrapperService, + ) { + super(jovo, options); + } + + @Intents('IntentA') + handleIntentA() { + return this.$send(this.wrapperService.getExample()); + } + } + + const app = new App({ + plugins: [new ExamplePlatform()], + providers: [ExampleService, WrapperService], + components: [ComponentA], + }); + await app.initialize(); + + const server = new ExampleServer({ + input: { + type: InputType.Intent, + intent: 'IntentA', + }, + }); + await app.handle(server); + expect(server.response.output).toEqual([ + { + message: 'example_IntentA', + }, + ]); + }); +}); + +describe('dependency injection in output classes', () => { + test('output class with dependency injection', async () => { + @Injectable() + class ExampleService { + constructor(readonly jovo: Jovo) {} + + getExample() { + return `example_${this.jovo.$input.getIntentName()}`; + } + } + + @Output() + class ExampleOutput extends BaseOutput { + constructor( + jovo: Jovo, + options: DeepPartial | undefined, + readonly exampleService: ExampleService, + ) { + super(jovo, options); + } + + build(): OutputTemplate { + return { + message: this.exampleService.getExample(), + }; + } + } + + @Global() + @Component() + class ComponentA extends BaseComponent { + constructor(jovo: Jovo, options: ComponentOptions | undefined) { + super(jovo, options); + } + + @Intents('IntentA') + handleIntentA() { + return this.$send(ExampleOutput); + } + } + + const app = new App({ + plugins: [new ExamplePlatform()], + providers: [ExampleService], + components: [ComponentA], + }); + await app.initialize(); + + const server = new ExampleServer({ + input: { + type: InputType.Intent, + intent: 'IntentA', + }, + }); + await app.handle(server); + expect(server.response.output).toEqual([ + { + message: 'example_IntentA', + }, + ]); + }); +}); + +describe('dependency injection variations', () => { + test('dependency tokens', async () => { + class TokenA {} + abstract class TokenB {} + + const EXAMPLE_TOKEN_1 = Symbol('example_token_1'); + const EXAMPLE_TOKEN_2 = 'example_token_2'; + const EXAMPLE_TOKEN_3 = Jovo; + const EXAMPLE_TOKEN_4 = TokenA; + const EXAMPLE_TOKEN_5 = TokenB; + + @Global() + @Component() + class InjectTokenVariationsComponent extends BaseComponent { + constructor( + jovo: Jovo, + options: ComponentOptions | undefined, + @Inject(EXAMPLE_TOKEN_1) readonly example1: string, + @Inject(EXAMPLE_TOKEN_2) readonly example2: string, + @Inject(EXAMPLE_TOKEN_3) readonly example3: string, + @Inject(EXAMPLE_TOKEN_4) readonly example4: string, + @Inject(EXAMPLE_TOKEN_5) readonly example5: string, + ) { + super(jovo, options); + } + + @Intents('IntentA') + handleIntentA() { + return this.$send( + `${this.example1}_${this.example2}_${this.example3}_${this.example4}_${this.example5}`, + ); + } + } + + const app = new App({ + plugins: [new ExamplePlatform()], + providers: [ + { + provide: EXAMPLE_TOKEN_1, + useValue: 'example1', + }, + { + provide: EXAMPLE_TOKEN_2, + useValue: 'example2', + }, + { + provide: EXAMPLE_TOKEN_3, + useValue: 'example3', + }, + { + provide: EXAMPLE_TOKEN_4, + useValue: 'example4', + }, + { + provide: EXAMPLE_TOKEN_5, + useValue: 'example5', + }, + ], + components: [InjectTokenVariationsComponent], + }); + await app.initialize(); + + const server = new ExampleServer({ + input: { + type: InputType.Intent, + intent: 'IntentA', + }, + }); + await app.handle(server); + expect(server.response.output).toEqual([ + { + message: 'example1_example2_example3_example4_example5', + }, + ]); + }); + + test('provider variations', async () => { + const EXAMPLE_TOKEN_1 = Symbol('example_token_1'); + const EXAMPLE_TOKEN_2 = Symbol('example_token_2'); + const EXAMPLE_TOKEN_3 = Symbol('example_token_3'); + + @Global() + @Component() + class ComponentA extends BaseComponent { + constructor( + jovo: Jovo, + options: ComponentOptions | undefined, + @Inject(EXAMPLE_TOKEN_1) readonly example1: string, + @Inject(EXAMPLE_TOKEN_2) readonly example2: string, + @Inject(EXAMPLE_TOKEN_3) readonly example3: string, + ) { + super(jovo, options); + } + + @Intents('IntentA') + handleIntentA() { + return this.$send(`${this.example1}_${this.example2}_${this.example3}`); + } + } + + const app = new App({ + plugins: [new ExamplePlatform()], + providers: [ + { + provide: EXAMPLE_TOKEN_1, + useValue: 'example1', + }, + { + provide: EXAMPLE_TOKEN_2, + useFactory: () => 'example2', + }, + { + provide: EXAMPLE_TOKEN_3, + useClass: class Example3 extends String { + constructor() { + super('example3'); + } + }, + }, + ], + components: [ComponentA], + }); + await app.initialize(); + + const server = new ExampleServer({ + input: { + type: InputType.Intent, + intent: 'IntentA', + }, + }); + await app.handle(server); + expect(server.response.output).toEqual([ + { + message: 'example1_example2_example3', + }, + ]); + }); +}); + +describe('dependency overrides', () => { + test('override provider with app.configure', () => { + @Injectable() + class ExampleService { + getExample() { + return 'example'; + } + } + + @Injectable() + class UnrelatedService {} + + class OverrideService { + getExample() { + return 'override'; + } + } + + const app = new App({ + plugins: [new ExamplePlatform()], + providers: [ExampleService, UnrelatedService], + components: [], + }); + + app.configure({ + providers: [ + { + provide: ExampleService, + useClass: OverrideService, + }, + ], + }); + + expect(app.providers).toEqual([ + { + provide: ExampleService, + useClass: OverrideService, + }, + UnrelatedService, + ]); + }); +}); + +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 | 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('unresolvable dependency detection', () => { + test('unresolvable dependency', async () => { + @Global() + @Component() + class ComponentA extends BaseComponent { + constructor( + jovo: Jovo, + options: ComponentOptions | undefined, + readonly unresolvableDependency: unknown, + ) { + super(jovo, options); + } + + @Intents('IntentA') + handleIntentA() { + return this.$send('IntentA'); + } + } + + const app = new App({ + plugins: [new ExamplePlatform()], + providers: [], + 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(UnresolvableDependencyError); + }); +}); + +describe('dependency injection middleware', () => { + test('middleware arguments', async () => { + @Injectable() + class ExampleService {} + + @Global() + @Component() + class ComponentA extends BaseComponent { + constructor( + jovo: Jovo, + options: ComponentOptions | 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 = 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); + }); +}); diff --git a/platforms/platform-alexa/src/output/AlexaOutputTemplateConverterStrategy.ts b/platforms/platform-alexa/src/output/AlexaOutputTemplateConverterStrategy.ts index 00e86fd6f7..30abb0b792 100644 --- a/platforms/platform-alexa/src/output/AlexaOutputTemplateConverterStrategy.ts +++ b/platforms/platform-alexa/src/output/AlexaOutputTemplateConverterStrategy.ts @@ -137,8 +137,9 @@ export class AlexaOutputTemplateConverterStrategy extends SingleResponseOutputTe const quickReplies = output.quickReplies; if (quickReplies && this.config.genericOutputToApl) { - const directive: AplRenderDocumentDirective | undefined = response.response - .directives?.[0] as AplRenderDocumentDirective | undefined; + const directive: AplRenderDocumentDirective | undefined = response.response.directives?.find( + (directive) => directive.type === 'Alexa.Presentation.APL.RenderDocument', + ) as AplRenderDocumentDirective | undefined; if (directive) { if (!directive.datasources?.data) { directive.datasources = { diff --git a/platforms/platform-alexa/src/output/templates/AskForListReadPermissionOutput.ts b/platforms/platform-alexa/src/output/templates/AskForListReadPermissionOutput.ts index 3ca6d0780a..b05a4c21c0 100644 --- a/platforms/platform-alexa/src/output/templates/AskForListReadPermissionOutput.ts +++ b/platforms/platform-alexa/src/output/templates/AskForListReadPermissionOutput.ts @@ -1,11 +1,11 @@ -import { Jovo, Output } from '@jovotech/framework'; +import { Jovo, Output, DeepPartial } from '@jovotech/framework'; import { PermissionScope } from '../models'; -import { AskForPermissionOutput } from './AskForPermissionOutput'; +import { AskForPermissionOutput, AskForPermissionOutputOptions } from './AskForPermissionOutput'; @Output() export class AskForListReadPermissionOutput extends AskForPermissionOutput { - constructor(jovo: Jovo) { - super(jovo); + constructor(jovo: Jovo, options: DeepPartial | undefined) { + super(jovo, options); this.options.permissionScope = PermissionScope.ReadList; } } diff --git a/platforms/platform-alexa/src/output/templates/AskForRemindersPermissionOutput.ts b/platforms/platform-alexa/src/output/templates/AskForRemindersPermissionOutput.ts index a37bdafa34..5b87952d74 100644 --- a/platforms/platform-alexa/src/output/templates/AskForRemindersPermissionOutput.ts +++ b/platforms/platform-alexa/src/output/templates/AskForRemindersPermissionOutput.ts @@ -1,11 +1,11 @@ -import { Jovo, Output } from '@jovotech/framework'; +import { Jovo, Output, DeepPartial } from '@jovotech/framework'; import { PermissionScope } from '../models'; -import { AskForPermissionOutput } from './AskForPermissionOutput'; +import { AskForPermissionOutput, AskForPermissionOutputOptions } from './AskForPermissionOutput'; @Output() export class AskForRemindersPermissionOutput extends AskForPermissionOutput { - constructor(jovo: Jovo) { - super(jovo); + constructor(jovo: Jovo, options: DeepPartial | undefined) { + super(jovo, options); this.options.permissionScope = PermissionScope.ReadWriteReminders; } } diff --git a/platforms/platform-alexa/src/output/templates/AskForTimersPermissionOutput.ts b/platforms/platform-alexa/src/output/templates/AskForTimersPermissionOutput.ts index 6b39ec5b10..9c5068c8e7 100644 --- a/platforms/platform-alexa/src/output/templates/AskForTimersPermissionOutput.ts +++ b/platforms/platform-alexa/src/output/templates/AskForTimersPermissionOutput.ts @@ -1,11 +1,11 @@ -import { Jovo, Output } from '@jovotech/framework'; +import { Jovo, Output, DeepPartial } from '@jovotech/framework'; import { PermissionScope } from '../models'; -import { AskForPermissionOutput } from './AskForPermissionOutput'; +import { AskForPermissionOutput, AskForPermissionOutputOptions } from './AskForPermissionOutput'; @Output() export class AskForTimersPermissionOutput extends AskForPermissionOutput { - constructor(jovo: Jovo) { - super(jovo); + constructor(jovo: Jovo, options: DeepPartial | undefined) { + super(jovo, options); this.options.permissionScope = PermissionScope.ReadWriteTimers; } }