feat: implement error handling and validation middleware, enhance result handling

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Jose Selesan
2026-04-23 16:35:57 -03:00
parent 7bfa3b390e
commit 20d6018836
14 changed files with 306 additions and 8 deletions

View File

@@ -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<AppEnv>();
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())

View File

@@ -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 }
}
}

View File

@@ -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)
}
}

View File

@@ -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<T>(
c: Context,
result: Result<T>,
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)
}

View File

@@ -0,0 +1,29 @@
// http/problem-builders.ts
import type { ProblemDetails } from './problem-details'
export function validationProblem(params: {
detail?: string
instance?: string
errors?: Record<string, string[]>
}): 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
}
}

View File

@@ -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<string, string[]>
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,16 @@
import type { MiddlewareHandler } from 'hono'
type Env = {
Variables: {
requestId: string
}
}
export const requestIdMiddleware: MiddlewareHandler<Env> = async (c, next) => {
const requestId = crypto.randomUUID()
c.set('requestId', requestId)
c.header('X-Request-Id', requestId)
await next()
}

View File

@@ -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<TSchema extends ZodSchema>(
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<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('json', schema)
},
query<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('query', schema)
},
param<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('param', schema)
},
header<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('header', schema)
},
form<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('form', schema)
}
}

View File

@@ -0,0 +1,18 @@
type IssueLike = {
path: PropertyKey[]
message: string
}
export function zodIssuesToRecord(
issues: IssueLike[]
): Record<string, string[]> {
const out: Record<string, string[]> = {}
for (const issue of issues) {
const key = issue.path.length ? issue.path.join('.') : 'root'
out[key] ??= []
out[key].push(issue.message)
}
return out
}

View File

@@ -0,0 +1,14 @@
// lib/result.ts
import type { AppError } from '@/lib/errors';
export type Result<T, E = AppError> =
| { ok: true; value: T }
| { ok: false; error: E }
export function ok<T>(value: T): Result<T> {
return { ok: true, value }
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error }
}

View File

@@ -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);
}

View File

@@ -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<Result<Sport[]>> {
const sports = await db.sport.findMany({
orderBy: { name: 'asc' },
});
return ok(sports);
}
export async function createSport(input: CreateSportInput) {

View File

@@ -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<AppEnv>();
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
);