Skip to content

Commit 6c1a685

Browse files
authored
Merge pull request #171 from ForgeRock/feat_network-interceptors
feat(davinci-client): introduce request middleware
2 parents f88a77c + ce0d18f commit 6c1a685

17 files changed

Lines changed: 701 additions & 25 deletions

.changeset/fifty-berries-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@forgerock/davinci-client': minor
3+
---
4+
5+
Introduce request middleware feature to DaVinci Client

e2e/davinci-app/main.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import './style.css';
22
import { Config, FRUser, TokenManager } from '@forgerock/javascript-sdk';
33
import { davinci } from '@forgerock/davinci-client';
44

5-
import type { DaVinciConfig } from '@forgerock/davinci-client/types';
5+
import type { DaVinciConfig, RequestMiddleware } from '@forgerock/davinci-client/types';
66

77
import usernameComponent from './components/text.js';
88
import passwordComponent from './components/password.js';
@@ -18,10 +18,27 @@ const searchParams = new URLSearchParams(qs);
1818
const config: DaVinciConfig =
1919
serverConfigs[searchParams.get('clientId') || '724ec718-c41c-4d51-98b0-84a583f450f9'];
2020

21+
const requestMiddleware: RequestMiddleware[] = [
22+
(fetchArgs, action, next) => {
23+
if (action.type === 'DAVINCI_START') {
24+
fetchArgs.url.searchParams.set('start', 'true');
25+
fetchArgs.headers?.set('Accept-Language', 'xx-XX');
26+
}
27+
next();
28+
},
29+
(fetchArgs, action, next) => {
30+
if (action.type === 'DAVINCI_NEXT') {
31+
fetchArgs.url.searchParams.set('next', 'true');
32+
fetchArgs.headers?.set('Accept-Language', 'zz-ZZ');
33+
}
34+
next();
35+
},
36+
];
37+
2138
const urlParams = new URLSearchParams(window.location.search);
2239

