Skip to content

Commit e5badbe

Browse files
committed
refactor(journey-client): use Either for response parsing in start and next
1 parent 091667c commit e5badbe

8 files changed

Lines changed: 108 additions & 96 deletions

File tree

packages/journey-client/api-report/journey-client.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export type JourneyLoginSuccess = AuthResponse & {
229229
getSuccessUrl: () => string | undefined;
230230
};
231231

232-
// @public
232+
// @public (undocumented)
233233
export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError;
234234

235235
// @public

packages/journey-client/api-report/journey-client.types.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ export type JourneyLoginSuccess = AuthResponse & {
216216
getSuccessUrl: () => string | undefined;
217217
};
218218

219-
// @public
219+
// @public (undocumented)
220220
export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError;
221221

222222
// @public

packages/journey-client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@forgerock/sdk-utilities": "workspace:*",
4040
"@forgerock/storage": "workspace:*",
4141
"@reduxjs/toolkit": "catalog:",
42+
"effect": "catalog:effect",
4243
"tslib": "catalog:"
4344
},
4445
"devDependencies": {

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

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,15 @@ import { createJourneyStore } from './client.store.utils.js';
2121
import { configSlice } from './config.slice.js';
2222
import { journeyApi } from './journey.api.js';
2323
import { createStorage } from '@forgerock/storage';
24-
import { createJourneyObject, handleJourneyResponse } from './journey.utils.js';
24+
import { match } from 'effect/Either';
25+
import { createJourneyObject, parseJourneyResponse } from './journey.utils.js';
26+
import type { JourneyResult } from './journey.utils.js';
2527
import { wellknownApi } from './wellknown.api.js';
2628

2729
import type { JourneyStep } from './step.utils.js';
2830
import type { JourneyClientConfig } from './config.types.js';
2931
import type { RedirectCallback } from './callbacks/redirect-callback.js';
3032
import type { NextOptions, StartParam, ResumeOptions } from './interfaces.js';
31-
import type { JourneyLoginFailure } from './login-failure.utils.js';
32-
import type { JourneyLoginSuccess } from './login-success.utils.js';
33-
34-
/** Result type for journey client methods. */
35-
export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError;
3633

3734
/** The journey client instance returned by the `journey()` function. */
3835
export interface JourneyClient {
@@ -158,28 +155,22 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
158155
subscribe: store.subscribe,
159156

160157
start: async (options?: StartParam) => {
161-
const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
162-
const result = handleJourneyResponse(data, error);
163-
if ('error' in result) {
164-
return result;
165-
}
166-
167-
return createJourneyObject(result);
158+
const response = await store.dispatch(journeyApi.endpoints.start.initiate(options));
159+
return match(parseJourneyResponse(response), {
160+
onLeft: (err): JourneyResult => err,
161+
onRight: (step): JourneyResult => createJourneyObject(step),
162+
});
168163
},
169164

170165
/**
171166
* Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey.
172167
*/
173168
next: async (step: JourneyStep, options?: NextOptions) => {
174-
const { data, error } = await store.dispatch(
175-
journeyApi.endpoints.next.initiate({ step, options }),
176-
);
177-
const result = handleJourneyResponse(data, error);
178-
if ('error' in result) {
179-
return result;
180-
}
181-
182-
return createJourneyObject(result);
169+
const response = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options }));
170+
return match(parseJourneyResponse(response), {
171+
onLeft: (err): JourneyResult => err,
172+
onRight: (step): JourneyResult => createJourneyObject(step),
173+
});
183174
},
184175

185176
// TODO: Remove the actual redirect from this method and just return the URL to the caller

packages/journey-client/src/lib/journey.utils.test.ts

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
1010
import { StepType } from '../types.js';
1111
import { type Step } from '../index.js';
1212

13-
import { createJourneyObject, handleJourneyResponse } from './journey.utils.js';
13+
import { createJourneyObject, parseJourneyResponse } from './journey.utils.js';
1414
import type { JourneyLoginFailure } from './login-failure.utils.js';
1515

1616
describe('createJourneyObject', () => {
@@ -63,85 +63,99 @@ describe('createJourneyObject', () => {
6363
});
6464
});
6565

