Skip to content

Commit efe6107

Browse files
committed
feat: add http exception handler base class
1 parent 2a85021 commit efe6107

File tree

9 files changed

+798
-4
lines changed

9 files changed

+798
-4
lines changed

index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ export { RouteResource } from './src/router/resource.js'
1919
export { BriskRoute } from './src/router/brisk.js'
2020
export { HttpContext } from './src/http_context/main.js'
2121
export * as errors from './src/exceptions.js'
22+
export { HttpExceptionHandler } from './src/exception_handler.js'

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"@adonisjs/encryption": "^5.1.0-0",
4545
"@adonisjs/events": "^8.4.3-0",
4646
"@adonisjs/fold": "^9.9.0-0",
47-
"@adonisjs/logger": "^5.2.0-0",
47+
"@adonisjs/logger": "^5.3.0-0",
4848
"@commitlint/cli": "^17.4.2",
4949
"@commitlint/config-conventional": "^17.4.2",
5050
"@fastify/middie": "^8.1.0",
@@ -113,14 +113,15 @@
113113
"qs": "^6.11.0",
114114
"tmp-cache": "^1.1.0",
115115
"type-is": "^1.6.18",
116-
"vary": "^1.1.2"
116+
"vary": "^1.1.2",
117+
"youch": "^3.2.2"
117118
},
118119
"peerDependencies": {
119120
"@adonisjs/application": "^6.8.0-0",
120121
"@adonisjs/encryption": "^5.1.0-0",
121122
"@adonisjs/events": "^8.4.3-0",
122123
"@adonisjs/fold": "^9.9.0-0",
123-
"@adonisjs/logger": "^5.2.0-0"
124+
"@adonisjs/logger": "^5.3.0-0"
124125
},
125126
"repository": {
126127
"type": "git",

src/exception_handler.ts

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
/*
2+
* @adonisjs/http-server
3+
*
4+
* (c) AdonisJS
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import is from '@sindresorhus/is'
11+
import type { Logger } from '@adonisjs/logger'
12+
import type { Level } from '@adonisjs/logger/types'
13+
14+
import { parseRange } from './helpers.js'
15+
import * as errors from './exceptions.js'
16+
import type { HttpContext } from './http_context/main.js'
17+
import type { HttpError, StatusPageRange, StatusPageRenderer } from './types/server.js'
18+
19+
/**
20+
* The base HTTP exception handler one can inherit from to handle
21+
* HTTP exceptions.
22+
*
23+
* The HTTP exception handler has support for
24+
*
25+
* - Ability to render exceptions by calling the render method on the exception.
26+
* - Rendering status pages
27+
* - Pretty printing errors during development
28+
* - Transforming errors to JSON or HTML using content negotiation
29+
* - Reporting errors
30+
*/
31+
export class HttpExceptionHandler {
32+
/**
33+
* Whether or not to render debug info. When set to true, the errors
34+
* will have the complete error stack.
35+
*/
36+
protected debug: boolean = process.env.NODE_ENV !== 'production'
37+
38+
/**
39+
* Whether or not to render status pages. When set to true, the unhandled
40+
* errors with matching status codes will be rendered using a status
41+
* page.
42+
*/
43+
protected renderStatusPages: boolean = process.env.NODE_ENV === 'production'
44+
45+
/**
46+
* A collection of error status code range and the view to render.
47+
*/
48+
protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {}
49+
50+
/**
51+
* Computed from the status pages property
52+
*/
53+
#expandedStatusPages?: Record<number, StatusPageRenderer>
54+
55+
/**
56+
* Renderers for rendering an error.
57+
*/
58+
protected renderers: {
59+
html: (error: HttpError, ctx: HttpContext) => Promise<void>
60+
json: (error: HttpError, ctx: HttpContext) => Promise<void>
61+
json_api: (error: HttpError, ctx: HttpContext) => Promise<void>
62+
} = {
63+
html: async (error: HttpError, ctx: HttpContext) => {
64+
if (this.isDebuggingEnabled(ctx)) {
65+
const { default: Youch } = await import('youch')
66+
const html = await new Youch(error, ctx.request.request).toHTML()
67+
ctx.response.status(error.status).send(html)
68+
69+
return
70+
}
71+
72+
ctx.response.status(error.status).send(`<p> ${error.message} </p>`)
73+
},
74+
75+
json: async (error: HttpError, ctx: HttpContext) => {
76+
if (this.isDebuggingEnabled(ctx)) {
77+
const { default: Youch } = await import('youch')
78+
const json = await new Youch(error, ctx.request.request).toJSON()
79+
ctx.response.status(error.status).send(json.error)
80+
81+
return
82+
}
83+
84+
ctx.response.status(error.status).send({ message: error.message })
85+
},
86+
87+
json_api: async (error: HttpError, ctx: HttpContext) => {
88+
if (this.isDebuggingEnabled(ctx)) {
89+
const { default: Youch } = await import('youch')
90+
const json = await new Youch(error, ctx.request.request).toJSON()
91+
ctx.response.status(error.status).send(json.error)
92+
93+
return
94+
}
95+
96+
ctx.response.status(error.status).send({
97+
errors: [
98+
{
99+
title: error.message,
100+
code: error.code,
101+
status: error.status,
102+
},
103+
],
104+
})
105+
},
106+
}
107+
108+
/**
109+
* Enable/disable errors reporting
110+
*/
111+
protected reportErrors: boolean = true
112+
113+
/**
114+
* An array of exception classes to ignore when
115+
* reporting an error
116+
*/
117+
protected ignoreExceptions: any[] = [
118+
errors.E_HTTP_EXCEPTION,
119+
errors.E_ROUTE_NOT_FOUND,
120+
errors.E_CANNOT_LOOKUP_ROUTE,
121+
errors.E_HTTP_REQUEST_ABORTED,
122+
]
123+
124+
/**
125+
* An array of HTTP status codes to ignore when reporting
126+
* an error
127+
*/
128+
protected ignoreStatuses: number[] = [400, 422, 401]
129+
130+
/**
131+
* An array of error codes to ignore when reporting
132+
* an error
133+
*/
134+
protected ignoreCodes: string[] = []
135+
136+
constructor(protected logger: Logger) {}
137+
138+
/**
139+
* Expands status pages
140+
*/
141+
#expandStatusPages() {
142+
if (!this.#expandedStatusPages) {
143+
this.#expandedStatusPages = Object.keys(this.statusPages).reduce((result, range) => {
144+
const renderer = this.statusPages[range as StatusPageRange]
145+
result = Object.assign(result, parseRange(range, renderer))
146+
return result
147+
}, {} as Record<number, StatusPageRenderer>)
148+
}
149+
150+
return this.#expandedStatusPages
151+
}
152+
153+
/**
154+
* Forcefully tweaking the type of the error object to
155+
* have known properties.
156+
*/
157+
#toHttpError(error: unknown): HttpError {
158+
const httpError: any = is.object(error) ? error : new Error(String(error))
159+
httpError.message = httpError.message || 'Internal server error'
160+
httpError.status = httpError.status || 500
161+
return httpError
162+
}
163+
164+
/**
165+
* Error reporting context
166+
*/
167+
protected context(ctx: HttpContext): any {
168+
const requestId = ctx.request.id()
169+
return requestId
170+
? {
171+
'x-request-id': requestId,
172+
}
173+
: {}
174+
}
175+
176+
/**
177+
* The format in which the error should be rendered.
178+
*/
179+
protected getResponseFormat(ctx: HttpContext): 'html' | 'json' | 'json_api' {
180+
switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) {
181+
case 'application/vnd.api+json':
182+
return 'json_api'
183+
case 'json':
184+
return 'json'
185+
case 'html':
186+
default:
187+
return 'html'
188+
}
189+
}
190+
191+
/**
192+
* Returns the log level for an error based upon the error
193+
* status code.
194+
*/
195+
protected getErrorLogLevel(error: HttpError): Level {
196+
if (error.status >= 500) {
197+
return 'error'
198+
}
199+
200+
if (error.status >= 400) {
201+
return 'warn'
202+
}
203+
204+
return 'info'
205+
}
206+
207+
/**
208+
* A boolean to control if errors should be rendered with
209+
* all the available debugging info.
210+
*/
211+
protected isDebuggingEnabled(_: HttpContext): boolean {
212+
return this.debug
213+
}
214+
215+
/**
216+
* Returns a boolean by checking if an error should be reported.
217+
*/
218+
protected shouldReport(error: HttpError): boolean {
219+
if (this.reportErrors === false) {
220+
return false
221+
}
222+
223+
if (this.ignoreStatuses.includes(error.status)) {
224+
return false
225+
}
226+
227+
if (error.code && this.ignoreCodes.includes(error.code)) {
228+
return false
229+
}
230+
231+
if (this.ignoreExceptions.find((exception) => error instanceof exception)) {
232+
return false
233+
}
234+
235+
return true
236+
}
237+
238+
/**
239+
* Reports an error during an HTTP request
240+
*/
241+
async report(error: unknown, ctx: HttpContext) {
242+
const httpError = this.#toHttpError(error)
243+
if (!this.shouldReport(httpError)) {
244+
return
245+
}
246+
247+
if (typeof httpError.report === 'function') {
248+
httpError.report(httpError, ctx)
249+
return
250+
}
251+
252+
/**
253+
* Log the error using the logger
254+
*/
255+
const level = this.getErrorLogLevel(httpError)
256+
this.logger.log(
257+
level,
258+
{
259+
...(level === 'error' || level === 'fatal' ? { err: httpError } : {}),
260+
...this.context(ctx),
261+
},
262+
httpError.message
263+
)
264+
}
265+
266+
/**
267+
* Handles the error during the HTTP request.
268+
*/
269+
async handle(error: unknown, ctx: HttpContext) {
270+
const httpError = this.#toHttpError(error)
271+
272+
/**
273+
* Self handle exception
274+
*/
275+
if (typeof httpError.handle === 'function') {
276+
return httpError.handle(httpError, ctx)
277+
}
278+
279+
/**
280+
* Render status page
281+
*/
282+
const statusPages = this.#expandStatusPages()
283+
if (this.renderStatusPages && statusPages[httpError.status]) {
284+
return statusPages[httpError.status](httpError, ctx)
285+
}
286+
287+
/**
288+
* Use the format renderers.
289+
*/
290+
const responseFormat = this.getResponseFormat(ctx)
291+
return this.renderers[responseFormat](httpError, ctx)
292+
}
293+
}

