Browse Source

feat(server): Move asset storage into a plugin

Relates to #22 and also prototypes the implementation of #24.
Michael Bromley 7 years ago
parent
commit
d98cc5be43

+ 12 - 0
server/dev-config.ts

@@ -1,6 +1,8 @@
+import * as path from 'path';
 import { API_PATH, API_PORT } from 'shared/shared-constants';
 
 import { VendureConfig } from './src/config/vendure-config';
+import { DefaultAssetServerPlugin } from './src/plugin/default-asset-server/default-asset-server-plugin';
 
 /**
  * Config settings used during development
@@ -30,4 +32,14 @@ export const devConfig: VendureConfig = {
             { name: 'nickname', type: 'localeString' },
         ],
     },
+    plugins: [
+        new DefaultAssetServerPlugin({
+            route: 'assets',
+            assetUploadDir: path.join(__dirname, 'assets'),
+            port: 4000,
+            hostname: 'http://localhost',
+            previewMaxHeight: 200,
+            previewMaxWidth: 200,
+        }),
+    ],
 };

+ 4 - 1
server/package.json

@@ -29,23 +29,25 @@
     "apollo-server-express": "^2.0.4",
     "bcrypt": "^3.0.0",
     "body-parser": "^1.18.3",
+    "express": "^4.16.3",
     "fs-extra": "^7.0.0",
     "graphql": "^14.0.0",
     "graphql-iso-date": "^3.5.0",
     "graphql-tag": "^2.9.2",
     "graphql-tools": "^3.1.1",
     "graphql-type-json": "^0.2.1",
+    "http-proxy-middleware": "^0.19.0",
     "i18next": "^11.6.0",
     "i18next-express-middleware": "^1.3.2",
     "i18next-icu": "^0.4.0",
     "i18next-node-fs-backend": "^2.0.0",
-    "jimp": "^0.4.0",
     "jsonwebtoken": "^8.2.2",
     "mysql": "^2.16.0",
     "passport": "^0.4.0",
     "passport-jwt": "^4.0.0",
     "reflect-metadata": "^0.1.12",
     "rxjs": "^6.2.0",
+    "sharp": "^0.20.8",
     "typeorm": "^0.2.6",
     "typescript": "^2.9.0"
   },
@@ -59,6 +61,7 @@
     "@types/jest": "^23.3.1",
     "@types/jsonwebtoken": "^7.2.7",
     "@types/node": "^9.3.0",
+    "@types/sharp": "^0.17.10",
     "faker": "^4.1.0",
     "graphql-request": "^1.8.2",
     "gulp": "^4.0.0",

+ 27 - 1
server/src/app.module.ts

@@ -1,4 +1,5 @@
 import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
+import { RequestHandler } from 'express';
 import { GraphQLDateTime } from 'graphql-iso-date';
 
 import { ApiModule } from './api/api.module';
@@ -16,6 +17,31 @@ export class AppModule implements NestModule {
 
     configure(consumer: MiddlewareConsumer) {
         validateCustomFieldsConfig(this.configService.customFields);
-        consumer.apply(this.i18nService.handle()).forRoutes(this.configService.apiPath);
+
+        const defaultMiddleware: Array<{ handler: RequestHandler; route?: string }> = [
+            { handler: this.i18nService.handle(), route: this.configService.apiPath },
+        ];
+        const allMiddleware = defaultMiddleware.concat(this.configService.middleware);
+        const middlewareByRoute = this.groupMiddlewareByRoute(allMiddleware);
+        for (const [route, handlers] of Object.entries(middlewareByRoute)) {
+            consumer.apply(...handlers).forRoutes(route);
+        }
+    }
+
+    /**
+     * Groups middleware handlers together in an object with the route as the key.
+     */
+    private groupMiddlewareByRoute(
+        middlewareArray: Array<{ handler: RequestHandler; route?: string }>,
+    ): { [route: string]: RequestHandler[] } {
+        const result = {} as { [route: string]: RequestHandler[] };
+        for (const middleware of middlewareArray) {
+            const route = middleware.route || this.configService.apiPath;
+            if (!result[route]) {
+                result[route] = [];
+            }
+            result[route].push(middleware.handler);
+        }
+        return result;
     }
 }

