Elegant API Request Body Validation by Route in Express.js

Elegant API Request Body Validation by Route in Express.js

As we build complex web applications with an increasing number of routes and endpoints, managing request validation becomes a critical aspect of the API design. In Node.js applications, employing a clean and efficient validation strategy for every request reaching the server is pivotal. This article proposes an effective approach to API request validation where every route has its own validation schema. This technique leverages the lightweight JavaScript library Yup for schema building and validation, and introduces a middleware function for validating request bodies.

The primary benefits of this approach include:

  • Streamlined validation with clearly defined schemas for each route.

  • Utilization of Yup's easy-to-use API and human-readable error messages.

  • Centralized request validation middleware simplifies code organization and debugging.

  • Decoupling of validation logic from route handlers, promoting code reuse and separation of concerns.

  • Increased application reliability by ensuring all incoming requests are valid.

  • Simple human-readable errors that the frontend can show the user directly/

Implementing the System

Let's dive into how we can create this system.

1. Route-Specific Validation Schemas

Firstly, every route should have its validation schemas defined in a validators file.
This one is called expenses.validator.ts
Here's an example of a validation schema for an expense route using Yup:

import * as Yup from "yup";

export const addExpenseValidationSchema = Yup.object({
  price: Yup.string()
    .required()
    .matches(/^\d+(\.\d+)?$/, "Enter a valid price")
    .label("Price"),
  category: Yup.string().required("Please select a category").label("Category"),
  remark: Yup.string().label("Remark").required(),
});

2. Route-Schema Lookup Table

Next, we establish a schema lookup table defined in a schema-lookup file which maps routes to their corresponding validation schemas. This serves as a centralized place for all our route-specific schemas:

import { addExpenseValidationSchema } from "../modules/expense/expense.validators";

const schemaLookup: { [key: string]: any } = {
  "POST/expenses": addExpenseValidationSchema,
};

export default schemaLookup;

3. Request Validation Middleware

With our schemas and lookup table set up, we create a middleware function, bodyValidator, which uses the request's method and path to fetch the correct schema from the lookup table:

import { NextFunction, Request, Response } from "express";

import { BadRequestError } from "../config/errors";
import schemaLookup from "./schema-lookup";

export async function bodyValidator(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const route = `${req.method}${req.path}`;
  const schema = schemaLookup[route];
  if (!schema) return next();

  try {
    await schema.validate(req.body);
    next();
  } catch (error) {
    throw new BadRequestError(error.errors[0]);
  }
}

4. Integrating the Middleware

Finally, we wire up our application to use the bodyValidator middleware. This ensures all incoming requests pass through our validation process:

app.use("/api/v1", bodyValidator, routes);

5. Routes Without Repeated Validation Middleware

With the validation system, we've described above in place, our routes file becomes remarkably clean and simple. Here's an example of what a routes file for our expenses endpoint would look like:

Before

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

import { bodyValidator } from "../../../src/middlewares/validator";
import controller from "../expense/expense.controller";

router.post("/expenses", bodyValidator, controller.create);

router.put("/expenses", bodyValidator, controller.update);

router.patch("/expenses", bodyValidator, controller.singleUpdate);

export default router;

After

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

import controller from "../expense/expense.controller";

router.post("/expenses", controller.create);

router.put("/expenses", controller.update);

router.patch("/expenses", controller.singleUpdate);

export default router;

Notice how there's no longer a need to include the bodyValidator middleware within each route? This not only enhances code readability but also improves maintainability by keeping your code DRY (Don't Repeat Yourself). All the magic of validation is happening in the background, managed centrally by the bodyValidator middleware.

6. Conclusion

This approach allows for a more structured and elegant way of handling API request validation. It demonstrates the power of middleware in Express.js, allowing us to write cleaner, more maintainable code. By leveraging the flexibility of Yup for schema definition and validation, we've streamlined the process of ensuring all incoming requests are valid and structured as expected, leading to more robust and reliable APIs.