66-
describe('handleJourneyResponse', () => {
67-
it('returns Step data when FetchBaseQueryError has numeric status and object body', () => {
66+
describe('parseJourneyResponse', () => {
67+
it('returns right(Step) when FetchBaseQueryError has numeric status and object body', () => {
6868
const body = { code: 401, message: 'Access Denied', reason: 'Unauthorized' };
6969
const error = { status: 401, data: body };
7070

71-
const result = handleJourneyResponse(undefined, error);
71+
const result = parseJourneyResponse({ data: undefined, error });
7272

73-
expect(result).toBe(body);
73+
expect(result._tag).toBe('Right');
74+
expect((result as { right: unknown }).right).toBe(body);
7475
});
7576

76-
it('returns GenericError when FetchBaseQueryError has numeric status but non-object body', () => {
77+
it('returns left(GenericError) when FetchBaseQueryError has numeric status but non-object body', () => {
7778
const error = { status: 500, data: 'Internal Server Error' };
7879

79-
const result = handleJourneyResponse(undefined, error);
80+
const result = parseJourneyResponse({ data: undefined, error });
8081

81-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
82+
expect(result._tag).toBe('Left');
83+
expect((result as { left: unknown }).left).toMatchObject({
84+
error: 'request_failed',
85+
type: 'unknown_error',
86+
});
8287
});
8388

84-
it('returns GenericError for FETCH_ERROR', () => {
89+
it('returns left(GenericError) for FETCH_ERROR', () => {
8590
const error = { status: 'FETCH_ERROR' as const, error: 'Network error' };
8691

87-
const result = handleJourneyResponse(undefined, error);
92+
const result = parseJourneyResponse({ data: undefined, error });
8893

89-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
90-
expect((result as { message: string }).message).toContain('Network error');
94+
expect(result._tag).toBe('Left');
95+
expect((result as { left: { message: string } }).left.message).toContain('Network error');
9196
});
9297

93-
it('returns GenericError for PARSING_ERROR', () => {
98+
it('returns left(GenericError) for PARSING_ERROR', () => {
9499
const error = {
95100
status: 'PARSING_ERROR' as const,
96101
originalStatus: 200,
97102
data: '<html>Not JSON</html>',
98103
error: 'JSON parse error',
99104
};
100105

101-
const result = handleJourneyResponse(undefined, error);
106+
const result = parseJourneyResponse({ data: undefined, error });
102107

103-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
104-
expect((result as { message: string }).message).toContain('JSON parse error');
108+
expect(result._tag).toBe('Left');
109+
expect((result as { left: { message: string } }).left.message).toContain('JSON parse error');
105110
});
106111

107-
it('returns GenericError for TIMEOUT_ERROR', () => {
112+
it('returns left(GenericError) for TIMEOUT_ERROR', () => {
108113
const error = { status: 'TIMEOUT_ERROR' as const, error: 'Request timed out' };
109114

110-
const result = handleJourneyResponse(undefined, error);
115+
const result = parseJourneyResponse({ data: undefined, error });
111116

112-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
113-
expect((result as { message: string }).message).toContain('Request timed out');
117+
expect(result._tag).toBe('Left');
118+
expect((result as { left: { message: string } }).left.message).toContain('Request timed out');
114119
});
115120

116-
it('returns GenericError for CUSTOM_ERROR', () => {
121+
it('returns left(GenericError) for CUSTOM_ERROR', () => {
117122
const error = { status: 'CUSTOM_ERROR' as const, error: 'Custom error occurred' };
118123

119-
const result = handleJourneyResponse(undefined, error);
124+
const result = parseJourneyResponse({ data: undefined, error });
120125

121-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
122-
expect((result as { message: string }).message).toContain('Custom error occurred');
126+
expect(result._tag).toBe('Left');
127+
expect((result as { left: { message: string } }).left.message).toContain(
128+
'Custom error occurred',
129+
);
123130
});
124131

125-
it('returns GenericError for SerializedError', () => {
132+
it('returns left(GenericError) for SerializedError', () => {
126133
const error = { name: 'Error', message: 'Something went wrong', stack: '...' };
127134

128-
const result = handleJourneyResponse(undefined, error);
135+
const result = parseJourneyResponse({ data: undefined, error });
129136

130-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
131-
expect((result as { message: string }).message).toContain('Something went wrong');
137+
expect(result._tag).toBe('Left');
138+
expect((result as { left: { message: string } }).left.message).toContain(
139+
'Something went wrong',
140+
);
132141
});
133142

134-
it('returns GenericError when no data and no error', () => {
135-
const result = handleJourneyResponse(undefined, undefined);
143+
it('returns left(GenericError) when no data and no error', () => {
144+
const result = parseJourneyResponse({ data: undefined, error: undefined });
136145

137-
expect(result).toMatchObject({ error: 'no_response_data', type: 'unknown_error' });
146+
expect(result._tag).toBe('Left');
147+
expect((result as { left: unknown }).left).toMatchObject({
148+
error: 'no_response_data',
149+
type: 'unknown_error',
150+
});
138151
});
139152

140-
it('returns data when no error and data is present', () => {
153+
it('returns right(Step) when no error and data is present', () => {
141154
const data: Step = { authId: 'test-auth-id', callbacks: [] };
142155

143-
const result = handleJourneyResponse(data, undefined);
156+
const result = parseJourneyResponse({ data, error: undefined });
144157

145-
expect(result).toBe(data);
158+
expect(result._tag).toBe('Right');
159+
expect((result as { right: unknown }).right).toBe(data);
146160
});
147161
});

packages/journey-client/src/lib/journey.utils.ts

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* of the MIT license. See the LICENSE file for details.
66
*/
77

8+
import { left, right, type Either } from 'effect/Either';
9+
810
import { StepType } from '@forgerock/sdk-types';
911

1012
import type { GenericError, Step } from '@forgerock/sdk-types';
@@ -19,6 +21,8 @@ import type { JourneyStep } from './step.utils.js';
1921
import type { JourneyLoginFailure } from './login-failure.utils.js';
2022
import type { JourneyLoginSuccess } from './login-success.utils.js';
2123

24+
export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError;
25+
2226
/**
2327
* Creates a journey object from a raw Step response.
2428
* Determines the step type based on the presence of authId or successUrl properties
@@ -56,53 +60,51 @@ export function createJourneyObject(
5660
}
5761

5862
/**
59-
* Resolves an RTK Query response to a Step or GenericError.
60-
*
61-
* @param data - The Step data returned by the RTK Query endpoint, if any
62-
* @param error - The error returned by the RTK Query endpoint, if any
63-
* @returns Step on success, GenericError on failure
63+
* Parses a resolved RTK Query journey response into Either<Step, GenericError>.
64+
* Right = valid Step to classify; Left = infrastructure failure.
6465
*/
65-
export function handleJourneyResponse(
66-
data: Step | undefined,
67-
error: FetchBaseQueryError | SerializedError | undefined,
68-
): Step | GenericError {
69-
/**
70-
* https://redux-toolkit.js.org/rtk-query/api/fetchBaseQuery#signature
71-
* FetchBaseQueryError with status: number means AM returned an HTTP response with a JSON body.
72-
* Only this variant can carry an AM failure payload — FETCH_ERROR, PARSING_ERROR, TIMEOUT_ERROR,
73-
* and CUSTOM_ERROR either have no body or a non-object body (raw string for PARSING_ERROR).
74-
*/
75-
if (
76-
error &&
77-
'status' in error &&
78-
typeof error.status === 'number' &&
79-
typeof error.data === 'object' &&
80-
error.data !== null
81-
) {
82-
return error.data as Step;
66+
export function parseJourneyResponse(res: {
67+
data?: Step;
68+
error?: FetchBaseQueryError | SerializedError;
69+
}): Either<Step, GenericError> {
70+
// https://redux-toolkit.js.org/rtk-query/usage-with-typescript#type-safe-error-handling
71+
// FetchBaseQueryError non-HTTP variants expose `error` string
72+
if (res.error && 'error' in res.error) {
73+
return left({
74+
error: 'request_failed',
75+
message: `Request failed: ${res.error.error}`,
76+
type: 'unknown_error',
77+
});
8378
}
8479

85-
/**
86-
* https://redux-toolkit.js.org/rtk-query/usage-with-typescript#type-safe-error-handling
87-
* All other FetchBaseQueryError variants expose an `error` string; SerializedError exposes `message`.
88-
* Both represent infrastructure failures with no usable AM response body.
89-
*/
90-
if (error) {
91-
const msg = 'error' in error ? error.error : 'message' in error ? error.message : undefined;
92-
return {
80+
// SerializedError exposes `message`
81+
if (res.error && 'message' in res.error) {
82+
return left({
9383
error: 'request_failed',
94-
message: `Request failed: ${msg ?? 'Unknown error'}`,
84+
message: `Request failed: ${res.error.message ?? 'Unknown error'}`,
9585
type: 'unknown_error',
96-
};
86+
});
9787
}
9888

99-
if (!data) {
100-
return {
89+
if (!res.data) {
90+
return left({
10191
error: 'no_response_data',
10292
message: 'No data received from server',
10393
type: 'unknown_error',
104-
};
94+
});
95+
}
96+
97+
// https://redux-toolkit.js.org/rtk-query/api/fetchBaseQuery#signature
98+
// FetchBaseQueryError with numeric status + object body = AM failure step over HTTP error
99+
if (
100+
res.error &&
101+
'status' in res.error &&
102+
typeof res.error.status === 'number' &&
103+
typeof res.error.data === 'object' &&
104+
res.error.data !== null
105+
) {
106+
return right(res.error.data);
105107
}
106108

107-
return data;
109+
return right(res.data);
108110
}

packages/journey-client/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export * from './lib/callbacks/validated-create-username-callback.js';
6161

6262
// Re-export types used in public API signatures that aren't covered above
6363
export type { DeviceProfileData, Geolocation } from './lib/device/interfaces.js';
64-
export type { JourneyResult } from './lib/client.store.js';
64+
export type { JourneyResult } from './lib/journey.utils.js';
6565
export type { ResolvedServerConfig } from './lib/wellknown.utils.js';
6666
export type { JourneyLoginSuccess } from './lib/login-success.utils.js';
6767
export type { JourneyLoginFailure } from './lib/login-failure.utils.js';

pnpm-lock.yaml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)