GraphQL Schema Input Validation using GraphQL Directives

Today, we delve into the world of GraphQL APIs and explore a crucial aspect of their development: schema input validation. As GraphQL continues to gain traction for its flexibility and efficiency in data querying, ensuring the integrity of your API's schema becomes paramount.

We'll be using these packages from NPM to implement schema validation. The main package to lookout for is graphql-constraint-directive. This package will allow us to apply GraphQL directives to enforce schema validation.

For this project, this will be our directory structure:

Github Repo:

https://github.com/17bcs1837/graphql-constraint-directive

Setting Up the Project

To get started, let's examine the index.js file where we configure our GraphQL server. We begin by importing necessary dependencies such as express for building our web server and ApolloServer for creating our GraphQL server instance. Additionally, we import utilities from graphql-constraint-directive to enable schema validation using directives.

index.js

const express = require("express");
const { ApolloServer } = require("@apollo/server");
const { expressMiddleware } = require("@apollo/server/express4");
const {
  createApollo4QueryValidationPlugin,
  constraintDirectiveTypeDefs,
} = require("graphql-constraint-directive/apollo4");

const { makeExecutableSchema } = require("@graphql-tools/schema");

const userTypeDefs = require("./graphql/typedefs");
const userResolvers = require("./graphql/resolvers");

async function graphqlServer() {
  const app = express();
  const apolloServer = new ApolloServer({
    typeDefs: makeExecutableSchema({
      typeDefs: [constraintDirectiveTypeDefs, userTypeDefs],
    }),
    resolvers: userResolvers,
    introspection: true,
    plugins: [createApollo4QueryValidationPlugin()],
  });

  await apolloServer.start();

  app.use("/api/graphql", express.json(), expressMiddleware(apolloServer));

  app.listen(3000, () => {
    console.log(`GraphQL server running at http://localhost:3000/api/graphql`);
  });
}

graphqlServer();

Notice how we pass:

  1. constraintDirectiveTypeDefs

  2. createApollo4QueryValidationPlugin

from graphql-constraint-directive/apollo4

This adds all the constraint directives to our schema and necessary logic to resolve them.

Defining GraphQL Schema and Resolvers

In this section, we'll define the GraphQL schema for a simple user management system and implement corresponding resolvers to handle queries and mutations.

User Type Definitions (./graphql/typedefs.js)

const { gql } = require("graphql-tag");

const userTypeDefs = gql`
  type User {
    id: Int
    username: String!
    email: String!
    age: Int
    createdAt: String!
    updatedAt: String!
  }

  type Query {
    getUser(id: Int! @constraint(max: 5)): User
    getUsers: [User]
  }

  type Mutation {
    createUser(
      username: String!
      email: String! @constraint(format: "email")
      age: Int
    ): User
    updateUser(id: ID!, username: String, email: String, age: Int): User
    deleteUser(id: ID!): User
  }
`;

module.exports = userTypeDefs;

We have added a constraint on getUser query argument id and createUser mutation argument email.

User Resolvers (./graphql/resolvers.js)

// Dummy user data
let users = [
  {
    id: 1,
    username: "user1",
    email: "user1@example.com",
    age: 25,
    createdAt: "2024-04-16",
    updatedAt: "2024-04-16",
  },
  {
    id: 2,
    username: "user2",
    email: "user2@example.com",
    age: 30,
    createdAt: "2024-04-16",
    updatedAt: "2024-04-16",
  },
];

const userResolvers = {
  Query: {
    getUser: (_, { id }) => users.find((user) => user.id === id),
    getUsers: () => users,
  },
  Mutation: {
    createUser: (_, { username, email, age }) => {
      const newUser = {
        id: String(users.length + 1),
        username,
        email,
        age,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      };
      users.push(newUser);
      return newUser;
    },
    updateUser: (_, { id, username, email, age }) => {
      const index = users.findIndex((user) => user.id === id);
      if (index !== -1) {
        if (username) users[index].username = username;
        if (email) users[index].email = email;
        if (age) users[index].age = age;
        users[index].updatedAt = new Date().toISOString();
        return users[index];
      }
      return null;
    },
    deleteUser: (_, { id }) => {
      const index = users.findIndex((user) => user.id === id);
      if (index !== -1) {
        const deletedUser = users.splice(index, 1);
        return deletedUser[0];
      }
      return null;
    },
  },
};

module.exports = userResolvers;

Finally, We can run our project:

node index.js

Let's open GraphQL playground and see our constraints in action:

  1. getUser Query: We'll pass id as 20. It should throw an error, because id cannot be greater than 5 as per our constraints.

  1. createUser Mutation: We'll pass email as 'email.com'. It should throw an error, because it is not a valid email.

Point To Remember

  1. If we have more than one constraint and both of them fails. Then the validation errors are returned under extensions.validationErrors:

💡

You can also implement custom error handler, to have all the errors under extensions.validationErrors like this:

const apolloServer = new ApolloServer({
  typeDefs: makeExecutableSchema({
    typeDefs: [constraintDirectiveTypeDefs, userTypeDefs],
  }),
  resolvers: userResolvers,
  introspection: true,
  plugins: [createApollo4QueryValidationPlugin()],
  formatError: (formattedError, _error) => {
    const msg = formattedError.message;
    if (msg.startsWith("Variable") || msg.startsWith("Argument")) {
      return {
        ...formattedError,
        extensions: {
          validationErrors: [formattedError],
        },
      };
    }

    return formattedError;
  },
});

You can get the list of all the constraint directives here:

https://www.npmjs.com/package/graphql-constraint-directive