Skip to content

Add shared error handler #21

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Dec 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,55 @@ const { GET } = compose({
export { GET };
```

## Error handling

Handling errors both in middleware and in the main handler is as simple as providing `sharedErrorHandler` to the `compose` function's second parameter _(a.k.a compose settings)_. Main goal of the shared error handler is to provide clear and easy way to e.g. send the error metadata to Sentry or other error tracking service.

By default, shared error handler looks like this:

```ts
sharedErrorHandler: {
handler: undefined;
// ^^^^ This is the handler function. By default there is no handler, so the error is being just thrown.
includeRouteHandler: false;
// ^^^^^^^^^^^^^^^^ This toggles whether the route handler itself should be included in a error handled area.
// By default only middlewares are being caught by the sharedErrorHandler
}
```

... and some usage example:

```ts
// [...]
function errorMiddleware() {
throw new Error("foo");
}

const { GET } = compose(
{
GET: [
[errorMiddleware],
() => {
// Unreachable code due to errorMiddleware throwing an error and halting the chain
return new Response(JSON.stringify({ foo: "bar" }));
},
],
},
{
sharedErrorHandler: {
handler: (_method, error) => {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
});
},
},
}
);
// [...]
```

will return `{"error": "foo"}` along with `500` status code instead of throwing an error.

## Theory and caveats

1. Unfortunately there is no way to dynamically export named ESModules _(or at least I did not find a way)_ so you have to use `export { GET, POST }` syntax instead of something like `export compose(...)` if you're composing GET and POST methods :(
Expand Down Expand Up @@ -113,4 +162,4 @@ The project is licensed under The MIT License. Thanks for all the contributions!
[next-api-route-handlers]: https://nextjs.org/docs/app/building-your-application/routing/route-handlers
[next-app-router-intro]: https://nextjs.org/docs/app/building-your-application/routing#the-app-router
[next-app-router]: https://nextjs.org/docs/app
[next-pages-router]: https://nextjs.org/docs/pages
[next-pages-router]: https://nextjs.org/docs/pages
4 changes: 2 additions & 2 deletions packages/next-api-compose/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverageFrom: ['src/*'],
coverageReporters: ['html', 'json', 'lcov'],
setupFilesAfterEnv: ['./jest.setup.js'],
coverageProvider: 'v8'
coverageProvider: 'v8',
}
9 changes: 3 additions & 6 deletions packages/next-api-compose/jest.setup.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
global.Response = class MockedResponse {
constructor(body, status) {
this.body = body
this.status = status
}
}
const { Response } = require('undici')

global.Response = Response
3 changes: 2 additions & 1 deletion packages/next-api-compose/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
"ts-toolbelt": "^9.6.0",
"tsup": "^7.2.0",
"type-fest": "^4.2.0",
"typescript": "^5.1.6"
"typescript": "^5.1.6",
"undici": "^5.28.2"
},
"prettier": {
"printWidth": 90,
Expand Down
74 changes: 72 additions & 2 deletions packages/next-api-compose/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Promisable } from 'type-fest'
import type { Promisable, PartialDeep } from 'type-fest'
import type { NextApiRequest, NextApiResponse } from 'next'
import type { NextResponse } from 'next/server'

Expand All @@ -16,6 +16,22 @@ type NextApiMethodHandler = (
request: NextApiRequest
) => Promisable<NextApiResponse> | Promisable<Response>

type ComposeSettings = PartialDeep<{
sharedErrorHandler: {
/**
* @param {NextApiRouteMethod} method HTTP method of the composed route handler that failed.
* @param {Error} error Error that was thrown by the middleware or the handler.
*/
handler: (method: NextApiRouteMethod, error: Error) => Promisable<Response | void>
/**
* Whether to include the route handler in the error handled area.
*
* By default only middlewares are included (being caught by the sharedErrorHandler).
*/
includeRouteHandler: boolean
}
}>

type ComposeParameters<
Methods extends NextApiRouteMethod,
MiddlewareChain extends Array<
Expand All @@ -39,6 +55,7 @@ type ComposeParameters<
* Function that allows to define complex API structure in Next.js App router's Route Handlers.
*
* @param {ComposeParameters} parameters Middlewares array **(order matters)** or options object with previously mentioned middlewares array as `middlewareChain` property and error handler shared by every middleware in the array as `sharedErrorHandler` property.
* @param {ComposeSettings} composeSettings Settings object that allows to configure the compose function.
* @returns Method handlers with applied middleware.
*/
export function compose<
Expand All @@ -51,7 +68,22 @@ export function compose<
| Promisable<Response | undefined>
| Promisable<void | undefined>
>
>(parameters: ComposeParameters<UsedMethods, MiddlewareChain>) {
>(
parameters: ComposeParameters<UsedMethods, MiddlewareChain>,
composeSettings?: ComposeSettings
) {
const defaultComposeSettings = {
sharedErrorHandler: {
handler: undefined,
includeRouteHandler: false
}
}

const mergedComposeSettings = {
...defaultComposeSettings,
...composeSettings
}

const modified = Object.entries(parameters).map(
([method, composeForMethodData]: [
UsedMethods,
Expand All @@ -66,12 +98,50 @@ export function compose<
[method]: async (request: any) => {
if (typeof composeForMethodData === 'function') {
const handler = composeForMethodData
if (
mergedComposeSettings.sharedErrorHandler.includeRouteHandler &&
mergedComposeSettings.sharedErrorHandler.handler != null
) {
try {
return await handler(request)
} catch (error) {
const composeSharedErrorHandlerResult =
await mergedComposeSettings.sharedErrorHandler.handler(method, error)

if (
composeSharedErrorHandlerResult != null &&
composeSharedErrorHandlerResult instanceof Response
) {
return composeSharedErrorHandlerResult
}
}
}

return await handler(request)
}

const [middlewareChain, handler] = composeForMethodData

for (const middleware of middlewareChain) {
if (mergedComposeSettings.sharedErrorHandler.handler != null) {
try {
const abortedMiddleware = await middleware(request)

if (abortedMiddleware != null && abortedMiddleware instanceof Response)
return abortedMiddleware
} catch (error) {
const composeSharedErrorHandlerResult =
await mergedComposeSettings.sharedErrorHandler.handler(method, error)

if (
composeSharedErrorHandlerResult != null &&
composeSharedErrorHandlerResult instanceof Response
) {
return composeSharedErrorHandlerResult
}
}
}

const abortedMiddleware = await middleware(request)

if (abortedMiddleware != null && abortedMiddleware instanceof Response)
Expand Down
Loading