diff --git a/packages/core/e2e/lifecycle.e2e-spec.ts b/packages/core/e2e/lifecycle.e2e-spec.ts new file mode 100644 index 0000000000..8246343462 --- /dev/null +++ b/packages/core/e2e/lifecycle.e2e-spec.ts @@ -0,0 +1,52 @@ +import { Injector } from '@vendure/core'; +import { createTestEnvironment } from '@vendure/testing'; +import path from 'path'; + +import { initialData } from '../../../e2e-common/e2e-initial-data'; +import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config'; +import { AutoIncrementIdStrategy } from '../src/config/entity-id-strategy/auto-increment-id-strategy'; +import { ProductService } from '../src/service/services/product.service'; + +const initSpy = jest.fn(); +const destroySpy = jest.fn(); + +class TestIdStrategy extends AutoIncrementIdStrategy { + async init(injector: Injector) { + const productService = injector.get(ProductService); + const connection = injector.getConnection(); + await new Promise(resolve => setTimeout(resolve, 100)); + initSpy(productService.constructor.name, connection.name); + } + + async destroy() { + await new Promise(resolve => setTimeout(resolve, 100)); + destroySpy(); + } +} + +describe('lifecycle hooks for configurable objects', () => { + const { server, adminClient } = createTestEnvironment({ + ...testConfig, + entityIdStrategy: new TestIdStrategy(), + }); + + beforeAll(async () => { + await server.init({ + initialData, + productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-empty.csv'), + customerCount: 1, + }); + await adminClient.asSuperAdmin(); + }, TEST_SETUP_TIMEOUT_MS); + + it('runs init with Injector', () => { + expect(initSpy).toHaveBeenCalled(); + expect(initSpy.mock.calls[0][0]).toEqual('ProductService'); + expect(initSpy.mock.calls[0][1]).toBe('default'); + }); + + it('runs destroy', async () => { + await server.destroy(); + expect(destroySpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/app.module.ts b/packages/core/src/app.module.ts index 2ba34100f6..e0d0d1c51f 100644 --- a/packages/core/src/app.module.ts +++ b/packages/core/src/app.module.ts @@ -11,6 +11,8 @@ import cookieSession = require('cookie-session'); import { RequestHandler } from 'express'; import { ApiModule } from './api/api.module'; +import { Injector } from './common/injector'; +import { InjectableStrategy } from './common/types/injectable-strategy'; import { ConfigModule } from './config/config.module'; import { ConfigService } from './config/config.service'; import { Logger } from './config/logger/vendure-logger'; @@ -30,10 +32,7 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat ) {} async onApplicationBootstrap() { - const { jobQueueStrategy } = this.configService.jobQueueOptions; - if (typeof jobQueueStrategy.init === 'function') { - await jobQueueStrategy.init(this.moduleRef); - } + await this.initInjectableStrategies(); } configure(consumer: MiddlewareConsumer) { @@ -60,10 +59,7 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat } async onApplicationShutdown(signal?: string) { - const { jobQueueStrategy } = this.configService.jobQueueOptions; - if (typeof jobQueueStrategy.destroy === 'function') { - await jobQueueStrategy.destroy(); - } + await this.destroyInjectableStrategies(); if (signal) { Logger.info('Received shutdown signal:' + signal); } @@ -85,4 +81,43 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat } return result; } + + private async initInjectableStrategies() { + const injector = new Injector(this.moduleRef); + for (const strategy of this.getInjectableStrategies()) { + if (typeof strategy.init === 'function') { + await strategy.init(injector); + } + } + } + + private async destroyInjectableStrategies() { + for (const strategy of this.getInjectableStrategies()) { + if (typeof strategy.destroy === 'function') { + await strategy.destroy(); + } + } + } + + private getInjectableStrategies(): InjectableStrategy[] { + const { + assetNamingStrategy, + assetPreviewStrategy, + assetStorageStrategy, + } = this.configService.assetOptions; + const { taxCalculationStrategy, taxZoneStrategy } = this.configService.taxOptions; + const { jobQueueStrategy } = this.configService.jobQueueOptions; + const { mergeStrategy } = this.configService.orderOptions; + const { entityIdStrategy } = this.configService; + return [ + assetNamingStrategy, + assetPreviewStrategy, + assetStorageStrategy, + taxCalculationStrategy, + taxZoneStrategy, + jobQueueStrategy, + mergeStrategy, + entityIdStrategy, + ]; + } } diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index ba7cc05127..fe1ed5c38c 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -1,3 +1,4 @@ +export * from './async-queue'; export * from './error/errors'; +export * from './injector'; export * from './utils'; -export * from './async-queue'; diff --git a/packages/core/src/common/injector.ts b/packages/core/src/common/injector.ts new file mode 100644 index 0000000000..461c5680a6 --- /dev/null +++ b/packages/core/src/common/injector.ts @@ -0,0 +1,44 @@ +import { Type } from '@nestjs/common'; +import { ContextId, ModuleRef } from '@nestjs/core'; +import { getConnectionToken } from '@nestjs/typeorm'; +import { Connection } from 'typeorm'; + +/** + * @description + * The Injector wraps the underlying Nestjs `ModuleRef`, allowing injection of providers + * known to the application's dependency injection container. This is intended to enable the injection + * of services into objects which exist outside of the Nestjs module system, e.g. the various + * Strategies which can be supplied in the VendureConfig. + * + * @docsCategory common + */ +export class Injector { + constructor(private moduleRef: ModuleRef) {} + + /** + * @description + * Retrieve an instance of the given type from the app's dependency injection container. + * Wraps the Nestjs `ModuleRef.get()` method. + */ + get(typeOrToken: Type | string | symbol): R { + return this.moduleRef.get(typeOrToken, { strict: false }); + } + + /** + * @description + * Retrieve the TypeORM `Connection` instance. + */ + getConnection(): Connection { + return this.moduleRef.get(getConnectionToken() as any, { strict: false }); + } + + /** + * @description + * Retrieve an instance of the given scoped provider (transient or request-scoped) from the + * app's dependency injection container. + * Wraps the Nestjs `ModuleRef.resolve()` method. + */ + resolve(typeOrToken: Type | string | symbol, contextId?: ContextId): Promise { + return this.moduleRef.resolve(typeOrToken, contextId, { strict: false }); + } +} diff --git a/packages/core/src/common/types/injectable-strategy.ts b/packages/core/src/common/types/injectable-strategy.ts new file mode 100644 index 0000000000..8453a43799 --- /dev/null +++ b/packages/core/src/common/types/injectable-strategy.ts @@ -0,0 +1,32 @@ +import { Injector } from '../injector'; + +/** + * @description + * This interface defines the setup and teardown hooks available to the + * various strategies used to configure Vendure. + * + * @docsCategory common + */ +export interface InjectableStrategy { + /** + * @description + * Defines setup logic to be run during application bootstrap. Receives + * the {@link Injector} as an argument, which allows application providers + * to be used as part of the setup. + * + * @example + * ```TypeScript + * async init(injector: Injector) { + * const myService = injector.get(MyService); + * await myService.doSomething(); + * } + * ``` + */ + init?: (injector: Injector) => void | Promise; + + /** + * @description + * Defines teardown logic to be run before application shutdown. + */ + destroy?: () => void | Promise; +} diff --git a/packages/core/src/config/asset-naming-strategy/asset-naming-strategy.ts b/packages/core/src/config/asset-naming-strategy/asset-naming-strategy.ts index 901ba61445..b52c678f71 100644 --- a/packages/core/src/config/asset-naming-strategy/asset-naming-strategy.ts +++ b/packages/core/src/config/asset-naming-strategy/asset-naming-strategy.ts @@ -1,3 +1,5 @@ +import { InjectableStrategy } from '../../common/types/injectable-strategy'; + /** * @description * The AssetNamingStrategy determines how file names are generated based on the uploaded source file name, @@ -5,7 +7,7 @@ * * @docsCategory assets */ -export interface AssetNamingStrategy { +export interface AssetNamingStrategy extends InjectableStrategy { /** * @description * Given the original file name of the uploaded file, generate a file name to diff --git a/packages/core/src/config/asset-preview-strategy/asset-preview-strategy.ts b/packages/core/src/config/asset-preview-strategy/asset-preview-strategy.ts index b9bcc8e02b..0980e7da72 100644 --- a/packages/core/src/config/asset-preview-strategy/asset-preview-strategy.ts +++ b/packages/core/src/config/asset-preview-strategy/asset-preview-strategy.ts @@ -1,5 +1,7 @@ import { Stream } from 'stream'; +import { InjectableStrategy } from '../../common/types/injectable-strategy'; + /** * @description * The AssetPreviewStrategy determines how preview images for assets are created. For image @@ -11,6 +13,6 @@ import { Stream } from 'stream'; * * @docsCategory assets */ -export interface AssetPreviewStrategy { +export interface AssetPreviewStrategy extends InjectableStrategy { generatePreviewImage(mimeType: string, data: Buffer): Promise; } diff --git a/packages/core/src/config/asset-storage-strategy/asset-storage-strategy.ts b/packages/core/src/config/asset-storage-strategy/asset-storage-strategy.ts index c1144cc4b0..dadb188518 100644 --- a/packages/core/src/config/asset-storage-strategy/asset-storage-strategy.ts +++ b/packages/core/src/config/asset-storage-strategy/asset-storage-strategy.ts @@ -1,6 +1,8 @@ import { Request } from 'express'; import { Stream } from 'stream'; +import { InjectableStrategy } from '../../common/types/injectable-strategy'; + /** * @description * The AssetPersistenceStrategy determines how Asset files are physically stored @@ -8,7 +10,7 @@ import { Stream } from 'stream'; * * @docsCategory assets */ -export interface AssetStorageStrategy { +export interface AssetStorageStrategy extends InjectableStrategy { /** * @description * Writes a buffer to the store and returns a unique identifier for that @@ -57,5 +59,5 @@ export interface AssetStorageStrategy { * (i.e. the identifier is already an absolute url) then this method * should not be implemented. */ - toAbsoluteUrl?(reqest: Request, identifier: string): string; + toAbsoluteUrl?(request: Request, identifier: string): string; } diff --git a/packages/core/src/config/entity-id-strategy/entity-id-strategy.ts b/packages/core/src/config/entity-id-strategy/entity-id-strategy.ts index 0a14d7e22d..775fef93b3 100644 --- a/packages/core/src/config/entity-id-strategy/entity-id-strategy.ts +++ b/packages/core/src/config/entity-id-strategy/entity-id-strategy.ts @@ -1,5 +1,7 @@ import { ID } from '@vendure/common/lib/shared-types'; +import { InjectableStrategy } from '../../common/types/injectable-strategy'; + /** * @description * Defines the type of primary key used for all entities in the database. @@ -20,7 +22,7 @@ export type PrimaryKeyType = 'increment' | 'uuid'; * @docsCategory entities * @docsPage Entity Configuration * */ -export interface EntityIdStrategy { +export interface EntityIdStrategy extends InjectableStrategy { readonly primaryKeyType: PrimaryKeyType; encodeId: (primaryKey: T) => string; decodeId: (id: string) => T; diff --git a/packages/core/src/config/job-queue/job-queue-strategy.ts b/packages/core/src/config/job-queue/job-queue-strategy.ts index 43dd6b089b..b65bfd14f0 100644 --- a/packages/core/src/config/job-queue/job-queue-strategy.ts +++ b/packages/core/src/config/job-queue/job-queue-strategy.ts @@ -2,6 +2,7 @@ import { ModuleRef } from '@nestjs/core'; import { JobListOptions } from '@vendure/common/lib/generated-types'; import { ID, PaginatedList } from '@vendure/common/lib/shared-types'; +import { InjectableStrategy } from '../../common/types/injectable-strategy'; import { Job } from '../../job-queue/job'; /** @@ -12,30 +13,7 @@ import { Job } from '../../job-queue/job'; * * @docsCategory JobQueue */ -export interface JobQueueStrategy { - /** - * @description - * Initialization logic to be run after the Vendure server has been initialized - * (in the Nestjs [onApplicationBootstrap hook](https://docs.nestjs.com/fundamentals/lifecycle-events)). - * - * Receives an instance of the application's ModuleRef, which can be used to inject - * providers: - * - * @example - * ```TypeScript - * init(moduleRef: ModuleRef) { - * const myService = moduleRef.get(MyService, { strict: false }); - * } - * ``` - */ - init?(moduleRef: ModuleRef): void | Promise; - - /** - * @description - * Teardown logic to be run when the Vendure server shuts down. - */ - destroy?(): void | Promise; - +export interface JobQueueStrategy extends InjectableStrategy { /** * @description * Add a new job to the queue. diff --git a/packages/core/src/config/order-merge-strategy/order-merge-strategy.ts b/packages/core/src/config/order-merge-strategy/order-merge-strategy.ts index 8773ab858d..51e61b5d69 100644 --- a/packages/core/src/config/order-merge-strategy/order-merge-strategy.ts +++ b/packages/core/src/config/order-merge-strategy/order-merge-strategy.ts @@ -1,3 +1,4 @@ +import { InjectableStrategy } from '../../common/types/injectable-strategy'; import { OrderLine } from '../../entity/order-line/order-line.entity'; import { Order } from '../../entity/order/order.entity'; @@ -11,7 +12,7 @@ import { Order } from '../../entity/order/order.entity'; * * @docsCategory orders */ -export interface OrderMergeStrategy { +export interface OrderMergeStrategy extends InjectableStrategy { /** * @description * Merges the lines of the guest Order with those of the existing Order which is associated diff --git a/packages/core/src/config/tax/tax-calculation-strategy.ts b/packages/core/src/config/tax/tax-calculation-strategy.ts index be35494da0..904965890b 100644 --- a/packages/core/src/config/tax/tax-calculation-strategy.ts +++ b/packages/core/src/config/tax/tax-calculation-strategy.ts @@ -1,4 +1,5 @@ import { RequestContext } from '../../api/common/request-context'; +import { InjectableStrategy } from '../../common/types/injectable-strategy'; import { TaxCategory, Zone } from '../../entity'; import { TaxCalculationResult } from '../../service/helpers/tax-calculator/tax-calculator'; import { TaxRateService } from '../../service/services/tax-rate.service'; @@ -9,7 +10,7 @@ import { TaxRateService } from '../../service/services/tax-rate.service'; * * @docsCategory tax */ -export interface TaxCalculationStrategy { +export interface TaxCalculationStrategy extends InjectableStrategy { calculate(args: TaxCalculationArgs): TaxCalculationResult; } diff --git a/packages/core/src/config/tax/tax-zone-strategy.ts b/packages/core/src/config/tax/tax-zone-strategy.ts index 2f9b264a0a..2580cd3abd 100644 --- a/packages/core/src/config/tax/tax-zone-strategy.ts +++ b/packages/core/src/config/tax/tax-zone-strategy.ts @@ -1,3 +1,4 @@ +import { InjectableStrategy } from '../../common/types/injectable-strategy'; import { Channel, Order, Zone } from '../../entity'; /** @@ -6,6 +7,6 @@ import { Channel, Order, Zone } from '../../entity'; * * @docsCategory tax */ -export interface TaxZoneStrategy { +export interface TaxZoneStrategy extends InjectableStrategy { determineTaxZone(zones: Zone[], channel: Channel, order?: Order): Zone | undefined; } diff --git a/packages/core/src/plugin/default-job-queue-plugin/sql-job-queue-strategy.ts b/packages/core/src/plugin/default-job-queue-plugin/sql-job-queue-strategy.ts index ec70981b03..eb33507b9b 100644 --- a/packages/core/src/plugin/default-job-queue-plugin/sql-job-queue-strategy.ts +++ b/packages/core/src/plugin/default-job-queue-plugin/sql-job-queue-strategy.ts @@ -1,9 +1,8 @@ -import { ModuleRef } from '@nestjs/core'; -import { getConnectionToken } from '@nestjs/typeorm'; import { JobListOptions, JobState } from '@vendure/common/lib/generated-types'; import { ID, PaginatedList } from '@vendure/common/lib/shared-types'; -import { Brackets, Connection, FindConditions, In, LessThan, Not } from 'typeorm'; +import { Brackets, Connection, FindConditions, In, LessThan } from 'typeorm'; +import { Injector } from '../../common/injector'; import { JobQueueStrategy } from '../../config/job-queue/job-queue-strategy'; import { Job } from '../../job-queue/job'; import { ProcessContext } from '../../process-context/process-context'; @@ -22,11 +21,11 @@ export class SqlJobQueueStrategy implements JobQueueStrategy { private connection: Connection | undefined; private listQueryBuilder: ListQueryBuilder; - init(moduleRef: ModuleRef) { - const processContext = moduleRef.get(ProcessContext, { strict: false }); + init(injector: Injector) { + const processContext = injector.get(ProcessContext); if (processContext.isServer) { - this.connection = moduleRef.get(getConnectionToken() as any, { strict: false }); - this.listQueryBuilder = moduleRef.get(ListQueryBuilder, { strict: false }); + this.connection = injector.getConnection(); + this.listQueryBuilder = injector.get(ListQueryBuilder); } }