Browse Source

feat(core): Allow custom field validate fn to be async & injectable

Michael Bromley 5 years ago
parent
commit
5e04a141f1

+ 53 - 1
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -1,5 +1,5 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
-import { CustomFields, mergeConfig } from '@vendure/core';
+import { CustomFields, mergeConfig, TransactionalConnection } from '@vendure/core';
 import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
@@ -14,6 +14,8 @@ fixPostgresTimezone();
 
 // tslint:disable:no-non-null-assertion
 
+const validateInjectorSpy = jest.fn();
+
 const customConfig = mergeConfig(testConfig, {
     dbConnectionOptions: {
         timezone: 'Z',
@@ -65,6 +67,22 @@ const customConfig = mergeConfig(testConfig, {
                     }
                 },
             },
+            {
+                name: 'validateFn3',
+                type: 'string',
+                validate: (value, injector) => {
+                    const connection = injector.get(TransactionalConnection);
+                    validateInjectorSpy(connection);
+                },
+            },
+            {
+                name: 'validateFn4',
+                type: 'string',
+                validate: async (value, injector) => {
+                    await new Promise(resolve => setTimeout(resolve, 1));
+                    return `async error`;
+                },
+            },
             {
                 name: 'stringWithOptions',
                 type: 'string',
@@ -197,6 +215,8 @@ describe('Custom fields', () => {
                 { name: 'validateDateTime', type: 'datetime', list: false },
                 { name: 'validateFn1', type: 'string', list: false },
                 { name: 'validateFn2', type: 'string', list: false },
+                { name: 'validateFn3', type: 'string', list: false },
+                { name: 'validateFn4', type: 'string', list: false },
                 { name: 'stringWithOptions', type: 'string', list: false },
                 { name: 'nonPublic', type: 'string', list: false },
                 { name: 'public', type: 'string', list: false },
@@ -562,6 +582,38 @@ describe('Custom fields', () => {
             `);
             expect(updateProduct.customFields.intListWithValidation).toEqual([1, 42, 3]);
         });
+
+        it('can inject providers into validation fn', async () => {
+            const { updateProduct } = await adminClient.query(gql`
+                mutation {
+                    updateProduct(input: { id: "T_1", customFields: { validateFn3: "some value" } }) {
+                        id
+                        customFields {
+                            validateFn3
+                        }
+                    }
+                }
+            `);
+            expect(updateProduct.customFields.validateFn3).toBe('some value');
+            expect(validateInjectorSpy).toHaveBeenCalledTimes(1);
+            expect(validateInjectorSpy.mock.calls[0][0] instanceof TransactionalConnection).toBe(true);
+        });
+
+        it(
+            'supports async validation fn',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateProduct(input: { id: "T_1", customFields: { validateFn4: "some value" } }) {
+                            id
+                            customFields {
+                                validateFn4
+                            }
+                        }
+                    }
+                `);
+            }, `async error`),
+        );
     });
 
     describe('public access', () => {

+ 61 - 40
packages/core/src/api/common/validate-custom-field-value.spec.ts

@@ -1,8 +1,21 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
+import { Injector } from '../../common/injector';
+
 import { validateCustomFieldValue } from './validate-custom-field-value';
 
 describe('validateCustomFieldValue()', () => {
+    const injector = new Injector({} as any);
+
+    async function assertThrowsError(validateFn: () => Promise<void>, message: string) {
+        try {
+            await validateFn();
+            fail('Should have thrown');
+        } catch (e) {
+            expect(e.message).toBe(message);
+        }
+    }
+
     describe('string & localeString', () => {
         const validate = (value: string) => () =>
             validateCustomFieldValue(
@@ -12,18 +25,19 @@ describe('validateCustomFieldValue()', () => {
                     pattern: '^[0-9]+',
                 },
                 value,
+                injector,
             );
 
-        it('passes valid pattern', () => {
-            expect(validate('1')).not.toThrow();
-            expect(validate('123')).not.toThrow();
-            expect(validate('1foo')).not.toThrow();
+        it('passes valid pattern', async () => {
+            expect(await validate('1')).not.toThrow();
+            expect(await validate('123')).not.toThrow();
+            expect(await validate('1foo')).not.toThrow();
         });
 
-        it('throws on invalid pattern', () => {
-            expect(validate('')).toThrowError('error.field-invalid-string-pattern');
-            expect(validate('foo')).toThrowError('error.field-invalid-string-pattern');
-            expect(validate(' 1foo')).toThrowError('error.field-invalid-string-pattern');
+        it('throws on invalid pattern', async () => {
+            await assertThrowsError(validate(''), 'error.field-invalid-string-pattern');
+            await assertThrowsError(validate('foo'), 'error.field-invalid-string-pattern');
+            await assertThrowsError(validate(' 1foo'), 'error.field-invalid-string-pattern');
         });
     });
 
@@ -36,17 +50,18 @@ describe('validateCustomFieldValue()', () => {
                     options: [{ value: 'small' }, { value: 'large' }],
                 },
                 value,
+                injector,
             );
 
-        it('passes valid option', () => {
-            expect(validate('small')).not.toThrow();
-            expect(validate('large')).not.toThrow();
+        it('passes valid option', async () => {
+            expect(await validate('small')).not.toThrow();
+            expect(await validate('large')).not.toThrow();
         });
 
-        it('throws on invalid option', () => {
-            expect(validate('SMALL')).toThrowError('error.field-invalid-string-option');
-            expect(validate('')).toThrowError('error.field-invalid-string-option');
-            expect(validate('bad')).toThrowError('error.field-invalid-string-option');
+        it('throws on invalid option', async () => {
+            await assertThrowsError(validate('SMALL'), 'error.field-invalid-string-option');
+            await assertThrowsError(validate(''), 'error.field-invalid-string-option');
+            await assertThrowsError(validate('bad'), 'error.field-invalid-string-option');
         });
     });
 
@@ -60,18 +75,19 @@ describe('validateCustomFieldValue()', () => {
                     max: 10,
                 },
                 value,
+                injector,
             );
 
-        it('passes valid range', () => {
-            expect(validate(5)).not.toThrow();
-            expect(validate(7)).not.toThrow();
-            expect(validate(10)).not.toThrow();
+        it('passes valid range', async () => {
+            expect(await validate(5)).not.toThrow();
+            expect(await validate(7)).not.toThrow();
+            expect(await validate(10)).not.toThrow();
         });
 
-        it('throws on invalid range', () => {
-            expect(validate(4)).toThrowError('error.field-invalid-number-range-min');
-            expect(validate(11)).toThrowError('error.field-invalid-number-range-max');
-            expect(validate(-7)).toThrowError('error.field-invalid-number-range-min');
+        it('throws on invalid range', async () => {
+            await assertThrowsError(validate(4), 'error.field-invalid-number-range-min');
+            await assertThrowsError(validate(11), 'error.field-invalid-number-range-max');
+            await assertThrowsError(validate(-7), 'error.field-invalid-number-range-min');
         });
     });
 
@@ -85,19 +101,22 @@ describe('validateCustomFieldValue()', () => {
                     max: '2019-06-01T08:30',
                 },
                 value,
+                injector,
             );
 
-        it('passes valid range', () => {
-            expect(validate('2019-01-01T08:30:00.000')).not.toThrow();
-            expect(validate('2019-06-01T08:30:00.000')).not.toThrow();
-            expect(validate('2019-04-12T14:15:51.200')).not.toThrow();
+        it('passes valid range', async () => {
+            expect(await validate('2019-01-01T08:30:00.000')).not.toThrow();
+            expect(await validate('2019-06-01T08:30:00.000')).not.toThrow();
+            expect(await validate('2019-04-12T14:15:51.200')).not.toThrow();
         });
 
-        it('throws on invalid range', () => {
-            expect(validate('2019-01-01T08:29:00.000')).toThrowError(
+        it('throws on invalid range', async () => {
+            await assertThrowsError(
+                validate('2019-01-01T08:29:00.000'),
                 'error.field-invalid-datetime-range-min',
             );
-            expect(validate('2019-06-01T08:30:00.100')).toThrowError(
+            await assertThrowsError(
+                validate('2019-06-01T08:30:00.100'),
                 'error.field-invalid-datetime-range-max',
             );
         });
@@ -116,6 +135,7 @@ describe('validateCustomFieldValue()', () => {
                     },
                 },
                 value,
+                injector,
             );
         const validate2 = (value: string, languageCode: LanguageCode) => () =>
             validateCustomFieldValue(
@@ -132,27 +152,28 @@ describe('validateCustomFieldValue()', () => {
                     },
                 },
                 value,
+                injector,
                 languageCode,
             );
 
-        it('passes validate fn string', () => {
-            expect(validate1('valid')).not.toThrow();
+        it('passes validate fn string', async () => {
+            expect(await validate1('valid')).not.toThrow();
         });
 
-        it('passes validate fn localized string', () => {
-            expect(validate2('valid', LanguageCode.de)).not.toThrow();
+        it('passes validate fn localized string', async () => {
+            expect(await validate2('valid', LanguageCode.de)).not.toThrow();
         });
 
-        it('fails validate fn string', () => {
-            expect(validate1('bad')).toThrowError('invalid');
+        it('fails validate fn string', async () => {
+            await assertThrowsError(validate1('bad'), 'invalid');
         });
 
-        it('fails validate fn localized string en', () => {
-            expect(validate2('bad', LanguageCode.en)).toThrowError('invalid');
+        it('fails validate fn localized string en', async () => {
+            await assertThrowsError(validate2('bad', LanguageCode.en), 'invalid');
         });
 
-        it('fails validate fn localized string de', () => {
-            expect(validate2('bad', LanguageCode.de)).toThrowError('ungültig');
+        it('fails validate fn localized string de', async () => {
+            await assertThrowsError(validate2('bad', LanguageCode.de), 'ungültig');
         });
     });
 });

+ 8 - 5
packages/core/src/api/common/validate-custom-field-value.ts

@@ -2,6 +2,7 @@ import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
 
 import { UserInputError } from '../../common/error/errors';
+import { Injector } from '../../common/injector';
 import {
     CustomFieldConfig,
     DateTimeCustomFieldConfig,
@@ -16,11 +17,12 @@ import {
  * Validates the value of a custom field input against any configured constraints.
  * If validation fails, an error is thrown.
  */
-export function validateCustomFieldValue(
+export async function validateCustomFieldValue(
     config: CustomFieldConfig,
     value: any,
+    injector: Injector,
     languageCode?: LanguageCode,
-): void {
+): Promise<void> {
     if (config.readonly) {
         throw new UserInputError('error.field-invalid-readonly', { name: config.name });
     }
@@ -49,16 +51,17 @@ export function validateCustomFieldValue(
         default:
             assertNever(config);
     }
-    validateCustomFunction(config as TypedCustomFieldConfig<any, any>, value, languageCode);
+    await validateCustomFunction(config as TypedCustomFieldConfig<any, any>, value, injector, languageCode);
 }
 
-function validateCustomFunction<T extends TypedCustomFieldConfig<any, any>>(
+async function validateCustomFunction<T extends TypedCustomFieldConfig<any, any>>(
     config: T,
     value: any,
+    injector: Injector,
     languageCode?: LanguageCode,
 ) {
     if (typeof config.validate === 'function') {
-        const error = config.validate(value);
+        const error = await config.validate(value, injector);
         if (typeof error === 'string') {
             throw new UserInputError(error);
         }

+ 26 - 10
packages/core/src/api/middleware/validate-custom-fields-interceptor.ts

@@ -1,4 +1,5 @@
 import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
 import { GqlExecutionContext } from '@nestjs/graphql';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import {
@@ -11,6 +12,7 @@ import {
 } from 'graphql';
 
 import { REQUEST_CONTEXT_KEY } from '../../common/constants';
+import { Injector } from '../../common/injector';
 import { ConfigService } from '../../config/config.service';
 import { CustomFieldConfig, CustomFields } from '../../config/custom-field/custom-field-types';
 import { parseContext } from '../common/parse-context';
@@ -26,7 +28,7 @@ import { validateCustomFieldValue } from '../common/validate-custom-field-value'
 export class ValidateCustomFieldsInterceptor implements NestInterceptor {
     private readonly inputsWithCustomFields: Set<string>;
 
-    constructor(private configService: ConfigService) {
+    constructor(private configService: ConfigService, private moduleRef: ModuleRef) {
         this.inputsWithCustomFields = Object.keys(configService.customFields).reduce((inputs, entityName) => {
             inputs.add(`Create${entityName}Input`);
             inputs.add(`Update${entityName}Input`);
@@ -34,8 +36,9 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor {
         }, new Set<string>());
     }
 
-    intercept(context: ExecutionContext, next: CallHandler<any>) {
+    async intercept(context: ExecutionContext, next: CallHandler<any>) {
         const parsedContext = parseContext(context);
+        const injector = new Injector(this.moduleRef);
         if (parsedContext.isGraphQL) {
             const gqlExecutionContext = GqlExecutionContext.create(context);
             const { operation, schema } = parsedContext.info;
@@ -44,21 +47,27 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor {
 
             if (operation.operation === 'mutation') {
                 const inputTypeNames = this.getArgumentMap(operation, schema);
-                Object.entries(inputTypeNames).forEach(([inputName, typeName]) => {
+                for (const [inputName, typeName] of Object.entries(inputTypeNames)) {
                     if (this.inputsWithCustomFields.has(typeName)) {
                         if (variables[inputName]) {
-                            this.validateInput(typeName, ctx.languageCode, variables[inputName]);
+                            await this.validateInput(
+                                typeName,
+                                ctx.languageCode,
+                                injector,
+                                variables[inputName],
+                            );
                         }
                     }
-                });
+                }
             }
         }
         return next.handle();
     }
 
-    private validateInput(
+    private async validateInput(
         typeName: string,
         languageCode: LanguageCode,
+        injector: Injector,
         variableValues?: { [key: string]: any },
     ) {
         if (variableValues) {
@@ -66,16 +75,22 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor {
             const customFieldConfig = this.configService.customFields[entityName as keyof CustomFields];
 
             if (variableValues.customFields) {
-                this.validateCustomFieldsObject(customFieldConfig, languageCode, variableValues.customFields);
+                await this.validateCustomFieldsObject(
+                    customFieldConfig,
+                    languageCode,
+                    variableValues.customFields,
+                    injector,
+                );
             }
             const translations = variableValues.translations;
             if (Array.isArray(translations)) {
                 for (const translation of translations) {
                     if (translation.customFields) {
-                        this.validateCustomFieldsObject(
+                        await this.validateCustomFieldsObject(
                             customFieldConfig,
                             languageCode,
                             translation.customFields,
+                            injector,
                         );
                     }
                 }
@@ -83,15 +98,16 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor {
         }
     }
 
-    private validateCustomFieldsObject(
+    private async validateCustomFieldsObject(
         customFieldConfig: CustomFieldConfig[],
         languageCode: LanguageCode,
         customFieldsObject: { [key: string]: any },
+        injector: Injector,
     ) {
         for (const [key, value] of Object.entries(customFieldsObject)) {
             const config = customFieldConfig.find(c => c.name === key);
             if (config) {
-                validateCustomFieldValue(config, value, languageCode);
+                await validateCustomFieldValue(config, value, injector, languageCode);
             }
         }
     }

+ 5 - 1
packages/core/src/config/custom-field/custom-field-types.ts

@@ -11,6 +11,7 @@ import {
 } from '@vendure/common/lib/generated-types';
 import { CustomFieldsObject, CustomFieldType, Type } from '@vendure/common/lib/shared-types';
 
+import { Injector } from '../../common/injector';
 import { VendureEntity } from '../../entity/base/base.entity';
 
 // prettier-ignore
@@ -46,7 +47,10 @@ export type TypedCustomSingleFieldConfig<
 > = BaseTypedCustomFieldConfig<T, C> & {
     list?: false;
     defaultValue?: DefaultValueType<T>;
-    validate?: (value: DefaultValueType<T>) => string | LocalizedString[] | void;
+    validate?: (
+        value: DefaultValueType<T>,
+        injector: Injector,
+    ) => string | LocalizedString[] | void | Promise<string | LocalizedString[] | void>;
 };
 
 export type TypedCustomListFieldConfig<