Creating a simple Apollo Server plugin

Creating a simple Apollo Server plugin

Find the complete code for this article on Github

In part 1 we created a GraphQL directive @proposedNonNullable to provide a safe way of converting all our nullable GraphQL fields to non-nullable. In parts 2, 3, and 4 we will build out an Apollo Server plugin to watch our responses and tell us whenever it finds a null in a field that’s been marked @proposedNonNullable. In this part, we will create a very simple plugin that just lists the fields that have been requested.

Articles in this series:

Getting started

If you’re not already familiar with Apollo Server plugins, their documentation is a really good place to start. The hook we’re interested in is willSendResponse, so the shell of our plugin looks something like this:

export const nonNullableViolationsReporter: ApolloServerPlugin = {
  requestDidStart: async () => {
    return {
      willSendResponse: async (requestContext) => {
        // Insert code here
      },
    }
  },
}

Note how the willSendResponse() hook is returned from the requestDidStart one.

The first thing we need to do is ignore incremental responses. They’re not enabled by default and so our plugin doesn’t expect to ever see one, but TypeScript doesn’t know that so we need to add this inside willSendResponse():

const responseBody = requestContext.response.body
if (responseBody.kind === 'incremental') {
  return
}

Now that we're only dealing with "complete" requests, we can start processing them. But first we need to understand how Apollo represents requests.

The anatomy of a request (and response)

There are four things that are important to us:

  • The schema: This is defined by the server and tells us which types are defined and the fields they have

  • The request document: This tells us what the client requested and how to interpret the response

  • The selection sets: Part of the request document, these are the sets of fields that have been requested for each type

  • The response object: This is the response as it will be sent to the client.

We will cover each of these things separately

The schema

This is represented as a GraphQLSchema. It provides a list of all the types and definitions that have been defined - if we want to know what type Person.name is, we query the schema.

For the purposes of this article, this is what our schema looks like:

type Query {
    media: [Media]!
}

union Media = Book | Movie

type Book {
    name: String!
    author: Person!
}

type Movie {
    name: String!
    director: Person!
}

type Person {
    name: String!
}

The request document

Apollo GraphQL represents the request as a DocumentNode and makes it available in requestContext.document. The main thing of interest in a DocumentNode is the list of definitions. Each query or mutation in the request has an entry here, as well as any fragments that have been defined. For example, if you had the following request:

query bookQuery {
    media {
        ...on Book {
            name
        }
    }
}

query movieQuery {
    media {
        ...on Movie {
            director {
                ...PersonFragment
            }
        }
    }
}

fragment PersonFragment on Person {
    name
}

requestContext.document.definitions would contain two OperationDefinitions (one for each of bookQuery and movieQuery) as well as a FragmentDefinition for PersonFragment.

The selection sets

A selection set is the set of fields or fragments that have been requested for the response. In the example above, the selection set of the director field consists of one element, the PersonFragment. The PersonFragment itself contains a selection set with a single field Person.name.

Apollo represents a selection set as an array of SelectionNodes, and selection nodes come in three types:

  • FieldNode is requesting a normal field, for example name on Person

  • FragmentSpreadNode is requesting all of the fields defined by a fragment, for example ...PersonFragment on the director field

  • InlineFragmentNode appears when an inline fragment is requested on a union or interface, for example the ... on Book { } structure in bookQuery

FragmentSpreadNodes and InlineFragmentNodes can also contain other fragments, so finding the actual set of fields that has been requested may require looking them up recursively.

The response object

And finally we have the response object. This is a POJO containing the response exactly as it will be sent to the client. It doesn't include anything not specifically requested by the client. Any extra properties on the objects your resolvers returned have been removed, and if the client didn't ask for __typename then this isn't present either. This creates some complications we'll talk about in part 4 of this series.

To build our full plugin that checks for @proposedNonNullable violations we will need all of these things, but for this part we only need the request document and the selection sets within it.

Building a basic plugin

So far, we only have the shell of the plugin — code that will run whenever a response is about to be returned. Now it's time to fill it out so that we correctly log all of the requested fields.

Getting the operations

