MissionaryZeal

API Error Handling and Validation in Node.js, Express, and Mongoose

Error handling and validation are crucial aspects of building robust web applications using Node.js, Express, and Mongoose. In this post, we’ll explore how to implement error handling and validation techniques to ensure the reliability and security of our applications.

Configuring Your Development Environment: Managing Dependencies with package.json

{
  "name": "api-error-handling-validation",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "start": "node server/server.js",
    "server": "nodemon server/server.js",
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "bcrypt": "^5.1.1",

    "dotenv": "^16.3.1",
    "express": "^4.18.2",

    "jsonwebtoken": "^9.0.2",
    "mongoose": "^8.0.3"
  },
  "devDependencies": {
    "nodemon": "^3.0.2"
  }
}

First step, create custom API errors by extending the built-in Error class, we define a CustomAPIError class that allows us to customize error messages and associated status codes. This enables us to provide more descriptive and structured error responses in our APIs, errors/custom-error.js

class CustomAPIError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.message = message;
  }
}

module.exports = CustomAPIError;

Second step, handling MongoDB Errors with Custom API Responses. By defining custom error messages with key and value and status codes, you can tailor error handling to your application’s needs, improving user experience and facilitating troubleshooting, errors/mongo-error.js

const CustomAPIError = require("./custom-error");
const handleMongoError = (error) => {
  let statusCode;
  let errorMessages = {};
  if (error.code === 11000) {
    statusCode = 400;
    errorMessages["email"] = "Email must be unique";
  } else if (error.errors) {
    statusCode = 400;
    Object.keys(error.errors).forEach((field) => {
      errorMessages[field] = error.errors[field].message;
    });
  } else {
    statusCode = 500;
    errorMessages["error"] = `Internal server error. Please try again later.`;
  }

  return new CustomAPIError(errorMessages, statusCode);
};

module.exports = handleMongoError;

Third step, create middleware bridges the gap between your error files and your Express.js routes, allowing for seamless error management. By connecting to your custom error classes, this middleware ensures that errors are appropriately handled and responded to, streamlining the error-handling process in your application, middleware/error-handling.js

const CustomAPIError = require("../errors/custom-error");
module.exports = (err, req, res, next) => {
  if (err instanceof CustomAPIError) {
    res.status(err.statusCode).json(err.message);
  } else {
    res.status(500).json({ msg: err.message });
  }
};

Fourth step is optional, this module exports a function to handle 404 errors, responding with a “Route Not Found” message and a 404 status code. It ensures that requests to undefined routes are properly handled, improving the overall user experience, middleware/not-found.js

module.exports = (req, res) => {
  res.status(404).send("Route Not Found");
};

Fifth step, create a controller to manage the registration of new users, ensuring proper error handling and validation. It utilizes the User model for database interactions and relies on the handleMongoError function to handle MongoDB-specific errors. Additionally, it ensures that the user’s password is appropriately encrypted before being stored. This controller plays a crucial role in the overall error handling and validation process, controllers/AuthController.js

const User = require("../models/User.js");
const handleMongoError = require("../errors/mongo-error");

/** Register new user
 * @API api/auth/register
 */
const register = async (req, res, next) => {
  try {
    const user = await User.create(req.body);
    if (user) {
      user.password = undefined;
      res.status(201).json({ message: "Register successfully", user });
    } else {
      next(handleMongoError(ErrorMessage));
    }
  } catch (error) {
    next(handleMongoError(error));
  }
};

/** Login new user
 * @API api/auth/login
 */
const login = async (req, res, next) => {
  // Extract email and password from request body
  const { email, password } = req.body;

  try {
    const user = await User.findOne({ email });

    if (user && (await user.matchPassword(password))) {
      user.password = undefined;
      res.status(200).json({ message: "Login successful", user });
    } else {
      return res.status(400).json({ message: "Invalid credentials" });
    }
  } catch (error) {
    next(handleMongoError(error));
  }
};

module.exports = { register, login };

