À propos de ce cours
Objectifs du TP
- Concevoir une API CRUD complète avec une séparation stricte contrôleur / service.
- Injecter des dépendances par jeton (Symbol, classe abstraite) avec useClass, useValue et useFactory.
- Changer d'implémentation par configuration, sans modifier le service.
- Utiliser un provider de portée requête à bon escient.
- Publier le projet sur GitHub dès le départ, un commit par partie.
Durée: 4 h | Prérequis: chapitres 05 à 07 | Livrable: le dépôt api-todolist avec l'étiquette v1.0.0.
Un service singleton, trois providers personnalisés et un contexte de portée requête.
Cahier des charges
L'équipe pédagogique veut une API de gestion de tâches pour organiser ses préparations (cours, corrections, réunions). Une tâche a la forme suivante :
export const STATUTS = ['a-faire', 'en-cours', 'terminee'] as const;
export type Statut = (typeof STATUTS)[number];
export const PRIORITES = ['basse', 'normale', 'haute'] as const;
export type Priorite = (typeof PRIORITES)[number];
export interface Tache {
id: string;
titre: string;
description?: string;
statut: Statut;
priorite: Priorite;
echeance?: string; // AAAA-MM-JJ
creePar: string;
creeLe: string;
modifieLe: string;
}
| Route | Rôle | Réponses |
|---|---|---|
GET /api/v1/taches?statut=&priorite=&q= | lister, filtrer, rechercher ; tri par échéance (sans échéance en dernier) ; en-tête X-Total-Count | 200 ; 400 si statut ou priorité inconnus |
GET /api/v1/taches/statistiques | total, nombre par statut, nombre en retard, pourcentage d'avancement | 200 |
GET /api/v1/taches/:id | une tâche | 200 ; 404 |
POST /api/v1/taches | créer : titre obligatoire, priorité par défaut issue de la configuration, échéance au format AAAA-MM-JJ, auteur lu dans X-Utilisateur (« anonyme » sinon) | 201 + Location; 400 ; 409 si le nombre maximal est atteint |
PATCH /api/v1/taches/:id | modifier titre, description, statut, priorité, échéance (les autres champs sont ignorés) | 200 ; 400 ; 404 |
PATCH /api/v1/taches/:id/terminer | passer au statut « terminee » | 200 ; 409 si déjà terminée |
DELETE /api/v1/taches/terminees | supprimer toutes les tâches terminées | 200 { supprimees: n } |
DELETE /api/v1/taches/:id | supprimer une tâche | 204 ; 404 |
GET /sante | état du service | 200 |
Partie 1 Créer le projet et le dépôt GitHub
nest new api-todolist --package-manager npm --strict, puis créez le dépôt GitHub publicapi-todolistet poussez (« chore: création du projet »).- Générez
nest g mo taches,nest g s taches --no-spec,nest g co taches --no-spec; supprimezAppControlleretAppService. - Dans
main.ts: préfixeapi(saufsante), versionnement URI (defaultVersion: '1'), CORS avecexposedHeaders: ['Location', 'X-Total-Count']. - Commit :
chore: structure du projet.
Partie 2 Le modèle et les providers communs
src/taches/tache.model.ts: listesSTATUTSetPRIORITES(as const), types, interfaceTache, classesCreerTacheDtoetModifierTacheDto, interfaceTodoConfiget jetonTODO_CONFIG.src/common/horloge.ts: jetonHORLOGE(Symbol), interfaceHorloge, classeHorlogeSysteme.src/common/generateur-id.ts: classe abstraiteGenerateurIdet deux implémentations,GenerateurSequentiel(T-0001, T-0002…) etGenerateurUuid.src/common/contexte-utilisateur.ts: provider de portée requête qui litX-Utilisateur.- Commit :
feat: modèle et providers communs.
Partie 3 Le service des tâches
TachesServiceinjecteGenerateurId,HORLOGEetTODO_CONFIG. Il ne connaît ni HTTP ni la requête : l'auteur lui est passé en paramètre decreate.- Écrivez
findAll(filtre),findOne,create,update,terminer,remove,supprimerTermineesetstatistiques, avec les règles du cahier des charges (exceptions 400, 404, 409). - Dans
update, ne recopiez que les champs autorisés : un client ne doit pas pouvoir modifierid,creeParoucreeLe. - Toutes les dates (
creeLe,modifieLe, calcul du retard) viennent de l'horloge injectée, jamais denew Date()directement. - Commit :
feat: service des tâches.
Pourquoi une horloge injectée ? Parce qu'un test du calcul « en retard » doit pouvoir fixer la date du jour. Avec new Date() dans le service, le résultat du test changerait selon le jour où il est lancé.
Partie 4 Le module et le contrôleur
- Dans
TachesModule, déclarez :HORLOGEavec useClass ;GenerateurIdavec useClass,GenerateurUuidsiprocess.env.ID_MODE === 'uuid', sinonGenerateurSequentiel;TODO_CONFIGavec useFactory (MAX_TACHES, 100 par défaut ; priorité par défaut « normale ») ;ContexteUtilisateur. - Écrivez
TachesController(version 1). Il injecte le service et le contexte, déclare les routes statiques (statistiques,terminees,:id/terminer) avant:id, renvoieLocationetX-Total-Countavecpassthrough, et répond 204 au DELETE. - Ajoutez
SanteController(VERSION_NEUTRAL). - Commit :
feat: contrôleur et configuration des providers.
Partie 5 Tester et changer d'implémentation
- Créez
requetes/taches.http(base ci-dessous) et vérifiez tous les statuts. - Relancez avec
ID_MODE=uuid npm run start:dev(sous PowerShell :$env:ID_MODE="uuid"; npm run start:dev) : les identifiants deviennent des UUID. Notez dans le README que le service n'a pas été modifié. - Relancez avec
MAX_TACHES=3et vérifiez le 409. - Commit :
test: requêtes de l'API.
@base = http://localhost:3000/api/v1/taches
### Créer (201 + Location)
POST {{base}}
Content-Type: application/json
X-Utilisateur: hajji
{ "titre": "Préparer le TP 4", "priorite": "haute", "echeance": "2026-11-20" }
### Créer sans priorité (priorité par défaut)
POST {{base}}
Content-Type: application/json
{ "titre": "Réviser NestJS", "description": "chapitres 05 à 07" }
### Erreurs : titre vide, priorité invalide, date invalide (400)
POST {{base}}
Content-Type: application/json
{ "titre": "Tâche", "priorite": "critique" }
### Lister (X-Total-Count), filtrer, rechercher
GET {{base}}
###
GET {{base}}?priorite=haute
###
GET {{base}}?q=nestjs
### Statistiques
GET {{base}}/statistiques
### Terminer (409 si déjà terminée)
PATCH {{base}}/T-0001/terminer
### Modifier (id et creePar ignorés)
PATCH {{base}}/T-0002
Content-Type: application/json
{ "statut": "en-cours", "id": "PIRATE" }
### Supprimer les tâches terminées
DELETE {{base}}/terminees
### Supprimer une tâche (204)
DELETE {{base}}/T-0002
Partie 6 Documenter et livrer
- Rédigez le
README.md: présentation, lancement, variables d'environnement (PORT,ID_MODE,MAX_TACHES), tableau des routes, et un paragraphe « Architecture » qui explique chaque provider (jeton, recette, portée). - Vérifiez
npm run lintetnpm run build. - Commit
docs: README, push, étiquettev1.0.0.
Résultat attendu
Réponses obtenues avec la correction (lancée avec MAX_TACHES=4) :
POST /api/v1/taches → 201 {"id":"T-0001","titre":"Préparer le TP 4","statut":"a-faire","priorite":"haute","echeance":"2026-11-20","creePar":"hajji","creeLe":"…","modifieLe":"…"} [location: /api/v1/taches/T-0001]
POST /api/v1/taches → 201 {"id":"T-0002","titre":"Corriger les copies","statut":"a-faire","priorite":"normale","echeance":"2026-11-10","creePar":"hajji","creeLe":"…","modifieLe":"…"}
POST /api/v1/taches → 201 {"id":"T-0003","titre":"Réviser NestJS","description":"chapitres 05 à 07","statut":"a-faire","priorite":"normale","creePar":"anonyme","creeLe":"…","modifieLe":"…"}
POST /api/v1/taches → 400 {"message":"Le titre est obligatoire","error":"Bad Request","statusCode":400}
POST /api/v1/taches → 400 {"message":"Priorité invalide : critique (basse, normale, haute)","error":"Bad Request","statusCode":400}
POST /api/v1/taches → 400 {"message":"Échéance invalide : 20/11/2026 (format AAAA-MM-JJ)","error":"Bad Request","statusCode":400}
GET /api/v1/taches → 200 [{"id":"T-0002","titre":"Corriger les copies","statut":"a-faire","priorite":"normale","echeance":"2026-11-10","creePar":"hajji","creeLe":"…","modifieLe":"…"},{"id":"T-0001","titre":"Préparer le TP 4","statut":"a-faire","priorite":"haute","echeance":"2026-11-20","creePar":"hajji","creeLe":"…","modifieLe":"…"},{"id":"T-0003","titre":"Réviser NestJS","description":"chapitres 05 à 07","statut":"a-faire","priorite":"normale","creePar":"anonyme","creeLe":"…","modifieLe":"…"}] [x-total-count: 3]
GET /api/v1/taches?priorite=haute → 200 [{"id":"T-0001","titre":"Préparer le TP 4","statut":"a-faire","priorite":"haute","echeance":"2026-11-20","creePar":"hajji","creeLe":"…","modifieLe":"…"}]
GET /api/v1/taches?q=nestjs → 200 [{"id":"T-0003","titre":"Réviser NestJS","description":"chapitres 05 à 07","statut":"a-faire","priorite":"normale","creePar":"anonyme","creeLe":"…","modifieLe":"…"}]
GET /api/v1/taches?statut=fini → 400 {"message":"Statut invalide : fini (a-faire, en-cours, terminee)","error":"Bad Request","statusCode":400}
PATCH /api/v1/taches/T-0002/terminer → 200 {"id":"T-0002","titre":"Corriger les copies","statut":"terminee","priorite":"normale","echeance":"2026-11-10","creePar":"hajji","creeLe":"…","modifieLe":"…"}
PATCH /api/v1/taches/T-0002/terminer → 409 {"message":"La tâche T-0002 est déjà terminée","error":"Conflict","statusCode":409}
PATCH /api/v1/taches/T-0003 → 200 {"id":"T-0003","titre":"Réviser NestJS","description":"chapitres 05 à 07","statut":"en-cours","priorite":"normale","creePar":"anonyme","creeLe":"…","modifieLe":"…"}
GET /api/v1/taches/statistiques → 200 {"total":3,"parStatut":{"a-faire":1,"en-cours":1,"terminee":1},"enRetard":0,"avancement":33}
POST /api/v1/taches → 201 {"id":"T-0004","titre":"Quatrième tâche","statut":"a-faire","priorite":"normale","creePar":"anonyme","creeLe":"…","modifieLe":"…"}
POST /api/v1/taches → 409 {"message":"Nombre maximal de tâches atteint (4)","error":"Conflict","statusCode":409}
DELETE /api/v1/taches/terminees → 200 {"supprimees":1}
DELETE /api/v1/taches/T-0001 → 204
GET /api/v1/taches/T-0001 → 404 {"message":"Tâche T-0001 introuvable","error":"Not Found","statusCode":404}
GET /sante → 200 {"statut":"ok","application":"api-todolist"}
Avec ID_MODE=uuid, les mêmes requêtes produisent des identifiants comme d0daba74-7d91-4b88-93a3-41731920e528, sans aucune modification du service.
Pour aller plus loin
- Écrivez un test avec
@nestjs/testingqui fournit une horloge fixée au 2026-11-25 et vérifie questatistiques().enRetardcompte la tâche dont l'échéance est le 2026-11-20. - Ajoutez une ressource
listes(une tâche appartient à une liste) dans un second module, sans créer de dépendance circulaire. - Remplacez
ContexteUtilisateur(portée requête) par un décorateur de paramètre@Utilisateur()(leçon 5.4) : le contrôleur redevient un singleton. Mesurez la différence avec l'activité de la leçon 7.5. - Ajoutez un
StockageTachesabstrait avec une implémentation en mémoire et une implémentation dans un fichier JSON (useClass selonSTOCKAGE).
Correction
src/taches/tache.model.ts
export const STATUTS = ['a-faire', 'en-cours', 'terminee'] as const;
export type Statut = (typeof STATUTS)[number];
export const PRIORITES = ['basse', 'normale', 'haute'] as const;
export type Priorite = (typeof PRIORITES)[number];
export interface Tache {
id: string;
titre: string;
description?: string;
statut: Statut;
priorite: Priorite;
echeance?: string; // AAAA-MM-JJ
creePar: string;
creeLe: string;
modifieLe: string;
}
export class CreerTacheDto {
titre: string;
description?: string;
priorite?: Priorite;
echeance?: string;
}
export class ModifierTacheDto {
titre?: string;
description?: string;
statut?: Statut;
priorite?: Priorite;
echeance?: string;
}
export interface TodoConfig {
maxTaches: number;
prioriteParDefaut: Priorite;
}
export const TODO_CONFIG = 'TODO_CONFIG';
src/common/horloge.ts
import { Injectable } from '@nestjs/common';
export const HORLOGE = Symbol('HORLOGE');
export interface Horloge {
maintenant(): Date;
}
@Injectable()
export class HorlogeSysteme implements Horloge {
maintenant(): Date {
return new Date();
}
}
src/common/generateur-id.ts
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
export abstract class GenerateurId {
abstract suivant(): string;
}
@Injectable()
export class GenerateurSequentiel extends GenerateurId {
private compteur = 0;
suivant(): string {
return `T-${String(++this.compteur).padStart(4, '0')}`; // T-0001, T-0002…
}
}
@Injectable()
export class GenerateurUuid extends GenerateurId {
suivant(): string {
return randomUUID();
}
}
src/common/contexte-utilisateur.ts
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import type { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class ContexteUtilisateur {
readonly nom: string;
constructor(@Inject(REQUEST) requete: Request) {
const entete = requete.headers['x-utilisateur'];
this.nom = typeof entete === 'string' && entete.trim() ? entete.trim() : 'anonyme';
}
}
src/taches/taches.service.ts
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { GenerateurId } from '../common/generateur-id';
import { HORLOGE, Horloge } from '../common/horloge';
import {
CreerTacheDto, ModifierTacheDto, Priorite, PRIORITES, Statut, STATUTS, Tache, TODO_CONFIG, TodoConfig,
} from './tache.model';
export interface FiltreTaches {
statut?: string;
priorite?: string;
q?: string;
}
@Injectable()
export class TachesService {
private taches: Tache[] = [];
constructor(
private readonly generateurId: GenerateurId,
@Inject(HORLOGE) private readonly horloge: Horloge,
@Inject(TODO_CONFIG) private readonly config: TodoConfig,
) {}
findAll(filtre: FiltreTaches = {}): Tache[] {
if (filtre.statut) this.verifierStatut(filtre.statut);
if (filtre.priorite) this.verifierPriorite(filtre.priorite);
const q = filtre.q?.toLowerCase();
return this.taches
.filter((t) => !filtre.statut || t.statut === filtre.statut)
.filter((t) => !filtre.priorite || t.priorite === filtre.priorite)
.filter((t) => !q || `${t.titre} ${t.description ?? ''}`.toLowerCase().includes(q))
.toSorted((a, b) => (a.echeance ?? '9999').localeCompare(b.echeance ?? '9999')); // échéance la plus proche d'abord
}
findOne(id: string): Tache {
const t = this.taches.find((x) => x.id === id);
if (!t) throw new NotFoundException(`Tâche ${id} introuvable`);
return t;
}
create(dto: CreerTacheDto, utilisateur: string): Tache {
if (!dto?.titre?.trim()) throw new BadRequestException('Le titre est obligatoire');
if (this.taches.length >= this.config.maxTaches) {
throw new ConflictException(`Nombre maximal de tâches atteint (${this.config.maxTaches})`);
}
if (dto.priorite) this.verifierPriorite(dto.priorite);
if (dto.echeance) this.verifierDate(dto.echeance);
const maintenant = this.horloge.maintenant().toISOString();
const tache: Tache = {
id: this.generateurId.suivant(),
titre: dto.titre.trim(),
description: dto.description,
statut: 'a-faire',
priorite: dto.priorite ?? this.config.prioriteParDefaut,
echeance: dto.echeance,
creePar: utilisateur,
creeLe: maintenant,
modifieLe: maintenant,
};
this.taches.push(tache);
return tache;
}
update(id: string, dto: ModifierTacheDto): Tache {
const t = this.findOne(id);
if (dto.statut) this.verifierStatut(dto.statut);
if (dto.priorite) this.verifierPriorite(dto.priorite);
if (dto.echeance) this.verifierDate(dto.echeance);
if (dto.titre !== undefined && !dto.titre.trim()) throw new BadRequestException('Le titre ne peut pas être vide');
const { titre, description, statut, priorite, echeance } = dto; // seuls les champs autorisés
Object.assign(t, Object.fromEntries(
Object.entries({ titre, description, statut, priorite, echeance }).filter(([, v]) => v !== undefined),
));
t.modifieLe = this.horloge.maintenant().toISOString();
return t;
}
terminer(id: string): Tache {
const t = this.findOne(id);
if (t.statut === 'terminee') throw new ConflictException(`La tâche ${id} est déjà terminée`);
return this.update(id, { statut: 'terminee' });
}
remove(id: string): void {
this.findOne(id);
this.taches = this.taches.filter((t) => t.id !== id);
}
supprimerTerminees(): number {
const avant = this.taches.length;
this.taches = this.taches.filter((t) => t.statut !== 'terminee');
return avant - this.taches.length;
}
statistiques() {
const aujourdHui = this.horloge.maintenant().toISOString().slice(0, 10);
const parStatut = Object.fromEntries(STATUTS.map((s) => [s, this.taches.filter((t) => t.statut === s).length]));
const enRetard = this.taches.filter((t) => t.statut !== 'terminee' && t.echeance && t.echeance < aujourdHui).length;
const avancement = this.taches.length ? Math.round((parStatut['terminee'] / this.taches.length) * 100) : 0;
return { total: this.taches.length, parStatut, enRetard, avancement };
}
private verifierStatut(s: string): asserts s is Statut {
if (!STATUTS.includes(s as Statut)) throw new BadRequestException(`Statut invalide : ${s} (${STATUTS.join(', ')})`);
}
private verifierPriorite(p: string): asserts p is Priorite {
if (!PRIORITES.includes(p as Priorite)) throw new BadRequestException(`Priorité invalide : ${p} (${PRIORITES.join(', ')})`);
}
private verifierDate(d: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || Number.isNaN(Date.parse(d))) {
throw new BadRequestException(`Échéance invalide : ${d} (format AAAA-MM-JJ)`);
}
}
}
src/taches/taches.controller.ts
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { ContexteUtilisateur } from '../common/contexte-utilisateur';
import { CreerTacheDto, ModifierTacheDto } from './tache.model';
import { TachesService } from './taches.service';
@Controller({ path: 'taches', version: '1' })
export class TachesController {
constructor(
private readonly tachesService: TachesService,
private readonly contexte: ContexteUtilisateur, // portée requête
) {}
@Get()
findAll(
@Query('statut') statut?: string,
@Query('priorite') priorite?: string,
@Query('q') q?: string,
@Res({ passthrough: true }) res?: Response,
) {
const liste = this.tachesService.findAll({ statut, priorite, q });
res?.setHeader('X-Total-Count', String(liste.length));
return liste;
}
@Get('statistiques') // avant ':id'
statistiques() {
return this.tachesService.statistiques();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.tachesService.findOne(id);
}
@Post()
create(@Body() dto: CreerTacheDto, @Res({ passthrough: true }) res: Response) {
const tache = this.tachesService.create(dto, this.contexte.nom);
res.location(`/api/v1/taches/${tache.id}`);
return tache;
}
@Patch(':id/terminer')
terminer(@Param('id') id: string) {
return this.tachesService.terminer(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: ModifierTacheDto) {
return this.tachesService.update(id, dto);
}
@Delete('terminees') // avant ':id'
supprimerTerminees() {
return { supprimees: this.tachesService.supprimerTerminees() };
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id') id: string) {
this.tachesService.remove(id);
}
}
src/taches/taches.module.ts
import { Module } from '@nestjs/common';
import { ContexteUtilisateur } from '../common/contexte-utilisateur';
import { GenerateurId, GenerateurSequentiel, GenerateurUuid } from '../common/generateur-id';
import { HORLOGE, HorlogeSysteme } from '../common/horloge';
import { TODO_CONFIG, TodoConfig } from './tache.model';
import { TachesController } from './taches.controller';
import { TachesService } from './taches.service';
@Module({
controllers: [TachesController],
providers: [
TachesService,
ContexteUtilisateur,
{ provide: HORLOGE, useClass: HorlogeSysteme },
{
provide: GenerateurId,
useClass: process.env.ID_MODE === 'uuid' ? GenerateurUuid : GenerateurSequentiel,
},
{
provide: TODO_CONFIG,
useFactory: (): TodoConfig => ({
maxTaches: Number(process.env.MAX_TACHES ?? 100),
prioriteParDefaut: 'normale',
}),
},
],
})
export class TachesModule {}
src/sante.controller.ts
import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common';
@Controller({ path: 'sante', version: VERSION_NEUTRAL })
export class SanteController {
@Get()
etat() {
return { statut: 'ok', application: 'api-todolist' };
}
}
src/app.module.ts
import { Module } from '@nestjs/common';
import { SanteController } from './sante.controller';
import { TachesModule } from './taches/taches.module';
@Module({
imports: [TachesModule],
controllers: [SanteController],
})
export class AppModule {}
src/main.ts
import { Logger, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api', { exclude: ['sante'] });
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
app.enableCors({ exposedHeaders: ['Location', 'X-Total-Count'] });
const port = process.env.PORT ?? 3000;
await app.listen(port);
Logger.log(`API TodoList : http://localhost:${port}/api/v1/taches`, 'Bootstrap');
}
bootstrap();
Grille d'évaluation
| Critère | Points |
|---|---|
| Dépôt GitHub dès la Partie 1, un commit clair par partie, étiquette v1.0.0 | 2 |
| Service : CRUD, filtres, tri, statistiques, règles (400, 404, 409), champs protégés en modification | 6 |
Providers : jeton Symbol, classe abstraite, useClass selon l'environnement, useFactory, aucun new Date() dans le service | 5 |
| Contrôleur : routes dans le bon ordre, Location, X-Total-Count, 204, contexte de portée requête | 4 |
| Fichier .http complet, README avec variables d'environnement et architecture | 3 |
| Total | 20 |