| name | nestjs-rules |
| description | Règles partagées NestJS pour les APIs de la toolbox Soliguide. À utiliser dès qu'on code un controller, un service, un endpoint, un DTO, un POST/PATCH/DELETE, ou que l'utilisateur dit "ajoute une route", "crée un endpoint", "valide les données", "DTO". Documente la règle d'or : 100% de la donnée entrante doit être validée (DTO `class-validator` OU parser explicite). Aucun param ne se balade sans vérif. |
Règles NestJS Soliguide
Conventions pour toutes les APIs NestJS de la toolbox. Toujours répondre en français. Tutoyer l'utilisateur.
1. RÈGLE D'OR — Aucune donnée entrante sans validation
100% des données qui entrent dans un endpoint passent par une validation explicite. Body, query, params, headers : tout. Pas une seule valeur ne traverse le controller sans qu'on ait vérifié son type, ses bornes, et sa forme.
Deux approches autorisées, au choix selon la complexité :
Approche A — DTO class-validator (à privilégier dès qu'il y a > 2 champs)
import { IsEnum, IsOptional, IsString, MaxLength } from "class-validator";
export class CreateFooDto {
@IsString()
@MaxLength(2000)
content!: string;
@IsString()
@MaxLength(128)
username!: string;
@IsOptional()
@IsEnum(SomeEnum)
kind?: SomeEnum;
}
@Post()
create(@Body() dto: CreateFooDto): Promise<FooResponse> {
return this.service.create(dto);
}
Avec ValidationPipe global activé dans main.ts :
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
Bénéfices : déclaratif, lisible, type-safe, transforme et nettoie en même temps. Le choix par défaut.
Approche B — Parser explicite (cas simples, 1-2 champs, ou body schéma-libre)
Quand un body a un schéma libre (Record<string, unknown>) ou un seul champ ad-hoc, on accepte un parser à la main — mais il doit être explicite, exhaustif, et lever 400 en cas de mismatch :
function parsePatchBody(body: Record<string, unknown>): SourcePlacePatch {
const out: SourcePlacePatch = {};
if ("openingStatus" in body) {
const raw = body.openingStatus;
if (typeof raw !== "string" || !(raw in OpeningStatus)) {
throw new BadRequestException(
`openingStatus invalide. Valeurs : ${Object.keys(OpeningStatus).join(", ")}.`,
);
}
out.openingStatus = raw as OpeningStatus;
}
if (Object.keys(out).length === 0) {
throw new BadRequestException("Body vide.");
}
return out;
}
Toujours :
- Check
typeof ou instanceof.
- Check les valeurs d'enum / les bornes.
- Lever
BadRequestException (= 400) avec un message clair listant les valeurs attendues.
2. Patterns interdits
❌ @Body() body: any ou @Body() sans typage strict
❌ body.x sans avoir vérifié son type juste avant
❌ Number(query.page) direct sans check de validité (NaN passe…)
❌ Cast type avec as sans vérification (body.x as string)
❌ Trust dans le format renvoyé par le client (jamais)
3. Erreurs : codes HTTP corrects
| Cas | Exception |
|---|
| Body / param invalide | BadRequestException (400) |
| Ressource cible inexistante | NotFoundException (404) |
| Conflit (unique violation, état incohérent) | ConflictException (409) |
| Non autorisé | ForbiddenException (403) |
| Auth manquante | UnauthorizedException (401) |
| Bug serveur | InternalServerErrorException (500) — laisse Nest gérer |
Pour Prisma P2025 (record not found) → mapper en NotFoundException. Pour P2002 (unique constraint) → ConflictException.
4. Pagination — type partagé Page<T>
Toutes les listes paginées renvoient l'enveloppe partagée depuis @solihub/domain :
export interface Page<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
Query parsée via le helper apps/backend/src/common/page-query.ts (resolvePageQuery) qui valide page ≥ 1 et pageSize ∈ [1, MAX_PAGE_SIZE].
5. Dependency Injection — @Inject() explicite
tsx / SWC n'émettent pas toujours la metadata design:paramtypes correctement. Toujours utiliser @Inject(Token) explicite sur les constructeurs :
@Injectable()
export class FooService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
}
@Controller("foo")
export class FooController {
constructor(@Inject(FooService) private readonly service: FooService) {}
}
Sans ça, on rencontre des Cannot read properties of undefined (reading 'X') au runtime.
6. Structure des modules
apps/backend/src/api/foo/
├── foo.module.ts — @Module({ controllers, providers, exports })
├── foo.controller.ts — routes
├── foo.service.ts — logique métier + accès Prisma
└── dto/
├── create-foo.dto.ts
└── update-foo.dto.ts
Importer le module dans app.module.ts. Le PrismaModule est @Global(), donc PrismaService est injectable partout sans re-déclarer.
7. Réponses : DTO de sortie typés
Le service renvoie une interface explicite (pas any, pas le row Prisma brut). Évite de leaker des champs sensibles ou non-stables (updatedAt interne, passwordHash, etc.).
export interface FooDto {
id: string;
name: string;
createdAt: string;
}
Toujours mapper Date → .toISOString() dans le service. JSON ne sérialise pas Date directement.
8. Compression + CORS — au niveau global
Dans main.ts :
import compression from "compression";
app.use(compression());
app.enableCors({ origin: true });
En résumé
Un endpoint correct dans la toolbox :
✅ DTO class-validator OU parser explicite — aucune donnée sans check
✅ BadRequestException avec message clair en cas de mismatch
✅ @Inject(Token) explicite sur tous les constructeurs
✅ Réponse typée via interface dédiée (pas le row Prisma brut)
✅ Date → ISO string en sortie JSON
✅ Codes HTTP corrects (400/404/409/…)
✅ Pagination via Page<T> + resolvePageQuery
✅ Module isolé (controller/service/dto/) importé dans app.module.ts