Skip to content

Commit

Permalink
fix(validator): Add support for custom validator (#28)
Browse files Browse the repository at this point in the history
  • Loading branch information
ktutnik committed Mar 8, 2023
1 parent 2f65943 commit 72f6a59
Show file tree
Hide file tree
Showing 11 changed files with 683 additions and 328 deletions.
4 changes: 2 additions & 2 deletions packages/core-validator/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@graphql-directive/core-validator",
"version": "1.0.0",
"description": "",
"version": "0.0.0",
"description": "The core validator is a critical part of the validator directive in GraphQL that validates user-supplied values. It is highly extensible and enables effective validation of complex data structures. It also provides detailed error messages that aid in debugging.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"scripts": {
Expand Down
50 changes: 43 additions & 7 deletions packages/core-validator/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import { createTransformer, Plugins } from "@graphql-directive/core-validator"

// plugins, the logic to validate field
const plugins:Plugins = {
EMAIL: () => (str) => val.isEmail(str)
EMAIL: (str, { directiveArgs: args }) => val.isEmail(str)
|| args.message
|| `Must be a valid email address`,

LENGTH: (options: { min?: number, max?: number }) => (str) => val.isLength(str, options)
|| `Must be a string or array between ${options?.min ?? 0} and ${options?.max}`,
LENGTH: (str, { directiveArgs: args }) => val.isLength(str, ctx.directiveArgs)
|| args.message
|| `Must be a string or array between ${args?.min ?? 0} and ${args?.max}`,
}

// the validation directive schema
Expand All @@ -34,6 +36,8 @@ const typeDefs = `
}
directive @validate(
method: ValidationMethod!,
message: String,
validator: String,
min:Int,
max:Int
) repeatable on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
Expand All @@ -47,24 +51,56 @@ The plugins are a key-value object consisting of plugin method names and their v

```typescript
{
METHOD: (options) => (value) => { /* logic */ }
METHOD: (value, ctx) => isValid(value)
|| ctx.directiveArgs.message
|| "The error message"
}
```

The validation logic should return `true` or an error message.

`args` : Is the argument passed from the `@validate` directive, for example `@validate(method: LENGTH, min: 1, max: 150)`, the `args` parameter will contains `{ min: 1, max: 150 }`.

`option`: Is the transformer options, contains some useful information such as list of plugins, directive name (for custom directive name), and list of custom functions.

Validator is a function with signature like above with below parameters:

* `value`: Is the value that will be validate

* `ctx`: Is the validation context, it contains more detail information required for custom validation.
* `options` : Contains options values of transformer
* `path` : The location of where validator applied from the root path through the GraphQL fields
* `contextValue` : An object shared across all resolvers that are executing for a particular operation. Use this to share per-operation state, including authentication information, dataloader instances, and anything else to track across resolvers.
* `parent` : The return value of the resolver for this field's parent (i.e., the previous resolver in the resolver chain).
* `args` : An object that contains all GraphQL arguments provided for this field.
* `info` : Contains information about the operation's execution state, including the field name, the path to the field from the root, and more.
* `directiveArgs` : Contains argument passed by the @validate directive. For example `@validate(method: LENGTH, min: 1, max: 150)`, the `args` parameter will contains `{ min: 1, max: 150 }`.


The return value is `true | string`


The directive schema should reflect the plugin functionalities such as the method name and its parameters. Like example above we providing the enum for the method and list of supported parameters.

```graphql
enum ValidationMethod {
EMAIL, LENGTH
CUSTOM, EMAIL, LENGTH
}
directive @validate(
# the method name
method: ValidationMethod!,
# the custom validator
validator: String,
# for custom message
message: String,
# list of all plugin parameters
min:Int,
max:Int
) repeatable on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
```

The last step is creating the transform function by calling the `createTransformer`. The first parameter is the plugins and the last parameter is the name of the directive in this case is `validate`.
The last step is creating the transform function by calling the `createTransformer`. The first parameter is the plugins and the last parameter is the name of the directive in this case is `validate`.

> IMPORTANT
>
> * `CUSTOM` on `ValidationMethod` is required
> * Top 3 parameters (`method`, `validator`, `message`) are required to add.
155 changes: 101 additions & 54 deletions packages/core-validator/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { getDirective, MapperKind, mapSchema } from "@graphql-tools/utils"
import { defaultFieldResolver, GraphQLError, GraphQLFieldConfig, GraphQLInputFieldConfig, GraphQLSchema } from "graphql"
import { defaultFieldResolver, GraphQLError, GraphQLFieldConfig, GraphQLInputFieldConfig, GraphQLResolveInfo, GraphQLSchema } from "graphql"
import { Path } from "graphql/jsutils/Path"

// ====================================================== //
// ======================== TYPES ======================= //
// ====================================================== //

export type FieldValidator = (val: any, ctx: ValidatorContext) => Promise<ErrorMessage[] | true>

export type Validator = (val: any, ctx: ValidatorContext) => (string | true) | Promise<(string | true)>

export interface TypeValidationConfig {
kind: "Type"
name: String
Expand All @@ -15,32 +19,79 @@ export interface TypeValidationConfig {
export interface FieldValidationConfig {
kind: "Field",
name: string
validator: Validator
validator: FieldValidator
}

export interface DirectiveArgs {
method: string,
message: string,
min: number,
max: number
}

export interface ErrorMessage {
path: string,
message: string
message: string,
}

export interface Plugins {
[key: string]: (config: any) => NativeValidator
[key: string]: Validator
}

export interface TransformerOptions {
plugins: Plugins,
directive: string
export interface TransformerOptions {
/**
* List of plugins
*/
plugins: Plugins,

/**
* Name of the directive
*/
directive: string

/**
* List of custom validators
*/
customValidators?: Plugins
}

export type NativeValidator = (val: any) => string | true
export interface ValidatorContext {
/**
* Contains options values of transformer
*/
options: TransformerOptions,

/**
* The location of where validator applied from the root path through the GraphQL fields
*/
path: string

/**
* An object shared across all resolvers that are executing for a particular operation. Use this to share per-operation state, including authentication information, dataloader instances, and anything else to track across resolvers.
*/
contextValue: any

/**
* The return value of the resolver for this field's parent (i.e., the previous resolver in the resolver chain).
*/
parent: any

/**
* An object that contains all GraphQL arguments provided for this field.
*/
args: any

/**
* Contains information about the operation's execution state, including the field name, the path to the field from the root, and more.
*/
info: GraphQLResolveInfo

/**
* Contains argument passed by the @validate directive. For example `@validate(method: LENGTH, min: 1, max: 150)`, the `args` parameter will contains `{ method: LENGTH, min: 1, max: 150 }`.
*/
directiveArgs: any
}

export type Validator = (val: any) => ErrorMessage[] | true

export { GraphQLSchema }

Expand All @@ -64,57 +115,42 @@ const transform = (schema: GraphQLSchema, options: TransformerOptions): GraphQLS
return { name, isArray, dataType }
}

const fixErrorMessagePath = (messages: ErrorMessage[], root?: string) =>
messages.map<ErrorMessage>((e) => ({ path: [root, e.path].filter(x => x !== "").join("."), message: e.message }))
const joinContext = (ctx: ValidatorContext, ...paths: string[]): ValidatorContext => ({ ...ctx, path: [ctx.path, ...paths].filter(x => x !== "").join(".") })

const composeValidationResult = (results: (true | ErrorMessage[])[], path: string = "") => {
const messages: ErrorMessage[] = []
for (const result of results) {
if (result !== true) {
messages.push(...result)
}
}
return messages.length > 0 ? fixErrorMessagePath(messages, path) : true
const composeValidationResult = (results: (true | ErrorMessage[])[]) => {
const messages = results.filter((x): x is ErrorMessage[] => x !== true).flat()
return messages.length > 0 ? messages : true
}

const createValidatorByDirectives = (path: string, directives: DirectiveArgs[]): Validator => {
const validators: NativeValidator[] = directives.map(({ method, ...config }) => options.plugins[method](config))
return (value: any) => {
const messages: ErrorMessage[] = []
for (const validator of validators) {
const message = validator(value[path])
if (message !== true) messages.push({ message, path: "" })
}
return composeValidationResult([messages], path)
const createValidatorByDirectives = (path: string, directives: DirectiveArgs[]): FieldValidator => {
const fieldVal = (validator: Validator, args:any): FieldValidator => async (val, ctx) => {
const message = await validator(val, {...ctx, directiveArgs: args})
return message !== true ? [{ message, path: ctx.path }] : true
}
const validators = directives.map((args) =>
fieldVal(options.plugins[args.method], args))
return async (val: any, ctx: ValidatorContext) => {
const results = await Promise.all(validators.map(v => v(val[path], joinContext(ctx, path))))
return composeValidationResult(results)
}
}

const createValidatorByField = (info: FieldInfo, fieldConfigs: FieldValidationConfig[]) => {
const createValidatorByField = (info: FieldInfo, fieldConfigs: FieldValidationConfig[]): FieldValidator => {
const validator = composeValidator(...fieldConfigs.map(x => x.validator))
return (val: any) => {
return async (val: any, ctx: ValidatorContext) => {
const value = val[info.name]
if (info.isArray && Array.isArray(value)) {
const result: ErrorMessage[] = []
for (const [i, val] of value.entries()) {
const msg = validator(val)
if (msg !== true)
result.push(...fixErrorMessagePath(msg, i.toString()))
}
return composeValidationResult([result], info.name)
const values = await Promise.all(value.map((val, i) => validator(val, joinContext(ctx, info.name, i.toString()))))
return composeValidationResult(values)
}
else return composeValidationResult([validator(value)], info.name)
else return validator(value, joinContext(ctx, info.name))
}
}


const composeValidator = (...validators: Validator[]): Validator => {
return (val: any) => {
const messages: ErrorMessage[] = []
for (const validator of validators) {
const result = validator(val)
if (result !== true) messages.push(...result)
}
return composeValidationResult([messages])
const composeValidator = (...validators: FieldValidator[]): FieldValidator => {
return async (val: any, ctx: ValidatorContext) => {
const results = await Promise.all(validators.map(v => v(val, ctx)))
return composeValidationResult(results)
}
}

Expand Down Expand Up @@ -156,18 +192,29 @@ const transform = (schema: GraphQLSchema, options: TransformerOptions): GraphQLS
const { resolve = defaultFieldResolver } = config
return {
...config,
resolve: (source, args, context, info) => {
const valid = validator(args)
if (valid !== true) {
const path = getPath(info.path).substring(1)
throw new GraphQLError("USER_INPUT_ERROR", { extensions: { error: fixErrorMessagePath(valid, path) } })
resolve: async (parent, args, context, info) => {
const path = getPath(info.path).substring(1)
const error = await validator(args, {
path, options, args, info, parent,
contextValue: context,
directiveArgs: {}
})
if (error !== true) {
throw new GraphQLError("USER_INPUT_ERROR", { extensions: { error } })
}
return resolve(source, args, context, info)
return resolve(parent, args, context, info)
}
}
}
}
})
}

export const createTransformer = (option: TransformerOptions) => (schema: GraphQLSchema): GraphQLSchema => transform(schema, option)
export const createTransformer = (option: TransformerOptions) =>
(schema: GraphQLSchema, opt: { customValidators?: Plugins } = {}): GraphQLSchema => {
const customValidatorsPlugin: Plugins = {
CUSTOM: (val, ctx) => opt.customValidators![ctx.directiveArgs.validator](val, ctx)
}
const plugins: Plugins = { ...option.plugins, ...customValidatorsPlugin }
return transform(schema, { ...option, ...opt, plugins })
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,70 @@ exports[`Custom Type Validation Should validate argument with primitive type on
}
`;

exports[`Custom Validator Should able to access context from custom validator 1`] = `
{
"error": [
{
"message": "Always error",
"path": "checkEmail.email",
},
],
}
`;

exports[`Custom Validator Should able to access context from custom validator 2`] = `
[
[
"mail",
{
"args": {
"email": "mail",
},
"contextValue": undefined,
"directiveArgs": {
"method": "CUSTOM",
"validator": "email",
},
"options": {
"customValidators": {
"email": [Function],
},
"directive": "validate",
"plugins": {
"CUSTOM": [Function],
"EMAIL": [Function],
"LENGTH": [Function],
},
},
"parent": undefined,
"path": "checkEmail.email",
},
],
]
`;

exports[`Custom Validator Should able to create your own validator 1`] = `
{
"error": [
{
"message": "Always error",
"path": "checkEmail.email",
},
],
}
`;

exports[`Custom Validator Should able to create your own validator 2`] = `
{
"error": [
{
"message": "Always error",
"path": "checkEmail.email",
},
],
}
`;

exports[`Mutation Validation Should validate argument inside array of custom type property 1`] = `
{
"error": [
Expand Down
Loading

0 comments on commit 72f6a59

Please sign in to comment.