📐 Construire une app en JS
40. Couper en deux : l'API HTTP

Couper en deux : l'API HTTP

Notre domaine s'exécute dans le navigateur depuis le chapitre 22. C'était un raccourci assumé :

Évidemment, c'est temporaire. Nous verrons bientôt comment bénéficier de frameworks permettant de gérer le backend et le frontend avec une abstraction du réseau qui les sépare.

Le chapitre 31 a dit pourquoi il faut en sortir : tant que la commande s'exécute chez l'utilisateur, aucune règle n'est garantie. Et le chapitre 39 vient d'ajouter une transaction sur une base de données à laquelle un navigateur n'a, heureusement, aucun accès.

Coupons.

Ce qu'est un contrôleur

Un contrôleur ne fait que quatre choses :

  1. traduire une requête HTTP en scénario — une liste de commandes
  2. exécuter le scénario
  3. traduire le contexte en réponse HTTP
  4. rien d'autre

Le point 4 est le plus important. Un contrôleur qui contient un if sur une date, un calcul de prix ou une vérification de capacité est un contrôleur qui a volé du code au domaine. C'est l'origine de ce que le chapitre 22 appelait les contrôleurs obèses.

Et remarquez que nous avons déjà écrit des contrôleurs : les fonctions action et loader du frontend font exactement cela, sans le réseau. Nous n'inventons rien — nous déplaçons.

Commandes et requêtes deviennent des verbes

Le chapitre 4 avait établi une distinction. HTTP l'a encodée dans sa spécification, trente ans avant nous :

CommandeRequête
verbePOST, PUT, DELETEGET
rejouable sans risquenonoui
peut être mise en cachejamaisoui
relancée automatiquement par le navigateurnonoui

Ce n'est pas une convention esthétique. Un GET sera rejoué par un navigateur, préchargé par un antivirus, mis en cache par un proxy. Le jour où vous exposez une réservation derrière un GET, un aspirateur de site réserve tout votre catalogue. Ça s'est vu.

Notre API :

POST   /api/sessions                  se connecter
DELETE /api/sessions                  se déconnecter
GET    /api/accommodations?from&to    logements disponibles   (public)
POST   /api/bookings                  réserver
GET    /api/bookings                  mes réservations
POST   /api/bookings/:id/cancellation annuler

Arrêtons-nous sur la dernière ligne, parce qu'elle provoque toujours une discussion.

Le réflexe REST serait DELETE /api/bookings/:id. Mais nous ne supprimons rien : le chapitre 33 a établi que la réservation reste, avec un statut. DELETE mentirait sur ce qui se passe, et le jour où quelqu'un ajoutera une vraie suppression, il n'aura plus de verbe disponible.

Quand une action métier n'est pas un CRUD, ne la déguisez pas en CRUD. Donnez-lui son nom, et faites-en une ressource : POST /bookings/42/cancellation crée une annulation. C'est explicite, ça se journalise, et ça se lit dans un log sans traduction.

Le contrôleur générique

Tous nos contrôleurs se ressemblent, alors écrivons la mécanique une seule fois :

// api/handle.js
export function handle(scenario) {
  return async function (request, response) {
    const context = await app.run(scenario(request));
    const result = context.session();      // la vue du chapitre 30
    if (context.error) {
      return respondWithError(response, context.error);   // chapitre 42
    }
    response.status(200).json({ data: context.data ?? null, ...result });
  };
}

Et chaque route devient une ligne :

// api/routes.js
router.post("/api/bookings", handle((request) => [
  authenticate(request.cookies.session),
  book(request.body),
]));
 
router.get("/api/bookings", handle((request) => [
  authenticate(request.cookies.session),
  listMyBookings(),
]));
 
router.post("/api/bookings/:id/cancellation", handle((request) => [
  authenticate(request.cookies.session),
  cancelBooking({ bookingId: request.params.id }),
]));

Voilà notre couche HTTP. Elle décide de trois choses : quelles commandes, dans quel ordre, avec quels paramètres.

authenticate(request.cookies.session) en tête de chaque route privée, comme le chapitre 30 l'avait annoncé — un péage. Et c'est le seul endroit de toute l'application qui sait qu'un jeton voyage dans un cookie.

Notez book(request.body) : nous passons le corps de la requête tel quel, sans le regarder.

Faut-il valider le corps de la requête ?

La réponse habituelle est oui, avec un schéma — Zod, Joi, Ajv — avant d'entrer dans le domaine.

Notre réponse est plus nuancée, et elle vient du chapitre 21.

Que fait book d'un corps de requête douteux ?

const guests = Occupancy.of(payload);   // adults: "beaucoup" → NeedsAtLeastOneAdult
const stay = Stay.parse(payload);       // from: null        → NotACalendarDay

Nos Value Objects sont les validateurs. Ils refusent les chaînes, les nombres à virgule, les jours qui n'existent pas, les séjours à l'envers. Rien ne peut se construire dans un état invalide — c'était toute la troisième propriété du chapitre 21.

Ajouter un schéma par-dessus, c'est écrire les mêmes règles deux fois, avec deux messages d'erreur différents et une occasion de diverger.

Ce qui manque en revanche, et qui n'est pas du domaine :

  • une limite de taille sur le corps de la requête (express.json({ limit: "16kb" })) — sans quoi un client envoie 500 Mo et fait tomber le serveur ;
  • le type de contenu : refuser ce qui n'est pas du JSON ;
  • le nombre de requêtes par IP et par compte, sur les routes sensibles.

