Skip to content

Commit 04d7b1c

Browse files
committed
feat(error): return problem+json structured error body
- Add ProblemDetails interface describing structured error payload - Add problemDetails builder with type, title, status, instance, errors - Add problemJsonContentType constant for application/problem+json - Add safeReasons extracting string causes from 422 status errors - Recognize application/problem+json in wantsJson negotiation - Replace ad-hoc JSON error body with problem details on primary and fallback paths
1 parent 5055162 commit 04d7b1c

3 files changed

Lines changed: 81 additions & 13 deletions

File tree

src/core/Constant.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export class Constant {
7171
httpOnly: true,
7272
secure: true
7373
}
74+
/** Problem details JSON content type */
75+
static readonly problemJsonContentType = 'application/problem+json'
7476
/** Status code to error message */
7577
static readonly serverErrorMessages: Readonly<
7678
Partial<Record<Types.HttpStatusCode, string>>

src/core/Handler.ts

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,19 @@ export class Handler {
135135
static errorResponse(ctx: Context, statusCode: number): globalThis.Response {
136136
const errorMessage = Handler.safeMessage(statusCode)
137137
const wantsJson = Handler.wantsJson(ctx.request.headers)
138+
const reasons = Handler.safeReasons(ctx[Core.InternalContext].getFrameworkError())
138139
try {
139140
if (wantsJson) {
140-
return ctx.send.json(
141-
{ error: errorMessage, path: ctx.pathname, statusCode },
142-
{ status: statusCode }
143-
)
141+
return ctx.send.json(Handler.problemDetails(statusCode, ctx.pathname, undefined, reasons), {
142+
status: statusCode,
143+
headers: { 'Content-Type': Core.Constant.problemJsonContentType }
144+
})
144145
}
145146
return ctx.send.html(Handler.defaultErrorHtml(statusCode, errorMessage), {
146147
status: statusCode
147148
})
148149
} catch {
149-
return Handler.safeFallbackResponse(ctx, statusCode, errorMessage, wantsJson)
150+
return Handler.safeFallbackResponse(ctx, statusCode, errorMessage, wantsJson, reasons)
150151
}
151152
}
152153

@@ -217,14 +218,13 @@ export class Handler {
217218
statusCode: number,
218219
message: string,
219220
wantsJson: boolean,
220-
pathname?: string
221+
pathname?: string,
222+
reasons?: readonly string[]
221223
): globalThis.Response {
222224
const headers = new Core.API.Headers(Core.Constant.securityHeaderDefaults)
223225
if (wantsJson) {
224-
headers.set('Content-Type', 'application/json')
225-
const body = pathname === undefined
226-
? { error: message, statusCode }
227-
: { error: message, path: pathname, statusCode }
226+
headers.set('Content-Type', Core.Constant.problemJsonContentType)
227+
const body = Handler.problemDetails(statusCode, pathname, message, reasons)
228228
return new Core.API.Response(Core.API.jsonStringify(body), { status: statusCode, headers })
229229
}
230230
headers.set('Content-Type', 'text/html; charset=utf-8')
@@ -234,6 +234,32 @@ export class Handler {
234234
})
235235
}
236236

237+
/**
238+
* Build structured error problem details.
239+
* @description Returns problem body with type, title, status, optional instance.
240+
* @param statusCode - HTTP status code
241+
* @param pathname - Optional request pathname as instance
242+
* @param title - Optional title overriding safe message
243+
* @param reasons - Optional validation reasons added as errors
244+
* @returns Problem details object
245+
*/
246+
static problemDetails(
247+
statusCode: number,
248+
pathname?: string,
249+
title?: string,
250+
reasons?: readonly string[]
251+
): Types.ProblemDetails {
252+
const base: Types.ProblemDetails = {
253+
type: 'about:blank',
254+
title: title ?? Handler.safeMessage(statusCode),
255+
status: statusCode
256+
}
257+
const withInstance = pathname === undefined ? base : { ...base, instance: pathname }
258+
return reasons !== undefined && reasons.length > 0
259+
? { ...withInstance, errors: reasons }
260+
: withInstance
261+
}
262+
237263
/**
238264
* Resolve safe message for status.
239265
* @description Returns known message or generic fallback for status.
@@ -247,6 +273,27 @@ export class Handler {
247273
)
248274
}
249275

276+
/**
277+
* Extract safe reasons from error.
278+
* @description Returns string causes only for 422 status errors.
279+
* @param error - Error to inspect, or null
280+
* @returns Reason strings, or undefined when none
281+
*/
282+
static safeReasons(error: Error | null): readonly string[] | undefined {
283+
if (
284+
error === null ||
285+
!Handler.isErrorWithStatus(error) ||
286+
error.statusCode !== 422 ||
287+
!Array.isArray(error.cause)
288+
) {
289+
return undefined
290+
}
291+
const reasons = (error.cause as readonly unknown[]).filter(
292+
(reason): reason is string => typeof reason === 'string'
293+
)
294+
return reasons.length > 0 ? reasons : undefined
295+
}
296+
250297
/**
251298
* Create a branded state key.
252299
* @description Returns type-branded string for compile-time safety.
@@ -284,7 +331,11 @@ export class Handler {
284331
* @returns True when JSON is preferred
285332
*/
286333
static wantsJson(headers: Headers): boolean {
287-
return headers.get('accept')?.includes('application/json') === true
334+
const accept = headers.get('accept')
335+
if (accept === null) {
336+
return false
337+
}
338+
return accept.includes('application/json') || accept.includes('application/problem+json')
288339
}
289340

290341
/**
@@ -328,8 +379,9 @@ export class Handler {
328379
ctx: Context,
329380
statusCode: number,
330381
message: string,
331-
wantsJson: boolean
382+
wantsJson: boolean,
383+
reasons?: readonly string[]
332384
): globalThis.Response {
333-
return Handler.negotiatedResponse(statusCode, message, wantsJson, ctx.pathname)
385+
return Handler.negotiatedResponse(statusCode, message, wantsJson, ctx.pathname, reasons)
334386
}
335387
}

src/interfaces/Core.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ export interface ParsedIp {
102102
readonly version: 4 | 6
103103
}
104104

105+
/** Structured error problem details payload. */
106+
export interface ProblemDetails {
107+
/** Problem type URI reference */
108+
readonly type: string
109+
/** Short human-readable problem summary */
110+
readonly title: string
111+
/** HTTP status code for problem */
112+
readonly status: number
113+
/** Optional URI reference of occurrence */
114+
readonly instance?: string
115+
/** Optional list of validation reasons */
116+
readonly errors?: readonly string[]
117+
}
118+
105119
/**
106120
* Response helpers on context.
107121
* @description Provides typed methods for common response formats.

0 commit comments

Comments
 (0)