Sixth step, hashing the password after validation checking in the User model by which we can add more secure password using this mechanism. This process occurs after validation checks, further solidifying our approach to user data protection, models/User.js

const mongoose = require("mongoose");
const bcrypt = require("bcrypt");
const handleMongoError = require("../errors/mongo-error");
const userSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: [true, "User name is required"],
      minlength: [3, "Name must be at least 3 character"],
      maxlength: [30, "Name cannot be more than 30"],
    },
    email: {
      type: String,
      required: [true, "Email is required"],
      unique: true,
      validate: {
        validator: function (v) {
          return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
        },
        message: (props) => `${props.value} is not a valid email`,
      },
    },
    phone: {
      type: String,
      validate: {
        validator: function (v) {
          return /\d{3}-\d{3}-\d{4}/.test(v);
        },
        message: (props) => `${props.value} is not a valid phone number.`,
      },
    },
    password: {
      type: String,
      required: [true, "Password is required"],
      validate: {
        validator: function (v) {
          return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/.test(
            v
          );
        },
        message:
          "Password must contain at least one uppercase letter, one lowercase letter, one digit, and one special character",
      },
    },
  },
  {
    timestamps: true,
  }
);

// Hash the password before saving to the database
userSchema.pre("save", async function (next) {
  if (!this.isModified("password")) {
    return next();
  }

  try {
    const salt = await bcrypt.genSalt(10);
    const hashPassword = await bcrypt.hash(this.password, salt);
    this.password = hashPassword;
    next();
  } catch (error) {
    next(handleMongoError(error));
  }
});

// compare passwords for login credentials
userSchema.methods.matchPassword = async function (password) {
  try {
    return await bcrypt.compare(password, this.password);
  } catch (error) {
    next(handleMongoError(error));
  }
};

const User = mongoose.model("User", userSchema);

module.exports = User;

Seventh step, create a separate route for this auth request to register and login the user, routes/authRoutes.js

const express = require("express");
const router = express.Router();

const { register, login } = require("../controllers/AuthController");

router.post("/register", register);
router.post("/login", login);

module.exports = router;

Last step, To create the Heart of Your Application! we can give the file name index.js, or app.js or serve.js but I make the name consistent all my express app which is server.js

const express = require("express");
require("dotenv").config();
const connectDB = require("./config/db");
const groceryRoutes = require("./routes/groceryRoutes");
const authRoutes = require("./routes/authRoutes");
const app = express();
const PORT = process.env.PORT || 4000;

// import middleware
const notFound = require("./middleware/not-found");
const errorHandling = require("./middleware/error-handling");

// coss plartform request
app.use((req, res, next) => {
  res.header("Access-Control-Allow-Origin", "*");
  res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
  res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");

  if (req.method === "OPTIONS") {
    res.sendStatus(200);
  } else {
    next();
  }
});

// configuration some packages
app.use(express.json());
app.use(express.urlencoded({ extended: true }));


// calling routes
app.use("/api/auth", authRoutes);
app.use("/api/grocery", groceryRoutes);
app.get("/", (req, res) => {
  res.send("Grocery Inventory");
});

// configuration of the middleware
app.use(notFound);
app.use(errorHandling);

// start the server
const start = async () => {
  try {
    await connectDB();
    app.listen(PORT, () => console.log(`Server listen on port ${PORT}`));
  } catch (error) {
    console.log(error);
  }
};

start();

Conclusion:

In this post, we delved into the essential aspects of API error handling and validation in Node.js, Express, and Mongoose. We started by configuring our application’s environment and middleware, ensuring seamless integration of error handling throughout the request-response cycle. Through custom error classes and middleware, we crafted meaningful error messages and responses tailored to various scenarios, enhancing the user experience and aiding in debugging.

Furthermore, we can implement robust validation techniques using express-validator, safeguarding our application against malformed user input and maintaining data integrity. Leveraging Mongoose’s capabilities, we fortified our authentication system with secure password hashing, enhancing user data protection.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top