Skip to content

Commit 78ccc0e

Browse files
committed
fix(journey-client): use Effect Either to handle journey response in start and next
1 parent a6e704b commit 78ccc0e

6 files changed

Lines changed: 80 additions & 45 deletions

File tree

lefthook.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ pre-commit:
33
nx-sync:
44
run: pnpm nx sync
55
nx-check:
6-
run: pnpm nx affected -t typecheck lint build api-report --tui=false
6+
run: env -u GIT_DIR pnpm nx affected -t typecheck lint build api-report --tui=false
77
stage_fixed: true
88
format:
99
run: pnpm nx format:write

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": "^2.3.0"
4344
},
4445
"devDependencies": {

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

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

8+
import { Either } from 'effect';
89
import { logger as loggerFn, LogLevel, CustomLogger } from '@forgerock/sdk-logger';
910
import { callbackType } from '@forgerock/sdk-types';
1011
import {
@@ -156,12 +157,10 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
156157
const self: JourneyClient = {
157158
start: async (options?: StartParam) => {
158159
const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
159-
const result = handleJourneyResponse(data, error);
160-
if ('error' in result) {
161-
return result;
162-
}
163-
164-
return createJourneyObject(result);
160+
return Either.match(handleJourneyResponse(data, error), {
161+
onLeft: (err) => err,
162+
onRight: (step) => createJourneyObject(step),
163+
});
165164
},
166165

167166
/**
@@ -171,12 +170,10 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
171170
const { data, error } = await store.dispatch(
172171
journeyApi.endpoints.next.initiate({ step, options }),
173172
);
174-
const result = handleJourneyResponse(data, error);
175-
if ('error' in result) {
176-
return result;
177-
}
178-
179-
return createJourneyObject(result);
173+
return Either.match(handleJourneyResponse(data, error), {
174+
onLeft: (err) => err,
175+
onRight: (step) => createJourneyObject(step),
176+
});
180177
},
181178

182179
// 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: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* of the MIT license. See the LICENSE file for details.
66
*/
77

8+
import { Either } from 'effect';
89
import { describe, expect, it } from 'vitest';
910

1011
import { StepType } from '../types.js';
@@ -64,33 +65,42 @@ describe('createJourneyObject', () => {
6465
});
6566

6667
describe('handleJourneyResponse', () => {
67-
it('returns Step data when FetchBaseQueryError has numeric status and object body', () => {
68+
it('returns Right(Step) when FetchBaseQueryError has numeric status and object body', () => {
6869
const body = { code: 401, message: 'Access Denied', reason: 'Unauthorized' };
6970
const error = { status: 401, data: body };
7071

7172
const result = handleJourneyResponse(undefined, error);
7273

73-
expect(result).toBe(body);
74+
expect(Either.isRight(result)).toBe(true);
75+
if (Either.isRight(result)) {
76+
expect(result.right).toBe(body);
77+
}
7478
});
7579

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

7983
const result = handleJourneyResponse(undefined, error);
8084

81-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
85+
expect(Either.isLeft(result)).toBe(true);
86+
if (Either.isLeft(result)) {
87+
expect(result.left).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
88+
}
8289
});
8390

84-
it('returns GenericError for FETCH_ERROR', () => {
91+
it('returns Left(GenericError) for FETCH_ERROR', () => {
8592
const error = { status: 'FETCH_ERROR' as const, error: 'Network error' };
8693

8794
const result = handleJourneyResponse(undefined, error);
8895

89-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
90-
expect((result as { message: string }).message).toContain('Network error');
96+
expect(Either.isLeft(result)).toBe(true);
97+
if (Either.isLeft(result)) {
98+
expect(result.left).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
99+
expect(result.left.message).toContain('Network error');
100+
}
91101
});
92102

93-
it('returns GenericError for PARSING_ERROR', () => {
103+
it('returns Left(GenericError) for PARSING_ERROR', () => {
94104
const error = {
95105
status: 'PARSING_ERROR' as const,
96106
originalStatus: 200,
@@ -100,48 +110,66 @@ describe('handleJourneyResponse', () => {
100110

101111
const result = handleJourneyResponse(undefined, error);
102112

103-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
104-
expect((result as { message: string }).message).toContain('JSON parse error');
113+
expect(Either.isLeft(result)).toBe(true);
114+
if (Either.isLeft(result)) {
115+
expect(result.left).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
116+
expect(result.left.message).toContain('JSON parse error');
117+
}
105118
});
106119

107-
it('returns GenericError for TIMEOUT_ERROR', () => {
120+
it('returns Left(GenericError) for TIMEOUT_ERROR', () => {
108121
const error = { status: 'TIMEOUT_ERROR' as const, error: 'Request timed out' };
109122

110123
const result = handleJourneyResponse(undefined, error);
111124

112-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
113-
expect((result as { message: string }).message).toContain('Request timed out');
125+
expect(Either.isLeft(result)).toBe(true);
126+
if (Either.isLeft(result)) {
127+
expect(result.left).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
128+
expect(result.left.message).toContain('Request timed out');
129+
}
114130
});
115131

116-
it('returns GenericError for CUSTOM_ERROR', () => {
132+
it('returns Left(GenericError) for CUSTOM_ERROR', () => {
117133
const error = { status: 'CUSTOM_ERROR' as const, error: 'Custom error occurred' };
118134

119135
const result = handleJourneyResponse(undefined, error);
120136

121-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
122-
expect((result as { message: string }).message).toContain('Custom error occurred');
137+
expect(Either.isLeft(result)).toBe(true);
138+
if (Either.isLeft(result)) {
139+
expect(result.left).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
140+
expect(result.left.message).toContain('Custom error occurred');
141+
}
123142
});
124143

125-
it('returns GenericError for SerializedError', () => {
144+
it('returns Left(GenericError) for SerializedError', () => {
126145
const error = { name: 'Error', message: 'Something went wrong', stack: '...' };
127146

128147
const result = handleJourneyResponse(undefined, error);
129148

130-
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
131-
expect((result as { message: string }).message).toContain('Something went wrong');
149+
expect(Either.isLeft(result)).toBe(true);
150+
if (Either.isLeft(result)) {
151+
expect(result.left).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
152+
expect(result.left.message).toContain('Something went wrong');
153+
}
132154
});
133155

134-
it('returns GenericError when no data and no error', () => {
156+
it('returns Left(GenericError) when no data and no error', () => {
135157
const result = handleJourneyResponse(undefined, undefined);
136158

137-
expect(result).toMatchObject({ error: 'no_response_data', type: 'unknown_error' });
159+
expect(Either.isLeft(result)).toBe(true);
160+
if (Either.isLeft(result)) {
161+
expect(result.left).toMatchObject({ error: 'no_response_data', type: 'unknown_error' });
162+
}
138163
});
139164

140-
it('returns data when no error and data is present', () => {
165+
it('returns Right(Step) when no error and data is present', () => {
141166
const data: Step = { authId: 'test-auth-id', callbacks: [] };
142167

143168
const result = handleJourneyResponse(data, undefined);
144169

145-
expect(result).toBe(data);
170+
expect(Either.isRight(result)).toBe(true);
171+
if (Either.isRight(result)) {
172+
expect(result.right).toBe(data);
173+
}
146174
});
147175
});

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

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

8+
import { Either } from 'effect';
89
import { StepType } from '@forgerock/sdk-types';
910

1011
import type { GenericError, Step } from '@forgerock/sdk-types';
@@ -56,16 +57,19 @@ export function createJourneyObject(
5657
}
5758

5859
/**
59-
* Resolves an RTK Query response to a Step or GenericError.
60+
* Resolves an RTK Query response to an Either of Step or GenericError.
61+
*
62+
* Right(Step) carries either a successful response or an HTTP failure body
63+
* that the caller will narrow via createJourneyObject.
64+
* Left(GenericError) represents infrastructure failures with no usable AM body.
6065
*
6166
* @param data - The Step data returned by the RTK Query endpoint, if any
6267
* @param error - The error returned by the RTK Query endpoint, if any
63-
* @returns Step on success, GenericError on failure
6468
*/
6569
export function handleJourneyResponse(
6670
data: Step | undefined,
6771
error: FetchBaseQueryError | SerializedError | undefined,
68-
): Step | GenericError {
72+
): Either.Either<Step, GenericError> {
6973
/**
7074
* https://redux-toolkit.js.org/rtk-query/api/fetchBaseQuery#signature
7175
* FetchBaseQueryError with status: number means AM returned an HTTP response with a JSON body.
@@ -79,7 +83,7 @@ export function handleJourneyResponse(
7983
typeof error.data === 'object' &&
8084
error.data !== null
8185
) {
82-
return error.data as Step;
86+
return Either.right(error.data as Step);
8387
}
8488

8589
/**
@@ -89,20 +93,20 @@ export function handleJourneyResponse(
8993
*/
9094
if (error) {
9195
const msg = 'error' in error ? error.error : 'message' in error ? error.message : undefined;
92-
return {
96+
return Either.left({
9397
error: 'request_failed',
9498
message: `Request failed: ${msg ?? 'Unknown error'}`,
9599
type: 'unknown_error',
96-
};
100+
});
97101
}
98102

99103
if (!data) {
100-
return {
104+
return Either.left({
101105
error: 'no_response_data',
102106
message: 'No data received from server',
103107
type: 'unknown_error',
104-
};
108+
});
105109
}
106110

107-
return data;
111+
return Either.right(data);
108112
}

pnpm-lock.yaml

Lines changed: 5 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)