Pagination et CRUD en NestJS

Qui suis-je ?

Un peu de contexte

Un peu de contexte

De quoi ai-je besoin ?

  • Mettre en place facilement des routes GET/POST/PUT/DELETE
  • Outil facile à configurer
  • Outil qui fonctionne avec mon ORM (typeorm)

La solution

@nestjsx/crud

Les features clés

  • Facile à mettre en place
  • Facile à overrider
  • Stratégie de filtres
  • Validation du payload à partir du DTO
  • Pagination out of the box
  • Swagger out of the box

Comment ça marche ?

L'entité

import { CrudValidationGroups } from '@nestjsx/crud';

const { CREATE, UPDATE } = CrudValidationGroups;

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @IsString()
  @Column({ length: 50 })
  name: string;

  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @IsString()
  @Column({ length: 100, select: false })
  password: string;

  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @IsString()
  @Column({ length: 100 })
  email: string;

  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @IsString({ each: true })
  @Column('simple-array')
  roles: string[];
}

Comment ça marche ?

Le controlleur

Le décorateur Crud

@Crud({
  model: {
    type: User,
  },
  params: {
    id: {
      field: 'id',
      type: 'uuid',
      primary: true,
    },
  },
  routes: {
    only: [
      'getOneBase', 
      'getManyBase', 
      'deleteOneBase', 
      'createOneBase', 
      'updateOneBase'
    ],
    getOneBase: {
      decorators: [Roles('admin')]
    },
    getManyBase: {
      decorators: [Roles('admin')]
    },
    deleteOneBase: {
      decorators: [Roles('admin')]
    }
  },
  query: {
    exclude: ['password']
  }
})

Comment ça marche ?

Le controlleur

Les routes surchargées

@Controller('users')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class UserController implements CrudController<User> {
  constructor(
    public readonly service: UserService,
  ) {}

  @Override()
  createOne(@Body() user: User) {
    return this.service.createUser(user);
  }

  @Roles('admin')
  @Override('updateOneBase')
  anotherName(@Param('id') id: string, @Body() user: User) {
    return this.service.updateUser(id, user);
  }
}

Comment ça marche ?

Le service

@Injectable()
export class UserService extends TypeOrmCrudService<User> {
  constructor(@InjectRepository(User) private readonly userRepository: Repository<User>) {
    super(userRepository);
  }

  createUser = async (user: User) => {
    const plainPassword = user.password;
    const encryptedPassword = await hash(plainPassword, SALT_ROUNDS);
    const createdUser = await this.userRepository.save({ ...user, password: encryptedPassword });
    const { password, ...createdUserDto } = createdUser;
    return createdUserDto;
  };

  updateUser = async (id: string, user: User) => {
    let newUser = user;
    const plainPassword = user.password;
    if (Boolean(plainPassword)) {
      const encryptedPassword = await hash(plainPassword, SALT_ROUNDS);
      newUser = { ...newUser, password: encryptedPassword };
    }

    await this.userRepository.update({ id }, newUser);

    return await this.userRepository.findOne({ id });
  };
}

On rajoute une entité ?

import { CrudValidationGroups } from '@nestjsx/crud';

const { CREATE, UPDATE } = CrudValidationGroups;

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @IsString()
  @Column({ length: 50 })
  name: string;

  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @IsString()
  @Column({ length: 100, select: false })
  password: string;

  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @IsString()
  @Column({ length: 100 })
  email: string;

  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @IsString({ each: true })
  @Column('simple-array')
  roles: string[];
}

Ce qu'il se passe

Les solutions

L'exclude dans la jointure

@Crud({
  model: {
    type: Article,
  },
  params: {
    id: {
      field: 'id',
      type: 'uuid',
      primary: true,
    },
  },
  query: {
    join: {
      user: { eager: true, exclude: ['password'] },
    },
    alwaysPaginate: true,
  },
})

Les solutions

L'entité

// user.entity.ts
 
import { Exclude } from 'class-transformer';
...

@Entity('users')
export class User {
  ...
  
  @Exclude()
  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @IsString()
  @Column({ length: 100, select: false })
  password: string;

  ...
}
  
  
// article.controller.ts
  
import { UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common';
...

@UseInterceptors(ClassSerializerInterceptor)
@Controller('articles')
...

Les limites

  • Ignore les hidden columns de typeorm
  • Fonctionnement orienté blacklist (/!\ sécu)
  • Génère automatiquement toutes les routes sauf si vous précisez le contraire

Quelles alternatives ?

  • Les exemples de la doc Nest
  • Les query builders de typeorm
  • nestjs-typeorm-paginate (soon 2.0 <3)

Quelles alternatives ?

function getCandidates(options: IPaginationOptions): Promise<CustomPagination<Candidate>> {
  const queryBuilder = this.candidateRepository
  	.createQueryBuilder('candidate')
    .leftJoinAndSelect('candidate.headhunter', 'headhunter')
    .leftJoinAndSelect('candidate.school', 'school');
	
  return customPaginate<Candidate>(queryBuilder, options);
}

interface IPaginationOptions {
    limit: number;
    page: number;
    route?: string;
}

@nestjsx/crud ou pas ?

Questions

NestJs Paris #3

By Léo Anesi

NestJs Paris #3

  • 515