Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Allow intercepting evaluations #58

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pink-rings-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vercel/flags': minor
---

allow intercepting flag evaluations
200 changes: 200 additions & 0 deletions packages/flags/src/next/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>({
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<number>({
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<number>({
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', () => {
Expand Down Expand Up @@ -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<number>({
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<number>({
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<number>({
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', () => {
Expand Down
19 changes: 14 additions & 5 deletions packages/flags/src/next/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,12 +282,21 @@ function getRun<ValueType, EntitiesType>(
// fall back to defaultValue if it is set,
let decisionPromise: Promise<ValueType> | ValueType;
try {
const decideOptions = {
headers: readonlyHeaders,
cookies: readonlyCookies,
entities,
};

decisionPromise = Promise.resolve<ValueType>(
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<ValueType, ValueType>(
Expand Down
26 changes: 26 additions & 0 deletions packages/flags/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,32 @@ export type FlagDeclaration<ValueType, EntitiesType> = {
* This function can establish entities which the `decide` function will be called with.
*/
identify?: Identify<EntitiesType>;
/**
* 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<ValueType>,
) => Promise<ValueType>;
} & (
| {
adapter: Adapter<ValueType, EntitiesType>;
Expand Down
Loading