diff --git a/README.md b/README.md index b862e4f..df00921 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ type Query { emailAddr: String @constraint(format: "email") otherEmailAddr: String @constraint(format: "email", differsFrom: "emailAddr") age: Int @constraint(min: 18) + bio: String @constraint(OR: [{contains: "foo"}, {contains: "bar"}]) ): User } ``` @@ -54,6 +55,9 @@ You may need to declare the directive in the schema: ```graphql directive @constraint( + OR: [ConstraintInput!], + NOT: [ConstraintInput!], + AND: [ConstraintInput!], minLength: Int maxLength: Int startsWith: String @@ -69,6 +73,26 @@ directive @constraint( exclusiveMax: Float notEqual: Float ) on ARGUMENT_DEFINITION + +input ConstraintInput { + OR: [ConstraintInput!] + NOT: [ConstraintInput!] + AND: [ConstraintInput!] + minLength: Int + maxLength: Int + startsWith: String + endsWith: String + contains: String + notContains: String + pattern: String + format: String + differsFrom: String + min: Float + max: Float + exclusiveMin: Float + exclusiveMax: Float + notEqual: Float +} ``` ## API diff --git a/package.json b/package.json index 3d03d7b..ffd30af 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "git-cred": "git config credential.helper store", "lint": "eslint .", "test": "jest", + "test:debug": "node --inspect-brk node_modules/.bin/jest", "release": "standard-version", "release:push": "git push --follow-tags origin master", "release:npm": "yarn publish" diff --git a/src/index.js b/src/index.js index 8ae55d7..193348f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,12 @@ +// @ts-check +/** + * Support for code assist and type checing in vscode + * @typedef {import("graphql").GraphQLInterfaceType} GraphQLInterfaceType + * @typedef {import("graphql").GraphQLObjectType} GraphQLObjectType + * @typedef {import("graphql").GraphQLField} GraphQLField + * @typedef {import("graphql").GraphQLArgument} GraphQLArgument + */ + const { mapObjIndexed, compose, map, filter, values } = require('./utils') const { SchemaDirectiveVisitor } = require('graphql-tools') const { @@ -12,52 +21,76 @@ const { GraphQLFloat, GraphQLString, GraphQLSchema, + GraphQLInputObjectType, + GraphQLList, + GraphQLNonNull, printSchema } = require('graphql') const prepareConstraintDirective = (validationCallback, errorMessageCallback) => - class extends SchemaDirectiveVisitor { + class ConstraintDirectiveVisitor extends SchemaDirectiveVisitor { /** * When using e.g. graphql-yoga, we need to include schema of this directive * into our SDL, otherwise the graphql schema validator would report errors. */ static getSDL () { - const constraintDirective = this.getDirectiveDeclaration('constraint') + const thisDirective = this.getDirectiveDeclaration('constraint', null) const schema = new GraphQLSchema({ - directives: [constraintDirective] + query: undefined, + directives: [thisDirective] }) return printSchema(schema) } + /** + * @param {string} directiveName + * @param {GraphQLSchema} schema + */ static getDirectiveDeclaration (directiveName, schema) { + const constraintInput = new GraphQLNonNull(new GraphQLInputObjectType({ + name: 'ConstraintInput', + fields: () => ({ + ...args + }) + })) + + const args = { + /* Logical combinators */ + OR: { type: new GraphQLList(constraintInput) }, + NOT: { type: new GraphQLList(constraintInput) }, + AND: { type: new GraphQLList(constraintInput) }, + + /* Strings */ + minLength: { type: GraphQLInt }, + maxLength: { type: GraphQLInt }, + startsWith: { type: GraphQLString }, + endsWith: { type: GraphQLString }, + contains: { type: GraphQLString }, + notContains: { type: GraphQLString }, + pattern: { type: GraphQLString }, + format: { type: GraphQLString }, + differsFrom: { type: GraphQLString }, + + /* Numbers (Int/Float) */ + min: { type: GraphQLFloat }, + max: { type: GraphQLFloat }, + exclusiveMin: { type: GraphQLFloat }, + exclusiveMax: { type: GraphQLFloat }, + notEqual: { type: GraphQLFloat } + } + return new GraphQLDirective({ name: directiveName, locations: [DirectiveLocation.ARGUMENT_DEFINITION], args: { - /* Strings */ - minLength: { type: GraphQLInt }, - maxLength: { type: GraphQLInt }, - startsWith: { type: GraphQLString }, - endsWith: { type: GraphQLString }, - contains: { type: GraphQLString }, - notContains: { type: GraphQLString }, - pattern: { type: GraphQLString }, - format: { type: GraphQLString }, - differsFrom: { type: GraphQLString }, - - /* Numbers (Int/Float) */ - min: { type: GraphQLFloat }, - max: { type: GraphQLFloat }, - exclusiveMin: { type: GraphQLFloat }, - exclusiveMax: { type: GraphQLFloat }, - notEqual: { type: GraphQLFloat } + ...args } }) } /** * @param {GraphQLArgument} argument - * @param {{field:GraphQLField, objectType:GraphQLObjectType | GraphQLInterfaceType}} details + * @param {{field:GraphQLField, objectType:GraphQLObjectType | GraphQLInterfaceType}} details */ visitArgumentDefinition (argument, details) { // preparing the resolver @@ -76,6 +109,7 @@ const prepareConstraintDirective = (validationCallback, errorMessageCallback) => ) ) + // validation starts here and errors are collected const errors = validate(this.args) if (errors && errors.length > 0) throw new Error(errors) diff --git a/src/utils.js b/src/utils.js index 9085978..6d6f224 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,13 +1,25 @@ -// api same as ramda +// @ts-check + +/** api same as ramda */ const map = fn => list => list.map(fn) + +/** api same as ramda */ const filter = fn => list => list.filter(fn) + +/** api same as ramda */ const values = obj => Object.keys(obj).map(key => obj[key]) + +/** api same as ramda */ const length = strOrArray => (strOrArray != null ? strOrArray.length : 0) + +/** api same as ramda */ const isString = x => x != null && x.constructor === String +/** api same as ramda */ const compose = (...fnlist) => data => [...fnlist, data].reduceRight((prev, fn) => fn(prev)) +/** api same as ramda */ const mapObjIndexed = fn => obj => { const acc = {} Object.keys(obj).forEach(key => (acc[key] = fn(obj[key], key, obj))) diff --git a/src/validators.js b/src/validators.js index 2f97e62..dba4ba7 100644 --- a/src/validators.js +++ b/src/validators.js @@ -52,12 +52,20 @@ const numericValidators = { notEqual: neq => x => x !== neq } +// TODO: implement it +const logicalValidators = { + // OR: , + // AND: , + // NOT: +} + const defaultErrorMessageCallback = ({ argName, cName, cVal, data }) => `Constraint '${cName}:${cVal}' violated in field '${argName}'` const defaultValidators = { ...formatValidator(format2fun), ...numericValidators, + ...logicalValidators, ...stringValidators } @@ -73,6 +81,7 @@ module.exports = { createValidationCallback, stringValidators, numericValidators, + logicalValidators, formatValidator, format2fun } diff --git a/test/class.test.js b/test/class.test.js new file mode 100644 index 0000000..2e7a7e2 --- /dev/null +++ b/test/class.test.js @@ -0,0 +1,46 @@ +// @ts-check + +const { makeExecutableSchema } = require('graphql-tools') +const { GraphQLSchema } = require('graphql') +const { constraint } = require('../src/index') + +describe('constraint directive class', () => { + it('should provide its own graphql SDL', () => { + const sdl = constraint.getSDL() + expect(sdl).toMatch('directive @constraint') + }) + + it('should work when used properly in other graphql schema', () => { + const withOtherSchema = ` + ${constraint.getSDL()} + type Mutation { + signup( + name: String @constraint(maxLength:20) + ): Boolean + } + ` + const schema = makeExecutableSchema({ + typeDefs: withOtherSchema, + schemaDirectives: { constraint } + }) + + expect(schema).toBeInstanceOf(GraphQLSchema) + }) + + it('should NOT work when using unknown parameter', () => { + const withOtherSchema = ` + ${constraint.getSDL()} + type Mutation { + signup( + name: String @constraint(DUMMY:123) + ): Boolean + } + ` + expect(() => + makeExecutableSchema({ + typeDefs: withOtherSchema, + schemaDirectives: { constraint } + }) + ).toThrowError('Unknown argument "DUMMY" on directive "@constraint"') + }) +}) diff --git a/test/test.js b/test/test.js deleted file mode 100644 index 565ff27..0000000 --- a/test/test.js +++ /dev/null @@ -1,146 +0,0 @@ -const { makeExecutableSchema } = require('graphql-tools') -const { GraphQLSchema } = require('graphql') - -const { constraint, prepareConstraintDirective } = require('../src/index') -const { - format2fun, - defaultValidators, - defaultValidationCallback, - defaultErrorMessageCallback -} = require('../src/validators') - -describe('constraint directive usage', () => { - it('customization of messages', async () => { - // this is just an example showing that it works, it can be implemented better - const customizedMessageCallback = input => { - if (input.argName === 'primaryEmail') return 'Wrong primary email' - else return defaultErrorMessageCallback(input) - } - - const MyConstraintClass = prepareConstraintDirective( - defaultValidationCallback, - customizedMessageCallback - ) - - const cInst = new MyConstraintClass({ args: { format: 'email' } }) - - const details = { field: {} } - - cInst.visitArgumentDefinition({ name: 'primaryEmail' }, details) - await expect( - details.field.resolve(null, { primaryEmail: 'some_wrong_email' }) - ).rejects.toEqual(Error([`Wrong primary email`])) - - cInst.visitArgumentDefinition({ name: 'secondaryEmail' }, details) - await expect( - details.field.resolve(null, { secondaryEmail: 'some_wrong_email' }) - ).rejects.toEqual( - Error([`Constraint 'format:email' violated in field 'secondaryEmail'`]) - ) - }) - - it('constraint violations should be reported', async () => { - // eslint-disable-next-line new-cap - const cInst = new constraint({ - args: { - maxLength: 5, - format: 'email', - minLength: 100 - } - }) - - const details = { field: {} } - cInst.visitArgumentDefinition({ name: 'primaryEmail' }, details) - - await expect( - details.field.resolve(null, { primaryEmail: 'test@test.com' }) - ).rejects.toEqual( - Error([ - `Constraint 'maxLength:5' violated in field 'primaryEmail'`, - `Constraint 'minLength:100' violated in field 'primaryEmail'` - ]) - ) - }) - - describe('format2fun', () => { - it('email', () => { - expect(format2fun.email('test@test.com')).toEqual(true) - expect(format2fun.email('testtest.com')).toEqual(false) - }) - }) - - describe('defaultValidators', () => { - it('contains', () => { - const { contains } = defaultValidators - expect(contains('@')('test@test.com')).toEqual(true) - expect(contains('@')('testtest.com')).toEqual(false) - }) - it('startsWith', () => { - const { startsWith } = defaultValidators - expect(startsWith('b')('ab')).toEqual(false) - expect(startsWith('a')('ab')).toEqual(true) - expect(startsWith('')('')).toEqual(true) - expect(startsWith('')('a')).toEqual(true) - expect(startsWith('a')('')).toEqual(false) - }) - - it('endsWith', () => { - const { endsWith } = defaultValidators - expect(endsWith('b')('ab')).toEqual(true) - expect(endsWith('a')('ab')).toEqual(false) - expect(endsWith('')('')).toEqual(true) - expect(endsWith('')('a')).toEqual(true) - expect(endsWith('a')('')).toEqual(false) - }) - - it('minLength', () => { - const { minLength } = defaultValidators - expect(minLength(10)('ab')).toEqual(false) - expect(minLength(2)('ab')).toEqual(true) - expect(minLength(0)('ab')).toEqual(true) - expect(minLength(0)('')).toEqual(true) - expect(minLength(1)('')).toEqual(false) - }) - }) -}) - -describe('constraint directive class', () => { - it('should provide its own graphql SDL', () => { - const sdl = constraint.getSDL() - expect(sdl).toMatch('directive @constraint') - }) - - it('should work when used properly in other graphql schema', () => { - const withOtherSchema = ` - ${constraint.getSDL()} - type Mutation { - signup( - name: String @constraint(maxLength:20) - ): Boolean - } - ` - const schema = makeExecutableSchema({ - typeDefs: withOtherSchema, - schemaDirectives: { constraint } - }) - - expect(schema).toBeInstanceOf(GraphQLSchema) - }) - - it('should NOT work when using unknown parameter', () => { - const withOtherSchema = ` - ${constraint.getSDL()} - type Mutation { - signup( - name: String @constraint(DUMMY:123) - ): Boolean - } - ` - expect(() => - makeExecutableSchema({ - typeDefs: withOtherSchema, - schemaDirectives: { constraint } - }) - ).toThrowError('Unknown argument "DUMMY" on directive "@constraint"') - }) -}) diff --git a/test/usage.test.js b/test/usage.test.js new file mode 100644 index 0000000..f93d8b3 --- /dev/null +++ b/test/usage.test.js @@ -0,0 +1,127 @@ +const { constraint, prepareConstraintDirective } = require('../src/index') +const { + format2fun, + defaultValidators, + defaultValidationCallback, + defaultErrorMessageCallback +} = require('../src/validators') + +describe('constraint directive usage', () => { + it('customization of messages', async () => { + // this is just an example showing that it works, it can be implemented better + const customizedMessageCallback = input => { + if (input.argName === 'primaryEmail') return 'Wrong primary email' + else return defaultErrorMessageCallback(input) + } + + const MyConstraintClass = prepareConstraintDirective( + defaultValidationCallback, + customizedMessageCallback + ) + + const cInst = new MyConstraintClass({ args: { format: 'email' } }) + + const details = { field: {} } + + cInst.visitArgumentDefinition({ name: 'primaryEmail' }, details) + await expect( + details.field.resolve(null, { primaryEmail: 'some_wrong_email' }) + ).rejects.toEqual(Error([`Wrong primary email`])) + + cInst.visitArgumentDefinition({ name: 'secondaryEmail' }, details) + await expect( + details.field.resolve(null, { secondaryEmail: 'some_wrong_email' }) + ).rejects.toEqual( + Error([`Constraint 'format:email' violated in field 'secondaryEmail'`]) + ) + }) + + it('constraint violations should be reported', async () => { + // eslint-disable-next-line new-cap + const cInst = new constraint({ + args: { + maxLength: 5, + format: 'email', + minLength: 100 + } + }) + + const details = { field: {} } + cInst.visitArgumentDefinition({ name: 'primaryEmail' }, details) + + await expect( + details.field.resolve(null, { primaryEmail: 'test@test.com' }) + ).rejects.toEqual( + Error([ + `Constraint 'maxLength:5' violated in field 'primaryEmail'`, + `Constraint 'minLength:100' violated in field 'primaryEmail'` + ]) + ) + }) +}) + +describe('nested logical operators', () => { + it('TODO', async () => { + // eslint-disable-next-line new-cap + const cInst = new constraint({ + args: { + OR: [ + { contains: "foo" }, + { contains: "bar" } + ] + } + }) + + const details = { field: {} } + cInst.visitArgumentDefinition({ name: 'primaryEmail' }, details) + await expect( + details.field.resolve(null, { primaryEmail: 'test@test.com' }) + ).rejects.toEqual( + Error([ + `Constraint 'maxLength:5' violated in field 'primaryEmail'`, + `Constraint 'minLength:100' violated in field 'primaryEmail'` + ]) + ) + }) +}) + +describe('format2fun', () => { + it('email', () => { + expect(format2fun.email('test@test.com')).toEqual(true) + expect(format2fun.email('testtest.com')).toEqual(false) + }) +}) + +describe('defaultValidators', () => { + it('contains', () => { + const { contains } = defaultValidators + expect(contains('@')('test@test.com')).toEqual(true) + expect(contains('@')('testtest.com')).toEqual(false) + }) + it('startsWith', () => { + const { startsWith } = defaultValidators + expect(startsWith('b')('ab')).toEqual(false) + expect(startsWith('a')('ab')).toEqual(true) + expect(startsWith('')('')).toEqual(true) + expect(startsWith('')('a')).toEqual(true) + expect(startsWith('a')('')).toEqual(false) + }) + + it('endsWith', () => { + const { endsWith } = defaultValidators + expect(endsWith('b')('ab')).toEqual(true) + expect(endsWith('a')('ab')).toEqual(false) + expect(endsWith('')('')).toEqual(true) + expect(endsWith('')('a')).toEqual(true) + expect(endsWith('a')('')).toEqual(false) + }) + + it('minLength', () => { + const { minLength } = defaultValidators + expect(minLength(10)('ab')).toEqual(false) + expect(minLength(2)('ab')).toEqual(true) + expect(minLength(0)('ab')).toEqual(true) + expect(minLength(0)('')).toEqual(true) + expect(minLength(1)('')).toEqual(false) + }) +})