From 58a0c81e674df768cf12396543e29fe8f6db4e04 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Thu, 30 Apr 2026 15:16:18 -0600 Subject: [PATCH 1/2] chore: use-either-for-composable-branching --- .../api-report/davinci-client.api.md | 12 +- .../api-report/davinci-client.types.api.md | 12 +- .../src/lib/client.store.effects.test.ts | 240 +++++++++++--- .../src/lib/client.store.effects.ts | 159 +++++---- .../src/lib/davinci.utils.test.ts | 199 +++++++++++- .../davinci-client/src/lib/davinci.utils.ts | 306 ++++++++++-------- .../src/lib/updater-narrowing.types.test-d.ts | 2 +- 7 files changed, 655 insertions(+), 275 deletions(-) diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index b2528bf664..33cb405246 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -267,13 +267,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; poll: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +285,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +297,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +306,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +321,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md index 2321431a0a..b4897b665c 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -267,13 +267,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; poll: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +285,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +297,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +306,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +321,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; diff --git a/packages/davinci-client/src/lib/client.store.effects.test.ts b/packages/davinci-client/src/lib/client.store.effects.test.ts index 365118aac0..299e0467cc 100644 --- a/packages/davinci-client/src/lib/client.store.effects.test.ts +++ b/packages/davinci-client/src/lib/client.store.effects.test.ts @@ -6,58 +6,211 @@ */ import { Micro } from 'effect'; +import * as Either from 'effect/Either'; +import * as Option from 'effect/Option'; import { describe, expect, vi } from 'vitest'; import { it } from '@effect/vitest'; import { buildChallengeEndpoint, + classifyPollResponse, isChallengeStillPending, interpretChallengeResponse, getPollingModeµ, + validatePollingPrerequisitesµ, } from './client.store.effects.js'; import type { PollDispatchResult } from './client.store.effects.js'; import type { PollingCollector } from './collector.types.js'; +import { logger } from '@forgerock/sdk-logger'; +import { createClientStore } from './client.store.utils.js'; -const mockLog = { +const mockLog: ReturnType = { + changeLevel: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn(), -} as any; +}; // --------------------------------------------------------------------------- // buildChallengeEndpoint // --------------------------------------------------------------------------- describe('buildChallengeEndpoint', () => { - it('returns a constructed URL string for a valid self link', () => { + it('returns Right with constructed URL for a valid self link', () => { const selfHref = 'https://auth.pingone.ca/3b2b0d54-99f9-4c28-b57e-d4e66e8e72c2/davinci/orchestrate'; const challenge = 'abc123'; const result = buildChallengeEndpoint(selfHref, challenge); - expect(result).toBe( + expect(Either.isRight(result)).toBe(true); + expect(Either.getOrThrow(result)).toBe( 'https://auth.pingone.ca/3b2b0d54-99f9-4c28-b57e-d4e66e8e72c2/davinci/user/credentials/challenge/abc123/status', ); }); - it('returns InternalErrorResponse when envId is missing from URL path', () => { + it('returns Left with InternalErrorResponse when envId is missing from URL path', () => { // pathname is just '/' → split gives ['', ''] → envId is empty string → falsy const selfHref = 'https://auth.pingone.ca/'; const challenge = 'abc123'; const result = buildChallengeEndpoint(selfHref, challenge); - expect(typeof result).toBe('object'); - expect((result as any).type).toBe('internal_error'); + expect(Either.isLeft(result)).toBe(true); + expect(Option.getOrThrow(Either.getLeft(result))).toMatchObject({ type: 'internal_error' }); }); - it('returns InternalErrorResponse for a completely invalid URL', () => { + it('returns Left with InternalErrorResponse for a completely invalid URL', () => { const result = buildChallengeEndpoint('not-a-url', 'abc123'); - expect(typeof result).toBe('object'); - expect((result as any).type).toBe('internal_error'); + expect(Either.isLeft(result)).toBe(true); + expect(Option.getOrThrow(Either.getLeft(result))).toMatchObject({ type: 'internal_error' }); + }); +}); + +// --------------------------------------------------------------------------- +// validatePollingPrerequisitesµ +// --------------------------------------------------------------------------- + +function makeContinueState(interactionId: string, selfHref: string) { + const store = createClientStore({}); + store.dispatch({ + type: 'node/next', + payload: { + httpStatus: 200, + requestId: 'test-request', + data: { + _links: { self: { href: selfHref } }, + id: 'node-1', + interactionId, + interactionToken: 'token', + eventName: 'continue', + form: { components: { fields: [] } }, + }, + }, + }); + return store.getState(); +} + +describe('validatePollingPrerequisitesµ', () => { + it('fails with state_error when challenge is empty', async () => { + const state = makeContinueState( + 'interaction-1', + 'https://auth.pingone.ca/env1/davinci/orchestrate', + ); + + const result = await Micro.runPromiseExit(validatePollingPrerequisitesµ(state, '')); + + expect(Micro.exitIsFailure(result)).toBe(true); + expect(result).toMatchObject({ cause: { error: { type: 'internal_error' } } }); + }); + + it('fails with state_error when node is not in continue state', async () => { + const store = createClientStore({}); + const state = store.getState(); + + const result = await Micro.runPromiseExit(validatePollingPrerequisitesµ(state, 'abc123')); + + expect(Micro.exitIsFailure(result)).toBe(true); + expect(result).toMatchObject({ cause: { error: { type: 'internal_error' } } }); + }); + + it('fails with state_error when interactionId is missing', async () => { + const store = createClientStore({}); + store.dispatch({ + type: 'node/next', + payload: { + httpStatus: 200, + requestId: 'test-request', + data: { + _links: { self: { href: 'https://auth.pingone.ca/env1/davinci/orchestrate' } }, + id: 'node-1', + interactionToken: 'token', + eventName: 'continue', + form: { components: { fields: [] } }, + }, + }, + }); + + const result = await Micro.runPromiseExit( + validatePollingPrerequisitesµ(store.getState(), 'abc123'), + ); + + expect(Micro.exitIsFailure(result)).toBe(true); + expect(result).toMatchObject({ cause: { error: { type: 'internal_error' } } }); + }); + + it('succeeds with interactionId and constructed challengeEndpoint', async () => { + const state = makeContinueState( + 'interaction-abc', + 'https://auth.pingone.ca/3b2b0d54-99f9-4c28-b57e-d4e66e8e72c2/davinci/orchestrate', + ); + + const result = await Micro.runPromiseExit(validatePollingPrerequisitesµ(state, 'abc123')); + + expect(Micro.exitIsSuccess(result)).toBe(true); + expect(result).toMatchObject({ + value: { + interactionId: 'interaction-abc', + challengeEndpoint: + 'https://auth.pingone.ca/3b2b0d54-99f9-4c28-b57e-d4e66e8e72c2/davinci/user/credentials/challenge/abc123/status', + }, + }); + }); +}); + +// --------------------------------------------------------------------------- +// classifyPollResponse +// --------------------------------------------------------------------------- + +describe('classifyPollResponse', () => { + it("classifies a 400 error with serviceName 'challengeExpired' as _tag: expired", () => { + const response: PollDispatchResult = { + error: { status: 400, data: { serviceName: 'challengeExpired' } }, + }; + expect(classifyPollResponse(response)).toMatchObject({ _tag: 'expired' }); + }); + + it('classifies other HTTP errors as _tag: error', () => { + const response: PollDispatchResult = { + error: { status: 500, data: { message: 'Server Error' } }, + }; + expect(classifyPollResponse(response)).toMatchObject({ _tag: 'error' }); + }); + + it('classifies a SerializedError as _tag: internalError', () => { + const response: PollDispatchResult = { + error: { name: 'SerializedError', message: 'Network failure' }, + }; + expect(classifyPollResponse(response)).toMatchObject({ _tag: 'internalError' }); + }); + + it('classifies non-object data as _tag: error', () => { + const response: PollDispatchResult = { data: 'just a string' }; + expect(classifyPollResponse(response)).toMatchObject({ _tag: 'error' }); + }); + + it('classifies a completed challenge as _tag: complete with status', () => { + const response: PollDispatchResult = { + data: { isChallengeComplete: true, status: 'approved' }, + }; + expect(classifyPollResponse(response)).toMatchObject({ _tag: 'complete', status: 'approved' }); + }); + + it('classifies a completed challenge with missing status as _tag: error', () => { + const response: PollDispatchResult = { data: { isChallengeComplete: true } }; + expect(classifyPollResponse(response)).toMatchObject({ _tag: 'error' }); + }); + + it('classifies an in-progress challenge as _tag: pending', () => { + const response: PollDispatchResult = { data: { isChallengeComplete: false } }; + expect(classifyPollResponse(response)).toMatchObject({ _tag: 'pending' }); + }); + + it('classifies a response with no isChallengeComplete field as _tag: pending', () => { + const response: PollDispatchResult = { data: { someOtherField: 'value' } }; + expect(classifyPollResponse(response)).toMatchObject({ _tag: 'pending' }); }); }); @@ -94,74 +247,64 @@ describe('isChallengeStillPending', () => { // --------------------------------------------------------------------------- describe('interpretChallengeResponse', () => { - it("returns 'expired' for a 400 error with serviceName 'challengeExpired'", () => { + it("returns Right 'expired' for a 400 error with serviceName 'challengeExpired'", () => { const response: PollDispatchResult = { error: { status: 400, data: { serviceName: 'challengeExpired' } }, }; - const result = interpretChallengeResponse(response, mockLog); - - expect(result).toBe('expired'); + expect(Either.isRight(result)).toBe(true); + expect(Either.getOrThrow(result)).toBe('expired'); }); - it("returns 'error' for other HTTP errors (status 500)", () => { + it("returns Right 'error' for other HTTP errors (status 500)", () => { const response: PollDispatchResult = { error: { status: 500, data: { message: 'Server Error' } }, }; - const result = interpretChallengeResponse(response, mockLog); - - expect(result).toBe('error'); + expect(Either.isRight(result)).toBe(true); + expect(Either.getOrThrow(result)).toBe('error'); }); - it('returns InternalErrorResponse for a SerializedError (has message, no status)', () => { + it('returns Left InternalErrorResponse for a SerializedError', () => { const response: PollDispatchResult = { error: { name: 'SerializedError', message: 'Network failure' }, }; - const result = interpretChallengeResponse(response, mockLog); - - expect(typeof result).toBe('object'); - expect((result as any).type).toBe('internal_error'); - expect((result as any).error.message).toBe('Network failure'); + expect(Either.isLeft(result)).toBe(true); + expect(Option.getOrThrow(Either.getLeft(result))).toMatchObject({ + type: 'internal_error', + error: { message: 'Network failure' }, + }); }); - it("returns 'error' for non-object data", () => { + it("returns Right 'error' for non-object data", () => { const response: PollDispatchResult = { data: 'just a string' }; - const result = interpretChallengeResponse(response, mockLog); - - expect(result).toBe('error'); + expect(Either.isRight(result)).toBe(true); + expect(Either.getOrThrow(result)).toBe('error'); }); - it('returns the status from a completed challenge', () => { + it('returns Right with the status from a completed challenge', () => { const response: PollDispatchResult = { data: { isChallengeComplete: true, status: 'approved' }, }; - const result = interpretChallengeResponse(response, mockLog); - - expect(result).toBe('approved'); + expect(Either.isRight(result)).toBe(true); + expect(Either.getOrThrow(result)).toBe('approved'); }); - it("returns 'error' when challenge is complete but status is missing", () => { - const response: PollDispatchResult = { - data: { isChallengeComplete: true }, - }; - + it("returns Right 'error' when challenge is complete but status is missing", () => { + const response: PollDispatchResult = { data: { isChallengeComplete: true } }; const result = interpretChallengeResponse(response, mockLog); - - expect(result).toBe('error'); + expect(Either.isRight(result)).toBe(true); + expect(Either.getOrThrow(result)).toBe('error'); }); - it("returns 'timedOut' for an incomplete challenge when the schedule is exhausted", () => { - const response: PollDispatchResult = { - data: { isChallengeComplete: false }, - }; - + it("returns Right 'timedOut' for an incomplete challenge when the schedule is exhausted", () => { + const response: PollDispatchResult = { data: { isChallengeComplete: false } }; const result = interpretChallengeResponse(response, mockLog); - - expect(result).toBe('timedOut'); + expect(Either.isRight(result)).toBe(true); + expect(Either.getOrThrow(result)).toBe('timedOut'); }); }); @@ -240,7 +383,10 @@ describe('getPollingModeµ', () => { it.effect('fails when collector type is not PollingCollector', () => Micro.gen(function* () { - const badCollector = { ...basePollingCollector, type: 'TextCollector' } as any; + const badCollector = { + ...basePollingCollector, + type: 'TextCollector', + } as unknown as PollingCollector; const result = yield* Micro.exit(getPollingModeµ(badCollector)); diff --git a/packages/davinci-client/src/lib/client.store.effects.ts b/packages/davinci-client/src/lib/client.store.effects.ts index 9923ea4b67..f162b5ee73 100644 --- a/packages/davinci-client/src/lib/client.store.effects.ts +++ b/packages/davinci-client/src/lib/client.store.effects.ts @@ -6,6 +6,7 @@ */ import { Micro } from 'effect'; +import * as Either from 'effect/Either'; import { SerializedError } from '@reduxjs/toolkit/react'; import { FetchBaseQueryError } from '@reduxjs/toolkit/query/react'; @@ -15,7 +16,7 @@ import type { ClientStore, RootState } from './client.store.utils.js'; import type { PollingStatus, InternalErrorResponse } from './client.types.js'; import type { PollingCollector } from './collector.types.js'; -import { createInternalError, isInternalError } from './client.store.utils.js'; +import { createInternalError } from './client.store.utils.js'; import { davinciApi } from './davinci.api.js'; import { nodeSlice } from './node.slice.js'; @@ -92,38 +93,31 @@ export function getPollingModeµ( export function buildChallengeEndpoint( selfHref: string, challenge: string, -): string | InternalErrorResponse { - try { - const url = new URL(selfHref); - const envId = url.pathname.split('/')[1]; - - if (!url.origin || !envId) { - return createInternalError( - 'Failed to construct challenge polling endpoint. Requires host and environment ID.', - 'parse_error', - ); - } +): Either.Either { + const parseError = createInternalError( + 'Failed to construct challenge polling endpoint. Requires host and environment ID.', + 'parse_error', + ); - return `${url.origin}/${envId}/davinci/user/credentials/challenge/${challenge}/status`; - } catch { - return createInternalError( - 'Failed to construct challenge polling endpoint. Requires host and environment ID.', - 'parse_error', - ); - } + return Either.try({ try: () => new URL(selfHref), catch: () => parseError }).pipe( + Either.flatMap((url) => { + const envId = url.pathname.split('/')[1]; + return url.origin && envId + ? Either.right( + `${url.origin}/${envId}/davinci/user/credentials/challenge/${challenge}/status`, + ) + : Either.left(parseError); + }), + ); } -/** - * Lifts a selector result with { error, state } shape into a Micro. - * Succeeds with state when error is null, fails with InternalErrorResponse otherwise. - */ -function fromSelectorµ(result: { +function fromSelector(result: { error: { message: string } | null; state: T; -}): Micro.Micro, InternalErrorResponse> { +}): Either.Either, InternalErrorResponse> { return result.error - ? Micro.fail(createInternalError(result.error.message, 'state_error')) - : Micro.succeed(result.state as NonNullable); + ? Either.left(createInternalError(result.error.message, 'state_error')) + : Either.right(result.state as NonNullable); } /** @@ -140,7 +134,7 @@ export function validatePollingPrerequisitesµ( ); } - return fromSelectorµ(nodeSlice.selectors.selectContinueServer(rootState)).pipe( + return Micro.fromEither(fromSelector(nodeSlice.selectors.selectContinueServer(rootState))).pipe( Micro.filterOrFail( (server) => !!server.interactionId, () => @@ -150,81 +144,85 @@ export function validatePollingPrerequisitesµ( ), ), Micro.flatMap((server) => - fromSelectorµ(nodeSlice.selectors.selectSelfLink(rootState)).pipe( + Micro.fromEither(fromSelector(nodeSlice.selectors.selectSelfLink(rootState))).pipe( Micro.map((selfLink) => ({ server, selfLink })), ), ), - Micro.flatMap(({ server, selfLink }) => { - const endpoint = buildChallengeEndpoint(selfLink, challenge); - return typeof endpoint === 'string' - ? Micro.succeed({ - interactionId: server.interactionId!, - challengeEndpoint: endpoint, - }) - : Micro.fail(endpoint); - }), + Micro.flatMap(({ server, selfLink }) => + Either.match(buildChallengeEndpoint(selfLink, challenge), { + onLeft: Micro.fail, + onRight: (challengeEndpoint) => + Micro.succeed({ interactionId: server.interactionId as string, challengeEndpoint }), + }), + ), ); } -/** - * Pure predicate: determines if challenge polling should continue. - * Returns true when the challenge has not yet completed and no error occurred. - */ -export function isChallengeStillPending(response: PollDispatchResult): boolean { - if (response.error) return false; - - const data = isRecord(response.data) ? response.data : undefined; - if (data?.['isChallengeComplete']) return false; +type PollClassification = + | { _tag: 'expired' } + | { _tag: 'error' } + | { _tag: 'internalError'; error: InternalErrorResponse } + | { _tag: 'complete'; status: PollingStatus } + | { _tag: 'pending' }; - return true; -} - -export function interpretChallengeResponse( - response: PollDispatchResult, - log: ReturnType, -): PollingStatus | InternalErrorResponse { +export function classifyPollResponse(response: PollDispatchResult): PollClassification { const { data, error } = response; if (error) { - // FetchBaseQueryError — has status field if ('status' in error) { const errorDetails = isRecord(error.data) ? error.data : undefined; - const serviceName = errorDetails?.['serviceName']; - - // Expired challenge is an expected polling outcome, not a failure - if (error.status === 400 && serviceName === 'challengeExpired') { - log.debug('Challenge expired for polling'); - return 'expired'; + if (error.status === 400 && errorDetails?.['serviceName'] === 'challengeExpired') { + return { _tag: 'expired' }; } - - // Other HTTP errors are also expected outcomes (e.g. bad challenge returning 400 with code 4019) - log.debug('Unknown error occurred during polling'); - return 'error'; + return { _tag: 'error' }; } - // SerializedError — has message field const message = 'message' in error && error.message ? error.message : 'An unknown error occurred while challenge polling'; - - return createInternalError(message, 'unknown_error'); + return { _tag: 'internalError', error: createInternalError(message, 'unknown_error') }; } - if (!isRecord(data)) { - log.debug('Unable to parse polling response'); - return 'error'; - } + if (!isRecord(data)) return { _tag: 'error' }; - // Challenge completed — extract status if (data['isChallengeComplete'] === true) { - const pollStatus = data['status']; - return pollStatus ? (pollStatus as PollingStatus) : 'error'; + const status = data['status']; + return status ? { _tag: 'complete', status: status as PollingStatus } : { _tag: 'error' }; } - // If we reach here, Micro.repeat exhausted its schedule without the challenge completing - log.debug('Challenge polling timed out'); - return 'timedOut'; + return { _tag: 'pending' }; +} + +export function isChallengeStillPending(response: PollDispatchResult): boolean { + return classifyPollResponse(response)._tag === 'pending'; +} + +export function interpretChallengeResponse( + response: PollDispatchResult, + log: ReturnType, +): Either.Either { + const classification = classifyPollResponse(response); + + switch (classification._tag) { + case 'expired': + log.debug('Challenge expired for polling'); + return Either.right('expired'); + case 'error': + log.debug('Unknown error occurred during polling'); + return Either.right('error'); + case 'internalError': + return Either.left(classification.error); + case 'complete': + return Either.right(classification.status); + case 'pending': + log.debug('Challenge polling timed out'); + return Either.right('timedOut'); + default: { + const exhaustive: never = classification; + throw new Error(`Unhandled poll classification: ${JSON.stringify(exhaustive)}`); + } + } } /** @@ -262,10 +260,7 @@ function challengePollingµ({ times: maxRetries - 1, schedule: Micro.scheduleSpaced(pollInterval), }), - Micro.map((response) => interpretChallengeResponse(response, log)), - Micro.flatMap((result) => - isInternalError(result) ? Micro.fail(result) : Micro.succeed(result), - ), + Micro.flatMap((response) => Micro.fromEither(interpretChallengeResponse(response, log))), ); } diff --git a/packages/davinci-client/src/lib/davinci.utils.test.ts b/packages/davinci-client/src/lib/davinci.utils.test.ts index 81a12bcf40..6f0f52871b 100644 --- a/packages/davinci-client/src/lib/davinci.utils.test.ts +++ b/packages/davinci-client/src/lib/davinci.utils.test.ts @@ -8,7 +8,12 @@ import { describe, it, expect, vi } from 'vitest'; import { logger } from '@forgerock/sdk-logger'; -import { handleResponse, transformSubmitRequest, transformActionRequest } from './davinci.utils.js'; +import { + handleResponse, + classifyResponse, + transformSubmitRequest, + transformActionRequest, +} from './davinci.utils.js'; import type { ContinueNode } from './node.types.d.ts'; import { next0 } from './mock-data/davinci.next.mock.js'; @@ -252,6 +257,173 @@ describe('transformActionRequest', () => { }); }); +// --------------------------------------------------------------------------- +// classifyResponse +// --------------------------------------------------------------------------- + +describe('classifyResponse', () => { + it('classifies a next response as _tag: next', () => { + const cacheEntry = { + data: next0, + error: undefined, + requestId: '123', + status: 'fulfilled', + isError: false, + isSuccess: true, + } as DaVinciCacheEntry; + + const result = classifyResponse(cacheEntry, 200); + + expect(result).toMatchObject({ _tag: 'next' }); + }); + + it('classifies a success response as _tag: success', () => { + const cacheEntry = { + data: success0, + error: undefined, + requestId: '123', + status: 'fulfilled', + isError: false, + isSuccess: true, + } as DaVinciCacheEntry; + + const result = classifyResponse(cacheEntry, 200); + + expect(result).toMatchObject({ _tag: 'success' }); + }); + + it('classifies a recoverable 4XX error as _tag: error with logMessage', () => { + const cacheEntry = { + data: undefined, + error: { data: error0a, status: 400 }, + requestId: '123', + status: 'rejected', + isError: true, + isSuccess: false, + } as DaVinciCacheEntry; + + const result = classifyResponse(cacheEntry, 400); + + expect(result).toMatchObject({ + _tag: 'error', + logMessage: 'Response with this error type should be recoverable', + }); + }); + + it('classifies a 5XX error as _tag: failure with logMessage', () => { + const cacheEntry = { + data: undefined, + error: { data: {}, status: 500 }, + requestId: '123', + status: 'rejected', + isError: true, + isSuccess: false, + } as DaVinciCacheEntry; + + const result = classifyResponse(cacheEntry, 500); + + expect(result).toMatchObject({ + _tag: 'failure', + logMessage: 'Response of 5XX indicates unrecoverable failure', + }); + }); + + it('classifies a timeout error (code 1999) as _tag: failure with logMessage', () => { + const cacheEntry = { + data: undefined, + error: { data: error3, status: 400 }, + requestId: '123', + status: 'rejected', + isError: true, + isSuccess: false, + } as DaVinciCacheEntry; + + const result = classifyResponse(cacheEntry, 400); + + expect(result).toMatchObject({ _tag: 'failure', logMessage: 'Error is a client-side timeout' }); + }); + + it('classifies a pingOneAuthenticationConnector failure as _tag: failure with logMessage', () => { + const cacheEntry = { + data: undefined, + error: { + data: { + connectorId: 'pingOneAuthenticationConnector', + capabilityName: 'returnSuccessResponseRedirect', + }, + status: 400, + }, + requestId: '123', + status: 'rejected', + isError: true, + isSuccess: false, + } as DaVinciCacheEntry; + + const result = classifyResponse(cacheEntry, 400); + + expect(result).toMatchObject({ + _tag: 'failure', + logMessage: 'Error is a PingOne Authentication Connector unrecoverable failure', + }); + }); + + it('classifies a FETCH_ERROR as _tag: failure with logMessage', () => { + const cacheEntry = { + data: undefined, + error: { data: {}, status: 'FETCH_ERROR' }, + requestId: '123', + status: 'rejected', + isError: true, + isSuccess: false, + } as DaVinciCacheEntry; + + const result = classifyResponse(cacheEntry, 0); + + expect(result).toMatchObject({ + _tag: 'failure', + logMessage: + 'Response with FETCH_ERROR indicates configuration failure. Please ensure a correct Client ID for your OAuth application.', + }); + }); + + it('classifies a 2XX response with error property as _tag: failure with logMessage', () => { + const cacheEntry = { + data: { error: { code: 'unknown', status: 400 } }, + error: undefined, + requestId: '123', + status: 'fulfilled', + isError: false, + isSuccess: true, + } as DaVinciCacheEntry; + + const result = classifyResponse(cacheEntry, 200); + + expect(result).toMatchObject({ + _tag: 'failure', + logMessage: 'Response with `isSuccess` but `error` property indicates unrecoverable failure', + }); + }); + + it('classifies a 2XX response with status:failure as _tag: failure with logMessage', () => { + const cacheEntry = { + data: { status: 'failure' }, + error: undefined, + requestId: '123', + status: 'fulfilled', + isError: false, + isSuccess: true, + } as DaVinciCacheEntry; + + const result = classifyResponse(cacheEntry, 200); + + expect(result).toMatchObject({ + _tag: 'failure', + logMessage: + 'Response with `isSuccess` and `status` of "failure" indicates unrecoverable failure', + }); + }); +}); + describe('handleResponse', () => { it('should handle a next response', () => { const cacheEntry = { @@ -425,4 +597,29 @@ describe('handleResponse', () => { const [action] = dispatch.mock.calls[0]; expect(action.type).toBe('node/failure'); }); + + it('logs the classification-specific message via logger', () => { + const cacheEntry = { + data: undefined, + error: { data: {}, status: 500 }, + requestId: '123', + status: 'rejected', + isError: true, + isSuccess: false, + } as DaVinciCacheEntry; + const dispatch = vi.fn(); + const mockLogger: ReturnType = { + changeLevel: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }; + + handleResponse(cacheEntry, dispatch, 500, mockLogger); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Response of 5XX indicates unrecoverable failure', + ); + }); }); diff --git a/packages/davinci-client/src/lib/davinci.utils.ts b/packages/davinci-client/src/lib/davinci.utils.ts index f3271d1017..363babff15 100644 --- a/packages/davinci-client/src/lib/davinci.utils.ts +++ b/packages/davinci-client/src/lib/davinci.utils.ts @@ -8,6 +8,7 @@ * Import the used types */ import type { Dispatch } from '@reduxjs/toolkit'; +import * as Either from 'effect/Either'; import { logger as loggerFn } from '@forgerock/sdk-logger'; @@ -114,161 +115,202 @@ export function transformActionRequest( }; } -export function handleResponse( - cacheEntry: DaVinciCacheEntry, - dispatch: Dispatch, - status: number, - logger: ReturnType, -) { - /** - * 5XX errors are treated as unrecoverable failures - */ - if (cacheEntry.isError && cacheEntry.error.status >= 500) { - logger.error('Response of 5XX indicates unrecoverable failure'); - const data = cacheEntry.error.data as unknown; - const requestId = cacheEntry.requestId; - dispatch(nodeSlice.actions.failure({ data, requestId, httpStatus: cacheEntry.error.status })); +type ResponseClassification = + | { _tag: 'failure'; data: unknown; requestId: string; httpStatus: number; logMessage: string } + | { + _tag: 'error'; + data: DavinciErrorResponse; + requestId: string; + httpStatus: number; + logMessage: string; + } + | { _tag: 'success'; data: DaVinciSuccessResponse; requestId: string; httpStatus: number } + | { _tag: 'poll'; requestId: string } + | { _tag: 'next'; data: DaVinciNextResponse; requestId: string; httpStatus: number }; - return; // Filter out 5XX's +function classifyError( + error: NonNullable, + requestId: string, +): ResponseClassification { + if (error.status >= 500) { + return { + _tag: 'failure', + data: error.data, + requestId, + httpStatus: error.status, + logMessage: 'Response of 5XX indicates unrecoverable failure', + }; } - /** - * Check for 4XX errors that are unrecoverable - */ - if (cacheEntry.isError && cacheEntry.error.status >= 400 && cacheEntry.error.status < 500) { - const data = cacheEntry.error.data as DavinciErrorResponse; - const requestId = cacheEntry.requestId; - - // Filter out client-side "timeout" related unrecoverable failures - if (data.code === 1999 || data.code === 'requestTimedOut') { - logger.error('Error is a client-side timeout'); - dispatch(nodeSlice.actions.failure({ data, requestId, httpStatus: cacheEntry.error.status })); + if (error.status === 'FETCH_ERROR') { + return { + _tag: 'failure', + data: { + code: error.status, + message: 'Fetch Error: Please ensure a correct Client ID for your OAuth application.', + }, + requestId, + httpStatus: 0, + logMessage: + 'Response with FETCH_ERROR indicates configuration failure. Please ensure a correct Client ID for your OAuth application.', + }; + } - return; // Filter out timeouts - } + const data = error.data as DavinciErrorResponse; - // Filter our "PingOne Authentication Connector" unrecoverable failures - if ( - data.connectorId === 'pingOneAuthenticationConnector' && - (data.capabilityName === 'returnSuccessResponseRedirect' || - data.capabilityName === 'setSession') - ) { - logger.error('Error is a PingOne Authentication Connector unrecoverable failure'); - dispatch(nodeSlice.actions.failure({ data, requestId, httpStatus: cacheEntry.error.status })); + if (data.code === 1999 || data.code === 'requestTimedOut') { + return { + _tag: 'failure', + data, + requestId, + httpStatus: error.status, + logMessage: 'Error is a client-side timeout', + }; + } - return; - } + if ( + data.connectorId === 'pingOneAuthenticationConnector' && + (data.capabilityName === 'returnSuccessResponseRedirect' || + data.capabilityName === 'setSession') + ) { + return { + _tag: 'failure', + data, + requestId, + httpStatus: error.status, + logMessage: 'Error is a PingOne Authentication Connector unrecoverable failure', + }; + } - logger.debug('Response with this error type should be recoverable'); - // If we're still here, we have a 4XX failure that should be recoverable - dispatch(nodeSlice.actions.error({ data, requestId, httpStatus: cacheEntry.error.status })); + return { + _tag: 'error', + data, + requestId, + httpStatus: error.status, + logMessage: 'Response with this error type should be recoverable', + }; +} - return; +function classifySuccess( + data: NonNullable, + requestId: string, + httpStatus: number, +): ResponseClassification { + if ('error' in data) { + return { + _tag: 'failure', + data: (data as DaVinciFailureResponse).error, + requestId, + httpStatus, + logMessage: 'Response with `isSuccess` but `error` property indicates unrecoverable failure', + }; } - /** - * Check for 3XX errors that result in CORS errors, reported as FETCH_ERROR - */ - if (cacheEntry.isError && cacheEntry.error.status === 'FETCH_ERROR') { - logger.error( - 'Response with FETCH_ERROR indicates configuration failure. Please ensure a correct Client ID for your OAuth application.', - ); - const data = { - code: cacheEntry.error.status, - message: 'Fetch Error: Please ensure a correct Client ID for your OAuth application.', + if ('status' in data && (data as { status: string }).status.toLowerCase() === 'failure') { + return { + _tag: 'failure', + data: (data as DaVinciFailureResponse).error, + requestId, + httpStatus, + logMessage: + 'Response with `isSuccess` and `status` of "failure" indicates unrecoverable failure', }; - const requestId = cacheEntry.requestId; - dispatch(nodeSlice.actions.failure({ data, requestId, httpStatus: cacheEntry.error.status })); + } - return; + if ('session' in data || 'authorizeResponse' in data) { + return { _tag: 'success', data: data as DaVinciSuccessResponse, requestId, httpStatus }; } - /** - * If the response's HTTP status is a success (2XX), but the DaVinci API has returned an error, - * we need to handle this as a failure or return as unknown. - */ - if (cacheEntry.isSuccess && 'error' in cacheEntry.data) { - logger.error('Response with `isSuccess` but `error` property indicates unrecoverable failure'); - const data = cacheEntry.data as DaVinciFailureResponse; - const requestId = cacheEntry.requestId; - dispatch( - nodeSlice.actions.failure({ - data: data.error, - requestId, - httpStatus: status, - }), - ); + if ( + 'eventName' in data && + ['rewindStateToLastRenderedUI', 'rewindStateToSpecificRenderedUI'].includes( + (data as { eventName: string }).eventName, + ) + ) { + return { _tag: 'poll', requestId }; + } - return; // Filter out 2XX errors + if ('_links' in data && 'next' in (data as { _links: object })._links) { + return { _tag: 'next', data: data as DaVinciNextResponse, requestId, httpStatus }; } - /** - * If the response's HTTP status is a success (2XX), but the DaVinci API has returned an error, - * we need to handle this as a failure or return as unknown. - */ - if (cacheEntry.isSuccess && 'status' in cacheEntry.data) { - const status = cacheEntry.data.status.toLowerCase(); + return { + _tag: 'failure', + data, + requestId, + httpStatus, + logMessage: 'Response type is unknown and therefore an unrecoverable failure', + }; +} - if (status === 'failure') { - logger.error( - 'Response with `isSuccess` and `status` of "failure" indicates unrecoverable failure', - ); - const data = cacheEntry.data as DaVinciFailureResponse; - const requestId = cacheEntry.requestId; +export function classifyResponse( + cacheEntry: DaVinciCacheEntry, + httpStatus: number, +): ResponseClassification { + // requestId is typed as string | undefined by RTK; empty string produces a no-op cache key rather than crashing + const requestId = cacheEntry.requestId ?? ''; + return Either.match( + cacheEntry.isError ? Either.left(cacheEntry.error) : Either.right(cacheEntry.data), + { + onLeft: (error) => classifyError(error, requestId), + onRight: (data) => classifySuccess(data, requestId, httpStatus), + }, + ); +} + +export function handleResponse( + cacheEntry: DaVinciCacheEntry, + dispatch: Dispatch, + status: number, + logger: ReturnType, +) { + const classification = classifyResponse(cacheEntry, status); + + switch (classification._tag) { + case 'failure': + logger.error(classification.logMessage); dispatch( nodeSlice.actions.failure({ - data: data.error, - requestId, - httpStatus: status, + data: classification.data, + requestId: classification.requestId, + httpStatus: classification.httpStatus, }), ); - - return; // Filter out 2XX errors with 'failure' status - } else { - // Do nothing - } - } - - /** - * If we've made it here, we have a successful response and do not have an error property. - * Parse for state of the flow and dispatch appropriate action. - */ - if (cacheEntry.isSuccess) { - const requestId = cacheEntry.requestId; - - const hasNextUrl = () => { - const data = cacheEntry.data; - - if ('_links' in data) { - if ('next' in data._links) { - if ('href' in data._links.next) { - return true; - } - } - } - return false; - }; - - const isContinuePollingEvent = () => { - const data = cacheEntry.data; - return ( - 'eventName' in data && - ['rewindStateToLastRenderedUI', 'rewindStateToSpecificRenderedUI'].includes(data.eventName) + break; + case 'error': + logger.debug(classification.logMessage); + dispatch( + nodeSlice.actions.error({ + data: classification.data, + requestId: classification.requestId, + httpStatus: classification.httpStatus, + }), ); - }; - - if ('session' in cacheEntry.data || 'authorizeResponse' in cacheEntry.data) { - const data = cacheEntry.data as DaVinciSuccessResponse; - dispatch(nodeSlice.actions.success({ data, requestId, httpStatus: status })); - } else if (isContinuePollingEvent()) { - dispatch(nodeSlice.actions.poll({ requestId })); - } else if (hasNextUrl()) { - const data = cacheEntry.data as DaVinciNextResponse; - dispatch(nodeSlice.actions.next({ data, requestId, httpStatus: status })); - } else { - // If we got here, the response type is unknown and therefore an unrecoverable failure - const data = cacheEntry.data as DaVinciFailureResponse; - dispatch(nodeSlice.actions.failure({ data, requestId, httpStatus: status })); + break; + case 'success': + dispatch( + nodeSlice.actions.success({ + data: classification.data, + requestId: classification.requestId, + httpStatus: classification.httpStatus, + }), + ); + break; + case 'poll': + dispatch(nodeSlice.actions.poll({ requestId: classification.requestId })); + break; + case 'next': + dispatch( + nodeSlice.actions.next({ + data: classification.data, + requestId: classification.requestId, + httpStatus: classification.httpStatus, + }), + ); + break; + default: { + const exhaustive: never = classification; + throw new Error(`Unhandled response classification: ${JSON.stringify(exhaustive)}`); } } } diff --git a/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts b/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts index 7ba8b20d75..735a463a48 100644 --- a/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts +++ b/packages/davinci-client/src/lib/updater-narrowing.types.test-d.ts @@ -43,7 +43,7 @@ type MockUpdate = < ) => Updater; const mockUpdate: MockUpdate = (collector) => { - return ((value: unknown) => null) as any; + return ((value: unknown) => null) as Updater; }; describe('Updater Type Narrowing with Real Usage Pattern', () => { From 26bc2ff4995e8004853ecbe870411c26bdbd9642 Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:21:55 +0000 Subject: [PATCH 2/2] chore: use-either-for-composable-branching [Self-Healing CI Rerun]