0.5.0 • Published 1 year ago

@marcduez/graphql-validation-directives v0.5.0

Weekly downloads
-
License
MIT
Repository
github
Last release
1 year ago

graphql-validation-directives

Library for adding input validation to GraphQL services, using schema directives.

This library was heavily inspired by the approach used by @profusion/apollo-validation-directives.

Under the hood, this library:

  1. Uses the schema visitor logic to copy directive metadata into the extension methods of input objects, input object fields, and field arguments.
  2. Wraps the resolver functions of all fields with validated arguments, validating all arguments before calling the original resolver. If validation fails, a validation error is returned and the original resolver is never executed.

General usage

import { makeExecutableSchema } from "@graphql-tools/schema"
import {
  addValidationToSchema,
  ValidListDirective,
  ValidObjectDirective,
  ValidStringDirective,
} from "@marcduez/graphql-validation-directives"
import gql from "graphql-tag"

const validListDirective = new ValidListDirective()
const validObjectDirective = new ValidObjectDirective()
const validStringDirective = new ValidStringDirective()

const executableSchema = addValidationToSchema(
  validListDirective.applyDirectiveToSchema(
    validObjectDirective.applyDirectiveToSchema(
      validStringDirective.applyDirectiveToSchema(
        makeExecutableSchema({
          typeDefs: [
            validListDirective.typeDefs,
            validObjectDirective.typeDefs,
            validStringDirective.typeDefs,
            gql`
              input Mutation1Input
                @validObject(equalFields: ["string1", "string2"]) {
                list: [[String!]]
                  @validList(maxItems: 2, listDepth: 0)
                  @validList(uniqueItems: true, listDepth: 1)
                  @validString(startsWith: "abc")
                string1: String!
                string2: String!
              }

              type Mutation {
                mutation1(input: Mutation1Input!): Boolean!
                mutation2(
                  input: String! @validString(maxLength: 255, startsWith: "xyz")
                ): Boolean!
              }
            `,
          ],
          resolvers: [
            {
              Query: {
                mutation1: () => true,
                mutation2: () => true,
              },
            },
          ],
        })
      )
    )
  )
)

Null values

Null and undefined values are not validated. Use non-null GraphQL type hints for null checks.

List Depth

The GraphQL type system allows us to define list of lists. To target our validation at the right level of nesting, we need a way to specify the level of nesting. See documentation of BaseValidationDirective#getListDepth method for more details.

Custom directive names

If the default names of the validation directives collide with something in your own type definitions, you can use them with a custom name.

import { makeExecutableSchema } from "@graphql-tools/schema"
import {
  addValidationToSchema,
  ValidStringDirective,
} from "@marcduez/graphql-validation-directives"
import gql from "graphql-tag"

const validStringDirective = new ValidStringDirective("customDirectiveName")

const executableSchema = addValidationToSchema(
  validStringDirective.applyDirectiveToSchema(
    makeExecutableSchema({
      typeDefs: [
        validStringDirective.typeDefs,
        gql`
          type Mutation {
            mutation1(
              input: String!
                @customDirectiveName(maxLength: 255, startsWith: "xyz")
            ): Boolean!
          }
        `,
      ],
      resolvers: [
        {
          Query: {
            mutation1: () => true,
          },
        },
      ],
    })
  )
)

Built-in directives

@validString directive

format

Throws if a string does not match the provided format. Currently allowed values are EMAIL and UUID.

input Mutation1Input {
  field: String! @validString(format: EMAIL)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: String! @validString(format: EMAIL)): Boolean!
}

maxLength

Throws if the string is longer than the provided value.

input Mutation1Input {
  field: String! @validString(maxLength: 255)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: String! @validString(maxLength: 255)): Boolean!
}

minLength

Throws if the string is shorter than the provided value.

input Mutation1Input {
  field: String! @validString(minLength: 8)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: String! @validString(minLength: 8)): Boolean!
}

startsWith

Throws if the string does not start with the provided value.

input Mutation1Input {
  field: String! @validString(startsWith: "account-")
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: String! @validString(startsWith: "account-")): Boolean!
}

endsWith

Throws if the string does not end with the provided value.

input Mutation1Input {
  field: String! @validString(endsWith: "-cad")
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: String! @validString(endsWith: "-cad")): Boolean!
}

includes

Throws if the string does not include the provided value.

input Mutation1Input {
  field: String! @validString(includes: "-rrsp-")
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: String! @validString(includes: "-rrsp-")): Boolean!
}

regex and regexFlags

Throws if the string does not match the provided regular expression pattern. If flags are provided, they are used.

input Mutation1Input {
  field: String! @validString(regex: "^[a-z0-9]$", regexFlags: "i")
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: String! @validString(regex: "^[a-z0-9]$", regexFlags: "i")): Boolean!
}

oneOf

Throws if the string is not in the provided collection of strings.

input Mutation1Input {
  field: String! @validString(oneOf: ["tfsa", "rrsp", "individual"])
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: String! @validString(oneOf: ["tfsa", "rrsp", "individual"])): Boolean!
}

@validInt directive

multipleOf

Throws if the number is not multiple of the provided integer value.

input Mutation1Input {
  field: Int! @validInt(multipleOf: 2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Int! @validInt(multipleOf: 2)): Boolean!
}

max

Throws if the number is greater than the provided integer value.

input Mutation1Input {
  field: Int! @validInt(max: 2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Int! @validInt(max: 2)): Boolean!
}

min

