feat: implement error handling and validation middleware, enhance result handling
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -3,10 +3,15 @@ import type { AppEnv } from '@/types/hono';
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import { requestId } from 'hono/request-id';
|
import { requestId } from 'hono/request-id';
|
||||||
|
import { errorHandler } from './lib/http/error-handler';
|
||||||
|
import { requestIdMiddleware } from './lib/http/request-id';
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = new Hono<AppEnv>();
|
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')
|
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
|
|||||||
58
apps/backend/src/lib/errors.ts
Normal file
58
apps/backend/src/lib/errors.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
14
apps/backend/src/lib/result.ts
Normal file
14
apps/backend/src/lib/result.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { handleResult } from '@/lib/http/handle-result';
|
||||||
import { listSports } from '@/modules/sport/services/sport.service';
|
import { listSports } from '@/modules/sport/services/sport.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
|
|
||||||
export async function listSportsHandler(c: AppContext) {
|
export async function listSportsHandler(c: AppContext) {
|
||||||
const sports = await listSports();
|
const sports = await listSports();
|
||||||
return c.json(sports);
|
return handleResult(c, sports);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Prisma } from '@/generated/prisma/client';
|
import { Prisma, Sport } from '@/generated/prisma/client';
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
|
import { ok, Result } from '@/lib/result';
|
||||||
import { v7 as uuidv7 } from 'uuid';
|
import { v7 as uuidv7 } from 'uuid';
|
||||||
|
|
||||||
export type CreateSportInput = {
|
export type CreateSportInput = {
|
||||||
@@ -51,10 +52,11 @@ async function buildUniqueSlug(source: string, excludeSportId?: string): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSports() {
|
export async function listSports(): Promise<Result<Sport[]>> {
|
||||||
return db.sport.findMany({
|
const sports = await db.sport.findMany({
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
|
return ok(sports);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSport(input: CreateSportInput) {
|
export async function createSport(input: CreateSportInput) {
|
||||||
|
|||||||
@@ -8,18 +8,18 @@ import { zValidator } from '@hono/zod-validator';
|
|||||||
import { createSportSchema, updateSportSchema } from '@repo/api-contract';
|
import { createSportSchema, updateSportSchema } from '@repo/api-contract';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { validate } from '@/lib/http/validate'
|
||||||
export const sportRoutes = new Hono<AppEnv>();
|
export const sportRoutes = new Hono<AppEnv>();
|
||||||
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
||||||
|
|
||||||
sportRoutes.use('*', requireAuth);
|
sportRoutes.use('*', requireAuth);
|
||||||
|
|
||||||
sportRoutes.get('/', listSportsHandler);
|
sportRoutes.get('/', listSportsHandler);
|
||||||
sportRoutes.post('/', requireSuperAdmin, zValidator('json', createSportSchema), createSportHandler);
|
sportRoutes.post('/', requireSuperAdmin, validate.json(createSportSchema), createSportHandler);
|
||||||
sportRoutes.patch(
|
sportRoutes.patch(
|
||||||
'/:id',
|
'/:id',
|
||||||
requireSuperAdmin,
|
requireSuperAdmin,
|
||||||
zValidator('param', sportIdParamsSchema),
|
validate.param(sportIdParamsSchema),
|
||||||
zValidator('json', updateSportSchema),
|
validate.json(updateSportSchema),
|
||||||
updateSportHandler
|
updateSportHandler
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user