API Validations in Express.js

Mirza Leka
12 min readJul 19, 2023

--

This article will teach you how to validate APIs in Express.js using various validation methods.

The first thing to do when setting validations is to try to identify entries where traffic is coming from and what you expect to receive. There are three places where you’d want to validate incoming data, each with its pros and cons:

  • Middleware level
  • Service level
  • Data Access Level

The Middleware level validation is suitable for stopping requests sooner, before it even hits the controller, with the obvious downside of slowing down requests as each request needs to go through one or multiple middlewares before getting to the desired location.

The Controller / Service level is good when you want to combine multiple services to process the information, but delegates validation to the second step for things that could have already been done by the middleware.

The Data Access level provides validation using database constraints. If you attempt to insert invalid data, the DB will immediately complain. So you’re safe from invalid data inputs. However, if someone is attempting to inject your database, you want to stop before it gets to DB by any means necessary. Also, going through several layers only to validate data at the finish line is a time-consuming process that should be avoided.

So which is the best?
All of them! Combined. However, in practice, we usually use one or two techniques and hope for the best.

We won’t focus on defending against injection attacks as there will be a separate article on that in the future.

Photo by Pixabay from Pexels

Tools of the Trade

There is a number of free NPM libraries that can be used to validate API requests, but here I’ll mention just a few:

And if these solutions do not work for you you can always write your own custom validations.

Apply These Techniques to Express-APIvantage

Going back to the first article, I created a separate branch for API validations (2-API-Validations). We’ll apply two libraries:

  • Joi for users (Mongoose)
  • Class-Validator for products (TypeORM/SQL)

The folder structure will look like this:

I created a shared folder that will have two folders inside:

  • middlewares
  • validators

Inside validators, we’ll create validator schemas (using Joi and
Class-Validator) for products and users. The middlewares folder will have the middlewares for products and users which will consume these validators.

Then the controllers will invoke middlewares to handle the validation.

Photo by Erik Mclean from Pexels

Validating Products with Class-Validator

We start by installing a package:

> npm i class-validator

Set up product validators

Then we create a validation file. In my case, it’s called product.class.validator.ts to help differentiate it from Joi validator.

Now let’s import all packages that we need.

import {
IsInt,
Length,
IsUrl,
IsDate,
Min,
Max,
IsNotEmpty,
IsOptional,
IsNumber
} from 'class-validator';
import { IProduct } from '../../databases/postgresql/model/product.model';

We want to validate several scenarios:

  • Creating new products
  • Updating products
  • Product Id (used when retrieving, updating, or deleting a product)

We’ll create a separate schema (class) for each validation scenario.

export class CreateProductValidationSchema implements IProduct {
@Length(3, 50)
@IsNotEmpty()
name!: string;

@Length(0, 300)
@IsOptional()
description?: string;

@IsNumber()
@Min(0)
@Max(1000_000)
@IsOptional()
price?: number;

@IsUrl()
@IsOptional()
image?: string;

@IsDate()
@IsOptional()
datePublished!: string;
}

Where:

  • name (product name) is a mandatory field and must be between 3 and 50 characters long
  • description is optional and must not exceed 300 characters
  • price is a number, is also optional, and must not exceed one million
  • image is URL and is also optional
  • datePublished is a date and is of course optional

Updating product schema will be similar, with the major difference being that every parameter is optional. So we can extend the schema above and just tweak the name field and make it optional as well.

export class UpdateProductValidationSchema
extends CreateProductValidationSchema
{
@Length(3, 50)
@IsOptional()
name!: string;
}

Finally, we’ll create a schema that validates a product id.

export class GetProductIdValidationSchema {
@IsInt()
@Min(1)
id!: number
}

Set up product middlewares

Now we’ll create middlewares that will consume these validators. Mine is called product-validator.middleware.ts.

Starting with imports:

import { validateOrReject } from 'class-validator';
import { Request, Response, NextFunction } from 'express';

import {
CreateProductValidationSchema,
GetProductIdValidationSchema,
UpdateProductValidationSchema,
} from '../validators/product.class.validator';

Every middleware needs to have three parameters (in this order):

  • request (object)
  • response (object)
  • next (function)

We read incoming data, such as query information, parameters, headers, or body from the request object. Using the response object we can send a response from the middleware to the user, just as we can do that on the controller level.

