Validate request body and parameter in a Node.js Express API

Validate request body and parameter in a Node.js Express API
Photo by Joseph Chan / Unsplash

Photo by Joseph Chan / Unsplash

Never trust user input

When building a public Web API that will be consumed by many clients or developers, ensuring the request body contains a valid payload is essential to make your API reliable and prevent some unexpected behavior in the system.

For Node.js API, input validation from the request is usually validated manually, and the validation is a source code that should be maintained along with the core feature. The more complex the valid is, the more code it requires.

Fortunately, there are many object schema validation libraries in the Node.js ecosystem, like Joi, Zod, Ajv, Yup, etc... We will see how to use Yup to validate the input we receive through API routes.

What will we do

You are building a Bookshop API that exposes many endpoints, among which we have the following below:

Method Route Description
POST /users/register Register a new user
GET /books/:id Find a book by its id
GET /books/search Search books using many criteria

The first endpoint contains a payload in the request body, the second has a path parameter to validate, and the third has URL query parameters to validate.

Prerequisites

To follow this tutorial, you must have the following tool installed on your computer:

  • Node.js 14 or higher - Download's link
  • NPM or Yarn (I will use Yarn)
  • An HTTP client for consuming the API; I will use Postman.
Create a REST API with Node.js, Express, MongoDB and Typescript
In this post, we will build a REST API for a user management application in Node.js and Typescript. Connect to a MongoDB database and store the data.

Set up the project

We will use a boilerplate for the Node.js project we built on this tutorial. The branch express contains a project with the Framework Express configured.


git clone https://github.com/tericcabrel/node-ts-starter.git -b express node-rest-api-validation

cd node-rest-api-validation

cp .env.example .env

nano .env

yarn install

yarn start

Open your browser and navigate to http://localhost:4500.

A hello world from our API in the browser.
A hello world from our API in the browser.

Install Yup

Let's install the Node package Yup, which will help us validate the request's input.


yarn install yup
yarn instal -D @types/yup

This is how we work with Yup:

  1. Create a schema describing the shape of the data you want to validate
  2. Validate the JSON data using the schema defined
  3. If the data is valid, throw an error; otherwise, infer the TypeScript type of the data from the schema and use the data in your code.

Check out the documentation to see what others things you can do with Yup.

GitHub - jquense/yup: Dead simple Object schema validation
Dead simple Object schema validation. Contribute to jquense/yup development by creating an account on GitHub.

Validate the request body

To register a new user, the input must be sent from the client to our server, and for this case, here are the information required:

Field Requirement
Full name A string with a length in the range of 2 and 50
Email address It must be a valid email address
Birth date It must be a date in the format YYY-MM-DD
Password It must be a string containing only letters and numbers with a minimum of 8 characters
Password confirmation It must be equal to the password field
Gender It must be one of the following values: MALE, FEMALE, OTHER
Address It must be a string, but it is not required

An example of a valid payload will look like this:

{
    fullName: "John DOE",
    email: "john.doe@gmail.com",
    birthDate: "1990-08-22",
    password: "Aest03Lurf74",
    confirmPassword: "Aest03Lurf74",
    gender: "MALE",
    address: "Av. de Concha Espina, 1, 28036 Madrid, Espagne"
}

Create the API route

We use the Express framework to easily create API in the Node.js app; to a new route, open the file src/index.ts and add the code below:


app.post('/users/register', (req, res) => {
  return res.json({ message: 'Success' });
});

Run the application and test it

Input sent to the API route is not validated.
Input sent to the API route is not validated.

As you can see, whether we provide valid or invalid input, the API responds with a successful status response. Let's validate the input and throw an error saying the input is invalid.

Write the Yup schema

Create a folder named validators then add the file register-user-schema.ts and add the code below:


import * as yup from 'yup';

enum GenderEnum {
  MALE = 'MALE',
  FEMALE = 'FEMALE',
  OTHER = 'OTHER',
}

const DATE_REGEX = /^([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))$/;
const PASSWORD_REGEX = /^[a-zA-Z0-9]{8,}$/;

export const registerUserSchema = yup
  .object({
    address: yup.string().optional().trim(),
    birthDate: yup.string().matches(DATE_REGEX, 'birthDate must be in the format YYYY-MM-DD'),
    email: yup.string().required().email(),
    fullName: yup.string().trim().min(2).max(50),
    gender: yup.string().oneOf([GenderEnum.MALE, GenderEnum.OTHER, GenderEnum.FEMALE]),
    password: yup.string().matches(PASSWORD_REGEX, 'password must contain only letters and numbers with a minimum of 8 characters'),
    confirmPassword: yup.string().oneOf([yup.ref('password'), null], "confirmPassword doesn't match the password"),
  })
  .required();
  
  

In the file src/index.ts, update the API route definition to validate the input using the schema above:

import { ValidationError } from 'yup';
import { registerUserSchema } from './validators/register-user-schema';


