diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-11-30 06:39:51 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-04 23:26:11 +0900 |
| commit | 442c60b8f92499c45076c3c4cc1a1472b2dd8098 (patch) | |
| tree | 67da31227d316a7d7ce5de1abd7c29b865279849 /pkgs/server | |
| parent | 64a1b249d25fe52df9ad7d8c034e2e004354ecd5 (diff) | |
| download | kioku-442c60b8f92499c45076c3c4cc1a1472b2dd8098.tar.gz kioku-442c60b8f92499c45076c3c4cc1a1472b2dd8098.tar.zst kioku-442c60b8f92499c45076c3c4cc1a1472b2dd8098.zip | |
feat(server): add error handling middleware
Add global error handling middleware for Hono with:
- AppError class for application-specific errors with status codes
- Errors factory for common HTTP errors (badRequest, unauthorized, etc.)
- Consistent JSON error response format
- Tests covering all error types
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'pkgs/server')
| -rw-r--r-- | pkgs/server/src/index.ts | 3 | ||||
| -rw-r--r-- | pkgs/server/src/middleware/error-handler.test.ts | 173 | ||||
| -rw-r--r-- | pkgs/server/src/middleware/error-handler.ts | 95 | ||||
| -rw-r--r-- | pkgs/server/src/middleware/index.ts | 1 |
4 files changed, 272 insertions, 0 deletions
diff --git a/pkgs/server/src/index.ts b/pkgs/server/src/index.ts index 6c89217..54c1059 100644 --- a/pkgs/server/src/index.ts +++ b/pkgs/server/src/index.ts @@ -1,8 +1,11 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; +import { errorHandler } from "./middleware/error-handler"; const app = new Hono(); +app.onError(errorHandler); + app.get("/", (c) => { return c.json({ message: "Kioku API" }); }); diff --git a/pkgs/server/src/middleware/error-handler.test.ts b/pkgs/server/src/middleware/error-handler.test.ts new file mode 100644 index 0000000..21d6fc1 --- /dev/null +++ b/pkgs/server/src/middleware/error-handler.test.ts @@ -0,0 +1,173 @@ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; +import { AppError, Errors, errorHandler } from "./error-handler"; + +function createTestApp() { + const app = new Hono(); + app.onError(errorHandler); + return app; +} + +describe("errorHandler", () => { + describe("AppError handling", () => { + it("returns correct status and message for AppError", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw new AppError("Custom error", 400, "CUSTOM_ERROR"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: { + message: "Custom error", + code: "CUSTOM_ERROR", + }, + }); + }); + + it("uses default values for AppError", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw new AppError("Something went wrong"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: { + message: "Something went wrong", + code: "INTERNAL_ERROR", + }, + }); + }); + }); + + describe("Errors factory functions", () => { + it("handles badRequest error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.badRequest("Invalid input"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: { + message: "Invalid input", + code: "BAD_REQUEST", + }, + }); + }); + + it("handles unauthorized error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.unauthorized(); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ + error: { + message: "Unauthorized", + code: "UNAUTHORIZED", + }, + }); + }); + + it("handles forbidden error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.forbidden("Access denied"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: { + message: "Access denied", + code: "FORBIDDEN", + }, + }); + }); + + it("handles notFound error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.notFound("Resource not found"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: { + message: "Resource not found", + code: "NOT_FOUND", + }, + }); + }); + + it("handles conflict error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.conflict("Already exists"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: { + message: "Already exists", + code: "CONFLICT", + }, + }); + }); + + it("handles validationError", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.validationError("Invalid data"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { + message: "Invalid data", + code: "VALIDATION_ERROR", + }, + }); + }); + + it("handles internal error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.internal("Database connection failed"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: { + message: "Database connection failed", + code: "INTERNAL_ERROR", + }, + }); + }); + }); + + describe("unknown error handling", () => { + it("handles generic Error with 500 status", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw new Error("Unexpected error"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("INTERNAL_ERROR"); + }); + }); +}); diff --git a/pkgs/server/src/middleware/error-handler.ts b/pkgs/server/src/middleware/error-handler.ts new file mode 100644 index 0000000..7b92940 --- /dev/null +++ b/pkgs/server/src/middleware/error-handler.ts @@ -0,0 +1,95 @@ +import type { Context } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +/** + * Application-specific error with status code and optional details + */ +export class AppError extends Error { + readonly statusCode: ContentfulStatusCode; + readonly code: string; + + constructor( + message: string, + statusCode: ContentfulStatusCode = 500, + code = "INTERNAL_ERROR", + ) { + super(message); + this.name = "AppError"; + this.statusCode = statusCode; + this.code = code; + } +} + +/** + * Common error factory functions + */ +export const Errors = { + badRequest: (message = "Bad request", code = "BAD_REQUEST") => + new AppError(message, 400, code), + + unauthorized: (message = "Unauthorized", code = "UNAUTHORIZED") => + new AppError(message, 401, code), + + forbidden: (message = "Forbidden", code = "FORBIDDEN") => + new AppError(message, 403, code), + + notFound: (message = "Not found", code = "NOT_FOUND") => + new AppError(message, 404, code), + + conflict: (message = "Conflict", code = "CONFLICT") => + new AppError(message, 409, code), + + validationError: (message = "Validation failed", code = "VALIDATION_ERROR") => + new AppError(message, 422, code), + + internal: (message = "Internal server error", code = "INTERNAL_ERROR") => + new AppError(message, 500, code), +}; + +interface ErrorResponse { + error: { + message: string; + code: string; + }; +} + +/** + * Global error handler middleware for Hono + */ +export function errorHandler(err: Error, c: Context): Response { + // Handle AppError + if (err instanceof AppError) { + const response: ErrorResponse = { + error: { + message: err.message, + code: err.code, + }, + }; + return c.json(response, err.statusCode); + } + + // Handle Hono's HTTPException + if (err instanceof HTTPException) { + const response: ErrorResponse = { + error: { + message: err.message, + code: "HTTP_ERROR", + }, + }; + return c.json(response, err.status as ContentfulStatusCode); + } + + // Handle unknown errors + console.error("Unhandled error:", err); + const response: ErrorResponse = { + error: { + message: + process.env.NODE_ENV === "production" + ? "Internal server error" + : err.message, + code: "INTERNAL_ERROR", + }, + }; + return c.json(response, 500); +} diff --git a/pkgs/server/src/middleware/index.ts b/pkgs/server/src/middleware/index.ts new file mode 100644 index 0000000..bd3d4dc --- /dev/null +++ b/pkgs/server/src/middleware/index.ts @@ -0,0 +1 @@ +export { AppError, Errors, errorHandler } from "./error-handler"; |
