J2 · Controllers, routing, DTO et validation
Un contrôleur Nest ressemble beaucoup à un @RestController : des méthodes annotées qui reçoivent la requête et renvoient un objet sérialisé en JSON. La vraie différence est ailleurs : en TypeScript, les types disparaissent à l'exécution. La validation des entrées repose donc entièrement sur des classes DTO décorées et sur un ValidationPipe actif. Sans lui, rien n'est vérifié.
Objectifs
- Déclarer des routes, choisir les codes de statut et versionner l'API.
- Écrire des DTO validés avec
class-validatoret convertis avecclass-transformer. - Configurer un
ValidationPipeglobal strict (whitelist,forbidNonWhitelisted,transform). - Dériver les DTO de mise à jour avec
PartialType,PickType,OmitType. - Livrer un CRUD
projectsen mémoire, avec pagination et filtre en query string.
Spring → NestJS
| Spring | NestJS | À retenir |
|---|---|---|
@RestController + @RequestMapping("/projects") | @Controller('projects') | Préfixe de chemin au niveau de la classe |
@GetMapping("/{id}"), @PostMapping | @Get(':id'), @Post() | Paramètres de chemin notés :id |
@PathVariable, @RequestParam, @RequestBody, @RequestHeader | @Param('id'), @Query(), @Body(), @Headers() | @Query() sans nom reçoit tout l'objet de query |
@ResponseStatus(NO_CONTENT) | @HttpCode(HttpStatus.NO_CONTENT) | Par défaut : 200, sauf POST qui renvoie 201 |
ResponseEntity<T> | Retourner l'objet (ou une Promise) | @Res() existe mais désactive le traitement Nest |
@Valid + Bean Validation (@NotNull, @Size) | ValidationPipe + class-validator (@IsNotEmpty(), @MaxLength()) | Le pipe doit être actif, sinon aucune validation |
| Jackson (conversion JSON ↔ objet) | class-transformer | Le corps arrive en objet littéral : transform: true le convertit en instance du DTO |
Comprendre
Routing et décorateurs
@Controller('projects') fixe le préfixe, chaque méthode ajoute un verbe et un chemin. Nest lit ces décorateurs au démarrage et enregistre les routes dans l'adaptateur HTTP (Express 5 par défaut). Le journal de démarrage liste chaque route enregistrée (contexte RouterExplorer) : c'est le premier endroit à regarder quand une route répond 404.
Un handler peut renvoyer une valeur, une Promise ou un Observable : Nest attend le résultat, le sérialise en JSON et applique le code de statut. Vous ne manipulez pas la réponse vous-même, c'est ce qui permet aux intercepteurs et à la sérialisation globale de s'appliquer partout.
Codes de statut
GET, PATCH, DELETE renvoient 200 et POST renvoie 201. Pour changer le code, utilisez @HttpCode() : DELETE sans corps → 204, une action POST /projects/:id/archive qui ne crée rien → 200. Pour une erreur, levez une exception (NotFoundException, ConflictException…) : Nest la transforme en réponse, comme ResponseStatusException côté Spring.
Versioning d'API
TaskFlow active le versioning par URI dans setupApp() : app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }). Toutes les routes deviennent /v1/... sans toucher aux contrôleurs. Une route peut sortir du versioning avec version: VERSION_NEUTRAL (c'est le cas de /health, voir J11). D'autres stratégies existent (en-tête, media type), l'URI reste la plus simple à déboguer.
DTO, class-validator et class-transformer
Un DTO est une classe (pas une interface) : seules les classes existent à l'exécution et peuvent porter des décorateurs. class-validator fournit les contraintes (@IsString(), @IsNotEmpty(), @MaxLength(100), @IsUUID(), @IsEnum()…), l'équivalent de Bean Validation. class-transformer convertit l'objet JSON reçu en instance de la classe et applique @Type() / @Transform().
Le type TypeScript ne valide rien
create(@Body() dto: CreateProjectDto) n'empêche pas de recevoir { "name": 42, "admin": true }. Le type est effacé à la compilation. Seul le ValidationPipe, en lisant les décorateurs du DTO, rejette la requête.
ValidationPipe global
TaskFlow l'enregistre une fois pour toute l'application, comme provider APP_PIPE (il s'applique ainsi aussi dans les tests e2e qui démarrent AppModule) :
// Global pipes, guards, filters and interceptors declared as providers, so
// they can inject dependencies and also apply in tests built from AppModule.
providers: [
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true, // strip properties without a decorator
forbidNonWhitelisted: true, // ...or reject the request: 400
transform: true, // plain JSON -> DTO instances, "2" -> 2
}),
},
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // guards run in this order
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor }, // applies @Exclude
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],| Option | Effet | Sans l'option |
|---|---|---|
whitelist: true | Retire les propriétés sans décorateur | Le client peut injecter des champs inattendus |
forbidNonWhitelisted: true | Refuse la requête (400) au lieu de les retirer | Les champs inconnus disparaissent en silence |
transform: true | Crée une instance du DTO, convertit "2" en 2 quand le type le demande | dto reste un objet littéral, query.page reste une chaîne |
Avec forbidNonWhitelisted, une faute de frappe dans un nom de champ produit une 400 explicite plutôt qu'un champ ignoré : c'est plus facile à déboguer côté client.
PartialType, PickType, OmitType
Plutôt que de recopier les champs, dérivez les DTO :
PartialType(CreateProjectDto): mêmes champs, tous optionnels, mêmes validations (pourPATCH).PickType(CreateProjectDto, ['name']): seulement certains champs.OmitType(CreateProjectDto, ['description']): tous sauf certains.- Ils se composent :
PartialType(OmitType(CreateUserDto, ['password'])).
Ces fonctions viennent de @nestjs/mapped-types. Une fois Swagger installé (J11), importez-les depuis @nestjs/swagger : elles conservent alors les métadonnées de documentation.
Cas TaskFlow
Générer la ressource
nest g resource projects # choisir "REST API", puis "Yes" pour les points d'entrée CRUD
nest g resource projects --type rest --crud # même résultat, sans questionLa commande crée projects.module.ts, projects.controller.ts, projects.service.ts, les DTO et une entité vide, et ajoute le module aux imports d'AppModule. Ce squelette est ensuite remplacé par le code ci-dessous. Liste complète des fichiers produits : aide-mémoire Nest CLI.
Les DTO de création et de mise à jour
// Bean Validation on a class, checked at runtime by the global ValidationPipe.
// The TypeScript types alone validate nothing: they are erased at runtime.
export class CreateProjectDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
}// Same fields, all optional. Import PartialType from @nestjs/swagger once
// Swagger is set up (from @nestjs/mapped-types before that).
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}Une requête invalide est rejetée avant d'atteindre le contrôleur :
POST /v1/projects
Content-Type: application/json
{ "name": "" }{
"status": 400,
"code": "BAD_REQUEST",
"message": ["name should not be empty"],
"path": "/v1/projects",
"timestamp": "2026-11-10T09:12:44.120Z",
"correlationId": "9f1c0c6e-3a51-4d6b-9f0e-2a7c51d8e0b4"
}Le format problem+json vient du filtre global de J6. Le test e2e vérifie aussi qu'un champ inconnu ("owner": "me") renvoie 400.
Le contrôleur CRUD
// @RestController + @RequestMapping("/projects"). With URI versioning the
// full path is /v1/projects.
@ApiTags('projects')
@ApiBearerAuth()
@Controller('projects')
export class ProjectsController {
constructor(private readonly projects: ProjectsService) {}
@Post() // 201 by default for POST
create(@Body() dto: CreateProjectDto, @CurrentUser() user: AuthUser) {
return this.projects.create(dto, user);
}
@Get()
findAll(@Query() query: ProjectQueryDto, @CurrentUser() user: AuthUser) {
return this.projects.findAll(query, user);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.projects.findOne(id, user);
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateProjectDto,
@CurrentUser() user: AuthUser,
) {
return this.projects.update(id, dto, user);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.projects.remove(id, user);
}
@Post(':id/archive')
@HttpCode(HttpStatus.OK)
archive(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.projects.archive(id, user);
}
@Post(':id/members')
@HttpCode(HttpStatus.NO_CONTENT)
addMember(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AddMemberDto,
@CurrentUser() user: AuthUser,
) {
return this.projects.addMember(id, dto.userId, user);
}
}@Param('id', ParseUUIDPipe)convertit et valide le paramètre :/v1/projects/not-a-uuid→ 400 avant le handler (les pipes sont détaillés en J6).@CurrentUser()est un décorateur de paramètre maison ajouté en J8. En J2, sans authentification, les méthodes ne prennent que le DTO et l'id.- Le contrôleur ne contient aucune règle métier : il traduit HTTP en appels au service.
Le CRUD en mémoire
En J2, les projets vivent dans une Map. TaskFlow garde cette implémentation : elle sert dans les tests unitaires et derrière l'abstraction introduite en J3.
// The J2 version: an array in memory. Still useful in unit tests, and the
// service and controller do not know which store they get.
@Injectable()
export class InMemoryProjectsStore implements ProjectsStore {
private readonly projects = new Map<string, Project>();
private readonly members = new Set<string>();
async create(data: CreateProjectDto, ownerId: string): Promise<Project> {
const now = new Date();
const project = Object.assign(new Project(), {
id: randomUUID(),
name: data.name,
description: data.description ?? null,
archived: false,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
this.projects.set(project.id, project);
this.members.add(`${project.id}:${ownerId}`);
return project;
}
async findPage(query: ProjectQueryDto, memberId?: string): Promise<Page<Project>> {
const search = query.search?.toLowerCase();
const matching = [...this.projects.values()]
.filter((p) => !p.deletedAt)
.filter((p) => !memberId || this.members.has(`${p.id}:${memberId}`))
.filter((p) => !search || p.name.toLowerCase().includes(search))
.filter((p) => query.archived === undefined || p.archived === query.archived)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return {
items: matching.slice(query.skip, query.skip + query.limit),
total: matching.length,
page: query.page,
limit: query.limit,
};
}
async findById(id: string): Promise<Project | null> {
const project = this.projects.get(id);
return project && !project.deletedAt ? project : null;
}
async update(id: string, changes: UpdateProjectDto): Promise<Project | null> {
const project = await this.findById(id);
if (!project) return null;
Object.assign(project, changes, { updatedAt: new Date() });
return project;
}
async softDelete(id: string): Promise<boolean> {
const project = await this.findById(id);
if (!project) return false;
project.deletedAt = new Date();
return true;
}
async archive(id: string): Promise<Project | null> {
const project = await this.findById(id);
if (project) project.archived = true;
return project;
}
async isMember(projectId: string, userId: string): Promise<boolean> {
return this.members.has(`${projectId}:${userId}`);
}
async addMember(projectId: string, userId: string): Promise<void> {
this.members.add(`${projectId}:${userId}`);
}
}Pagination et filtre avec un QueryDto
Les paramètres de query arrivent toujours en chaînes. Un DTO de query les valide et les convertit :
// Query strings are always strings: @Type(() => Number) plus the global
// ValidationPipe({ transform: true }) turn "?page=2" into the number 2.
export class PaginationQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 20;
get skip(): number {
return (this.page - 1) * this.limit;
}
}
/** What Spring Data calls Page<T>; TypeORM has no built-in equivalent. */
export interface Page<T> {
items: T[];
total: number;
page: number;
limit: number;
}export class ProjectQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
@MaxLength(100)
search?: string;
// "?archived=false" arrives as the string "false", which is truthy.
@IsOptional()
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
@IsBoolean()
archived?: boolean;
}Le suffixe .dto.ts de pagination.dto.ts compte : le plugin Swagger du J11 ne lit que les fichiers *.dto.ts et *.entity.ts, et c'est ce qui fait apparaître page et limit dans la documentation OpenAPI (J11).
GET /v1/projects?search=app&page=1&limit=1{
"items": [
{
"id": "5b0c…",
"name": "Web app",
"description": null,
"archived": false,
"createdAt": "2026-11-10T09:15:02.481Z",
"updatedAt": "2026-11-10T09:15:02.481Z",
"deletedAt": null
}
],
"total": 2,
"page": 1,
"limit": 1
}?page=0 donne une 400 (@Min(1)). Notez ?archived=false : la chaîne "false" est vraie en JavaScript, d'où le @Transform explicite. @Type(() => Number) ne suffirait pas ici.
Situations
- Vous devez renvoyer 204 sur un
DELETE→@HttpCode(HttpStatus.NO_CONTENT)(codes de statut). - Un champ inconnu doit être refusé →
ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })(pipe global). - Le DTO de mise à jour duplique celui de création →
PartialType(CreateXDto)(mapped types). ?page=2arrive en chaîne →@Type(() => Number)+transform: true(pagination).- Un booléen en query (
?archived=false) est toujours vrai →@Transformexplicite (pagination). - Un paramètre
:iddoit être un UUID →@Param('id', ParseUUIDPipe)(contrôleur). - Vous voulez toutes les routes sous
/v1→enableVersioning(versioning). - Vous êtes tenté par
@Res()pour fixer un en-tête → préférez@Header()ou un intercepteur (pièges).
Pièges
DTO déclaré comme interface
Une interface ne laisse aucune trace à l'exécution : pas de validation possible. Les DTO sont des classes.
ValidationPipe absent ou partiel
Sans pipe global, chaque contrôleur doit l'ajouter avec @UsePipes(), et un oubli laisse passer n'importe quoi. Déclarez-le une fois via APP_PIPE (ou app.useGlobalPipes() dans main.ts, mais il ne s'appliquera pas aux tests e2e qui n'exécutent pas main.ts).
@Res() dans un contrôleur
Injecter la réponse Express avec @Res() fait passer le handler en mode « manuel » : Nest n'envoie plus la valeur retournée, les intercepteurs de réponse et la sérialisation (ClassSerializerInterceptor) ne s'appliquent plus. Si vous en avez vraiment besoin (streaming), utilisez @Res({ passthrough: true }).
@Query()sanstransform: truelaisse tous les nombres en chaînes : les comparaisonspage > 1sont alors fausses sans erreur visible.@IsOptional()laisse passernulletundefined: combinez-le avec la contrainte de type (@IsString()), qui ne s'applique que si la valeur est présente.
Exercices
- [ ]
nest g resource projects, puis remplacer le squelette parCreateProjectDto/UpdateProjectDto. - [ ] CRUD complet stocké dans un tableau ou une
Mapen mémoire. - [ ] Pagination (
page,limit) et filtre (search) via@Query()et unQueryDto. - [ ] Vérifier avec
curl: champ inconnu → 400,namevide → 400,?page=0→ 400,DELETE→ 204. - [ ] Écrire un
UpdateProjectDtoavecPickTypepuis avecPartialType, et comparer. - [ ] Noter dans
LEARNINGS.mdce qui diffère de@Valid+ Bean Validation.