First, we need to know the operations of the request. Most requests only have one operation, but there might be more. The operations are listed in the .definitions property of the request document, so we can use a filter on the array:

export function getOperations(requestContext: RequestContext): OperationDefinitionNode[] {
  return (requestContext.document?.definitions ?? [])
    .filter((definition): definition is OperationDefinitionNode =>
      definition.kind === 'OperationDefinition',
    )
}

This is what an OperationDefinitionNode looks like in TypeScript:

export interface OperationDefinitionNode {
  readonly kind: Kind.OPERATION_DEFINITION;
  readonly loc?: Location;
  readonly operation: OperationTypeNode;
  readonly name?: NameNode;
  readonly variableDefinitions?: ReadonlyArray<VariableDefinitionNode>;
  readonly directives?: ReadonlyArray<DirectiveNode>;
  readonly selectionSet: SelectionSetNode;
}

Although complex, Apollo's internal representation is well thought out. We spoke above about how fragments have a selection set, that is the fields that the fragment gets expanded out into. Operation definitions also have a selection set — each requested query or mutation is represented as selecting a field on the Query or Mutation types respectively.

Getting the requested fields

Because selection sets can contain fragments, we need to recursively dereference them. The code to do this is a little more involved, but bear with me:

/**
 * Returns the actual fields represented by the given SelectionNode,
 * dereferencing fragments
 */
export function getFields(
  requestContext: GraphQLRequestContext<unknown>,
  selectionNode: SelectionNode
): FieldNode[] {
  if (!requestContext.document) {
    // This is mostly to keep TypeScript happy, since the only
    // way we can obtain a selectionNode is from the request
    // context's document
    return []
  } else if (selectionNode.kind === 'Field') {
    // This node represents a normal field
    return [selectionNode]
  } else {
    // This node is a fragment, so we need to dereference it and return the fields
    // that the fragment defines
    const selectionSet = getFragmentSelectionSet(
      requestContext.document,
      selectionNode
    )

    return selectionSet.selections.flatMap((fragmentSelectionNode) =>
      getFields(requestContext, fragmentSelectionNode)
    )
  }
}

/**
 * Returns the selection set of the given fragment
 */
function getFragmentSelectionSet(
  document: DocumentNode,
  selectionNode: FragmentSpreadNode | InlineFragmentNode
): SelectionSetNode {
  switch (selectionNode.kind) {
    case Kind.INLINE_FRAGMENT:
      // Used in the case of a union or interface
      // eg: myMedia { ...on Book { author } ...on Movie { director } }
      return selectionNode.selectionSet
    case Kind.FRAGMENT_SPREAD:
      // A defined fragment.
      // eg: myBook { ...BookFragment }
      return getFragment(document, selectionNode.name.value).selectionSet
  }
}

/**
 * Returns the definition of a fragment
 */
function getFragment(
  document: DocumentNode,
  name: string
): FragmentDefinitionNode {
  return document.definitions.find(
    (definition) =>
      definition.kind === 'FragmentDefinition' && definition.name.value === name
  ) as FragmentDefinitionNode
}

In a nutshell, the above code handles all possibilities of a selection node. It's either a field, or a fragment. If it's a field we can just return it directly. If it's a fragment we need to get the selection set and recursively find the fields it requests. Inline fragments define the selection set directly, but for document fragments we need to look up the fragment definition first.

Apollo represents each field as a FieldNode. FieldNodes have a name, alias, any argument values that have been specified and, if the field is requesting an object, the selection set representing the sub-fields that have been requested. Here is the TypeScript definition:

export interface FieldNode {
  readonly kind: Kind.FIELD;
  readonly loc?: Location;
  readonly alias?: NameNode;
  readonly name: NameNode;
  readonly arguments?: ReadonlyArray<ArgumentNode>;
  readonly directives?: ReadonlyArray<DirectiveNode>;
  readonly selectionSet?: SelectionSetNode;
}

You can see it's not too dissimilar to an OperationDefinitionNode .

So far, we have the shell of a plugin, code to find the operations of a request, and the ability to find all of the fields represented by a selection node. Let's put these together to log all of the requested fields in each request.

