Dynamic GraphQL queries with Mercurius

Photo by Colin Lloyd on Unsplash

Dynamic GraphQL queries with Mercurius

If you're using Fastify with Mercurius as your GraphQL adapter, you may be looking for some advanced usages. In this article, we'll explore a real world example with Dynamic GQL queries with Mercurius.

The example we will discuss is only one scenario that you may encounter as Software Engineer at NearForm! If you want to solve these kind of problems, we are hiring!

What are Dynamic GraphQL queries?

A dynamic GraphQL query is a query that is constructed at runtime, based on the needs of the client. This is useful when the client is uncertain of the structure of the data and needs to retrieve specific data based on conditions, or when the client needs to retrieve a subset of data depending on the user's role or permissions.

In our use case, we want to invert the control of query dynamicity from the client to the server. This means that the client will send a standard GQL query and the server will return the data based on:

  • The client's query
  • The user's role

It's important to note that we are not talking about returning a subset of a generic GraphQL type, but a completely different GraphQL type.

Defining the schema

When creating a GraphQL server, the first step is to define the schema. The GraphQL specification provides clear guidance on how to accomplish our target by utilizing GraphQL Unions.

Unions in GraphQL allow for multiple types to be returned from a single field, making it a powerful tool for querying related data. This can be especially useful when retrieving data from multiple types in a single query.

To begin, let's define our schema using GraphQL Unions.

type Query {
  searchData: Grid
}

union Grid = AdminGrid | ModeratorGrid | UserGrid


type AdminGrid {
  totalRevenue: Float
}

type ModeratorGrid {
  banHammer: Boolean
}

type UserGrid {
  basicColumn: String
}

As you can see, our schema includes a Query type with a searchData field that returns a Grid union type. This Grid union type can represent one of three possible types: AdminGrid, ModeratorGrid, or UserGrid.

Currently, the client can send a query using inline fragments like this:

query {
  searchData {
    ... on AdminGrid {
      totalRevenue
    }
    ... on ModeratorGrid {
      banHammer
    }
    ... on UserGrid {
      basicColumn
    }
  }
}

While this is a valid query, it is not the desired outcome. We aim to return a different type based on the user's role, so that the client can send a query like this:

query {
  searchData {
    totalRevenue
  }
}

It's important to note that the above query is not valid against the schema defined above, but with some additional implementation, we can make it work!

Implementing the business logic

Let's start building the basic implementation of our server. You can skip this section if you are already familiar with Fastify and Mercurius.

The first step is to install the necessary dependencies.

mkdir graphql-dynamic-queries
cd graphql-dynamic-queries
npm init -y
npm install fastify@4 mercurius@11

Now, copy the schema we defined above in a gql-schema.js file, then you need to create an app.js file where we will write our server:

const Fastify = require('fastify')
const GQL = require('mercurius')
const schema = require('./gql-schema')

// For simplicity, we will start the server only if started with `node app.js run`
if (process.argv[2] === 'run') {
  buildApp()
    .then(app => app.listen({ port: 8080 }))
}

async function buildApp () {
  const app = Fastify({ logger: true })

  await app.register(GQL, {
    schema,
    resolvers: {
      Query: {
        searchData: async function (root, args, context, info) {
          // TODO: implement the business logic
          return {}
        }
      },
      Grid: {
        resolveType (obj, context) {
          return getUserType(context)
        }
      }
    }
  })

  return app
}

module.exports = buildApp

function getUserType (context) {
  switch (context.reply.request.headers['x-user-type']) {
    case 'admin':
      return 'AdminGrid'
    case 'moderator':
      return 'ModeratorGrid'
    default:
      return 'UserGrid'
  }
}

To verify that everything is working, you can start the server running node app.js run and you should see the following output:

Server listening at http://127.0.0.1:8080

Great! Now we must list all the use cases we want to implement by adding some test cases.

Testing our server

We want to test our server with the following use cases:

  • A user with the admin role should be able to retrieve the totalRevenue field without inline fragments
  • A user with the moderator role should be able to retrieve the banHammer field without inline fragments
  • A user with the user role should be able to retrieve the basicColumn field without inline fragments
  • A user without the admin role should not be able to retrieve the totalRevenue field
  • A user without the moderator role should not be able to retrieve the banHammer field
  • A user without the user role should not be able to retrieve the basicColumn field
  • A user without any role should not be able to retrieve any field

We must install the required dependencies:

npm install tap@15 -D

We can write these tests in a test.js file.

const { test } = require('tap')

const buildApp = require('./app')

test('A user with the `admin` role should be able to retrieve the `totalRevenue` field without inline fragments', async t => {
  const app = await buildApp()
  const res = await doQuery(app, 'admin', `
    query {
      searchData {
        totalRevenue
      }
    }
  `)

  t.same(res.data.searchData, { totalRevenue: 42 })
})

// TODO: add the other tests

async function doQuery (app, userType, query) {
  const res = await app.inject({
    method: 'POST',
    url: '/graphql',
    headers: {
      'x-user-type': userType
    },
    body: {
      query
    }
  })

  return res.json()
}

For the sake of simplicity, we will not list all the tests here, but you can find the complete complete source code in the GitHub repository.

Running the tests with node test.js will fail because we have not implemented the business logic yet. So, let's start writing the code!

