@@ -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}
0 commit comments