From 169d0737f8c993f2fddab67bf2f3abd936efb830 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 5 Feb 2025 14:16:28 +0200 Subject: [PATCH] add intercept function --- .changeset/pink-rings-dream.md | 5 + packages/flags/src/next/index.test.ts | 200 ++++++++++++++++++++++++++ packages/flags/src/next/index.ts | 19 ++- packages/flags/src/types.ts | 26 ++++ 4 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 .changeset/pink-rings-dream.md diff --git a/.changeset/pink-rings-dream.md b/.changeset/pink-rings-dream.md new file mode 100644 index 0000000..324a206 --- /dev/null +++ b/.changeset/pink-rings-dream.md @@ -0,0 +1,5 @@ +--- +'@vercel/flags': minor +--- + +allow intercepting flag evaluations diff --git a/packages/flags/src/next/index.test.ts b/packages/flags/src/next/index.test.ts index 9396746..41fb21a 100644 --- a/packages/flags/src/next/index.test.ts +++ b/packages/flags/src/next/index.test.ts @@ -231,6 +231,99 @@ describe('flag on app router', () => { '@vercel/flags: Flag "async-flag" must have a defaultValue or a decide function that returns a value', ); }); + + describe('intercept', () => { + it('allows intercepting without calling decide', async () => { + const decide = vi.fn(() => 0); + const f = flag({ + key: 'first-flag', + decide, + intercept: async () => 3, + }); + + // first request using the flag + const headersOfFirstRequest = new Headers(); + mocks.headers.mockReturnValueOnce(headersOfFirstRequest); + await expect(f()).resolves.toEqual(3); + + // ensure decide did not run + expect(decide).not.toHaveBeenCalled(); + }); + + it('allows forwarding options', async () => { + const decide = vi.fn(() => 0); + const intercept = vi.fn((options, next) => next(options)); + const f = flag({ + key: 'first-flag', + decide, + identify: () => ({ user: 'test-user' }), + intercept, + }); + + // first request using the flag + const headersOfFirstRequest = new Headers(); + mocks.headers.mockReturnValueOnce(headersOfFirstRequest); + await expect(f()).resolves.toEqual(0); + + // ensure intercept was called with the correct options + expect(intercept).toHaveBeenCalledWith( + expect.objectContaining({ + cookies: expect.objectContaining({}), + headers: headersOfFirstRequest, + entities: expect.objectContaining({ user: 'test-user' }), + }), + expect.any(Function), + ); + + // ensure decide ran + expect(decide).toHaveBeenCalledTimes(1); + expect(decide).toHaveBeenCalledWith( + expect.objectContaining({ + cookies: expect.objectContaining({}), + headers: headersOfFirstRequest, + entities: expect.objectContaining({ user: 'test-user' }), + }), + ); + }); + + it('allows intercepting options', async () => { + const decide = vi.fn(() => 0); + const intercept = vi.fn((options, next) => + next({ ...options, entities: { user: 'other-user' } }), + ); + const f = flag({ + key: 'first-flag', + decide, + identify: () => ({ user: 'test-user' }), + intercept, + }); + + // first request using the flag + const headersOfFirstRequest = new Headers(); + mocks.headers.mockReturnValueOnce(headersOfFirstRequest); + await expect(f()).resolves.toEqual(0); + + // ensure intercept was called with the correct options + expect(intercept).toHaveBeenCalledWith( + expect.objectContaining({ + cookies: expect.objectContaining({}), + headers: headersOfFirstRequest, + entities: expect.objectContaining({ user: 'test-user' }), + }), + expect.any(Function), + ); + + // ensure decide ran with the intercepted options + expect(decide).toHaveBeenCalledTimes(1); + expect(decide).toHaveBeenCalledWith( + expect.objectContaining({ + cookies: expect.objectContaining({}), + headers: headersOfFirstRequest, + entities: expect.objectContaining({ user: 'other-user' }), + }), + ); + }); + }); }); describe('flag on pages router', () => { @@ -433,6 +526,113 @@ describe('flag on pages router', () => { expect(catchFn).not.toHaveBeenCalled(); expect(mockDecide).toHaveBeenCalledTimes(1); }); + + describe('intercept', () => { + it('allows intercepting without calling decide', async () => { + const decide = vi.fn(() => 0); + const f = flag({ + key: 'first-flag', + decide, + intercept: async () => 3, + }); + + // first request using the flag + const headersOfFirstRequest = new Headers(); + mocks.headers.mockReturnValueOnce(headersOfFirstRequest); + + const fakeRequest = { + headers: {}, + cookies: {}, + } as unknown as IncomingMessage & { cookies: NextApiRequestCookies }; + + await expect(f(fakeRequest)).resolves.toEqual(3); + + // ensure decide did not run + expect(decide).not.toHaveBeenCalled(); + }); + + it('allows forwarding options', async () => { + const decide = vi.fn(() => 0); + const intercept = vi.fn((options, next) => next(options)); + const f = flag({ + key: 'first-flag', + decide, + identify: () => ({ user: 'test-user' }), + intercept, + }); + + // first request using the flag + const headersOfFirstRequest = new Headers(); + mocks.headers.mockReturnValueOnce(headersOfFirstRequest); + const fakeRequest = { + headers: {}, + cookies: {}, + } as unknown as IncomingMessage & { cookies: NextApiRequestCookies }; + await expect(f(fakeRequest)).resolves.toEqual(0); + + // ensure intercept was called with the correct options + expect(intercept).toHaveBeenCalledWith( + expect.objectContaining({ + cookies: expect.objectContaining({}), + headers: headersOfFirstRequest, + entities: expect.objectContaining({ user: 'test-user' }), + }), + expect.any(Function), + ); + + // ensure decide ran + expect(decide).toHaveBeenCalledTimes(1); + expect(decide).toHaveBeenCalledWith( + expect.objectContaining({ + cookies: expect.objectContaining({}), + headers: headersOfFirstRequest, + entities: expect.objectContaining({ user: 'test-user' }), + }), + ); + }); + + it('allows intercepting options', async () => { + const decide = vi.fn(() => 0); + const intercept = vi.fn((options, next) => + next({ ...options, entities: { user: 'other-user' } }), + ); + const f = flag({ + key: 'first-flag', + decide, + identify: () => ({ user: 'test-user' }), + intercept, + }); + + // first request using the flag + const headersOfFirstRequest = new Headers(); + mocks.headers.mockReturnValueOnce(headersOfFirstRequest); + const fakeRequest = { + headers: {}, + cookies: {}, + } as unknown as IncomingMessage & { cookies: NextApiRequestCookies }; + await expect(f(fakeRequest)).resolves.toEqual(0); + + // ensure intercept was called with the correct options + expect(intercept).toHaveBeenCalledWith( + expect.objectContaining({ + cookies: expect.objectContaining({}), + headers: headersOfFirstRequest, + entities: expect.objectContaining({ user: 'test-user' }), + }), + expect.any(Function), + ); + + // ensure decide ran with the intercepted options + expect(decide).toHaveBeenCalledTimes(1); + expect(decide).toHaveBeenCalledWith( + expect.objectContaining({ + cookies: expect.objectContaining({}), + headers: headersOfFirstRequest, + entities: expect.objectContaining({ user: 'other-user' }), + }), + ); + }); + }); }); describe('dynamic io', () => { diff --git a/packages/flags/src/next/index.ts b/packages/flags/src/next/index.ts index 74b350b..517146d 100644 --- a/packages/flags/src/next/index.ts +++ b/packages/flags/src/next/index.ts @@ -282,12 +282,21 @@ function getRun( // fall back to defaultValue if it is set, let decisionPromise: Promise | ValueType; try { + const decideOptions = { + headers: readonlyHeaders, + cookies: readonlyCookies, + entities, + }; + decisionPromise = Promise.resolve( - decide({ - headers: readonlyHeaders, - cookies: readonlyCookies, - entities, - }), + definition.intercept + ? definition.intercept( + decideOptions, + // creats the next() function and ensures it is always async to + // simplify the types + async (interceptedOptions) => decide(interceptedOptions), + ) + : decide(decideOptions), ) // catch errors in async "decide" functions .then( diff --git a/packages/flags/src/types.ts b/packages/flags/src/types.ts index bf9ad8e..0cd231b 100644 --- a/packages/flags/src/types.ts +++ b/packages/flags/src/types.ts @@ -205,6 +205,32 @@ export type FlagDeclaration = { * This function can establish entities which the `decide` function will be called with. */ identify?: Identify; + /** + * This function can be used to intercept the call to the `decide` function or adapters, and post-process the result. + * + * Intercepting allows you to + * - act before `decide()` or adapters are called + * - handle any errors that occur in decide() or adapters + * - post-process the result + * + * If intercept itself throws, the Flags SDK will handle it by + * - falling back to the default value if it's set + * - otherwise re-throwing the error + * + * The default intercept implementation is: + * + * ``` + * async intercept(options, next) { + * return next(options) + * } + * ``` + */ + intercept?: ( + options: FlagParamsType & { entities?: EntitiesType }, + next: ( + options: FlagParamsType & { entities?: EntitiesType }, + ) => Promise, + ) => Promise; } & ( | { adapter: Adapter;