The next() function is called to tell Express to proceed with the next middleware in-line or to the controller. If all validations pass on the middleware, we’ll use next() to invoke the next operation.

Create a product validator middleware

export const createProductValidator = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
if (!req.body) {
return res.status(400).send({ message: 'Missing request body!' });
}

// creates new product instance and assigns it data from request.body
const product = new CreateProductValidationSchema();
product.name = req.body.name;
product.image = req.body.image;
product.price = req.body.price;
product.description = req.body.description;

// checks a product instance against the schema validations
await validateOrReject(product);

// calls the next operation
next();
} catch (e: any) {
// handles the error generated by class-validator
const message = Object.values(e[0].constraints)[0];
res.status(400).send({ message });
}
};

We currently have this logic for creating a new product inside the controller:

  .post('/', createProductValidator, async (req: Request, res: Response) => {
const product = new ProductEntity();
product.name = req.body.name;
product.image = req.body.image;
product.price = req.body.price;
product.description = req.body.description;

const newProduct = await useTypeORM(ProductEntity).save(req.body);
res.status(201).send(newProduct);
})

Previously we created a new product in the middleware based-off CreateProductValidationSchema and now we’re creating a new product from ProductEntity in the controller using the exact same data we get from the request.body and repeating all assigning steps.

To avoid duplications we can go back to the middleware and assign a request.body object to the product we’ve instantiated in the middleware:

req.body = product;

Then in the controller just pass the req.body object to the new product entity.

const newProduct = await useTypeORM(ProductEntity).save(req.body);

However, there might be cases where the validation schema and the entity class do not have the same properties (follow the same blueprint), thus there will be a data mismatch. Also, the TS linter might complain that you’re trying to pass the product schema where the product entity was expected.

To solve both of these issues and avoid creating two instances, we’ll make use of a class-transformer package that will map properties from one class instance into an instance of another class.

We first add the mapping logic using Transform function to the product entity.

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { Transform } from 'class-transformer';
import { IProduct } from '../model/product.model';

@Entity()
export class ProductEntity implements IProduct {
@PrimaryGeneratedColumn()
id!: number;

@Column()
@Transform((value) => value.value)
name!: string;

@Column({ default: 0 })
@Transform((value) => value.value)
price!: number;

@Column({ nullable: true })
@Transform((value) => value.value)
description!: string;

@Column({ default: new Date().toDateString() })
@Transform((value) => value.value)
datePublished!: string;

@Column({ nullable: true })
@Transform((value) => value.value)
image!: string;
}

Then we apply it to the middleware.

import { plainToClass } from 'class-transformer';
import { ProductEntity } from '../../databases/postgresql/entity/product.entity';

And use the plainToClass function to map the product (that is CreateProductValidationSchema instance) to the ProductEntity instance.

req.body = plainToClass(ProductEntity, product);

Update Product Validator

Here, we’ll follow the same structure. We’ll also validate the product id here so that we do not need to do that inside the controller.

export const updateProductValidator = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
if (!req.params?.id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}

if (!req.body) {
return res.status(400).send({ message: 'Missing request body!' });
}

const product = new UpdateProductValidationSchema();
product.name = req.body.name;
product.image = req.body.image;
product.price = req.body.price;
product.description = req.body.description;

await validateOrReject(product);

// mapping UpdateProductValidationSchema instance to ProductEntity instance
req.body = plainToClass(ProductEntity, product);

next();
} catch (e: any) {
const message = Object.values(e[0].constraints)[0];
res.status(400).send({ message });
}
};

And finally, we apply similar logic to product id validator middleware.

export const getProductByIdValidator = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
if (!req.params?.id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}

const product = new GetProductIdValidationSchema();
product.id = Number(req.params.id);

await validateOrReject(product);

next();
} catch (e: any) {
// extract generated message from the errors array
const message = Object.values(e[0].constraints)[0];
res.status(400).send({ message });
}
};

Applying the middlewares inside the product controller

