From 67ba131cc61f3d3b30ef9ef7fc150344ca82da2f Mon Sep 17 00:00:00 2001 From: Alessio Gravili <70709113+AlessioGr@users.noreply.github.com> Date: Wed, 26 Jul 2023 15:07:49 +0200 Subject: [PATCH] feat: ability to add context to payload's request object (#2796) Co-authored-by: Dan Ribbens --- docs/hooks/context.mdx | 127 ++++++++++++++++++ docs/local-api/overview.mdx | 1 + src/auth/operations/forgotPassword.ts | 3 +- src/auth/operations/local/forgotPassword.ts | 2 + src/auth/operations/local/login.ts | 3 + src/auth/operations/local/resetPassword.ts | 2 + src/auth/operations/local/unlock.ts | 2 + src/auth/operations/login.ts | 5 + src/auth/operations/logout.ts | 1 + src/auth/operations/me.ts | 1 + src/auth/operations/refresh.ts | 2 + src/collections/config/types.ts | 22 ++- src/collections/operations/create.ts | 11 +- src/collections/operations/delete.ts | 5 + src/collections/operations/deleteByID.ts | 6 +- src/collections/operations/find.ts | 6 +- src/collections/operations/findByID.ts | 4 + src/collections/operations/findVersionByID.ts | 3 + src/collections/operations/findVersions.ts | 5 +- src/collections/operations/local/create.ts | 9 +- src/collections/operations/local/delete.ts | 9 +- src/collections/operations/local/find.ts | 9 +- src/collections/operations/local/findByID.ts | 9 +- .../operations/local/findVersionByID.ts | 10 +- .../operations/local/findVersions.ts | 10 +- .../operations/local/restoreVersion.ts | 10 +- src/collections/operations/local/update.ts | 9 +- src/collections/operations/restoreVersion.ts | 4 + src/collections/operations/update.ts | 10 ++ src/collections/operations/updateByID.ts | 18 ++- src/express/middleware/defaultPayload.ts | 10 ++ src/express/middleware/errorHandler.ts | 4 +- src/express/middleware/index.ts | 2 + src/express/setRequestContext.ts | 18 +++ src/express/types.ts | 12 +- src/fields/config/types.ts | 3 +- src/fields/hooks/afterChange/index.ts | 5 +- src/fields/hooks/afterChange/promise.ts | 11 +- .../hooks/afterChange/traverseFields.ts | 5 +- src/fields/hooks/afterRead/index.ts | 5 +- src/fields/hooks/afterRead/promise.ts | 14 +- src/fields/hooks/afterRead/traverseFields.ts | 5 +- src/fields/hooks/beforeChange/index.ts | 5 +- src/fields/hooks/beforeChange/promise.ts | 11 +- .../hooks/beforeChange/traverseFields.ts | 5 +- src/fields/hooks/beforeValidate/index.ts | 5 +- src/fields/hooks/beforeValidate/promise.ts | 11 +- .../hooks/beforeValidate/traverseFields.ts | 5 +- src/globals/operations/findOne.ts | 1 + src/globals/operations/findVersionByID.ts | 1 + src/globals/operations/findVersions.ts | 1 + src/globals/operations/local/findOne.ts | 2 + .../operations/local/findVersionByID.ts | 2 + src/globals/operations/local/findVersions.ts | 2 + .../operations/local/restoreVersion.ts | 2 + src/globals/operations/local/update.ts | 2 + src/globals/operations/restoreVersion.ts | 2 + src/globals/operations/update.ts | 5 + src/graphql/errorHandler.ts | 2 +- src/index.ts | 4 + test/hooks/collections/ContextHooks/index.ts | 68 ++++++++++ test/hooks/config.ts | 2 + test/hooks/int.spec.ts | 60 +++++++++ 63 files changed, 570 insertions(+), 40 deletions(-) create mode 100644 docs/hooks/context.mdx create mode 100644 src/express/middleware/defaultPayload.ts create mode 100644 src/express/setRequestContext.ts create mode 100644 test/hooks/collections/ContextHooks/index.ts diff --git a/docs/hooks/context.mdx b/docs/hooks/context.mdx new file mode 100644 index 000000000..303a1b057 --- /dev/null +++ b/docs/hooks/context.mdx @@ -0,0 +1,127 @@ +--- +title: Context +label: Context +order: 50 +desc: Context allows you to pass in extra data that can be shared between hooks +keywords: hooks, context, payload context, payloadcontext, data, extra data, shared data, shared, extra +--- + +The `context` object in hooks is used to share data across different hooks. The persists throughout the entire lifecycle of a request and is available within every hook. This allows you to add logic to your hooks based on the request state by setting properties to `req.context` and using them elsewhere. + +## When to use Context + +Context gives you a way forward on otherwise difficult problems such as: + +1. **Passing data between hooks**: Needing data in multiple hooks from a 3rd party API, it could be retrieved and used in `beforeChange` and later used again in an `afterChange` hook without having to fetch it twice. +2. **Preventing infinite loops**: Calling `payload.update()` on the same document that triggered an `afterChange` hook will create an infinite loop, control the flow by assigning a no-op condition to context +3. **Passing data to local API**: Setting values on the `req.context` and pass it to `payload.create()` you can provide additional data to hooks without adding extraneous fields. +4. **Passing data between hooks and middleware or custom endpoints**: Hooks could set context across multiple collections and then be used in a final `postMiddleware`. + +## How to Use Context + +Let's see examples on how context can be used in the first two scenarios mentioned above: + +### Passing data between hooks + +To pass data between hooks, you can assign values to context in an earlier hook in the lifecycle of a request and expect it the context in a later hook. + +For example: + +```ts +const Customer: CollectionConfig = { + slug: 'customers', + hooks: { + beforeChange: [async ({ context, data }) => { + // assign the customerData to context for use later + context.customerData = await fetchCustomerData(data.customerID); + return { + ...data, + // some data we use here + name: context.customerData.name + }; + }], + afterChange: [async ({ context, doc, req }) => { + // use context.customerData without needing to fetch it again + if (context.customerData.contacted === false) { + createTodo('Call Customer', context.customerData) + } + }], + }, + fields: [ /* ... */ ], +}; +``` + +### Preventing infinite loops + +Let's say you have an `afterChange` hook, and you want to do a calculation inside the hook (as the document ID needed for the calculation is available in the `afterChange` hook, but not in the `beforeChange` hook). Once that's done, you want to update the document with the result of the calculation. + +Bad example: + +```ts +const Customer: CollectionConfig = { + slug: 'customers', + hooks: { + afterChange: [async ({ doc }) => { + await payload.update({ + // DANGER: updating the same slug as the collection in an afterChange will create an infinite loop! + collection: 'customers', + id: doc.id, + data: { + ...(await fetchCustomerData(data.customerID)) + }, + }); + }], + }, + fields: [ /* ... */ ], +}; +``` + +Instead of the above, we need to tell the `afterChange` hook to not run again if it performs the update (and thus not update itself again). We can solve that with context. + +Fixed example: + +```ts +const MyCollection: CollectionConfig = { + slug: 'slug', + hooks: { + afterChange: [async ({ context, doc }) => { + // return if flag was previously set + if (context.triggerAfterChange === false) { + return; + } + await payload.update({ + collection: contextHooksSlug, + id: doc.id, + data: { + ...(await fetchCustomerData(data.customerID)) + }, + context: { + // set a flag to prevent from running again + triggerAfterChange: false, + }, + }); + }], + }, + fields: [ /* ... */ ], +}; +``` + +## Typing context + +The default typescript interface for `context` is `{ [key: string]: unknown }`. If you prefer a more strict typing in your project or when authoring plugins for others, you can override this using the `declare` syntax. + +This is known as "type augmentation" - a TypeScript feature which allows us to add types to existing objects. Simply put this in any .ts or .d.ts file: + +```ts +import { RequestContext as OriginalRequestContext } from 'payload'; + +declare module 'payload' { + // Create a new interface that merges your additional fields with the original one + export interface RequestContext extends OriginalRequestContext { + myObject?: string; + // ... + } +} +``` + +This will add a the property `myObject` with a type of string to every context object. Make sure to follow this example correctly, as type augmentation can mess up your types if you do it wrong. diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index 400b73eae..142038117 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -77,6 +77,7 @@ You can specify more options within the Local API vs. REST or GraphQL due to the | `user` | If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. | | `showHiddenFields` | Opt-in to receiving hidden fields. By default, they are hidden from returned documents in accordance to your config. | | `pagination` | Set to false to return all documents and avoid querying for document counts. | +| `context` | [Context](/docs/hooks/context), which will then be passed to `context` and `req.context`, which can be read by hooks. Useful if you want to pass additional information to the hooks which shouldn't be necessarily part of the document, for example a `triggerBeforeChange` option which can be read by the BeforeChange hook to determine if it should run or not. | _There are more options available on an operation by operation basis outlined below._ diff --git a/src/auth/operations/forgotPassword.ts b/src/auth/operations/forgotPassword.ts index f2445566a..d8464e34b 100644 --- a/src/auth/operations/forgotPassword.ts +++ b/src/auth/operations/forgotPassword.ts @@ -34,6 +34,7 @@ async function forgotPassword(incomingArgs: Arguments): Promise { args = (await hook({ args, operation: 'forgotPassword', + context: args.req.context, })) || args; }, Promise.resolve()); @@ -122,7 +123,7 @@ async function forgotPassword(incomingArgs: Arguments): Promise { await collectionConfig.hooks.afterForgotPassword.reduce(async (priorHook, hook) => { await priorHook; - await hook({ args }); + await hook({ args, context: req.context }); }, Promise.resolve()); return token; diff --git a/src/auth/operations/local/forgotPassword.ts b/src/auth/operations/local/forgotPassword.ts index ca50cb545..498117240 100644 --- a/src/auth/operations/local/forgotPassword.ts +++ b/src/auth/operations/local/forgotPassword.ts @@ -5,6 +5,7 @@ import { Payload } from '../../../payload'; import { getDataLoader } from '../../../collections/dataloader'; import i18n from '../../../translations/init'; import { APIError } from '../../../errors'; +import { setRequestContext } from '../../../express/setRequestContext'; export type Options = { collection: T @@ -27,6 +28,7 @@ async function localForgotPassword = { collection: TSlug @@ -37,6 +38,8 @@ async function localLogin( overrideAccess = true, showHiddenFields, } = options; + setRequestContext(options.req); + const collection = payload.collections[collectionSlug]; diff --git a/src/auth/operations/local/resetPassword.ts b/src/auth/operations/local/resetPassword.ts index 8381fc1ba..957722e5d 100644 --- a/src/auth/operations/local/resetPassword.ts +++ b/src/auth/operations/local/resetPassword.ts @@ -5,6 +5,7 @@ import { PayloadRequest } from '../../../express/types'; import { getDataLoader } from '../../../collections/dataloader'; import i18n from '../../../translations/init'; import { APIError } from '../../../errors'; +import { setRequestContext } from '../../../express/setRequestContext'; export type Options = { collection: T @@ -26,6 +27,7 @@ async function localResetPassword overrideAccess, req = {} as PayloadRequest, } = options; + setRequestContext(options.req); const collection = payload.collections[collectionSlug]; diff --git a/src/auth/operations/local/unlock.ts b/src/auth/operations/local/unlock.ts index 667633906..53822bfd4 100644 --- a/src/auth/operations/local/unlock.ts +++ b/src/auth/operations/local/unlock.ts @@ -5,6 +5,7 @@ import unlock from '../unlock'; import { getDataLoader } from '../../../collections/dataloader'; import i18n from '../../../translations/init'; import { APIError } from '../../../errors'; +import { setRequestContext } from '../../../express/setRequestContext'; export type Options = { collection: T @@ -25,6 +26,7 @@ async function localUnlock( overrideAccess = true, req = {} as PayloadRequest, } = options; + setRequestContext(options.req); const collection = payload.collections[collectionSlug]; diff --git a/src/auth/operations/login.ts b/src/auth/operations/login.ts index 8f9bb27f4..19b30d128 100644 --- a/src/auth/operations/login.ts +++ b/src/auth/operations/login.ts @@ -48,6 +48,7 @@ async function login( args = (await hook({ args, operation: 'login', + context: args.req.context, })) || args; }, Promise.resolve()); @@ -133,6 +134,7 @@ async function login( user = (await hook({ user, req: args.req, + context: req.context, })) || user; }, Promise.resolve()); @@ -172,6 +174,7 @@ async function login( user, req: args.req, token, + context: req.context, }) || user; }, Promise.resolve()); @@ -186,6 +189,7 @@ async function login( overrideAccess, req, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -198,6 +202,7 @@ async function login( user = await hook({ req, doc: user, + context: req.context, }) || user; }, Promise.resolve()); diff --git a/src/auth/operations/logout.ts b/src/auth/operations/logout.ts index 799c68302..66c1c6fc3 100644 --- a/src/auth/operations/logout.ts +++ b/src/auth/operations/logout.ts @@ -46,6 +46,7 @@ async function logout(incomingArgs: Arguments): Promise { args = (await hook({ req, res, + context: req.context, })) || args; }, Promise.resolve()); diff --git a/src/auth/operations/me.ts b/src/auth/operations/me.ts index b1c271729..5947ac9aa 100644 --- a/src/auth/operations/me.ts +++ b/src/auth/operations/me.ts @@ -60,6 +60,7 @@ async function me({ response = await hook({ req, response, + context: req.context, }) || response; }, Promise.resolve()); diff --git a/src/auth/operations/refresh.ts b/src/auth/operations/refresh.ts index 635a87ee1..aef689660 100644 --- a/src/auth/operations/refresh.ts +++ b/src/auth/operations/refresh.ts @@ -34,6 +34,7 @@ async function refresh(incomingArgs: Arguments): Promise { args = (await hook({ args, operation: 'refresh', + context: args.req.context, })) || args; }, Promise.resolve()); @@ -114,6 +115,7 @@ async function refresh(incomingArgs: Arguments): Promise { res: args.res, exp, token: refreshedToken, + context: args.req.context, })) || response; }, Promise.resolve()); diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index 9a209d064..7bfb02eca 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -6,7 +6,7 @@ import { Response } from 'express'; import { Config as GeneratedTypes } from 'payload/generated-types'; import { Access, Endpoint, EntityDescription, GeneratePreviewURL } from '../../config/types'; import { Field } from '../../fields/config/types'; -import { PayloadRequest } from '../../express/types'; +import { PayloadRequest, RequestContext } from '../../express/types'; import { Auth, IncomingAuthType, User } from '../../auth/types'; import { IncomingUploadType, Upload } from '../../uploads/types'; import { IncomingCollectionVersions, SanitizedCollectionVersions } from '../../versions/types'; @@ -49,6 +49,7 @@ export type BeforeOperationHook = (args: { * Hook operation being performed */ operation: HookOperationType; + context: RequestContext; }) => any; export type BeforeValidateHook = (args: { @@ -64,6 +65,7 @@ export type BeforeValidateHook = (args: { * `undefined` on 'create' operation */ originalDoc?: T; + context: RequestContext; }) => any; export type BeforeChangeHook = (args: { @@ -79,6 +81,7 @@ export type BeforeChangeHook = (args: { * `undefined` on 'create' operation */ originalDoc?: T; + context: RequestContext; }) => any; export type AfterChangeHook = (args: { @@ -89,53 +92,62 @@ export type AfterChangeHook = (args: { * Hook operation being performed */ operation: CreateOrUpdateOperation; + context: RequestContext; }) => any; export type BeforeReadHook = (args: { doc: T; req: PayloadRequest; query: { [key: string]: any }; + context: RequestContext; }) => any; export type AfterReadHook = (args: { doc: T; req: PayloadRequest; query?: { [key: string]: any }; - findMany?: boolean + findMany?: boolean; + context: RequestContext; }) => any; export type BeforeDeleteHook = (args: { req: PayloadRequest; id: string | number; + context: RequestContext; }) => any; export type AfterDeleteHook = (args: { doc: T; req: PayloadRequest; id: string | number; + context: RequestContext; }) => any; -export type AfterErrorHook = (err: Error, res: unknown) => { response: any, status: number } | void; +export type AfterErrorHook = (err: Error, res: unknown, context: RequestContext) => { response: any, status: number } | void; export type BeforeLoginHook = (args: { req: PayloadRequest; - user: T + user: T; + context: RequestContext; }) => any; export type AfterLoginHook = (args: { req: PayloadRequest; user: T; token: string; + context: RequestContext; }) => any; export type AfterLogoutHook = (args: { req: PayloadRequest; res: Response; + context: RequestContext; }) => any; export type AfterMeHook = (args: { req: PayloadRequest; response: unknown; + context: RequestContext; }) => any; export type AfterRefreshHook = (args: { @@ -143,10 +155,12 @@ export type AfterRefreshHook = (args: { res: Response; token: string; exp: number; + context: RequestContext; }) => any; export type AfterForgotPasswordHook = (args: { args?: any; + context: RequestContext; }) => any; type BeforeDuplicateArgs = { diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts index 101f3299d..4dad449ec 100644 --- a/src/collections/operations/create.ts +++ b/src/collections/operations/create.ts @@ -54,6 +54,7 @@ async function create( args = (await hook({ args, operation: 'create', + context: args.req.context, })) || args; }, Promise.resolve()); @@ -130,6 +131,7 @@ async function create( operation: 'create', overrideAccess, req, + context: req.context, }); // ///////////////////////////////////// @@ -143,6 +145,7 @@ async function create( data, req, operation: 'create', + context: req.context, })) || data; }, Promise.resolve()); @@ -165,6 +168,7 @@ async function create( data, req, operation: 'create', + context: req.context, })) || data; }, Promise.resolve()); @@ -180,6 +184,7 @@ async function create( operation: 'create', req, skipValidation: shouldSaveDraft, + context: req.context, }); // ///////////////////////////////////// @@ -203,7 +208,7 @@ async function create( doc: resultWithLocales, payload: req.payload, password: data.password as string, - }) + }); } else { try { doc = await Model.create(resultWithLocales); @@ -266,6 +271,7 @@ async function create( overrideAccess, req, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -278,6 +284,7 @@ async function create( result = await hook({ req, doc: result, + context: req.context, }) || result; }, Promise.resolve()); @@ -292,6 +299,7 @@ async function create( entityConfig: collectionConfig, operation: 'create', req, + context: req.context, }); // ///////////////////////////////////// @@ -306,6 +314,7 @@ async function create( previousDoc: {}, req: args.req, operation: 'create', + context: req.context, }) || result; }, Promise.resolve()); diff --git a/src/collections/operations/delete.ts b/src/collections/operations/delete.ts index 17f0aa5b1..951c4e306 100644 --- a/src/collections/operations/delete.ts +++ b/src/collections/operations/delete.ts @@ -41,6 +41,7 @@ async function deleteOperation(inc args = (await hook({ args, operation: 'delete', + context: args.req.context, })) || args; }, Promise.resolve()); @@ -72,6 +73,7 @@ async function deleteByID(inc return hook({ req, id, + context: req.context, }); }, Promise.resolve()); @@ -145,6 +147,7 @@ async function deleteByID(inc overrideAccess, req, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -157,6 +160,7 @@ async function deleteByID(inc result = await hook({ req, doc: result, + context: req.context, }) || result; }, Promise.resolve()); @@ -167,7 +171,7 @@ async function deleteByID(inc await collectionConfig.hooks.afterDelete.reduce(async (priorHook, hook) => { await priorHook; - result = await hook({ req, id, doc: result }) || result; + result = await hook({ req, id, doc: result, context: req.context }) || result; }, Promise.resolve()); // ///////////////////////////////////// diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index e17b979d0..750e45eec 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -41,6 +41,7 @@ async function find>( args = (await hook({ args, operation: 'read', + context: args.req.context, })) || args; }, Promise.resolve()); @@ -180,7 +181,7 @@ async function find>( await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { await priorHook; - docRef = await hook({ req, query, doc: docRef }) || docRef; + docRef = await hook({ req, query, doc: docRef, context: req.context }) || docRef; }, Promise.resolve()); return docRef; @@ -202,6 +203,7 @@ async function find>( req, showHiddenFields, findMany: true, + context: req.context, }))), }; @@ -217,7 +219,7 @@ async function find>( await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { await priorHook; - docRef = await hook({ req, query, doc: docRef, findMany: true }) || doc; + docRef = await hook({ req, query, doc: docRef, findMany: true, context: req.context }) || doc; }, Promise.resolve()); return docRef; diff --git a/src/collections/operations/findByID.ts b/src/collections/operations/findByID.ts index 5609fdfdd..e1ff5e28d 100644 --- a/src/collections/operations/findByID.ts +++ b/src/collections/operations/findByID.ts @@ -35,6 +35,7 @@ async function findByID( args = (await hook({ args, operation: 'read', + context: args.req.context, })) || args; }, Promise.resolve()); @@ -138,6 +139,7 @@ async function findByID( req, query, doc: result, + context: req.context, }) || result; }, Promise.resolve()); @@ -153,6 +155,7 @@ async function findByID( overrideAccess, req, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -166,6 +169,7 @@ async function findByID( req, query, doc: result, + context: req.context, }) || result; }, Promise.resolve()); diff --git a/src/collections/operations/findVersionByID.ts b/src/collections/operations/findVersionByID.ts index 67a7d1abd..edf8a39ea 100644 --- a/src/collections/operations/findVersionByID.ts +++ b/src/collections/operations/findVersionByID.ts @@ -98,6 +98,7 @@ async function findVersionByID = any>(args: Argumen req, query, doc: result.version, + context: req.context, }) || result.version; }, Promise.resolve()); @@ -113,6 +114,7 @@ async function findVersionByID = any>(args: Argumen overrideAccess, req, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -126,6 +128,7 @@ async function findVersionByID = any>(args: Argumen req, query, doc: result.version, + context: req.context, }) || result.version; }, Promise.resolve()); diff --git a/src/collections/operations/findVersions.ts b/src/collections/operations/findVersions.ts index 91cc88554..c1ee1286c 100644 --- a/src/collections/operations/findVersions.ts +++ b/src/collections/operations/findVersions.ts @@ -110,7 +110,7 @@ async function findVersions>( await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { await priorHook; - docRef.version = await hook({ req, query, doc: docRef.version }) || docRef.version; + docRef.version = await hook({ req, query, doc: docRef.version, context: req.context }) || docRef.version; }, Promise.resolve()); return docRef; @@ -133,6 +133,7 @@ async function findVersions>( req, showHiddenFields, findMany: true, + context: req.context, }), }))), }; @@ -149,7 +150,7 @@ async function findVersions>( await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { await priorHook; - docRef.version = await hook({ req, query, doc: doc.version, findMany: true }) || doc.version; + docRef.version = await hook({ req, query, doc: doc.version, findMany: true, context: req.context }) || doc.version; }, Promise.resolve()); return docRef; diff --git a/src/collections/operations/local/create.ts b/src/collections/operations/local/create.ts index 98a9f2519..866a80c79 100644 --- a/src/collections/operations/local/create.ts +++ b/src/collections/operations/local/create.ts @@ -2,7 +2,7 @@ import { Config as GeneratedTypes } from 'payload/generated-types'; import { UploadedFile } from 'express-fileupload'; import { MarkOptional } from 'ts-essentials'; import { Payload } from '../../../payload'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import { Document } from '../../../types'; import getFileByPath from '../../../uploads/getFileByPath'; import create from '../create'; @@ -10,6 +10,7 @@ import { getDataLoader } from '../../dataloader'; import { File } from '../../../uploads/types'; import i18n from '../../../translations/init'; import { APIError } from '../../../errors'; +import { setRequestContext } from '../../../express/setRequestContext'; export type Options = { collection: TSlug @@ -26,6 +27,10 @@ export type Options = { overwriteExistingFiles?: boolean req?: PayloadRequest draft?: boolean + /** + * context, which will then be passed to req.context, which can be read by hooks + */ + context?: RequestContext } export default async function createLocal( @@ -47,7 +52,9 @@ export default async function createLocal = { collection: T @@ -17,6 +18,10 @@ export type BaseOptions = { user?: Document overrideAccess?: boolean showHiddenFields?: boolean + /** + * context, which will then be passed to req.context, which can be read by hooks + */ + context?: RequestContext } export type ByIDOptions = BaseOptions & { @@ -45,6 +50,7 @@ async function deleteLocal(pa user, overrideAccess = true, showHiddenFields, + context, } = options; const collection = payload.collections[collectionSlug]; @@ -63,6 +69,7 @@ async function deleteLocal(pa payload, i18n: i18n(payload.config.i18n), } as PayloadRequest; + setRequestContext(req, context); if (!req.t) req.t = req.i18n.t; if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req); diff --git a/src/collections/operations/local/find.ts b/src/collections/operations/local/find.ts index 08b9c5883..83cf156bf 100644 --- a/src/collections/operations/local/find.ts +++ b/src/collections/operations/local/find.ts @@ -2,11 +2,12 @@ import { Config as GeneratedTypes } from 'payload/generated-types'; import { PaginatedDocs } from '../../../mongoose/types'; import { Document, Where } from '../../../types'; import { Payload } from '../../../payload'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import find from '../find'; import { getDataLoader } from '../../dataloader'; import i18n from '../../../translations/init'; import { APIError } from '../../../errors'; +import { setRequestContext } from '../../../express/setRequestContext'; export type Options = { collection: T @@ -25,6 +26,10 @@ export type Options = { where?: Where draft?: boolean req?: PayloadRequest + /** + * context, which will then be passed to req.context, which can be read by hooks + */ + context?: RequestContext } export default async function findLocal( @@ -48,7 +53,9 @@ export default async function findLocal = { collection: T @@ -20,6 +21,10 @@ export type Options = { disableErrors?: boolean req?: PayloadRequest draft?: boolean + /** + * context, which will then be passed to req.context, which can be read by hooks + */ + context?: RequestContext, } export default async function findByIDLocal( @@ -39,7 +44,9 @@ export default async function findByIDLocal = { collection: T @@ -19,6 +20,11 @@ export type Options = { showHiddenFields?: boolean disableErrors?: boolean req?: PayloadRequest + draft?: boolean + /** + * context, which will then be passed to req.context, which can be read by hooks + */ + context?: RequestContext, } export default async function findVersionByIDLocal( @@ -35,7 +41,9 @@ export default async function findVersionByIDLocal = { collection: T @@ -21,6 +22,11 @@ export type Options = { showHiddenFields?: boolean sort?: string where?: Where + draft?: boolean + /** + * context, which will then be passed to req.context, which can be read by hooks + */ + context?: RequestContext, } export default async function findVersionsLocal( @@ -39,6 +45,7 @@ export default async function findVersionsLocal = { collection: T @@ -16,6 +17,11 @@ export type Options = { user?: Document overrideAccess?: boolean showHiddenFields?: boolean + draft?: boolean + /** + * context, which will then be passed to req.context, which can be read by hooks + */ + context?: RequestContext, } export default async function restoreVersionLocal( @@ -31,6 +37,7 @@ export default async function restoreVersionLocal = { collection: TSlug @@ -26,6 +27,10 @@ export type BaseOptions = { overwriteExistingFiles?: boolean draft?: boolean autosave?: boolean + /** + * context, which will then be passed to req.context, which can be read by hooks + */ + context?: RequestContext } export type ByIDOptions = BaseOptions & { @@ -60,6 +65,7 @@ async function updateLocal(pa autosave, id, where, + context, } = options; const collection = payload.collections[collectionSlug]; @@ -82,6 +88,7 @@ async function updateLocal(pa file: file ?? await getFileByPath(filePath), }, } as PayloadRequest; + setRequestContext(req, context); if (!req.t) req.t = req.i18n.t; if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req); diff --git a/src/collections/operations/restoreVersion.ts b/src/collections/operations/restoreVersion.ts index 984ca5bdb..1167fbc91 100644 --- a/src/collections/operations/restoreVersion.ts +++ b/src/collections/operations/restoreVersion.ts @@ -143,6 +143,7 @@ async function restoreVersion(args: Arguments): Prom req, overrideAccess, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -155,6 +156,7 @@ async function restoreVersion(args: Arguments): Prom result = await hook({ req, doc: result, + context: req.context, }) || result; }, Promise.resolve()); @@ -168,6 +170,7 @@ async function restoreVersion(args: Arguments): Prom previousDoc: prevDocWithLocales, entityConfig: collectionConfig, operation: 'update', + context: req.context, req, }); @@ -183,6 +186,7 @@ async function restoreVersion(args: Arguments): Prom req, previousDoc: prevDocWithLocales, operation: 'update', + context: req.context, }) || result; }, Promise.resolve()); diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index 935f804e2..fd56f5752 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -46,6 +46,7 @@ async function update( args = (await hook({ args, operation: 'update', + context: args.req.context, })) || args; }, Promise.resolve()); @@ -149,6 +150,7 @@ async function update( req, overrideAccess: true, showHiddenFields: true, + context: req.context, }); await deleteAssociatedFiles({ config, collectionConfig, files: filesToUpload, doc: docWithLocales, t, overrideDelete: false }); @@ -165,6 +167,7 @@ async function update( operation: 'update', overrideAccess, req, + context: req.context, }); // ///////////////////////////////////// @@ -179,6 +182,7 @@ async function update( req, operation: 'update', originalDoc, + context: req.context, })) || data; }, Promise.resolve()); @@ -202,6 +206,7 @@ async function update( req, originalDoc, operation: 'update', + context: req.context, })) || data; }, Promise.resolve()); @@ -218,6 +223,7 @@ async function update( operation: 'update', req, skipValidation: shouldSaveDraft || data._status === 'draft', + context: req.context, }); // ///////////////////////////////////// @@ -275,6 +281,7 @@ async function update( req, overrideAccess, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -287,6 +294,7 @@ async function update( result = await hook({ req, doc: result, + context: req.context, }) || result; }, Promise.resolve()); @@ -301,6 +309,7 @@ async function update( entityConfig: collectionConfig, operation: 'update', req, + context: req.context, }); // ///////////////////////////////////// @@ -315,6 +324,7 @@ async function update( previousDoc: originalDoc, req, operation: 'update', + context: req.context, }) || result; }, Promise.resolve()); diff --git a/src/collections/operations/updateByID.ts b/src/collections/operations/updateByID.ts index 87f9334fc..542e13366 100644 --- a/src/collections/operations/updateByID.ts +++ b/src/collections/operations/updateByID.ts @@ -49,6 +49,7 @@ async function updateByID( args = (await hook({ args, operation: 'update', + context: args.req.context, })) || args; }, Promise.resolve()); @@ -130,6 +131,7 @@ async function updateByID( req, overrideAccess: true, showHiddenFields: true, + context: req.context, }); // ///////////////////////////////////// @@ -165,6 +167,7 @@ async function updateByID( operation: 'update', overrideAccess, req, + context: req.context, }); // ///////////////////////////////////// @@ -179,6 +182,7 @@ async function updateByID( req, operation: 'update', originalDoc, + context: req.context, })) || data; }, Promise.resolve()); @@ -202,6 +206,7 @@ async function updateByID( req, originalDoc, operation: 'update', + context: req.context, })) || data; }, Promise.resolve()); @@ -218,18 +223,19 @@ async function updateByID( operation: 'update', req, skipValidation: shouldSaveDraft || data._status === 'draft', + context: req.context, }); // ///////////////////////////////////// // Handle potential password update // ///////////////////////////////////// - const dataToUpdate: Record = { ...result } + const dataToUpdate: Record = { ...result }; if (shouldSavePassword && typeof password === 'string') { - const { hash, salt } = await generatePasswordSaltHash({ password }) - dataToUpdate.salt = salt - dataToUpdate.hash = hash + const { hash, salt } = await generatePasswordSaltHash({ password }); + dataToUpdate.salt = salt; + dataToUpdate.hash = hash; delete data.password; delete result.password; } @@ -287,6 +293,7 @@ async function updateByID( req, overrideAccess, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -299,6 +306,7 @@ async function updateByID( result = await hook({ req, doc: result, + context: req.context, }) || result; }, Promise.resolve()); @@ -313,6 +321,7 @@ async function updateByID( entityConfig: collectionConfig, operation: 'update', req, + context: req.context, }); // ///////////////////////////////////// @@ -327,6 +336,7 @@ async function updateByID( previousDoc: originalDoc, req, operation: 'update', + context: req.context, }) || result; }, Promise.resolve()); diff --git a/src/express/middleware/defaultPayload.ts b/src/express/middleware/defaultPayload.ts new file mode 100644 index 000000000..c202c39c6 --- /dev/null +++ b/src/express/middleware/defaultPayload.ts @@ -0,0 +1,10 @@ +import type { Response, NextFunction } from 'express'; +import type { PayloadRequest } from '../types'; +import { setRequestContext } from '../setRequestContext'; + +function defaultPayload(req: PayloadRequest, res: Response, next: NextFunction) { + setRequestContext(req); + next(); +} + +export default defaultPayload; diff --git a/src/express/middleware/errorHandler.ts b/src/express/middleware/errorHandler.ts index bf4d7a711..a9c150d91 100644 --- a/src/express/middleware/errorHandler.ts +++ b/src/express/middleware/errorHandler.ts @@ -21,11 +21,11 @@ const errorHandler = (config: SanitizedConfig, logger: Logger) => async (err: AP } if (req.collection && typeof req.collection.config.hooks.afterError === 'function') { - ({ response, status } = await req.collection.config.hooks.afterError(err, response) || { response, status }); + ({ response, status } = await req.collection.config.hooks.afterError(err, response, req.context) || { response, status }); } if (typeof config.hooks.afterError === 'function') { - ({ response, status } = await config.hooks.afterError(err, response) || { response, status }); + ({ response, status } = await config.hooks.afterError(err, response, req.context) || { response, status }); } res.status(status).send(response); diff --git a/src/express/middleware/index.ts b/src/express/middleware/index.ts index 8ae0ebc97..094e88589 100644 --- a/src/express/middleware/index.ts +++ b/src/express/middleware/index.ts @@ -14,6 +14,7 @@ import { PayloadRequest } from '../types'; import corsHeaders from './corsHeaders'; import convertPayload from './convertPayload'; import { i18nMiddleware } from './i18n'; +import defaultPayload from './defaultPayload'; const middleware = (payload: Payload): any => { const rateLimitOptions: { @@ -32,6 +33,7 @@ const middleware = (payload: Payload): any => { } return [ + defaultPayload, ...(payload.config.express.preMiddleware || []), rateLimit(rateLimitOptions), passport.initialize(), diff --git a/src/express/setRequestContext.ts b/src/express/setRequestContext.ts new file mode 100644 index 000000000..5f1797ff0 --- /dev/null +++ b/src/express/setRequestContext.ts @@ -0,0 +1,18 @@ +/* eslint-disable no-param-reassign */ +import type { PayloadRequest, RequestContext } from './types'; + +/** + * This makes sure that req.context always exists (is {}) and populates it with an optional default context. + * This function mutates directly to avoid copying memory. As payloadRequest is not a primitive, the scope of the mutation is not limited to this function but should also be reflected in the calling function. + */ +export function setRequestContext(req: PayloadRequest = { context: null } as PayloadRequest, context: RequestContext = {}) { + if (req.context) { + if (Object.keys(req.context).length === 0 && req.context.constructor === Object) { // check if req.context is just {} + req.context = context; // Faster - ... is bad for performance + } else { + req.context = { ...req.context, ...context }; // Merge together + } + } else { + req.context = context; + } +} diff --git a/src/express/types.ts b/src/express/types.ts index 41f15177b..a6c4037a3 100644 --- a/src/express/types.ts +++ b/src/express/types.ts @@ -24,10 +24,16 @@ export declare type PayloadRequest = Request & { * - Configuration from payload-config.ts * - MongoDB model for this collection * - GraphQL type metadata - * */ + */ collection?: Collection; /** What triggered this request */ payloadAPI?: 'REST' | 'local' | 'GraphQL'; + /** context allows you to pass your own data to the request object as context + * This is useful for, for example, passing data from a beforeChange hook to an afterChange hook. + * payoadContext can also be fully typed using declare module + * {@link https://payloadcms.com/docs/hooks/context More info in the Payload Documentation}. + */ + context: RequestContext; /** Uploaded files */ files?: { /** @@ -49,3 +55,7 @@ export declare type PayloadRequest = Request & { [slug: string]: (q: unknown) => Document; }; }; + +export interface RequestContext { + [key: string]: unknown; +} diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 80e9bcd27..6b0bdc234 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -6,7 +6,7 @@ import type { EditorProps } from '@monaco-editor/react'; import { Operation, Where } from '../../types'; import { SanitizedConfig } from '../../config/types'; import { TypeWithID } from '../../collections/config/types'; -import { PayloadRequest } from '../../express/types'; +import { PayloadRequest, RequestContext } from '../../express/types'; import { ConditionalDateProps } from '../../admin/components/elements/DatePicker/types'; import { Description } from '../../admin/components/forms/FieldDescription/types'; import { User } from '../../auth'; @@ -33,6 +33,7 @@ export type FieldHookArgs = { /** The value of the field. */ value?: P, previousValue?: P, + context: RequestContext } export type FieldHook = (args: FieldHookArgs) => Promise

