OpenAPI / Swagger avec @nestjs/swagger
TL;DR —
@nestjs/swaggergénère ton OpenAPI à partir des décorateurs TS + DTOs. Bien utilisé, c'est un contrat machine-readable qui sert de doc, de client SDK, de mock server et de test de régression. Mal utilisé, c'est un Swagger UI joli qui ment sur la réalité. La règle ninja : le schéma OpenAPI est ta source de vérité — si le code et le schéma divergent, c'est un bug, pas une coquetterie.
🧠 Mental model — ASCII diagram + analogy
DTO + decorators ──► SwaggerModule ──► openapi.json
▲ │
│ ├─► Swagger UI (humans)
class-validator ├─► openapi-typescript-codegen → SDK
│ ├─► Prism mock server
▼ └─► Pact / Schemathesis (contract tests)
runtime checkAnalogie : OpenAPI = la carte d'embarquement entre client et serveur. La carte sans l'avion = doc périmée. L'avion sans carte = chacun part dans son coin. Décorateurs Nest = générateur automatique de carte à partir de l'avion réel.
Le moteur sous le capot s'appuie sur la réflexion TypeScript (emitDecoratorMetadata) + @nestjs/swagger plugin (CLI) qui inspecte les DTOs au build. Sans le plugin, tu dois décorer chaque champ avec @ApiProperty(). Avec, beaucoup est déduit (type, optional, description via JSDoc).
Mental model du staff — distingue trois objets qu'on confond souvent :
Objet Vit où Vérité sur quoi Outil DTO + décorateurs code source TS la forme attendue class-validator,@nestjs/swaggerDocument OpenAPI ( openapi.json)artefact de build le contrat publié SDK gen, mock, diff Réponse runtime réelle sur le câble ce que le serveur fait vraiment schemathesis, tests e2eLe métier de l'OpenAPI n'est pas « avoir une jolie doc ». C'est de faire converger ces trois objets par construction et d'échouer en CI dès qu'ils divergent. Tout le reste (Swagger UI, SDK, mock) est dérivé. Un staff engineer optimise la boucle de feedback drift → CI rouge, pas l'esthétique de la page
/docs.
Le coût de la réflexion : ce que TS perd au runtime
emitDecoratorMetadata n'émet que des métadonnées scalaires/shallow : le type d'une propriété (String, Number, une classe), mais jamais les paramètres de type génériques (Page<T> → Page), ni le type d'élément d'un tableau (T[] → Array), ni les unions ('a' | 'b' → Object). C'est pourquoi tu dois redonner explicitement ces trois choses à OpenAPI : @ApiProperty({ type: [Item] }), getSchemaPath pour les unions, un type concret par usage générique. Toute la liste de pitfalls plus bas découle de cette unique limitation. Garde cette cause racine en tête : ce que le compilateur efface, tu le re-déclares.
🛠️ Code minimal
Setup
// main.ts
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
const config = new DocumentBuilder()
.setTitle('Orders API')
.setVersion('1.4.0')
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'access-token')
.addServer('https://api.example.com')
.addTag('orders', 'Order lifecycle')
.build();
const doc = SwaggerModule.createDocument(app, config, {
operationIdFactory: (controllerKey, methodKey) => methodKey, // SDK names propres
});
SwaggerModule.setup('docs', app, doc, {
swaggerOptions: { persistAuthorization: true, displayRequestDuration: true },
});
// Exporte aussi pour le pipeline SDK
if (process.env.EXPORT_OPENAPI) {
require('fs').writeFileSync('openapi.json', JSON.stringify(doc, null, 2));
process.exit(0);
}DTO décorée
// create-order.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class OrderLineDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
productId!: string;
@ApiProperty({ minimum: 1, example: 3 })
@IsInt() @Min(1)
quantity!: number;
}
export class CreateOrderDto {
@ApiProperty({ example: 'cust_42' })
@IsString()
customerId!: string;
@ApiProperty({ type: [OrderLineDto] })
@ValidateNested({ each: true })
@Type(() => OrderLineDto)
lines!: OrderLineDto[];
@ApiPropertyOptional({ description: 'Idempotency token (24h)' })
idempotencyKey?: string;
}Controller + responses
// orders.controller.ts
import { ApiTags, ApiOperation, ApiCreatedResponse, ApiBadRequestResponse, ApiBearerAuth } from '@nestjs/swagger';
@ApiTags('orders')
@ApiBearerAuth('access-token')
@Controller('orders')
export class OrdersController {
@Post()
@ApiOperation({ summary: 'Create order', operationId: 'createOrder' })
@ApiCreatedResponse({ type: OrderDto })
@ApiBadRequestResponse({ type: ProblemJsonDto })
create(@Body() dto: CreateOrderDto): Promise<OrderDto> { /* ... */ }
}Polymorphism (discriminated unions)
@ApiExtraModels(CardPaymentDto, BankTransferDto)
export class CreatePaymentDto {
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(CardPaymentDto) },
{ $ref: getSchemaPath(BankTransferDto) },
],
discriminator: { propertyName: 'method', mapping: {
card: getSchemaPath(CardPaymentDto),
bank: getSchemaPath(BankTransferDto),
} },
})
payment!: CardPaymentDto | BankTransferDto;
}Security schemes (multiple)
const config = new DocumentBuilder()
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'access-token')
.addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'api-key')
.addOAuth2({
type: 'oauth2',
flows: {
authorizationCode: {
authorizationUrl: 'https://auth.example.com/oauth/authorize',
tokenUrl: 'https://auth.example.com/oauth/token',
scopes: { 'orders:read': 'Read orders', 'orders:write': 'Write orders' },
},
},
}, 'oauth2')
.build();Sur chaque endpoint, choisis le scheme : @ApiBearerAuth('access-token') ou @ApiSecurity('api-key'). Le SDK généré sait alors quel header poser.
Examples multiples
@Post()
@ApiBody({
type: CreateOrderDto,
examples: {
minimal: {
summary: 'Minimal valid order',
value: { customerId: 'cust_1', lines: [{ productId: 'p1', quantity: 1 }] },
},
withIdempotency: {
summary: 'Idempotent retry-safe',
value: { customerId: 'cust_1', lines: [...], idempotencyKey: 'uuid-...' },
},
},
})
create(@Body() dto: CreateOrderDto) {}Swagger UI affiche un dropdown ; le SDK généré expose chaque exemple comme test fixture. Pratique pour documenter les cas tordus (max items, edge cases).
🎯 Patterns courants
- CLI plugin (
"@nestjs/swagger": { "introspectComments": true }dansnest-cli.json) — extrait descriptions depuis les JSDoc, infère types etrequired. Réduit la verbosité de 80%. - Examples multiples via
examples: { ok: { value: {...} }, partial: { value: {...} } }— Swagger UI propose un dropdown, parfait pour la doc d'API publique. - Generated SDK in CI —
openapi-typescript-codegenouopenapi-generatorproduisent un client TS typé. Le front consomme l'SDK, jamaisfetchdirect. Un breaking change dans le schéma → build front cassé immédiatement. - Mock server avec Prism :
prism mock openapi.json. Le front peut bosser avant que le back soit prêt. - Schema as test —
schemathesis run http://localhost:3000/docs-jsonfait du property-based testing contre l'OpenAPI. Trouve des 500 sur des inputs limite. - Versioning — un
openapi-v1.json,openapi-v2.jsonséparés viaSwaggerModule.setup('docs/v1', ...). Pas de "v2 ajouté à la v1" qui casse les consommateurs.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- 7 : Swagger v3 (
@nestjs/swagger@4). API ancienne (@ApiModelProperty). - 8 : passage à
@nestjs/swagger@5. Renommages :@ApiModelProperty→@ApiProperty. Plugin CLI introduit. - 9 :
@nestjs/swagger@6, support OpenAPI 3.0.x complet (avant ça mélangeait). Meilleur supportoneOf/anyOf. - 10 :
@nestjs/swagger@7. Breaking :SwaggerModule.setupretournevoid, l'instance UI n'est plus exposée directement.swagger-ui-expressest en peer dep (à installer explicitement). - 11 : OpenAPI 3.1 support partiel (nullable via
type: [..., 'null']). Compatibilité Fastify améliorée.addBearerAuthaccepte un nom optionnel pour multi-schemes.
Si tu migres : commence par bumper @nestjs/swagger avant le core Nest, la lib est plus stable rétro-compatiblement que l'inverse.
OpenAPI 3.0 vs 3.1 — le piège du nullable
C'est le détail qui casse un pipeline SDK silencieusement. Les deux versions modélisent « ce champ peut être null » différemment :
// OpenAPI 3.0
{ "type": "string", "nullable": true }
// OpenAPI 3.1 (aligné JSON Schema 2020-12)
{ "type": ["string", "null"] }Conséquences concrètes pour le staff :
| Décision | 3.0 | 3.1 |
|---|---|---|
@ApiProperty({ nullable: true }) | émet nullable: true | émet type: [..., 'null'] selon la version du document |
| Générateur SDK legacy (openapi-generator < 6, codegen anciens) | OK | peut produire any ou planter |
oneOf/anyOf imbriqués | support partiel | natif (c'est du JSON Schema pur) |
examples (pluriel) au niveau schéma | non | oui |
Règle : ne bumpe pas vers 3.1 « parce que c'est plus récent ». Bumpe quand toute ta chaîne aval (générateur SDK, mock Prism, validateur ajv, lecteur Redoc) supporte 3.1. Sinon tu casses 35 partenaires pour gagner une syntaxe null plus propre. Teste la migration en générant le SDK dans une PR jetable avant de toucher prod. @nestjs/swagger@7+ produit du 3.0 par défaut ; le passage 3.1 se pilote explicitement — ce n'est pas un accident à subir lors d'un npm update.
⚠️ Pitfalls
- OpenAPI 3.0 vs 3.1 — la 3.1 aligne sur JSON Schema 2020-12 (
nullable→type: [..., 'null']). Beaucoup d'outils legacy ne supportent que 3.0. Vérifier la compat de ton générateur SDK et de ton mock server avant de bumper. emitDecoratorMetadata: false→ tous tes types deviennentObjectouanydans le schéma. Vérifiertsconfig.json.- Types génériques —
Page<UserDto>génère un schéma vide. UtiliseApiExtraModels(UserDto)+getSchemaPath()ou crée un type concretUserPage extends Page<UserDto>. @Type(() => Foo)oublié sur nested DTO — la validation passe mais le swagger afficheObject.- Enums string vs numeric —
@ApiProperty({ enum: OrderStatus })fonctionne mais ajoute@ApiProperty({ enumName: 'OrderStatus' })pour générer un type nommé dans le SDK plutôt qu'une union anonyme dupliquée partout. - Date —
Datese sérialise en string ISO mais le schéma ditformat: date-time. Si tu utilisesclass-transformeravec@Transform, vérifie que la doc reflète bien le format. additionalPropertieset le strict — par défaut@nestjs/swaggern'émet pasadditionalProperties: falsesur les objets ; un validateur basé sur le schéma (ajv, le SDK) acceptera donc des champs inconnus. Côté runtime, c'estValidationPipe({ forbidNonWhitelisted: true })qui rejette les champs en trop — pas le schéma. Pour aligner le contrat avec ce comportement, ajoute@ApiSchema({ /* ... */ })ou passeadditionalProperties: falseau niveau de la classe via le décorateur@ApiExtraModels+ un override de schéma. Ne crois pas qu'unwhitelist: trueruntime se reflète automatiquement dansopenapi.json: ce sont deux mondes (cf. tableau des trois objets plus haut).- Swagger UI exposé en prod — surface d'attaque (versions, structure). Soit auth sur
/docs, soit désactivé en prod, soit hébergé sur un sous-domaine interne. - Le schéma drift — un dev ajoute un champ optionnel sans
@ApiProperty. Le front SDK ne le voit pas, "bug mystérieux". Mitige avec : test CI qui compareopenapi.jsonà un snapshot et fait échouer si non commité.
🧪 Testing
- Snapshot du JSON OpenAPI —
expect(doc).toMatchSnapshot(). Le diff dans la PR montre la surface de l'API qui change. - Test de réponses — chaque endpoint doit avoir une assertion type-checkée :
const res = await request(app.getHttpServer()).post('/orders').send(payload).expect(201);
const parsed = OrderDtoSchema.parse(res.body); // Zod / ajv depuis le JSON Schema- Schemathesis en CI sur l'app booté en docker — détecte les divergences sans test à la main.
- Breaking change detector — workflow CI qui télécharge
openapi.jsondemain, le compare à HEAD avecopenapi-diff. Labelbreaking-changeposé automatiquement sur la PR, requiert review supplémentaire.
🎬 Cas d'usage concrets
Scénario 1 — API bancaire exposée à des fintechs partenaires (open banking)
Qui : banque française avec une plateforme d'API "Banque-as-a-Service" consommée par 35 fintechs partenaires (crédit, agrégation, paiement). Problème : chaque partenaire générait son client à la main à partir d'une doc PDF. Au moindre changement de payload, c'était 35 tickets support et trois semaines de drift. La direction a décidé : la source de vérité devient l'OpenAPI, publié versionné, et chaque partenaire génère son SDK depuis le contrat.
// payments.controller.ts
@ApiTags('payments')
@ApiBearerAuth('partner-oauth2')
@Controller({ path: 'payments', version: '2' })
export class PaymentsController {
@Post('initiate')
@ApiOperation({ operationId: 'initiatePayment', summary: 'Initiate SEPA SCT' })
@ApiCreatedResponse({ type: PaymentInitiationResponseDto })
@ApiBadRequestResponse({ type: ProblemJsonDto, description: 'Validation error' })
@ApiResponse({ status: 409, type: ProblemJsonDto, description: 'Idempotency conflict' })
@ApiHeader({ name: 'X-Idempotency-Key', required: true, schema: { format: 'uuid' } })
initiate(
@Headers('x-idempotency-key') key: string,
@Body() dto: InitiatePaymentDto,
): Promise<PaymentInitiationResponseDto> {
return this.payments.initiate(dto, key);
}
}Gains : publication d'un changelog automatique par openapi-diff à chaque release ; les partenaires bumpent leur SDK quand ils sont prêts ; le support a chuté de 70% sur les tickets de format. Bonus : le test schemathesis en CI a trouvé 6 endpoints qui répondaient 500 sur des inputs limites (UUIDs avec caractères Unicode, montants à 0,001 €).
Scénario 2 — Marketplace e-commerce avec vendeurs externes
Qui : marketplace artisanale française, 1 200 vendeurs qui pilotent leurs catalogues via API (ERP, PIM, Shopify exporteurs maison). Problème : la doc Swagger initiale était décorative — décorateurs @ApiProperty à la mano, types manquants sur les arrays, drift constant. Les vendeurs qui généraient un SDK depuis l'OpenAPI tombaient sur any[] partout, et le support devait expliquer la vraie forme.
// catalog.dto.ts
@ApiExtraModels(VariantDto, ImageDto)
export class ProductDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Bougie parfumée lavande' })
@MaxLength(200)
name!: string;
@ApiProperty({ type: [VariantDto], minItems: 1 })
@ValidateNested({ each: true })
@Type(() => VariantDto)
variants!: VariantDto[];
@ApiProperty({
enum: ProductStatus,
enumName: 'ProductStatus',
example: ProductStatus.PUBLISHED,
})
status!: ProductStatus;
@ApiPropertyOptional({ type: [ImageDto], maxItems: 12 })
images?: ImageDto[];
}Gains : activation du nest-cli.json plugin (introspectComments: true) → 80% de décorateurs explicites supprimés, descriptions issues de JSDoc. Le SDK TS publié sur npm interne est consommé par 14 vendeurs ; les autres reprennent le openapi.json brut. Plus de tickets "c'est quoi le format d'images".
Scénario 3 — API publique d'un ATS RH
Qui : éditeur d'un ATS (logiciel de recrutement) qui ouvre une API publique pour les intégrations job boards, signature électronique, e-learning. Problème : l'API doit être documentée comme un produit (portail dev, Try It, examples). Et chaque champ "candidat" est sensible RGPD — il faut indiquer explicitement ce qui est PII et ce qui ne l'est pas.
// candidate.dto.ts
export class CandidateDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: '[email protected]', description: 'PII — RGPD article 4' })
email!: string;
@ApiProperty({ enum: ['active', 'archived', 'gdpr_erased'] })
status!: 'active' | 'archived' | 'gdpr_erased';
@ApiPropertyOptional({ format: 'date-time', description: 'Set when gdpr_erased' })
erasedAt?: string;
}
// portail dev: examples multiples pour la même opération
@ApiBody({
type: CreateCandidateDto,
examples: {
minimal: { summary: 'Minimal', value: { email: '[email protected]', firstName: 'Jane', lastName: 'Doe' } },
withCV: { summary: 'With CV upload reference', value: { email: '[email protected]', firstName: 'Jane', lastName: 'Doe', cvUploadId: 'up_42' } },
fromJobBoard: { summary: 'Source = LinkedIn', value: { email: '[email protected]', firstName: 'Jane', lastName: 'Doe', source: 'linkedin', externalId: 'in_123' } },
},
})Gains : portail Redoc statique servi via CDN (Swagger UI fermé en prod). Les intégrateurs envoient le bon payload du premier coup grâce aux examples. La conformité RGPD est documentée dans le contrat, pas dans un wiki à jour 3 mois plus tard.
🛠️ Exemple end-to-end
Mise en situation : tu construis l'API d'une plateforme de signature électronique consommée par des clients front (React) et des intégrateurs externes (Zapier, n8n). Tu veux : un contrat OpenAPI complet, un SDK TS généré en CI, un mock server pour le front qui démarre avant le back, et un test schemathesis qui exerce l'API contre son propre contrat.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'node:fs';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }));
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
const config = new DocumentBuilder()
.setTitle('eSign API')
.setDescription('Electronic signature platform — public API')
.setVersion('1.4.0')
.setContact('API team', 'https://esign.example.com/dev', '[email protected]')
.addServer('https://api.esign.example.com', 'Production')
.addServer('https://api.staging.esign.example.com', 'Staging')
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'user-jwt')
.addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header', description: 'Tenant API key for server-to-server' }, 'api-key')
.addTag('envelopes', 'Document envelopes lifecycle')
.addTag('signers', 'Signers and authentication methods')
.addTag('webhooks', 'Webhook subscriptions')
.build();
const doc = SwaggerModule.createDocument(app, config, {
operationIdFactory: (_controller, methodKey) => methodKey,
extraModels: [ProblemJsonDto, EnvelopeDto, SignerDto, WebhookEventDto],
});
SwaggerModule.setup('docs', app, doc, {
swaggerOptions: { persistAuthorization: true, displayRequestDuration: true },
});
if (process.env.EXPORT_OPENAPI === '1') {
writeFileSync('openapi.json', JSON.stringify(doc, null, 2));
process.exit(0);
}
await app.listen(3000);
}
bootstrap();// src/envelopes/dto/create-envelope.dto.ts
import { ApiProperty, ApiPropertyOptional, ApiExtraModels } from '@nestjs/swagger';
import { IsArray, IsEmail, IsEnum, IsOptional, IsString, IsUUID, MaxLength, ValidateNested, ArrayMinSize } from 'class-validator';
import { Type } from 'class-transformer';
export enum SignerAuthMethod {
EMAIL_OTP = 'email_otp',
SMS_OTP = 'sms_otp',
ID_CHECK = 'id_check',
}
export class SignerInputDto {
@ApiProperty({ example: '[email protected]' })
@IsEmail()
email!: string;
@ApiProperty({ example: 'Jane Doe' })
@IsString() @MaxLength(120)
fullName!: string;
@ApiProperty({ enum: SignerAuthMethod, enumName: 'SignerAuthMethod' })
@IsEnum(SignerAuthMethod)
authMethod!: SignerAuthMethod;
@ApiPropertyOptional({ description: 'For SMS_OTP only, E.164' })
@IsOptional() @IsString()
phoneE164?: string;
}
@ApiExtraModels(SignerInputDto)
export class CreateEnvelopeDto {
@ApiProperty({ example: 'Q1 2026 NDA' })
@IsString() @MaxLength(200)
title!: string;
@ApiProperty({ format: 'uuid', description: 'PDF previously uploaded' })
@IsUUID()
documentId!: string;
@ApiProperty({ type: [SignerInputDto], minItems: 1 })
@IsArray() @ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => SignerInputDto)
signers!: SignerInputDto[];
@ApiPropertyOptional({ description: 'ISO 8601 expiration', example: '2026-06-30T23:59:59Z' })
@IsOptional()
expiresAt?: string;
}// src/envelopes/envelopes.controller.ts
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth, ApiCreatedResponse, ApiOperation, ApiTags,
ApiBadRequestResponse, ApiNotFoundResponse, ApiBody, ApiParam,
} from '@nestjs/swagger';
import { CreateEnvelopeDto } from './dto/create-envelope.dto';
import { EnvelopeDto } from './dto/envelope.dto';
import { ProblemJsonDto } from '../shared/problem-json.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@ApiTags('envelopes')
@ApiBearerAuth('user-jwt')
@UseGuards(JwtAuthGuard)
@Controller('envelopes')
export class EnvelopesController {
constructor(private readonly envelopes: EnvelopesService) {}
@Post()
@ApiOperation({ operationId: 'createEnvelope', summary: 'Create envelope & invite signers' })
@ApiBody({
type: CreateEnvelopeDto,
examples: {
single: {
summary: 'Single signer, email OTP',
value: {
title: 'Service agreement',
documentId: '6f8b...uuid',
signers: [{ email: '[email protected]', fullName: 'Jane Doe', authMethod: 'email_otp' }],
},
},
multipleWithSms: {
summary: 'Two signers, SMS OTP',
value: {
title: 'Mutual NDA',
documentId: '6f8b...uuid',
signers: [
{ email: '[email protected]', fullName: 'Jane Doe', authMethod: 'sms_otp', phoneE164: '+33611111111' },
{ email: '[email protected]', fullName: 'John Roe', authMethod: 'sms_otp', phoneE164: '+33622222222' },
],
},
},
},
})
@ApiCreatedResponse({ type: EnvelopeDto })
@ApiBadRequestResponse({ type: ProblemJsonDto })
create(@Body() dto: CreateEnvelopeDto): Promise<EnvelopeDto> {
return this.envelopes.create(dto);
}
@Get(':id')
@ApiOperation({ operationId: 'getEnvelope', summary: 'Get envelope by id' })
@ApiParam({ name: 'id', format: 'uuid' })
@ApiNotFoundResponse({ type: ProblemJsonDto })
findOne(@Param('id') id: string): Promise<EnvelopeDto> {
return this.envelopes.findOne(id);
}
}Pipeline CI (extrait) :
# .github/workflows/openapi.yml
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: EXPORT_OPENAPI=1 pnpm start
- name: Diff against main
run: npx openapi-diff origin/main:openapi.json HEAD:openapi.json --fail-on=breaking
- name: Generate SDK
run: npx openapi-typescript-codegen --input openapi.json --output packages/esign-sdk/src --client fetch
- name: Publish SDK on tag
if: startsWith(github.ref, 'refs/tags/sdk-')
run: pnpm --filter @esign/sdk publish --access restricted
- name: Schemathesis fuzz
run: docker run --network host schemathesis/schemathesis run http://localhost:3000/docs-jsonLe front démarre Prism (prism mock openapi.json -p 4010) avant que l'API soit prête. Les tests Cypress du front s'écrivent contre Prism. Quand le back livre, le SDK généré remplace la stub : zéro changement de code front. Le openapi-diff casse la CI si un champ obligatoire disparaît ou si un endpoint change de chemin — la PR doit alors être labelisée breaking-change et approuvée par les Tech Leads partenaires.
🔁 Quand utiliser / éviter
- Utilise OpenAPI dès qu'une API a > 1 consommateur, ou si tu génères un SDK, ou si la doc dérive trop vite.
- Évite l'overhead quand tu prototypes une API interne très volatile — la maintenance des décorateurs ralentit. Ajoute Swagger quand l'API se stabilise.
- Évite Swagger UI public si l'API n'est pas vraiment publique — préfère Redoc statique servi via CDN, ou rien.
- GraphQL ? OpenAPI ne s'applique pas. Tu as ton schéma
.graphqlqui joue le même rôle. Combine@nestjs/graphql+ introspection désactivée en prod. - gRPC ? Pareil — les
.protosont le contrat. OpenAPI optionnel pour exposer un gateway REST.
Syncing with API consumers
Le contrat OpenAPI est inutile si les consommateurs (web, mobile, autres backends) bossent à l'écart. Pratiques :
- SDK publié comme package — CI build → publish
@acme/[email protected]sur npm/Verdaccio interne. Les consommateurs bumpent quand ils veulent, le contrat reste typé. - Versionning sémantique sur le SDK — un breaking change schéma = major. Un nouveau champ optionnel = minor. Un changement de description = patch.
- Deprecation —
@ApiProperty({ deprecated: true })+Sunsetheader HTTP. Les outils générés montrent un warning sur l'usage. - Changelog auto —
openapi-diffcompare deux JSON et liste les breaking changes. Affiche en commentaire PR.
# In CI
npx openapi-diff main:openapi.json HEAD:openapi.json --fail-on=breakingCommon documentation bugs
| Symptôme | Cause | Fix |
|---|---|---|
Type {} ou object dans Swagger UI | Décorateur manquant ou TS metadata désactivée | emitDecoratorMetadata: true + @ApiProperty() |
| Array sans type d'élément | Réflexion TS ne capture pas T[] | @ApiProperty({ type: [Item] }) |
| Enum dupliqué par DTO | Pas de enumName | @ApiProperty({ enum: Status, enumName: 'Status' }) |
Date rendu comme string sans format | Manque format | @ApiProperty({ type: String, format: 'date-time' }) |
| Nested DTO vide | class-transformer @Type manquant | @Type(() => Child) |
Generic Page<T> non résolu | Génériques non sérialisables OpenAPI | Crée un type concret par usage |
🤖 Documenter une API d'agent IA (LLM streaming, tool-use, MCP)
Ton stack sert/consomme des agents IA. OpenAPI reste pertinent ici — mais l'IA casse trois hypothèses implicites de Swagger : (1) la réponse n'est pas un JSON unique mais un flux de tokens, (2) les tool-calls ont un schéma qu'il faut publier pour que le client puisse les valider/rendre, (3) un endpoint MCP doit annoncer ses tools de façon machine-readable. Un staff engineer ne « met pas l'IA dans une boîte noire
text/plain» — il garde le contrat aussi strict que pour le reste.
Mental model — où OpenAPI s'arrête, où il continue
POST /agent/chat ──► 200 text/event-stream ◄─ OpenAPI décrit le CONTRAT du body + le format SSE,
│ PAS chaque token (le flux est hors-schéma).
│ data: {"type":"token","v":"Bon"}
│ data: {"type":"tool_call","name":"search","args":{...}} ◄─ CHAQUE event a un schéma
│ data: {"type":"tool_result","id":"...","ok":true}
▼ data: {"type":"done","usage":{...}} ◄─ discriminated union
AbortController ──► le client coupe → le serveur AbortSignal → SDK Anthropic stopLa règle : le body de requête et l'enveloppe de chaque event SSE sont des DTOs documentés ; le contenu textuel des tokens, lui, est libre. Tu publies l'union discriminée des events comme un oneOf + discriminator (exactement le pattern « Polymorphism » plus haut).
1. DTO de requête + union d'events streamés
// agent/dto/chat.dto.ts
import { ApiProperty, ApiPropertyOptional, ApiExtraModels, getSchemaPath } from '@nestjs/swagger';
import { IsArray, IsIn, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class ChatMessageDto {
@ApiProperty({ enum: ['user', 'assistant', 'system'] })
@IsIn(['user', 'assistant', 'system'])
role!: 'user' | 'assistant' | 'system';
@ApiProperty({ example: 'Résume le dossier client 42.' })
@IsString() @MaxLength(32_000)
content!: string;
}
export class ChatRequestDto {
@ApiProperty({ type: [ChatMessageDto], minItems: 1 })
@IsArray() @ValidateNested({ each: true }) @Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
@ApiPropertyOptional({
enum: ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'],
default: 'claude-sonnet-4-6',
description: 'Modèle Anthropic. opus = flagship, haiku = low-latency.',
})
@IsOptional() @IsString()
model?: string;
@ApiPropertyOptional({ description: 'Idempotency / generation id — clé de retry & de cache', format: 'uuid' })
@IsOptional() @IsString()
generationId?: string;
}
// --- Union d'events SSE, documentée comme discriminated union ---
export class TokenEventDto {
@ApiProperty({ enum: ['token'] }) type!: 'token';
@ApiProperty({ example: 'Bonjour' }) v!: string;
}
export class ToolCallEventDto {
@ApiProperty({ enum: ['tool_call'] }) type!: 'tool_call';
@ApiProperty({ example: 'searchCrm' }) name!: string;
@ApiProperty({ type: 'object', additionalProperties: true }) args!: Record<string, unknown>;
}
export class ToolResultEventDto {
@ApiProperty({ enum: ['tool_result'] }) type!: 'tool_result';
@ApiProperty() ok!: boolean;
@ApiPropertyOptional({ type: 'object', additionalProperties: true }) data?: Record<string, unknown>;
}
export class DoneEventDto {
@ApiProperty({ enum: ['done'] }) type!: 'done';
@ApiProperty({ example: { input_tokens: 1820, output_tokens: 311 } })
usage!: { input_tokens: number; output_tokens: number };
@ApiProperty({ enum: ['end_turn', 'max_tokens', 'stop_sequence', 'tool_use'] })
stopReason!: string;
}
@ApiExtraModels(TokenEventDto, ToolCallEventDto, ToolResultEventDto, DoneEventDto)
export class ChatStreamEnvelopeDto {
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(TokenEventDto) },
{ $ref: getSchemaPath(ToolCallEventDto) },
{ $ref: getSchemaPath(ToolResultEventDto) },
{ $ref: getSchemaPath(DoneEventDto) },
],
discriminator: { propertyName: 'type' },
description: 'Une ligne `data:` du flux SSE. Le client switch sur `type`.',
})
event!: TokenEventDto | ToolCallEventDto | ToolResultEventDto | DoneEventDto;
}2. Controller SSE + annotation OpenAPI honnête
Swagger ne sait pas streamer dans le « Try It » — donc tu documentes le content-type et le schéma d'enveloppe à la main, sinon le contrat ment (@ApiOkResponse({ type: ChatStreamEnvelopeDto }) ferait croire à une réponse JSON unique).
// agent/agent.controller.ts
import { Controller, Post, Body, Sse, Req, MessageEvent } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiProduces, ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
import { Observable } from 'rxjs';
import { Request } from 'express';
import { ChatRequestDto, ChatStreamEnvelopeDto } from './dto/chat.dto';
@ApiTags('agent')
@Controller('agent')
export class AgentController {
constructor(private readonly agent: AgentService) {}
@Post('chat')
@Sse()
@ApiOperation({ operationId: 'chatStream', summary: 'Chat agentique en streaming SSE (tool-use)' })
@ApiProduces('text/event-stream')
@ApiExtraModels(ChatStreamEnvelopeDto)
@ApiOkResponse({
description: 'Flux SSE. Chaque `data:` est un ChatStreamEnvelopeDto. Se termine par un event `done`.',
content: {
'text/event-stream': { schema: { $ref: getSchemaPath(ChatStreamEnvelopeDto) } },
},
})
chat(@Body() dto: ChatRequestDto, @Req() req: Request): Observable<MessageEvent> {
// req.on('close') → AbortController côté serveur (voir §3)
return this.agent.stream(dto, req);
}
}Gotcha réel :
@Sse()était historiquement pensé pourGET. Combiner@Post()+@Body()+@Sse()fonctionne sur les versions récentes de Nest (10/11) sous Express, mais reste un terrain où les comportements diffèrent entre Express et Fastify (flush,Content-Type, compression). Pour un contrôle total — notamment poserCache-Control: no-cache, désactiver la compression sur cette route, et écrire le: heartbeat\n\nkeep-alive toutes les 15 s pour traverser les proxies — beaucoup d'équipes pilotent la réponse à la main avec@Res({ passthrough: false })etres.write('data: ...\n\n'). Dans les deux cas, l'annotation OpenAPI (@ApiProduces('text/event-stream')+contentexplicite) reste identique : le contrat ne dépend pas du mécanisme d'émission.
3. Service : SDK Anthropic streamé, DI'd, AbortController câblé
Le point staff : pas de new Anthropic() dans un champ. Le client est injecté via forRootAsync (clé/timeout/retries configurés une fois, mockable en test), et la déconnexion client coupe le stream serveur — sinon tu paies des tokens dans le vide.
// llm/llm.module.ts — DI propre, pas de secret en dur
import { Module } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
@Module({
providers: [{
provide: 'ANTHROPIC',
useFactory: (cfg: ConfigService) => new Anthropic({
apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
maxRetries: 3, // le SDK gère le backoff 429/5xx
timeout: 60_000,
}),
inject: [ConfigService],
}],
exports: ['ANTHROPIC'],
})
export class LlmModule {}// agent/agent.service.ts (extrait — la boucle agentique)
import { Inject, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import type { Request } from 'express';
import type Anthropic from '@anthropic-ai/sdk';
import { ChatRequestDto, ChatStreamEnvelopeDto } from './dto/chat.dto';
// Type-safe : on construit l'enveloppe sans `as any`. Le payload SSE de Nest
// est { data: <objet sérialisé en JSON> }. On type `data` = ChatStreamEnvelopeDto.
type Frame = { data: ChatStreamEnvelopeDto };
@Injectable()
export class AgentService {
constructor(@Inject('ANTHROPIC') private readonly anthropic: Anthropic) {}
stream(dto: ChatRequestDto, req: Request): Observable<Frame> {
return new Observable<Frame>((sub) => {
const ac = new AbortController();
// IMPORTANT : nommer le handler pour pouvoir le retirer — sinon fuite de listener
// sur une connexion keep-alive qui sert plusieurs requêtes.
const onClose = () => ac.abort();
req.on('close', onClose);
const emit = (event: ChatStreamEnvelopeDto['event']) => sub.next({ data: { event } });
(async () => {
const stream = this.anthropic.messages.stream(
{ model: dto.model ?? 'claude-sonnet-4-6', max_tokens: 1024, messages: dto.messages },
{ signal: ac.signal }, // AbortSignal propagé au SDK
);
// Tokens texte
stream.on('text', (t) => emit({ type: 'token', v: t }));
// Tool-use : l'API Anthropic émet des content blocks `tool_use` — on les expose
// tels quels dans le contrat (la boucle agentique réelle exécuterait le tool ici).
stream.on('contentBlock', (block) => {
if (block.type === 'tool_use') {
emit({ type: 'tool_call', name: block.name, args: block.input as Record<string, unknown> });
}
});
const final = await stream.finalMessage();
emit({
type: 'done',
usage: { input_tokens: final.usage.input_tokens, output_tokens: final.usage.output_tokens },
stopReason: final.stop_reason ?? 'end_turn',
});
sub.complete();
})().catch((e) => {
// AbortError n'est pas une vraie erreur : le client est parti, on complète proprement.
if (ac.signal.aborted) sub.complete();
else sub.error(e);
});
// teardown : unsubscribe OU complete → on abort ET on retire le listener
return () => {
ac.abort();
req.off('close', onClose);
};
});
}
}Trois corrections que beaucoup ratent dans cet exact pattern :
- Pas de
as any. L'enveloppe est typée via une fabriqueemit(). Si tu ajoutes un variant à l'union et oublies de l'émettre, TS ne te le signalera pas (les unions sont ouvertes à la construction) — mais au moins la forme de chaque event est vérifiée. - Listener retiré au teardown. Sur une connexion HTTP keep-alive,
req.on('close')sansreq.offempile un listener par requête →MaxListenersExceededWarningpuis fuite mémoire sous charge. C'est le genre de bug qui ne se voit qu'en prod à 2 h du matin. AbortError≠ erreur. Quand le client coupe, le SDK rejette avec uneAbortError. La traiter comme une vraie erreur pollue tes logs/alertes et fausse ton taux d'erreur SLO. Oncomplete()silencieusement sisignal.aborted.
4. Exposer un endpoint MCP / tools comme contrat OpenAPI
Si ton API offre des tools à un agent (le tien ou un client externe via MCP), publie le JSON Schema de chaque tool. Un input_schema JSON Schema est exactement ce qu'attend l'API Anthropic dans tools[].input_schema — donc tu peux dériver tes tool definitions depuis tes DTOs au lieu de les écrire deux fois.
// Un seul DTO → (a) validation Nest, (b) schéma OpenAPI, (c) input_schema du tool Anthropic
@Get('agent/tools')
@ApiOperation({ operationId: 'listAgentTools', summary: 'Catalogue machine-readable des tools (MCP-friendly)' })
@ApiOkResponse({ schema: { type: 'array', items: { $ref: getSchemaPath(ToolManifestDto) } } })
listTools() {
// getSchemaPath / le document OpenAPI déjà généré te donne le JSON Schema de SearchCrmArgsDto, etc.
return this.registry.manifest(); // [{ name, description, input_schema: <JSON Schema du DTO> }]
}Production : idempotency, cost-guard, rate-limit, jobs au bord
| Préoccupation | Pourquoi | Où l'appliquer |
|---|---|---|
| Idempotency | un retry réseau ne doit pas relancer (et repayer) une génération | clé = generationId, cache résultat partiel/final en Redis avant de streamer |
| Cost-guard | un prompt géant = facture surprise | borne max_tokens côté serveur, refuse les messages > N tokens (@MaxLength ne suffit pas — compte les tokens) |
| Rate-limit | l'IA est chère et lente | @nestjs/throttler par tenant/clé API, distinct du throttle global |
| Jobs BullMQ | génération longue/batch (résumé de doc, embeddings) | job idempotent keyé sur generationId, retry cost-aware (ne re-stream pas ce qui est déjà produit), persistance de la sortie partielle |
| Observabilité | tracer tokens/coût/latence | log usage du done event + model + stopReason ; alerte si max_tokens trop fréquent |
Côté OpenAPI strictement : documente
429(rate-limit) et409(idempotency conflict) avecProblemJsonDto, et un headerRetry-After. Le SDK généré saura alors gérer le backoff côté client.
🏋️ Exercices
Exercice 1 — Le contrat qui ment (correctness)
Objectif : repérer et corriger un schéma qui diverge de la réalité runtime. Pars d'un controller qui retourne { data: T[], nextCursor: string | null } mais déclare @ApiOkResponse({ type: [ItemDto] }). Génère openapi.json, branche schemathesis run, observe les faux positifs/négatifs. Corrige avec un PaginatedDto<ItemDto> concret. Indice/Solution : les génériques ne sérialisent pas — crée class ItemPage { @ApiProperty({ type: [ItemDto] }) data; @ApiProperty({ nullable: true }) nextCursor }. Vérifie que schemathesis ne lève plus de mismatch de schéma.
Exercice 2 — Polymorphisme + SDK typé (implement)
Objectif : modéliser une union discriminée et prouver qu'elle génère un type utilisable. Modélise Notification = EmailNotif | SmsNotif | PushNotif avec oneOf + discriminator. Génère le SDK via openapi-typescript-codegen et écris un switch exhaustif côté client TS qui ne compile que si tous les variants sont gérés. Indice/Solution : @ApiExtraModels(...) + getSchemaPath. Côté SDK, un switch (n.channel) avec default: const _exhaustive: never = n force l'exhaustivité au build.
Exercice 3 — Anti-drift en CI (production-grade)
Objectif : rendre impossible de merger une PR où le code et l'OpenAPI divergent. Mets en place un job CI qui (a) exporte openapi.json, (b) le compare à un snapshot committé, échoue si différent et non committé, (c) lance openapi-diff contre main et pose le label breaking-change sur breaking. Indice/Solution : EXPORT_OPENAPI=1 + git diff --exit-code openapi.json. Pour le label, openapi-diff --fail-on=breaking en continue-on-error puis gh pr edit --add-label conditionnel sur le code de sortie.
Exercice 4 — Streamer un agent IA avec contrat honnête (AI, implement)
Objectif : exposer POST /agent/chat en SSE avec une union d'events documentée et un Stop fonctionnel. Implémente le DTO d'enveloppe (token | tool_call | tool_result | done), le controller @Sse(), et câble req.on('close') → AbortController → signal du SDK Anthropic. Vérifie dans le contrat qu'un consommateur sait parser chaque data:. Indice/Solution : voir §1–3 ci-dessus. Test du Stop : curl puis Ctrl-C, et assert via un log que ac.abort() a bien coupé l'appel Anthropic (pas de tokens facturés après déconnexion).
Exercice 5 — Break it then fix it (failure mode)
Objectif : provoquer puis colmater une faille classique d'OpenAPI en prod. (a) Expose Swagger UI en prod sans auth et montre la surface d'attaque (énumération d'endpoints internes). (b) Ajoute un champ PII sans @ApiProperty et observe que le SDK front ne le voit pas → bug silencieux. (c) Corrige les deux : guard sur /docs en prod + test CI qui échoue si un champ de DTO n'a pas de décorateur de doc. Indice/Solution : un Reflect/AST-walk des classes *Dto qui assert que chaque propriété a ApiProperty/ApiPropertyOptional ; en prod, @UseGuards(DocsGuard) ou ne pas appeler SwaggerModule.setup du tout (if (!isProd)).
Exercice 6 — Tool definitions dérivées des DTOs (AI, architecture)
Objectif : éliminer la double-écriture entre tes DTOs et les tools[].input_schema de l'API Anthropic. Construis un ToolRegistry qui prend une classe DTO décorée et produit { name, description, input_schema } en extrayant le JSON Schema depuis le document OpenAPI déjà généré. Expose-le sur GET /agent/tools (MCP-friendly) et passe-le tel quel à anthropic.messages.create({ tools }). Indice/Solution : SwaggerModule.createDocument te donne components.schemas[DtoName] — c'est déjà du JSON Schema. Mappe-le vers input_schema. Un seul DTO sert validation + doc + tool-use : zéro drift.
Exercice 7 — Le streaming qui fuit sous charge (AI, break it then fix it)
Objectif : reproduire puis colmater la fuite de listener + le faux taux d'erreur du endpoint SSE agentique. (a) Pars du AgentService sans req.off('close') et sans traiter l'AbortError. (b) Mets une keep-alive HTTP côté client et envoie 10 000 requêtes courtes interrompues à mi-stream (autocannon/k6 + abort). Observe le MaxListenersExceededWarning, la RSS qui monte, et ton dashboard d'erreurs qui explose en AbortError. (c) Corrige : retire le listener au teardown, complete() silencieusement si signal.aborted, et ajoute un heartbeat : ping\n\n toutes les 15 s. Prouve par un test que EventEmitter.listenerCount(req, 'close') revient à 0 après chaque requête. Indice/Solution : voir §3 (les trois corrections). Pour le test de fuite : un afterEach qui assert req.listenerCount('close') === 0. Pour le SLO : sépare métrique aborted_by_client de errors — un abort n'est pas un incident, c'est un comportement nominal.
🎤 En entretien
Q : OpenAPI généré ou écrit à la main — lequel et pourquoi ? Généré depuis le code (décorateurs + DTOs) : le contrat ne peut pas dériver du runtime puisqu'il est le runtime. Le hand-written OpenAPI ment dès le 2ᵉ sprint. On garde une porte CI (schemathesis + openapi-diff) pour que « le code est la source de vérité » soit vérifié, pas juste espéré.
Q : Comment empêcher un breaking change de partir sans que les consommateurs le sachent ? SDK publié en package versionné sémantiquement + openapi-diff en CI qui échoue sur breaking et pose un label imposant une review. Un champ obligatoire qui disparaît ou un path qui change = major ; le consommateur bumpe quand il est prêt, jamais surpris en prod.
Q : Tu exposes une réponse LLM en streaming — comment la documentes-tu en OpenAPI ? Le body de requête et l'enveloppe de chaque event SSE sont des DTOs (oneOf + discriminator sur type) ; le contenu textuel des tokens est hors-schéma. On annonce text/event-stream via @ApiProduces et un content explicite — surtout pas @ApiOkResponse({ type }) qui ferait croire à un JSON unique. On documente aussi 429/409 + Retry-After pour le backoff côté SDK.
Q : Génériques Page<T>, polymorphisme, enums — pourquoi ça casse et comment le staff gère ? La réflexion TS n'a pas les paramètres de type au runtime → Page<T> rend un schéma vide. On crée des types concrets par usage. Pour les unions : @ApiExtraModels + getSchemaPath + discriminator. Pour les enums : enumName pour générer un type nommé réutilisable dans le SDK au lieu d'une union anonyme dupliquée. La règle commune : ce que TS perd au runtime, on le redonne explicitement à OpenAPI.
Q : Le whitelist/forbidNonWhitelisted du ValidationPipe se reflète-t-il dans l'OpenAPI ? Non, et c'est le piège. Le ValidationPipe agit au runtime sur la requête entrante ; openapi.json décrit le contrat publié. Par défaut le schéma n'émet pas additionalProperties: false, donc un client qui valide contre le schéma (ajv, SDK généré) acceptera des champs en trop que le serveur rejettera ensuite en 400. Ce sont deux mondes (cf. le tableau « trois objets »). Le staff aligne les deux explicitement quand le strict compte, et surtout teste avec schemathesis que le runtime fait vraiment ce que le contrat promet.
Q : Comment migrer d'OpenAPI 3.0 à 3.1 sans casser tes consommateurs ? On ne bumpe pas par réflexe. Le changement le plus visible est nullable: true → type: [..., 'null'], que beaucoup de générateurs SDK legacy ne savent pas lire (sortie any ou crash). On vérifie d'abord que toute la chaîne aval (générateur, mock Prism, ajv, Redoc) supporte 3.1, on régénère le SDK dans une PR jetable pour observer le diff, et seulement ensuite on bascule — derrière un flag explicite, jamais via un npm update non intentionnel.