Skip to content
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
76 changes: 55 additions & 21 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<any, any>, objectType:GraphQLObjectType | GraphQLInterfaceType}} details
* @param {{field:GraphQLField, objectType:GraphQLObjectType | GraphQLInterfaceType}} details
*/
visitArgumentDefinition (argument, details) {
// preparing the resolver
Expand All @@ -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)

Expand Down
14 changes: 13 additions & 1 deletion src/utils.js
Original file line number Diff line number Diff line change
@@ -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)))
Expand Down
9 changes: 9 additions & 0 deletions src/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -73,6 +81,7 @@ module.exports = {
createValidationCallback,
stringValidators,
numericValidators,
logicalValidators,
formatValidator,
format2fun
}
46 changes: 46 additions & 0 deletions test/class.test.js
Original file line number Diff line number Diff line change
@@ -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"')
})
})
Loading