Search code examples
nestjs

Dynamic DTO at the moment of executing an http request in Nest.js


There are several DTO that need to be used dynamically based on the http parameter that comes in the request. For example POST /api/tasks?dto_name=dto_1, you need to substitute the required DTO (dto_1) into the controller, taking into account its validation. Is there a way to do this in Nest.js?

I tried to implement via generics, I can't pass the parameter from http.


Solution

  • It's possible, by implementing the validation pipe yourself.

    This is a simple pipe that does what you want:

    class Dto1 {
      @IsNumber()
      n: number;
    }
    
    class Dto2 {
      @IsBoolean()
      b: boolean;
    }
    
    const DTO_DICT = {
      dto_1: Dto1,
      dto_2: Dto2,
    };
    
    /* A type alias to dynamically get a union of all classes from the dictionary */
    export type DtosUnion = InstanceType<ValuesOf<typeof DTO_DICT>>;
    
    const mappedPipe = async (
      payload: Record<string, any>,
      dtoKey: keyof typeof DTO_DICT,
      validationPipeOptions: ValidationPipeOptions = { transformOptions: undefined }, // You might want to pass global pipe options or override transform behavior
    ): Promise<DtosUnion> => {
      /* Validate payload, make sure it exists in your DTO dictionary */
      if (!payload) throw new BadRequestException(`Payload is not an object`);
      const constructor = DTO_DICT[dtoKey];
      if (!constructor) throw new BadRequestException(`Dto doesn't exists: ${dtoKey}`);
    
      /* Use the constructor that you found to instantiate the relevant Dto */
      const transformed = plainToInstance<any, any>(
        constructor,
        payload,
        validationPipeOptions.transformOptions,
      );
    
      /* Run the validations */
      const validation = await validate(transformed);
      if (!validation.length) return transformed;
    
      /* This is what Nest does out of the box behind the scenes when validation fails */
      const validationPipe = new ValidationPipe(validationPipeOptions);
      const exceptionFactory = validationPipe.createExceptionFactory();
      const exception = exceptionFactory(validation);
      throw exception;
    };
    

    This is how it's used:

    @Post()
    async create(@Body() dto: DtosUnion, @Query('dto_name') dtoName: string): Promise<any> {
      const validatedDto = mappedPipe(dto, dtoName);
    
      /* Do what you need with the `validatedDto` */
      /* ... */
    }
    

    validatedDto is still of type DtosUnion, since after the pipe it can be any of the DTOs(or it can fail validation and throw). If you'll print validatedDto in runtime - you'll get the specific DTO you expect.

    Do note that if the provided DTO name matches a DTO that has loose validation, it might pass validation for payloads that shouldn't have been passing.