Pagination et filtrage des résultats d'API dans NestJS

Pagination et filtrage des résultats d'API dans NestJS

Lorsqu'une API expose une liste de ressources, il est tentant de tout renvoyer en une seule requête. Cette approche fonctionne au début, mais elle devient rapidement problématique : requêtes lentes, bande passante saturée, mémoire serveur sous tension et expérience utilisateur dégradée. Dans ce tutoriel, nous allons construire un système de pagination et de filtrage réutilisable pour NestJS, en s'appuyant sur TypeORM, les DTO et la validation. Nous reprendrons les conventions déjà en place dans une API existante (réponses normalisées via ApiResponseDto, IDs UUID, décorateurs @Roles et @Public).

Pourquoi paginer ?

La pagination répond à plusieurs enjeux concrets :

  • Performance : une requête SELECT * FROM bookings sur 100 000 lignes mobilise inutilement la base et le réseau.
  • UX : l'utilisateur ne lit jamais 10 000 résultats. Mieux vaut afficher 20 entrées et permettre la navigation.
  • Mémoire : Node.js charge tout le résultat en RAM avant de le sérialiser en JSON. Sans limite, un pic de trafic peut faire planter l'instance.
  • Coût : moins de données transférées = factures cloud plus légères.

L'objectif est donc de proposer trois mécanismes complémentaires : pagination (page/limit), filtrage (par statut, dates, etc.) et tri dynamique.

Schéma illustrant la pagination et le filtrage d'une API NestJS
Image générée par Google Gemini

Prérequis

  • NestJS 10+ avec un module TypeORM configuré
  • class-validator et class-transformer installés
  • Un ValidationPipe global avec transform: true activé dans main.ts

1. Un DTO de pagination réutilisable

La première étape consiste à créer un DTO générique qui sera étendu par tous les endpoints listant des ressources. Il porte les paramètres communs : page, limit, sortBy, sortOrder et un éventuel search.

// src/common/dto/pagination-query.dto.ts
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, IsIn, Min, Max, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';

export class PaginationQueryDto {
  @ApiPropertyOptional({ default: 1, minimum: 1 })
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @IsOptional()
  page: number = 1;

  @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100) // Limite maximale pour éviter les abus
  @IsOptional()
  limit: number = 20;

  @ApiPropertyOptional({ description: 'Champ utilisé pour le tri' })
  @IsString()
  @IsOptional()
  @MaxLength(50)
  sortBy?: string;

  @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
  @IsIn(['ASC', 'DESC'])
  @IsOptional()
  sortOrder: 'ASC' | 'DESC' = 'DESC';

  @ApiPropertyOptional({ description: 'Recherche textuelle' })
  @IsString()
  @IsOptional()
  @MaxLength(100)
  search?: string;
}

Quelques points clés :

  • @Type(() => Number) est indispensable car les query params arrivent en string. Sans cette transformation, @IsInt() échouerait systématiquement.
  • @Max(100) protège la base d'un client qui demanderait ?limit=999999.
  • Les valeurs par défaut sont fixées directement dans la classe : si le client ne les fournit pas, NestJS les conserve.

2. Un format de réponse paginée standardisé

Pour rester cohérent avec le pattern ApiResponseDto déjà utilisé par les contrôleurs (ServicesController, BookingController), nous créons une enveloppe dédiée à la pagination.

// src/common/dto/paginated-result.dto.ts
export class PaginatedResult<T> {
  data: T[];
  meta: {
    total: number;
    page: number;
    limit: number;
    lastPage: number;
    hasNext: boolean;
    hasPrev: boolean;
  };

  constructor(data: T[], total: number, page: number, limit: number) {
    this.data = data;
    const lastPage = Math.max(1, Math.ceil(total / limit));
    this.meta = {
      total,
      page,
      limit,
      lastPage,
      hasNext: page < lastPage,
      hasPrev: page > 1,
    };
  }
}

Ce format expose tout ce dont un front a besoin pour afficher une pagination correcte : nombre total d'éléments, page courante, dernière page, et booléens de navigation.

3. Étendre le DTO pour des filtres spécifiques

Reprenons l'exemple des bookings. Le service BookingService.findAll() accepte déjà status, startDate et endDate. Combinons ces filtres avec la pagination en étendant le DTO de base.