src/helpers.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
*/
99

1010
import Cache from 'tmp-cache'
11+
import { InvalidArgumentsException } from '@poppinss/utils'
12+
1113
import { Route } from './router/route.js'
1214
import { BriskRoute } from './router/brisk.js'
1315
import { RouteGroup } from './router/group.js'
@@ -76,3 +78,31 @@ export function trustProxy(
7678
proxyCache.set(remoteAddress, result)
7779
return result
7880
}
81+
82+
/**
83+
* Parses a range expression to an object filled with the range
84+
*/
85+
export function parseRange<T>(range: string, value: T): Record<number, T> {
86+
const parts = range.split('..')
87+
const min = Number(parts[0])
88+
const max = Number(parts[1])
89+
90+
if (Number.isNaN(min) || Number.isNaN(max)) {
91+
return {}
92+
}
93+
94+
if (min === max) {
95+
return {
96+
[min]: value,
97+
}
98+
}
99+
100+
if (max < min) {
101+
throw new InvalidArgumentsException(`Invalid range "${range}"`)
102+
}
103+
104+
return [...Array(max - min + 1).keys()].reduce((result, step) => {
105+
result[min + step] = value
106+
return result
107+
}, {} as Record<number, T>)
108+
}

src/server/main.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export class Server {
4141
* The default error handler to use
4242
*/
4343
#defaultErrorHandler: ServerErrorHandler = {
44+
report() {},
4445
handle(error, ctx) {
4546
ctx.response.status(error.status || 500).send(error.message || 'Internal server error')
4647
},
@@ -162,7 +163,10 @@ export class Server {
162163
*/
163164
#handleRequest(ctx: HttpContext, resolver: ContainerResolver<any>) {
164165
return this.#serverMiddlewareStack!.runner()
165-
.errorHandler((error) => this.#resolvedErrorHandler.handle(error, ctx))
166+
.errorHandler((error) => {
167+
this.#resolvedErrorHandler.report(error, ctx)
168+
return this.#resolvedErrorHandler.handle(error, ctx)
169+
})
166170
.finalHandler(finalHandler(this.#router!, resolver, ctx))
167171
.run(middlewareHandler(resolver, ctx))
168172
.catch((error) => {

0 commit comments

Comments
 (0)