Skip to content

Commit

Permalink
add field method in ValidateTransformer class, add checks when pa…
Browse files Browse the repository at this point in the history
…rsing validate directive
  • Loading branch information
bobbyu99 committed Jan 21, 2025
1 parent bc6e88b commit 9656730
Show file tree
Hide file tree
Showing 7 changed files with 315 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const definition = /* GraphQL */ `
type: ValidationType!
value: String!
errorMessage: String
) on FIELD_DEFINITION
) repeatable on FIELD_DEFINITION
enum ValidationType {
gt
Expand Down
15 changes: 13 additions & 2 deletions packages/amplify-graphql-validate-transformer/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,25 @@
```ts

import { DirectiveNode } from 'graphql';
import { FieldDefinitionNode } from 'graphql';
import { InterfaceTypeDefinitionNode } from 'graphql';
import { ObjectTypeDefinitionNode } from 'graphql';
import { TransformerContextProvider } from '@aws-amplify/graphql-transformer-interfaces';
import { TransformerPluginBase } from '@aws-amplify/graphql-transformer-core';
import { TransformerPluginProvider } from '@aws-amplify/graphql-transformer-interfaces';
import { TransformerSchemaVisitStepContextProvider } from '@aws-amplify/graphql-transformer-interfaces';
import { TransformerValidationStepContextProvider } from '@aws-amplify/graphql-transformer-interfaces';

