typescriptgraphqlexpress-graphql

Unable to propagate validation error into my graphql express middleware


I am trying to get a response for my validation in my resolver function. I want to include additional information such as error message, HTTP status code etc. in my response. Since the default Error constructor does not have the status code and the error data i want, I created a custom class ValidationError and extend it to Error like so:

import { UserInputData } from './types';
import { User, UserDocument } from '../models/user';
import bcrypt from 'bcryptjs';
import validator from 'validator';

// Creating a custom validationError class to have additional properties
export class ValidationError extends Error {
  public data: any;
  public code: number;

  constructor(message: string, data: any, code: number) {
    super(message);
    this.name = 'ValidationError';
    this.data = data;
    this.code = code;
  }
}

const rootValue = {
  createUser: async ({
    userInput,
  }: {
    userInput: UserInputData;
  }): Promise<{
    _id: string;
    name: string;
    email: string;
    password?: string;
    status: string;
    posts: any[];
    createdAt: string;
    updatedAt: string;
  }> => {
    const errors: { message: string }[] = [];
    // Check the user email is a valid email
    if (!validator.isEmail(userInput.email)) {
      errors.push({ message: 'Email is Invalid!' });
    }
    // Check if the password field is not empty or has min length
    if (
      validator.isEmpty(userInput.password) ||
      !validator.isLength(userInput.password, { min: 5 })
    ) {
      errors.push({ message: 'Password is too short!' });
    }
    // Default error message
    if (errors.length > 0) {
      const error = new ValidationError('Invalid input!', errors, 422);
      console.log(error);
      throw error;
    }
    // Check for existing user, if so don't create any new user
    const existingUser = await User.findOne({ email: userInput.email });
    if (existingUser) {
      const error = new Error('User already exists!');
      throw error;
    }

    // Hash the password before saving it.
    const hashedPassword = await bcrypt.hash(userInput.password, 12);

    // Create the new user in the database
    const newUser = new User({
      email: userInput.email,
      name: userInput.name,
      password: hashedPassword,
    });

    // Save the new user to the database
    await newUser.save();

    // Convert the Mongoose document to a plain object and return
    const plainUser = newUser.toObject() as unknown as UserDocument;

    return {
      ...plainUser,
      _id: plainUser._id.toString(),
    };
  },
};

export default rootValue;

I can successfully log the error with my additional properties. However, When i am trying to use the validation error for my express middleware my error are not propagating to the middleware function and instead returns the default error.

import express from 'express';
import path from 'node:path';
import bodyParser from 'body-parser';
import { createHandler } from 'graphql-http/lib/use/express';
import { schema } from './graphql/schema';
import resolver, { ValidationError } from './graphql/resolvers';
import { ruruHTML } from 'ruru/server';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import multer, { FileFilterCallback } from 'multer';
import { GraphQLError } from 'graphql';

dotenv.config();

const app = express();
const MONGO_URI = process.env.DB_URI!;
const PORT = 8080;

const fileStorage = multer.diskStorage({
  destination: (req, file, callback) => {
    callback(null, 'images');
  },
  filename: (req, file, callback) => {
    callback(null, new Date().toISOString() + '-' + file.originalname);
  },
});

const fileFilter = (
  req: Express.Request,
  file: Express.Multer.File,
  callback: FileFilterCallback
) => {
  if (
    file.mimetype === 'image/png' ||
    file.mimetype === 'image/jpg' ||
    file.mimetype === 'image/jpeg'
  ) {
    callback(null, true);
  } else {
    callback(null, false);
  }
};

const upload = multer({ storage: fileStorage, fileFilter: fileFilter });

app.use(bodyParser.json()); // application/json
app.use(upload.single('image'));
app.use('/images', express.static(path.join(__dirname, 'images')));

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader(
    'Access-Control-Allow-Methods',
    'OPTIONS, GET, POST, PUT, PATCH, DELETE'
  );
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  next();
});

app.get('/graphiql', (req, res) => {
  res.type('html');
  res.end(ruruHTML({ endpoint: '/graphql' }));
});

app.all(
  '/graphql',
  createHandler({
    schema,
    rootValue: resolver,
    formatError: (err) => {
      if (err instanceof ValidationError) {
        return new GraphQLError(err.message, {
          originalError: err,
          extensions: { code: err.code, data: err.data },
        });
      }

      return new GraphQLError('An unexpected error has occured!', {
        originalError: err,
        extensions: { code: 500, data: [] },
      });
    },
  })
);

app.use((error: any, req: any, res: any, next: any) => {
  // console.log(`Status Code: ${error}`);
  const status = error.statusCode || 500;
  const message = error.message;
  const data = error.data;
  res.status(status).json({ message: message, data: data });
});

// MongoDB connection and server startup
const startServer = async () => {
  console.log('Starting server ...');
  try {
    await mongoose.connect(MONGO_URI);
    const server = app.listen(PORT, () => {
      console.log(`Server running at http://localhost:${PORT}/`);
    });
  } catch (err) {
    console.log(err);
  }
};

startServer().catch((err) => console.error('Error in starting server!'));

When I am running the mutation in my graphiql like so:

mutation {
  createUser(
    userInput: {
      email: "testuser1"
      name: "testuser1"
      password: "Testuser1"
    }
  ) {
    _id
    email
  }
}

I am getting the default error message