Implementing the server-side Dynamic Queries

To implement the business logic, there are these main steps:

  1. Retrieve the user's role from the request headers
  2. Manage the GraphQL query to return the correct type based on the user's role

Let's solve the first point.

How to retrieve the user's role

We can implement the user role retrieval by installing the mercurius-auth plugin.

npm i mercurius-auth@3

Then, we can register the plugin in our app.js file. To understand what the plugin does, you can read its documentation.

In the following example, we will compare the x-user-type HTTP header with the @auth directive we are going to define in the schema. If they match, the user will be authorized to access the field and run the query.

Let's start by defining the @auth directive in the schema:

directive @auth(
  role: String
) on OBJECT

# ..same as before

type AdminGrid @auth(role: "admin") {
  totalRevenue: Float
}

type ModeratorGrid @auth(role: "moderator") {
  banHammer: Boolean
}

type UserGrid @auth(role: "user") {
  basicColumn: String
}

Then, we can register the plugin in our app.js file and implement a simple searchData resolver:

async function buildApp () {
  const app = Fastify() // the same as before

  await app.register(GQL, {
    schema,
    resolvers: {
      Query: {
        searchData: async function (root, args, context, info) {
          switch (getUserType(context)) {
            case 'AdminGrid':
              return { totalRevenue: 42 }

            case 'ModeratorGrid':
              return { banHammer: true }

            default:
              return { basicColumn: 'basic' }
          }
        }
      },
    },
    Grid: {} // the same as before 
  })

  app.register(require('mercurius-auth'), {
    authContext (context) {
      return {
        identity: context.reply.request.headers['x-user-type']
      }
    },
    async applyPolicy (policy, parent, args, context, info) {
      const role = policy.arguments[0].value.value
      app.log.info('Applying policy %s on user %s', role, context.auth.identity)

      // we compare the schema role directive with the user role
      return context.auth.identity === role
    },
    authDirective: 'auth'
  })

  return app
}

Now, the user should be able to retrieve the totalRevenue field only if the x-user-type header is set to admin.

Nevertheless, we can't run the tests yet because we have not implemented the second point.

Implementing the Dynamic Queries

The last step is to implement the dynamic queries. Right now, our tests are failing with the following error:

"Cannot query field 'totalRevenue' on type 'Grid'. Did you mean to use an inline fragment on 'AdminGrid'?

The error is correct because we are not using inline fragments to query a Union type.

To overcome this issue, we can use the mercurius preValidation hook. Let's try to see how it works:

When a client sends a GraphQL query, the server will process the GQL Document in the preValidation hook, before validating the GQL against the GQL Schema.

In this hook, we can modify the GQL Document sent by the user to add the inline fragments. So, after the mercurius-auth plugin registration, we can add the following code:

async function buildApp () {
  const app = Fastify() // the same as before

  await app.register(GQL, {
    // the same as before 
  })

  app.register(require('mercurius-auth'), {
    // the same as before
  })

  app.graphql.addHook('preValidation', async (schema, document, context) => {
    const { visit } = require('graphql')

    const userType = getUserType(context)

    const newDocument = visit(document, {
      enter: (node) => {
        // We check if we must add the inline fragment
        const isMaskedQuery = node.kind === 'Field' &&
                              node.name.value === 'searchData' &&
                              node.selectionSet.selections.length === 1 &&
                              node.selectionSet?.selections.length > 0

        if (isMaskedQuery) {
          // This is the magic, we add a new inline fragment modifying the AST
          const nodeWithInlineFragment = {
            ...node,
            selectionSet: {
              kind: 'SelectionSet',
              selections: [
                {
                  kind: 'InlineFragment',
                  typeCondition: {
                    kind: 'NamedType',
                    name: {
                      kind: 'Name',
                      value: userType
                    }
                  },
                  selectionSet: node.selectionSet
                }
              ]
            }
          }
          return nodeWithInlineFragment
        }
      }
    })

    // We apply the new AST to the GQL Document
    document.definitions = newDocument.definitions
  })

  return app
}

In the previous code, we inspect the GraphQL Document Abstract Syntax Tree (AST) to determine if we need to add the inline fragment programmatically. If we recognize the query as a masked query, we add the inline fragment to the AST and return the modified AST to the GraphQL Document.

By doing this, Mercurius will process the modified GraphQL Document as if the user had sent it with the inline fragment. This means that it will:

  • Validate the GraphQL Document against the GraphQL Schema
  • Apply the authentication policy
  • Resolve the query

With this implementation, when running tests, everything should pass successfully.

Summary

We have seen how versatile Mercurius is and how simple it is to implement features like dynamic queries in a complex scenario. While the goal may seem challenging, this server-side implementation provides several benefits such as:

  • Avoiding breaking changes on the client side to support new features
  • Hiding GraphQL Types from the client through disabling schema introspection
  • Applying query optimizations for the client
  • Providing a specialized response based on the user type instead of a generic one.

This is just a small example of the possibilities when using Mercurius and manipulating the GraphQL Document.

If you have found this helpful, you may read other articles about Mercurius.

Now jump into the source code on GitHub and start to play with the GraphQL implemented in Fastify.

If you enjoyed this article comment, share and follow me on Twitter!