Skip to content

Commit

Permalink
feat(core): Add custom validation function to custom field config
Browse files Browse the repository at this point in the history
Relates to #85
  • Loading branch information
michaelbromley committed Jul 16, 2019
1 parent b6b13a5 commit 80eba9d
Show file tree
Hide file tree
Showing 5 changed files with 134 additions and 16 deletions.
48 changes: 48 additions & 0 deletions packages/core/e2e/custom-fields.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ process.env.TZ = 'UTC';
import gql from 'graphql-tag';
import path from 'path';

import { LanguageCode } from '../../common/lib/generated-types';

import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
import { TestAdminClient, TestShopClient } from './test-client';
import { TestServer } from './test-server';
Expand Down Expand Up @@ -47,6 +49,18 @@ describe('Custom fields', () => {
min: '2019-01-01T08:30',
max: '2019-06-01T08:30',
},
{ name: 'validateFn1', type: 'string', validate: value => {
if (value !== 'valid') {
return `The value ['${value}'] is not valid`;
}
},
},
{ name: 'validateFn2', type: 'string', validate: value => {
if (value !== 'valid') {
return [{ languageCode: LanguageCode.en, value: `The value ['${value}'] is not valid` }];
}
},
},
],
},
},
Expand Down Expand Up @@ -92,6 +106,8 @@ describe('Custom fields', () => {
{ name: 'validateInt', type: 'int' },
{ name: 'validateFloat', type: 'float' },
{ name: 'validateDateTime', type: 'datetime' },
{ name: 'validateFn1', type: 'string' },
{ name: 'validateFn2', type: 'string' },
],
});
});
Expand Down Expand Up @@ -244,5 +260,37 @@ describe('Custom fields', () => {
`);
}, `The custom field value [2019-01-01T05:25:00.000Z] is less than the minimum [2019-01-01T08:30]`),
);

it(
'invalid validate function with string',
assertThrowsWithMessage(async () => {
await adminClient.query(gql`
mutation {
updateProduct(input: {
id: "T_1"
customFields: { validateFn1: "invalid" }
}) {
id
}
}
`);
}, `The value ['invalid'] is not valid`),
);

it(
'invalid validate function with localized string',
assertThrowsWithMessage(async () => {
await adminClient.query(gql`
mutation {
updateProduct(input: {
id: "T_1"
customFields: { validateFn2: "invalid" }
}) {
id
}
}
`);
}, `The value ['invalid'] is not valid`),
);
});
});
47 changes: 47 additions & 0 deletions packages/core/src/api/common/validate-custom-field-value.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { LanguageCode } from '@vendure/common/lib/generated-types';

import { validateCustomFieldValue } from './validate-custom-field-value';

describe('validateCustomFieldValue()', () => {
Expand Down Expand Up @@ -65,4 +67,49 @@ describe('validateCustomFieldValue()', () => {
expect(validate('2019-06-01T08:30:00.100')).toThrowError('error.field-invalid-datetime-range-max');
});
});

describe('validate function', () => {

const validate1 = (value: string) => () => validateCustomFieldValue({
name: 'test',
type: 'string',
validate: v => {
if (v !== 'valid') {
return 'invalid';
}
},
}, value);
const validate2 = (value: string, languageCode: LanguageCode) => () => validateCustomFieldValue({
name: 'test',
type: 'string',
validate: v => {
if (v !== 'valid') {
return [
{ languageCode: LanguageCode.en, value: 'invalid' },
{ languageCode: LanguageCode.de, value: 'ungültig' },
];
}
},
}, value, languageCode);

it('passes validate fn string', () => {
expect(validate1('valid')).not.toThrow();
});

it('passes validate fn localized string', () => {
expect(validate2('valid', LanguageCode.de)).not.toThrow();
});

it('fails validate fn string', () => {
expect(validate1('bad')).toThrowError('invalid');
});

it('fails validate fn localized string en', () => {
expect(validate2('bad', LanguageCode.en)).toThrowError('invalid');
});

it('fails validate fn localized string de', () => {
expect(validate2('bad', LanguageCode.de)).toThrowError('ungültig');
});
});
});
25 changes: 21 additions & 4 deletions packages/core/src/api/common/validate-custom-field-value.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { LanguageCode } from '@vendure/common/lib/generated-types';
import { assertNever } from '@vendure/common/lib/shared-utils';

import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
import { UserInputError } from '../../common/error/errors';
import {
CustomFieldConfig,
Expand All @@ -8,30 +10,45 @@ import {
IntCustomFieldConfig,
LocaleStringCustomFieldConfig,
StringCustomFieldConfig,
TypedCustomFieldConfig,
} from '../../config/custom-field/custom-field-types';

/**
* Validates the value of a custom field input against any configured constraints.
* If validation fails, an error is thrown.
*/
export function validateCustomFieldValue(config: CustomFieldConfig, value: any): void {
export function validateCustomFieldValue(config: CustomFieldConfig, value: any, languageCode?: LanguageCode): void {
switch (config.type) {
case 'string':
case 'localeString':
return validateStringField(config, value);
validateStringField(config, value);
break;
case 'int':
case 'float':
return validateNumberField(config, value);
validateNumberField(config, value);
break;
case 'datetime':
return validateDateTimeField(config, value);
validateDateTimeField(config, value);
break;
case 'boolean':
break;
default:
assertNever(config);
}
validateCustomFunction(config, value, languageCode);
}

function validateCustomFunction<T extends TypedCustomFieldConfig<any, any>>(config: T, value: any, languageCode?: LanguageCode) {
if (typeof config.validate === 'function') {
const error = config.validate(value);
if (typeof error === 'string') {
throw new UserInputError(error);
}
if (Array.isArray(error)) {
const localizedError = error.find(e => e.languageCode === (languageCode || DEFAULT_LANGUAGE_CODE)) || error[0];
throw new UserInputError(localizedError.value);
}
}
}

function validateStringField(config: StringCustomFieldConfig | LocaleStringCustomFieldConfig, value: string): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { LanguageCode } from '@vendure/common/lib/generated-types';
import { assertNever } from '@vendure/common/lib/shared-utils';
import {
DefinitionNode,
GraphQLInputType,
Expand All @@ -11,7 +13,6 @@ import {
TypeNode,
} from 'graphql';

import { assertNever } from '../../../../common/lib/shared-utils';
import { UserInputError } from '../../common/error/errors';
import { ConfigService } from '../../config/config.service';
import {
Expand All @@ -20,6 +21,8 @@ import {
LocaleStringCustomFieldConfig,
StringCustomFieldConfig,
} from '../../config/custom-field/custom-field-types';
import { RequestContext } from '../common/request-context';
import { REQUEST_CONTEXT_KEY } from '../common/request-context.service';
import { validateCustomFieldValue } from '../common/validate-custom-field-value';

/**
Expand All @@ -40,48 +43,49 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor {
}

intercept(context: ExecutionContext, next: CallHandler<any>) {
const ctx = GqlExecutionContext.create(context);
const { operation, schema } = ctx.getInfo<GraphQLResolveInfo>();
const variables = ctx.getArgs();
const gqlExecutionContext = GqlExecutionContext.create(context);
const { operation, schema } = gqlExecutionContext.getInfo<GraphQLResolveInfo>();
const variables = gqlExecutionContext.getArgs();
const ctx: RequestContext = gqlExecutionContext.getContext().req[REQUEST_CONTEXT_KEY];

if (operation.operation === 'mutation') {
const inputTypeNames = this.getArgumentMap(operation, schema);
Object.entries(inputTypeNames).forEach(([inputName, typeName]) => {
if (this.inputsWithCustomFields.has(typeName)) {
if (variables[inputName]) {
this.validateInput(typeName, variables[inputName]);
this.validateInput(typeName, ctx.languageCode, variables[inputName]);
}
}
});
}
return next.handle();
}

private validateInput(typeName: string, variableValues?: { [key: string]: any }) {
private validateInput(typeName: string, languageCode: LanguageCode, variableValues?: { [key: string]: any }) {
if (variableValues) {
const entityName = typeName.replace(/(Create|Update)(.+)Input/, '$2');
const customFieldConfig = this.configService.customFields[entityName as keyof CustomFields];
if (customFieldConfig) {
if (variableValues.customFields) {
this.validateCustomFieldsObject(customFieldConfig, variableValues.customFields);
this.validateCustomFieldsObject(customFieldConfig, languageCode, variableValues.customFields);
}
const translations = variableValues.translations;
if (Array.isArray(translations)) {
for (const translation of translations) {
if (translation.customFields) {
this.validateCustomFieldsObject(customFieldConfig, translation.customFields);
this.validateCustomFieldsObject(customFieldConfig, languageCode, translation.customFields);
}
}
}
}
}
}

private validateCustomFieldsObject(customFieldConfig: CustomFieldConfig[], customFieldsObject: { [key: string]: any; }) {
private validateCustomFieldsObject(customFieldConfig: CustomFieldConfig[], languageCode: LanguageCode, customFieldsObject: { [key: string]: any; }) {
for (const [key, value] of Object.entries(customFieldsObject)) {
const config = customFieldConfig.find(c => c.name === key);
if (config) {
validateCustomFieldValue(config, value);
validateCustomFieldValue(config, value, languageCode);
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/config/custom-field/custom-field-types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { BooleanCustomFieldConfig as GraphQLBooleanCustomFieldConfig,
import {
BooleanCustomFieldConfig as GraphQLBooleanCustomFieldConfig,
CustomField,
CustomFieldConfig as GraphQLCustomFieldConfig,
DateTimeCustomFieldConfig as GraphQLDateTimeCustomFieldConfig,
FloatCustomFieldConfig as GraphQLFloatCustomFieldConfig,
IntCustomFieldConfig as GraphQLIntCustomFieldConfig,
LocaleStringCustomFieldConfig as GraphQLLocaleStringCustomFieldConfig,
LocalizedString,
StringCustomFieldConfig as GraphQLStringCustomFieldConfig,
} from '@vendure/common/lib/generated-types';
import { CustomFieldsObject, CustomFieldType } from '@vendure/common/src/shared-types';
Expand All @@ -29,6 +30,7 @@ export type TypedCustomFieldConfig<T extends CustomFieldType, C extends CustomFi
type: T;
defaultValue?: DefaultValueType<T>;
nullable?: boolean;
validate?: (value: DefaultValueType<T>) => string | LocalizedString[] | void;
};
export type StringCustomFieldConfig = TypedCustomFieldConfig<'string', GraphQLStringCustomFieldConfig>;
export type LocaleStringCustomFieldConfig = TypedCustomFieldConfig<'localeString', GraphQLLocaleStringCustomFieldConfig>;
Expand Down

0 comments on commit 80eba9d

Please sign in to comment.