Polishing the plugin

Find the complete code for this article on Github

In the first 4 articles of this series, we created an Apollo Server plugin to identify fields in our schema that we want to make non-nullable, but are presently returning null. In this final article, we will add some polish to our plugin, by doing two things:

  • Checking interface fields

  • Reporting more details about the operation

Articles in this series:

Checking interface fields

The code in our plugin (see part 4) only checks concrete types. For example, consider the schema

interface Media {
  id: ID!
  name: String! @proposedNonNullable
}

type Book implements Media {
  id: ID!
  name: String! @proposedNonNullable
}

type Movie implements Media {
  id: ID!
  name: String!
}

Our intention is to make name non-nullable, however we have forgotten to add the directive to Movie.name. The current version of the plugin wouldn't warn us about movies that don't have a name because it only checks Book and Movie for the @proposedNonNullable directive, not Media.

To add a startup check to ensure our schema doesn't have this kind of mistake, we use the serverWillStart hook:

export class ProposedNonNullableViolationsLogger implements ApolloServerPlugin {
  async serverWillStart({ schema }: GraphQLServerContext) {
    const problems = checkInterfaces(schema)

    if (problems.length) {
      throw new GraphQLError(`Invalid schema: ${problems.join(', ')}`)
    }
  }

As you can tell from the name, this hook is called by Apollo during startup and provides us with the schema it will be using. We can abort the startup by throwing an Error, as we do here if we detect any problems.

The purpose of the checkInterfaces() function is to ensure that for every interface field that has been declared @proposedNonNullable, the corresponding fields on all of the implementing types is either non-nullable or @proposedNonNullable(). Let's look at its code:

export function checkInterfaces(schema: GraphQLSchema) {
  const interfaces = Object.values(schema.getTypeMap()).filter(isInterfaceType)

  return interfaces.flatMap(getInterfaceProblems(schema))
}

function getInterfaceProblems(
  schema: GraphQLSchema
): (interfaceType: GraphQLInterfaceType) => string[] {
  return (interfaceType) => {
    const proposedNonNullableFields = Object.values(
      interfaceType.getFields()
    ).filter(isProposedNonNullable)

    return proposedNonNullableFields.flatMap(
      getInterfaceFieldProblems(schema, interfaceType)
    )
  }
}

In the first line, we obtain an array of all the interfaces in the schema. getTypeMap() returns a Record<string, GraphQLNamedType>, so we use Object.values() filtering with the GraphQL function isInterfaceType to get the interfaces as an array.

On the second line of the function we get all of the problems for each interface. We use flatMap() instead of map() because getInterfaceProblems() returns an array for each interface, and we want to combine them all into a single array. If we'd wanted to, we could have also written this as

return interfaces
   .map(getInterfaceProblems(schema))
   .flat()

The structure of getInterfaceProblems() is the same as checkInterfaces(). We get an array of fields that have the @proposedNonNullable directive, and we then check each field in turn by calling getInterfaceFieldProblems().

function getInterfaceFieldProblems(
  schema: GraphQLSchema,
  interfaceType: GraphQLInterfaceType
): (field: GraphQLField<unknown, unknown>) => string[] {
  const implementations = schema.getImplementations(interfaceType)
  const allImplementations = [
    ...implementations.objects,
    ...implementations.interfaces
  ]

  return (field) =>
    getProblemsForField(interfaceType, allImplementations, field)
}

To find the implementations of interface, we use the aptly-named getImplementations() function on the schema. It returns the following structure:

{
  objects: ReadonlyArray<GraphQLObjectType>;
  interfaces: ReadonlyArray<GraphQLInterfaceType>;
}

We combine the two arrays to get every implementation.

Finally, we call getProblemsForField() on each of the fields:

function getProblemsForField(
  interfaceType: GraphQLInterfaceType,
  implementations: readonly (GraphQLInterfaceType | GraphQLObjectType)[],
  field: GraphQLField<unknown, unknown>
): string[] {
  const problems: string[] = []

  for (const implementationType of implementations) {
    const implementationField = implementationType.getFields()[field.name]

    if (
      isNullableType(implementationField.type) &&
      !isProposedNonNullable(implementationField)
    ) {
      problems.push(
        `${implementationType.name}.${implementationField.name} must be @proposedNonNullable because ${interfaceType.name}.${field.name} is`
      )
    }
  }

  return problems
}

This function checks that each of the implementing fields is either already non-nullable or has the @proposedNonNullable directive on it.

Reporting operation details

In part 4, our plugin merely reported the name of each operation. Many if not most queries and mutations accept arguments, and knowing their values can be really helpful in working out where the nulls are coming from, especially if they're data-related.

It's important to remember that there is a difference between the way GraphQL represents operations and how we usually think of them. In most cases we think of an operation as making a single query or mutation, like so:

query {
  media {
    name
  }
}

However GraphQL represents this as querying the media field on the Query type, and we can query multiple fields if we wanted - for example:

query ($authorId: ID!) {
  media {
    name
  }

  author(ID: $authorId) {
    name
  }
}

So when we're logging the operation details, we need to log each of the fields (in this case media and author) to fully understand the request.

In the new version of our plugin, we have the following code:

// logProposedNonNullableViolations.ts
  if (proposedNonNullableViolations.length) {
    requestContext.logger.warn({
      msg: '@proposedNonNullable violation',
      operation: normalizeOperation(requestContext, operation),
      definiteViolations: definiteViolationPaths.map(prop('path')),
      possibleViolations: possibleViolationPaths.map(prop('path')),
      errors: result.errors ?? []
    })
  }

// normalizeOperation.ts
export function normalizeOperation(
  requestContext: GraphQLRequestContext<BaseContext>,
  operation: OperationDefinitionNode
): Operation {
  const fields = operation.selectionSet.selections
    .flatMap((selectionNode) => getFields(requestContext, selectionNode))
    .map(prop('fieldNode'))

  return {
    operationName: operation.name?.value,
    type: operation.operation,
    fields: fields.map(normalizeOperationField(requestContext))
  }
}

You can see that we're getting the list of fields in the same way we did in part 4, taking into account any fragments that need to be expanded. We don't need to know the parent type in this case, so we just extract the fieldNode prop.

For each field, we call normalizeOperationField():

function normalizeOperationField(
  requestContext: GraphQLRequestContext<BaseContext>
): (field: FieldNode) => OperationField {
  return (field) => {
    const normalizedArgumentsArr = field.arguments?.map((argument) => [
      argument.name.value,
      normalizeArgumentValue(requestContext, argument.value)
    ])

    return {
      alias: field.alias?.value ?? null,
      field: field.name.value,
      arguments: Object.fromEntries(normalizedArgumentsArr ?? [])
    }
  }
}

This function returns a structure giving the name of the field, its alias (if any) and the arguments for that field. In the example above, it would have been {ID: <author ID>}, where <author ID> is whatever value was specified as the query variable.

normalizeArgumentValue() is used to convert GraphQL's representation of the argument's value to a POJO.

function normalizeArgumentValue(
  requestContext: GraphQLRequestContext<BaseContext>,
  value: ValueNode
): unknown {
  switch (value.kind) {
    case Kind.NULL:
      return null
    case Kind.LIST:
      return value.values.map((itemValue) =>
        normalizeArgumentValue(requestContext, itemValue)
      )
    case Kind.OBJECT:
      return Object.fromEntries(
        value.fields.map((fieldValue) => {
          return [
            fieldValue.name.value,
            normalizeArgumentValue(requestContext, fieldValue.value)
          ]
        })
      )
    case Kind.VARIABLE:
      return requestContext.request.variables?.[value.name.value]
    case Kind.INT:
      return Number(value.value)
    case Kind.FLOAT:
      return Number(value.value)
    default:
      return value.value
  }
}

This function is fairly simple, with only List, Object and Variable arguments requiring any special handling. List and Object arguments simply perform a recursive call to normalizeArgumentValue(), while for variables we dereference the value from the request's variables.

And there you have it - nicely formatted arguments in your logs. It's worth noting that depending on your application these arguments may contain sensitive data - passwords, security tokens, or PII etc. So take care with what you log and add some code to redact sensitive values if required.

Finally, there is one more addition to our log messages - errors. This is so you can tell whether the violation was caused by an error or not. If there is an error generating the response, GraphQL will traverse up the tree looking for the nearest nullable field and set it to null, rather than returning a partial response that violates the schema. If that field is declared as @proposedNonNullable, our plugin will flag it as a violation, even if it may work fine during normal operations.

Wrapping up

Over these 5 articles, we've built an Apollo Server plugin to help us migrate from a schema that contains many nullable fields to one that correctly represents our actual responses. We've had a deep dive into how GraphQL internally represents queries and schemas, and the code we have written could be easily adapted to other purposes, for example logging who has accessed sensitive data.

This is the last article in this series, but there are plenty more interesting articles coming up, so subscribe now to read them from the convenience of your inbox.