| P; diff --git a/src/fields/hooks/afterChange/index.ts b/src/fields/hooks/afterChange/index.ts index 552af83a4..1c5fae6cc 100644 --- a/src/fields/hooks/afterChange/index.ts +++ b/src/fields/hooks/afterChange/index.ts @@ -1,6 +1,6 @@ import { SanitizedCollectionConfig } from '../../../collections/config/types'; import { SanitizedGlobalConfig } from '../../../globals/config/types'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import { traverseFields } from './traverseFields'; import deepCopyObject from '../../../utilities/deepCopyObject'; @@ -11,6 +11,7 @@ type Args = { entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig operation: 'create' | 'update' req: PayloadRequest + context: RequestContext } export const afterChange = async >({ @@ -20,6 +21,7 @@ export const afterChange = async >({ entityConfig, operation, req, + context, }: Args): Promise => { const doc = deepCopyObject(incomingDoc); @@ -33,6 +35,7 @@ export const afterChange = async >({ previousSiblingDoc: previousDoc, siblingDoc: doc, siblingData: data, + context, }); return doc; diff --git a/src/fields/hooks/afterChange/promise.ts b/src/fields/hooks/afterChange/promise.ts index d7e3f462f..8f204bb4b 100644 --- a/src/fields/hooks/afterChange/promise.ts +++ b/src/fields/hooks/afterChange/promise.ts @@ -1,5 +1,5 @@ /* eslint-disable no-param-reassign */ -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import { Field, fieldAffectsData, TabAsField, tabHasName } from '../../config/types'; import { traverseFields } from './traverseFields'; @@ -13,6 +13,7 @@ type Args = { req: PayloadRequest siblingData: Record siblingDoc: Record + context: RequestContext } // This function is responsible for the following actions, in order: @@ -28,6 +29,7 @@ export const promise = async ({ req, siblingData, siblingDoc, + context, }: Args): Promise => { if (fieldAffectsData(field)) { // Execute hooks @@ -45,6 +47,7 @@ export const promise = async ({ siblingData, operation, req, + context, }); if (hookedValue !== undefined) { @@ -67,6 +70,7 @@ export const promise = async ({ req, siblingData: siblingData?.[field.name] as Record || {}, siblingDoc: siblingDoc[field.name] as Record, + context, }); break; @@ -88,6 +92,7 @@ export const promise = async ({ req, siblingData: siblingData?.[field.name]?.[i] || {}, siblingDoc: { ...row } || {}, + context, })); }); await Promise.all(promises); @@ -114,6 +119,7 @@ export const promise = async ({ req, siblingData: siblingData?.[field.name]?.[i] || {}, siblingDoc: { ...row } || {}, + context, })); } }); @@ -135,6 +141,7 @@ export const promise = async ({ req, siblingData: siblingData || {}, siblingDoc: { ...siblingDoc }, + context, }); break; @@ -161,6 +168,7 @@ export const promise = async ({ previousDoc, siblingData: tabSiblingData, siblingDoc: tabSiblingDoc, + context, }); break; @@ -177,6 +185,7 @@ export const promise = async ({ req, siblingData: siblingData || {}, siblingDoc: { ...siblingDoc }, + context, }); break; } diff --git a/src/fields/hooks/afterChange/traverseFields.ts b/src/fields/hooks/afterChange/traverseFields.ts index ad6652cef..543d034c4 100644 --- a/src/fields/hooks/afterChange/traverseFields.ts +++ b/src/fields/hooks/afterChange/traverseFields.ts @@ -1,6 +1,6 @@ import { Field, TabAsField } from '../../config/types'; import { promise } from './promise'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; type Args = { data: Record @@ -12,6 +12,7 @@ type Args = { req: PayloadRequest siblingData: Record siblingDoc: Record + context: RequestContext } export const traverseFields = async ({ @@ -24,6 +25,7 @@ export const traverseFields = async ({ req, siblingData, siblingDoc, + context, }: Args): Promise => { const promises = []; @@ -38,6 +40,7 @@ export const traverseFields = async ({ req, siblingData, siblingDoc, + context, })); }); diff --git a/src/fields/hooks/afterRead/index.ts b/src/fields/hooks/afterRead/index.ts index a31a1edec..887f34057 100644 --- a/src/fields/hooks/afterRead/index.ts +++ b/src/fields/hooks/afterRead/index.ts @@ -1,6 +1,6 @@ import { SanitizedCollectionConfig } from '../../../collections/config/types'; import { SanitizedGlobalConfig } from '../../../globals/config/types'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import { traverseFields } from './traverseFields'; import deepCopyObject from '../../../utilities/deepCopyObject'; @@ -14,6 +14,7 @@ type Args = { req: PayloadRequest overrideAccess: boolean showHiddenFields: boolean + context: RequestContext } export async function afterRead(args: Args): Promise { @@ -27,6 +28,7 @@ export async function afterRead(args: Args): Promise { req, overrideAccess, showHiddenFields, + context, } = args; const doc = deepCopyObject(incomingDoc); @@ -51,6 +53,7 @@ export async function afterRead(args: Args): Promise { req, siblingDoc: doc, showHiddenFields, + context, }); await Promise.all(fieldPromises); diff --git a/src/fields/hooks/afterRead/promise.ts b/src/fields/hooks/afterRead/promise.ts index 417cf1864..e7c1e40e5 100644 --- a/src/fields/hooks/afterRead/promise.ts +++ b/src/fields/hooks/afterRead/promise.ts @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ import { Field, fieldAffectsData, TabAsField, tabHasName } from '../../config/types'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import { traverseFields } from './traverseFields'; import richTextRelationshipPromise from '../../richText/richTextRelationshipPromise'; import relationshipPopulationPromise from './relationshipPopulationPromise'; @@ -18,6 +18,7 @@ type Args = { overrideAccess: boolean siblingDoc: Record showHiddenFields: boolean + context: RequestContext } // This function is responsible for the following actions, in order: @@ -41,6 +42,7 @@ export const promise = async ({ req, siblingDoc, showHiddenFields, + context, }: Args): Promise => { if (fieldAffectsData(field) && field.hidden && typeof siblingDoc[field.name] !== 'undefined' && !showHiddenFields) { delete siblingDoc[field.name]; @@ -161,6 +163,7 @@ export const promise = async ({ siblingData: siblingDoc, operation: 'read', req, + context, }); if (hookedValue !== undefined) { @@ -178,6 +181,7 @@ export const promise = async ({ siblingData: siblingDoc, req, value: siblingDoc[field.name], + context, }); if (hookedValue !== undefined) { @@ -227,6 +231,7 @@ export const promise = async ({ req, siblingDoc: groupDoc, showHiddenFields, + context, }); break; @@ -250,6 +255,7 @@ export const promise = async ({ req, siblingDoc: row || {}, showHiddenFields, + context, }); }); } else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) { @@ -269,6 +275,7 @@ export const promise = async ({ req, siblingDoc: row || {}, showHiddenFields, + context, }); }); } @@ -298,6 +305,7 @@ export const promise = async ({ req, siblingDoc: row || {}, showHiddenFields, + context, }); } }); @@ -321,6 +329,7 @@ export const promise = async ({ req, siblingDoc: row || {}, showHiddenFields, + context, }); } }); @@ -346,6 +355,7 @@ export const promise = async ({ req, siblingDoc, showHiddenFields, + context, }); break; @@ -371,6 +381,7 @@ export const promise = async ({ req, siblingDoc: tabDoc, showHiddenFields, + context, }); break; @@ -390,6 +401,7 @@ export const promise = async ({ req, siblingDoc, showHiddenFields, + context, }); break; } diff --git a/src/fields/hooks/afterRead/traverseFields.ts b/src/fields/hooks/afterRead/traverseFields.ts index 6e6aad4ac..520347c06 100644 --- a/src/fields/hooks/afterRead/traverseFields.ts +++ b/src/fields/hooks/afterRead/traverseFields.ts @@ -1,6 +1,6 @@ import { Field, TabAsField } from '../../config/types'; import { promise } from './promise'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; type Args = { currentDepth: number @@ -15,6 +15,7 @@ type Args = { overrideAccess: boolean siblingDoc: Record showHiddenFields: boolean + context: RequestContext } export const traverseFields = ({ @@ -30,6 +31,7 @@ export const traverseFields = ({ req, siblingDoc, showHiddenFields, + context, }: Args): void => { fields.forEach((field) => { fieldPromises.push(promise({ @@ -45,6 +47,7 @@ export const traverseFields = ({ req, siblingDoc, showHiddenFields, + context, })); }); }; diff --git a/src/fields/hooks/beforeChange/index.ts b/src/fields/hooks/beforeChange/index.ts index 1d74cd87d..3a08c43db 100644 --- a/src/fields/hooks/beforeChange/index.ts +++ b/src/fields/hooks/beforeChange/index.ts @@ -1,7 +1,7 @@ import { SanitizedCollectionConfig } from '../../../collections/config/types'; import { SanitizedGlobalConfig } from '../../../globals/config/types'; import { Operation } from '../../../types'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import { traverseFields } from './traverseFields'; import { ValidationError } from '../../../errors'; import deepCopyObject from '../../../utilities/deepCopyObject'; @@ -15,6 +15,7 @@ type Args = { operation: Operation req: PayloadRequest skipValidation?: boolean + context: RequestContext } export const beforeChange = async >({ @@ -26,6 +27,7 @@ export const beforeChange = async >({ operation, req, skipValidation, + context, }: Args): Promise => { const data = deepCopyObject(incomingData); const mergeLocaleActions = []; @@ -46,6 +48,7 @@ export const beforeChange = async >({ siblingDocWithLocales: docWithLocales, fields: entityConfig.fields, skipValidation, + context, }); if (errors.length > 0) { diff --git a/src/fields/hooks/beforeChange/promise.ts b/src/fields/hooks/beforeChange/promise.ts index ec20b8c7b..5d7185b4f 100644 --- a/src/fields/hooks/beforeChange/promise.ts +++ b/src/fields/hooks/beforeChange/promise.ts @@ -2,7 +2,7 @@ import merge from 'deepmerge'; import { Field, fieldAffectsData, TabAsField, tabHasName } from '../../config/types'; import { Operation } from '../../../types'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import getValueWithDefault from '../../getDefaultValue'; import { traverseFields } from './traverseFields'; import { getExistingRowDoc } from './getExistingRowDoc'; @@ -23,6 +23,7 @@ type Args = { siblingDoc: Record siblingDocWithLocales?: Record skipValidation: boolean + context: RequestContext } // This function is responsible for the following actions, in order: @@ -49,6 +50,7 @@ export const promise = async ({ siblingDoc, siblingDocWithLocales, skipValidation, + context, }: Args): Promise => { const passesCondition = (field.admin?.condition) ? field.admin.condition(data, siblingData, { user: req.user }) : true; let skipValidationFromHere = skipValidation || !passesCondition; @@ -96,6 +98,7 @@ export const promise = async ({ siblingData, operation, req, + context, }); if (hookedValue !== undefined) { @@ -208,6 +211,7 @@ export const promise = async ({ siblingDoc: siblingDoc[field.name] as Record, siblingDocWithLocales: siblingDocWithLocales[field.name] as Record, skipValidation: skipValidationFromHere, + context, }); break; @@ -234,6 +238,7 @@ export const promise = async ({ siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]), siblingDocWithLocales: getExistingRowDoc(row, siblingDocWithLocales[field.name]), skipValidation: skipValidationFromHere, + context, })); }); @@ -267,6 +272,7 @@ export const promise = async ({ siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]), siblingDocWithLocales: getExistingRowDoc(row, siblingDocWithLocales[field.name]), skipValidation: skipValidationFromHere, + context, })); } }); @@ -294,6 +300,7 @@ export const promise = async ({ siblingDoc, siblingDocWithLocales, skipValidation: skipValidationFromHere, + context, }); break; @@ -331,6 +338,7 @@ export const promise = async ({ siblingDoc: tabSiblingDoc, siblingDocWithLocales: tabSiblingDocWithLocales, skipValidation: skipValidationFromHere, + context, }); break; @@ -352,6 +360,7 @@ export const promise = async ({ siblingDoc, siblingDocWithLocales, skipValidation: skipValidationFromHere, + context, }); break; diff --git a/src/fields/hooks/beforeChange/traverseFields.ts b/src/fields/hooks/beforeChange/traverseFields.ts index e96ea8154..42c63a7a5 100644 --- a/src/fields/hooks/beforeChange/traverseFields.ts +++ b/src/fields/hooks/beforeChange/traverseFields.ts @@ -1,7 +1,7 @@ import { Field, TabAsField } from '../../config/types'; import { promise } from './promise'; import { Operation } from '../../../types'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; type Args = { data: Record @@ -18,6 +18,7 @@ type Args = { siblingDoc: Record siblingDocWithLocales: Record skipValidation?: boolean + context: RequestContext } export const traverseFields = async ({ @@ -35,6 +36,7 @@ export const traverseFields = async ({ siblingDoc, siblingDocWithLocales, skipValidation, + context, }: Args): Promise => { const promises = []; @@ -54,6 +56,7 @@ export const traverseFields = async ({ siblingDoc, siblingDocWithLocales, skipValidation, + context, })); }); diff --git a/src/fields/hooks/beforeValidate/index.ts b/src/fields/hooks/beforeValidate/index.ts index 8735989d5..0de15a186 100644 --- a/src/fields/hooks/beforeValidate/index.ts +++ b/src/fields/hooks/beforeValidate/index.ts @@ -1,6 +1,6 @@ import { SanitizedCollectionConfig } from '../../../collections/config/types'; import { SanitizedGlobalConfig } from '../../../globals/config/types'; -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import { traverseFields } from './traverseFields'; import deepCopyObject from '../../../utilities/deepCopyObject'; @@ -12,6 +12,7 @@ type Args = { operation: 'create' | 'update' overrideAccess: boolean req: PayloadRequest + context: RequestContext } export const beforeValidate = async >({ @@ -22,6 +23,7 @@ export const beforeValidate = async >({ operation, overrideAccess, req, + context, }: Args): Promise => { const data = deepCopyObject(incomingData); @@ -35,6 +37,7 @@ export const beforeValidate = async >({ req, siblingData: data, siblingDoc: doc, + context, }); return data; diff --git a/src/fields/hooks/beforeValidate/promise.ts b/src/fields/hooks/beforeValidate/promise.ts index 929c05daf..5d6e0aa8b 100644 --- a/src/fields/hooks/beforeValidate/promise.ts +++ b/src/fields/hooks/beforeValidate/promise.ts @@ -1,5 +1,5 @@ /* eslint-disable no-param-reassign */ -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import { Field, fieldAffectsData, TabAsField, tabHasName, valueIsValueWithRelation } from '../../config/types'; import { traverseFields } from './traverseFields'; @@ -13,6 +13,7 @@ type Args = { req: PayloadRequest siblingData: Record siblingDoc: Record + context: RequestContext } // This function is responsible for the following actions, in order: @@ -30,6 +31,7 @@ export const promise = async ({ req, siblingData, siblingDoc, + context, }: Args): Promise => { if (fieldAffectsData(field)) { if (field.name === 'id') { @@ -170,6 +172,7 @@ export const promise = async ({ siblingData, operation, req, + context, }); if (hookedValue !== undefined) { @@ -207,6 +210,7 @@ export const promise = async ({ req, siblingData: groupData, siblingDoc: groupDoc, + context, }); break; @@ -228,6 +232,7 @@ export const promise = async ({ req, siblingData: row, siblingDoc: siblingDoc[field.name]?.[i] || {}, + context, })); }); await Promise.all(promises); @@ -254,6 +259,7 @@ export const promise = async ({ req, siblingData: row, siblingDoc: siblingDoc[field.name]?.[i] || {}, + context, })); } }); @@ -275,6 +281,7 @@ export const promise = async ({ req, siblingData, siblingDoc, + context, }); break; @@ -301,6 +308,7 @@ export const promise = async ({ req, siblingData: tabSiblingData, siblingDoc: tabSiblingDoc, + context, }); break; @@ -317,6 +325,7 @@ export const promise = async ({ req, siblingData, siblingDoc, + context, }); break; diff --git a/src/fields/hooks/beforeValidate/traverseFields.ts b/src/fields/hooks/beforeValidate/traverseFields.ts index 3e2174a82..68e4a20e6 100644 --- a/src/fields/hooks/beforeValidate/traverseFields.ts +++ b/src/fields/hooks/beforeValidate/traverseFields.ts @@ -1,4 +1,4 @@ -import { PayloadRequest } from '../../../express/types'; +import { PayloadRequest, RequestContext } from '../../../express/types'; import { Field, TabAsField } from '../../config/types'; import { promise } from './promise'; @@ -12,6 +12,7 @@ type Args = { req: PayloadRequest siblingData: Record siblingDoc: Record + context: RequestContext } export const traverseFields = async ({ @@ -24,6 +25,7 @@ export const traverseFields = async ({ req, siblingData, siblingDoc, + context, }: Args): Promise => { const promises = []; fields.forEach((field) => { @@ -37,6 +39,7 @@ export const traverseFields = async ({ req, siblingData, siblingDoc, + context, })); }); await Promise.all(promises); diff --git a/src/globals/operations/findOne.ts b/src/globals/operations/findOne.ts index b91c904a8..997c9e903 100644 --- a/src/globals/operations/findOne.ts +++ b/src/globals/operations/findOne.ts @@ -112,6 +112,7 @@ async function findOne>(args: Args): Promise = any>(args: Argumen req, overrideAccess, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// diff --git a/src/globals/operations/findVersions.ts b/src/globals/operations/findVersions.ts index 08fb44027..d21996542 100644 --- a/src/globals/operations/findVersions.ts +++ b/src/globals/operations/findVersions.ts @@ -107,6 +107,7 @@ async function findVersions>( overrideAccess, showHiddenFields, findMany: true, + context: req.context, }), }))), } as PaginatedDocs; diff --git a/src/globals/operations/local/findOne.ts b/src/globals/operations/local/findOne.ts index 1f9abd953..92f643348 100644 --- a/src/globals/operations/local/findOne.ts +++ b/src/globals/operations/local/findOne.ts @@ -6,6 +6,7 @@ import { Document } from '../../../types'; import findOne from '../findOne'; import i18nInit from '../../../translations/init'; import { APIError } from '../../../errors'; +import { setRequestContext } from '../../../express/setRequestContext'; export type Options = { slug: T @@ -50,6 +51,7 @@ export default async function findOneLocal = { slug: T @@ -52,6 +53,7 @@ export default async function findVersionByIDLocal = { slug: T @@ -57,6 +58,7 @@ export default async function findVersionsLocal = { slug: string @@ -49,6 +50,7 @@ export default async function restoreVersionLocal = { slug: TSlug @@ -52,6 +53,7 @@ export default async function updateLocal = any>(args: Argument req, overrideAccess, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -132,6 +133,7 @@ async function restoreVersion = any>(args: Argument entityConfig: globalConfig, operation: 'update', req, + context: req.context, }); // ///////////////////////////////////// diff --git a/src/globals/operations/update.ts b/src/globals/operations/update.ts index f0f2c737c..d96c95a3c 100644 --- a/src/globals/operations/update.ts +++ b/src/globals/operations/update.ts @@ -100,6 +100,7 @@ async function update( req, overrideAccess: true, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -113,6 +114,7 @@ async function update( operation: 'update', overrideAccess, req, + context: req.context, }); // ///////////////////////////////////// @@ -155,6 +157,7 @@ async function update( operation: 'update', req, skipValidation: shouldSaveDraft, + context: req.context, }); // ///////////////////////////////////// @@ -207,6 +210,7 @@ async function update( req, overrideAccess, showHiddenFields, + context: req.context, }); // ///////////////////////////////////// @@ -232,6 +236,7 @@ async function update( previousDoc: originalDoc, entityConfig: globalConfig, operation: 'update', + context: req.context, req, }); diff --git a/src/graphql/errorHandler.ts b/src/graphql/errorHandler.ts index 32f5ddc33..bcde4efeb 100644 --- a/src/graphql/errorHandler.ts +++ b/src/graphql/errorHandler.ts @@ -22,7 +22,7 @@ const errorHandler = async ( }; if (afterErrorHook) { - ({ response } = await afterErrorHook(err, response) || { response }); + ({ response } = await afterErrorHook(err, response, null) || { response }); } return response; diff --git a/src/index.ts b/src/index.ts index 325d1cab9..b5fc5c429 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ import { InitOptions } from './config/types'; import { initHTTP } from './initHTTP'; import { Payload as LocalPayload, BasePayload } from './payload'; +import type { RequestContext } from './express/types'; + export { getPayload } from './payload'; require('isomorphic-fetch'); @@ -25,3 +27,5 @@ const payload = new Payload(); export default payload; module.exports = payload; +// Export RequestContext type +export type { RequestContext }; diff --git a/test/hooks/collections/ContextHooks/index.ts b/test/hooks/collections/ContextHooks/index.ts new file mode 100644 index 000000000..f1a4c1b3f --- /dev/null +++ b/test/hooks/collections/ContextHooks/index.ts @@ -0,0 +1,68 @@ +/* eslint-disable no-param-reassign */ +import payload from '../../../../src'; +import { CollectionConfig } from '../../../../src/collections/config/types'; +import type { PayloadRequest } from '../../../../src/types'; + +export const contextHooksSlug = 'context-hooks'; +const ContextHooks: CollectionConfig = { + slug: contextHooksSlug, + access: { + read: () => true, + create: () => true, + delete: () => true, + update: () => true, + }, + hooks: { + beforeOperation: [async ({ context, args }) => { + // eslint-disable-next-line prefer-destructuring + const req: PayloadRequest = args.req; + + if (!req.query || !Object.keys(req.query).length) { + return args; + } + + Object.keys(req.query).forEach((key) => { + if (key.startsWith('context_')) { + // Strip 'context_' from key, add it to context object and remove it from query params + const newKey = key.substring('context_'.length); + context[newKey] = req.query[key]; + delete req.query[key]; + } + }); + + return args; + }], + beforeChange: [({ context, data, req }) => { + if (!context.secretValue) { + context.secretValue = 'secret'; + } + if (req.context !== context) { + throw new Error('req.context !== context'); + } + return data; + }], + afterChange: [async ({ context, doc }) => { + if (context.triggerAfterChange === false) { // Make sure we don't trigger afterChange again and again in an infinite loop + return; + } + await payload.update({ + collection: contextHooksSlug, + id: doc.id, + data: { + value: context.secretValue ?? '', + }, + context: { + triggerAfterChange: false, // Make sure we don't trigger afterChange again and again in an infinite loop. This should be done via context and not a potential disableHooks property, as we want to specifically test the context functionality here + }, + }); + }], + }, + fields: [ + { + name: 'value', + type: 'text', + }, + ], +}; + +export default ContextHooks; diff --git a/test/hooks/config.ts b/test/hooks/config.ts index 9586c7d3e..d36e9ce36 100644 --- a/test/hooks/config.ts +++ b/test/hooks/config.ts @@ -5,9 +5,11 @@ import NestedAfterReadHooks from './collections/NestedAfterReadHooks'; import ChainingHooks from './collections/ChainingHooks'; import Relations from './collections/Relations'; import Users, { seedHooksUsers } from './collections/Users'; +import ContextHooks from './collections/ContextHooks'; export default buildConfigWithDefaults({ collections: [ + ContextHooks, TransformHooks, Hooks, NestedAfterReadHooks, diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index 01db8ab10..d65909b9f 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -12,13 +12,16 @@ import type { NestedAfterReadHook } from './payload-types'; import { hooksUsersSlug } from './collections/Users'; import { devUser, regularUser } from '../credentials'; import { AuthenticationError } from '../../src/errors'; +import { contextHooksSlug } from './collections/ContextHooks'; let client: RESTClient; +let apiUrl; describe('Hooks', () => { beforeAll(async () => { const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } }); client = new RESTClient(config, { serverURL, defaultSlug: transformSlug }); + apiUrl = `${serverURL}/api`; }); afterAll(async () => { @@ -151,6 +154,63 @@ describe('Hooks', () => { expect(retrievedDocs[0].text).toEqual('ok!!'); }); + + it('should pass context from beforeChange to afterChange', async () => { + const document = await payload.create({ + collection: contextHooksSlug, + data: { + value: 'wrongvalue', + }, + }); + + const retrievedDoc = await payload.findByID({ + collection: contextHooksSlug, + id: document.id, + }); + + expect(retrievedDoc.value).toEqual('secret'); + }); + + it('should pass context from local API to hooks', async () => { + const document = await payload.create({ + collection: contextHooksSlug, + data: { + value: 'wrongvalue', + }, + context: { + secretValue: 'data from local API', + }, + }); + + const retrievedDoc = await payload.findByID({ + collection: contextHooksSlug, + id: document.id, + }); + + expect(retrievedDoc.value).toEqual('data from local API'); + }); + + it('should pass context from rest API to hooks', async () => { + const params = new URLSearchParams({ + context_secretValue: 'data from rest API', + }); + // send context as query params. It will be parsed by the beforeOperation hook + const response = await fetch(`${apiUrl}/${contextHooksSlug}?${params.toString()}`, { + body: JSON.stringify({ + value: 'wrongvalue', + }), + method: 'post', + }); + + const document = (await response.json()).doc; + + const retrievedDoc = await payload.findByID({ + collection: contextHooksSlug, + id: document.id, + }); + + expect(retrievedDoc.value).toEqual('data from rest API'); + }); }); describe('auth collection hooks', () => {