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`
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
* 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<unknown, AuthLocals>,
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<unknown, AuthLocals>) => {
|
||||
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. */
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
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
|
||||
// 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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
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 {
|
||||
/** 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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue