Browse Source

feat(core): Allow custom ApolloServerPlugins to be specified

For certain types of data transformation, express plugins and Nestjs interceptors are not sufficient since they occur at the wrong point in the request/response pipeline. This is the case with the built-in ID-transformation code. For these problems, ApolloServerPlugins provide very fine-grained control over the request/response, with full access to the GraphQL schema and document. This can in theory enable very advanced use-cases such as implementing custom GraphQL directives.

Closes #210
Michael Bromley 6 years ago
parent
commit
dc45c8717f

+ 106 - 0
packages/core/e2e/apollo-server-plugin.e2e-spec.ts

@@ -0,0 +1,106 @@
+import {
+    ApolloServerPlugin,
+    GraphQLRequestContext,
+    GraphQLRequestListener,
+    GraphQLServiceContext,
+} from 'apollo-server-plugin-base';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { createTestEnvironment } from '../../testing/lib/create-test-environment';
+
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
+
+class MyApolloServerPlugin implements ApolloServerPlugin {
+    static serverWillStartFn = jest.fn();
+    static requestDidStartFn = jest.fn();
+    static willSendResponseFn = jest.fn();
+
+    static reset() {
+        this.serverWillStartFn = jest.fn();
+        this.requestDidStartFn = jest.fn();
+        this.willSendResponseFn = jest.fn();
+    }
+
+    serverWillStart(service: GraphQLServiceContext): Promise<void> | void {
+        MyApolloServerPlugin.serverWillStartFn(service);
+    }
+
+    requestDidStart(): GraphQLRequestListener | void {
+        MyApolloServerPlugin.requestDidStartFn();
+        return {
+            willSendResponse(requestContext: any): Promise<void> | void {
+                const data = requestContext.response.data;
+                MyApolloServerPlugin.willSendResponseFn(data);
+            },
+        };
+    }
+}
+
+describe('custom apolloServerPlugins', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig,
+        apolloServerPlugins: [new MyApolloServerPlugin()],
+    });
+
+    beforeAll(async () => {
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('calls serverWillStart()', () => {
+        expect(MyApolloServerPlugin.serverWillStartFn).toHaveBeenCalled();
+    });
+
+    it('runs plugin on shop api query', async () => {
+        MyApolloServerPlugin.reset();
+        await shopClient.query(gql`
+            query Q1 {
+                product(id: "T_1") {
+                    id
+                    name
+                }
+            }
+        `);
+
+        expect(MyApolloServerPlugin.requestDidStartFn).toHaveBeenCalledTimes(1);
+        expect(MyApolloServerPlugin.willSendResponseFn).toHaveBeenCalledTimes(1);
+        expect(MyApolloServerPlugin.willSendResponseFn.mock.calls[0][0]).toEqual({
+            product: {
+                id: 'T_1',
+                name: 'Laptop',
+            },
+        });
+    });
+
+    it('runs plugin on admin api query', async () => {
+        MyApolloServerPlugin.reset();
+        await adminClient.query(gql`
+            query Q2 {
+                product(id: "T_1") {
+                    id
+                    name
+                }
+            }
+        `);
+
+        expect(MyApolloServerPlugin.requestDidStartFn).toHaveBeenCalledTimes(1);
+        expect(MyApolloServerPlugin.willSendResponseFn).toHaveBeenCalledTimes(1);
+        expect(MyApolloServerPlugin.willSendResponseFn.mock.calls[0][0]).toEqual({
+            product: {
+                id: 'T_1',
+                name: 'Laptop',
+            },
+        });
+    });
+});

+ 1 - 0
packages/core/src/api/config/configure-graphql-module.ts

@@ -146,6 +146,7 @@ async function createGraphQLOptions(
             new IdCodecPlugin(idCodecService),
             new TranslateErrorsPlugin(i18nService),
             new AssetInterceptorPlugin(configService),
+            ...configService.apolloServerPlugins,
         ],
     } as GqlModuleOptions;
 

+ 5 - 0
packages/core/src/config/config.service.ts

@@ -1,6 +1,7 @@
 import { DynamicModule, Injectable, Type } from '@nestjs/common';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { PluginDefinition } from 'apollo-server-core';
 import { RequestHandler } from 'express';
 import { ConnectionOptions } from 'typeorm';
 
@@ -110,6 +111,10 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.middleware;
     }
 
+    get apolloServerPlugins(): PluginDefinition[] {
+        return this.activeConfig.apolloServerPlugins;
+    }
+
     get plugins(): Array<DynamicModule | Type<any>> {
         return this.activeConfig.plugins;
     }

+ 1 - 0
packages/core/src/config/default-config.ts

@@ -104,5 +104,6 @@ export const defaultConfig: RuntimeVendureConfig = {
         User: [],
     },
     middleware: [],
+    apolloServerPlugins: [],
     plugins: [],
 };

+ 12 - 0
packages/core/src/config/vendure-config.ts

@@ -2,6 +2,7 @@ import { DynamicModule, Type } from '@nestjs/common';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { ClientOptions, Transport } from '@nestjs/microservices';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { PluginDefinition } from 'apollo-server-core';
 import { RequestHandler } from 'express';
 import { Observable } from 'rxjs';
 import { ConnectionOptions } from 'typeorm';
@@ -476,6 +477,17 @@ export interface VendureConfig {
      * @default []
      */
     middleware?: Array<{ handler: RequestHandler; route: string }>;
+    /**
+     * @description
+     * Custom [ApolloServerPlugins](https://www.apollographql.com/docs/apollo-server/integrations/plugins/) which
+     * allow the extension of the Apollo Server, which is the underlying GraphQL server used by Vendure.
+     *
+     * Apollo plugins can be used e.g. to perform custom data transformations on incoming operations or outgoing
+     * data.
+     *
+     * @default []
+     */
+    apolloServerPlugins?: PluginDefinition[];
     /**
      * @description
      * Configures available payment processing methods.