ErrorHandlerService has zero dependency on Express — it's a plain
"map an error to {status, body}" service that works identically
behind any HTTP framework. It had no business living in a package
named express-tools.
Extracted HttpError, ErrorHandlerService, and ErrorHandlingResult
into a new packages/error-tools package (same tsc-build-to-dist
pattern as shared/express-tools). express-tools now only keeps the
actual Express-specific layer: ExpressServer, wrapAsyncHandler, and
createErrorMiddleware (which adapts ErrorHandlerService, imported
from error-tools, onto Express).
- packages/error-tools: new package, depends on shared + zod
- packages/express-tools: drops zod dependency, adds error-tools
dependency for error-middleware.ts's type import
- apps/api: adds error-tools dependency; app.ts, auth.service.ts,
require-auth.ts now import HttpError/errorHandlerService from
error-tools instead of express-tools
- apps/api/Dockerfile: adds COPY for packages/error-tools in the
runtime stage
- specs/error-handling.md, specs/backend-architecture.md, README.md
updated to reflect the new package split
Verified: pnpm lint, pnpm build (all packages, correct dependency
order), pnpm test (9/9 Mocha), pnpm test:bdd (5/5 Cucumber), full
Docker rebuild + compose up (no crash-loop), curl + browser checks
of /health, unknown-route 404, signup (201), duplicate-email 409
(code 4001 EMAIL_ALREADY_IN_USE) — all going through the moved
ErrorHandlerService/HttpError correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
138 lines
5.7 KiB
Markdown
138 lines
5.7 KiB
Markdown
# Architecture backend — Projet Batch-cooking
|
|
|
|
> Documentation de l'organisation d'`apps/api` et de l'outillage partagé
|
|
> (`packages/express-tools`, `packages/error-tools`, `packages/shared`).
|
|
|
|
---
|
|
|
|
## `packages/express-tools` — outillage Express générique
|
|
|
|
Package séparé, réutilisable par n'importe quel service Express du monorepo (pas
|
|
seulement `apps/api`) : pas de logique métier, juste de l'infra Express.
|
|
|
|
### `ExpressServer` — init serveur, routes, middlewares
|
|
|
|
Enveloppe une application Express derrière une API typée, au lieu que chaque
|
|
service refasse le même `express()` à la main :
|
|
|
|
```ts
|
|
const server = new ExpressServer();
|
|
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); // cors + json + cookie-parser
|
|
server.addRoute("get", "/health", (_req, res) => res.status(200).json({ status: "ok" }));
|
|
server.mountRouter("/auth", authRouter);
|
|
server.addMiddleware(notFoundHandler);
|
|
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
|
|
server.listen(port, () => console.log(`Listening on ${port}`));
|
|
```
|
|
|
|
- `setupCore(options)` — middleware stack commun (CORS avec credentials, JSON,
|
|
cookies).
|
|
- `addRoute(method, path, ...handlers)` — enregistre une route ; avertit et
|
|
ignore au lieu d'écraser silencieusement si la même route (méthode + chemin)
|
|
est déjà enregistrée.
|
|
- `addMiddleware` / `mountRouter` / `setErrorHandler` — ajout de middleware
|
|
générique, montage d'un `Router` complet, middleware d'erreur final (4
|
|
arguments — doit être ajouté en dernier).
|
|
- `.instance` — l'app Express brute, nécessaire pour les outils de test
|
|
(supertest) qui attendent une instance `Express`, pas le wrapper.
|
|
- `.listen(port, onListening?)` — démarre le serveur.
|
|
|
|
`apps/api/src/app.ts` expose deux fonctions : `createServer(): ExpressServer`
|
|
(utilisée par `server.ts`, qui appelle `.listen()`) et `createApp(): Express`
|
|
(= `createServer().instance`, utilisée par les tests).
|
|
|
|
### `wrapAsyncHandler` — plus de try/catch répété dans les routes
|
|
|
|
```ts
|
|
router.post("/signup", wrapAsyncHandler(async (req, res) => {
|
|
const profile = await signup(req.body); // une erreur/rejet ici va automatiquement à next()
|
|
res.status(201).json(profile);
|
|
}));
|
|
```
|
|
|
|
Sans ça, une exception dans un handler `async` ne remonte jamais tout seule au
|
|
middleware d'erreur d'Express — chaque route devait faire son propre
|
|
`try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise.
|
|
|
|
### `createErrorMiddleware` — adaptateur Express pour `packages/error-tools`
|
|
|
|
Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et
|
|
`ErrorHandlerService` vivent dans **`packages/error-tools`**, pas ici :
|
|
`ErrorHandlerService` **n'a aucune dépendance à Express** — c'est un service
|
|
générique `erreur → { status, body }` qui fonctionnerait à l'identique derrière
|
|
Fastify ou n'importe quel autre framework, donc il n'a rien à faire dans un
|
|
package *express*-tools. `ExpressServer` et `createErrorMiddleware` (ici) sont
|
|
la vraie couche Express : elles adaptent des pièces indépendantes du framework
|
|
(`ErrorHandlerService`, importé depuis `@batch-cooking/error-tools`) à l'API
|
|
d'Express.
|
|
|
|
---
|
|
|
|
## Auth : `res.locals`, pas d'augmentation du namespace Express
|
|
|
|
`requireAuth` (`apps/api/src/middlewares/require-auth.ts`) attache le profil
|
|
authentifié à **`res.locals.userProfile`**, typé via l'interface `AuthLocals` :
|
|
|
|
```ts
|
|
export interface AuthLocals {
|
|
userProfile: SafeUserProfile;
|
|
}
|
|
|
|
export async function requireAuth(req: Request, res: Response<unknown, AuthLocals>, next: NextFunction) {
|
|
// ...
|
|
res.locals.userProfile = safeProfile;
|
|
next();
|
|
}
|
|
```
|
|
|
|
Un handler derrière ce middleware type sa réponse `Response<unknown, AuthLocals>`
|
|
et lit `res.locals.userProfile` sans cast :
|
|
|
|
```ts
|
|
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
|
|
res.status(200).json(res.locals.userProfile);
|
|
});
|
|
```
|
|
|
|
**Pourquoi pas `declare global { namespace Express { interface Request {...} } }`**
|
|
(l'approche initialement utilisée, retirée depuis) : `res.locals` est le
|
|
mécanisme natif d'Express prévu exactement pour ça (faire passer des données
|
|
d'un middleware au handler suivant), typé par route via un paramètre
|
|
générique — pas une augmentation globale et permanente qui change
|
|
silencieusement le type de **toutes** les `Request` du projet, qu'elles soient
|
|
passées par ce middleware ou non.
|
|
|
|
---
|
|
|
|
## `packages/shared` — `assertIsNever`
|
|
|
|
`packages/shared/src/tools/assert-is-never.ts` — vérification d'exhaustivité
|
|
pour un `switch`/`if`-chain sur une union :
|
|
|
|
```ts
|
|
switch (shape.kind) {
|
|
case "circle": return Math.PI * shape.radius ** 2;
|
|
case "square": return shape.side ** 2;
|
|
default: return assertIsNever(shape); // erreur de compilation si un cas manque
|
|
}
|
|
```
|
|
|
|
Si un membre de l'union n'est pas traité par une branche précédente, `shape`
|
|
n'est plus de type `never` au niveau du `default` → **erreur de compilation**
|
|
(vérifié : `tsc` rejette bien un cas manquant). Lève aussi une vraie erreur au
|
|
runtime, en filet de sécurité si une valeur invalide échappe au système de
|
|
types (ex. donnée externe non validée).
|
|
|
|
Pas encore de point d'usage réel dans le code métier actuel (aucun
|
|
switch/if-chain exhaustif sur une union n'existe encore) — prêt à l'emploi dès
|
|
qu'un cas s'y prête (le module « Calcul batch-cooking » ou le pipeline d'import
|
|
de recette, tous deux encore à construire, en auront probablement).
|
|
|
|
---
|
|
|
|
## Pas de fichiers `.d.ts` écrits à la main
|
|
|
|
Voir [frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
|
|
pour le détail côté `apps/web`. Côté `apps/api` : aucune augmentation de type
|
|
globale (`declare global`) n'est utilisée — voir la section `res.locals`
|
|
ci-dessus, qui est précisément ce qui aurait nécessité ce genre de fichier.
|