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:
Part 1: Removing nullables: A journey to a cleaner GraphQL schema
Part 2: Creating a simple Apollo Server plugin (this article)
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 OperationDefinition
s (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 examplename
onPerson
FragmentSpreadNode
is requesting all of the fields defined by a fragment, for example...PersonFragment
on thedirector
fieldInlineFragmentNode
appears when an inline fragment is requested on a union or interface, for example the... on Book { }
structure inbookQuery
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 FieldNode
s 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 SelectionNode
s in a SelectionSetNode
. It divides the SelectionNode
s 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.