What’s a directive?

Directives are a tool to add a layer of control on top of GQL types.

directive @deprecated(
  reason: String = "No longer supported"
) on FIELD_DEFINITION | ENUM_VALUE

type ExampleType {
  newField: String
  oldField: String @deprecated(reason: "Use `newField`.")
}

There’s no magic here, and it’s up to the server to interpret and implement directives.

Ruh-roh

No matter how many tools and best practices you have at your disposal, it can be difficult to implement a non-trivial schema directive in a reliable, reusable way. Exhaustive testing is essential, and using a typed language like TypeScript is recommended, because there are so many different schema types to worry about.

How do I use them?

The GraphQL spec offers no direction on directive implementation. Apollo Server uses SchemaDirectiveVisitor to write directives. By subclassing SchemaDirectiveVisitor we can write functions that gain insight into the client’s request.

So, I think I could write an auth directive to be applied to every schema type that will consider the current auth level of the logged in user and deny/grant access based on that.

Here is the AuthDirective I wrote:

import _ from 'lodash'
import { SchemaDirectiveVisitor, AuthenticationError } from 'apollo-server-express'
import { defaultFieldResolver } from 'graphql'
import { User } from 'src/models/User'

export default class AuthorizationDirective extends SchemaDirectiveVisitor {
  visitObject(type) {
    this.ensureFieldsWrapped(type)
    type._permittedAuthRoles = this.args.permits
  }

  visitFieldDefinition(field, details) {
    this.ensureFieldsWrapped(details.objectType)
    field._permittedAuthRoles = this.args.permits
  }

  ensureFieldsWrapped(objectType) {
    if (objectType._authFieldsWrapped) return

    objectType._authFieldsWrapped = true

    const fields = objectType.getFields()

    Object.keys(fields).forEach((fieldName) => {
      const field = fields[fieldName]
      const { resolve = defaultFieldResolver } = field

      field.resolve = async function (...args) {
        const context = args[2]

        if (!context.currentUser || !context.currentUser.id) throw new AuthenticationError('User not found')

        const permittedRoles = field._permittedAuthRoles || objectType._permittedAuthRoles

        const authStatus = await User.getAuthStatus(context.currentUser.id)

        const authorized = _(permittedRoles).includes(authStatus)

        if (authorized) {
          return await resolve.apply(this, args)
        }
        else {
          throw new AuthenticationError('User unauthorized for this resource')
        }
      }
    })
  }
}