Créer des endpoints de seed pour initialiser les données

Créer des endpoints de seed pour initialiser les données

Lorsqu'on développe une API avec NestJS, la question des données initiales se pose rapidement : comment peupler la base pour le développement local, les tests automatisés, ou encore une démo client ? Plutôt que de saisir manuellement des dizaines d'enregistrements, il est judicieux de créer des endpoints de seed qui permettent d'initialiser ou de régénérer le jeu de données en un clic.

Dans ce tutoriel, nous allons voir comment construire un système de seed propre, sécurisé et idempotent dans une application NestJS, en s'appuyant sur des exemples concrets tirés d'un projet réel (un site de voyance utilisant TypeORM et PostgreSQL).

Pourquoi créer un endpoint de seed ?

Un endpoint de seed permet de remplir la base avec des données réalistes et reproductibles. Les cas d'usage sont nombreux :

  • Développement local : un nouveau développeur clone le repo et obtient immédiatement un environnement fonctionnel.
  • Tests automatisés : un état de base connu permet d'écrire des tests fiables.
  • Démos client : présenter l'application avec un contenu cohérent et professionnel.
  • Environnements de staging : reset rapide après des manipulations.

Contrairement aux migrations (qui modifient la structure du schéma), les seeds gèrent uniquement les données. Et contrairement aux fixtures de test, ils peuvent vivre en production sous une forme contrôlée.

Définir l'endpoint dans le contrôleur

L'idée est d'exposer une route POST /resource/seed protégée par un guard de rôle. Voici un exemple tiré du module OracleController :

import { Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { OracleService } from './oracle.service';
import { Roles } from '../../common/decorators/roles.decorator';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import { UserRole } from '../../database/entities/user.entity';

@ApiTags('oracles')
@Controller('oracles')
export class OracleController {
  constructor(private readonly oracleService: OracleService) {}

  @Post('seed')
  @Roles(UserRole.ADMIN)
  @ApiOperation({ summary: 'Seed initial oracles data' })
  @ApiResponse({ status: 200, description: 'Oracles seeded' })
  async seed(): Promise<ApiResponseDto<void>> {
    await this.oracleService.seed();
    return ApiResponseDto.success(undefined, 'Oracles seeded successfully');
  }
}

Plusieurs choses à remarquer :

  • Le décorateur @Roles(UserRole.ADMIN) restreint l'accès aux administrateurs uniquement (combiné à un JwtAuthGuard et un RolesGuard globaux).
  • L'endpoint retourne un ApiResponseDto, format de réponse cohérent dans toute l'application.
  • Le verbe HTTP est POST car l'opération modifie l'état du serveur.

Implémenter une méthode seed idempotente

Le mot-clé ici est idempotent : appeler l'endpoint plusieurs fois ne doit pas dupliquer les données. La règle d'or consiste à vérifier l'existence avant d'insérer.

@Injectable()
export class OracleService implements OnModuleInit {
  constructor(
    @InjectRepository(Oracle)
    private readonly oracleRepository: Repository<Oracle>,
  ) {}

  async seed(): Promise<void> {
    // Court-circuit : si la table contient déjà des données, on ne fait rien
    const count = await this.oracleRepository.count();
    if (count > 0) {
      return;
    }

    const oracles: CreateOracleDto[] = [
      {
        name: 'Tarot de Marseille',
        subtitle: "L'ancien, la référence absolue",
        description: 'Le tarot traditionnel par excellence...',
        category: 'universel',
        imageUrl: '/oracles/tarot-marseille.jpg',
        cardCount: 78,
        sortOrder: 0,
      },
      // ... autres oracles
    ];

    for (const oracleData of oracles) {
      const oracle = this.oracleRepository.create(oracleData);
      await this.oracleRepository.save(oracle);
    }
  }
}

Cette stratégie « tout ou rien » convient pour des données simples. Pour des cas plus fins, on peut vérifier chaque entrée individuellement via une clé unique :

async seed(): Promise<void> {
  const count = await this.contentRepository.count();
  if (count > 0) return;

  const contents: CreateContentDto[] = [
    { key: 'header.logoText', section: 'header', value: 'Le Murmure des Cartes', /* ... */ },
    // ...
  ];

  for (const contentData of contents) {
    try {
      const existing = await this.contentRepository.findOne({
        where: { key: contentData.key },
      });
      if (!existing) {
        const content = this.contentRepository.create(contentData);
        await this.contentRepository.save(content);
      }
    } catch (error) {
      console.warn(`Content with key "${contentData.key}" already exists, skipping...`);
    }
  }
}

Cette approche, utilisée dans le ContentService, permet d'ajouter de nouvelles entrées à un seed existant sans tout réinitialiser : très pratique quand on étend progressivement le contenu d'un site.

Données réalistes et variées

Un bon seed n'est pas un alignement de « lorem ipsum ». Inspirez-vous de la production :

  • Des noms et descriptions cohérents avec le métier (ici : noms d'oracles authentiques avec sous-titres travaillés).
  • Des catégories variées (universel, amour, travail, spirituel) pour tester les filtres.
  • Des champs optionnels présents et absents pour vérifier le comportement (cardCount renseigné pour le Tarot de Marseille mais pas pour l'Oracle des Émotions).
  • Un ordre de tri (sortOrder) explicite pour valider l'affichage front.

Seed automatique au démarrage avec OnModuleInit

Pour les environnements de dev, on peut aller plus loin et déclencher le seed automatiquement au démarrage du module via le hook de cycle de vie OnModuleInit :

import { Injectable, OnModuleInit } from '@nestjs/common';

@Injectable()
export class OracleService implements OnModuleInit {
  async onModuleInit() {
    await this.seed();
  }

  // ...
}

Grâce à la vérification count > 0 dans la méthode seed(), ce hook est totalement sûr : au premier démarrage, la base est peuplée ; aux démarrages suivants, rien ne se passe. Aucune duplication possible.

Attention : en production, vous voudrez sans doute conditionner cet appel à une variable d'environnement (NODE_ENV !== 'production' ou SEED_ON_START === 'true') pour éviter toute surprise.

Gérer les seeds avec relations

Lorsque les entités sont liées (par exemple ReservationServiceUser), il faut respecter l'ordre d'insertion : les parents avant les enfants. Voici le pattern :

async seed(): Promise<void> {
  const count = await this.reservationRepository.count();
  if (count > 0) return;

  // 1. Récupérer (ou créer) les entités liées
  const service = await this.serviceRepository.findOne({
    where: { slug: 'guidance-tarot' },
  });
  if (!service) {
    throw new Error('Service prerequis manquant : lancez d\'abord le seed des services');
  }

  // 2. Créer les enfants en référençant les parents
  const reservation = this.reservationRepository.create({
    clientName: 'Marie Dupont',
    clientEmail: 'marie@example.com',
    service, // relation TypeORM
    scheduledAt: new Date('2025-06-15T14:00:00Z'),
  });
  await this.reservationRepository.save(reservation);
}

Si les seeds dépendent les uns des autres entre modules, ordonnez les appels dans un service dédié, ou exploitez le fait que NestJS instancie les modules selon l'ordre de leurs imports.

Rendre le seed transactionnel

Pour un jeu de données complexe et lié, on veut souvent du tout ou rien : si une insertion échoue, on annule tout. TypeORM permet d'utiliser un QueryRunner ou la fonction dataSource.transaction() :

import { DataSource } from 'typeorm';

@Injectable()
export class ReservationService {
  constructor(private readonly dataSource: DataSource) {}

  async seed(): Promise<void> {
    await this.dataSource.transaction(async (manager) => {
      const service = manager.create(Service, { name: 'Tarot', price: 60 });
      await manager.save(service);

      const reservation = manager.create(Reservation, {
        clientEmail: 'demo@example.com',
        service,
      });
      await manager.save(reservation);
    });
  }
}

Si l'une des opérations lève une exception, la transaction est automatiquement rollback : aucune donnée partielle ne reste en base.

Endpoint de nettoyage : /resource/clear

Le pendant naturel du seed est le clear, utile pour repartir d'une base propre. À réserver impérativement aux environnements de dev.

@Delete('clear')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Clear all oracles (dev only)' })
async clear(): Promise<ApiResponseDto<void>> {
  if (process.env.NODE_ENV === 'production') {
    throw new ForbiddenException('Clear is disabled in production');
  }
  await this.oracleService.clear();
  return ApiResponseDto.success(undefined, 'Oracles cleared successfully');
}

Et côté service :

async clear(): Promise<void> {
  await this.oracleRepository.clear(); // TRUNCATE
}

Note : repository.clear() émet un TRUNCATE qui peut échouer si des contraintes de clés étrangères pointent vers la table. Dans ce cas, supprimez d'abord les enfants ou utilisez repository.delete({}).

Alternative : une commande CLI avec nest-commander

Si exposer un endpoint HTTP vous gêne (même protégé), une alternative élégante consiste à créer une commande CLI avec nest-commander :

npm install nest-commander
import { Command, CommandRunner } from 'nest-commander';
import { OracleService } from './oracle.service';

@Command({ name: 'seed:oracles', description: 'Seed oracles data' })
export class SeedOraclesCommand extends CommandRunner {
  constructor(private readonly oracleService: OracleService) {
    super();
  }

  async run(): Promise<void> {
    await this.oracleService.seed();
    console.log('Oracles seeded ✔');
  }
}

Puis on l'exécute via :

npx ts-node src/cli.ts seed:oracles

Avantage : aucune surface d'attaque HTTP, et on peut intégrer la commande dans un script de déploiement ou un job CI.

Conclusion

Mettre en place des endpoints de seed dans NestJS est un investissement minime pour un gain de productivité considérable. Les principes clés à retenir :

  • Idempotence avant tout : vérifier l'existence avant d'insérer.
  • Sécurité via @Roles(UserRole.ADMIN) ou désactivation en production.
  • Données réalistes qui couvrent les cas variés du métier.
  • Transactionnalité pour les seeds complexes avec relations.
  • Hook OnModuleInit pour automatiser le premier démarrage.
  • Endpoint /clear en miroir, strictement réservé au dev.

Pour aller plus loin, vous pouvez explorer les bibliothèques dédiées comme typeorm-extension qui propose un système de fixtures et factories à la FactoryBot, ou intégrer @faker-js/faker pour générer des volumes massifs de données réalistes lors de tests de charge. Bons seeds !

Continuer la lecture

Article suivant — NestJS Le pattern Singleton pour le contenu CMS dans NestJS Explorer tout : NestJS

Commentaires

Soyez le premier à laisser un commentaire — le robot attend.

Laisser un commentaire

Les champs obligatoires sont indiqués avec *