From 20d60188361e4b4cdd97ec7089e21d40682b0c0c Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Thu, 23 Apr 2026 16:35:57 -0300 Subject: [PATCH] feat: implement error handling and validation middleware, enhance result handling Co-authored-by: Copilot --- apps/backend/src/app.ts | 5 ++ apps/backend/src/lib/errors.ts | 58 +++++++++++++++++++ apps/backend/src/lib/http/error-handler.ts | 20 +++++++ apps/backend/src/lib/http/handle-result.ts | 20 +++++++ apps/backend/src/lib/http/problem-builders.ts | 29 ++++++++++ apps/backend/src/lib/http/problem-details.ts | 10 ++++ apps/backend/src/lib/http/problem-mapper.ts | 53 +++++++++++++++++ apps/backend/src/lib/http/request-id.ts | 16 +++++ apps/backend/src/lib/http/validate.ts | 52 +++++++++++++++++ apps/backend/src/lib/http/zod-issues.ts | 18 ++++++ apps/backend/src/lib/result.ts | 14 +++++ .../sport/handlers/list-sports.handler.ts | 3 +- .../modules/sport/services/sport.service.ts | 8 ++- .../backend/src/modules/sport/sport.routes.ts | 8 +-- 14 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 apps/backend/src/lib/errors.ts create mode 100644 apps/backend/src/lib/http/error-handler.ts create mode 100644 apps/backend/src/lib/http/handle-result.ts create mode 100644 apps/backend/src/lib/http/problem-builders.ts create mode 100644 apps/backend/src/lib/http/problem-details.ts create mode 100644 apps/backend/src/lib/http/problem-mapper.ts create mode 100644 apps/backend/src/lib/http/request-id.ts create mode 100644 apps/backend/src/lib/http/validate.ts create mode 100644 apps/backend/src/lib/http/zod-issues.ts create mode 100644 apps/backend/src/lib/result.ts diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index f06980c..662d52a 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -3,10 +3,15 @@ import type { AppEnv } from '@/types/hono'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { requestId } from 'hono/request-id'; +import { errorHandler } from './lib/http/error-handler'; +import { requestIdMiddleware } from './lib/http/request-id'; export function createApp() { const app = new Hono(); + app.use('*', requestIdMiddleware) + app.use('*', errorHandler) + const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173') .split(',') .map((value) => value.trim()) diff --git a/apps/backend/src/lib/errors.ts b/apps/backend/src/lib/errors.ts new file mode 100644 index 0000000..3555e82 --- /dev/null +++ b/apps/backend/src/lib/errors.ts @@ -0,0 +1,58 @@ +// lib/errors.ts +export type ValidationIssue = { + path: string + message: string +} + +export type AppError = + | { + type: 'validation' + message: string + issues?: ValidationIssue[] + } + | { + type: 'not_found' + message: string + } + | { + type: 'conflict' + message: string + } + | { + type: 'unauthorized' + message: string + } + | { + type: 'forbidden' + message: string + } + | { + type: 'unexpected' + message: string + } + +export const Errors = { + validation(message: string, issues?: ValidationIssue[]): AppError { + return { type: 'validation', message, issues } + }, + + notFound(message = 'Resource not found'): AppError { + return { type: 'not_found', message } + }, + + conflict(message: string): AppError { + return { type: 'conflict', message } + }, + + unauthorized(message = 'Unauthorized'): AppError { + return { type: 'unauthorized', message } + }, + + forbidden(message = 'Forbidden'): AppError { + return { type: 'forbidden', message } + }, + + unexpected(message = 'Unexpected error'): AppError { + return { type: 'unexpected', message } + } +} \ No newline at end of file diff --git a/apps/backend/src/lib/http/error-handler.ts b/apps/backend/src/lib/http/error-handler.ts new file mode 100644 index 0000000..f1a833c --- /dev/null +++ b/apps/backend/src/lib/http/error-handler.ts @@ -0,0 +1,20 @@ +// http/error-handler.ts +import type { MiddlewareHandler } from 'hono' +import { unexpectedProblem } from './problem-builders' +import { logger } from '../logger' + +export const errorHandler: MiddlewareHandler = async (c, next) => { + try { + await next() + } catch (error) { + const requestId = c.get('requestId') as string | undefined + const instance = requestId ? `/requests/${requestId}` : undefined + + logger.error({ + requestId, + error + }) + + return c.json(unexpectedProblem({ instance }), 500) + } +} \ No newline at end of file diff --git a/apps/backend/src/lib/http/handle-result.ts b/apps/backend/src/lib/http/handle-result.ts new file mode 100644 index 0000000..3fda6cb --- /dev/null +++ b/apps/backend/src/lib/http/handle-result.ts @@ -0,0 +1,20 @@ +import type { Context } from 'hono' +import type { ContentfulStatusCode } from 'hono/utils/http-status' +import type { Result } from '@/lib/result' +import { mapAppErrorToProblem } from './problem-mapper' + +export function handleResult( + c: Context, + result: Result, + successStatus: ContentfulStatusCode = 200 +) { + if (!result.ok) { + const requestId = c.get('requestId') as string | undefined + const instance = requestId ? `/requests/${requestId}` : undefined + const problem = mapAppErrorToProblem(result.error, instance) + + return c.json(problem, problem.status) + } + + return c.json(result.value, successStatus) +} \ No newline at end of file diff --git a/apps/backend/src/lib/http/problem-builders.ts b/apps/backend/src/lib/http/problem-builders.ts new file mode 100644 index 0000000..a159112 --- /dev/null +++ b/apps/backend/src/lib/http/problem-builders.ts @@ -0,0 +1,29 @@ +// http/problem-builders.ts +import type { ProblemDetails } from './problem-details' + +export function validationProblem(params: { + detail?: string + instance?: string + errors?: Record +}): ProblemDetails { + return { + type: 'https://api.myapp.dev/problems/validation', + title: 'Bad Request', + status: 400, + detail: params.detail ?? 'Invalid request data', + instance: params.instance, + errors: params.errors + } +} + +export function unexpectedProblem(params?: { + instance?: string +}): ProblemDetails { + return { + type: 'https://api.myapp.dev/problems/unexpected', + title: 'Internal Server Error', + status: 500, + detail: 'Internal server error', + instance: params?.instance + } +} \ No newline at end of file diff --git a/apps/backend/src/lib/http/problem-details.ts b/apps/backend/src/lib/http/problem-details.ts new file mode 100644 index 0000000..1631d7a --- /dev/null +++ b/apps/backend/src/lib/http/problem-details.ts @@ -0,0 +1,10 @@ +import type { ContentfulStatusCode } from 'hono/utils/http-status' + +export type ProblemDetails = { + type: string + title: string + status: ContentfulStatusCode + detail?: string + instance?: string + errors?: Record +} \ No newline at end of file diff --git a/apps/backend/src/lib/http/problem-mapper.ts b/apps/backend/src/lib/http/problem-mapper.ts new file mode 100644 index 0000000..386906a --- /dev/null +++ b/apps/backend/src/lib/http/problem-mapper.ts @@ -0,0 +1,53 @@ +import type { ContentfulStatusCode } from 'hono/utils/http-status' +import type { AppError } from '@/lib/errors' +import type { ProblemDetails } from './problem-details' + +function statusFromError(error: AppError): ContentfulStatusCode { + switch (error.type) { + case 'validation': + return 400 + case 'unauthorized': + return 401 + case 'forbidden': + return 403 + case 'not_found': + return 404 + case 'conflict': + return 409 + case 'unexpected': + default: + return 500 + } +} + +function titleFromStatus(status: ContentfulStatusCode): string { + switch (status) { + case 400: + return 'Bad Request' + case 401: + return 'Unauthorized' + case 403: + return 'Forbidden' + case 404: + return 'Not Found' + case 409: + return 'Conflict' + default: + return 'Internal Server Error' + } +} + +export function mapAppErrorToProblem( + error: AppError, + instance?: string +): ProblemDetails { + const status = statusFromError(error) + + return { + type: `https://api.myapp.dev/problems/${error.type}`, + title: titleFromStatus(status), + status, + detail: status === 500 ? 'Internal server error' : error.message, + instance + } +} \ No newline at end of file diff --git a/apps/backend/src/lib/http/request-id.ts b/apps/backend/src/lib/http/request-id.ts new file mode 100644 index 0000000..a7faf19 --- /dev/null +++ b/apps/backend/src/lib/http/request-id.ts @@ -0,0 +1,16 @@ +import type { MiddlewareHandler } from 'hono' + +type Env = { + Variables: { + requestId: string + } +} + +export const requestIdMiddleware: MiddlewareHandler = async (c, next) => { + const requestId = crypto.randomUUID() + + c.set('requestId', requestId) + c.header('X-Request-Id', requestId) + + await next() +} \ No newline at end of file diff --git a/apps/backend/src/lib/http/validate.ts b/apps/backend/src/lib/http/validate.ts new file mode 100644 index 0000000..72fd9ab --- /dev/null +++ b/apps/backend/src/lib/http/validate.ts @@ -0,0 +1,52 @@ + +import { zValidator } from '@hono/zod-validator' +import type { ZodSchema } from 'zod' +import { validationProblem } from './problem-builders' +import { zodIssuesToRecord } from './zod-issues' + +type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form' + +function makeValidator( + target: ValidationTarget, + schema: TSchema +) { + return zValidator(target, schema, (result, c) => { + if (result.success) { + return + } + + const requestId = c.get('requestId') as string | undefined + const instance = requestId ? `/requests/${requestId}` : undefined + + return c.json( + validationProblem({ + detail: `Invalid ${target} data`, + instance, + errors: zodIssuesToRecord(result.error.issues) + }), + 400 + ) + }) +} + +export const validate = { + json(schema: TSchema) { + return makeValidator('json', schema) + }, + + query(schema: TSchema) { + return makeValidator('query', schema) + }, + + param(schema: TSchema) { + return makeValidator('param', schema) + }, + + header(schema: TSchema) { + return makeValidator('header', schema) + }, + + form(schema: TSchema) { + return makeValidator('form', schema) + } +} \ No newline at end of file diff --git a/apps/backend/src/lib/http/zod-issues.ts b/apps/backend/src/lib/http/zod-issues.ts new file mode 100644 index 0000000..eb77b49 --- /dev/null +++ b/apps/backend/src/lib/http/zod-issues.ts @@ -0,0 +1,18 @@ +type IssueLike = { + path: PropertyKey[] + message: string +} + +export function zodIssuesToRecord( + issues: IssueLike[] +): Record { + const out: Record = {} + + for (const issue of issues) { + const key = issue.path.length ? issue.path.join('.') : 'root' + out[key] ??= [] + out[key].push(issue.message) + } + + return out +} \ No newline at end of file diff --git a/apps/backend/src/lib/result.ts b/apps/backend/src/lib/result.ts new file mode 100644 index 0000000..63143d9 --- /dev/null +++ b/apps/backend/src/lib/result.ts @@ -0,0 +1,14 @@ +// lib/result.ts +import type { AppError } from '@/lib/errors'; + +export type Result = + | { ok: true; value: T } + | { ok: false; error: E } + +export function ok(value: T): Result { + return { ok: true, value } +} + +export function err(error: E): Result { + return { ok: false, error } +} \ No newline at end of file diff --git a/apps/backend/src/modules/sport/handlers/list-sports.handler.ts b/apps/backend/src/modules/sport/handlers/list-sports.handler.ts index 337d7ff..c813cc8 100644 --- a/apps/backend/src/modules/sport/handlers/list-sports.handler.ts +++ b/apps/backend/src/modules/sport/handlers/list-sports.handler.ts @@ -1,7 +1,8 @@ +import { handleResult } from '@/lib/http/handle-result'; import { listSports } from '@/modules/sport/services/sport.service'; import type { AppContext } from '@/types/hono'; export async function listSportsHandler(c: AppContext) { const sports = await listSports(); - return c.json(sports); + return handleResult(c, sports); } diff --git a/apps/backend/src/modules/sport/services/sport.service.ts b/apps/backend/src/modules/sport/services/sport.service.ts index 4b4d90c..bba9485 100644 --- a/apps/backend/src/modules/sport/services/sport.service.ts +++ b/apps/backend/src/modules/sport/services/sport.service.ts @@ -1,5 +1,6 @@ -import { Prisma } from '@/generated/prisma/client'; +import { Prisma, Sport } from '@/generated/prisma/client'; import { db } from '@/lib/prisma'; +import { ok, Result } from '@/lib/result'; import { v7 as uuidv7 } from 'uuid'; export type CreateSportInput = { @@ -51,10 +52,11 @@ async function buildUniqueSlug(source: string, excludeSportId?: string): Promise } } -export async function listSports() { - return db.sport.findMany({ +export async function listSports(): Promise> { + const sports = await db.sport.findMany({ orderBy: { name: 'asc' }, }); + return ok(sports); } export async function createSport(input: CreateSportInput) { diff --git a/apps/backend/src/modules/sport/sport.routes.ts b/apps/backend/src/modules/sport/sport.routes.ts index bd23be1..e557a06 100644 --- a/apps/backend/src/modules/sport/sport.routes.ts +++ b/apps/backend/src/modules/sport/sport.routes.ts @@ -8,18 +8,18 @@ import { zValidator } from '@hono/zod-validator'; import { createSportSchema, updateSportSchema } from '@repo/api-contract'; import { Hono } from 'hono'; import { z } from 'zod'; - +import { validate } from '@/lib/http/validate' export const sportRoutes = new Hono(); const sportIdParamsSchema = z.object({ id: z.uuid() }); sportRoutes.use('*', requireAuth); sportRoutes.get('/', listSportsHandler); -sportRoutes.post('/', requireSuperAdmin, zValidator('json', createSportSchema), createSportHandler); +sportRoutes.post('/', requireSuperAdmin, validate.json(createSportSchema), createSportHandler); sportRoutes.patch( '/:id', requireSuperAdmin, - zValidator('param', sportIdParamsSchema), - zValidator('json', updateSportSchema), + validate.param(sportIdParamsSchema), + validate.json(updateSportSchema), updateSportHandler );