app.post('/users/register', (req, res) => {
  const { body } = req;

  try {
    const data = registerUserSchema.validateSync(body, { abortEarly: false, stripUnknown: true });

    return res.json({ message: 'Success', data });
  } catch (e) {
    const error = e as ValidationError;

    return res.status(422).json({ errors: error.errors });
  }
});

  • We get data to validate from the request body
  • Use the function validateSync() of the schema to synchronously validate the data; we set two options: abortEarly for not stopping the validation when the first invalid for is found and stripUnknown to delete fields that aren't defined in the schema.
  • When the data is invalid, we catch the error thrown by the function validateSync() and return an HTTP status 422 with the validation errors.
  • When the data is valid, the function returns the data.

Re-run the application and test the endpoint again:

Input sent to the API route is validated.
Input sent to the API route is validated.

Validate the request path parameter

To retrieve a book by its ID, the id of the book is passed as the URL path parameter. This value can be a string, a number, an ObjectId, UUID, etc...

In the case of string or number, it is straightforward to validate using yup.


// When the path ID is a string
export const pathIdSchema = yup.object({ id: yup.string().required().trim() }).required();

// When the path ID is a number
export const pathIdSchema = yup.object({ id: yup.number().required() }).required();

However, when the id is more than a string like ObjectId, UUID, CUID, etc... that must match a specific calculated format (there is the logic behind the format of the string), there is not a Yup field schema for that. Fortunately, there is a function called transform() we can use to validate the ID format.

For example, check out this link to see how a Mongo ObjectId is constituted.

Let's say the URL path ID is an ObjectId. In the folder validators, create a file called path-id-schema.ts and add the code below:


import * as yup from 'yup';
import { isValidObjectId } from 'mongoose';

export const pathIdSchema = yup
  .object({
    id: yup
      .string()
      .required('id must be a valid ObjectId')
      .trim()
      .transform((value) => {
        if (isValidObjectId(value)) {
          return value;
        }

        return '';
      }),
  })
  .required();
  
  

The transform() method is called before the other fields schema, and you can transform the value and return it for validation. Here we use the function isValidObjectId() from the mongoose package to validate the ID provided.

If it is valid, we return the value, and if it isn't, we return an empty so that this value will not match the field schema required().

Update the file src/index.ts to create the route to retrieve a book and validate the id using the schema we defined:


import { ValidationError } from 'yup';
import { pathIdSchema } from './validators/path-id-schema';

app.get('/books/:id', (req, res) => {
  const input = { id: req.params.id };

  try {
    const data = pathIdSchema.validateSync(input, { abortEarly: false, stripUnknown: true });

    return res.json({ message: 'Success', data });
  } catch (e) {
    const error = e as ValidationError;

    return res.status(422).json({ errors: error.errors });
  }
});

Re-run the application and test the endpoint:

The URL parameter path value is validated.
The URL parameter path value is validated.

Validate the query parameter

We can search books by providing the following criteria:

Field Requirement
keyword A string with a minimum of 3 characters and is required
Publish year It must be a number between 2000 and the current year and is optional
Minimum price It must be greater or equal to 5 and is optional
Maximum price It must be greater than the minimum price if provided and optional

Here are some valid URL query parameters:

  • /book/search?keyword=progra
  • /book/search?keyword=micro&publishYear=2019
  • /book/search?keyword=docker&minPrice=7&maxPrice=25
  • /book/search?keyword=nginx&maxPrice=25

In the folder validators create a file named search-book-schema and add the code below:


import * as yup from 'yup';

export const searchBookSchema = yup
  .object({
    keyword: yup.string().required(),
    publishYear: yup.number().min(2000).max(new Date().getFullYear()).optional(),
    minPrice: yup.number().min(5).optional(),
    maxPrice: yup
      .number()
      .optional()
      .when('minPrice', {
        is: (minPrice) => !!minPrice,
        then: yup.number().min(yup.ref('minPrice')),
        otherwise: yup.number().min(5),
      }),
  })
  .required();

Update the file src/index.ts to create the route to search books and validate the query parameters using the schema we defined:


import { ValidationError } from 'yup';
import { searchBookSchema } from './validators/search-book-schema';


app.get('/books/search', (req, res) => {
  const input = req.query;

  try {
    const data = searchBookSchema.validateSync(input, { abortEarly: false, stripUnknown: true });

    return res.json({ message: 'Success', data });
  } catch (e) {
    const error = e as ValidationError;

    return res.status(422).json({ errors: error.errors });
  }
});

💡
This route definition must be placed before the route /books/:id because /books/search match this pattern. By explicitly putting it before, we ensure that Express will find it first.

Re-run the application and test the endpoint:

The URL query parameters' path values are validated.
The URL query parameters' path values are validated.

Wrap up

You should never trust user input and always validate them to avoid unexpected behavior or unnecessary function execution (database calls, API requests, etc...).

With the help of Yup, we can easily define a schema that represents the shape of the object we expect and use this schema to validate the input. We saw how to validate the information from the request body, URL path parameter, and URL query parameters.

You can find the code source on the GitHub repository.

Follow me on Twitter or subscribe to my newsletter to avoid missing the upcoming posts and the tips and tricks I occasionally share.