Now we’ll add these middlewares to the routes we’ve created in the previous article. We’ll also wrap everything into a try/catch block to avoid any other errors.

  .post('/', createProductValidator, async (req: Request, res: Response) => {
try {
const newProduct = await useTypeORM(ProductEntity).save(req.body);
res.status(201).send(newProduct);
} catch(e: unknown) {
res.status(500).send({ message: 'Unable to save entry to DB!' })
}
})
  .get('/:id',getProductByIdValidator, async (req: Request, res: Response) => {
try {
const { id } = req.params;

const existingProduct = await useTypeORM(ProductEntity).findOneBy({ id });

if (!existingProduct) {
return res.status(404).send({ message: `Product with id: ${id} was not found.` });
}

res.send(existingProduct);

} catch (e: unknown) {
res.status(500).send({ message: 'Unable to retrieve data from DB!' })
}
})
  .patch('/:id', getProductByIdValidator, updateProductValidator, async (req: Request, res: Response) => {
try {
const { id } = req.params;

const existingProduct = await useTypeORM(ProductEntity).findOneBy({ id });

if (!existingProduct) {
return res.status(404).send({ message: `Product with id: ${id} was not found.` });
}

const changes: Partial<ProductEntity> = req.body;
const productChanges = { ...existingProduct, ...changes };

const updatedProduct = await useTypeORM(ProductEntity)
.save(productChanges);

res.send(updatedProduct);

} catch(e: unknown) {
res.status(500).send({ message: 'Unable to update entry in DB!' })
}
})
  .delete('/:id', getProductByIdValidator, async (req: Request, res: Response) => {
try {
const { id } = req.params;

const existingProduct = await useTypeORM(ProductEntity).findOneBy({ id });

if (!existingProduct) {
return res.status(404).send({ message: `Product with id: ${id} was not found.` });
}

await useTypeORM(ProductEntity).remove(existingProduct);
res.send({ message: 'Product removed!' });
} catch(e: unknown) {
res.status(500).send({ message: 'Unable to delete entry from DB!' })
}
})

The Get All products route does not need any middlewares, so that’s why I left it out.

Photo by cottonbro studio from Pexels

Validating Users with Joi

We start by installing the Joi package:

> npm i joi

I made some changes to creating and updating users. For starters, when creating a user, now you must provide an additional field. Previously there were only:

  • username
  • email
  • password,

but now I also added repeat_password into the mix. Repeat password and password must be both provided and must be the same.

The second change I made is I separated the logic for updating passwords into a new route (PATCH /change-password/:id). This route is used for updating the user password only and the other update route can no longer be used to update the password.

Set up user validators

When creating a user we want to validate:

  • the username contains letters or numbers and is within a certain range
  • passwords match
  • the email is a valid email
  • all fields are mandatory
import Joi from 'joi';

export const createUserValidationSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),

password: Joi.string().required(),

repeat_password: Joi.any().valid(Joi.ref('password')).required().messages({
'any.only' : 'Passwords must match'
}),

email: Joi.string().email({
minDomainSegments: 2, // the minimum number of domain segments (e.g. x.y.z has 3 segments)
tlds: { allow: ['com', 'net'] }, // allowed domains
}).required(),
});

Updating the user follows a similar logic with every field above (username and email) being optional:

export const updateUserValidationSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).optional(),

email: Joi.string().email({
minDomainSegments: 2,
tlds: { allow: ['com', 'net'] },
}).optional(),
});

Now the change password validations.

export const changePasswordValidationSchema = Joi.object({

old_password: Joi.string().required(),

new_password: Joi.string().required(),

repeat_password: Joi.any().valid(Joi.ref('new_password')).required().messages({
'any.only' : 'Passwords must match'
})
});

And validating user id, which in this case is not an integer, but rather a MongoDB Object ID (GUID):

// MongoDB Object_ID Validator
export const getUserIdValidationSchema = Joi.object({
id: Joi.string().hex().length(24)
});

With this in place, we need to create the middlewares.

Set up user middlewares

Imports

import { Request, Response, NextFunction } from 'express';
import {
changePasswordValidationSchema,
createUserValidationSchema,
updateUserValidationSchema,
getUserIdValidationSchema,
} from '../validators/user.joi.validator';

Create User Validator Middleware

export const createUserValidator = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
if (!req.body) {
return res.status(400).send({ message: 'Missing request body!' });
}

// the validateAsync method is built into Joi
await createUserValidationSchema.validateAsync(req.body);

next();
} catch (e: any) {
// if validation fails we send the message generated by Joi
res.status(400).send({ message: e.message });
}
};

