Browse Source

feat(core): Implement injectable lifecycle hooks for strategies

Relates to #303
Michael Bromley 5 years ago
parent
commit
451caf16a1

+ 52 - 0
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();
+    });
+});

+ 43 - 8
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,
+        ];
+    }
 }

+ 2 - 1
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';

+ 44 - 0
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<T, R = T>(typeOrToken: Type<T> | 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<T, R = T>(typeOrToken: Type<T> | string | symbol, contextId?: ContextId): Promise<R> {
+        return this.moduleRef.resolve(typeOrToken, contextId, { strict: false });
+    }
+}

+ 32 - 0
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<void>;
+
+    /**
+     * @description
+     * Defines teardown logic to be run before application shutdown.
+     */
+    destroy?: () => void | Promise<void>;
+}

+ 3 - 1
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

+ 3 - 1
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<Buffer>;
 }

+ 4 - 2
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;
 }

+ 3 - 1
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<T extends ID = ID> {
+export interface EntityIdStrategy<T extends ID = ID> extends InjectableStrategy {
     readonly primaryKeyType: PrimaryKeyType;
     encodeId: (primaryKey: T) => string;
     decodeId: (id: string) => T;

+ 2 - 24
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<void>;
-
-    /**
-     * @description
-     * Teardown logic to be run when the Vendure server shuts down.
-     */
-    destroy?(): void | Promise<void>;
-
+export interface JobQueueStrategy extends InjectableStrategy {
     /**
      * @description
      * Add a new job to the queue.

+ 2 - 1
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

+ 2 - 1
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;
 }
 

+ 2 - 1
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;
 }

+ 6 - 7
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);
         }
     }