+ 6 - 3
server/src/bootstrap.ts

@@ -21,8 +21,6 @@ export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INe
     // tslint:disable-next-line:whitespace
     const appModule = await import('./app.module');
     const app = await NestFactory.create(appModule.AppModule, { cors: config.cors });
-    await config.assetStorageStrategy.init(app);
-
     return app.listen(config.port);
 }
 
@@ -49,7 +47,12 @@ export async function preBootstrapConfig(
         },
     });
 
-    const config = getConfig();
+    let config = getConfig();
+
+    // Initialize plugins
+    for (const plugin of config.plugins) {
+        config = (await plugin.init(config)) as ReadOnlyRequired<VendureConfig>;
+    }
 
     registerCustomEntityFields(config);
     return config;

+ 0 - 23
server/src/config/asset-preview-strategy/default-asset-preview-strategy.ts

@@ -1,23 +0,0 @@
-import Jimp = require('jimp');
-import { Stream } from 'stream';
-
-import { AssetPreviewStrategy } from './asset-preview-strategy';
-
-export class DefaultAssetPreviewStrategy implements AssetPreviewStrategy {
-    constructor(
-        private config: {
-            maxHeight: number;
-            maxWidth: number;
-        },
-    ) {}
-
-    async generatePreviewImage(mimetype: string, data: Buffer): Promise<Buffer> {
-        const image = await Jimp.read(data);
-        const { maxWidth, maxHeight } = this.config;
-        if (maxWidth < image.getWidth() || maxHeight < image.getHeight()) {
-            image.scaleToFit(maxWidth, maxHeight);
-            return image.getBufferAsync(image.getMIME());
-        }
-        return data;
-    }
-}

+ 12 - 0
server/src/config/asset-preview-strategy/no-asset-preview-strategy.ts

@@ -0,0 +1,12 @@
+import { I18nError } from '../../i18n/i18n-error';
+
+import { AssetPreviewStrategy } from './asset-preview-strategy';
+
+/**
+ * A placeholder strategy which will simply throw an error when used.
+ */
+export class NoAssetPreviewStrategy implements AssetPreviewStrategy {
+    generatePreviewImage(mimetype: string, data: Buffer): Promise<Buffer> {
+        throw new I18nError('error.no-asset-preview-strategy-configured');
+    }
+}

+ 0 - 6
server/src/config/asset-storage-strategy/asset-storage-strategy.ts

