Address second review round: interface comments, res.locals, ExpressServer, assertIsNever
Five more explicit review points, on the same PR branch. ## Every interface key commented Audited all 6 interfaces in the codebase. Two had partially-commented members (violates the "every key gets /** */" rule): AuthResult (apps/api/auth.service.ts) and SafeUserProfile (packages/shared) — both now fully commented. The other four (AuthTokenPayload, AuthContextValue, ErrorHandlingResult, ApiErrorResponse) were already compliant. ## Removed the Express namespace augmentation apps/api/src/types/express.d.ts (renamed to express-request.augment.ts in the last round) is gone entirely. requireAuth now attaches the authenticated profile to `res.locals.userProfile` — Express's own built-in per-request mechanism for exactly this — typed via a new AuthLocals interface and `Response<unknown, AuthLocals>`, instead of a project-wide `declare global` silently changing every Request's type whether or not it went through the middleware. ## ErrorHandlerService confirmed framework-agnostic It already had zero Express import. Documented this explicitly (in the package's index.ts and the new backend-architecture.md spec) as a deliberate split: ErrorHandlerService is framework-agnostic (would work behind Fastify too), ExpressServer/createErrorMiddleware are the actual Express integration layer. ## packages/express-tools: server init + route/middleware utilities New ExpressServer class, modeled on the pattern shared as a reference (adapted, not copied 1:1 — deliberately left out the reference's custom runtime param-type-validation system, since zod already does that job in this codebase and running two parallel validation mechanisms would be redundant, not "propre"): - setupCore() — the common cors/json/cookie-parser stack - addRoute() — registers a route, warns+skips instead of silently double-registering the same method+path - addMiddleware() / mountRouter() / setErrorHandler() - listen() - .instance — the raw Express app, for supertest Also added wrapAsyncHandler() — forwards a thrown/rejected error from an async handler to next(err) automatically, removing the manual try/catch/next(err) every route needed. apps/api/src/app.ts now builds via ExpressServer (createServer(), consumed by both server.ts's .listen() and createApp()'s .instance for tests). auth.routes.ts's signup/login handlers use wrapAsyncHandler instead of manual try/catch. cookie-parser/cors moved out of apps/api's own dependencies entirely — they're express-tools' concern now. ## assertIsNever (packages/shared/src/tools/) Exhaustiveness-check helper for switch/if-chains over a union: takes a `never`-typed value and throws, so a forgotten case in a later-added union member becomes a compile error instead of a silent runtime fallthrough. Verified for real (not just written and assumed correct): wrote a throwaway switch missing a case and confirmed `tsc` rejects it with the exact expected error, then deleted the scratch file. No existing switch/if-chain over a union in the codebase yet to retrofit it into — noted as ready for when one appears (e.g. the not-yet-built batch-cooking calculation module or recipe-import pipeline). ## specs/ updated New specs/backend-architecture.md — ExpressServer, wrapAsyncHandler, the res.locals decision (with the "why not declare global" reasoning spelled out), assertIsNever. error-handling.md and frontend-architecture.md cross-link to it instead of duplicating. README covers the same, briefly. ## Verification Full lint/mocha/cucumber/build green. Re-ran `node dist/server.js` standalone (mirrors Docker, no tsx) after the ExpressServer refactor: /health, a 404 (numeric 4040), and a real signup + GET /me round trip confirming res.locals-based auth actually works at runtime, not just that tsc accepts the types.
This commit is contained in:
parent
1cedb25d74
commit
3a5dc83bf9
19 changed files with 440 additions and 96 deletions
16
README.md
16
README.md
|
|
@ -11,9 +11,10 @@ Monorepo pnpm workspaces :
|
||||||
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
|
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
|
||||||
numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
|
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.
|
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`,
|
- `packages/express-tools` — outillage Express générique et réutilisable : `ExpressServer`
|
||||||
`ErrorHandlerService`, `createErrorMiddleware`), séparé d'`apps/api` : pas de
|
(init serveur, routes, middlewares), `wrapAsyncHandler`, `HttpError`,
|
||||||
logique métier, juste de l'infra Express.
|
`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/`,
|
`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
|
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) :
|
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
|
||||||
[specs/error-handling.md](specs/error-handling.md).
|
[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
|
## i18n
|
||||||
|
|
||||||
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
|
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@
|
||||||
"@batch-cooking/shared": "workspace:*",
|
"@batch-cooking/shared": "workspace:*",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"argon2": "0.31.2",
|
"argon2": "0.31.2",
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"cors": "^2.8.6",
|
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
|
@ -28,8 +26,6 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cucumber/cucumber": "^13.2.1",
|
"@cucumber/cucumber": "^13.2.1",
|
||||||
"@faker-js/faker": "^10.6.0",
|
"@faker-js/faker": "^10.6.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
|
||||||
"@types/cors": "^2.8.19",
|
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
|
|
|
||||||
|
|
@ -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 { ErrorCode } from "@batch-cooking/shared";
|
||||||
import cookieParser from "cookie-parser";
|
import type { Express, Request, Response } from "express";
|
||||||
import cors from "cors";
|
|
||||||
import express, { type Request, type Response } from "express";
|
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a fresh Express application instance (no shared mutable state
|
* Builds the API's `ExpressServer`: standard middleware, routes, and the
|
||||||
* between calls — used both by the real server entrypoint and by tests,
|
* final error handler, in that order. Returns the `ExpressServer` wrapper
|
||||||
* which each get their own app via supertest).
|
* (not just the raw Express app) so `server.ts` can call `.listen()` on
|
||||||
*
|
* it — {@link createApp} below is the thinner entry point that exposes
|
||||||
* Feature modules are mounted under `/`-prefixed routers as specs land;
|
* just the raw `Express` instance, for test tooling (supertest) that
|
||||||
* `auth` is the first one (login page / profile creation).
|
* expects one.
|
||||||
*/
|
*/
|
||||||
export function createApp() {
|
export function createServer(): ExpressServer {
|
||||||
const app = express();
|
const server = new ExpressServer();
|
||||||
|
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
||||||
|
|
||||||
// Frontend and API run on different origins — `credentials: true` is
|
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||||
// 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) => {
|
|
||||||
res.status(200).json({ status: "ok" });
|
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
|
// No route matched — same shape as every other error response, via the
|
||||||
// shared ErrorCode contract, so clients never special-case 404s.
|
// 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" });
|
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
|
// the app ends up here. All the "what status/body does this error map
|
||||||
// to" logic lives in ErrorHandlerService, from @batch-cooking/express-tools
|
// to" logic lives in ErrorHandlerService, from @batch-cooking/express-tools
|
||||||
// — this stays a thin adapter.
|
// — 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
import { HttpError } from "@batch-cooking/express-tools";
|
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 type { NextFunction, Request, Response } from "express";
|
||||||
import { env } from "../config/env.js";
|
import { env } from "../config/env.js";
|
||||||
import { prisma } from "../db/prisma.js";
|
import { prisma } from "../db/prisma.js";
|
||||||
import { verifyAuthToken } from "../lib/jwt.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<unknown, AuthLocals>` (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
|
* Express middleware guarding routes that require an authenticated
|
||||||
* profile. Reads the session cookie, verifies the JWT, and re-checks
|
* 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,
|
* invalidated server-side (e.g. on password change / logout-everywhere,
|
||||||
* once that feature exists) despite carrying no server-side session.
|
* once that feature exists) despite carrying no server-side session.
|
||||||
*
|
*
|
||||||
* On success, attaches the resolved profile to `req.userProfile` (see
|
* On success, attaches the resolved profile to `res.locals.userProfile`
|
||||||
* `src/types/express-request.augment.ts`) for downstream handlers to use.
|
* (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
|
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
||||||
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
|
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
|
||||||
* Never distinguishes the reason to the client.
|
* 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<unknown, AuthLocals>,
|
||||||
|
next: NextFunction,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const token = req.cookies?.[env.AUTH_COOKIE_NAME];
|
const token = req.cookies?.[env.AUTH_COOKIE_NAME];
|
||||||
if (typeof token !== "string") {
|
if (typeof token !== "string") {
|
||||||
|
|
@ -34,7 +55,7 @@ export async function requireAuth(req: Request, _res: Response, next: NextFuncti
|
||||||
}
|
}
|
||||||
|
|
||||||
const { passwordHash: _passwordHash, ...safeProfile } = profile;
|
const { passwordHash: _passwordHash, ...safeProfile } = profile;
|
||||||
req.userProfile = safeProfile;
|
res.locals.userProfile = safeProfile;
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpError) {
|
if (err instanceof HttpError) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { loginSchema, signupSchema } from "@batch-cooking/shared";
|
import { loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { CookieOptions } from "express";
|
import type { CookieOptions, Response } from "express";
|
||||||
import { env } from "../../config/env.js";
|
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";
|
import { login, signup } from "./auth.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
|
/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
|
||||||
|
|
@ -23,29 +24,31 @@ const cookieOptions: CookieOptions = {
|
||||||
maxAge: SEVEN_DAYS_MS,
|
maxAge: SEVEN_DAYS_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Creates a profile (+ its household) and logs the new user in immediately. */
|
/**
|
||||||
authRouter.post("/signup", async (req, res, next) => {
|
* Creates a profile (+ its household) and logs the new user in
|
||||||
try {
|
* 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 input = signupSchema.parse(req.body);
|
||||||
const { profile, token } = await signup(input);
|
const { profile, token } = await signup(input);
|
||||||
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
||||||
res.status(201).json(profile);
|
res.status(201).json(profile);
|
||||||
} catch (err) {
|
}),
|
||||||
next(err);
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Verifies credentials and starts a new session. */
|
/** Verifies credentials and starts a new session. */
|
||||||
authRouter.post("/login", async (req, res, next) => {
|
authRouter.post(
|
||||||
try {
|
"/login",
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
const input = loginSchema.parse(req.body);
|
const input = loginSchema.parse(req.body);
|
||||||
const { profile, token } = await login(input);
|
const { profile, token } = await login(input);
|
||||||
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
||||||
res.status(200).json(profile);
|
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). */
|
/** 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) => {
|
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. */
|
/** Returns the currently authenticated profile. Behind requireAuth — 401s if there's no valid session. */
|
||||||
authRouter.get("/me", requireAuth, (req, res) => {
|
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
|
||||||
res.status(200).json(req.userProfile);
|
res.status(200).json(res.locals.userProfile);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ type SafeProfile = Omit<UserProfile, "passwordHash">;
|
||||||
|
|
||||||
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
|
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
|
||||||
interface AuthResult {
|
interface AuthResult {
|
||||||
|
/** The authenticated profile, safe to hand back to the client. */
|
||||||
profile: SafeProfile;
|
profile: SafeProfile;
|
||||||
|
/** Signed session JWT — the caller sets this as the session cookie's value. */
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { createApp } from "./app.js";
|
import { createServer } from "./app.js";
|
||||||
import { env } from "./config/env.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}`);
|
console.log(`API listening on http://localhost:${env.PORT}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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<UserProfile, "passwordHash">;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -17,12 +17,16 @@
|
||||||
"postinstall": "tsc -p tsconfig.json"
|
"postinstall": "tsc -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@batch-cooking/shared": "workspace:*",
|
"@batch-cooking/shared": "workspace:*",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
packages/express-tools/src/async-handler.ts
Normal file
34
packages/express-tools/src/async-handler.ts
Normal file
|
|
@ -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<string, unknown> = Record<string, unknown>,
|
||||||
|
> = (req: Request, res: Response<ResBody, Locals>, next: NextFunction) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown> = Record<string, unknown>,
|
||||||
|
>(handler: AsyncRequestHandler<ResBody, Locals>): RequestHandler {
|
||||||
|
return (req, res, next) => {
|
||||||
|
handler(req, res as Response<ResBody, Locals>, next).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
96
packages/express-tools/src/express-server.ts
Normal file
96
packages/express-tools/src/express-server.ts
Normal file
|
|
@ -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<string>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,16 @@
|
||||||
// Express app in this monorepo (currently apps/api). Generic HTTP/Express
|
// Express app in this monorepo (currently apps/api). Generic HTTP/Express
|
||||||
// infrastructure lives here — domain-specific code (auth, business logic)
|
// infrastructure lives here — domain-specific code (auth, business logic)
|
||||||
// stays in the consuming app.
|
// 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-handler.service.js";
|
||||||
export * from "./error-middleware.js";
|
export * from "./error-middleware.js";
|
||||||
|
export * from "./express-server.js";
|
||||||
export * from "./http-error.js";
|
export * from "./http-error.js";
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,5 @@
|
||||||
|
|
||||||
export * from "./errors/error-codes.js";
|
export * from "./errors/error-codes.js";
|
||||||
export * from "./schemas/auth.js";
|
export * from "./schemas/auth.js";
|
||||||
|
export * from "./tools/assert-is-never.js";
|
||||||
export * from "./types/user-profile.js";
|
export * from "./types/user-profile.js";
|
||||||
|
|
|
||||||
35
packages/shared/src/tools/assert-is-never.ts
Normal file
35
packages/shared/src/tools/assert-is-never.ts
Normal file
|
|
@ -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)}`);
|
||||||
|
}
|
||||||
|
|
@ -7,8 +7,11 @@
|
||||||
export interface SafeUserProfile {
|
export interface SafeUserProfile {
|
||||||
/** Primary key. */
|
/** Primary key. */
|
||||||
id: number;
|
id: number;
|
||||||
|
/** First name. */
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
/** Last name. */
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
/** Email address — unique, used as the login identifier. */
|
||||||
email: string;
|
email: string;
|
||||||
/** Incremented server-side to invalidate previously-issued JWTs (e.g. on password change). Not used directly by the client. */
|
/** Incremented server-side to invalidate previously-issued JWTs (e.g. on password change). Not used directly by the client. */
|
||||||
tokenVersion: number;
|
tokenVersion: number;
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,6 @@ importers:
|
||||||
argon2:
|
argon2:
|
||||||
specifier: 0.31.2
|
specifier: 0.31.2
|
||||||
version: 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:
|
dotenv:
|
||||||
specifier: ^16.4.5
|
specifier: ^16.4.5
|
||||||
version: 16.6.1
|
version: 16.6.1
|
||||||
|
|
@ -54,12 +48,6 @@ importers:
|
||||||
'@faker-js/faker':
|
'@faker-js/faker':
|
||||||
specifier: ^10.6.0
|
specifier: ^10.6.0
|
||||||
version: 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':
|
'@types/express':
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.25
|
version: 4.17.25
|
||||||
|
|
@ -151,6 +139,12 @@ importers:
|
||||||
'@batch-cooking/shared':
|
'@batch-cooking/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared
|
version: link:../shared
|
||||||
|
cookie-parser:
|
||||||
|
specifier: ^1.4.7
|
||||||
|
version: 1.4.7
|
||||||
|
cors:
|
||||||
|
specifier: ^2.8.6
|
||||||
|
version: 2.8.6
|
||||||
express:
|
express:
|
||||||
specifier: ^4.21.1
|
specifier: ^4.21.1
|
||||||
version: 4.22.2
|
version: 4.22.2
|
||||||
|
|
@ -158,6 +152,12 @@ importers:
|
||||||
specifier: ^3.25.76
|
specifier: ^3.25.76
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
devDependencies:
|
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':
|
'@types/express':
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.25
|
version: 4.17.25
|
||||||
|
|
|
||||||
136
specs/backend-architecture.md
Normal file
136
specs/backend-architecture.md
Normal file
|
|
@ -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<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.
|
||||||
|
|
@ -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
|
`packages/express-tools` contient aussi `ExpressServer` (init serveur,
|
||||||
service Express du monorepo pourrait les utiliser), pas de logique métier.
|
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
|
- **`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
|
`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
|
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
|
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.
|
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
|
- **`error-middleware.ts`** — `createErrorMiddleware(service)` : construit le
|
||||||
middleware d'erreur Express (signature à 4 arguments) à partir du service —
|
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é,
|
Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé,
|
||||||
pas en TS brut — voir la note dans
|
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`)
|
## Côté API (`apps/api`)
|
||||||
|
|
||||||
- **`app.ts`** — le middleware d'erreur final est
|
- **`app.ts`** — le middleware d'erreur final est enregistré via
|
||||||
`app.use(createErrorMiddleware(errorHandlerService))` ; aucune logique de
|
`server.setErrorHandler(createErrorMiddleware(errorHandlerService))` (voir
|
||||||
mapping n'y vit directement, tout est dans `express-tools`.
|
[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`)
|
- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
|
||||||
importent `HttpError` depuis `@batch-cooking/express-tools` et `ErrorCode` depuis
|
importent `HttpError` depuis `@batch-cooking/express-tools` et `ErrorCode` depuis
|
||||||
`@batch-cooking/shared`.
|
`@batch-cooking/shared`.
|
||||||
|
|
|
||||||
|
|
@ -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),
|
`tsconfig.app.json` — même effet (typage de `import.meta.env`, imports d'assets),
|
||||||
sans fichier dédié.
|
sans fichier dédié.
|
||||||
|
|
||||||
Même logique côté `apps/api` : l'augmentation du type `Express.Request` (pour
|
Côté `apps/api`, aucune augmentation de type globale n'est utilisée du tout — voir
|
||||||
`req.userProfile`) vit dans `src/types/express-request.augment.ts`, un fichier
|
[backend-architecture.md](./backend-architecture.md#auth--reslocals-pas-daugmentation-du-namespace-express)
|
||||||
`.ts` classique (pas `.d.ts`) — une augmentation `declare global` fonctionne
|
: le profil authentifié passe par `res.locals` (mécanisme natif d'Express), pas
|
||||||
identiquement dans les deux, tant que le fichier a un import qui en fait un module.
|
par un `declare global` sur `Express.Request`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue