From 0d716c84974505ed59c80ccf0058643849c93dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Sat, 25 Aug 2018 14:45:05 +0200 Subject: [PATCH 1/3] test(resolvers): add test cases for input transformations bug --- tests/functional/resolvers.ts | 127 ++++++++++++++++++++++++++++++---- 1 file changed, 112 insertions(+), 15 deletions(-) diff --git a/tests/functional/resolvers.ts b/tests/functional/resolvers.ts index 9af4fc27f..f01b90873 100644 --- a/tests/functional/resolvers.ts +++ b/tests/functional/resolvers.ts @@ -824,7 +824,7 @@ describe("Resolvers", () => { try { @Resolver() - class SampleResolver { + class SampleResolverWithError { @Query(returns => String) sampleQuery(@Arg("arg") arg: any): string { return "sampleQuery"; @@ -845,7 +845,7 @@ describe("Resolvers", () => { try { @Resolver() - class SampleResolver { + class SampleResolverWithError { @Query() sampleQuery() { return "sampleQuery"; @@ -864,7 +864,7 @@ describe("Resolvers", () => { try { @Resolver() - class SampleResolver { + class SampleResolverWithError { @Query() sampleQuery(): any { return "sampleQuery"; @@ -883,7 +883,7 @@ describe("Resolvers", () => { try { @Resolver() - class SampleResolver { + class SampleResolverWithError { @Mutation() sampleMutation() { return "sampleMutation"; @@ -902,7 +902,7 @@ describe("Resolvers", () => { try { @Resolver() - class SampleResolver { + class SampleResolverWithError { @Mutation() sampleMutation(): any { return "sampleMutation"; @@ -920,14 +920,14 @@ describe("Resolvers", () => { expect.assertions(3); @ObjectType() - class SampleObject { + class SampleObjectWithError { @Field() sampleField: string; } try { @Resolver() - class SampleResolver { + class SampleResolverWithError { @Query() sampleQuery(): string { return "sampleQuery"; @@ -938,13 +938,13 @@ describe("Resolvers", () => { } } await buildSchema({ - resolvers: [SampleResolver], + resolvers: [SampleResolverWithError], }); } catch (err) { expect(err).toBeInstanceOf(Error); const error = err as Error; expect(error.message).toContain("@Resolver"); - expect(error.message).toContain("SampleResolver"); + expect(error.message).toContain("SampleResolverWithError"); } }); @@ -952,14 +952,14 @@ describe("Resolvers", () => { expect.assertions(4); @ObjectType() - class SampleObject { + class SampleObjectWithError { @Field() sampleField: string; } try { - @Resolver(of => SampleObject) - class SampleResolver { + @Resolver(of => SampleObjectWithError) + class SampleResolverWithError { @Query() sampleQuery(): string { return "sampleQuery"; @@ -970,13 +970,13 @@ describe("Resolvers", () => { } } await buildSchema({ - resolvers: [SampleResolver], + resolvers: [SampleResolverWithError], }); } catch (err) { expect(err).toBeInstanceOf(Error); const error = err as Error; expect(error.message).toContain("explicit type"); - expect(error.message).toContain("SampleResolver"); + expect(error.message).toContain("SampleResolverWithError"); expect(error.message).toContain("independentField"); } }); @@ -1079,7 +1079,9 @@ describe("Resolvers", () => { }); describe("Functional", () => { + const classes: any = {}; let schema: GraphQLSchema; + let queryRoot: any; let queryContext: any; let queryInfo: any; @@ -1118,6 +1120,7 @@ describe("Resolvers", () => { return context; } + let mutationInputValue: any; beforeEach(() => { queryRoot = undefined; queryContext = undefined; @@ -1126,6 +1129,7 @@ describe("Resolvers", () => { querySecondCustom = undefined; descriptorEvaluated = false; sampleObjectConstructorCallCount = 0; + mutationInputValue = undefined; }); beforeAll(async () => { @@ -1146,6 +1150,7 @@ describe("Resolvers", () => { return this.TRUE; } } + classes.SampleArgs = SampleArgs; @InputType() class SampleInput { @@ -1159,6 +1164,29 @@ describe("Resolvers", () => { return this.TRUE; } } + classes.SampleInput = SampleInput; + + @InputType() + class SampleNestedInput { + instanceField = Math.random(); + + @Field() + nestedField: SampleInput; + + @Field(type => [SampleInput]) + nestedArrayField: SampleInput[]; + } + classes.SampleNestedInput = SampleNestedInput; + + @ArgsType() + class SampleNestedArgs { + @Field() + factor: number; + + @Field() + input: SampleInput; + } + classes.SampleNestedArgs = SampleNestedArgs; @ObjectType() class SampleObject { @@ -1213,7 +1241,8 @@ describe("Resolvers", () => { @Query() sampleQuery(): SampleObject { - return plainToClass(SampleObject, {}); + const obj = new SampleObject(); + return obj; } @Query() @@ -1277,6 +1306,24 @@ describe("Resolvers", () => { } } + @Mutation() + mutationWithNestedInputs(@Arg("input") input: SampleNestedInput): number { + mutationInputValue = input; + return input.instanceField; + } + + @Mutation() + mutationWithNestedArgsInput(@Args() { factor, input }: SampleNestedArgs): number { + mutationInputValue = input; + return factor; + } + + @Mutation() + mutationWithInputs(@Arg("inputs", type => [SampleInput]) inputs: SampleInput[]): number { + mutationInputValue = inputs[0]; + return inputs[0].factor; + } + @FieldResolver() fieldResolverField() { return this.randomValueField; @@ -1507,6 +1554,56 @@ describe("Resolvers", () => { expect(result).toBeLessThanOrEqual(10); }); + it("should create instances of nested input fields input objects", async () => { + const mutation = `mutation { + mutationWithNestedInputs(input: { + nestedField: { + factor: 20 + } + nestedArrayField: [{ + factor: 30 + }] + }) + }`; + + const mutationResult = await graphql(schema, mutation); + const result = mutationResult.data!.mutationWithNestedInputs; + + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(1); + expect(mutationInputValue).toBeInstanceOf(classes.SampleNestedInput); + expect(mutationInputValue.nestedField).toBeInstanceOf(classes.SampleInput); + expect(mutationInputValue.nestedArrayField[0]).toBeInstanceOf(classes.SampleInput); + }); + + it("should create instance of nested input field of args type object", async () => { + const mutation = `mutation { + mutationWithNestedArgsInput(factor: 20, input: { factor: 30 }) + }`; + + const mutationResult = await graphql(schema, mutation); + const result = mutationResult.data!.mutationWithNestedArgsInput; + + expect(result).toEqual(20); + expect(mutationInputValue).toBeInstanceOf(classes.SampleInput); + expect(mutationInputValue.instanceField).toBeGreaterThanOrEqual(0); + expect(mutationInputValue.instanceField).toBeLessThanOrEqual(1); + }); + + it("should create instance of inputs array from arg", async () => { + const mutation = `mutation { + mutationWithInputs(inputs: [{ factor: 30 }]) + }`; + + const mutationResult = await graphql(schema, mutation); + const result = mutationResult.data!.mutationWithInputs; + + expect(result).toEqual(30); + expect(mutationInputValue).toBeInstanceOf(classes.SampleInput); + expect(mutationInputValue.instanceField).toBeGreaterThanOrEqual(0); + expect(mutationInputValue.instanceField).toBeLessThanOrEqual(1); + }); + it("should create instance of root object when root type is provided", async () => { const query = `query { sampleQuery { From a56048d8d6cf2012b2934ab1873ca87056c10ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Sun, 10 Nov 2019 21:39:57 +0100 Subject: [PATCH 2/3] fix(inputs): transform and validate nested inputs and arrays --- src/helpers/types.ts | 4 + src/resolvers/convert-args.ts | 138 +++++++++++++++++++++++++ src/resolvers/helpers.ts | 5 +- src/resolvers/validate-arg.ts | 6 +- src/schema/schema-generator.ts | 2 +- tests/functional/resolvers.ts | 25 ++++- tests/functional/validation.ts | 182 ++++++++++++++++++++++++--------- 7 files changed, 311 insertions(+), 51 deletions(-) create mode 100644 src/resolvers/convert-args.ts diff --git a/src/helpers/types.ts b/src/helpers/types.ts index ba7608301..ea839e003 100644 --- a/src/helpers/types.ts +++ b/src/helpers/types.ts @@ -100,6 +100,10 @@ export function convertToType(Target: any, data?: object): object | undefined { if (data instanceof Target) { return data; } + // convert array to instances + if (Array.isArray(data)) { + return data.map(item => convertToType(Target, item)); + } return Object.assign(new Target(), data); } diff --git a/src/resolvers/convert-args.ts b/src/resolvers/convert-args.ts new file mode 100644 index 000000000..22bbbf0da --- /dev/null +++ b/src/resolvers/convert-args.ts @@ -0,0 +1,138 @@ +import { ArgParamMetadata, ClassMetadata, ArgsParamMetadata } from "../metadata/definitions"; +import { convertToType } from "../helpers/types"; +import { ArgsDictionary, ClassType } from "../interfaces"; +import { getMetadataStorage } from "../metadata/getMetadataStorage"; +import { TypeValue } from "../decorators/types"; + +interface TransformationTreeField { + name: string; + target: TypeValue; + fields?: TransformationTree; +} + +interface TransformationTree { + target: TypeValue; + getFields: () => TransformationTreeField[]; +} + +const generatedTrees = new Map(); + +function getInputType(target: TypeValue): ClassMetadata | undefined { + return getMetadataStorage().inputTypes.find(t => t.target === target); +} + +function getArgsType(target: TypeValue): ClassMetadata | undefined { + return getMetadataStorage().argumentTypes.find(t => t.target === target); +} + +function generateInstanceTransformationTree(target: TypeValue): TransformationTree | null { + if (generatedTrees.has(target)) { + return generatedTrees.get(target)!; + } + + const inputType = getInputType(target); + if (!inputType) { + generatedTrees.set(target, null); + return null; + } + + function generateTransformationTree(metadata: ClassMetadata): TransformationTree { + let inputFields = metadata.fields!; + let superClass = Object.getPrototypeOf(metadata.target); + while (superClass.prototype !== undefined) { + const superInputType = getInputType(superClass); + if (superInputType) { + inputFields = [...inputFields, ...superInputType.fields!]; + } + superClass = Object.getPrototypeOf(superClass); + } + + const transformationTree: TransformationTree = { + target: metadata.target, + getFields: () => + inputFields.map(field => { + const fieldTarget = field.getType(); + const fieldInputType = getInputType(fieldTarget); + return { + name: field.name, + target: fieldTarget, + fields: + fieldTarget === metadata.target + ? transformationTree + : fieldInputType && generateTransformationTree(fieldInputType), + }; + }), + }; + + return transformationTree; + } + + const generatedTransformationTree = generateTransformationTree(inputType); + generatedTrees.set(target, generatedTransformationTree); + return generatedTransformationTree; +} + +function convertToInput(tree: TransformationTree, data: any) { + const inputFields = tree.getFields().reduce>((fields, field) => { + const siblings = field.fields; + const value = data[field.name]; + if (!siblings || !value) { + fields[field.name] = convertToType(field.target, value); + } else if (Array.isArray(value)) { + fields[field.name] = value.map(itemValue => convertToInput(siblings, itemValue)); + } else { + fields[field.name] = convertToInput(siblings, value); + } + return fields; + }, {}); + + return convertToType(tree.target, inputFields); +} + +function convertValueToInstance(target: TypeValue, value: any) { + const transformationTree = generateInstanceTransformationTree(target); + return transformationTree + ? convertToInput(transformationTree, value) + : convertToType(target, value); +} + +function convertValuesToInstances(target: TypeValue, value: any) { + // skip converting undefined and null + if (value == null) { + return value; + } + if (Array.isArray(value)) { + return value.map(itemValue => convertValueToInstance(target, itemValue)); + } + return convertValueToInstance(target, value); +} + +export function convertArgsToInstance(argsMetadata: ArgsParamMetadata, args: ArgsDictionary) { + const ArgsClass = argsMetadata.getType() as ClassType; + const argsType = getArgsType(ArgsClass)!; + + let argsFields = argsType.fields!; + let superClass = Object.getPrototypeOf(argsType.target); + while (superClass.prototype !== undefined) { + const superArgumentType = getArgsType(superClass); + if (superArgumentType) { + argsFields = [...argsFields, ...superArgumentType.fields!]; + } + superClass = Object.getPrototypeOf(superClass); + } + + const transformedFields = argsFields.reduce>((fields, field) => { + const fieldValue = args[field.name]; + const fieldTarget = field.getType(); + fields[field.name] = convertValuesToInstances(fieldTarget, fieldValue); + return fields; + }, {}); + + return convertToType(ArgsClass, transformedFields); +} + +export function convertArgToInstance(argMetadata: ArgParamMetadata, args: ArgsDictionary) { + const argValue = args[argMetadata.name]; + const argTarget = argMetadata.getType(); + return convertValuesToInstances(argTarget, argValue); +} diff --git a/src/resolvers/helpers.ts b/src/resolvers/helpers.ts index c21eb9728..cf0f32b52 100644 --- a/src/resolvers/helpers.ts +++ b/src/resolvers/helpers.ts @@ -8,6 +8,7 @@ import { ResolverData, AuthChecker, AuthMode } from "../interfaces"; import { Middleware, MiddlewareFn, MiddlewareClass } from "../interfaces/Middleware"; import { IOCContainer } from "../utils/container"; import { AuthMiddleware } from "../helpers/auth-middleware"; +import { convertArgsToInstance, convertArgToInstance } from "./convert-args"; export async function getParams( params: ParamMetadata[], @@ -22,13 +23,13 @@ export async function getParams( switch (paramInfo.kind) { case "args": return await validateArg( - convertToType(paramInfo.getType(), resolverData.args), + convertArgsToInstance(paramInfo, resolverData.args), globalValidate, paramInfo.validate, ); case "arg": return await validateArg( - convertToType(paramInfo.getType(), resolverData.args[paramInfo.name]), + convertArgToInstance(paramInfo, resolverData.args), globalValidate, paramInfo.validate, ); diff --git a/src/resolvers/validate-arg.ts b/src/resolvers/validate-arg.ts index 6c5e532e2..f813d1bde 100644 --- a/src/resolvers/validate-arg.ts +++ b/src/resolvers/validate-arg.ts @@ -23,7 +23,11 @@ export async function validateArg( const { validateOrReject } = await import("class-validator"); try { - await validateOrReject(arg, validatorOptions); + if (Array.isArray(arg)) { + await Promise.all(arg.map(argItem => validateOrReject(argItem, validatorOptions))); + } else { + await validateOrReject(arg, validatorOptions); + } return arg; } catch (err) { throw new ArgumentValidationError(err); diff --git a/src/schema/schema-generator.ts b/src/schema/schema-generator.ts index 6c77b29db..d153c7890 100644 --- a/src/schema/schema-generator.ts +++ b/src/schema/schema-generator.ts @@ -554,7 +554,7 @@ export abstract class SchemaGenerator { while (superClass.prototype !== undefined) { const superArgumentType = getMetadataStorage().argumentTypes.find( it => it.target === superClass, - )!; + ); if (superArgumentType) { this.mapArgFields(superArgumentType, args); } diff --git a/tests/functional/resolvers.ts b/tests/functional/resolvers.ts index f01b90873..b7c0e8760 100644 --- a/tests/functional/resolvers.ts +++ b/tests/functional/resolvers.ts @@ -1173,6 +1173,9 @@ describe("Resolvers", () => { @Field() nestedField: SampleInput; + @Field({ nullable: true }) + optionalNestedField?: SampleInput; + @Field(type => [SampleInput]) nestedArrayField: SampleInput[]; } @@ -1324,6 +1327,14 @@ describe("Resolvers", () => { return inputs[0].factor; } + @Mutation() + mutationWithOptionalArg( + @Arg("input", { nullable: true }) input?: SampleNestedInput, + ): number { + mutationInputValue = typeof input; + return 0; + } + @FieldResolver() fieldResolverField() { return this.randomValueField; @@ -1554,7 +1565,7 @@ describe("Resolvers", () => { expect(result).toBeLessThanOrEqual(10); }); - it("should create instances of nested input fields input objects", async () => { + it("should create instances of nested input fields input objects without nulls", async () => { const mutation = `mutation { mutationWithNestedInputs(input: { nestedField: { @@ -1574,6 +1585,7 @@ describe("Resolvers", () => { expect(mutationInputValue).toBeInstanceOf(classes.SampleNestedInput); expect(mutationInputValue.nestedField).toBeInstanceOf(classes.SampleInput); expect(mutationInputValue.nestedArrayField[0]).toBeInstanceOf(classes.SampleInput); + expect(mutationInputValue.optionalNestedField).toBeUndefined(); }); it("should create instance of nested input field of args type object", async () => { @@ -1604,6 +1616,17 @@ describe("Resolvers", () => { expect(mutationInputValue.instanceField).toBeLessThanOrEqual(1); }); + it("shouldn't create instance of an argument if the value is null or not provided", async () => { + const mutation = `mutation { + mutationWithOptionalArg + }`; + + const { data, errors } = await graphql(schema, mutation); + expect(errors).toBeUndefined(); + expect(data.mutationWithOptionalArg).toBeDefined(); + expect(mutationInputValue).toEqual("undefined"); + }); + it("should create instance of root object when root type is provided", async () => { const query = `query { sampleQuery { diff --git a/tests/functional/validation.ts b/tests/functional/validation.ts index ebdf4ad63..6fee3c344 100644 --- a/tests/functional/validation.ts +++ b/tests/functional/validation.ts @@ -1,5 +1,5 @@ import "reflect-metadata"; -import { MaxLength, Max, Min } from "class-validator"; +import { MaxLength, Max, Min, ValidateNested } from "class-validator"; import { GraphQLSchema, graphql } from "graphql"; import { getMetadataStorage } from "../../src/metadata/getMetadataStorage"; @@ -51,6 +51,14 @@ describe("Validation", () => { @Field({ nullable: true }) @Min(5) optionalField?: number; + + @Field(type => SampleInput, { nullable: true }) + @ValidateNested() + nestedField?: SampleInput; + + @Field(type => [SampleInput], { nullable: true }) + @ValidateNested({ each: true }) + arrayField?: SampleInput[]; } @ArgsType() @@ -81,6 +89,14 @@ describe("Validation", () => { argInput = input; return {}; } + + @Mutation() + mutationWithInputsArray( + @Arg("inputs", type => [SampleInput]) inputs: SampleInput[], + ): SampleObject { + argInput = inputs; + return {}; + } } sampleResolver = SampleResolver; @@ -92,13 +108,13 @@ describe("Validation", () => { it("should pass input validation when data without optional field is correct", async () => { const mutation = `mutation { - sampleMutation(input: { - stringField: "12345", - numberField: 5, - }) { - field - } - }`; + sampleMutation(input: { + stringField: "12345", + numberField: 5, + }) { + field + } + }`; await graphql(schema, mutation); expect(argInput).toEqual({ stringField: "12345", numberField: 5 }); @@ -106,14 +122,14 @@ describe("Validation", () => { it("should pass input validation when data with optional field is correct", async () => { const mutation = `mutation { - sampleMutation(input: { - stringField: "12345", - numberField: 5, - optionalField: 5, - }) { - field - } - }`; + sampleMutation(input: { + stringField: "12345", + numberField: 5, + optionalField: 5, + }) { + field + } + }`; await graphql(schema, mutation); expect(argInput).toEqual({ stringField: "12345", numberField: 5, optionalField: 5 }); @@ -121,13 +137,87 @@ describe("Validation", () => { it("should throw validation error when input is incorrect", async () => { const mutation = `mutation { - sampleMutation(input: { - stringField: "12345", - numberField: 15, - }) { - field - } - }`; + sampleMutation(input: { + stringField: "12345", + numberField: 15, + }) { + field + } + }`; + + const result = await graphql(schema, mutation); + expect(result.data).toBeNull(); + expect(result.errors).toHaveLength(1); + + const validationError = result.errors![0].originalError! as ArgumentValidationError; + expect(validationError).toBeInstanceOf(ArgumentValidationError); + expect(validationError.validationErrors).toHaveLength(1); + expect(validationError.validationErrors[0].property).toEqual("numberField"); + }); + + it("should throw validation error when nested input field is incorrect", async () => { + const mutation = `mutation { + sampleMutation(input: { + stringField: "12345", + numberField: 5, + nestedField: { + stringField: "12345", + numberField: 15, + } + }) { + field + } + }`; + + const result = await graphql(schema, mutation); + expect(result.data).toBeNull(); + expect(result.errors).toHaveLength(1); + + const validationError = result.errors![0].originalError! as ArgumentValidationError; + expect(validationError).toBeInstanceOf(ArgumentValidationError); + expect(validationError.validationErrors).toHaveLength(1); + expect(validationError.validationErrors[0].property).toEqual("nestedField"); + }); + + it("should throw validation error when nested array input field is incorrect", async () => { + const mutation = `mutation { + sampleMutation(input: { + stringField: "12345", + numberField: 5, + arrayField: [{ + stringField: "12345", + numberField: 15, + }] + }) { + field + } + }`; + + const result = await graphql(schema, mutation); + expect(result.data).toBeNull(); + expect(result.errors).toHaveLength(1); + + const validationError = result.errors![0].originalError! as ArgumentValidationError; + expect(validationError).toBeInstanceOf(ArgumentValidationError); + expect(validationError.validationErrors).toHaveLength(1); + expect(validationError.validationErrors[0].property).toEqual("arrayField"); + }); + + it("should throw validation error when one of input array is incorrect", async () => { + const mutation = `mutation { + mutationWithInputsArray(inputs: [ + { + stringField: "12345", + numberField: 5, + }, + { + stringField: "12345", + numberField: 15, + }, + ]) { + field + } + }`; const result = await graphql(schema, mutation); expect(result.data).toBeNull(); @@ -141,14 +231,14 @@ describe("Validation", () => { it("should throw validation error when optional input field is incorrect", async () => { const mutation = `mutation { - sampleMutation(input: { - stringField: "12345", - numberField: 5, - optionalField: -5, - }) { - field - } - }`; + sampleMutation(input: { + stringField: "12345", + numberField: 5, + optionalField: -5, + }) { + field + } + }`; const result = await graphql(schema, mutation); expect(result.data).toBeNull(); @@ -191,13 +281,13 @@ describe("Validation", () => { it("should throw validation error when one of arguments is incorrect", async () => { const query = `query { - sampleQuery( - stringField: "12345", - numberField: 15, - ) { - field - } - }`; + sampleQuery( + stringField: "12345", + numberField: 15, + ) { + field + } + }`; const result = await graphql(schema, query); expect(result.data).toBeNull(); @@ -211,14 +301,14 @@ describe("Validation", () => { it("should throw validation error when optional argument is incorrect", async () => { const query = `query { - sampleQuery( - stringField: "12345", - numberField: 5, - optionalField: -5, - ) { - field - } - }`; + sampleQuery( + stringField: "12345", + numberField: 5, + optionalField: -5, + ) { + field + } + }`; const result = await graphql(schema, query); expect(result.data).toBeNull(); From 430918d44f80c4aae5a03fcfd25bef2b31a02c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Sun, 10 Nov 2019 21:48:17 +0100 Subject: [PATCH 3/3] docs(inputs): add info about validating nested inputs --- CHANGELOG.md | 1 + docs/validation.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 854d24315..eaf8a71ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - add basic support for directives with `@Directive()` decorator (#369) ### Fixes - refactor union types function syntax handling to prevent possible errors with circular refs +- fix transforming and validating nested inputs and arrays (#462) ## v0.17.5 ### Features diff --git a/docs/validation.md b/docs/validation.md index 0f878ff11..dff6f9cb9 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -98,6 +98,8 @@ Note that by default, the `skipMissingProperties` setting of the `class-validato GraphQL will also check whether the fields have correct types (String, Int, Float, Boolean, etc.) so we don't have to use the `@IsOptional`, `@Allow`, `@IsString` or the `@IsInt` decorators at all! +However, when using nested input or arrays, we always have to use [`@ValidateNested()` decorator](https://github.com/typestack/class-validator#validating-nested-objects) or [`{ each: true }` option](https://github.com/typestack/class-validator#validating-arrays) to make nested validation work properly. + ## Response to the Client When a client sends incorrect data to the server: