From 48457936e83bfc25aa43d2dca2a7072af24775e8 Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 12 Apr 2026 08:48:43 +0200 Subject: [PATCH] feat(api): add global error handler with typed error classes - Add AppError base class, ValidationError (400), NotFoundError (404) - Add central error middleware in app.ts - Remove inline safeParse error handling from controllers - Replace plain Error throws with NotFoundError in gameService --- apps/api/src/app.ts | 3 ++ apps/api/src/controllers/gameController.ts | 48 +++++++++++++--------- apps/api/src/errors/AppError.ts | 21 ++++++++++ apps/api/src/middleware/errorHandler.ts | 18 ++++++++ apps/api/src/services/gameService.ts | 8 ++-- documentation/api-development.md | 21 +++++++++- scripts/gametest/test-game.ts | 19 +++++++++ 7 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/errors/AppError.ts create mode 100644 apps/api/src/middleware/errorHandler.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 367b101..2d19c97 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,11 +1,14 @@ import express from "express"; import type { Express } from "express"; import { apiRouter } from "./routes/apiRouter.js"; +import { errorHandler } from "./middleware/errorHandler.js"; export function createApp() { const app: Express = express(); + app.use(express.json()); app.use("/api/v1", apiRouter); + app.use(errorHandler); return app; } diff --git a/apps/api/src/controllers/gameController.ts b/apps/api/src/controllers/gameController.ts index 101763a..5c05482 100644 --- a/apps/api/src/controllers/gameController.ts +++ b/apps/api/src/controllers/gameController.ts @@ -1,32 +1,42 @@ -import type { Request, Response } from "express"; +import type { Request, Response, NextFunction } from "express"; import { GameRequestSchema, AnswerSubmissionSchema } from "@glossa/shared"; import { createGameSession, evaluateAnswer } from "../services/gameService.js"; +import { ValidationError } from "../errors/AppError.js"; -export const createGame = async (req: Request, res: Response) => { - const gameSettings = GameRequestSchema.safeParse(req.body); +export const createGame = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const gameSettings = GameRequestSchema.safeParse(req.body); - // TODO: remove when global error handler is implemented - if (!gameSettings.success) { - res.status(400).json({ success: false }); - return; + if (!gameSettings.success) { + throw new ValidationError(gameSettings.error.message); + } + + const gameQuestions = await createGameSession(gameSettings.data); + res.json({ success: true, data: gameQuestions }); + } catch (error) { + next(error); } - - const gameQuestions = await createGameSession(gameSettings.data); - - res.json({ success: true, data: gameQuestions }); }; -export const submitAnswer = async (req: Request, res: Response) => { - const submission = AnswerSubmissionSchema.safeParse(req.body); - // TODO: remove when global error handler is implemented - if (!submission.success) { - res.status(400).json({ success: false }); - return; - } +export const submitAnswer = async ( + req: Request, + res: Response, + next: NextFunction, +) => { try { + const submission = AnswerSubmissionSchema.safeParse(req.body); + + if (!submission.success) { + throw new ValidationError(submission.error.message); + } + const result = await evaluateAnswer(submission.data); res.json({ success: true, data: result }); } catch (error) { - res.status(404).json({ success: false }); + next(error); } }; diff --git a/apps/api/src/errors/AppError.ts b/apps/api/src/errors/AppError.ts new file mode 100644 index 0000000..6611b9b --- /dev/null +++ b/apps/api/src/errors/AppError.ts @@ -0,0 +1,21 @@ +export class AppError extends Error { + public readonly statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.name = this.constructor.name; + this.statusCode = statusCode; + } +} + +export class ValidationError extends AppError { + constructor(message: string) { + super(message, 400); + } +} + +export class NotFoundError extends AppError { + constructor(message: string) { + super(message, 404); + } +} diff --git a/apps/api/src/middleware/errorHandler.ts b/apps/api/src/middleware/errorHandler.ts new file mode 100644 index 0000000..fd36040 --- /dev/null +++ b/apps/api/src/middleware/errorHandler.ts @@ -0,0 +1,18 @@ +import type { Request, Response, NextFunction } from "express"; +import { AppError } from "../errors/AppError.js"; + +export const errorHandler = ( + err: Error, + _req: Request, + res: Response, + _next: NextFunction, +) => { + if (err instanceof AppError) { + res.status(err.statusCode).json({ success: false, error: err.message }); + return; + } + + console.error("Unexpected error:", err); + + res.status(500).json({ success: false, error: "Internal server error" }); +}; diff --git a/apps/api/src/services/gameService.ts b/apps/api/src/services/gameService.ts index b5e9f7b..45a1579 100644 --- a/apps/api/src/services/gameService.ts +++ b/apps/api/src/services/gameService.ts @@ -8,8 +8,8 @@ import type { AnswerSubmission, AnswerResult, } from "@glossa/shared"; - import { InMemoryGameSessionStore } from "../gameSessionStore/index.js"; +import { NotFoundError } from "../errors/AppError.js"; const gameSessionStore = new InMemoryGameSessionStore(); @@ -39,7 +39,6 @@ export const createGameSession = async ( const optionTexts = [correctAnswer.targetText, ...distractorTexts]; const shuffledTexts = shuffle(optionTexts); - const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText); const options: AnswerOption[] = shuffledTexts.map((text, index) => ({ @@ -64,6 +63,7 @@ export const createGameSession = async ( return { sessionId, questions }; }; + const shuffle = (array: T[]): T[] => { const result = [...array]; for (let i = result.length - 1; i > 0; i--) { @@ -81,13 +81,13 @@ export const evaluateAnswer = async ( const session = await gameSessionStore.get(submission.sessionId); if (!session) { - throw new Error(`Game session not found: ${submission.sessionId}`); + throw new NotFoundError(`Game session not found: ${submission.sessionId}`); } const correctOptionId = session.answers.get(submission.questionId); if (correctOptionId === undefined) { - throw new Error(`Question not found: ${submission.questionId}`); + throw new NotFoundError(`Question not found: ${submission.questionId}`); } return { diff --git a/documentation/api-development.md b/documentation/api-development.md index 23ed459..ac02d89 100644 --- a/documentation/api-development.md +++ b/documentation/api-development.md @@ -128,6 +128,25 @@ touches the database. A service never reads `req.body`. A model never knows what - No fallback logic for insufficient distractors. Data volumes are sufficient; strict query throws if something is genuinely broken. - Distractor query excludes both the correct term ID and the correct answer text, preventing duplicate options from different terms with the same translation. - Submit-before-send flow on frontend: user selects, then confirms. Prevents misclicks. +- AppError base class over error code maps. A statusCode on the error itself means the middleware doesn't need a lookup table. New error types are self-contained — one class, one status code. +- next(error) over res.status().json() in controllers. Express requires explicit next(error) for async handlers. Centralises all error formatting in one place. Controllers stay clean — validate, call service, send response. +- Zod .message over .issues[0]?.message. Returns all validation failures, not just the first. Output is verbose (raw JSON string) — revisit formatting post-MVP if the frontend needs structured error objects. + +--- + +## Global error handler: typed error classes + central middleware + +Three-layer pattern: error classes define the shape, services throw them, middleware catches them. + +AppError is the base class — carries a statusCode and a message. ValidationError (400) and NotFoundError (404) extend it. Adding a new error type is one class with a super() call. + +Controllers wrap their body in try/catch and call next(error) in the catch block. They never build error responses themselves. This is required because Express does not catch errors from async handlers automatically — without next(error), an unhandled rejection crashes the process. + +The middleware in app.ts (registered after all routes) checks instanceof AppError. Known errors get their statusCode and message. Unknown errors get logged and return a generic 500 — no stack traces leak to the client. + +Zod validation error format: used gameSettings.error.message rather than gameSettings.error.issues[0]?.message. This sends all validation failures at once instead of just the first. Tradeoff: the output is a raw JSON string, not a clean object. Acceptable for MVP — if the frontend needs structured errors later, format .issues into { field, message }[] in the ValidationError constructor. + +Where errors are thrown: ValidationError is thrown in the controller (it's the layer that runs safeParse). NotFoundError is thrown in the service (it's the layer that knows whether a session or question exists). The service still doesn't know about HTTP — it throws a typed error, and the middleware maps it to a status code. --- @@ -288,7 +307,7 @@ Required before: implementing the double join for source language prompt. - `POST /api/v1/game/answer` route, controller, service method -### Step 6 — Global error handler +### Step 6 — Global error handler - done - Typed error classes (`ValidationError`, `NotFoundError`) - Central error middleware in `app.ts` diff --git a/scripts/gametest/test-game.ts b/scripts/gametest/test-game.ts index 230bf0c..f85c46c 100644 --- a/scripts/gametest/test-game.ts +++ b/scripts/gametest/test-game.ts @@ -34,6 +34,25 @@ async function main() { `${question.prompt}: ${result.data.isCorrect ? "✓" : "✗"} (picked ${0}, correct was ${result.data.correctOptionId})`, ); } + + const badRequest = await fetch("http://localhost:3000/api/v1/game/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source_language: "en" }), + }); + console.log("400 test:", badRequest.status, await badRequest.json()); + + // Send a valid shape but a session that doesn't exist + const notFound = await fetch("http://localhost:3000/api/v1/game/answer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: "00000000-0000-0000-0000-000000000000", + questionId: "00000000-0000-0000-0000-000000000000", + selectedOptionId: 0, + }), + }); + console.log("404 test:", notFound.status, await notFound.json()); } main();