Skip to content

Commit 5429843

Browse files
committed
feat(davinci-client): add continue polling support
1 parent 3113110 commit 5429843

4 files changed

Lines changed: 244 additions & 13 deletions

File tree

e2e/davinci-app/components/polling.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
66
*/
7-
import {
7+
import type {
88
PollingCollector,
99
PollingStatus,
1010
InternalErrorResponse,
1111
Updater,
12+
ContinueNode,
1213
} from '@forgerock/davinci-client/types';
1314

1415
export default function pollingComponent(
1516
formEl: HTMLFormElement,
1617
collector: PollingCollector,
17-
poll: (collector: PollingCollector) => Promise<PollingStatus | InternalErrorResponse>,
18+
poll: (
19+
collector: PollingCollector,
20+
) => Promise<PollingStatus | ContinueNode | InternalErrorResponse>,
1821
updater: Updater<PollingCollector>,
1922
submitForm: () => Promise<void>,
2023
) {
@@ -29,15 +32,24 @@ export default function pollingComponent(
2932
p.innerText = 'Polling...';
3033
formEl?.appendChild(p);
3134

35+
// TODO: support continue polling
3236
const status = await poll(collector);
3337
if (typeof status !== 'string' && 'error' in status) {
34-
console.error(status.error.message);
38+
console.error(status.error?.message);
39+
40+
const errEl = document.createElement('p');
41+
errEl.innerText = 'Polling error: ' + status.error?.message;
42+
formEl?.appendChild(errEl);
3543
return;
3644
}
3745

3846
const result = updater(status);
3947
if (result && 'error' in result) {
4048
console.error(result.error.message);
49+
50+
const errEl = document.createElement('p');
51+
errEl.innerText = 'Polling error: ' + result.error.message;
52+
formEl?.appendChild(errEl);
4153
return;
4254
}
4355

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { isGenericError, createWellknownError } from '@forgerock/sdk-utilities';
1414
import {
1515
createClientStore,
1616
handleChallengePolling,
17+
handleContinuePolling,
1718
handleUpdateValidateError,
1819
RootState,
1920
} from './client.store.utils.js';
@@ -413,8 +414,13 @@ export async function davinci<ActionType extends ActionTypes = ActionTypes>({
413414

414415
/**
415416
* @method: poll - Poll for updates for a polling collector
417+
* @returns {Promise<PollingStatus | ContinueNode | InternalErrorResponse>} - Returns a promise that resolves to
418+
* a polling status or error for challenge polling. Returns a promise that resolves to the next node or
419+
* an error for continue polling
416420
*/
417-
poll: async (collector: PollingCollector): Promise<PollingStatus | InternalErrorResponse> => {
421+
poll: async (
422+
collector: PollingCollector,
423+
): Promise<PollingStatus | ContinueNode | InternalErrorResponse> => {
418424
try {
419425
if (collector.type !== 'PollingCollector') {
420426
log.error('Collector provided to poll is not a PollingCollector');
@@ -427,19 +433,33 @@ export async function davinci<ActionType extends ActionTypes = ActionTypes>({
427433
};
428434
}
429435

436+
const pollChallengeStatus = collector.output.config.pollChallengeStatus;
430437
const challenge = collector.output.config.challenge;
431438

432-
// Challenge Polling
433-
if (challenge) {
439+
if (challenge && pollChallengeStatus === true) {
440+
// Challenge Polling
434441
return await handleChallengePolling({
435442
collector,
436443
challenge,
437444
store,
438445
log,
439446
});
447+
} else if (!challenge && !pollChallengeStatus) {
448+
// Continue polling
449+
return await handleContinuePolling({
450+
collector,
451+
store,
452+
log,
453+
});
440454
} else {
441-
// TODO: Handle continue polling
442-
return 'error' as PollingStatus;
455+
log.error('Invalid polling collector configuration');
456+
return {
457+
error: {
458+
message: 'Invalid polling collector configuration',
459+
type: 'internal_error',
460+
},
461+
type: 'internal_error',
462+
};
443463
}
444464
} catch (err) {
445465
const errorMessage = err instanceof Error ? err.message : String(err);

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

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,9 @@ export async function handleChallengePolling({
183183
retriesLeft--;
184184
return store.dispatch(
185185
davinciApi.endpoints.poll.initiate({
186-
challengeEndpoint,
186+
endpoint: challengeEndpoint,
187187
interactionId,
188+
mode: 'challenge',
188189
}),
189190
);
190191
});
@@ -272,3 +273,190 @@ export async function handleChallengePolling({
272273
};
273274
}
274275
}
276+
277+
export async function handleContinuePolling({
278+
collector,
279+
store,
280+
log,
281+
}: {
282+
collector: PollingCollector;
283+
store: ReturnType<ClientStore>;
284+
log: ReturnType<typeof loggerFn>;
285+
}): Promise<ContinueNode | InternalErrorResponse> {
286+
const rootState: RootState = store.getState();
287+
const serverSlice = nodeSlice.selectors.selectServer(rootState);
288+
289+
if (serverSlice === null) {
290+
log.error('No server info found for poll operation');
291+
return {
292+
error: {
293+
message: 'No server info found for poll operation',
294+
type: 'state_error',
295+
},
296+
type: 'internal_error',
297+
};
298+
}
299+
300+
if (isGenericError(serverSlice)) {
301+
log.error(serverSlice.message ?? serverSlice.error);
302+
return {
303+
error: {
304+
message: serverSlice.message ?? 'Failed to retrieve server info for poll operation',
305+
type: 'internal_error',
306+
},
307+
type: 'internal_error',
308+
};
309+
}
310+
311+
if (serverSlice.status !== 'continue') {
312+
return {
313+
error: {
314+
message: 'Not in a continue node state, must be in a continue node to use poll method',
315+
type: 'state_error',
316+
},
317+
} as InternalErrorResponse;
318+
}
319+
320+
// Get the continue polling endpoint
321+
const links = serverSlice._links;
322+
if (!links || !('next' in links) || !('href' in links['next']) || !links['next'].href) {
323+
return {
324+
error: {
325+
message: 'No next link found in server info for continue polling operation',
326+
type: 'internal_error',
327+
},
328+
} as InternalErrorResponse;
329+
}
330+
331+
const nextUrl = links['next'].href;
332+
333+
const interactionId = serverSlice.interactionId;
334+
if (!interactionId) {
335+
return {
336+
error: {
337+
message: 'Missing interactionId in server info for challenge polling',
338+
type: 'internal_error',
339+
},
340+
} as InternalErrorResponse;
341+
}
342+
343+
// Start continue polling
344+
let retriesLeft = collector.output.config.pollRetries ?? 60;
345+
const pollInterval = collector.output.config.pollInterval ?? 2000; // miliseconds
346+
347+
const updateµ = Micro.try({
348+
try: () => {
349+
// Update the polling collector input value to 'continue' or 'timedOut'. We will call
350+
// continue polling endpoint (_links.next.href) with this data in queryµ
351+
if (retriesLeft > 0) {
352+
return store.dispatch(
353+
nodeSlice.actions.update({ id: collector.id, value: 'continue' as PollingStatus }),
354+
);
355+
} else {
356+
return store.dispatch(
357+
nodeSlice.actions.update({ id: collector.id, value: 'timedOut' as PollingStatus }),
358+
);
359+
}
360+
},
361+
catch: (err) => {
362+
const errorMessage = err instanceof Error ? err.message : String(err);
363+
return {
364+
type: 'internal_error',
365+
error: { message: errorMessage, type: 'internal_error' },
366+
} as InternalErrorResponse;
367+
},
368+
});
369+
370+
const queryµ = Micro.promise(() => {
371+
retriesLeft--;
372+
return store.dispatch(
373+
davinciApi.endpoints.poll.initiate({
374+
endpoint: nextUrl,
375+
interactionId,
376+
mode: 'continue',
377+
}),
378+
);
379+
});
380+
381+
const repeatµ = updateµ.pipe(Micro.flatMap(() => queryµ));
382+
383+
const continuePollµ = Micro.repeat(repeatµ, {
384+
while: ({ data, error }) =>
385+
retriesLeft > 0 &&
386+
!error &&
387+
['rewindStateToLastRenderedUI', 'rewindStateToSpecificRenderedUI'].includes(
388+
(data as Record<string, unknown>)['eventName'] as string,
389+
),
390+
schedule: Micro.scheduleSpaced(pollInterval),
391+
}).pipe(
392+
Micro.flatMap(({ data, error }) => {
393+
const pollResponse = data as Record<string, unknown>;
394+
console.log('Continue poll response', pollResponse);
395+
396+
if (error) {
397+
// SerializedError
398+
let message = 'An unknown error occurred while challenge polling';
399+
if ('message' in error && error.message) {
400+
message = error.message;
401+
return Micro.fail({
402+
error: {
403+
message,
404+
type: 'unknown_error',
405+
},
406+
type: 'internal_error',
407+
} as InternalErrorResponse);
408+
}
409+
410+
// FetchBaseQueryError
411+
if ('status' in error) {
412+
return Micro.fail({
413+
error: {
414+
message: 'An unknown error occured during continue polling',
415+
type: 'unknown_error',
416+
},
417+
type: 'internal_error',
418+
} as InternalErrorResponse);
419+
}
420+
}
421+
422+
if (retriesLeft <= 0) {
423+
// Note: when retries are exhausted, DaVinci currently returns another continue polling response instead of an error
424+
return Micro.fail({
425+
error: {
426+
message: 'Continue polling timed out',
427+
type: 'internal_error',
428+
},
429+
type: 'internal_error',
430+
} as InternalErrorResponse);
431+
}
432+
433+
// TODO: If there are still retries left but we did not find a rewind event then polling succeeded and the next node was returned
434+
// return the next node here
435+
436+
// Just in case no polling status was determined
437+
return Micro.fail({
438+
error: {
439+
message: 'Unknown error occurred during continue polling',
440+
type: 'unknown_error',
441+
},
442+
type: 'internal_error',
443+
} as InternalErrorResponse);
444+
}),
445+
);
446+
447+
const result = await Micro.runPromiseExit(continuePollµ);
448+
449+
if (exitIsSuccess(result)) {
450+
return result.value;
451+
} else if (exitIsFail(result)) {
452+
return result.cause.error;
453+
} else {
454+
return {
455+
error: {
456+
message: result.cause.message,
457+
type: 'unknown_error',
458+
},
459+
type: 'internal_error',
460+
};
461+
}
462+
}

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,19 +437,30 @@ export const davinciApi = createApi({
437437
* us to use the response from onQueryFn, while avoiding updating the node state with the poll
438438
* response which causes the node to lose the initial collectors state when polling started.
439439
*/
440-
poll: builder.mutation<unknown, { challengeEndpoint: string; interactionId: string }>({
441-
async queryFn({ challengeEndpoint, interactionId }, api, _c, baseQuery) {
440+
poll: builder.mutation<
441+
unknown,
442+
{ endpoint: string; interactionId: string; mode: 'challenge' | 'continue' }
443+
>({
444+
async queryFn({ endpoint, interactionId, mode }, api, _c, baseQuery) {
445+
const state = api.getState() as RootStateWithNode<ContinueNode>;
442446
const { requestMiddleware, logger } = api.extra as Extras;
443447

448+
let requestBody = {};
449+
if (mode === 'continue') {
450+
requestBody = transformSubmitRequest(state.node, logger);
451+
} else if (mode !== 'challenge') {
452+
logger.error('Invalid polling mode, defaulting to challenge mode');
453+
}
454+
444455
const request: FetchArgs = {
445-
url: challengeEndpoint,
456+
url: endpoint,
446457
credentials: 'include',
447458
method: 'POST',
448459
headers: {
449460
'Content-Type': 'application/json',
450461
interactionId,
451462
},
452-
body: JSON.stringify({}),
463+
body: JSON.stringify(requestBody),
453464
};
454465

455466
logger.debug('Davinci API request', request);

0 commit comments

Comments
 (0)