Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
NestJS

TP 3 : Conception d'une API calculatrice avec NestJS

Ce TP fait concevoir une API calculatrice complète avec NestJS, publiée sur GitHub dès le départ. Il mobilise toutes les notions du chapitre 06 : routes et méthodes HTTP, ordre des routes, paramètres ...

0 chapitres 0 QCM Accès gratuit
TP 3 : Conception d'une API calculatrice avec NestJS

À propos de ce cours

Ce TP fait concevoir une API calculatrice complète avec NestJS, publiée sur GitHub dès le départ. Il mobilise toutes les notions du chapitre 06 : routes et méthodes HTTP, ordre des routes, paramètres de route et query string avec ParseFloatPipe, ParseIntPipe et DefaultValuePipe, corps de requête, codes de statut (201 avec Location, 204, 400, 404), redirection, préfixe global avec exclusion et versionnement URI (v1 à deux opérandes, v2 sur une liste de nombres). Il comprend un fichier de requêtes, la correction complète vérifiée et une grille d'évaluation.

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.

Routes de l'API calculatrice

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.

RouteRôleRéponse
GET /api/v1/calculs/operationsopérations de la v1200
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 corps201 + 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/:idun calcul200 ; 404
DELETE /api/v1/calculs/historiquevider l'historique204
GET /api/v1/calculs/aideredirection vers le README du dépôt302
GET /api/v2/calculs/operationsopérations de la v2 (somme, produit, moyenne, min, max)200
POST /api/v2/calculs { operation, nombres: [] }calcul sur une liste201 ; 400 si liste vide ou invalide
GET /santeétat du service, sans préfixe ni version200

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
  1. nest new api-calculatrice --package-manager npm --strict
  2. Créez le dépôt GitHub public api-calculatrice, reliez-le et poussez le projet (commit « chore: création du projet »).
  3. Générez le module et le service : nest g mo calculs et nest g s calculs --no-spec. Créez à la main les fichiers des deux contrôleurs et de la route de santé (ou avec nest g co, puis renommez).
  4. Supprimez AppController et AppService (et leurs fichiers de test), devenus inutiles.
  5. Commit : chore: structure du module calculs.
Partie 2 Les types et le service
  1. Dans calculs.types.ts, déclarez les listes d'opérations (as const), les types correspondants, l'interface Calcul et les deux DTO.
  2. 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) et vider().
  3. 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
  1. Écrivez CalculsV1Controller (@Controller({ path: 'calculs', version: '1' })) avec les routes du cahier des charges.
  2. Utilisez ParseFloatPipe pour a et b, ParseIntPipe pour :id, DefaultValuePipe(10) et ParseIntPipe pour limite.
  3. Attention à l'ordre: operations, aide et historique doivent être déclarées avant :operation.
  4. Le POST renvoie l'en-tête Location; le DELETE répond 204 ; aide redirige avec @Redirect.
  5. Dans main.ts: préfixe api (en excluant sante), versionnement URI avec defaultVersion: '1', CORS.
  6. Commit : feat: API calculatrice v1.
Partie 4 La version 2 et la route de santé
  1. Écrivez CalculsV2Controller (version 2) : GET operations et POST sur une liste de nombres, en réutilisant le même service.
  2. Écrivez SanteController (VERSION_NEUTRAL) qui renvoie l'état, le nom de l'application et la durée de fonctionnement.
  3. Commit : feat: API calculatrice v2 et route de santé.
Partie 5 Tester avec un fichier .http
  1. Créez requetes/calculatrice.http avec au moins les requêtes ci-dessous.
  2. Vérifiez chaque statut et chaque message d'erreur. Complétez le fichier avec vos propres cas (soustraction négative, limite=1, filtre operation=division, POST v2 avec nombres absent…).
  3. 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
  1. 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 /aide redirige bien vers ce README (remplacez VOTRE-COMPTE).
  2. Vérifiez : npm run lint et npm run build.
  3. Commit docs: README, push, puis étiquette git tag -a v1.0.0 -m "TP 3 : API calculatrice" et git 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: true et Sunset (avec @Header sur 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èrePoints
Dépôt GitHub dès la Partie 1, un commit clair par partie, étiquette v1.0.03
Service : calculs v1 et v2, historique, exceptions 400 et 404, aucune notion HTTP4
Contrôleur v1 : routes, ordre correct, pipes de conversion, 201 + Location, 204, redirection5
Versionnement : v1 et v2 partageant le service, route de santé neutre et hors préfixe4
Fichier requetes/calculatrice.http complet (cas normaux et d'erreur)2
README avec tableau des routes et codes d'erreur2
Total20