Brakebein / prisma-generator-nestjs-dto

Generates NestJS DTO classes from Prisma Schema
Apache License 2.0
51 stars 28 forks source link

Adding validation decorators to plain DTO and Entity #30

Open gwesterman opened 1 year ago

gwesterman commented 1 year ago

Currently, validation decorators are only added to CreateDTO and UpdateDTO, the types of DTOs which are used to send data to the API, unlike Entity and plain DTO, which are returned by the API and therefore normally do not need validation decorators.

Scenarios

Usage of Query() together with a DTO

I use the plain DTO to send data to the API, but since it lacks validation decorators the necessary type transformations are not applied, forcing me to transform the values manually.

The plain DTO is used to type the query parameters of my GET endpoints. This allows consumers of the API to filter results using a combination of any of the DTO's fields. This is done by mapping the plain DTO to a QueryDTO using Nest's PartialType() utility function, which creates a copy of the plain DTO with all fields now optional.

// query-profile.dto.ts
import { PartialType } from '@nestjs/swagger';
import { ProfileDto } from '../generated';

export class QueryProfileDto extends PartialType(ProfileDto) {}
// profile.controller.ts
@Get()
get(@Query() query: QueryProfileDto) {
   return this.profileService.get(query);
}

Since QueryDTO is now based on the plain DTO it always reflects the table and the API consumer can apply whatever filter combination they want.

Other possible scenarios

Nest's mapping utility functions can also be used to create more fine-grained DTOs or Entities for specific scenarios, for instance by using PickType() to trim the DTO down to what is needed.

For this to work well though, the base DTOs/Entities that are used together with these utility functions need to have class validators.

I propose an additional configuration flag that, if set to true, causes these validators to be added to both the plain DTO and Entity.

Problem when using Query() together with a DTO

Transformation using validation decorators only works when using Query(), if enableImplicitConversion enabled:

// main.ts
app.useGlobalPipes(
   new ValidationPipe({
      transform: true,
      transformOptions: { enableImplicitConversion: true }
   })
);

This is not optimal, as there are downsides to using enableImplicitConversion.

Solution

Explicitly adding a transform decorator to the property does the trick:

// profile.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsOptional } from 'class-validator';
import { Transform } from 'class-transformer';

@ApiProperty({
   type: 'integer',
   format: 'int32',
   required: false,
   nullable: true
})
@IsOptional()
@IsInt()
@Transform((params) => Number(params.value))
age?: number | null;

Note

Instead of adding these decorators to plain DTO and Entity, a new DTO type could be introduced, which includes all fields the plain DTO possesses, but also adds the required validation and transform decorators.

Brakebein commented 1 year ago

Interesting use case. I think a separate QueryDTO would be the cleaner solution. Especially if it needs extra type conversion.

So, query params are all strings by default. For basic types, type conversion should be simple.

?q=foo&q=bar&bool=true&count=10

should give

{
  q: ['foo', 'bar'],
  bool: true,
  count: 10,
}

But what if the user defines more complex types, e.g., relations etc.?

model Profile {
  user  User
}
gwesterman commented 1 year ago

I too think that a dedicated QueryDTO would make a lot of sense.

I do not however think, that the QueryDTO has to cover relations, as those would be available under their own path using a dedicated QueryDTO.

/profiles/ would accept query parameters defined in QueryProfileDTO /profiles/123/followers would also accept QueryProfileDTO (followers are other profiles/users) /profiles/123/galleries would accept QueryGalleryDTO

The /profiles/ endpoint can return a ResultDTO that includes the actual data requested, information about pagination and sorting and also additional links, pointing to the linked resources that can be queried separately.

But I would consider all of this API implementation details, that do not directly concern this library.

Mapping multiple DTOs into a single QueryDTO can get quite messy if the DTO properties are not unique.

Sometimes you want endpoints that take relations into consideration of course, as this would reduce the amount of queries needed, but in this case I would manually create a dedicated DTO, which can also be done using Nest's PartialType() mapping utility function, based on the QueryDTOs in question.

Since the QueryDTOs, the new QueryDTO is based on, have all the transformation decorators in place these custom QueryDTOs should be able to make use of them, too.

Brakebein commented 1 year ago

My question above was about how to handle relations and complex types. Since query params can only be simple strings, I think the fields meta and user in the example below should just be ignored. So, only fields of basic types (string, number, boolean) can be queried.

The following model:

model Profile {
  id      String @id
  age     Int
  isAdmin Boolean
  tags    String[]
  meta    Json
  user    User @relation(...)
}

could result in something like this:

export class QueryProfileDto {
  @ApiProperty({
    required: false,
    nullable: true
  })
  @IsOptional()
  @IsString()
  id?: string;

  @ApiProperty({
    type: 'integer',
    format: 'int32',
    required: false,
    nullable: true
  })
  @IsOptional()
  @IsInteger()
  @Type(() => Number)
  age?: number;

  @ApiProperty({
    required: false,
    nullable: true
  })
  @IsOptional()
  @IsBoolean()
  @Transform(({ value }) => value === 'true' || value === '1')
  isAdmin?: boolean;

  @ApiProperty({
    isArray: true,
    required: false,
    nullable: true
  })
  @IsOptional()
  @IsArray()
  @IsString()
  tags?: string[];
}

Possible query url:

?tags=foo&tags=bar&age=40&isAdmin=1

I think | null is not necessary, because unlike in the request body you can't pass null. Either the query param is defined or not. Maybe tags also needs some custom @Transform, because a single ?tags=foo may result only in a string, but not string[].