Our getFields() function returns all of the FieldNodes that a given selection node represents, taking into account any fragments that have been referenced. What we need to do is create a function to list all of the fields and subfields of a selection set, which is all of the fields and fragments requested on an object.

For example, in the document below, the selection set on testQuery.media would be two inline fragments (one for Book, the other for Movie). The selection set on the inline fragment for Book would be name and author.

query testQuery {
    media {
        ...on Book {
            name
            author {
                ...PersonFragment
            }
        }

        ...on Movie {
            name
        }
    }
}

fragment PersonFragment on Person {
    name
}

To get all of the fields represented by a selection set, we need to recurse down the entire selection tree:

export function listFields(
  requestContext: GraphQLRequestContext<unknown>,
  selectionSet: SelectionSetNode,
  path: readonly string[] = []
): string[] {
  const fieldNodes = selectionSet.selections.flatMap((selection) =>
    getFields(requestContext, selection)
  )
  const branchNodes = fieldNodes.filter(({ selectionSet }) => selectionSet)
  const leafNodes = fieldNodes.filter(({ selectionSet }) => !selectionSet)

  return [
    ...branchNodes.flatMap(processBranchNode),
    ...leafNodes.map(processLeafNode)
  ]

  function processBranchNode(branchNode: FieldNode): string[] {
    return listFields(requestContext, branchNode.selectionSet, [
      ...path,
      getDataProperty(branchNode)
    ])
  }

  function processLeafNode(leafNode: FieldNode): string {
    return [...path, getDataProperty(leafNode)].join('.')
  }
}

The listFields() function processes all of the SelectionNodes in a SelectionSetNode. It divides the SelectionNodes into leaf nodes, which are scalar fields and branch nodes, which are object fields. For each of the branch fields it recurses down until only scalar fields have been selected.

The path variable is used to track the parent fields. You can see that when listChildren() recurses back into listFields() it adds the name of the branch field to the end of the path. It's not possible to create cycles in a GraphQL request, so we don't need any special code to handle them.

Logging the requested fields

Now that we can get the list of fields that have been requested, it's time to build a logging function. Fortunately, this part is much simpler:

export function logOperationFields(
  requestContext: GraphQLRequestContext<unknown>,
  operation: OperationDefinitionNode
) {
  requestContext.logger.info({
    msg: 'GraphQL operation fields',
    name: operation.name,
    fields: listFields(requestContext, operation.selectionSet)
  })
}

requestContext.logger is the logger that was provided to the Apollo Server when it was created. Apollo will create one if not present. For this example I have assumed a Bunyan/Pino-style logger that accepts the details object as its first argument. If you are using Winston you may wish to add a message as the first argument.

The path parameter of listFields() is only meant to be used when recursing so we have omitted it here. When listFields() is called it will default to an empty array.

Putting it all together

Finally we have the actual plugin:

export class RequestLogger implements ApolloServerPlugin {
  async requestDidStart(): Promise<
    GraphQLRequestListener<GraphQLRequestContext<unknown>>
  > {
    return {
      willSendResponse: async (
        requestContext: GraphQLRequestContext<unknown>
      ) => {
        const responseBody = requestContext.response.body
        if (responseBody.kind === 'incremental') {
          return
        }

        const operations = getOperations(requestContext)
        operations.forEach((operation) =>
          logOperationFields(requestContext, operation)
        )
      }
    }
  }
}

As before, to keep TypeScript happy we check to ensure that we're not dealing with an incremental response, then for each of the operations we log the details. To use our newly-created plugin, we add it to the plugins array when creating the Apollo server instance.

And there we have it - a simple plugin to list all of the requested fields. You'll notice that we don't log the type of any of the fields. Because a FieldNode is solely concerned with the field in the request document, it doesn't provide any information about the schema field it references. In part 3 we will extend our plugin to fetch and log this information too.

If you haven't already, check out the complete code for this article on Github, and try incorporating it into your own server to see it working in action. The plugin is designed for Apollo Server 4, but it should only need minor changes to make it work with versions 2 or 3. Make sure you subscribe or follow to read the next article where we start inspecting the schema too.