Build awareness and adoption for your software startup with Circuit.

Request Multi DTOs Validation — NestJS

Implementing a custom validation pipe in a NestJS application.

Introduction

In NestJS applications, validating request payload with one of multiple DTOs (Data Transfer Objects) is a common requirement. By leveraging decorators and validation pipes, we can ensure incoming data matches the expected structures and adheres to defined validation rules. In this post, we’ll explore an approach to achieve this flexibility in NestJS controllers.

Problem Statement:

When developing RESTful APIs, it’s often necessary to create endpoints that can handle multiple types of data with varying structures. Consider the following scenarios:

  • User Profile Updates: An API endpoint /users/profile is responsible for updating user profiles. However, user profiles can have different fields based on user roles or account types. For example, a regular user may only update their name and email, while an administrator may also have access to update additional fields such as permissions or roles.
  • Document Upload Endpoint: An endpoint /documents/upload allows users to upload various types of documents. Depending on the document type (e.g., resume, invoice, contract), the required fields and validation rules may differ. For example, a resume may require fields such as education, work experience, and skills, while an invoice may need details like invoice number, date, and total amount.

Solution Overview: Handling Multiple DTOs with Conditional Validation

In this solution, we aim to handle multiple DTOs with conditional validation based on a common field, typically named 'type'. Each DTO will be associated with a specific value for this field, allowing us to match incoming requests against the appropriate DTO for validation. If a matching DTO is found, we validate the request body against it. Otherwise, we throw an exception indicating that no valid DTO was found.

Implement TypeField Decorator

This code defines a decorator that we can use to decorate the 'type' field within our Data Transfer Objects (DTOs). By applying this decorator, we assign constant value to the type field in the metadata of the DTO prototype. This metadata becomes invaluable when utilizing the MultiDtoValidationPipe, as it allows us to retrieve the value of the DTO's type field dynamically during validation.

// multi-dto.decorator.ts

import 'reflect-metadata';
const ALIAS_NAME = 'md';
const TYPE_FIELD_KEY = "TYPE_FIELD"

interface MD_TYPE_FIELD {
 value: string,
 propertyKey: string,
}

function TypeField(type: string): PropertyDecorator {
 return (target: Object, propertyKey: string | symbol) => {
  Reflect.defineMetadata(ALIAS_NAME, { value: type, propertyKey }, target, TYPE_FIELD_KEY);
 };
}

function getTypeFieldValue(target: any): MD_TYPE_FIELD {
 return Reflect.getMetadata(ALIAS_NAME, target, TYPE_FIELD_KEY);
}

export const md = {
 TypeField,
 getTypeFieldValue,
};

Implement MultiDtoValidationPipe

By supplying a list of DTOs upon instantiation, this pipe iterates through them within its transform method. It retrieves the ‘type’ field value of each DTO using the md.getTypeFieldValue function and compares it with the corresponding ‘type’ field value in the client’s request payload. If a match is found, the data is validated using the matched DTO. If no match is found, indicating that no valid DTO exists for the provided ‘type’, a BadRequestException is thrown, signaling the absence of a suitable DTO for validation.

// multi-dto.validation.pipe.ts

import { Injectable, PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate, ValidationError } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { md } from './multi-dto.decorator';

@Injectable()
export class MultiDtoValidationPipe implements PipeTransform<any> {
 constructor(private readonly dtos: any[]) {}

 formatValidationErrors(errors: ValidationError[], parentProperty = '): Record<string, string[]> {
  const formattedErrors: Record<string, string[]> = {};

  for (const error of errors) {
   const property = error.property;
   const constraints = error.constraints;
   const children = error.children;
   const currentProperty = parentProperty ? `${parentProperty}.${property}` : property;
   if (constraints) {
    formattedErrors[currentProperty] = Object.values(constraints);
   }
   if (children && children.length > 0) {
    const nestedErrors = this.formatValidationErrors(children, currentProperty);
    Object.entries(nestedErrors).forEach(([nestedProperty, nestedMessages]) => {
     if (!formattedErrors[nestedProperty]) {
      formattedErrors[nestedProperty] = [];
     }
     formattedErrors[nestedProperty] = formattedErrors[nestedProperty].concat(nestedMessages);
    });
   }
  }

  return formattedErrors;
 }

 async transform(value: any, metadata: ArgumentMetadata) {
  let errors = [];
  let validDtoFound = false;

  for (const dto of this.dtos) {
   const mdType = md.getTypeFieldValue(dto.prototype);
   const { [mdType.propertyKey]: payload_type, ...payload } = value;

   if (mdType.value === payload_type) {
    validDtoFound = true;

    const object = plainToInstance(dto, payload);
    errors = await validate(object, {
     whitelist: true,
     forbidNonWhitelisted: true,
    });

    if (errors.length === 0) {
     return value;
    }
    break;
   }
  }

  if (!validDtoFound) {
   throw new BadRequestException('No valid DTO found');
  }

  throw new BadRequestException({ message: this.formatValidationErrors(errors) });
 }
}

Usage

here we are defining DTOs are with a field named md_type, which is decorated by the md.TypeField decorator.

// dto.ts

import md from './multi-dto';

export enum MdType {
  FIRST = 'FIRST',
  SECOND = 'SECOND',
}

export class FirstDto {
  @md.TypeField(MdType.FIRST)
  readonly md_type: MdType.FIRST;

  @IsNotEmpty()
  @IsString()
  readonly name: string;
}

export class SecondDto {
  @md.TypeField(MdType.SECOND)
  readonly md_type: MdType.SECOND;

  @IsNotEmpty()
  @IsEmail()
  readonly email: string;
}

Controller

We assign our validation pipe, containing a list of DTOs, to the NestJS Body decorator. By doing so, we specify the expected structure of the incoming request body. Additionally, we assign a union type to the data parameter. Consequently, we gain the capability to inspect data.md_type within our endpoint handler, enabling us to execute specific logic based on the type of data received.

// app.controller.ts

import md from './multi-dto';

@Controller()
export class AppController {
  @Post()
  async handleMultiDto(
    @Body(new md.ValidationPipe([FirstDto, SecondDto])) 
      data: FirstDto | SecondDto
  ) {
    if(data.md_type === MdType.FIRST) {
      return data.name;
    }

    if(data.md_type === MdType.SECOND) {
      return data.email;
    }

    console.log(data);
  }
}

Summary

Thank you for reading my post. In summary, the implementation of a custom validation pipe in our NestJS application enables us to effectively handle incoming data with varying structures. By associating constant values with specific fields in our DTOs and utilizing the MultiDtoValidationPipe, we ensure that each payload is validated according to its designated structure. This approach enhances the flexibility and robustness of our API, allowing for dynamic handling of diverse data types within a single endpoint.

Gitlab snippethttps://gitlab.com/-/snippets/3681469




Continue Learning