Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

@UsePipes(ValidationPipe) not working with generics (abstract controller)

I am building an API using Nest.js and MySQL. Due to agility and DRY principles, I am creating an OOP structure which sets all the basic CRUD endpoints for a given Entity (from TypeORM). The main goal is to avoid writing the same common methods for different Entities.

To achieve that, I'm using a strategy with TypeScript Generics. I still have to create all the common files (.controller.ts, .service.ts, .module.ts, .entity.ts) for each Entity, but I don't have to write its methods. Instead, I just extends two classes: RestController and RestService. These classes already implements the common methods, but I have to pass some T types as parameters so TypeORM can inject the right repository to the Service.

The problem: The @UsePipes decorator is not being called when I use it in the parent class (RestController), but it works normally when I overwrite de RestController create method in the child class (SubcategoriesController).

rest.controller.ts:

import { Get, Post, Body, Param, Put, Delete, UsePipes, ValidationPipe } from '@nestjs/common';
import { RestService } from './rest.service';
import { ObjectLiteral } from 'typeorm';

export abstract class RestController<T, C = T, U = T> {
  constructor(protected service: RestService<T, C, U>) {}

  @Get()
  async index(): Promise<T[]> {
    return this.service.getAll();
  }

  @Post('create')
  @UsePipes(ValidationPipe) //HERE!
  async create(@Body() data: C): Promise<T> {
    return this.service.create(data as C);
  }
}

rest.service.ts:

import { Repository, UpdateResult, DeleteResult, Entity, DeepPartial } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';

export interface RestClass<T, C = T, U = T> {
  // Properties
  repository: Repository<T>;

  // Default Methods
  getAll(): Promise<T[]>;
  create(model: T | C | U): Promise<T>;
}

export class RestService<T, C = T, U = T> implements RestClass<T, C, U> {
  constructor(
    public repository: Repository<T>,
  ) {}

  getAll = async () => {
    return await this.repository.find({relations:: this.repository.metadata.ownRelations.map(r => r.propertyName)});
  }

  create = async (model: C) => {
    return await this.repository.save(model as C);
  }
}

And here is how I set a real entity endpoints, extending the above classes:

subcategories.controller.ts:

import { Controller, Get, Post, UsePipes, ValidationPipe, Body } from '@nestjs/common';
import { SubcategoriesService } from './subcategories.service';
import { Subcategory } from './subcategory.entity';
import { RestController } from '../rest.controller';
import { CreateSubcategoryDTO } from './dto/createSubcategory.dto';

//NOTE THE TYPE PARAMS IN <>
@Controller('subcategories')
export class SubcategoriesController extends RestController<Subcategory, CreateSubcategoryDTO> {
  constructor(public service: SubcategoriesService) {
    super(service);
  }    
}

subcategories.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Subcategory } from './subcategory.entity';
import { Repository } from 'typeorm';
import { RestService } from '../rest.service';
import { CreateSubcategoryDTO } from './dto/createSubcategory.dto';

//NOTE THE TYPE PARAMS IN <>
@Injectable()
export class SubcategoriesService extends RestService<Subcategory, CreateSubcategoryDTO> {

  constructor(
    @InjectRepository(Subcategory) repository: Repository<Subcategory>,
  ) {
    super(repository);
  }
}

createSubcategory.dto.ts

import { IsString, Length, IsInt } from 'class-validator';

export class CreateSubcategoryDTO {

  @IsString()
  @Length(5, 60)
  name: string;

  @IsString()
  @Length(0, 140)
  summary: string;

  @Length(0, 140)
  icon: string;

  @IsInt()
  category: number;
}

You can see that the parent class accepts 3 types parameters:

  • T : the Entity
  • C : the CreateDTO, optional
  • U : the UpdateDTO, optional

The code above creates the endpoints perfectly, however it does not validate the payload in the /create, as expected from the ValidationPipe.

If I overwrite the create method in the SubcategoriesController, and add the UsePipes there, it works!

I think this may be an error referring to Nests lifecycle, which may not support the use of Pipes in abstract classes.

Does someone have an idea?

P.S. There are no transpilation errors, lint warnings or runtime exceptions.

like image 593
IntegraStudio Avatar asked Oct 20 '25 09:10

IntegraStudio


1 Answers

One solution to this is to create a factory function for your controllers that will accept your body param class as an argument and then pass it to a custom ValidationPipe extension like this:

@Injectable()
export class AbstractValidationPipe extends ValidationPipe {
  constructor(
    options: ValidationPipeOptions,
    private readonly targetTypes: {
      body?: Type<any>;
      query?: Type<any>;
      param?: Type<any>;
      custom?: Type<any>;
    },
  ) {
    super(options);
  }

  async transform(value: any, metadata: ArgumentMetadata) {
    const targetType = this.targetTypes[metadata.type];
    if (!targetType) {
      return super.transform(value, metadata);
    }
    return super.transform(value, { ...metadata, metatype: targetType });
  }
}

export interface IController<T> {
  hello(body: T);
}

export function Factory<T>(bodyDto: ClassType<T>): ClassType<IController<T>> {
  @Controller()
  class ControllerHost<T> implements IController<T> {
    @Post()
    @UsePipes(new AbstractValidationPipe({whitelist: true, transform: true}, {body: bodyDto}))
    hello(@Body() body: T) {
      return "hello"
    }
  }
  return ControllerHost;
}

export class MyDto {
  @Expose()
  @IsDefined()
  @IsString()
  hello: string;
}

export class AppController extends Factory<MyDto>(MyDto) {}

There is no information on generics available with Reflection so standard ValidationPipe is not getting any meaningful information from metadata.metatype. I'm working around this by providing it with optional types params that it can use to overwrite the content of metadata.metatype. It has this nice feature that it will work for normal use cases (without generics) too. If you want to overwrite query or param too just provided appropriate values through targetTypes param.

like image 99
pbn Avatar answered Oct 21 '25 23:10

pbn



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!