Skip to content

Commit 28ea0c5

Browse files
authored
feat!: improve afterError hook to accept array of functions, change to object args (#8389)
Changes the `afterError` hook structure, adds tests / more docs. Ensures that the `req.responseHeaders` property is respected in the error handler. **Breaking** `afterError` now accepts an array of functions instead of a single function: ```diff - afterError: () => {...} + afterError: [() => {...}] ``` The args are changed to accept an object with the following properties: | Argument | Description | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`error`** | The error that occurred. | | **`context`** | Custom context passed between Hooks. [More details](./context). | | **`graphqlResult`** | The GraphQL result object, available if the hook is executed within a GraphQL context. | | **`req`** | The [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object containing the currently authenticated `user` | | **`collection`** | The [Collection](../configuration/collections) in which this Hook is running against. This will be `undefined` if the hook is executed from a non-collection endpoint or GraphQL. | | **`result`** | The formatted error result object, available if the hook is executed from a REST context. |
1 parent 6da4f06 commit 28ea0c5

File tree

12 files changed

+238
-77
lines changed

12 files changed

+238
-77
lines changed

docs/hooks/collections.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const CollectionWithHooks: CollectionConfig = {
4646
afterRead: [(args) => {...}],
4747
afterDelete: [(args) => {...}],
4848
afterOperation: [(args) => {...}],
49+
afterError: [(args) => {....}],
4950

5051
// Auth-enabled Hooks
5152
beforeLogin: [(args) => {...}],
@@ -289,6 +290,30 @@ The following arguments are provided to the `afterOperation` hook:
289290
| **`operation`** | The name of the operation that this hook is running within. |
290291
| **`result`** | The result of the operation, before modifications. |
291292

293+
### afterError
294+
295+
The `afterError` Hook is triggered when an error occurs in the Payload application. This can be useful for logging errors to a third-party service, sending an email to the development team, logging the error to Sentry or DataDog, etc. The output can be used to transform the result object / status code.
296+
297+
```ts
298+
import type { CollectionAfterErrorHook } from 'payload';
299+
300+
const afterDeleteHook: CollectionAfterErrorHook = async ({
301+
req,
302+
id,
303+
doc,
304+
}) => {...}
305+
```
306+
The following arguments are provided to the `afterError` Hook:
307+
308+
| Argument | Description |
309+
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
310+
| **`error`** | The error that occurred. |
311+
| **`context`** | Custom context passed between Hooks. [More details](./context). |
312+
| **`graphqlResult`** | The GraphQL result object, available if the hook is executed within a GraphQL context. |
313+
| **`req`** | The `PayloadRequest` object that extends [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request). Contains currently authenticated `user` and the Local API instance `payload`. |
314+
| **`collection`** | The [Collection](../configuration/collections) in which this Hook is running against. |
315+
| **`result`** | The formatted error result object, available if the hook is executed from a REST context. |
316+
292317
### beforeLogin
293318

294319
For [Auth-enabled Collections](../authentication/overview), this hook runs during `login` operations where a user with the provided credentials exist, but before a token is generated and added to the response. You can optionally modify the user that is returned, or throw an error in order to deny the login operation.

docs/hooks/overview.mdx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export default buildConfig({
4343
// ...
4444
// highlight-start
4545
hooks: {
46-
afterError: () => {...}
46+
afterError:[() => {...}]
4747
},
4848
// highlight-end
4949
})
@@ -57,28 +57,31 @@ The following options are available:
5757

5858
### afterError
5959

60-
The `afterError` Hook is triggered when an error occurs in the Payload application. This can be useful for logging errors to a third-party service, sending an email to the development team, logging the error to Sentry or DataDog, etc.
60+
The `afterError` Hook is triggered when an error occurs in the Payload application. This can be useful for logging errors to a third-party service, sending an email to the development team, logging the error to Sentry or DataDog, etc. The output can be used to transform the result object / status code.
6161

6262
```ts
6363
import { buildConfig } from 'payload'
6464

6565
export default buildConfig({
6666
// ...
6767
hooks: {
68-
afterError: async ({ error }) => {
68+
afterError: [async ({ error }) => {
6969
// Do something
70-
}
70+
}]
7171
},
7272
})
7373
```
7474

7575
The following arguments are provided to the `afterError` Hook:
7676

77-
| Argument | Description |
78-
|----------|-----------------------------------------------------------------------------------------------|
79-
| **`error`** | The error that occurred. |
80-
| **`context`** | Custom context passed between Hooks. [More details](./context). |
81-
77+
| Argument | Description |
78+
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
79+
| **`error`** | The error that occurred. |
80+
| **`context`** | Custom context passed between Hooks. [More details](./context). |
81+
| **`graphqlResult`** | The GraphQL result object, available if the hook is executed within a GraphQL context. |
82+
| **`req`** | The `PayloadRequest` object that extends [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request). Contains currently authenticated `user` and the Local API instance `payload`. |
83+
| **`collection`** | The [Collection](../configuration/collections) in which this Hook is running against. This will be `undefined` if the hook is executed from a non-collection endpoint or GraphQL. |
84+
| **`result`** | The formatted error result object, available if the hook is executed from a REST context. |
8285
## Async vs. Synchronous
8386

8487
All Hooks can be written as either synchronous or asynchronous functions. Choosing the right type depends on your use case, but switching between the two is as simple as adding or removing the `async` keyword.

packages/next/src/routes/graphql/handler.ts

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { GraphQLError, GraphQLFormattedError } from 'graphql'
2-
import type { CollectionAfterErrorHook, Payload, SanitizedConfig } from 'payload'
2+
import type { APIError, Payload, PayloadRequest, SanitizedConfig } from 'payload'
33

44
import { configToSchema } from '@payloadcms/graphql'
55
import { createHandler } from 'graphql-http/lib/use/fetch'
@@ -11,38 +11,51 @@ import { createPayloadRequest } from '../../utilities/createPayloadRequest.js'
1111
import { headersWithCors } from '../../utilities/headersWithCors.js'
1212
import { mergeHeaders } from '../../utilities/mergeHeaders.js'
1313

14-
const handleError = async (
15-
payload: Payload,
16-
err: any,
17-
debug: boolean,
18-
afterErrorHook: CollectionAfterErrorHook,
19-
// eslint-disable-next-line @typescript-eslint/require-await
20-
): Promise<GraphQLFormattedError> => {
21-
const status = err.originalError.status || httpStatus.INTERNAL_SERVER_ERROR
14+
const handleError = async ({
15+
err,
16+
payload,
17+
req,
18+
}: {
19+
err: GraphQLError
20+
payload: Payload
21+
req: PayloadRequest
22+
}): Promise<GraphQLFormattedError> => {
23+
const status = (err.originalError as APIError).status || httpStatus.INTERNAL_SERVER_ERROR
2224
let errorMessage = err.message
2325
payload.logger.error(err.stack)
2426

2527
// Internal server errors can contain anything, including potentially sensitive data.
2628
// Therefore, error details will be hidden from the response unless `config.debug` is `true`
27-
if (!debug && status === httpStatus.INTERNAL_SERVER_ERROR) {
29+
if (!payload.config.debug && status === httpStatus.INTERNAL_SERVER_ERROR) {
2830
errorMessage = 'Something went wrong.'
2931
}
3032

3133
let response: GraphQLFormattedError = {
3234
extensions: {
3335
name: err?.originalError?.name || undefined,
34-
data: (err && err.originalError && err.originalError.data) || undefined,
35-
stack: debug ? err.stack : undefined,
36+
data: (err && err.originalError && (err.originalError as APIError).data) || undefined,
37+
stack: payload.config.debug ? err.stack : undefined,
3638
statusCode: status,
3739
},
3840
locations: err.locations,
3941
message: errorMessage,
4042
path: err.path,
4143
}
4244

43-
if (afterErrorHook) {
44-
;({ response } = afterErrorHook(err, response, null, null) || { response })
45-
}
45+
await payload.config.hooks.afterError?.reduce(async (promise, hook) => {
46+
await promise
47+
48+
const result = await hook({
49+
context: req.context,
50+
error: err,
51+
graphqlResult: response,
52+
req,
53+
})
54+
55+
if (result) {
56+
response = result.graphqlResult || response
57+
}
58+
}, Promise.resolve())
4659

4760
return response
4861
}
@@ -95,9 +108,6 @@ export const POST =
95108

96109
const { payload } = req
97110

98-
const afterErrorHook =
99-
typeof payload.config.hooks.afterError === 'function' ? payload.config.hooks.afterError : null
100-
101111
const headers = {}
102112
const apiResponse = await createHandler({
103113
context: { headers, req },
@@ -113,7 +123,7 @@ export const POST =
113123
if (response.errors) {
114124
const errors = (await Promise.all(
115125
result.errors.map((error) => {
116-
return handleError(payload, error, payload.config.debug, afterErrorHook)
126+
return handleError({ err: error, payload, req })
117127
}),
118128
)) as GraphQLError[]
119129
// errors type should be FormattedGraphQLError[] but onOperation has a return type of ExecutionResult instead of FormattedExecutionResult

packages/next/src/routes/rest/routeError.ts

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import type { Collection, PayloadRequest, SanitizedConfig } from 'payload'
1+
import type { Collection, ErrorResult, PayloadRequest, SanitizedConfig } from 'payload'
22

33
import httpStatus from 'http-status'
44
import { APIError, APIErrorName, ValidationErrorName } from 'payload'
55

66
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
77
import { headersWithCors } from '../../utilities/headersWithCors.js'
8+
import { mergeHeaders } from '../../utilities/mergeHeaders.js'
89

9-
export type ErrorResponse = { data?: any; errors: unknown[]; stack?: string }
10-
11-
const formatErrors = (incoming: { [key: string]: unknown } | APIError): ErrorResponse => {
10+
const formatErrors = (incoming: { [key: string]: unknown } | APIError): ErrorResult => {
1211
if (incoming) {
1312
// Cannot use `instanceof` to check error type: https://github.com/microsoft/TypeScript/issues/13965
1413
// Instead, get the prototype of the incoming error and check its constructor name
@@ -73,14 +72,14 @@ export const routeError = async ({
7372
collection,
7473
config: configArg,
7574
err,
76-
req,
75+
req: incomingReq,
7776
}: {
7877
collection?: Collection
7978
config: Promise<SanitizedConfig> | SanitizedConfig
8079
err: APIError
81-
req: Partial<PayloadRequest>
80+
req: PayloadRequest | Request
8281
}) => {
83-
let payload = req?.payload
82+
let payload = 'payload' in incomingReq && incomingReq?.payload
8483

8584
if (!payload) {
8685
try {
@@ -95,6 +94,8 @@ export const routeError = async ({
9594
}
9695
}
9796

97+
const req = incomingReq as PayloadRequest
98+
9899
req.payload = payload
99100
const headers = headersWithCors({
100101
headers: new Headers(),
@@ -119,26 +120,44 @@ export const routeError = async ({
119120
response.stack = err.stack
120121
}
121122

122-
if (collection && typeof collection.config.hooks.afterError === 'function') {
123-
;({ response, status } = collection.config.hooks.afterError(
124-
err,
125-
response,
126-
req?.context,
127-
collection.config,
128-
) || { response, status })
123+
if (collection) {
124+
await collection.config.hooks.afterError?.reduce(async (promise, hook) => {
125+
await promise
126+
127+
const result = await hook({
128+
collection: collection.config,
129+
context: req.context,
130+
error: err,
131+
req,
132+
result: response,
133+
})
134+
135+
if (result) {
136+
response = (result.response as ErrorResult) || response
137+
status = result.status || status
138+
}
139+
}, Promise.resolve())
129140
}
130141

131-
if (typeof config.hooks.afterError === 'function') {
132-
;({ response, status } = config.hooks.afterError(
133-
err,
134-
response,
135-
req?.context,
136-
collection?.config,
137-
) || {
138-
response,
139-
status,
142+
await config.hooks.afterError?.reduce(async (promise, hook) => {
143+
await promise
144+
145+
const result = await hook({
146+
collection: collection?.config,
147+
context: req.context,
148+
error: err,
149+
req,
150+
result: response,
140151
})
141-
}
142152

143-
return Response.json(response, { headers, status })
153+
if (result) {
154+
response = (result.response as ErrorResult) || response
155+
status = result.status || status
156+
}
157+
}, Promise.resolve())
158+
159+
return Response.json(response, {
160+
headers: req.responseHeaders ? mergeHeaders(req.responseHeaders, headers) : headers,
161+
status,
162+
})
144163
}

packages/payload/src/collections/config/types.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import type {
1616
import type { Auth, ClientUser, IncomingAuthType } from '../../auth/types.js'
1717
import type {
1818
Access,
19+
AfterErrorHookArgs,
20+
AfterErrorResult,
1921
CustomComponent,
2022
EditConfig,
2123
Endpoint,
@@ -178,14 +180,6 @@ export type AfterOperationHook<TOperationGeneric extends CollectionSlug = string
178180
>
179181
>
180182

181-
export type AfterErrorHook = (
182-
err: Error,
183-
res: unknown,
184-
context: RequestContext,
185-
/** The collection which this hook is being run on. This is null if the AfterError hook was be added to the payload-wide config */
186-
collection: null | SanitizedCollectionConfig,
187-
) => { response: any; status: number } | void
188-
189183
export type BeforeLoginHook<T extends TypeWithID = any> = (args: {
190184
/** The collection which this hook is being run on */
191185
collection: SanitizedCollectionConfig
@@ -237,6 +231,10 @@ export type AfterRefreshHook<T extends TypeWithID = any> = (args: {
237231
token: string
238232
}) => any
239233

234+
export type AfterErrorHook = (
235+
args: { collection: SanitizedCollectionConfig } & AfterErrorHookArgs,
236+
) => AfterErrorResult | Promise<AfterErrorResult>
237+
240238
export type AfterForgotPasswordHook = (args: {
241239
args?: any
242240
/** The collection which this hook is being run on */
@@ -402,7 +400,7 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
402400
hooks?: {
403401
afterChange?: AfterChangeHook[]
404402
afterDelete?: AfterDeleteHook[]
405-
afterError?: AfterErrorHook
403+
afterError?: AfterErrorHook[]
406404
afterForgotPassword?: AfterForgotPasswordHook[]
407405
afterLogin?: AfterLoginHook[]
408406
afterLogout?: AfterLogoutHook[]

packages/payload/src/config/types.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
} from '@payloadcms/translations'
77
import type { BusboyConfig } from 'busboy'
88
import type GraphQL from 'graphql'
9+
import type { GraphQLFormattedError } from 'graphql'
910
import type { JSONSchema4 } from 'json-schema'
1011
import type { DestinationStream, pino } from 'pino'
1112
import type React from 'react'
@@ -23,15 +24,14 @@ import type {
2324
InternalImportMap,
2425
} from '../bin/generateImportMap/index.js'
2526
import type {
26-
AfterErrorHook,
2727
Collection,
2828
CollectionConfig,
2929
SanitizedCollectionConfig,
3030
} from '../collections/config/types.js'
3131
import type { DatabaseAdapterResult } from '../database/types.js'
3232
import type { EmailAdapter, SendEmailOptions } from '../email/types.js'
3333
import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js'
34-
import type { Payload, TypedUser } from '../index.js'
34+
import type { Payload, RequestContext, TypedUser } from '../index.js'
3535
import type { PayloadRequest, Where } from '../types/index.js'
3636
import type { PayloadLogger } from '../utilities/logger.js'
3737

@@ -621,6 +621,33 @@ export type FetchAPIFileUploadOptions = {
621621
useTempFiles?: boolean | undefined
622622
} & Partial<BusboyConfig>
623623

624+
export type ErrorResult = { data?: any; errors: unknown[]; stack?: string }
625+
626+
export type AfterErrorResult = {
627+
graphqlResult?: GraphQLFormattedError
628+
response?: Partial<ErrorResult> & Record<string, unknown>
629+
status?: number
630+
} | void
631+
632+
export type AfterErrorHookArgs = {
633+
/** The Collection that the hook is operating on. This will be undefined if the hook is executed from a non-collection endpoint or GraphQL. */
634+
collection?: SanitizedCollectionConfig
635+
/** Custom context passed between hooks */
636+
context: RequestContext
637+
/** The error that occurred. */
638+
error: Error
639+
/** The GraphQL result object, available if the hook is executed within a GraphQL context. */
640+
graphqlResult?: GraphQLFormattedError
641+
/** The Request object containing the currently authenticated user. */
642+
req: PayloadRequest
643+
/** The formatted error result object, available if the hook is executed from a REST context. */
644+
result?: ErrorResult
645+
}
646+
647+
export type AfterErrorHook = (
648+
args: AfterErrorHookArgs,
649+
) => AfterErrorResult | Promise<AfterErrorResult>
650+
624651
/**
625652
* This is the central configuration
626653
*
@@ -895,7 +922,7 @@ export type Config = {
895922
* @see https://payloadcms.com/docs/hooks/overview
896923
*/
897924
hooks?: {
898-
afterError?: AfterErrorHook
925+
afterError?: AfterErrorHook[]
899926
}
900927
/** i18n config settings */
901928
// eslint-disable-next-line @typescript-eslint/no-empty-object-type

0 commit comments

Comments
 (0)