Update User Validator Middleware

As stated before, here I prohibited the password update using this route.

export const updateUserValidator = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
if (!req.params?.id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}

if (!req.body) {
return res.status(400).send({ message: 'Missing request body!' });
}

if (req.body.password || req.body.new_password) {
return res.status(400).send({ message: 'Invalid change requested!' });
}

await updateUserValidationSchema.validateAsync(req.body);

next();
} catch (e: any) {
res.status(400).send({ message: e.message });
}
};

Change Password Validator Middleware

export const changePasswordValidator = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
if (!req.body) {
return res.status(400).send({ message: 'Missing request body!' });
}

await changePasswordValidationSchema.validateAsync(req.body);

next();
} catch (e: any) {
res.status(400).send({ message: e.message });
}
};

User Id Validator Middleware

export const getUserByIdValidator = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {

if (!req.params?.id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}

await getUserIdValidationSchema.validateAsync(req.params);

next();
} catch (e: any) {
res.status(400).send({ message: e.message });
}
};

Applying these middlewares inside the user controller

Once again I wrapped all routes with the try/catch block. The rest should be pretty straightforward.

  .post('/', createUserValidator, async (req, res) => {
try {
const newUser = new UserModel();
newUser.username = req.body.username;
newUser.email = req.body.email;
newUser.password = req.body.password;

await newUser.save();
} catch(e: unknown) {
res.status(500).send({ message: 'Unable to save entry to DB!' })
}
})
  .get('/:id', getUserByIdValidator, async (req: Request, res: Response) => {
try {
const { id } = req.params;

const existingUser = await UserModel.findById(id);

if (!existingUser) {
return res
.status(404)
.send({ message: `User with id: ${id} was not found.` });
}

res.send(existingUser);

} catch(e: unknown) {
res.status(500).send({ message: 'Unable to retrieve data from DB!' })
}
})
  .patch('/:id', getUserByIdValidator, updateUserValidator, async (req, res) => {
try {
const { id } = req.params;

const changes: Partial<IUser> = req.body;

const updatedUser = await UserModel.findOneAndUpdate(
{ _id: id },
{ $set: { ...changes } },
{ new: true }
);

if (!updatedUser) {
return res
.status(404)
.send({ message: `User with id: ${id} was not found.` });
}

res.send(updatedUser);
} catch(e: unknown) {
res.status(500).send({ message: 'Unable to update data in DB!' })
}
})
  .patch(
'/change-password/:id',
getUserByIdValidator,
changePasswordValidator,
async (req: Request, res: Response) => {
try {
const { id } = req.params;

const updatedUser = await UserModel.findOneAndUpdate(
{ _id: id },
{ $set: { password: req.body.new_password } },
{ new: true }
);

if (!updatedUser) {
return res
.status(404)
.send({ message: `User with id: ${id} was not found.` });
}

res.send(updatedUser);
} catch(e: unknown) {
res.status(500).send({ message: 'Unable to update data in DB!' })
}
}
)
  .delete('/:id', getUserByIdValidator, async (req, res) => {
try {
const { id } = req.params;

const existingUser = await UserModel.findById(id);

if (!existingUser) {
return res
.status(404)
.send({ message: `User with id: ${id} was not found.` });
}

await UserModel.findOneAndRemove({ _id: id });

res.send({ message: 'User removed!' });

} catch(e: unknown) {
res.status(500).send({ message: 'Unable to delete entry from DB!' })
}
});

I once again left out the Get All route as it does not need any middleware validations.

Other changes

I created changes.md file in the root directory to have a clear vision of what changed from one version to another.

I also created a new directory, Docs, where I moved database setup and Postman directories. I also updated Postman routes with new validations we’ve just added.

Preview of the new Postman API testing documentation (in docs/postman directory)

You can find a full source code for this article in the repository below:

Photo by Min An from Pexels

Wrapping up

With middlewares in place, we managed to validate incoming traffic making our app safer and our controllers much cleaner. In future updates, we’ll learn to move code to the services and apply other improvements.

Bye for now 👋

--

--

Mirza Leka
Mirza Leka

Written by Mirza Leka

Web Developer. DevOps Enthusiast. I share my experience with the rest of the world. Follow me on https://twitter.com/mirzaleka for news & updates #FreePalestine

No responses yet