Browse Source

feat(server): Rework the plugin interface to account for 2 apis

Relates to #65
Michael Bromley 7 years ago
parent
commit
6e8b24a4a3

+ 103 - 0
server/e2e/default-search-plugin.e2e-spec.ts

@@ -0,0 +1,103 @@
+import path from 'path';
+
+import { SEARCH_PRODUCTS } from '../../admin-ui/src/app/data/definitions/product-definitions';
+import { SearchProducts } from '../../shared/generated-types';
+import { SimpleGraphQLClient } from '../mock-data/simple-graphql-client';
+import { DefaultSearchPlugin } from '../src/plugin';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { TestAdminClient, TestShopClient } from './test-client';
+import { TestServer } from './test-server';
+
+describe('Default search plugin', () => {
+    const adminClient = new TestAdminClient();
+    const shopClient = new TestShopClient();
+    const server = new TestServer();
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+                customerCount: 1,
+            },
+            {
+                plugins: [new DefaultSearchPlugin()],
+            },
+        );
+        await adminClient.init();
+        await shopClient.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    async function testGroupByProduct(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+            input: {
+                groupByProduct: true,
+            },
+        });
+        expect(result.search.totalItems).toBe(20);
+    }
+
+    async function testNoGrouping(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+            input: {
+                groupByProduct: false,
+            },
+        });
+        expect(result.search.totalItems).toBe(34);
+    }
+
+    async function testMatchSearchTerm(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+            input: {
+                term: 'camera',
+                groupByProduct: true,
+            },
+        });
+        expect(result.search.items.map(i => i.productName)).toEqual([
+            'Instant Camera',
+            'Camera Lens',
+            'SLR Camera',
+        ]);
+    }
+
+    async function testMatchFacetIds(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+            input: {
+                facetIds: ['T_1', 'T_2'],
+                groupByProduct: true,
+            },
+        });
+        expect(result.search.items.map(i => i.productName)).toEqual([
+            'Laptop',
+            'Curvy Monitor',
+            'Gaming PC',
+            'Hard Drive',
+            'Clacky Keyboard',
+            'USB Cable',
+        ]);
+    }
+
+    describe('admin api', () => {
+        it('group by product', () => testGroupByProduct(adminClient));
+
+        it('no grouping', () => testNoGrouping(adminClient));
+
+        it('matches search term', () => testMatchSearchTerm(adminClient));
+
+        it('matches by facetId', () => testMatchFacetIds(adminClient));
+    });
+
+    describe('shop api', () => {
+        it('group by product', () => testGroupByProduct(shopClient));
+
+        it('no grouping', () => testNoGrouping(shopClient));
+
+        it('matches search term', () => testMatchSearchTerm(shopClient));
+
+        it('matches by facetId', () => testMatchFacetIds(shopClient));
+    });
+});

+ 94 - 0
server/e2e/fixtures/test-plugins.ts