// src/modules/booking/dto/find-bookings.dto.ts
import { IsEnum, IsOptional, IsDateString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { BookingStatus } from '../../../database/entities/booking.entity';

export class FindBookingsDto extends PaginationQueryDto {
  @ApiPropertyOptional({ enum: BookingStatus })
  @IsEnum(BookingStatus)
  @IsOptional()
  status?: BookingStatus;

  @ApiPropertyOptional({ description: 'Date de début (YYYY-MM-DD)' })
  @IsDateString()
  @IsOptional()
  startDate?: string;

  @ApiPropertyOptional({ description: 'Date de fin (YYYY-MM-DD)' })
  @IsDateString()
  @IsOptional()
  endDate?: string;
}

L'héritage permet de mutualiser page, limit, sortBy et sortOrder tout en ajoutant les filtres spécifiques aux bookings.

4. Implémentation côté service avec TypeORM

Le QueryBuilder de TypeORM est idéal ici : il combine élégamment where, orderBy, skip et take. On ajoute aussi un garde-fou sur les colonnes triables pour éviter une injection SQL via sortBy.

// Extrait de booking.service.ts
import { PaginatedResult } from '../../common/dto/paginated-result.dto';
import { FindBookingsDto } from './dto/find-bookings.dto';

private readonly ALLOWED_SORT_FIELDS = [
  'createdAt',
  'bookingDate',
  'status',
  'amountPaid',
];

async findAllPaginated(query: FindBookingsDto): Promise<PaginatedResult<Booking>> {
  const { page, limit, sortBy, sortOrder, search, status, startDate, endDate } = query;

  const qb = this.bookingRepository
    .createQueryBuilder('booking')
    .leftJoinAndSelect('booking.service', 'service')
    .leftJoinAndSelect('booking.user', 'user');

  // Filtrage par statut
  if (status) {
    qb.andWhere('booking.status = :status', { status });
  }

  // Filtrage par plage de dates
  if (startDate) {
    qb.andWhere('booking.bookingDate >= :startDate', { startDate });
  }
  if (endDate) {
    qb.andWhere('booking.bookingDate <= :endDate', { endDate });
  }

  // Recherche textuelle (PostgreSQL : ILIKE pour case-insensitive)
  if (search) {
    qb.andWhere(
      '(booking.guestName ILIKE :search OR booking.guestEmail ILIKE :search OR booking.question ILIKE :search)',
      { search: `%${search}%` },
    );
  }

  // Tri sécurisé : on n'accepte que des colonnes whitelistées
  const safeSortBy = this.ALLOWED_SORT_FIELDS.includes(sortBy ?? '')
    ? sortBy!
    : 'createdAt';
  qb.orderBy(`booking.${safeSortBy}`, sortOrder);

  // Pagination
  qb.skip((page - 1) * limit).take(limit);

  const [data, total] = await qb.getManyAndCount();

  return new PaginatedResult(data, total, page, limit);
}

Pourquoi getManyAndCount() ? Cette méthode exécute deux requêtes en parallèle : une pour récupérer la page et une pour le total. C'est exactement ce qu'il faut pour calculer lastPage sans charger toute la table.

Astuce sécurité : ne JAMAIS injecter directement sortBy dans orderBy() sans whitelist. Un attaquant pourrait passer ?sortBy=(SELECT...) et provoquer un comportement inattendu. La liste ALLOWED_SORT_FIELDS ferme la porte à ce type d'abus.

5. Mise à jour du contrôleur

Le contrôleur reste très simple : on remplace les multiples @Query() individuels par un unique DTO validé automatiquement.

// Extrait de booking.controller.ts
@Get()
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get paginated bookings' })
@ApiResponse({ status: 200, description: 'Paginated list of bookings' })
async findAll(
  @Query() query: FindBookingsDto,
): Promise<ApiResponseDto<PaginatedResult<Booking>>> {
  const result = await this.bookingService.findAllPaginated(query);
  return ApiResponseDto.success(result, 'Bookings retrieved successfully');
}

Un appel typique côté client ressemblera à :

curl "https://api.exemple.com/bookings?page=2&limit=20&status=confirmed&sortBy=bookingDate&sortOrder=ASC&search=dupont"

6. Recherche textuelle : LIKE vs ILIKE

Sur PostgreSQL, ILIKE permet une recherche insensible à la casse. Sur MySQL, on utilisera LOWER(colonne) LIKE LOWER(:search). Pour des recherches plus avancées (multi-mots, pertinence), pensez à to_tsvector/to_tsquery sur PostgreSQL ou à un moteur dédié comme Meilisearch ou Elasticsearch.

Attention aux caractères spéciaux : % et _ sont interprétés par LIKE. Si la recherche peut contenir ces caractères, échappez-les avant l'injection dans la requête.

7. Bonnes pratiques à retenir

  • Toujours imposer une limite max (@Max(100)) pour empêcher les requêtes destructrices.
  • Valeurs par défaut sensées : page=1, limit=20, tri par createdAt DESC.
  • Whitelister les champs triables pour bloquer l'injection SQL via sortBy.
  • Indexer les colonnes filtrées (status, booking_date, created_at) pour des performances stables sur de gros volumes.
  • Privilégier getManyAndCount() plutôt que deux appels séparés.
  • Documenter avec Swagger via @ApiPropertyOptional : la documentation reste synchronisée avec le code.
  • Pour de très gros datasets, envisagez la pagination par curseur (cursor-based) plus performante que OFFSET sur des tables de plusieurs millions de lignes.

Conclusion

Nous avons construit un système de pagination et de filtrage générique, validé et sécurisé, parfaitement intégré au pattern ApiResponseDto de l'API. Le DTO PaginationQueryDto est réutilisable sur n'importe quelle ressource — services, utilisateurs, créneaux horaires — et son extension via héritage permet d'ajouter des filtres métier sans dupliquer la logique de base.

Pour aller plus loin, vous pouvez explorer :

  • La pagination par curseur avec des bibliothèques comme nestjs-paginate ou typeorm-cursor-pagination
  • La mise en cache des résultats paginés via CacheInterceptor de NestJS
  • La recherche full-text avec PostgreSQL (tsvector) ou un moteur dédié
  • L'export CSV streamé pour les cas où l'utilisateur a vraiment besoin de toutes les données

Avec ces fondations, votre API restera rapide et prévisible, quel que soit le volume de données qu'elle manipule.

Commentaires

Soyez le premier à laisser un commentaire !

Laisser un commentaire

Les champs obligatoires sont indiqués avec *