From 3914b78cff40c88dcf46a172ea358294d30fd0e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Wed, 20 Sep 2023 14:48:02 +0200 Subject: [PATCH] feat(validation): support custom validate fn on args decorator level --- CHANGELOG.md | 1 + docs/validation.md | 31 +++++++++++++--- src/decorators/types.ts | 2 ++ src/helpers/params.ts | 3 +- src/metadata/definitions/param-metadata.ts | 5 +-- src/resolvers/helpers.ts | 12 ++++--- src/resolvers/validate-arg.ts | 14 ++++---- tests/functional/validation.ts | 42 +++++++++++++++++++++- 8 files changed, 90 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc27c2033..53bc041f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - **Breaking Change**: expose shim as a package entry point `type-graphql/shim` (and `/node_modules/type-graphql/build/typings/shim.ts`) - support defining directives on `@Field` of `@Args` - support defining directives on inline `@Arg` +- allow passing custom validation function as `validateFn` option of `@Arg` and `@Args` decorators ## v2.0.0-beta.3 diff --git a/docs/validation.md b/docs/validation.md index a49df81b4..b6a4e8be0 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -203,21 +203,23 @@ An alternative solution that allows to completely get rid off big `class-validat We can also use other libraries than `class-validator` together with TypeGraphQL. -To integrate it, all we need to do is to provide a custom function as `validate` option in `buildSchema`. -It receives two parameters: +To integrate it, all we need to do is to provide a custom function. +It receives three parameters: - `argValue` which is the injected value of `@Arg()` or `@Args()` -- `argType` which is a runtime type information (e.g. `String` or `RecipeInput`). +- `argType` which is a runtime type information (e.g. `String` or `RecipeInput`) +- `resolverData` which holds the resolver execution context, described as generic type `ResolverData` -The `validateFn` option can be an async function and should return nothing (`void`) when validation passes or throw an error when validation fails. +This function can be an async function and should return nothing (`void`) when validation passes, or throw an error when validation fails. So be aware of this while trying to wrap another library in `validateFn` function for TypeGraphQL. +Then we provide this function as a `validateFn` option in `buildSchema`. Example using [decorators library for Joi validators (`joiful`)](https://github.com/joiful-ts/joiful): ```ts const schema = await buildSchema({ // ... - validate: argValue => { + validateFn: argValue => { // Call joiful validate const { error } = joiful.validate(argValue); if (error) { @@ -228,6 +230,25 @@ const schema = await buildSchema({ }); ``` +The `validateFn` option is also supported as a `@Arg()` or `@Args()` decorator option, e.g.: + +```ts +@Resolver() +class SampleResolver { + @Query() + sampleQuery( + @Arg("sampleArg", { + validateFn: (argValue, argType) => { + // Do something here with arg value and type... + }, + }) + sampleArg: string, + ): string { + // ... + } +} +``` + > Be aware that when using custom validator, the error won't be wrapped with `ArgumentValidationError` like for the built-in `class-validator` validation. ### Custom Validation Example diff --git a/src/decorators/types.ts b/src/decorators/types.ts index c8f8efccc..be10c9483 100644 --- a/src/decorators/types.ts +++ b/src/decorators/types.ts @@ -6,6 +6,7 @@ import { type ResolverFilterData, type ResolverTopicData, type TypeResolver, + type ValidatorFn, } from "@/typings"; export type RecursiveArray = Array | TValue>; @@ -45,6 +46,7 @@ export interface DeprecationOptions { } export interface ValidateOptions { validate?: ValidateSettings; + validateFn?: ValidatorFn; } export interface ComplexityOptions { complexity?: Complexity; diff --git a/src/helpers/params.ts b/src/helpers/params.ts index 3c43955c5..e2d739413 100644 --- a/src/helpers/params.ts +++ b/src/helpers/params.ts @@ -39,6 +39,7 @@ export function getParamInfo({ index: parameterIndex, getType, typeOptions, - validate: options.validate, + validateSettings: options.validate, + validateFn: options.validateFn, }; } diff --git a/src/metadata/definitions/param-metadata.ts b/src/metadata/definitions/param-metadata.ts index 2f8b17d00..16ee241a3 100644 --- a/src/metadata/definitions/param-metadata.ts +++ b/src/metadata/definitions/param-metadata.ts @@ -1,6 +1,6 @@ import { type TypeOptions, type TypeValueThunk } from "@/decorators/types"; import { type ValidateSettings } from "@/schema/build-context"; -import { type ResolverData } from "@/typings"; +import { type ResolverData, type ValidatorFn } from "@/typings"; export interface BasicParamMetadata { target: Function; @@ -31,7 +31,8 @@ export type RootParamMetadata = { export type CommonArgMetadata = { getType: TypeValueThunk; typeOptions: TypeOptions; - validate: ValidateSettings | undefined; + validateSettings: ValidateSettings | undefined; + validateFn: ValidatorFn | undefined; } & BasicParamMetadata; export type ArgParamMetadata = { diff --git a/src/resolvers/helpers.ts b/src/resolvers/helpers.ts index 6afe87eb4..d7624bc6f 100644 --- a/src/resolvers/helpers.ts +++ b/src/resolvers/helpers.ts @@ -14,7 +14,7 @@ export function getParams( params: ParamMetadata[], resolverData: ResolverData, globalValidate: ValidateSettings, - validateFn: ValidatorFn | undefined, + globalValidateFn: ValidatorFn | undefined, pubSub: PubSubEngine, ): Promise | any[] { const paramValues = params @@ -28,8 +28,9 @@ export function getParams( paramInfo.getType(), resolverData, globalValidate, - paramInfo.validate, - validateFn, + paramInfo.validateSettings, + globalValidateFn, + paramInfo.validateFn, ); case "arg": @@ -38,8 +39,9 @@ export function getParams( paramInfo.getType(), resolverData, globalValidate, - paramInfo.validate, - validateFn, + paramInfo.validateSettings, + globalValidateFn, + paramInfo.validateFn, ); case "context": diff --git a/src/resolvers/validate-arg.ts b/src/resolvers/validate-arg.ts index e9e7e1331..948afd613 100644 --- a/src/resolvers/validate-arg.ts +++ b/src/resolvers/validate-arg.ts @@ -13,23 +13,25 @@ export async function validateArg( argValue: any | undefined, argType: TypeValue, resolverData: ResolverData, - globalValidate: ValidateSettings, - argValidate: ValidateSettings | undefined, - validateFn: ValidatorFn | undefined, + globalValidateSettings: ValidateSettings, + argValidateSettings: ValidateSettings | undefined, + globalValidateFn: ValidatorFn | undefined, + argValidateFn: ValidatorFn | undefined, ): Promise { + const validateFn = argValidateFn ?? globalValidateFn; if (typeof validateFn === "function") { await validateFn(argValue, argType, resolverData); return argValue; } - const validate = argValidate !== undefined ? argValidate : globalValidate; + const validate = argValidateSettings !== undefined ? argValidateSettings : globalValidateSettings; if (validate === false || !shouldArgBeValidated(argValue)) { return argValue; } const validatorOptions: ValidatorOptions = { - ...(typeof globalValidate === "object" ? globalValidate : {}), - ...(typeof argValidate === "object" ? argValidate : {}), + ...(typeof globalValidateSettings === "object" ? globalValidateSettings : {}), + ...(typeof argValidateSettings === "object" ? argValidateSettings : {}), }; if (validatorOptions.skipMissingProperties !== false) { validatorOptions.skipMissingProperties = true; diff --git a/tests/functional/validation.ts b/tests/functional/validation.ts index d5923c1b6..490cb2b40 100644 --- a/tests/functional/validation.ts +++ b/tests/functional/validation.ts @@ -676,7 +676,7 @@ describe("Custom validation", () => { let validateArgs: Array = []; let validateTypes: TypeValue[] = []; - const validateResolverData: ResolverData[] = []; + let validateResolverData: ResolverData[] = []; let sampleQueryArgs: any[] = []; beforeAll(async () => { @@ -707,6 +707,20 @@ describe("Custom validation", () => { sampleQueryArgs.push(arrayArg); return true; } + + @Query() + sampleInlineArgValidateFnQuery( + @Arg("arg", { + validateFn: (arg, type, resolverData) => { + validateArgs.push(arg); + validateTypes.push(type); + validateResolverData.push(resolverData); + }, + }) + arg: SampleInput, + ): string { + return arg.sampleField; + } } sampleResolverCls = SampleResolver; }); @@ -714,6 +728,7 @@ describe("Custom validation", () => { beforeEach(() => { validateArgs = []; validateTypes = []; + validateResolverData = []; sampleQueryArgs = []; }); @@ -775,6 +790,31 @@ describe("Custom validation", () => { expect(sampleQueryArgs).toEqual([{ sampleField: "sampleFieldValue" }]); }); + it("should call `validateFn` function provided inline in arg option with proper params", async () => { + schema = await buildSchema({ + resolvers: [sampleResolverCls], + }); + + await graphql({ + schema, + source: /* graphql */ ` + query { + sampleInlineArgValidateFnQuery(arg: { sampleField: "sampleArgValue" }) + } + `, + contextValue: { isContext: true }, + }); + + expect(validateArgs).toEqual([{ sampleField: "sampleArgValue" }]); + expect(validateArgs[0]).toBeInstanceOf(sampleInputCls); + expect(validateTypes).toEqual([sampleInputCls]); + expect(validateResolverData).toEqual([ + expect.objectContaining({ + context: { isContext: true }, + }), + ]); + }); + it("should rethrow wrapped error when error thrown in `validate`", async () => { schema = await buildSchema({ resolvers: [sampleResolverCls],