Browse Source

feat(server): Extend plugin interface

Allows plugins to extends GraphQL schema and create new database entities. Relates to #24
Michael Bromley 7 years ago
parent
commit
89b53fc8b5

+ 10 - 0
server/src/api/api.module.ts

@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
 import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
 import { GraphQLModule } from '@nestjs/graphql';
 
+import { notNullOrUndefined } from '../../../shared/shared-utils';
+import { getConfig } from '../config/config-helpers';
 import { ConfigModule } from '../config/config.module';
 import { DataImportModule } from '../data-import/data-import.module';
 import { I18nModule } from '../i18n/i18n.module';
@@ -59,6 +61,13 @@ const exportedProviders = [
     ZoneResolver,
 ];
 
+// Plugins may define resolver classes which must also be registered with this module
+// alongside the build-in resolvers.
+const pluginResolvers = getConfig()
+    .plugins.map(p => (p.defineGraphQlTypes ? p.defineGraphQlTypes() : undefined))
+    .filter(notNullOrUndefined)
+    .map(types => types.resolver);
+
 /**
  * The ApiModule is responsible for the public API of the application. This is where requests
  * come in, are parsed and then handed over to the ServiceModule classes which take care
@@ -75,6 +84,7 @@ const exportedProviders = [
     ],
     providers: [
         ...exportedProviders,
+        ...pluginResolvers,
         RequestContextService,
         IdCodecService,
         {

+ 16 - 1
server/src/api/config/graphql-config.service.ts

@@ -1,10 +1,12 @@
 import { Injectable } from '@nestjs/common';
 import { GqlModuleOptions, GqlOptionsFactory, GraphQLTypesLoader } from '@nestjs/graphql';
 import { GraphQLUpload } from 'apollo-server-core';
+import { extendSchema, printSchema } from 'graphql';
 import { GraphQLDateTime } from 'graphql-iso-date';
 import * as GraphQLJSON from 'graphql-type-json';
 import * as path from 'path';
 
+import { notNullOrUndefined } from '../../../../shared/shared-utils';
 import { ConfigService } from '../../config/config.service';
 import { I18nService } from '../../i18n/i18n.service';
 import { TranslateErrorExtension } from '../middleware/translate-errors-extension';
@@ -52,9 +54,22 @@ export class GraphqlConfigService implements GqlOptionsFactory {
         };
     }
 
+    /**
+     * Generates the server's GraphQL schema by combining:
+     * 1. the default schema as defined in the source .graphql files specified by `typePaths`
+     * 2. any custom fields defined in the config
+     * 3. any schema extensions defined by plugins
+     */
     private createTypeDefs(): string {
         const customFields = this.configService.customFields;
         const typeDefs = this.typesLoader.mergeTypesByPaths(this.typePaths);
-        return addGraphQLCustomFields(typeDefs, customFields);
+        let schema = addGraphQLCustomFields(typeDefs, customFields);
+        const pluginTypes = this.configService.plugins
+            .map(p => (p.defineGraphQlTypes ? p.defineGraphQlTypes() : undefined))
+            .filter(notNullOrUndefined);
+        for (const { types } of pluginTypes) {
+            schema = extendSchema(schema, types);
+        }
+        return printSchema(schema);
     }
 }

+ 10 - 8
server/src/api/config/graphql-custom-fields.spec.ts

@@ -1,3 +1,5 @@
+import { printSchema } from 'graphql';
+
 import { CustomFields } from '../../../../shared/shared-types';
 
 import { addGraphQLCustomFields } from './graphql-custom-fields';