@@ -7,12 +7,6 @@ import { Stream } from 'stream';
  * and retrieved.
  */
 export interface AssetStorageStrategy {
-    /**
-     * Perform any setup required on bootstrapping the app, such as registering
-     * a static server or procuring keys from a 3rd-party service.
-     */
-    init(app: INestApplication & INestExpressApplication): Promise<void>;
-
     /**
      * Writes a buffer to the store and returns a unique identifier for that
      * file such as a file path or a URL.

+ 33 - 0
server/src/config/asset-storage-strategy/no-asset-storage-strategy.ts

@@ -0,0 +1,33 @@
+import { Request } from 'express';
+import { Stream } from 'stream';
+
+import { I18nError } from '../../i18n/i18n-error';
+
+import { AssetStorageStrategy } from './asset-storage-strategy';
+
+const errorMessage = 'error.no-asset-storage-strategy-configured';
+
+/**
+ * A placeholder strategy which will simply throw an error when used.
+ */
+export class NoAssetStorageStrategy implements AssetStorageStrategy {
+    writeFileFromStream(fileName: string, data: Stream): Promise<string> {
+        throw new I18nError(errorMessage);
+    }
+
+    writeFileFromBuffer(fileName: string, data: Buffer): Promise<string> {
+        throw new I18nError(errorMessage);
+    }
+
+    readFileToBuffer(identifier: string): Promise<Buffer> {
+        throw new I18nError(errorMessage);
+    }
+
+    readFileToStream(identifier: string): Promise<Stream> {
+        throw new I18nError(errorMessage);
+    }
+
+    toAbsoluteUrl(request: Request, identifier: string): string {
+        throw new I18nError(errorMessage);
+    }
+}

+ 2 - 0
server/src/config/config.service.mock.ts

@@ -18,6 +18,8 @@ export class MockConfigService implements MockClass<ConfigService> {
     uploadMaxFileSize = 1024;
     dbConnectionOptions = {};
     customFields = {};
+    middleware = [];
+    plugins = [];
 }
 
 export const ENCODED = 'encoded';

+ 19 - 9
server/src/config/config.service.ts

@@ -1,5 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
+import { RequestHandler } from 'express';
 import { LanguageCode } from 'shared/generated-types';
 import { CustomFields } from 'shared/shared-types';
 import { ConnectionOptions } from 'typeorm';
@@ -10,9 +11,22 @@ import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-str
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { getConfig, VendureConfig } from './vendure-config';
+import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
 @Injectable()
 export class ConfigService implements VendureConfig {
+    private activeConfig: ReadOnlyRequired<VendureConfig>;
+
+    constructor() {
+        this.activeConfig = getConfig();
+        if (this.activeConfig.disableAuth) {
+            // tslint:disable-next-line
+            console.warn(
+                'WARNING: auth has been disabled. This should never be the case for a production system!',
+            );
+        }
+    }
+
     get disableAuth(): boolean {
         return this.activeConfig.disableAuth;
     }
@@ -65,15 +79,11 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.customFields;
     }
 
-    private activeConfig: ReadOnlyRequired<VendureConfig>;
+    get middleware(): Array<{ handler: RequestHandler; route: string }> {
+        return this.activeConfig.middleware;
+    }
 
-    constructor() {
-        this.activeConfig = getConfig();
-        if (this.activeConfig.disableAuth) {
-            // tslint:disable-next-line
-            console.warn(
-                'WARNING: auth has been disabled. This should never be the case for a production system!',
-            );
-        }
+    get plugins(): VendurePlugin[] {
+        return this.activeConfig.plugins;
     }
 }

+ 16 - 5
server/src/config/vendure-config.ts

@@ -1,4 +1,5 @@
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
+import { RequestHandler } from 'express';
 import { LanguageCode } from 'shared/generated-types';
 import { API_PATH, API_PORT } from 'shared/shared-constants';
 import { CustomFields, DeepPartial } from 'shared/shared-types';
@@ -7,12 +8,13 @@ import { ConnectionOptions } from 'typeorm';
 import { ReadOnlyRequired } from '../common/types/common-types';
 
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
-import { DefaultAssetPreviewStrategy } from './asset-preview-strategy/default-asset-preview-strategy';
+import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
-import { LocalAssetStorageStrategy } from './asset-storage-strategy/local-asset-storage-strategy';
+import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storage-strategy';
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { mergeConfig } from './merge-config';
+import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
 export interface VendureConfig {
     /**
@@ -81,6 +83,14 @@ export interface VendureConfig {
      * The max file size in bytes for uploaded assets.
      */
     uploadMaxFileSize?: number;
+    /**
+     * Custom Express middleware for the server.
+     */
+    middleware?: Array<{ handler: RequestHandler; route: string }>;
+    /**
+     * An array of plugins.
+     */
+    plugins?: VendurePlugin[];
 }
 
 const defaultConfig: ReadOnlyRequired<VendureConfig> = {
@@ -92,9 +102,8 @@ const defaultConfig: ReadOnlyRequired<VendureConfig> = {
     jwtSecret: 'secret',
     apiPath: API_PATH,
     entityIdStrategy: new AutoIncrementIdStrategy(),
-    // tslint:disable-next-line:no-non-null-assertion
-    assetStorageStrategy: new LocalAssetStorageStrategy('assets'),
-    assetPreviewStrategy: new DefaultAssetPreviewStrategy({ maxHeight: 50, maxWidth: 50 }),
+    assetStorageStrategy: new NoAssetStorageStrategy(),
+    assetPreviewStrategy: new NoAssetPreviewStrategy(),
     dbConnectionOptions: {
         type: 'mysql',
     },
@@ -110,6 +119,8 @@ const defaultConfig: ReadOnlyRequired<VendureConfig> = {
         ProductVariant: [],
         User: [],
     } as ReadOnlyRequired<CustomFields>,
+    middleware: [],
+    plugins: [],
 };
 
 let activeConfig = defaultConfig;

+ 17 - 0
server/src/config/vendure-plugin/vendure-plugin.ts

@@ -0,0 +1,17 @@
+import { VendureConfig } from '../vendure-config';
+
+/**
+ * A VendurePlugin is a means of configuring and/or extending the functionality of the Vendure server. In its simplest form,
+ * a plugin simply modifies the VendureConfig object. Although such configuration can be directly supplied to the bootstrap
+ * function, using a plugin allows one to abstract away a set of related configuration.
+ *
+ * Aditionally, the init() method can perform async work such as starting servers, making calls to 3rd party services, or any other
+ * kind of task which may be called for.
+ */
+export interface VendurePlugin {
+    /**
+     * This method is called when the app bootstraps, and can modify the VendureConfig object and perform
+     * other (potentially async) tasks needed.
+     */
+    init(config: Required<VendureConfig>): Required<VendureConfig> | Promise<Required<VendureConfig>>;
+}

+ 28 - 0
server/src/plugin/default-asset-server/default-asset-preview-strategy.ts

@@ -0,0 +1,28 @@
+import * as sharp from 'sharp';
+import { Stream } from 'stream';
+
+import { AssetPreviewStrategy } from '../../config/asset-preview-strategy/asset-preview-strategy';
+
+export class DefaultAssetPreviewStrategy implements AssetPreviewStrategy {
+    constructor(
+        private config: {
+            maxHeight: number;
+            maxWidth: number;
+        },
+    ) {}
+
+    async generatePreviewImage(mimetype: string, data: Buffer): Promise<Buffer> {
+        const image = sharp(data);
+        const metadata = await image.metadata();
+        const width = metadata.width || 0;
+        const height = metadata.height || 0;
+        const { maxWidth, maxHeight } = this.config;
+        if (maxWidth < width || maxHeight < height) {
+            return image
+                .resize(maxWidth, maxHeight)
+                .max()
+                .toBuffer();
+        }
+        return data;
+    }
+}

+ 61 - 0
server/src/plugin/default-asset-server/default-asset-server-plugin.ts

@@ -0,0 +1,61 @@
+import * as express from 'express';
+import * as proxy from 'http-proxy-middleware';
+
+import { VendureConfig } from '../../config/vendure-config';
+import { VendurePlugin } from '../../config/vendure-plugin/vendure-plugin';
+
+import { DefaultAssetPreviewStrategy } from './default-asset-preview-strategy';
+import { DefaultAssetStorageStrategy } from './default-asset-storage-strategy';
+
+export interface DefaultAssetServerOptions {
+    hostname: string;
+    port: number;
+    route: string;
+    assetUploadDir: string;
+    previewMaxWidth: number;
+    previewMaxHeight: number;
+}
+
+/**
+ * The DefaultAssetServerPlugin instantiates a static Express server which is used to
+ * serve the assets.
+ */
+export class DefaultAssetServerPlugin implements VendurePlugin {
+    constructor(private options: DefaultAssetServerOptions) {}
+
+    init(config: Required<VendureConfig>) {
+        this.createAssetServer();
+        config.assetPreviewStrategy = new DefaultAssetPreviewStrategy({
+            maxWidth: this.options.previewMaxWidth,
+            maxHeight: this.options.previewMaxHeight,
+        });
+        config.assetStorageStrategy = new DefaultAssetStorageStrategy(this.options.assetUploadDir);
+        config.middleware.push({
+            handler: this.createProxyHandler(),
+            route: this.options.route,
+        });
+        return config;
+    }
+
+    /**
+     * Creates the image server instance
+     */
+    private createAssetServer() {
+        const assetServer = express();
+        assetServer.use(express.static(this.options.assetUploadDir));
+        assetServer.listen(this.options.port);
+    }
+
+    /**
+     * Configures the proxy middleware which will be passed to the main Vendure server.
+     */
+    private createProxyHandler() {
+        const route = this.options.route.charAt(0) === '/' ? this.options.route : '/' + this.options.route;
+        return proxy({
+            target: `${this.options.hostname}:${this.options.port}`,
+            pathRewrite: {
+                [`^${route}`]: '/',
+            },
+        });
+    }
+}

+ 14 - 21
server/src/config/asset-storage-strategy/local-asset-storage-strategy.ts → server/src/plugin/default-asset-server/default-asset-storage-strategy.ts

@@ -4,31 +4,16 @@ import * as fs from 'fs-extra';
 import * as path from 'path';
 import { Stream } from 'stream';
 
-import { AssetStorageStrategy } from './asset-storage-strategy';
+import { AssetStorageStrategy } from '../../config/asset-storage-strategy/asset-storage-strategy';
 
 /**
- * A persistence strategy which saves files to the local file system and
- * adds a static route to the server config to serve them.
+ * A persistence strategy which saves files to the local file system.
  */
-export class LocalAssetStorageStrategy implements AssetStorageStrategy {
+export class DefaultAssetStorageStrategy implements AssetStorageStrategy {
     private uploadPath: string;
 
-    constructor(public uploadDir: string = 'assets') {}
-
-    async init(app: INestApplication & INestExpressApplication): Promise<any> {
-        // tslint:disable-next-line
-        const uploadPath = this.setAbsoluteUploadPath(path.join(path.basename(require.main!.filename), '..'));
-        app.useStaticAssets(uploadPath, {
-            prefix: `/${this.uploadDir}`,
-        });
-    }
-
-    setAbsoluteUploadPath(rootDir: string): string {
-        this.uploadPath = path.join(rootDir, this.uploadDir);
-        if (!fs.existsSync(this.uploadPath)) {
-            fs.mkdirSync(this.uploadPath);
-        }
-        return this.uploadPath;
+    constructor(uploadDir: string = 'assets') {
+        this.setAbsoluteUploadPath(uploadDir);
     }
 
     writeFileFromStream(fileName: string, data: Stream): Promise<string> {
@@ -60,8 +45,16 @@ export class LocalAssetStorageStrategy implements AssetStorageStrategy {
         return `${request.protocol}://${request.get('host')}/${identifier}`;
     }
 
+    private setAbsoluteUploadPath(uploadDir: string): string {
+        this.uploadPath = uploadDir;
+        if (!fs.existsSync(this.uploadPath)) {
+            fs.mkdirSync(this.uploadPath);
+        }
+        return this.uploadPath;
+    }
+
     private filePathToIdentifier(filePath: string): string {
-        return `${this.uploadDir}/${path.basename(filePath)}`;
+        return `${path.basename(this.uploadPath)}/${path.basename(filePath)}`;
     }
 
     private identifierToFilePath(identifier: string): string {

+ 1 - 0
server/src/service/helpers/connection.decorator.ts

@@ -3,6 +3,7 @@ import { getConnectionManager } from 'typeorm';
 
 import { getConfig } from '../../config/vendure-config';
 
+// TODO: Should be ok to remove this and just use @InjectConnection
 export function ActiveConnection() {
     const cm = getConnectionManager();
     return InjectConnection(getConfig().dbConnectionOptions.name);

File diff suppressed because it is too large
+ 232 - 381
server/yarn.lock


Some files were not shown because too many files changed in this diff