2340
(async () => {
24-
const davinciClient = await davinci({ config });
41+
const davinciClient = await davinci({ config, requestMiddleware });
2542
const continueToken = urlParams.get('continueToken');
2643
const formEl = document.getElementById('form') as HTMLFormElement;
2744
let resumed: any;
@@ -194,7 +211,7 @@ const urlParams = new URLSearchParams(window.location.search);
194211
* It returns an unsubscribe function that you can call to stop listening
195212
*/
196213
davinciClient.subscribe(() => {
197-
const node = davinciClient.getClient();
214+
const node = davinciClient.getNode();
198215
console.log('Event emitted from store:', node);
199216
});
200217

e2e/davinci-suites/src/basic.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test } from '@playwright/test';
22
import { asyncEvents } from './utils/async-events.js';
3+
import { password, username } from './utils/demo-user.js';
34

45
test('Test happy paths on test page', async ({ page }) => {
56
const { navigate } = asyncEvents(page);
@@ -9,8 +10,8 @@ test('Test happy paths on test page', async ({ page }) => {
910

1011
await expect(page.getByText('Username/Password Form')).toBeVisible();
1112

12-
await page.getByLabel('Username').fill('demouser');
13-
await page.getByLabel('Password').fill('U.CDmhGLK*nrQPDWEN47ZMyJh');
13+
await page.getByLabel('Username').fill(username);
14+
await page.getByLabel('Password').fill(password);
1415

1516
await page.getByRole('button', { name: 'Sign On' }).click();
1617

@@ -68,8 +69,8 @@ test('ensure query params passed to start are sent off in authorize call', async
6869

6970
await expect(page.getByText('Username/Password Form')).toBeVisible();
7071

71-
await page.getByLabel('Username').fill('demouser');
72-
await page.getByLabel('Password').fill('U.CDmhGLK*nrQPDWEN47ZMyJh');
72+
await page.getByLabel('Username').fill(username);
73+
await page.getByLabel('Password').fill(password);
7374

7475
await page.getByText('Sign On').click();
7576

e2e/davinci-suites/src/error.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ test('Test happy paths on test page', async ({ page }) => {
99

1010
await expect(page.getByText('Username/Password Form')).toBeVisible();
1111

12-
await page.getByLabel('Username').fill('demouser');
12+
await page.getByLabel('Username').fill('baduser');
1313
await page.getByLabel('Password').fill('badpassword');
1414

1515
await page.getByText('Sign On').click();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect, test } from '@playwright/test';
2+
import { asyncEvents } from './utils/async-events.js';
3+
4+
test('Test middleware on test page', async ({ page }) => {
5+
const networkArray = [];
6+
page.on('request', async (req) => {
7+
const url = req.url().toString();
8+
const langHeader = await req.headerValue('Accept-Language');
9+
if (url.includes('https://auth.pingone.ca')) {
10+
networkArray.push({ url, langHeader });
11+
}
12+
});
13+
14+
const { navigate } = asyncEvents(page);
15+
await navigate('/');
16+
17+
expect(page.url()).toBe('http://localhost:5829/');
18+
19+
await expect(page.getByText('Username/Password Form')).toBeVisible();
20+
21+
const startRequest = networkArray.find((req) => req.url.includes('/authorize'));
22+
const nextRequest = networkArray.find((req) => req.url.includes('/customHTMLTemplate'));
23+
24+
// Check for addition of query params
25+
await expect(startRequest.url.includes('start=true')).toBeTruthy();
26+
await expect(startRequest.url.includes('next=true')).toBeFalsy();
27+
await expect(nextRequest.url.includes('next=true')).toBeTruthy();
28+
await expect(nextRequest.url.includes('start=true')).toBeFalsy();
29+
30+
// Check that Accept-Language header was modified from default en-US locale
31+
await expect(startRequest.langHeader).not.toContain('en-US');
32+
await expect(startRequest.langHeader).toBe('xx-XX');
33+
await expect(nextRequest.langHeader).not.toContain('en-US');
34+
await expect(nextRequest.langHeader).toBe('zz-ZZ');
35+
});

e2e/davinci-suites/src/register.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test } from '@playwright/test';
22
import { asyncEvents } from './utils/async-events.js';
3+
import { password } from './utils/demo-user.js';
34

45
test('Test happy paths on test page', async ({ page }) => {
56
const { navigate } = asyncEvents(page);
@@ -18,7 +19,7 @@ test('Test happy paths on test page', async ({ page }) => {
1819
await page.getByLabel('First Name').fill('Bruce');
1920
await page.getByLabel('Last Name').fill('Wayne');
2021
await page.getByLabel('Email').fill(`${randomEmailPrefix}@autogenerated.com`);
21-
await page.getByLabel('Password').fill('U.CDmhGLK*nrQPDWEN47ZMyJh');
22+
await page.getByLabel('Password').fill(password);
2223

2324
await page.getByText('Save').click();
2425

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const username = 'demouser';
2+
export const password = 'U.QPDWEN47ZMyJhCDmhGLK*nr';

packages/davinci-client/src/lib/client.store.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { SingleValueCollectors, IdpCollector } from './collector.types.js';
2121
import type { InitFlow, Updater } from './client.types.js';
2222
import { returnValidator } from './collector.utils.js';
2323
import { authorize } from './davinci.utils.js';
24+
import type { RequestMiddleware } from './effects/request.effect.types.js';
2425

2526
/**
2627
* Create a client function that returns a set of methods
@@ -30,8 +31,14 @@ import { authorize } from './davinci.utils.js';
3031
* @param {ConfigurationOptions} options - the configuration options for the client
3132
* @returns {Observable} - an observable client for DaVinci flows
3233
*/
33-
export async function davinci({ config }: { config: DaVinciConfig }) {
34-
const store = createClientStore();
34+
export async function davinci({
35+
config,
36+
requestMiddleware,
37+
}: {
38+
config: DaVinciConfig;
39+
requestMiddleware?: RequestMiddleware[];
40+
}) {
41+
const store = createClientStore({ requestMiddleware });
3542

3643
if (!config.serverConfig.wellknown) {
3744
throw new Error('`wellknown` property is a required as part of the `config.serverOptions`');

packages/davinci-client/src/lib/client.store.utils.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { davinciApi } from './davinci.api.js';
66
import { ErrorNode, ContinueNode, StartNode, SuccessNode } from '../types.js';
77
import { wellknownApi } from './wellknown.api.js';
88

9-
export function createClientStore() {
9+
import type { RequestMiddleware } from './effects/request.effect.types.js';
10+
11+
export function createClientStore({
12+
requestMiddleware,
13+
}: {
14+
requestMiddleware?: RequestMiddleware[];
15+
}) {
1016
return configureStore({
1117
reducer: {
1218
config: configSlice.reducer,
@@ -15,7 +21,19 @@ export function createClientStore() {
1521
[wellknownApi.reducerPath]: wellknownApi.reducer,
1622
},
1723
middleware: (getDefaultMiddleware) =>
18-
getDefaultMiddleware().concat(davinciApi.middleware).concat(wellknownApi.middleware),
24+
getDefaultMiddleware({
25+
thunk: {
26+
extraArgument: {
27+
/**
28+
* This becomes the `api.extra` argument, and will be passed into the
29+
* customer query wrapper for `baseQuery`
30+
*/
31+
requestMiddleware: requestMiddleware,
32+
},
33+
},
34+
})
35+
.concat(davinciApi.middleware)
36+
.concat(wellknownApi.middleware),
1937
});
2038
}
2139

packages/davinci-client/src/lib/davinci.api.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22
* Import the RTK Query library from Redux Toolkit
33
* @see https://redux-toolkit.js.org/rtk-query/overview
44
*/
5-
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
5+
import {
6+
createApi,
7+
FetchArgs,
8+
fetchBaseQuery,
9+
FetchBaseQueryError,
10+
FetchBaseQueryMeta,
11+
QueryReturnValue,
12+
} from '@reduxjs/toolkit/query';
613

714
/**
815
* Import internal modules
@@ -22,13 +29,24 @@ import type {
2229
} from './davinci.types.js';
2330
import type { ContinueNode } from './node.types.js';
2431
import type { StartNode } from '../types.js';
32+
import { initQuery } from './effects/request.effect.utils.js';
33+
import { RequestMiddleware } from './effects/request.effect.types.js';
34+
35+
type BaseQueryResponse = Promise<
36+
QueryReturnValue<unknown, FetchBaseQueryError, FetchBaseQueryMeta>
37+
>;
38+
39+
interface Extras {
40+
requestMiddleware: RequestMiddleware[];
41+
}
2542

2643
/**
2744
* @const davinciApi - Define the DaVinci API for Redux state management
2845
* @see https://redux-toolkit.js.org/rtk-query/overview
2946
*/
3047
export const davinciApi = createApi({
3148
reducerPath: 'davinci',
49+
// TODO: implement extraOptions for request interceptors: https://stackoverflow.com/a/77569083 & https://stackoverflow.com/a/65129117
3250
baseQuery: fetchBaseQuery({
3351
prepareHeaders: (headers) => {
3452
headers.set('Accept', 'application/json');
@@ -49,14 +67,14 @@ export const davinciApi = createApi({
4967
const state = api.getState() as RootStateWithNode<ContinueNode>;
5068
const links = state.node.server._links;
5169
const requestBody = transformActionRequest(state.node, params.action);
70+
const requestMiddleware = (api.extra as Extras).requestMiddleware;
5271

5372
let href = '';
5473

5574
if (links && 'next' in links) {
5675
href = links['next'].href || '';
5776
}
58-
59-
const response = await baseQuery({
77+
const request: FetchArgs = {
6078
// TODO: If we don't have a `next.href`, we should handle this better
6179
url: href,
6280
credentials: 'include',
@@ -67,7 +85,10 @@ export const davinciApi = createApi({
6785
interactionToken: state.node.server.interactionToken,
6886
},
6987
body: JSON.stringify(requestBody),
70-
});
88+
};
89+
const response: BaseQueryResponse = initQuery(request, 'flow')
90+
.applyMiddleware(requestMiddleware)
91+
.applyQuery(async (req: FetchArgs) => await baseQuery(req));
7192

7293
/**
7394
* Returns the original response from DaVinci,
@@ -120,6 +141,7 @@ export const davinciApi = createApi({
120141
async queryFn(body, api, __, baseQuery) {
121142
const state = api.getState() as RootStateWithNode<ContinueNode>;
122143
const links = state.node.server._links;
144+
const requestMiddleware = (api.extra as Extras).requestMiddleware;
123145

124146
let requestBody;
125147
let href = '';
@@ -134,7 +156,7 @@ export const davinciApi = createApi({
134156
requestBody = body;
135157
}
136158

137-
const response = await baseQuery({
159+
const request: FetchArgs = {
138160
url: href,
139161
credentials: 'include',
140162
method: 'POST',
@@ -144,7 +166,10 @@ export const davinciApi = createApi({
144166
interactionToken: state.node.server.interactionToken,
145167
},
146168
body: JSON.stringify(requestBody),
147-
});
169+
};
170+
const response: BaseQueryResponse = initQuery(request, 'next')
171+
.applyMiddleware(requestMiddleware)
172+
.applyQuery(async (req: FetchArgs) => await baseQuery(req));
148173

149174
/**
150175
* Returns the original response from DaVinci,
@@ -196,6 +221,7 @@ export const davinciApi = createApi({
196221
* @method queryFn - This is just a wrapper around the fetch call
197222
*/
198223
async queryFn(options, api, __, baseQuery) {
224+
const requestMiddleware = (api.extra as Extras).requestMiddleware;
199225
const state = api.getState() as RootStateWithNode<StartNode>;
200226

201227
if (!state) {
@@ -236,14 +262,17 @@ export const davinciApi = createApi({
236262
url.search = existingParams.toString();
237263
}
238264

239-
const response = await baseQuery({
265+
const request: FetchArgs = {
240266
url: url.toString(),
241267
credentials: 'include',
242268
method: 'GET',
243269
headers: {
244270
'Content-Type': 'application/x-www-form-urlencoded',
245271
},
246-
});
272+
};
273+
const response: BaseQueryResponse = initQuery(request, 'start')
274+
.applyMiddleware(requestMiddleware)
275+
.applyQuery(async (req: FetchArgs) => await baseQuery(req));
247276

248277
/**
249278
* Returns the original response from DaVinci,
@@ -291,8 +320,9 @@ export const davinciApi = createApi({
291320
},
292321
}),
293322
resume: builder.query<unknown, { continueToken: string }>({
294-
async queryFn({ continueToken }, _api, _c, baseQuery) {
323+
async queryFn({ continueToken }, api, _c, baseQuery) {
295324
const continueUrl = window.localStorage.getItem('continueUrl') || null;
325+
const requestMiddleware = (api.extra as Extras).requestMiddleware;
296326

297327
if (!continueToken) {
298328
return {
@@ -319,7 +349,7 @@ export const davinciApi = createApi({
319349
window.localStorage.removeItem('continueUrl');
320350
}
321351

322-
const response = await baseQuery({
352+
const request: FetchArgs = {
323353
url: continueUrl,
324354
credentials: 'include',
325355
method: 'POST',
@@ -328,7 +358,10 @@ export const davinciApi = createApi({
328358
Authorization: `Bearer ${continueToken}`,
329359
},
330360
body: JSON.stringify({}),
331-
});
361+
};
362+
const response: BaseQueryResponse = initQuery(request, 'resume')
363+
.applyMiddleware(requestMiddleware)
364+
.applyQuery(async (req: FetchArgs) => await baseQuery(req));
332365

333366
return response;
334367
},
@@ -349,7 +382,6 @@ export const davinciApi = createApi({
349382
}
350383

351384
const cacheEntry: DaVinciCacheEntry = api.getCacheEntry();
352-
console.log('resumed handling repsonse');
353385
handleResponse(cacheEntry, api.dispatch, response?.status || 0);
354386
},
355387
}),

0 commit comments

Comments
 (0)