{
  "errors": [
    {
      "message": "An unexpected error has occured!",
      "extensions": {
        "code": 500,
        "data": []
      }
    }
  ],
  "data": null
}

My validation error's are not getting propagated to my middleware.


Solution

  • Rather than creating additional class to add my custom properties into the graphQL error, I simply used the class GraphQLError provided by the graphql package and used the extensions property in the GraphQLErrorOptions to add the addition information to create my own validation error.

    import { UserInputData } from './types';
    import { User, UserDocument } from '../models/user';
    import bcrypt from 'bcryptjs';
    import validator from 'validator';
    import { GraphQLError } from 'graphql';
    
    const rootValue = {
      createUser: async ({
        userInput,
      }: {
        userInput: UserInputData;
      }): Promise<{
        _id: string;
        name: string;
        email: string;
        password?: string;
        status: string;
        posts: any[];
        createdAt: string;
        updatedAt: string;
      }> => {
        // Check the user email is a valid email
        if (!validator.isEmail(userInput.email)) {
          throw new GraphQLError('Invalid email!', {
            extensions: {
              statusCode: 422,
              data: { field: 'email' },
            },
          });
        }
        // Check if the password field is not empty or has min length
        if (
          validator.isEmpty(userInput.password) ||
          !validator.isLength(userInput.password, { min: 5 })
        ) {
          throw new GraphQLError('Password is too short!', {
            extensions: {
              statusCode: 422,
              data: { field: 'password' },
            },
          });
        }
    
        // Check for existing user, if so don't create any new user
        const existingUser = await User.findOne({ email: userInput.email });
        if (existingUser) {
          throw new GraphQLError('User already exists!', {
            extensions: {
              statusCode: 409,
              data: { field: 'email' },
            },
          });
        }
    
        // Hash the password before saving it.
        const hashedPassword = await bcrypt.hash(userInput.password, 12);
    
        // Create the new user in the database
        const newUser = new User({
          email: userInput.email,
          name: userInput.name,
          password: hashedPassword,
        });
    
        // Save the new user to the database
        await newUser.save();
    
        // Convert the Mongoose document to a plain object and return
        const plainUser = newUser.toObject() as unknown as UserDocument;
    
        return {
          ...plainUser,
          _id: plainUser._id.toString(),
        };
      },
    };
    
    export default rootValue;
    
    import express from 'express';
    import path from 'node:path';
    import bodyParser from 'body-parser';
    import { createHandler } from 'graphql-http/lib/use/express';
    import { schema } from './graphql/schema';
    import resolver from './graphql/resolvers';
    import { ruruHTML } from 'ruru/server';
    import mongoose from 'mongoose';
    import dotenv from 'dotenv';
    import multer, { FileFilterCallback } from 'multer';
    import { GraphQLError } from 'graphql';
    
    dotenv.config();
    
    const app = express();
    const MONGO_URI = process.env.DB_URI!;
    const PORT = 8080;
    
    const fileStorage = multer.diskStorage({
      destination: (req, file, callback) => {
        callback(null, 'images');
      },
      filename: (req, file, callback) => {
        callback(null, new Date().toISOString() + '-' + file.originalname);
      },
    });
    
    const fileFilter = (
      req: Express.Request,
      file: Express.Multer.File,
      callback: FileFilterCallback
    ) => {
      if (
        file.mimetype === 'image/png' ||
        file.mimetype === 'image/jpg' ||
        file.mimetype === 'image/jpeg'
      ) {
        callback(null, true);
      } else {
        callback(null, false);
      }
    };
    
    const upload = multer({ storage: fileStorage, fileFilter: fileFilter });
    
    app.use(bodyParser.json()); // application/json
    app.use(upload.single('image'));
    app.use('/images', express.static(path.join(__dirname, 'images')));
    
    app.use((req, res, next) => {
      res.setHeader('Access-Control-Allow-Origin', '*');
      res.setHeader(
        'Access-Control-Allow-Methods',
        'OPTIONS, GET, POST, PUT, PATCH, DELETE'
      );
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
      next();
    });
    
    app.get('/graphiql', (req, res) => {
      res.type('html');
      res.end(ruruHTML({ endpoint: '/graphql' }));
    });
    
    app.use(
      '/graphql',
      createHandler({
        schema,
        rootValue: resolver,
        formatError: (err) => {
          // console.log('Error received:', err);
          if (err instanceof GraphQLError) {
            return new GraphQLError(err.message, {
              originalError: err,
              extensions: {
                statusCode: err.extensions?.statusCode || 400,
                data: err.extensions?.data || {},
              },
            });
          }
    
          return new GraphQLError('An unexpected error has occured!', {
            originalError: err,
            extensions: { statusCode: 500, data: [] },
          });
        },
      })
    );
    
    app.use((error: any, req: any, res: any, next: any) => {
      // console.log(`Status Code: ${error}`);
      const status = error.statusCode || 500;
      const message = error.message;
      const data = error.data;
      res.status(status).json({ message: message, data: data });
    });
    
    // MongoDB connection and server startup
    const startServer = async () => {
      console.log('Starting server ...');
      try {
        await mongoose.connect(MONGO_URI);
        const server = app.listen(PORT, () => {
          console.log(`Server running at http://localhost:${PORT}/`);
        });
      } catch (err) {
        console.log(err);
      }
    };
    
    startServer().catch((err) => console.error('Error in starting server!'));