Skip to content

Commit

Permalink
Merge pull request #462 from MichalLytek/nested-inputs
Browse files Browse the repository at this point in the history
Fix transforming and validating nested inputs and arrays
  • Loading branch information
MichalLytek authored Nov 21, 2019
2 parents 20c81f4 + 430918d commit 7e8b5c2
Show file tree
Hide file tree
Showing 9 changed files with 425 additions and 65 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
138 changes: 138 additions & 0 deletions src/resolvers/convert-args.ts
Original file line number Diff line number Diff line change
@@ -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<TypeValue, TransformationTree | null>();

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<TransformationTreeField>(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<Record<string, any>>((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<Record<string, any>>((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);
}
5 changes: 3 additions & 2 deletions src/resolvers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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,
);
Expand Down
6 changes: 5 additions & 1 deletion src/resolvers/validate-arg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ export async function validateArg<T extends Object>(

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);
Expand Down
2 changes: 1 addition & 1 deletion src/schema/schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading

0 comments on commit 7e8b5c2

Please sign in to comment.