@@ -13,7 +15,7 @@ describe('addGraphQLCustomFields()', () => {
             Product: [],
         };
         const result = addGraphQLCustomFields(input, customFieldConfig);
-        expect(result).toMatchSnapshot();
+        expect(printSchema(result)).toMatchSnapshot();
     });
 
     it('extends a type', () => {
@@ -26,7 +28,7 @@ describe('addGraphQLCustomFields()', () => {
             Product: [{ name: 'available', type: 'boolean' }],
         };
         const result = addGraphQLCustomFields(input, customFieldConfig);
-        expect(result).toMatchSnapshot();
+        expect(printSchema(result)).toMatchSnapshot();
     });
 
     it('extends a type with a translation', () => {
@@ -44,7 +46,7 @@ describe('addGraphQLCustomFields()', () => {
             Product: [{ name: 'available', type: 'boolean' }, { name: 'shortName', type: 'localeString' }],
         };
         const result = addGraphQLCustomFields(input, customFieldConfig);
-        expect(result).toMatchSnapshot();
+        expect(printSchema(result)).toMatchSnapshot();
     });
 
     it('extends a type with a Create input', () => {
@@ -61,7 +63,7 @@ describe('addGraphQLCustomFields()', () => {
             Product: [{ name: 'available', type: 'boolean' }, { name: 'shortName', type: 'localeString' }],
         };
         const result = addGraphQLCustomFields(input, customFieldConfig);
-        expect(result).toMatchSnapshot();
+        expect(printSchema(result)).toMatchSnapshot();
     });
 
     it('extends a type with an Update input', () => {
@@ -78,7 +80,7 @@ describe('addGraphQLCustomFields()', () => {
             Product: [{ name: 'available', type: 'boolean' }, { name: 'shortName', type: 'localeString' }],
         };
         const result = addGraphQLCustomFields(input, customFieldConfig);
-        expect(result).toMatchSnapshot();
+        expect(printSchema(result)).toMatchSnapshot();
     });
 
     it('extends a type with a Create input and a translation', () => {
@@ -103,7 +105,7 @@ describe('addGraphQLCustomFields()', () => {
             Product: [{ name: 'available', type: 'boolean' }, { name: 'shortName', type: 'localeString' }],
         };
         const result = addGraphQLCustomFields(input, customFieldConfig);
-        expect(result).toMatchSnapshot();
+        expect(printSchema(result)).toMatchSnapshot();
     });
 
     it('extends a type with SortParameters', () => {
@@ -125,7 +127,7 @@ describe('addGraphQLCustomFields()', () => {
             Product: [{ name: 'available', type: 'boolean' }, { name: 'shortName', type: 'localeString' }],
         };
         const result = addGraphQLCustomFields(input, customFieldConfig);
-        expect(result).toMatchSnapshot();
+        expect(printSchema(result)).toMatchSnapshot();
     });
 
     it('extends a type with FilterParameters', () => {
@@ -164,6 +166,6 @@ describe('addGraphQLCustomFields()', () => {
             ],
         };
         const result = addGraphQLCustomFields(input, customFieldConfig);
-        expect(result).toMatchSnapshot();
+        expect(printSchema(result)).toMatchSnapshot();
     });
 });

+ 3 - 3
server/src/api/config/graphql-custom-fields.ts

@@ -1,4 +1,4 @@
-import { buildSchema, extendSchema, parse, printSchema } from 'graphql';
+import { buildSchema, extendSchema, GraphQLSchema, parse } from 'graphql';
 
 import { CustomFieldConfig, CustomFields, CustomFieldType } from '../../../../shared/shared-types';
 import { assertNever } from '../../../../shared/shared-utils';
@@ -8,7 +8,7 @@ import { assertNever } from '../../../../shared/shared-utils';
  * types with a customFields property for all entities, translations and inputs for which
  * custom fields are defined.
  */
-export function addGraphQLCustomFields(typeDefs: string, customFieldConfig: CustomFields): string {
+export function addGraphQLCustomFields(typeDefs: string, customFieldConfig: CustomFields): GraphQLSchema {
     const schema = buildSchema(typeDefs);
 
     let customFieldTypeDefs = '';
@@ -134,7 +134,7 @@ export function addGraphQLCustomFields(typeDefs: string, customFieldConfig: Cust
         }
     }
 
-    return printSchema(extendSchema(schema, parse(customFieldTypeDefs)));
+    return extendSchema(schema, parse(customFieldTypeDefs));
 }
 
 type GraphQLFieldType = 'String' | 'Int' | 'Float' | 'Boolean' | 'ID';

+ 30 - 3
server/src/bootstrap.ts

@@ -4,10 +4,10 @@ import { EntitySubscriberInterface } from 'typeorm';
 
 import { Type } from '../../shared/shared-types';
 
+import { InternalServerError } from './common/error/errors';
 import { ReadOnlyRequired } from './common/types/common-types';
 import { getConfig, setConfig } from './config/config-helpers';
 import { VendureConfig } from './config/vendure-config';
-import { VendureEntity } from './entity/base/base.entity';
 import { registerCustomEntityFields } from './entity/custom-entity-fields';
 
 export type VendureBootstrapFunction = (config: VendureConfig) => Promise<INestApplication>;
@@ -44,11 +44,12 @@ export async function preBootstrapConfig(
     // base VendureEntity to be correctly configured with the primary key type
     // specified in the EntityIdStrategy.
     // tslint:disable-next-line:whitespace
-    const { coreEntitiesMap } = await import('./entity/entities');
+    const pluginEntities = getEntitiesFromPlugins(userConfig);
+    const entities = await getAllEntities(userConfig);
     const { coreSubscribersMap } = await import('./entity/subscribers');
     setConfig({
         dbConnectionOptions: {
-            entities: Object.values(coreEntitiesMap) as Array<Type<VendureEntity>>,
+            entities,
             subscribers: Object.values(coreSubscribersMap) as Array<Type<EntitySubscriberInterface>>,
         },
     });
@@ -63,3 +64,29 @@ export async function preBootstrapConfig(
     registerCustomEntityFields(config);
     return config;
 }
+
+async function getAllEntities(userConfig: Partial<VendureConfig>): Promise<Array<Type<any>>> {
+    const { coreEntitiesMap } = await import('./entity/entities');
+    const coreEntities = Object.values(coreEntitiesMap) as Array<Type<any>>;
+    const coreEntityNames = Object.keys(coreEntitiesMap);
+    const pluginEntities = getEntitiesFromPlugins(userConfig);
+
+    for (const pluginEntity of pluginEntities) {
+        if (coreEntityNames.includes(pluginEntity.name)) {
+            throw new InternalServerError(`error.entity-name-conflict`, { entityName: pluginEntity.name });
+        }
+    }
+    return [...coreEntities, ...pluginEntities];
+}
+
+/**
+ * Collects all entities defined in plugins into a single array.
+ */
+function getEntitiesFromPlugins(userConfig: Partial<VendureConfig>): Array<Type<any>> {
+    if (!userConfig.plugins) {
+        return [];
+    }
+    return userConfig.plugins
+        .map(p => (p.defineEntities ? p.defineEntities() : []))
+        .reduce((all, entities) => [...all, ...entities], []);
+}

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

@@ -1,5 +1,10 @@
+import { DocumentNode } from 'graphql';
+
+import { Type } from '../../../../shared/shared-types';
 import { VendureConfig } from '../vendure-config';
 
+export type PluginGraphQLExtension = { types: DocumentNode; resolver: Type<any> };
+
 /**
  * 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
@@ -14,4 +19,16 @@ export interface VendurePlugin {
      * other (potentially async) tasks needed.
      */
     init(config: Required<VendureConfig>): Required<VendureConfig> | Promise<Required<VendureConfig>>;
+
+    /**
+     * The plugin may extend the default Vendure GraphQL schema by implementing this method. For any type extension
+     * such as a new Query or Mutation field, a corresponding resolver must be supplied.
+     */
+    defineGraphQlTypes?(): PluginGraphQLExtension;
+
+    /**
+     * The plugin may define custom database entities, which should be defined as classes annotated as per the
+     * TypeORM documentation.
+     */
+    defineEntities?(): Array<Type<any>>;
 }