JOI - API schema validation

JOI - API schema validation

Timo
Timo

Data validation is one of topics that I am interesting. I always review my code after developed features or fixed bugs. There are many places where need to validate data, it is really terrible. Some cases, we need to validate data input because ensure the data into API, it will not make any problems to crash system.

As we see below example, the logic is too complicated for data validation.

if (!req.body.email) {
  throw new Exception('Email is missing.') 
}

if (!req.body.password) {
  throw new Exception('Password is missing.') 
}

if (req.body.password.length < 8) {
  throw new Exception('Password must be at least 8 characters.') 
}

In this article, I would like to introduce data validation with JOI Validation Module.

What is JOI?

It is a library to make ensure your data input the same with Object schema define before.

Why is use JOI to validate data before processing?

  • Verify between input data with Object schema define before is quickly.
  • Avoid some exceptions.
  • Avoid handling wrong data input by user.
  • Ensure data integrity, limit data rollback in the database.

Apply JOI into REST API using Express

  • Create a JOI project:

Run command below

mkdir joi-schema-validation

Go to joi-schema-validtion folder

cd joi-schema-validation

Setup project

Firstly, installing package dependencies

npm install body-parser express joi

Secondly, create a app.ts file on root project folder to setup Express

import express, { Express, NextFunction } from 'express';
import { json, urlencoded } from 'body-parser';

const app: Express = express();

app.use(urlencoded({ limit: '10mb', extended: true, parameterLimit: 50000 }));
app.use('/api', routes);
app.listen(9000, () => {
  console.info('Listening to port 9000');
});

Continue, create route file with file name is routes.ts

const router = express.Router();
const defaultRoute: IRoute[] = [
  {
    path: '/auth',
    route: new AuthRoute().getRouter(),
  },
];

defaultRoute.forEach((route) => {
  router.use(route.path, route.route);
});

export default router;

Finally, create a directory to hold the schemas

mkdir schema-validation
  • Create API schema:

Create a base.schema-validation.ts file

import Joi from 'joi';

export class BaseSchemaValidation {
  protected readonly _httpRequest: { params?: any; query?: any; body?: any };

  constructor() {
    this._httpRequest = {};
  }
  
  withBody(body) {
    this._httpRequest.body = body;

    return this;
  }
  
  build() {
    return {
      ...(this._httpRequest.body && { body: this._httpRequest.body }),
      ...(this._httpRequest.query && { query: this._httpRequest.query }),
      ...(this._httpRequest.params && { query: this._httpRequest.params }),
    };
  }
}

Create login.schema-validation.ts file to validate data input for login route

import Joi from 'joi';

export class LoginSchemaValidation extends BaseSchemaValidation {
  constructor() {
    super();
  }
  
  login() {
    return super
      .withBody({
        email: Joi.string().email().required(),
        password: Joi.string().custom(password).required(),
      })
      .build();
  }
}

const password = (value: string, helpers: CustomHelpers) => {
  if (value.length < 8) {
    return helpers.message({ custom: 'password must be at least 8 characters' });
  }
  if (!value.match(/\d/) ?? !value.match(/[a-zA-Z]/)) {
    return helpers.message({ custom: 'password must contain at least 1 letter and 1 number' });
  }
  return value;
};
  • Create schema validation middleware:

Create middlewares folder at root folder

mkdir middlewares

Move to middlewares folder then create a schema-validator.middleware.ts file

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

import Joi, { ValidationOptions } from 'joi';

const pick = (object: Record<string, any>, keys: string[]) =>
  keys.reduce((obj: any, key: string) => {
    if (object && Object.prototype.hasOwnProperty.call(object, key)) {
      obj[key] = object[key];
    }
    return obj;
  }, {});

const schemaValidatorMiddleware = (schema: any, body?: any, options?: ValidationOptions) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      const validSchema = pick(schema, ['params', 'query', 'body']);
      const object = pick(req, Object.keys(validSchema));
      const { value, error } = Joi.compile(validSchema)
        .prefs({ errors: { label: 'key' } })
        .validate(object, { abortEarly: false });

      if (error) {
        console.log(
          `Validate request has an error ${JSON.stringify({
            ...error,
          })}`,
        );

        const errorMessage = {};

        error.details.forEach((e: any) => {
          errorMessage[e.path[0]] = [...(errorMessage[e.path[0]] ?? []), e.message];
        });

        return res.status(422).json(errorMessage);
      }

      Object.assign(req, value);

      return next();
    } catch (error: any) {
      console.log('Validate request has an error', {
        ...error,
      });

      return res.status(422).json({
        message: 'Validate request has an error'
      })
    }
  };
};

export { schemaValidatorMiddleware };

Add schema validator middleware into auth.route.ts

export class AuthRoute {
  initialRoutes() {
    this.loginWithPassword([schemaValidatorMiddleware(new LoginSchemaValidation().login())]);
  }
  
  login(middleware: any = []) {
    this.router.post('/login', middleware, (req: Request, res: Response, next: NextFunction) =>
      this.controller.login(req, res, next),
    );
  }
}
  • Testing:

Start your project to test

npm start

Request without body

Email/password is incorrect format

Summary

In this article, you created yourself a schema to check input data before executing logic for a REST API using Joi and validating data from an HTTP request using middleware.

Having consistent data ensures that it will behave in a reliable and expected way when you reference it in your application.

Good luck to you, hope this post is of value to you!!!!


Reference documents

https://joi.dev/api/?v=17.9.1