// @public (undocumented)
export class ValidateTransformer extends TransformerPluginBase {
export class ValidateTransformer extends TransformerPluginBase implements TransformerPluginProvider {
constructor();
// (undocumented)
generateResolvers: (ctx: TransformerContextProvider) => void;
field: (parent: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, definition: FieldDefinitionNode, directive: DirectiveNode, _: TransformerSchemaVisitStepContextProvider) => void;
// (undocumented)
generateResolvers: (_: TransformerContextProvider) => void;
// (undocumented)
validate: (_: TransformerValidationStepContextProvider) => void;
}

// (No @packageDocumentation comment for this package)
Expand Down
7 changes: 0 additions & 7 deletions packages/amplify-graphql-validate-transformer/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,4 @@ const baseConfig = require('../../jest.config.base.js'); // eslint-disable-line

module.exports = {
...baseConfig,
coverageThreshold: {
global: {
branches: 100,
lines: 100,
functions: 100,
},
},
};
4 changes: 4 additions & 0 deletions packages/amplify-graphql-validate-transformer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,9 @@
"@aws-amplify/graphql-transformer-interfaces": "4.2.1",
"graphql": "^15.5.0",
"graphql-transformer-common": "5.1.2"
},
"devDependencies": {
"@aws-amplify/graphql-transformer-test-utils": "1.0.11",
"@aws-amplify/graphql-model-transformer": "3.1.4"
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,157 @@
import { TransformerContextProvider } from '@aws-amplify/graphql-transformer-interfaces';
import { testTransform } from '@aws-amplify/graphql-transformer-test-utils';
import { ModelTransformer } from '@aws-amplify/graphql-model-transformer';
import { ValidateTransformer } from '..';

test('amplify-graphql-validate-transformer', () => {
const transformer = new ValidateTransformer();
transformer.generateResolvers({} as TransformerContextProvider);
expect(transformer).toBeDefined();
describe('ValidateTransformer', () => {
/* ================================ */
/* Valid schema tests */
/* ================================ */
it('allows valid validation directives', () => {
const validSchema = /* GraphQL */ `
type Post @model {
id: ID!
title: String!
@validate(type: minLength, value: "3")
@validate(type: maxLength, value: "10")
rating: Float!
@validate(type: gt, value: "0")
@validate(type: lt, value: "6")
tags: [String]! @validate(type: maxLength, value: "20")
}
`;

const transformer = new ValidateTransformer();
expect(() => {
testTransform({
schema: validSchema,
transformers: [new ModelTransformer(), transformer],
});
}).not.toThrow();
});

/* ================================ */
/* Invalid directive usage tests */
/* ================================ */
it('throws error if duplicate validation type is used on the same field', () => {
const invalidSchema = /* GraphQL */ `
type Post @model {
id: ID!
title: String!
@validate(type: minLength, value: "5")
@validate(type: minLength, value: "10")
}
`;

const transformer = new ValidateTransformer();
expect(() => {
testTransform({
schema: invalidSchema,
transformers: [new ModelTransformer(), transformer],
});
}).toThrow(
"Duplicate @validate directive with type 'minLength' on field 'title'. Each validation type can only be used once per field.",
);
});

it('throws error if numeric validation is used on non-numeric field', () => {
const invalidSchema = /* GraphQL */ `
type Post @model {
id: ID!
title: String! @validate(type: gt, value: "5")
}
`;

const transformer = new ValidateTransformer();
expect(() => {
testTransform({
schema: invalidSchema,
transformers: [new ModelTransformer(), transformer],
});
}).toThrow("Validation type 'gt' can only be used with numeric fields (Int, Float). Field 'title' is of type 'String'");
});

it('throws error if string validation is used on non-string field', () => {
const invalidSchema = /* GraphQL */ `
type Post @model {
id: ID!
count: Int! @validate(type: minLength, value: "5")
}
`;

const transformer = new ValidateTransformer();
expect(() => {
testTransform({
schema: invalidSchema,
transformers: [new ModelTransformer(), transformer],
});
}).toThrow("Validation type 'minLength' can only be used with String fields. Field 'count' is of type 'Int'");
});

// it('throws error if minLength value is not a positive integer', () => {
// const invalidSchema = /* GraphQL */ `
// type Post @model {
// id: ID!
// title: String! @validate(type: minLength, value: "0")
// }
// `;

// const transformer = new ValidateTransformer();
// expect(() => {
// testTransform({
// schema: invalidSchema,
// transformers: [new ModelTransformer(), transformer],
// });
// }).toThrow("minLength value must be a positive integer. Received '0' for field 'title'");
// });

it('throws error if maxLength value is a negative integer', () => {
const invalidSchema = /* GraphQL */ `
type Post @model {
id: ID!
title: String! @validate(type: maxLength, value: "-5")
}
`;

const transformer = new ValidateTransformer();
expect(() => {
testTransform({
schema: invalidSchema,
transformers: [new ModelTransformer(), transformer],
});
}).toThrow("maxLength value must be a positive integer. Received '-5' for field 'title'");
});

it('throws error if length validation value is not a number', () => {
const invalidSchema = /* GraphQL */ `
type Post @model {
id: ID!
title: String! @validate(type: minLength, value: "abc")
}
`;

const transformer = new ValidateTransformer();
expect(() => {
testTransform({
schema: invalidSchema,
transformers: [new ModelTransformer(), transformer],
});
}).toThrow("minLength value must be a positive integer. Received 'abc' for field 'title'");
});

it('throws error if length validation value is a decimal', () => {
const invalidSchema = /* GraphQL */ `
type Post @model {
id: ID!
title: String! @validate(type: maxLength, value: "5.5")
}
`;

const transformer = new ValidateTransformer();
expect(() => {
testTransform({
schema: invalidSchema,
transformers: [new ModelTransformer(), transformer],
});
}).toThrow("maxLength value must be a positive integer. Received '5.5' for field 'title'");
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,129 @@
import { ValidateDirective } from '@aws-amplify/graphql-directives';
import { TransformerPluginBase } from '@aws-amplify/graphql-transformer-core';
import { TransformerContextProvider } from '@aws-amplify/graphql-transformer-interfaces';
import { TransformerPluginBase, DirectiveWrapper, InvalidDirectiveError } from '@aws-amplify/graphql-transformer-core';
import {
TransformerContextProvider,
TransformerSchemaVisitStepContextProvider,
TransformerPluginProvider,
} from '@aws-amplify/graphql-transformer-interfaces';
import {
ArgumentNode,
DirectiveNode,
FieldDefinitionNode,
InterfaceTypeDefinitionNode,
ObjectTypeDefinitionNode,
StringValueNode,
} from 'graphql';
import { getBaseType } from 'graphql-transformer-common';
import { NUMERIC_VALIDATION_TYPES, STRING_VALIDATION_TYPES, ValidateArguments, ValidateDirectiveConfiguration } from './types';

/**
* Validates that length validation values (minLength, maxLength) are valid positive integers.
*/
const validateLengthValue = (config: ValidateDirectiveConfiguration): void => {
if (config.type !== 'minLength' && config.type !== 'maxLength') {
return;
}

const value = parseFloat(config.value);
if (isNaN(value) || !Number.isInteger(value) || value <= 0) {
// TODO: decide if we want to allow 0
throw new InvalidDirectiveError(
`${config.type} value must be a positive integer. Received '${config.value}' for field '${config.field.name.value}'`,
);
}
};

/**
* Validates that the validation type is compatible with the field type.
*/
const validateTypeCompatibility = (field: FieldDefinitionNode, validationType: string): void => {
const baseTypeName = getBaseType(field.type);
const isNumericValidation = NUMERIC_VALIDATION_TYPES.includes(validationType as any);
const isStringValidation = STRING_VALIDATION_TYPES.includes(validationType as any);

if (isNumericValidation && baseTypeName !== 'Int' && baseTypeName !== 'Float') {
throw new InvalidDirectiveError(
`Validation type '${validationType}' can only be used with numeric fields (Int, Float). Field '${field.name.value}' is of type '${baseTypeName}'`,
);
}

if (isStringValidation && baseTypeName !== 'String') {
throw new InvalidDirectiveError(
`Validation type '${validationType}' can only be used with String fields. Field '${field.name.value}' is of type '${baseTypeName}'`,
);
}
};

/**
* Validates that there are no duplicate validation types on the same field.
*/
const validateNoDuplicateTypes = (field: FieldDefinitionNode, currentDirective: DirectiveNode, currentType: string): void => {
for (const peerDirective of field.directives!) {
if (peerDirective === currentDirective) {
continue;
}

if (peerDirective.name.value === 'validate') {
const peerType = peerDirective.arguments?.find((arg: ArgumentNode) => arg.name.value === 'type')?.value as StringValueNode;
if (peerType?.value === currentType) {
throw new InvalidDirectiveError(
`Duplicate @validate directive with type '${currentType}' on field '${field.name.value}'. Each validation type can only be used once per field.`,
);
}
}
}
};

export class ValidateTransformer extends TransformerPluginBase implements TransformerPluginProvider {
private directiveMap = new Map<string, ValidateDirectiveConfiguration[]>();

export class ValidateTransformer extends TransformerPluginBase {
constructor() {
super('amplify-graphql-validate-transformer', ValidateDirective.definition);
}

generateResolvers = (ctx: TransformerContextProvider): void => {
console.log('generateResolvers', ctx);
field = (
parent: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode,
definition: FieldDefinitionNode,
directive: DirectiveNode,
_: TransformerSchemaVisitStepContextProvider,
): void => {
const directiveWrapped = new DirectiveWrapper(directive);
const config = this.getValidateDirectiveConfiguration(directiveWrapped, parent as ObjectTypeDefinitionNode, definition);

validateTypeCompatibility(definition, config.type);
validateNoDuplicateTypes(definition, directive, config.type);
validateLengthValue(config);

if (!this.directiveMap.has(parent.name.value)) {
this.directiveMap.set(parent.name.value, []);
}
this.directiveMap.get(parent.name.value)!.push(config);
};

generateResolvers = (_: TransformerContextProvider): void => {
// TODO:
// 1. Generate validation checks in the resolver based on field type
// 2. Return appropriate error messages for validation failures
};

private getValidateDirectiveConfiguration(
directive: DirectiveWrapper,
object: ObjectTypeDefinitionNode,
field: FieldDefinitionNode,
): ValidateDirectiveConfiguration {
const defaultArgs: ValidateArguments = {
type: '',
value: '',
errorMessage: '',
};
const args = directive.getArguments<ValidateArguments>(defaultArgs);

return {
object,
field,
type: args.type,
value: args.value,
errorMessage: args.errorMessage,
};
}
}
22 changes: 22 additions & 0 deletions packages/amplify-graphql-validate-transformer/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ObjectTypeDefinitionNode, FieldDefinitionNode } from 'graphql';

// Validation type groups
export const NUMERIC_VALIDATION_TYPES = ['gt', 'lt', 'gte', 'lte'] as const;
export const STRING_VALIDATION_TYPES = ['minLength', 'maxLength', 'startsWith', 'endsWith', 'matches'] as const;

// All supported validation types
export const VALIDATION_TYPES = [...NUMERIC_VALIDATION_TYPES, ...STRING_VALIDATION_TYPES] as const;
export type ValidationType = (typeof VALIDATION_TYPES)[number];

// Interface for directive arguments
export interface ValidateArguments {
type: ValidationType | '';
value: string;
errorMessage: string;
}

// Interface to store validate directive configurations
export interface ValidateDirectiveConfiguration extends ValidateArguments {
object: ObjectTypeDefinitionNode;
field: FieldDefinitionNode;
}

0 comments on commit 9656730

Please sign in to comment.