feat: implement error handling and validation middleware, enhance result handling
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
20
apps/backend/src/lib/http/error-handler.ts
Normal file
20
apps/backend/src/lib/http/error-handler.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
20
apps/backend/src/lib/http/handle-result.ts
Normal file
20
apps/backend/src/lib/http/handle-result.ts
Normal 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)
|
||||
}
|
||||
29
apps/backend/src/lib/http/problem-builders.ts
Normal file
29
apps/backend/src/lib/http/problem-builders.ts
Normal 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
|
||||
}
|
||||
}
|
||||
10
apps/backend/src/lib/http/problem-details.ts
Normal file
10
apps/backend/src/lib/http/problem-details.ts
Normal 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[]>
|
||||
}
|
||||
53
apps/backend/src/lib/http/problem-mapper.ts
Normal file
53
apps/backend/src/lib/http/problem-mapper.ts
Normal 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
|
||||
}
|
||||
}
|
||||
16
apps/backend/src/lib/http/request-id.ts
Normal file
16
apps/backend/src/lib/http/request-id.ts
Normal 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()
|
||||
}
|
||||
52
apps/backend/src/lib/http/validate.ts
Normal file
52
apps/backend/src/lib/http/validate.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
18
apps/backend/src/lib/http/zod-issues.ts
Normal file
18
apps/backend/src/lib/http/zod-issues.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user