diff --git a/README.md b/README.md index 7899c97..077f274 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ Monorepo pnpm workspaces : `loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode` numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) — même règles des deux côtés, pas de risque de dérive entre front et back. -- `packages/express-tools` — outillage Express générique et réutilisable (`HttpError`, - `ErrorHandlerService`, `createErrorMiddleware`), séparé d'`apps/api` : pas de - logique métier, juste de l'infra Express. +- `packages/express-tools` — outillage Express générique et réutilisable : `ExpressServer` + (init serveur, routes, middlewares), `wrapAsyncHandler`, `HttpError`, + `ErrorHandlerService`, `createErrorMiddleware` — séparé d'`apps/api`, pas de + logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md). `packages/shared` et `packages/express-tools` ont un vrai build (`tsc` → `dist/`, voir leur `package.json`) : consommés en JS compilé, pas en TS brut — nécessaire @@ -202,6 +203,15 @@ en dur nulle part (toujours `ErrorCode.XXX`, y compris dans les mocks Cypress). Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) : [specs/error-handling.md](specs/error-handling.md). +Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile` +(typé via `AuthLocals`), pas par une augmentation du namespace global Express — +voir [specs/backend-architecture.md](specs/backend-architecture.md) pour le détail +et le pourquoi. + +`packages/shared` fournit aussi `assertIsNever` (vérification d'exhaustivité de +switch/if-chain sur une union, erreur de **compilation** si un cas est oublié) — +voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshared--assertisnever). + ## i18n **i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons, diff --git a/apps/api/package.json b/apps/api/package.json index 530d581..f73a193 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,8 +18,6 @@ "@batch-cooking/shared": "workspace:*", "@prisma/client": "^5.22.0", "argon2": "0.31.2", - "cookie-parser": "^1.4.7", - "cors": "^2.8.6", "dotenv": "^16.4.5", "express": "^4.21.1", "jsonwebtoken": "^9.0.3", @@ -28,8 +26,6 @@ "devDependencies": { "@cucumber/cucumber": "^13.2.1", "@faker-js/faker": "^10.6.0", - "@types/cookie-parser": "^1.4.10", - "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.9.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index db65ad0..a950827 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,37 +1,34 @@ -import { createErrorMiddleware, errorHandlerService } from "@batch-cooking/express-tools"; +import { + ExpressServer, + createErrorMiddleware, + errorHandlerService, +} from "@batch-cooking/express-tools"; import { ErrorCode } from "@batch-cooking/shared"; -import cookieParser from "cookie-parser"; -import cors from "cors"; -import express, { type Request, type Response } from "express"; +import type { Express, Request, Response } from "express"; import { env } from "./config/env.js"; import { authRouter } from "./modules/auth/auth.routes.js"; /** - * Builds a fresh Express application instance (no shared mutable state - * between calls — used both by the real server entrypoint and by tests, - * which each get their own app via supertest). - * - * Feature modules are mounted under `/`-prefixed routers as specs land; - * `auth` is the first one (login page / profile creation). + * Builds the API's `ExpressServer`: standard middleware, routes, and the + * final error handler, in that order. Returns the `ExpressServer` wrapper + * (not just the raw Express app) so `server.ts` can call `.listen()` on + * it — {@link createApp} below is the thinner entry point that exposes + * just the raw `Express` instance, for test tooling (supertest) that + * expects one. */ -export function createApp() { - const app = express(); +export function createServer(): ExpressServer { + const server = new ExpressServer(); + server.setupCore({ corsOrigin: env.CORS_ORIGIN }); - // Frontend and API run on different origins — `credentials: true` is - // required for the httpOnly session cookie to be sent/received. - app.use(cors({ origin: env.CORS_ORIGIN, credentials: true })); - app.use(express.json()); - app.use(cookieParser()); - - app.get("/health", (_req: Request, res: Response) => { + server.addRoute("get", "/health", (_req: Request, res: Response) => { res.status(200).json({ status: "ok" }); }); - app.use("/auth", authRouter); + server.mountRouter("/auth", authRouter); // No route matched — same shape as every other error response, via the // shared ErrorCode contract, so clients never special-case 404s. - app.use((_req: Request, res: Response) => { + server.addMiddleware((_req: Request, res: Response) => { res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" }); }); @@ -39,7 +36,17 @@ export function createApp() { // the app ends up here. All the "what status/body does this error map // to" logic lives in ErrorHandlerService, from @batch-cooking/express-tools // — this stays a thin adapter. - app.use(createErrorMiddleware(errorHandlerService)); + server.setErrorHandler(createErrorMiddleware(errorHandlerService)); - return app; + return server; +} + +/** + * Builds a fresh Express application instance (no shared mutable state + * between calls — used both indirectly by the real server entrypoint + * (`server.ts`, via {@link createServer}) and directly by tests, which + * each get their own app via supertest). + */ +export function createApp(): Express { + return createServer().instance; } diff --git a/apps/api/src/middlewares/require-auth.ts b/apps/api/src/middlewares/require-auth.ts index dacb37a..31c33ca 100644 --- a/apps/api/src/middlewares/require-auth.ts +++ b/apps/api/src/middlewares/require-auth.ts @@ -1,10 +1,21 @@ import { HttpError } from "@batch-cooking/express-tools"; -import { ErrorCode } from "@batch-cooking/shared"; +import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared"; import type { NextFunction, Request, Response } from "express"; import { env } from "../config/env.js"; import { prisma } from "../db/prisma.js"; import { verifyAuthToken } from "../lib/jwt.js"; +/** + * Shape of `res.locals` once {@link requireAuth} has run successfully. Type + * a route handler's response as `Response` (see + * `auth.routes.ts`'s `/me` handler) to read `res.locals.userProfile` fully + * typed, no cast needed. + */ +export interface AuthLocals { + /** The authenticated profile, resolved from the session cookie's JWT. */ + userProfile: SafeUserProfile; +} + /** * Express middleware guarding routes that require an authenticated * profile. Reads the session cookie, verifies the JWT, and re-checks @@ -12,14 +23,24 @@ import { verifyAuthToken } from "../lib/jwt.js"; * invalidated server-side (e.g. on password change / logout-everywhere, * once that feature exists) despite carrying no server-side session. * - * On success, attaches the resolved profile to `req.userProfile` (see - * `src/types/express-request.augment.ts`) for downstream handlers to use. + * On success, attaches the resolved profile to `res.locals.userProfile` + * (typed via {@link AuthLocals}) for downstream handlers to use. + * Deliberately `res.locals` rather than augmenting Express's global + * `Request` type via `declare global`: `res.locals` is Express's own + * built-in mechanism for exactly this (passing data from a middleware to + * the next handler), typed per-route through a generic parameter — no + * project-wide ambient augmentation silently changing every `Request` in + * the codebase, whether or not it went through this middleware. * * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing * cookie, malformed/expired JWT, unknown profile, or stale tokenVersion. * Never distinguishes the reason to the client. */ -export async function requireAuth(req: Request, _res: Response, next: NextFunction) { +export async function requireAuth( + req: Request, + res: Response, + next: NextFunction, +) { try { const token = req.cookies?.[env.AUTH_COOKIE_NAME]; if (typeof token !== "string") { @@ -34,7 +55,7 @@ export async function requireAuth(req: Request, _res: Response, next: NextFuncti } const { passwordHash: _passwordHash, ...safeProfile } = profile; - req.userProfile = safeProfile; + res.locals.userProfile = safeProfile; next(); } catch (err) { if (err instanceof HttpError) { diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts index 83760a2..044a8ab 100644 --- a/apps/api/src/modules/auth/auth.routes.ts +++ b/apps/api/src/modules/auth/auth.routes.ts @@ -1,8 +1,9 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { loginSchema, signupSchema } from "@batch-cooking/shared"; import { Router } from "express"; -import type { CookieOptions } from "express"; +import type { CookieOptions, Response } from "express"; import { env } from "../../config/env.js"; -import { requireAuth } from "../../middlewares/require-auth.js"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { login, signup } from "./auth.service.js"; /** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */ @@ -23,29 +24,31 @@ const cookieOptions: CookieOptions = { maxAge: SEVEN_DAYS_MS, }; -/** Creates a profile (+ its household) and logs the new user in immediately. */ -authRouter.post("/signup", async (req, res, next) => { - try { +/** + * Creates a profile (+ its household) and logs the new user in + * immediately. `wrapAsyncHandler` forwards a thrown/rejected error to + * Express's error middleware automatically — no manual try/catch needed. + */ +authRouter.post( + "/signup", + wrapAsyncHandler(async (req, res) => { const input = signupSchema.parse(req.body); const { profile, token } = await signup(input); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); res.status(201).json(profile); - } catch (err) { - next(err); - } -}); + }), +); /** Verifies credentials and starts a new session. */ -authRouter.post("/login", async (req, res, next) => { - try { +authRouter.post( + "/login", + wrapAsyncHandler(async (req, res) => { const input = loginSchema.parse(req.body); const { profile, token } = await login(input); res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions); res.status(200).json(profile); - } catch (err) { - next(err); - } -}); + }), +); /** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */ authRouter.post("/logout", (_req, res) => { @@ -54,6 +57,6 @@ authRouter.post("/logout", (_req, res) => { }); /** Returns the currently authenticated profile. Behind requireAuth — 401s if there's no valid session. */ -authRouter.get("/me", requireAuth, (req, res) => { - res.status(200).json(req.userProfile); +authRouter.get("/me", requireAuth, (_req, res: Response) => { + res.status(200).json(res.locals.userProfile); }); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index ec6b740..cfd5b7b 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -11,7 +11,9 @@ type SafeProfile = Omit; /** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */ interface AuthResult { + /** The authenticated profile, safe to hand back to the client. */ profile: SafeProfile; + /** Signed session JWT — the caller sets this as the session cookie's value. */ token: string; } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 89dabe6..f7753cc 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,8 +1,8 @@ -import { createApp } from "./app.js"; +import { createServer } from "./app.js"; import { env } from "./config/env.js"; -const app = createApp(); +const server = createServer(); -app.listen(env.PORT, () => { +server.listen(env.PORT, () => { console.log(`API listening on http://localhost:${env.PORT}`); }); diff --git a/apps/api/src/types/express-request.augment.ts b/apps/api/src/types/express-request.augment.ts deleted file mode 100644 index 3c20ed9..0000000 --- a/apps/api/src/types/express-request.augment.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { UserProfile } from "@prisma/client"; - -// Module augmentation: adds a `userProfile` field to Express's Request type -// so `requireAuth` can attach the authenticated profile and downstream -// handlers get it fully typed, without an `as` cast at every call site. -// -// Deliberately a plain .ts file, not a .d.ts: `declare global` augmentation -// works identically either way as long as the file has a top-level import -// (which makes it a module TS will include in the program) — no need for -// the .d.ts extension just for this. -declare global { - namespace Express { - interface Request { - /** Set by requireAuth once the session cookie's JWT has been verified. */ - userProfile?: Omit; - } - } -} diff --git a/packages/express-tools/package.json b/packages/express-tools/package.json index 27d3d09..68b90d3 100644 --- a/packages/express-tools/package.json +++ b/packages/express-tools/package.json @@ -17,12 +17,16 @@ "postinstall": "tsc -p tsconfig.json" }, "devDependencies": { + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/node": "^22.9.0", "typescript": "^5.7.2" }, "dependencies": { "@batch-cooking/shared": "workspace:*", + "cookie-parser": "^1.4.7", + "cors": "^2.8.6", "express": "^4.21.1", "zod": "^3.25.76" } diff --git a/packages/express-tools/src/async-handler.ts b/packages/express-tools/src/async-handler.ts new file mode 100644 index 0000000..1dcd692 --- /dev/null +++ b/packages/express-tools/src/async-handler.ts @@ -0,0 +1,34 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; + +/** + * An Express route handler whose body is `async` (returns a `Promise`). + * Only `ResBody`/`Locals` are made generic (what this codebase actually + * varies per-route) — params/request-body/query stay at Express's own + * internal defaults, same as an unparameterized `Request`. + */ +export type AsyncRequestHandler< + ResBody = unknown, + Locals extends Record = Record, +> = (req: Request, res: Response, next: NextFunction) => Promise; + +/** + * Wraps an async Express handler so a thrown error or rejected promise is + * forwarded to `next(err)` automatically. Without this, an unhandled + * rejection inside an `async` route handler never reaches Express's error + * middleware — every route ends up needing its own `try { ... } catch (err) + * { next(err); }` boilerplate, which this removes. + * + * @example + * router.post("/signup", wrapAsyncHandler(async (req, res) => { + * const profile = await signup(req.body); + * res.status(201).json(profile); + * })); + */ +export function wrapAsyncHandler< + ResBody = unknown, + Locals extends Record = Record, +>(handler: AsyncRequestHandler): RequestHandler { + return (req, res, next) => { + handler(req, res as Response, next).catch(next); + }; +} diff --git a/packages/express-tools/src/express-server.ts b/packages/express-tools/src/express-server.ts new file mode 100644 index 0000000..1e95541 --- /dev/null +++ b/packages/express-tools/src/express-server.ts @@ -0,0 +1,96 @@ +import cookieParser from "cookie-parser"; +import cors from "cors"; +import express, { + type ErrorRequestHandler, + type Express, + type RequestHandler, + type Router, +} from "express"; + +/** HTTP verbs {@link ExpressServer.addRoute} accepts. */ +export type HttpMethod = "get" | "post" | "put" | "patch" | "delete"; + +/** Options for {@link ExpressServer.setupCore}. */ +export interface ExpressServerCoreOptions { + /** Origin allowed by CORS — must match wherever the frontend is served from. */ + corsOrigin: string; +} + +/** + * Thin wrapper around an Express application: bundles the common + * "set up the standard middleware stack, register routes without + * duplicates, wire the error handler, start listening" concerns behind a + * small typed API, instead of every service in the monorepo repeating the + * same raw `express()` setup by hand. + * + * Framework-specific on purpose — unlike `ErrorHandlerService` (which has + * no Express dependency at all), this class *is* the Express integration + * layer. Business/domain code should never import `express` directly; + * it goes through this instead. + */ +export class ExpressServer { + /** The underlying Express application. */ + private readonly app: Express; + /** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */ + private readonly registeredRoutes = new Set(); + + public constructor() { + this.app = express(); + } + + /** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */ + public get instance(): Express { + return this.app; + } + + /** + * Registers the standard middleware stack every service in this + * monorepo needs: CORS (with credentials, for the session cookie), + * JSON body parsing, and cookie parsing. Call once, before registering + * any route. + */ + public setupCore(options: ExpressServerCoreOptions): void { + this.app.use(cors({ origin: options.corsOrigin, credentials: true })); + this.app.use(express.json()); + this.app.use(cookieParser()); + } + + /** Registers a middleware that runs on every request (e.g. logging, a catch-all 404 handler). */ + public addMiddleware(middleware: RequestHandler): void { + this.app.use(middleware); + } + + /** + * Registers the final Express error-handling middleware (the 4-argument + * form). Must be added last — Express only treats a middleware as an + * error handler by its arity, and only the last matching one runs. + */ + public setErrorHandler(middleware: ErrorRequestHandler): void { + this.app.use(middleware); + } + + /** Mounts a whole `express.Router` under a base path (e.g. `mountRouter("/auth", authRouter)`). */ + public mountRouter(basePath: string, router: Router): void { + this.app.use(basePath, router); + } + + /** + * Registers a single route with its handler(s). Warns and skips instead + * of registering if the same method+path was already added — catches a + * copy-paste mistake at startup instead of silently shadowing a route. + */ + public addRoute(method: HttpMethod, path: string, ...handlers: RequestHandler[]): void { + const key = `${method.toUpperCase()} ${path}`; + if (this.registeredRoutes.has(key)) { + console.warn(`[ExpressServer] Route already registered, skipping: ${key}`); + return; + } + this.registeredRoutes.add(key); + this.app[method](path, ...handlers); + } + + /** Starts listening on the given port. `onListening` is called once the server is up (e.g. to log the URL). */ + public listen(port: number, onListening?: () => void): void { + this.app.listen(port, onListening); + } +} diff --git a/packages/express-tools/src/index.ts b/packages/express-tools/src/index.ts index 69abfd0..cde2fbd 100644 --- a/packages/express-tools/src/index.ts +++ b/packages/express-tools/src/index.ts @@ -2,7 +2,16 @@ // Express app in this monorepo (currently apps/api). Generic HTTP/Express // infrastructure lives here — domain-specific code (auth, business logic) // stays in the consuming app. +// +// Note the split: ErrorHandlerService (error-handler.service.ts) has zero +// dependency on Express — it's a plain "map an error to {status, body}" +// service that would work identically behind Fastify or any other +// framework. ExpressServer and createErrorMiddleware are the actual +// Express-specific layer, adapting framework-agnostic pieces (like +// ErrorHandlerService) onto Express's API. +export * from "./async-handler.js"; export * from "./error-handler.service.js"; export * from "./error-middleware.js"; +export * from "./express-server.js"; export * from "./http-error.js"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b0017b4..98ed583 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,4 +5,5 @@ export * from "./errors/error-codes.js"; export * from "./schemas/auth.js"; +export * from "./tools/assert-is-never.js"; export * from "./types/user-profile.js"; diff --git a/packages/shared/src/tools/assert-is-never.ts b/packages/shared/src/tools/assert-is-never.ts new file mode 100644 index 0000000..8e59742 --- /dev/null +++ b/packages/shared/src/tools/assert-is-never.ts @@ -0,0 +1,35 @@ +/** + * Exhaustiveness check for a `switch`/`if`-chain over a union type. Call + * this in the `default` case (or final `else`) with the value being + * switched on: if every member of the union has been handled by an + * earlier branch, TypeScript narrows that value to `never` there, and the + * call type-checks. If a new member is later added to the union and a + * branch is forgotten, `value` is no longer `never` at that point — the + * call becomes a **compile error**, catching the missing case before it + * ships, instead of silently falling through at runtime. + * + * @example + * type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number }; + * + * function area(shape: Shape): number { + * switch (shape.kind) { + * case "circle": + * return Math.PI * shape.radius ** 2; + * case "square": + * return shape.side ** 2; + * default: + * // Compile error here if a new `Shape` variant is added without a + * // matching case above — `shape` wouldn't be `never` anymore. + * return assertIsNever(shape); + * } + * } + * + * @param value - The value that should be `never` at this point (compile-time check). + * @param message - Optional custom error message; defaults to including the offending value. + * @throws Always throws — this is also a real runtime safety net for a + * value that reaches here despite the type system (e.g. unvalidated + * external data cast to the union type). + */ +export function assertIsNever(value: never, message?: string): never { + throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`); +} diff --git a/packages/shared/src/types/user-profile.ts b/packages/shared/src/types/user-profile.ts index f4c0056..c221579 100644 --- a/packages/shared/src/types/user-profile.ts +++ b/packages/shared/src/types/user-profile.ts @@ -7,8 +7,11 @@ export interface SafeUserProfile { /** Primary key. */ id: number; + /** First name. */ firstName: string; + /** Last name. */ lastName: string; + /** Email address — unique, used as the login identifier. */ email: string; /** Incremented server-side to invalidate previously-issued JWTs (e.g. on password change). Not used directly by the client. */ tokenVersion: number; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5586805..9cae0c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,12 +29,6 @@ importers: argon2: specifier: 0.31.2 version: 0.31.2 - cookie-parser: - specifier: ^1.4.7 - version: 1.4.7 - cors: - specifier: ^2.8.6 - version: 2.8.6 dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -54,12 +48,6 @@ importers: '@faker-js/faker': specifier: ^10.6.0 version: 10.6.0 - '@types/cookie-parser': - specifier: ^1.4.10 - version: 1.4.10(@types/express@4.17.25) - '@types/cors': - specifier: ^2.8.19 - version: 2.8.19 '@types/express': specifier: ^4.17.21 version: 4.17.25 @@ -151,6 +139,12 @@ importers: '@batch-cooking/shared': specifier: workspace:* version: link:../shared + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 + cors: + specifier: ^2.8.6 + version: 2.8.6 express: specifier: ^4.21.1 version: 4.22.2 @@ -158,6 +152,12 @@ importers: specifier: ^3.25.76 version: 3.25.76 devDependencies: + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@4.17.25) + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 '@types/express': specifier: ^4.17.21 version: 4.17.25 diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md new file mode 100644 index 0000000..a7f5b4f --- /dev/null +++ b/specs/backend-architecture.md @@ -0,0 +1,136 @@ +# Architecture backend — Projet Batch-cooking + +> Documentation de l'organisation d'`apps/api` et de l'outillage partagé +> (`packages/express-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. + +### `HttpError` / `ErrorHandlerService` / `createErrorMiddleware` + +Voir [error-handling.md](./error-handling.md) pour le détail. À noter : +`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. `ExpressServer` et +`createErrorMiddleware` sont la vraie couche Express : elles adaptent des +pièces indépendantes du framework (comme `ErrorHandlerService`) à l'API +d'Express. C'est pour ça que `ErrorHandlerService` n'importe jamais `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, next: NextFunction) { + // ... + res.locals.userProfile = safeProfile; + next(); +} +``` + +Un handler derrière ce middleware type sa réponse `Response` +et lit `res.locals.userProfile` sans cast : + +```ts +authRouter.get("/me", requireAuth, (_req, res: Response) => { + 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. diff --git a/specs/error-handling.md b/specs/error-handling.md index f9a2303..b4982a4 100644 --- a/specs/error-handling.md +++ b/specs/error-handling.md @@ -97,10 +97,12 @@ Pour ajouter un nouveau cas d'erreur : --- -## `packages/express-tools` — outillage Express générique +## `packages/express-tools` — les pièces liées aux erreurs -Séparé d'`apps/api` volontairement : ce sont des briques génériques (n'importe quel -service Express du monorepo pourrait les utiliser), pas de logique métier. +`packages/express-tools` contient aussi `ExpressServer` (init serveur, +enregistrement de routes/middlewares) et `wrapAsyncHandler` — voir +[backend-architecture.md](./backend-architecture.md) pour le détail complet du +package. Les pièces qui concernent spécifiquement les erreurs : - **`http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP) et `code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de @@ -109,9 +111,11 @@ service Express du monorepo pourrait les utiliser), pas de logique métier. transformer n'importe quelle erreur JS (`ZodError`, `HttpError`, n'importe quoi d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`, 500) logue l'erreur côté serveur sans jamais exposer de détail interne au client. + **N'importe pas `express`** — c'est un service générique, indépendant du + framework HTTP, qui fonctionnerait à l'identique derrière Fastify ou autre. - **`error-middleware.ts`** — `createErrorMiddleware(service)` : construit le middleware d'erreur Express (signature à 4 arguments) à partir du service — - adaptateur fin, aucune logique de mapping n'y vit. + c'est LUI la vraie couche Express, `ErrorHandlerService` reste agnostique. Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé, pas en TS brut — voir la note dans @@ -120,9 +124,10 @@ sur pourquoi ça compte pour un runtime Node pur (Docker). ## Côté API (`apps/api`) -- **`app.ts`** — le middleware d'erreur final est - `app.use(createErrorMiddleware(errorHandlerService))` ; aucune logique de - mapping n'y vit directement, tout est dans `express-tools`. +- **`app.ts`** — le middleware d'erreur final est enregistré via + `server.setErrorHandler(createErrorMiddleware(errorHandlerService))` (voir + [backend-architecture.md](./backend-architecture.md) pour `ExpressServer`) ; + aucune logique de mapping n'y vit directement, tout est dans `express-tools`. - Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`) importent `HttpError` depuis `@batch-cooking/express-tools` et `ErrorCode` depuis `@batch-cooking/shared`. diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md index 81cef66..4348234 100644 --- a/specs/frontend-architecture.md +++ b/specs/frontend-architecture.md @@ -116,10 +116,10 @@ Aucun fichier `.d.ts` écrit à la main dans `apps/web` : le `tsconfig.app.json` — même effet (typage de `import.meta.env`, imports d'assets), sans fichier dédié. -Même logique côté `apps/api` : l'augmentation du type `Express.Request` (pour -`req.userProfile`) vit dans `src/types/express-request.augment.ts`, un fichier -`.ts` classique (pas `.d.ts`) — une augmentation `declare global` fonctionne -identiquement dans les deux, tant que le fichier a un import qui en fait un module. +Côté `apps/api`, aucune augmentation de type globale n'est utilisée du tout — voir +[backend-architecture.md](./backend-architecture.md#auth--reslocals-pas-daugmentation-du-namespace-express) +: le profil authentifié passe par `res.locals` (mécanisme natif d'Express), pas +par un `declare global` sur `Express.Request`. ---