À propos de ce cours
Objectifs du TP
- Concevoir les routes d'une API à partir d'un cahier des charges.
- Lire des données dans le chemin, la query string et le corps, en les convertissant.
- Renvoyer les bons codes de statut et signaler les erreurs par des exceptions.
- Versionner une API et partager un service entre deux versions.
- Publier le projet sur GitHub dès le départ, avec un commit par partie.
Durée: 3 h | Prérequis: leçons 6.1 à 6.6 | Livrable: le dépôt api-calculatrice avec l'étiquette v1.0.0.
Les routes à réaliser et les réponses attendues.
Cahier des charges
Une plateforme pédagogique a besoin d'un petit service de calcul utilisable par ses applications web et mobile. Il doit conserver un historique des calculs (en mémoire) et évoluer sans casser les applications déjà publiées.
| Route | Rôle | Réponse |
|---|---|---|
GET /api/v1/calculs/operations | opérations de la v1 | 200 |
GET /api/v1/calculs/:operation?a=…&b=… | calcul à deux opérandes (addition, soustraction, multiplication, division) | 200 ; 400 si opération inconnue, opérande non numérique ou division par zéro |
POST /api/v1/calculs { operation, a, b } | même calcul, par le corps | 201 + en-tête Location |
GET /api/v1/calculs/historique?operation=…&limite=… | derniers calculs, plus récents d'abord (limite 10 par défaut) | 200 |
GET /api/v1/calculs/historique/:id | un calcul | 200 ; 404 |
DELETE /api/v1/calculs/historique | vider l'historique | 204 |
GET /api/v1/calculs/aide | redirection vers le README du dépôt | 302 |
GET /api/v2/calculs/operations | opérations de la v2 (somme, produit, moyenne, min, max) | 200 |
POST /api/v2/calculs { operation, nombres: [] } | calcul sur une liste | 201 ; 400 si liste vide ou invalide |
GET /sante | état du service, sans préfixe ni version | 200 |
Chaque calcul enregistré a la forme { id, version, operation, operandes, resultat, date }, avec un résultat arrondi à 6 décimales.
Partie 1 Créer le projet et le dépôt GitHub
nest new api-calculatrice --package-manager npm --strict- Créez le dépôt GitHub public
api-calculatrice, reliez-le et poussez le projet (commit « chore: création du projet »). - Générez le module et le service :
nest g mo calculsetnest g s calculs --no-spec. Créez à la main les fichiers des deux contrôleurs et de la route de santé (ou avecnest g co, puis renommez). - Supprimez
AppControlleretAppService(et leurs fichiers de test), devenus inutiles. - Commit :
chore: structure du module calculs.
Partie 2 Les types et le service
- Dans
calculs.types.ts, déclarez les listes d'opérations (as const), les types correspondants, l'interfaceCalculet les deux DTO. - Dans
CalculsService, écrivez :calculer(operation, a, b): vérifie l'opération et les nombres, refuse la division par zéro (BadRequestException) et enregistre le calcul ;calculerListe(operation, nombres)pour la v2 ;lister(operation?, limite),trouver(id)(NotFoundException) etvider().
- Commit :
feat: service de calcul et historique.
Le service ne connaît pas HTTP : il reçoit des nombres, lève des exceptions et renvoie des objets. C'est ce qui permettra aux deux versions de le partager.
Partie 3 La version 1
- Écrivez
CalculsV1Controller(@Controller({ path: 'calculs', version: '1' })) avec les routes du cahier des charges. - Utilisez
ParseFloatPipepouraetb,ParseIntPipepour:id,DefaultValuePipe(10)etParseIntPipepourlimite. - Attention à l'ordre:
operations,aideethistoriquedoivent être déclarées avant:operation. - Le POST renvoie l'en-tête
Location; le DELETE répond 204 ;aideredirige avec@Redirect. - Dans
main.ts: préfixeapi(en excluantsante), versionnement URI avecdefaultVersion: '1', CORS. - Commit :
feat: API calculatrice v1.
Partie 4 La version 2 et la route de santé
- Écrivez
CalculsV2Controller(version 2) :GET operationsetPOSTsur une liste de nombres, en réutilisant le même service. - Écrivez
SanteController(VERSION_NEUTRAL) qui renvoie l'état, le nom de l'application et la durée de fonctionnement. - Commit :
feat: API calculatrice v2 et route de santé.
Partie 5 Tester avec un fichier .http
- Créez
requetes/calculatrice.httpavec au moins les requêtes ci-dessous. - Vérifiez chaque statut et chaque message d'erreur. Complétez le fichier avec vos propres cas (soustraction négative,
limite=1, filtreoperation=division, POST v2 avecnombresabsent…). - Commit :
test: requêtes de la calculatrice.
@base = http://localhost:3000/api
@v1 = {{base}}/v1/calculs
@v2 = {{base}}/v2/calculs
### Opérations disponibles
GET {{v1}}/operations
### Calcul par l'URL
GET {{v1}}/addition?a=12.5&b=7.5
### Division par zéro : 400
GET {{v1}}/division?a=10&b=0
### Opération inconnue : 400
GET {{v1}}/racine?a=9&b=2
### Opérande non numérique : 400
GET {{v1}}/addition?a=dix&b=2
### Calcul par le corps : 201 + Location
POST {{v1}}
Content-Type: application/json
{ "operation": "multiplication", "a": 6, "b": 7 }
### Version 2 : liste de nombres
POST {{v2}}
Content-Type: application/json
{ "operation": "moyenne", "nombres": [14, 12, 16, 9] }
### Historique (2 derniers)
GET {{v1}}/historique?limite=2
### Un calcul
GET {{v1}}/historique/3
### Vider l'historique : 204
DELETE {{v1}}/historique
### Santé (sans préfixe ni version)
GET http://localhost:3000/sante
Partie 6 Documenter et livrer
- Rédigez le
README.md: présentation, installation, lancement, tableau des routes avec exemples de réponses, codes d'erreur. Vérifiez que la route/aideredirige bien vers ce README (remplacezVOTRE-COMPTE). - Vérifiez :
npm run lintetnpm run build. - Commit
docs: README, push, puis étiquettegit tag -a v1.0.0 -m "TP 3 : API calculatrice"etgit push origin v1.0.0.
Résultat attendu
Réponses obtenues avec la correction :
GET /api/v1/calculs/operations → 200 {"version":1,"operations":["addition","soustraction","multiplication","division"]}
GET /api/v1/calculs/addition?a=12.5&b=7.5 → 200 {"id":1,"version":1,"operation":"addition","operandes":[12.5,7.5],"resultat":20,"date":"…"}
GET /api/v1/calculs/division?a=10&b=3 → 200 {"id":2,"version":1,"operation":"division","operandes":[10,3],"resultat":3.333333,"date":"…"}
GET /api/v1/calculs/division?a=10&b=0 → 400 {"message":"Division par zéro impossible","error":"Bad Request","statusCode":400}
GET /api/v1/calculs/racine?a=9&b=2 → 400 {"message":"Opération inconnue : racine. Opérations possibles : addition, soustraction, multiplication, division","error":"Bad Request","statusCode":400}
GET /api/v1/calculs/addition?a=dix&b=2 → 400 {"message":"Validation failed (numeric string is expected)","error":"Bad Request","statusCode":400}
POST /api/v1/calculs → 201 {"id":3,"version":1,"operation":"multiplication","operandes":[6,7],"resultat":42,"date":"…"} [location: /api/v1/calculs/historique/3]
POST /api/v1/calculs → 400 {"message":"Les opérandes doivent être des nombres","error":"Bad Request","statusCode":400}
POST /api/v2/calculs → 201 {"id":4,"version":2,"operation":"moyenne","operandes":[14,12,16,9],"resultat":12.75,"date":"…"}
POST /api/v2/calculs → 400 {"message":"La propriété « nombres » doit être une liste non vide","error":"Bad Request","statusCode":400}
GET /api/v1/calculs/historique?limite=2 → 200 [{"id":4,"version":2,"operation":"moyenne","operandes":[14,12,16,9],"resultat":12.75,"date":"…"},{"id":3,"version":1,"operation":"multiplication","operandes":[6,7],"resultat":42,"date":"…"}]
GET /api/v1/calculs/historique?operation=division → 200 [{"id":2,"version":1,"operation":"division","operandes":[10,3],"resultat":3.333333,"date":"…"}]
GET /api/v1/calculs/historique/3 → 200 {"id":3,"version":1,"operation":"multiplication","operandes":[6,7],"resultat":42,"date":"…"}
GET /api/v1/calculs/historique/99 → 404 {"message":"Calcul 99 introuvable","error":"Not Found","statusCode":404}
GET /api/v1/calculs/aide → 302 Found. Redirecting to https://github.com/VOTRE-COMPTE/api-calculatrice#readme [location: https://github.com/VOTRE-COMPTE/api-calculatrice#readme]
DELETE /api/v1/calculs/historique → 204
GET /api/v1/calculs/historique → 200 []
GET /api/v2/calculs/operations → 200 {"version":2,"operations":["somme","produit","moyenne","min","max"]}
GET /sante → 200 {"statut":"ok","application":"api-calculatrice","dureeFonctionnement":0}
Pour aller plus loin
- Ajoutez en v2 l'opération
puissance(exactement deux nombres, exposant entier entre 0 et 100) avec un message d'erreur précis. - Ajoutez l'en-tête
X-Total-CountàGET historique(nombre total de calculs, avant la limite). - Ajoutez
GET /api/v1/calculs/statistiques: nombre de calculs par opération et moyenne des résultats. Où placer cette route ? - Faites renvoyer aux réponses v1 les en-têtes
Deprecation: trueetSunset(avec@Headersur le contrôleur v1).
Correction
src/calculs/calculs.types.ts
export const OPERATIONS_V1 = ['addition', 'soustraction', 'multiplication', 'division'] as const;
export type OperationV1 = (typeof OPERATIONS_V1)[number];
export const OPERATIONS_V2 = ['somme', 'produit', 'moyenne', 'min', 'max'] as const;
export type OperationV2 = (typeof OPERATIONS_V2)[number];
export interface Calcul {
id: number;
version: 1 | 2;
operation: string;
operandes: number[];
resultat: number;
date: string;
}
export interface CalculV1Dto {
operation: OperationV1;
a: number;
b: number;
}
export interface CalculV2Dto {
operation: OperationV2;
nombres: number[];
}
src/calculs/calculs.service.ts
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Calcul, OPERATIONS_V1, OPERATIONS_V2, OperationV1, OperationV2 } from './calculs.types';
@Injectable()
export class CalculsService {
private historique: Calcul[] = [];
private prochainId = 1;
// ----- Version 1 : deux opérandes -----
calculer(operation: string, a: number, b: number): Calcul {
if (!OPERATIONS_V1.includes(operation as OperationV1)) {
throw new BadRequestException(`Opération inconnue : ${operation}. Opérations possibles : ${OPERATIONS_V1.join(', ')}`);
}
this.verifierNombres([a, b]);
if (operation === 'division' && b === 0) {
throw new BadRequestException('Division par zéro impossible');
}
const resultats: Record<OperationV1, number> = {
addition: a + b,
soustraction: a - b,
multiplication: a * b,
division: a / b,
};
return this.enregistrer(1, operation, [a, b], resultats[operation as OperationV1]);
}
// ----- Version 2 : une liste de nombres -----
calculerListe(operation: string, nombres: number[]): Calcul {
if (!OPERATIONS_V2.includes(operation as OperationV2)) {
throw new BadRequestException(`Opération inconnue : ${operation}. Opérations possibles : ${OPERATIONS_V2.join(', ')}`);
}
if (!Array.isArray(nombres) || nombres.length === 0) {
throw new BadRequestException('La propriété « nombres » doit être une liste non vide');
}
this.verifierNombres(nombres);
const somme = nombres.reduce((s, n) => s + n, 0);
const resultats: Record<OperationV2, number> = {
somme,
produit: nombres.reduce((p, n) => p * n, 1),
moyenne: somme / nombres.length,
min: Math.min(...nombres),
max: Math.max(...nombres),
};
return this.enregistrer(2, operation, nombres, resultats[operation as OperationV2]);
}
// ----- Historique -----
lister(operation?: string, limite = 10): Calcul[] {
const liste = operation ? this.historique.filter((c) => c.operation === operation) : this.historique;
return liste.slice(-limite).reverse(); // les plus récents d'abord
}
trouver(id: number): Calcul {
const c = this.historique.find((x) => x.id === id);
if (!c) throw new NotFoundException(`Calcul ${id} introuvable`);
return c;
}
vider(): void {
this.historique = [];
}
// ----- Outils privés -----
private verifierNombres(nombres: unknown[]): void {
if (nombres.some((n) => typeof n !== 'number' || !Number.isFinite(n))) {
throw new BadRequestException('Les opérandes doivent être des nombres');
}
}
private enregistrer(version: 1 | 2, operation: string, operandes: number[], resultat: number): Calcul {
const calcul: Calcul = {
id: this.prochainId++,
version,
operation,
operandes,
resultat: Math.round(resultat * 1e6) / 1e6,
date: new Date().toISOString(),
};
this.historique.push(calcul);
return calcul;
}
}
src/calculs/calculs-v1.controller.ts
import {
Body, Controller, DefaultValuePipe, Delete, Get, HttpCode, HttpStatus, Param, ParseFloatPipe,
ParseIntPipe, Post, Query, Redirect, Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { CalculsService } from './calculs.service';
import { CalculV1Dto, OPERATIONS_V1 } from './calculs.types';
@Controller({ path: 'calculs', version: '1' })
export class CalculsV1Controller {
constructor(private readonly calculsService: CalculsService) {}
@Get('operations') // routes statiques AVANT ':operation'
operations() {
return { version: 1, operations: OPERATIONS_V1 };
}
@Get('aide')
@Redirect('https://github.com/VOTRE-COMPTE/api-calculatrice#readme', 302)
aide() {}
@Get('historique')
historique(
@Query('operation') operation?: string,
@Query('limite', new DefaultValuePipe(10), ParseIntPipe) limite = 10,
) {
return this.calculsService.lister(operation, limite);
}
@Get('historique/:id')
unCalcul(@Param('id', ParseIntPipe) id: number) {
return this.calculsService.trouver(id);
}
@Delete('historique')
@HttpCode(HttpStatus.NO_CONTENT)
viderHistorique() {
this.calculsService.vider();
}
@Get(':operation') // GET /api/v1/calculs/addition?a=2&b=3
calculer(
@Param('operation') operation: string,
@Query('a', ParseFloatPipe) a: number,
@Query('b', ParseFloatPipe) b: number,
) {
return this.calculsService.calculer(operation, a, b);
}
@Post() // POST /api/v1/calculs { operation, a, b }
creer(@Body() dto: CalculV1Dto, @Res({ passthrough: true }) res: Response) {
const calcul = this.calculsService.calculer(dto.operation, dto.a, dto.b);
res.location(`/api/v1/calculs/historique/${calcul.id}`);
return calcul;
}
}
src/calculs/calculs-v2.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { CalculsService } from './calculs.service';
import { CalculV2Dto, OPERATIONS_V2 } from './calculs.types';
@Controller({ path: 'calculs', version: '2' })
export class CalculsV2Controller {
constructor(private readonly calculsService: CalculsService) {}
@Get('operations')
operations() {
return { version: 2, operations: OPERATIONS_V2 };
}
@Post() // POST /api/v2/calculs { operation, nombres }
creer(@Body() dto: CalculV2Dto) {
return this.calculsService.calculerListe(dto.operation, dto.nombres);
}
}
src/calculs/calculs.module.ts
import { Module } from '@nestjs/common';
import { CalculsV1Controller } from './calculs-v1.controller';
import { CalculsV2Controller } from './calculs-v2.controller';
import { CalculsService } from './calculs.service';
@Module({
controllers: [CalculsV1Controller, CalculsV2Controller],
providers: [CalculsService],
})
export class CalculsModule {}
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-calculatrice', dureeFonctionnement: Math.round(process.uptime()) };
}
}
src/app.module.ts
import { Module } from '@nestjs/common';
import { CalculsModule } from './calculs/calculs.module';
import { SanteController } from './sante.controller';
@Module({
imports: [CalculsModule],
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();
const port = process.env.PORT ?? 3000;
await app.listen(port);
Logger.log(`API calculatrice : http://localhost:${port}/api/v1/calculs/operations`, '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 | 3 |
| Service : calculs v1 et v2, historique, exceptions 400 et 404, aucune notion HTTP | 4 |
| Contrôleur v1 : routes, ordre correct, pipes de conversion, 201 + Location, 204, redirection | 5 |
| Versionnement : v1 et v2 partageant le service, route de santé neutre et hors préfixe | 4 |
| Fichier requetes/calculatrice.http complet (cas normaux et d'erreur) | 2 |
| README avec tableau des routes et codes d'erreur | 2 |
| Total | 20 |