@@ -0,0 +1,94 @@
+import { Query, Resolver } from '@nestjs/graphql';
+import gql from 'graphql-tag';
+
+import { LanguageCode } from '../../src';
+import { APIExtensionDefinition, InjectorFn, VendureConfig, VendurePlugin } from '../../src/config';
+import { ConfigService } from '../../src/config/config.service';
+
+export class TestAPIExtensionPlugin implements VendurePlugin {
+    extendShopAPI(): APIExtensionDefinition {
+        return {
+            resolvers: [TestShopPluginResolver],
+            schema: gql`
+                extend type Query {
+                    baz: [String]!
+                }
+            `,
+        };
+    }
+
+    extendAdminAPI(): APIExtensionDefinition {
+        return {
+            resolvers: [TestAdminPluginResolver],
+            schema: gql`
+                extend type Query {
+                    foo: [String]!
+                }
+            `,
+        };
+    }
+}
+
+@Resolver()
+export class TestAdminPluginResolver {
+    @Query()
+    foo() {
+        return ['bar'];
+    }
+}
+
+@Resolver()
+export class TestShopPluginResolver {
+    @Query()
+    baz() {
+        return ['quux'];
+    }
+}
+
+export class TestPluginWithProvider implements VendurePlugin {
+    extendShopAPI(): APIExtensionDefinition {
+        return {
+            resolvers: [TestResolverWithInjection],
+            schema: gql`
+                extend type Query {
+                    names: [String]!
+                }
+            `,
+        };
+    }
+
+    defineProviders() {
+        return [NameService];
+    }
+}
+
+export class NameService {
+    getNames(): string[] {
+        return ['seon', 'linda', 'hong'];
+    }
+}
+
+@Resolver()
+export class TestResolverWithInjection {
+    constructor(private nameService: NameService) {}
+
+    @Query()
+    names() {
+        return this.nameService.getNames();
+    }
+}
+
+export class TestPluginWithConfigAndBootstrap implements VendurePlugin {
+    constructor(private boostrapWasCalled: (arg: any) => void) {}
+
+    configure(config: Required<VendureConfig>): Required<VendureConfig> {
+        // tslint:disable-next-line:no-non-null-assertion
+        config.defaultLanguageCode = LanguageCode.zh;
+        return config;
+    }
+
+    onBootstrap(inject: InjectorFn) {
+        const configService = inject(ConfigService);
+        this.boostrapWasCalled(configService);
+    }
+}

+ 77 - 0
server/e2e/plugin.e2e-spec.ts

@@ -0,0 +1,77 @@
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { LanguageCode } from '../../shared/generated-types';
+import { ConfigService } from '../src/config/config.service';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import {
+    TestAPIExtensionPlugin,
+    TestPluginWithConfigAndBootstrap,
+    TestPluginWithProvider,
+} from './fixtures/test-plugins';
+import { TestAdminClient, TestShopClient } from './test-client';
+import { TestServer } from './test-server';
+
+describe('Plugins', () => {
+    const adminClient = new TestAdminClient();
+    const shopClient = new TestShopClient();
+    const server = new TestServer();
+    const bootstrapMockFn = jest.fn();
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+                customerCount: 1,
+            },
+            {
+                plugins: [
+                    new TestPluginWithConfigAndBootstrap(bootstrapMockFn),
+                    new TestAPIExtensionPlugin(),
+                    new TestPluginWithProvider(),
+                ],
+            },
+        );
+        await adminClient.init();
+        await shopClient.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('can modify the config in configure() and inject in onBootstrap()', () => {
+        expect(bootstrapMockFn).toHaveBeenCalled();
+        const configService: ConfigService = bootstrapMockFn.mock.calls[0][0];
+        expect(configService instanceof ConfigService).toBe(true);
+        expect(configService.defaultLanguageCode).toBe(LanguageCode.zh);
+    });
+
+    it('extends the admin API', async () => {
+        const result = await adminClient.query(gql`
+            query {
+                foo
+            }
+        `);
+        expect(result.foo).toEqual(['bar']);
+    });
+
+    it('extends the shop API', async () => {
+        const result = await shopClient.query(gql`
+            query {
+                baz
+            }
+        `);
+        expect(result.baz).toEqual(['quux']);
+    });
+
+    it('DI works with defined providers', async () => {
+        const result = await shopClient.query(gql`
+            query {
+                names
+            }
+        `);
+        expect(result.names).toEqual(['seon', 'linda', 'hong']);
+    });
+});

+ 2 - 1
server/e2e/test-server.ts

@@ -7,7 +7,7 @@ import { SqljsConnectionOptions } from 'typeorm/driver/sqljs/SqljsConnectionOpti
 
 import { Omit } from '../../shared/omit';
 import { populate, PopulateOptions } from '../mock-data/populate';
-import { preBootstrapConfig } from '../src/bootstrap';
+import { preBootstrapConfig, runPluginOnBootstrapMethods } from '../src/bootstrap';
 import { Mutable } from '../src/common/types/common-types';
 import { VendureConfig } from '../src/config/vendure-config';
 