Ce sont des protections de transport, pas des règles métier. Elles vivent dans le contrôleur, ou plutôt dans une couche avant lui, et elles ne connaissent rien aux réservations.

La règle qui en sort :

Le domaine valide le sens. Le contrôleur protège la ressource.

Un mot d'honnêteté : si votre équipe travaille en TypeScript, un schéma à l'entrée a un autre mérite — il fait passer unknown à un type connu. C'est un argument recevable, et nous y revenons au chapitre 43. Ce qu'il ne doit jamais faire, c'est réécrire les règles métier.

Le cookie promis

Le chapitre 31 avait laissé une dette : le jeton dans localStorage, lisible par n'importe quel script.

router.post("/api/sessions", async (request, response) => {
  const context = await app.run([login(request.body)]);
  if (context.error) {
    return response.status(401).json({ error: "Invalid credentials" });
  }
  response.cookie("session", context.token, {
    httpOnly: true,                       // invisible pour le JavaScript de la page
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",                      // pas envoyé depuis un site tiers
    maxAge: 7 * 24 * 3600 * 1000,
    path: "/",
  });
  response.status(200).json({ currentUser: context.session().currentUser });
});

Le frontend ne voit plus jamais le jeton. Il reçoit seulement de quoi afficher "connecté en tant que…". Le navigateur joint le cookie tout seul à chaque requête.

Deux conséquences immédiates.

secure conditionné à l'environnement : en développement, sur http://localhost, un cookie Secure n'est pas envoyé du tout, et vous chercherez longtemps pourquoi vous êtes déconnecté à chaque appel.

sameSite: "lax" coupe l'essentiel des attaques CSRF — un autre site ne peut pas déclencher de POST authentifié chez vous. Si votre frontend est servi depuis un domaine différent de l'API, il faudra sameSite: "none" (donc secure, donc HTTPS), et là un jeton anti-CSRF redevient nécessaire. Le plus simple reste de servir les deux depuis le même domaine, l'API sous /api.

Et la déconnexion supprime le cookie et la session en base :

router.delete("/api/sessions", async (request, response) => {
  await app.run([logout(request.cookies.session)]);
  response.clearCookie("session", { path: "/" });
  response.status(204).end();
});

Le chapitre 31 l'avait dit : effacer le ticket ne suffit pas, il faut le faire annuler au vestiaire.

Le serveur

// api/server.js
import express from "express";
import cookieParser from "cookie-parser";
import { App } from "@booking/core";
import { productionDependencies } from "./infra/productionDependencies";
import { subscribers } from "./infra/subscribers";
import { routes } from "./routes";
 
const config = readConfig(process.env);            // et rien d'autre ne lit process.env
const app = new App(productionDependencies(config), subscribers);
 
const server = express();
server.use(express.json({ limit: "16kb" }));
server.use(cookieParser());
server.use("/", routes(app));
server.use(errorHandler);                          // le filet de sécurité
 
server.listen(config.port, () => console.log(`listening on ${config.port}`));

Une quinzaine de lignes. C'est tout ce que le passage au réseau coûte, et c'est le résultat de trente-neuf chapitres de discipline : App ne change pas, les commandes ne changent pas, le domaine ne sait toujours pas qu'HTTP existe.

Deux remarques sur ce fichier.

Express, Fastify, Hono ou le module http de Node : cela n'a aucune importance. Ce qui compte est que le choix soit confiné dans ce répertoire. Si vous pouvez changer de framework HTTP en réécrivant api/, vous avez gagné ; si request et response circulent dans vos commandes, vous avez perdu.

Un seul fichier lit process.env. Une configuration lue au démarrage, validée, passée en paramètre. Un process.env.DATABASE_URL au milieu d'un repository est une dépendance cachée : elle ne se voit pas dans le constructeur, elle ne se remplace pas dans un test, et elle produit un undefined silencieux au premier oubli.

Et le gestionnaire d'erreurs final n'est pas facultatif :

function errorHandler(error, request, response, next) {
  logger.error({ error, url: request.url }, "unhandled");
  response.status(500).json({ error: "Internal error" });
}

Sans lui, une exception non prévue tue le processus, ou pire, laisse la requête pendante jusqu'au délai d'attente du client. Et notez ce qu'il ne fait pas : il ne renvoie pas la pile d'appels au client. Un message d'erreur détaillé est une carte de votre système offerte à qui la demande — nom des tables, chemins de fichiers, versions des bibliothèques.

Ce que nous venons de gagner

Faisons le compte, parce que c'est le moment où trente-neuf chapitres se paient.

La sécurité devient réelle. book vérifiait déjà context.loggedUser depuis le chapitre 11. Cette vérification s'exécute maintenant sur une machine que l'utilisateur ne contrôle pas. Nous n'avons pas écrit une ligne d'autorisation supplémentaire ; nous avons déplacé le lieu d'exécution.

La concurrence devient gérable. Le chapitre 7 nous avait prévenus : la concurrence d'accès se gère dans le backend. La transaction du chapitre 39 s'exécute enfin là où elle a un sens.

Le frontend maigrit. Il ne contient plus ni domaine, ni base, ni secrets. Il affiche et il appelle.

Le domaine n'a pas changé. Pas une commande, pas un Value Object, pas un test. C'était l'engagement de la clean architecture, et c'est agréable de le vérifier.

Il reste à convaincre le frontend de parler à cette API.