Search code examples
typescriptnestjsclass-validator

NestJs reusable controller with validation


Most of my NestJs controllers look the same. They have basic CRUD functionality and do the exact same things.

The only differences between the controllers are:

  • the path
  • the service that is injected (and the services are all extended from an abstract service)
  • the entity that is returned from the methods
  • the create, update, and query dtos

Here is an example CRUD controller:

@UseGuards(JwtAuthGuard)
@Controller("/api/warehouse/goods-receipts")
export class GoodsReceiptsController
  implements ICrudController<GoodsReceipt, CreateGoodsReceiptDto, UpdateGoodsReceiptDto, QueryGoodsReceiptDto> {
  constructor(private service: GoodsReceiptsService) {
  }

  @Post()
  create(@Body() body: CreateGoodsReceiptDto, @CurrentUser() user: Partial<User>): Promise<GoodsReceipt> {
    return this.service.createItem(body, user);
  }

  @Delete(":id")
  delete(@Param() params: NumberIdDto): Promise<Partial<GoodsReceipt>> {
    return this.service.deleteItem(params.id);
  }

  @Get(":id")
  getOne(@Param() params: NumberIdDto): Promise<GoodsReceipt> {
    return this.service.getItem(params.id);
  }

  @Get()
  get(@Query() query: QueryGoodsReceiptDto): Promise<GoodsReceipt[]> {
    return this.service.getItems(query);
  }

  @Patch()
  update(@Body() body: UpdateGoodsReceiptDto, @CurrentUser() user: Partial<User>): Promise<GoodsReceipt> {
    return this.service.updateItem(body,user);
  }
}

This is the interface I have created for my controllers:

export interface ICrudController<EntityType, CreateDto, UpdateDto, QueryDto> {

  getOne(id: NumberIdDto): Promise<EntityType>;

  get(query: QueryDto): Promise<EntityType[]>;

  create(body: CreateDto, user: Partial<User>): Promise<EntityType>;

  update(body: UpdateDto, user: Partial<User>): Promise<EntityType>;

  delete(id: NumberIdDto): Promise<Partial<EntityType>>;
}

Writing all these repetitive controllers has got pretty tiresome (yes I know about nest g resource but that is not really the point of this question), so I decided to create an abstract controller that will do most of the heavy lifting and have the controllers extend this.

export abstract class CrudController<T, C, U, Q> implements ICrudController<T, C, U, Q> {
  protected service: ICrudService<T, C, U, Q>;

  @Post()
  create(@Body() body: C, @CurrentUser() user: Partial<User>): Promise<T> {
    return this.service.createItem(body, user);
  }

  @Get(":id")
  getOne(@Param() params: NumberIdDto): Promise<T> {
    return this.service.getItem(params.id);
  }

  @Get()
  get(@Query() query: Q): Promise<T[]> {
    return this.service.getItems(query);
  }

  @Delete(":id")
  delete(@Param() params: NumberIdDto): Promise<Partial<T>> {
    return this.service.deleteItem(params.id);
  }

  @Patch()
  update(@Body() body: U, @CurrentUser() user: Partial<User>): Promise<T> {
    return this.service.updateItem(body, user);
  }
}

Now all I need to do to add a new controller is this:

@UseGuards(JwtAuthGuard)
@Controller("/api/warehouse/goods-receipts")
export class GoodsReceiptsController
  extends CrudController<GoodsReceipt, CreateGoodsReceiptDto, UpdateGoodsReceiptDto, QueryGoodsReceiptDto> {
  constructor(protected service: GoodsReceiptsService) {
    super();
  }
}

I was very proud of myself at that point. That is until I figured out that validation no longer works because class-validator doesn't work with generic types.

There has to be some way I could fix this with minimal intervention and maximal use of reusable code?


Solution

  • I have managed to make it work using this answer https://stackoverflow.com/a/64802874/1320704

    The trick is to create a controller factory, and use a custom validation pipe.

    Here is the solution:

    @Injectable()
    export class AbstractValidationPipe extends ValidationPipe {
      constructor(
        options: ValidationPipeOptions,
        private readonly targetTypes: { body?: Type; query?: Type; param?: Type; }
      ) {
        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 function ControllerFactory<T, C, U, Q>(
      createDto: Type<C>,
      updateDto: Type<U>,
      queryDto: Type<Q>
    ): ClassType<ICrudController<T, C, U, Q>> {
      const createPipe = new AbstractValidationPipe({ whitelist: true, transform: true }, { body: createDto });
      const updatePipe = new AbstractValidationPipe({ whitelist: true, transform: true }, { body: updateDto });
      const queryPipe = new AbstractValidationPipe({ whitelist: true, transform: true }, { query: queryDto });
    
      class CrudController<T, C, U, Q> implements ICrudController<T, C, U, Q> {
        protected service: ICrudService<T, C, U, Q>;
    
        @Post()
        @UsePipes(createPipe)
        async create(@Body() body: C, @CurrentUser() user: Partial<User>): Promise<T> {
          return this.service.createItem(body, user);
        }
    
        @Get(":id")
        getOne(@Param() params: NumberIdDto): Promise<T> {
          return this.service.getItem(params.id);
        }
    
        @Get()
        @UsePipes(queryPipe)
        get(@Query() query: Q): Promise<T[]> {
          return this.service.getItems(query);
        }
    
        @Delete(":id")
        delete(@Param() params: NumberIdDto): Promise<Partial<T>> {
          return this.service.deleteItem(params.id);
        }
    
        @Patch()
        @UsePipes(updatePipe)
        update(@Body() body: U, @CurrentUser() user: Partial<User>): Promise<T> {
          return this.service.updateItem(body, user);
        }
      }
    
      return CrudController;
    }
    

    And to create the actual controller you simply pass the desired dtos to the factory:

    @UseGuards(JwtAuthGuard)
    @Controller("/api/warehouse/goods-receipts")
    export class GoodsReceiptsController
      extends ControllerFactory<GoodsReceipt, CreateGoodsReceiptDto, UpdateGoodsReceiptDto, QueryGoodsReceiptDto>
      (CreateGoodsReceiptDto,UpdateGoodsReceiptDto,QueryGoodsReceiptDto){
      constructor(protected service: GoodsReceiptsService) {
        super();
      }
    }
    

    You can also optionally pass the response entity type into the factory and use that with the @ApiResponse tag if you use swagger. Also you could pass the path to the factory and move all the decorators (Controller, UseGuards etc.) to the factory controller definition.