@@ -91,6 +91,7 @@ export class TestServer {
         const config = await preBootstrapConfig(userConfig);
         const appModule = await import('../src/app.module');
         const app = await NestFactory.create(appModule.AppModule, { cors: config.cors, logger: false });
+        await runPluginOnBootstrapMethods(config, app);
         await app.listen(config.port);
         return app;
     }

+ 7 - 6
server/src/api/api.module.ts

@@ -8,7 +8,7 @@ import { ServiceModule } from '../service/service.module';
 
 import { IdCodecService } from './common/id-codec.service';
 import { RequestContextService } from './common/request-context.service';
-import { configureGraphQLModule } from './config/graphql-config.service';
+import { configureGraphQLModule } from './config/configure-graphql-module';
 import { AssetInterceptor } from './middleware/asset-interceptor';
 import { AuthGuard } from './middleware/auth-guard';
 import { IdInterceptor } from './middleware/id-interceptor';
@@ -82,15 +82,15 @@ const entityResolvers = [
 ];
 
 @Module({
-    imports: [ServiceModule, DataImportModule],
-    providers: [IdCodecService, ...adminResolvers, ...entityResolvers],
+    imports: [PluginModule, ServiceModule, DataImportModule],
+    providers: [IdCodecService, ...adminResolvers, ...entityResolvers, ...PluginModule.adminApiResolvers()],
     exports: adminResolvers,
 })
 class AdminApiModule {}
 
 @Module({
-    imports: [ServiceModule],
-    providers: [IdCodecService, ...shopResolvers, ...entityResolvers],
+    imports: [PluginModule, ServiceModule],
+    providers: [IdCodecService, ...shopResolvers, ...entityResolvers, ...PluginModule.shopApiResolvers()],
     exports: shopResolvers,
 })
 class ShopApiModule {}