Throws if the number is less than the provided integer value.

input Mutation1Input {
  field: Int! @validInt(min: 2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Int! @validInt(min: 2)): Boolean!
}

exclusiveMax

Throws if the number is greater than or equal to the provided integer value.

input Mutation1Input {
  field: Int! @validInt(exclusiveMax: 2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Int! @validInt(exclusiveMax: 2)): Boolean!
}

exclusiveMin

Throws if the number is less than or equal to the provided integer value.

input Mutation1Input {
  field: Int! @validInt(exclusiveMin: 2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Int! @validInt(exclusiveMin: 2.2)): Boolean!
}

oneOf

Throws if the number is not in the provided collection of integers.

input Mutation1Input {
  field: Int! @validInt(oneOf: [2, 3, 4])
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Int! @validInt(oneOf: [2, 3, 4])): Boolean!
}

@validFloat directive

multipleOf

Throws if the number is not multiple of the provided float value.

input Mutation1Input {
  field: Float! @validFloat(multipleOf: 2.2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Float! @validFloat(multipleOf: 2.2)): Boolean!
}

max

Throws if the number is greater than the provided float value.

input Mutation1Input {
  field: Float! @validFloat(max: 2.2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Float! @validFloat(max: 2.2)): Boolean!
}

min

Throws if the number is less than the provided float value.

input Mutation1Input {
  field: Float! @validFloat(min: 2.2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Float! @validFloat(min: 2.2)): Boolean!
}

exclusiveMax

Throws if the number is greater than or equal to the provided float value.

input Mutation1Input {
  field: Float! @validFloat(exclusiveMax: 2.2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Float! @validFloat(exclusiveMax: 2.2)): Boolean!
}

exclusiveMin

Throws if the number is less than or equal to the provided float value.

input Mutation1Input {
  field: Float! @validFloat(exclusiveMin: 2.2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Float! @validFloat(exclusiveMin: 2.2)): Boolean!
}

oneOf

Throws if the number is not in the provided collection of floats.

input Mutation1Input {
  field: Float! @validFloat(oneOf: [2.1, 2.2, 2.3])
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: Float! @validFloat(oneOf: [2.1, 2.2, 2.3])): Boolean!
}

@validList directive

maxItems

Throws if the list has more than the provided number of items.

input Mutation1Input {
  field: [String!]! @validList(maxItems: 5)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: [String!]! @validList(maxItems: 5)): Boolean!
}

minItems

Throws if the list has less than the provided number of items.

input Mutation1Input {
  field: [String!]! @validList(minItems: 2)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: [String!]! @validList(minItems: 2)): Boolean!
}

uniqueItems

Throws if the list has non-unique items.

input Mutation1Input {
  field: [String!]! @validList(uniqueItems: true)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: [String!]! @validList(uniqueItems: true)): Boolean!
}

listDepth

See documentation of BaseValidationDirective#getListDepth method for more details on this feature.

input Mutation1Input {
  field: [[String!]!]! @validList(maxItems: 3) @validList(maxItems: 2, listDepth: 1)
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

type Mutation {
  someMutation(arg: [[String!]!]! @validList(maxItems: 3) @validList(maxItems: 2, listDepth: 1)): Boolean!
}

@validObject directive

equalFields

Throws if the fields with the provided names have non-equal values.

input Mutation1Input @validObject(equalFields: ["password", "confirmPassword"]) {
  password: String!
  confirmPassword: String!
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

input Mutation1Input {
  password: String!
  confirmPassword: String!
}

type Mutation {
  someMutation(arg: Mutation1Input! @validObject(equalFields: ["password", "confirmPassword"])): Boolean!
}

nonEqualFields

Throws if the fields with the provided names have equal values.

input Mutation1Input @validObject(nonEqualFields: ["securityAnswer1", "securityAnswer2"]) {
  securityAnswer1: String!
  securityAnswer2: String!
}

type Mutation {
  someMutation(arg: Mutation1Input!): Boolean!
}

OR

input Mutation1Input {
  securityAnswer1: String!
  securityAnswer2: String!
}

type Mutation {
  someMutation(arg: Mutation1Input! @validObject(nonEqualFields: ["securityAnswer1", "securityAnswer2"])): Boolean!
}

Writing custom directives

Below is an example of a custom validation directive that checks whether a string is a valid timezone name.

import { makeExecutableSchema } from "@graphql-tools/schema"
import {
  addValidationToSchema,
  BaseValidationDirective,
} from "@marcduez/graphql-validation-directives"
import gql from "graphql-tag"
import { getTimezone } from "countries-and-timezones"

class ValidTimezoneDirective extends BaseValidationDirective {
  constructor(name = "validTimezone") {
    super(name)
  }

  get typeDefs() {
    return gql(`
      directive @${this.name} on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
    `)
  }

  validate(_directiveConfig: Record<string, any>, value: any) {
    if (!getTimezone(value)) {
      throw new Error("Value must be a valid timezone")
    }
  }
}

const validTimezoneDirective = new ValidTimezoneDirective()

const executableSchema = addValidationToSchema(
  validTimezoneDirective.applyDirectiveToSchema(
    makeExecutableSchema({
      typeDefs: [
        validTimezoneDirective.typeDefs,
        gql`
          type Mutation {
            mutation1(input: String! @validTimezone): Boolean!
          }
        `,
      ],
      resolvers: [
        {
          Query: {
            mutation1: () => true,
          },
        },
      ],
    })
  )
)