8
votes

In an update to our GraphQL API only the models _id field is required hence the ! in the below SDL language code. Other fields such as name don't have to be included on an update but also cannot have null value. Currently, excluding the ! from the name field allows the end user to not have to pass a name in an update but it allows them to pass a null value for the name in, which cannot be allowed.

A null value lets us know that a field needs to be removed from the database.

Below is an example of a model where this would cause a problem - the Name custom scalar doesn't allow null values but GraphQL still allows them through:

type language {
  _id: ObjectId
  iso: Language_ISO
  auto_translate: Boolean
  name: Name
  updated_at: Date_time
  created_at: Date_time
}
input language_create {
  iso: Language_ISO!
  auto_translate: Boolean
  name: Name!
}
input language_update {
  _id: ObjectId!
  iso: Language_ISO!
  auto_translate: Boolean
  name: Name
}

When a null value is passed in it bypasses our Scalars so we cannot throw a user input validation error if null isn't an allowed value.

I am aware that ! means non-nullable and that the lack of the ! means the field is nullable however it is frustrating that, as far as I can see, we cannot specify the exact values for a field if a field is not required / optional. This issue only occurs on updates.

Are there any ways to work around this issue through custom Scalars without having to start hardcoding logic into each update resolver which seems cumbersome?

EXAMPLE MUTATION THAT SHOULD FAIL

mutation tests_language_create( $input: language_update! ) { language_update( input: $input ) { name  }}

Variables

input: {
  _id: "1234",
  name: null
}

UPDATE 9/11/18: for reference, I can't find a way around this as there are issues with using custom scalars, custom directives and validation rules. I've opened an issue on GitHub here: https://github.com/apollographql/apollo-server/issues/1942

1
Have you tried schema directives ? [link] (apollographql.com/docs/graphql-tools/schema-directives.html)Amit Bhoyar
@AmitBhoyar Good shout - I'll try it now and get back - should have thought about directives! Thanks!Matthew P
Trying the directives route but it seems there is an issue with input fields - github.com/apollographql/graphql-tools/issues/858Matthew P
@MatthewP I've updated my answer with an alternative solution. It's not as nice as using a directive on the input fields themselves, but it should be a functional workaround.Daniel Rearden

1 Answers

7
votes

What you're effectively looking for is custom validation logic. You can add any validation rules you want on top of the "default" set that is normally included when you build a schema. Here's a rough example of how to add a rule that checks for null values on specific types or scalars when they are used as arguments:

const { specifiedRules } = require('graphql/validation')
const { GraphQLError } = require('graphql/error')

const typesToValidate = ['Foo', 'Bar']

// This returns a "Visitor" whose properties get called for
// each node in the document that matches the property's name
function CustomInputFieldsNonNull(context) {
  return {
    Argument(node) {
      const argDef = context.getArgument();
      const checkType = typesToValidate.includes(argDef.astNode.type.name.value)
      if (checkType && node.value.kind === 'NullValue') {
        context.reportError(
          new GraphQLError(
            `Type ${argDef.astNode.type.name.value} cannot be null`,
            node,
          ),
        )
      }
    },
  }
}

// We're going to override the validation rules, so we want to grab
// the existing set of rules and just add on to it
const validationRules = specifiedRules.concat(CustomInputFieldsNonNull)

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules,
})

EDIT: The above only works if you're not using variables, which isn't going to be very helpful in most cases. As a workaround, I was able to utilize a FIELD_DEFINITION directive to achieve the desired behavior. There's probably a number of ways you could approach this, but here's a basic example:

class NonNullInputDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field
    const { args: { paths } } = this
    field.resolve = async function (...resolverArgs) {
      const fieldArgs = resolverArgs[1]
      for (const path of paths) {
        if (_.get(fieldArgs, path) === null) {
          throw new Error(`${path} cannot be null`)
        }
      }
      return resolve.apply(this, resolverArgs)
    }
  }
}

Then in your schema:

directive @nonNullInput(paths: [String!]!) on FIELD_DEFINITION

input FooInput {
  foo: String
  bar: String
}

type Query {
  foo (input: FooInput!): String @nonNullInput(paths: ["input.foo"])
}

Assuming that the "non null" input fields are the same each time the input is used in the schema, you could map each input's name to an array of field names that should be validated. So you could do something like this as well:

const nonNullFieldMap = {
  FooInput: ['foo'],
}

class NonNullInputDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field
    const visitedTypeArgs = this.visitedType.args
    field.resolve = async function (...resolverArgs) {
      const fieldArgs = resolverArgs[1]
      visitedTypeArgs.forEach(arg => {
        const argType = arg.type.toString().replace("!", "")
        const nonNullFields = nonNullFieldMap[argType]
        nonNullFields.forEach(nonNullField => {
          const path = `${arg.name}.${nonNullField}`
          if (_.get(fieldArgs, path) === null) {
            throw new Error(`${path} cannot be null`)
          }
        })
      })      

      return resolve.apply(this, resolverArgs)
    }
  }
}

And then in your schema:

directive @nonNullInput on FIELD_DEFINITION

type Query {
  foo (input: FooInput!): String @nonNullInput
}