@@ -107,6 +107,7 @@ class ShopApiModule {}
         AdminApiModule,
         ShopApiModule,
         configureGraphQLModule(configService => ({
+            apiType: 'shop',
             apiPath: configService.shopApiPath,
             typePaths: ['type', 'shop-api', 'common'].map(p =>
                 path.join(__dirname, 'schema', p, '*.graphql'),
@@ -114,13 +115,13 @@ class ShopApiModule {}
             resolverModule: ShopApiModule,
         })),
         configureGraphQLModule(configService => ({
+            apiType: 'admin',
             apiPath: configService.adminApiPath,
             typePaths: ['type', 'admin-api', 'common'].map(p =>
                 path.join(__dirname, 'schema', p, '*.graphql'),
             ),
             resolverModule: AdminApiModule,
         })),
-        PluginModule,
     ],
     providers: [
         ...entityResolvers,

+ 10 - 7
server/src/api/config/graphql-config.service.ts → server/src/api/config/configure-graphql-module.ts

@@ -10,12 +10,14 @@ import { ConfigModule } from '../../config/config.module';
 import { ConfigService } from '../../config/config.service';
 import { I18nModule } from '../../i18n/i18n.module';
 import { I18nService } from '../../i18n/i18n.service';
+import { getPluginAPIExtensions } from '../../plugin/plugin-utils';
 import { TranslateErrorExtension } from '../middleware/translate-errors-extension';
 
 import { generateListOptions } from './generate-list-options';
 import { addGraphQLCustomFields } from './graphql-custom-fields';
 
 export interface GraphQLApiOptions {
+    apiType: 'shop' | 'admin';
     typePaths: string[];
     apiPath: string;
     // tslint:disable-next-line:ban-types
@@ -57,7 +59,7 @@ function createGraphQLOptions(
 
     return {
         path: '/' + options.apiPath,
-        typeDefs: createTypeDefs(),
+        typeDefs: createTypeDefs(options.apiType),
         include: [options.resolverModule],
         resolvers: {
             JSON: GraphQLJSON,
@@ -83,16 +85,17 @@ function createGraphQLOptions(
      * 2. any custom fields defined in the config
      * 3. any schema extensions defined by plugins
      */
-    function createTypeDefs(): string {
+    function createTypeDefs(apiType: 'shop' | 'admin'): string {
         const customFields = configService.customFields;
         const typeDefs = typesLoader.mergeTypesByPaths(...options.typePaths);
         let schema = generateListOptions(typeDefs);
         schema = addGraphQLCustomFields(schema, customFields);
-        const pluginTypes = configService.plugins
-            .map(p => (p.defineGraphQlTypes ? p.defineGraphQlTypes() : undefined))
-            .filter(notNullOrUndefined);
-        for (const types of pluginTypes) {
-            schema = extendSchema(schema, types);
+        const pluginSchemaExtensions = getPluginAPIExtensions(configService.plugins, apiType).map(
+            e => e.schema,
+        );
+
+        for (const documentNode of pluginSchemaExtensions) {
+            schema = extendSchema(schema, documentNode);
         }
         return printSchema(schema);
     }

+ 4 - 2
server/src/bootstrap.ts

@@ -69,7 +69,9 @@ async function runPluginConfigurations(
     config: ReadOnlyRequired<VendureConfig>,
 ): Promise<ReadOnlyRequired<VendureConfig>> {
     for (const plugin of config.plugins) {
-        config = (await plugin.configure(config)) as ReadOnlyRequired<VendureConfig>;
+        if (plugin.configure) {
+            config = (await plugin.configure(config)) as ReadOnlyRequired<VendureConfig>;
+        }
     }
     return config;
 }
@@ -77,7 +79,7 @@ async function runPluginConfigurations(
 /**
  * Run the onBootstrap() method of any configured plugins.
  */
-async function runPluginOnBootstrapMethods(
+export async function runPluginOnBootstrapMethods(
     config: ReadOnlyRequired<VendureConfig>,
     app: INestApplication,
 ): Promise<void> {

+ 37 - 3
server/src/config/vendure-plugin/vendure-plugin.ts

@@ -11,6 +11,32 @@ import { VendureConfig } from '../vendure-config';
  */
 export type InjectorFn = <T>(type: Type<T>) => T;
 
+/**
+ * @description
+ * An object which allows a plugin to extend the Vendure GraphQL API.
+ */
+export interface APIExtensionDefinition {
+    /**
+     * @description
+     * The schema extensions.
+     *
+     * @example
+     * ```TypeScript
+     * const schema = gql`extend type SearchReindexResponse {
+     *     timeTaken: Int!
+     *     indexedItemCount: Int!
+     * }`;
+     * ```
+     */
+    schema: DocumentNode;
+    /**
+     * @description
+     * An array of resolvers for the schema extensions. Should be defined as Nest GraphQL resolver
+     * classes, i.e. using the Nest `@Resolver()` decorator etc.
+     */
+    resolvers: Array<Type<any>>;
+}
+
 /**
  * @description
  * A VendurePlugin is a means of configuring and/or extending the functionality of the Vendure server. In its simplest form,
@@ -28,7 +54,7 @@ export interface VendurePlugin {
      * This method is called before the app bootstraps, and can modify the VendureConfig object and perform
      * other (potentially async) tasks needed.
      */
-    configure(config: Required<VendureConfig>): Required<VendureConfig> | Promise<Required<VendureConfig>>;
+    configure?(config: Required<VendureConfig>): Required<VendureConfig> | Promise<Required<VendureConfig>>;
 
     /**
      * @description
@@ -40,9 +66,17 @@ export interface VendurePlugin {
 
     /**
      * @description
-     * The plugin may extend the default Vendure GraphQL schema by implementing this method.
+     * The plugin may extend the default Vendure GraphQL shop api by implementing this method and providing extended
+     * schema definitions and any required resolvers.
+     */
+    extendShopAPI?(): APIExtensionDefinition;
+
+    /**
+     * @description
+     * The plugin may extend the default Vendure GraphQL admin api by implementing this method and providing extended
+     * schema definitions and any required resolvers.
      */
-    defineGraphQlTypes?(): DocumentNode;
+    extendAdminAPI?(): APIExtensionDefinition;
 
     /**
      * @description

+ 25 - 17
server/src/plugin/default-search-plugin/default-search-plugin.ts

@@ -1,12 +1,11 @@
-import { DocumentNode } from 'graphql';
 import gql from 'graphql-tag';
 
 import { SearchReindexResponse } from '../../../../shared/generated-types';
 import { Type } from '../../../../shared/shared-types';
 import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
-import { InjectorFn, VendureConfig, VendurePlugin } from '../../config';
+import { APIExtensionDefinition, InjectorFn, VendurePlugin } from '../../config';
 
-import { FulltextSearchResolver } from './fulltext-search.resolver';
+import { AdminFulltextSearchResolver, ShopFulltextSearchResolver } from './fulltext-search.resolver';
 import { FulltextSearchService } from './fulltext-search.service';
 import { SearchIndexItem } from './search-index-item.entity';
 
@@ -16,24 +15,33 @@ export interface DefaultSearceReindexResonse extends SearchReindexResponse {
 }
 
 export class DefaultSearchPlugin implements VendurePlugin {
-    private fulltextSearchService: FulltextSearchService;
-
-    async configure(config: Required<VendureConfig>): Promise<Required<VendureConfig>> {
-        return config;
-    }
-
     async onBootstrap(inject: InjectorFn): Promise<void> {
         const searchService = inject(FulltextSearchService);
         await searchService.checkIndex(DEFAULT_LANGUAGE_CODE);
     }
 
-    defineGraphQlTypes(): DocumentNode {
-        return gql`
-            extend type SearchReindexResponse {
-                timeTaken: Int!
-                indexedItemCount: Int!
-            }
-        `;
+    extendAdminAPI(): APIExtensionDefinition {
+        return {
+            resolvers: [AdminFulltextSearchResolver],
+            schema: gql`
+                extend type SearchReindexResponse {
+                    timeTaken: Int!
+                    indexedItemCount: Int!
+                }
+            `,
+        };
+    }
+
+    extendShopAPI(): APIExtensionDefinition {
+        return {
+            resolvers: [ShopFulltextSearchResolver],
+            schema: gql`
+                extend type SearchReindexResponse {
+                    timeTaken: Int!
+                    indexedItemCount: Int!
+                }
+            `,
+        };
     }
 
     defineEntities(): Array<Type<any>> {
@@ -41,6 +49,6 @@ export class DefaultSearchPlugin implements VendurePlugin {
     }
 
     defineProviders(): Array<Type<any>> {
-        return [FulltextSearchService, FulltextSearchResolver];
+        return [FulltextSearchService];
     }
 }

+ 27 - 4
server/src/plugin/default-search-plugin/fulltext-search.resolver.ts

@@ -2,6 +2,7 @@ import { Args, Context, Mutation, Query, ResolveProperty, Resolver } from '@nest
 
 import { Permission, SearchQueryArgs, SearchResponse } from '../../../../shared/generated-types';
 import { Omit } from '../../../../shared/omit';
+import { Decode } from '../../api';
 import { RequestContext } from '../../api/common/request-context';
 import { Allow } from '../../api/decorators/allow.decorator';
 import { Ctx } from '../../api/decorators/request-context.decorator';
@@ -13,13 +14,35 @@ import { DefaultSearceReindexResonse } from './default-search-plugin';
 import { FulltextSearchService } from './fulltext-search.service';
 
 @Resolver('SearchResponse')
-export class FulltextSearchResolver extends BaseSearchResolver {
-    constructor(private fulltextSearchService: FulltextSearchService) {
-        super();
-    }
+export class ShopFulltextSearchResolver implements Omit<BaseSearchResolver, 'reindex'> {
+    constructor(private fulltextSearchService: FulltextSearchService) {}
 
     @Query()
     @Allow(Permission.Public)
+    @Decode('facetIds')
+    async search(
+        @Ctx() ctx: RequestContext,
+        @Args() args: SearchQueryArgs,
+    ): Promise<Omit<SearchResponse, 'facetValues'>> {
+        return this.fulltextSearchService.search(ctx, args.input);
+    }
+
+    @ResolveProperty()
+    async facetValues(
+        @Ctx() ctx: RequestContext,
+        @Context() context: any,
+    ): Promise<Array<Translated<FacetValue>>> {
+        return this.fulltextSearchService.facetValues(ctx, context.req.body.variables.input);
+    }
+}
+
+@Resolver('SearchResponse')
+export class AdminFulltextSearchResolver implements BaseSearchResolver {
+    constructor(private fulltextSearchService: FulltextSearchService) {}
+
+    @Query()
+    @Allow(Permission.ReadCatalog)
+    @Decode('facetIds')
     async search(
         @Ctx() ctx: RequestContext,
         @Args() args: SearchQueryArgs,

+ 18 - 0
server/src/plugin/plugin-utils.ts

@@ -1,5 +1,8 @@
 import proxy from 'http-proxy-middleware';
 
+import { notNullOrUndefined } from '../../../shared/shared-utils';
+import { APIExtensionDefinition, VendurePlugin } from '../config';
+
 export interface ProxyOptions {
     route: string;
     port: number;
@@ -22,3 +25,18 @@ export function createProxyHandler(options: ProxyOptions, logging: boolean) {
         logLevel: logging ? 'info' : 'silent',
     });
 }
+
+/**
+ * Given an array of VendurePlugins, returns a flattened array of all APIExtensionDefinitions.
+ */
+export function getPluginAPIExtensions(
+    plugins: VendurePlugin[],
+    apiType: 'shop' | 'admin',
+): APIExtensionDefinition[] {
+    const extensions =
+        apiType === 'shop'
+            ? plugins.map(p => (p.extendShopAPI ? p.extendShopAPI() : undefined))
+            : plugins.map(p => (p.extendAdminAPI ? p.extendAdminAPI() : undefined));
+
+    return extensions.filter(notNullOrUndefined);
+}

+ 22 - 4
server/src/plugin/plugin.module.ts

@@ -1,23 +1,41 @@
 import { Module } from '@nestjs/common';
 
+import { Type } from '../../../shared/shared-types';
 import { notNullOrUndefined } from '../../../shared/shared-utils';
 import { getConfig } from '../config/config-helpers';
 import { ConfigModule } from '../config/config.module';
 import { EventBusModule } from '../event-bus/event-bus.module';
 import { ServiceModule } from '../service/service.module';
 
-const pluginProviders = getConfig()
-    .plugins.map(p => (p.defineProviders ? p.defineProviders() : undefined))
+import { getPluginAPIExtensions } from './plugin-utils';
+
+const plugins = getConfig().plugins;
+const pluginProviders = plugins
+    .map(p => (p.defineProviders ? p.defineProviders() : undefined))
     .filter(notNullOrUndefined)
     .reduce((flattened, providers) => flattened.concat(providers), []);
 
 /**
  * This module collects and re-exports all providers defined in plugins so that they can be used in other
- * modules (e.g. providing customer resolvers to the ApiModule)
+ * modules.
  */
 @Module({
     imports: [ServiceModule, EventBusModule, ConfigModule],
     providers: pluginProviders,
     exports: pluginProviders,
 })
-export class PluginModule {}
+export class PluginModule {
+    static shopApiResolvers(): Array<Type<any>> {
+        return graphQLResolversFor('shop');
+    }
+
+    static adminApiResolvers(): Array<Type<any>> {
+        return graphQLResolversFor('admin');
+    }
+}
+
+function graphQLResolversFor(apiType: 'shop' | 'admin'): Array<Type<any>> {
+    return getPluginAPIExtensions(plugins, apiType)
+        .map(extension => extension.resolvers)
+        .reduce((flattened, r) => [...flattened, ...r], []);
+}