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

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