Skip to content

Commit d34702c

Browse files
committed
(ts-api-react-actions) Enhance error handling in API actions and hooks with user-facing error mapping
1 parent ee03845 commit d34702c

File tree

4 files changed

+88
-21
lines changed

4 files changed

+88
-21
lines changed

packages/ts-api-react-actions/src/ApiAction.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useCallback } from "react";
22

33
import {
4-
type ApiEndpointProps,
5-
ApiError,
4+
ApiEndpointProps,
5+
UserFacingError,
6+
mapApiErrorToUserFacingError,
67
} from "@thunderstore/thunderstore-api";
7-
import { type ApiEndpoint } from "@thunderstore/ts-api-react";
8+
import { ApiEndpoint, UseApiCallOptions } from "@thunderstore/ts-api-react";
89

910
import { useApiAction } from "./useApiAction";
1011

@@ -15,25 +16,26 @@ export interface ApiActionProps<
1516
Return,
1617
> {
1718
endpoint: ApiEndpoint<Params, QueryParams, Data, Return>;
19+
apiCallOptions?: UseApiCallOptions;
1820
onSubmitSuccess?: (result: Awaited<Return>) => void;
19-
onSubmitError?: (error: Error | ApiError) => void;
21+
onSubmitError?: (error: UserFacingError) => void;
2022
}
2123

22-
// As of this moment ApiActions sole purpose is to gracefully handle errors from API calls
2324
export function ApiAction<
2425
Params extends object,
2526
QueryParams extends object,
2627
Data extends object,
2728
Return,
2829
>(props: ApiActionProps<Params, QueryParams, Data, Return>) {
29-
const { endpoint, onSubmitSuccess, onSubmitError } = props;
30+
const { endpoint, onSubmitSuccess, onSubmitError, apiCallOptions } = props;
3031
const submitHandler = useApiAction<
3132
Params,
3233
QueryParams,
3334
Data,
3435
ReturnType<typeof endpoint>
3536
>({
3637
endpoint: endpoint,
38+
apiCallOptions,
3739
});
3840
const onSubmit = useCallback(
3941
async (onSubmitProps: ApiEndpointProps<Params, QueryParams, Data>) => {
@@ -43,14 +45,17 @@ export function ApiAction<
4345
onSubmitSuccess(result);
4446
}
4547
} catch (e) {
48+
const mappedError =
49+
e instanceof UserFacingError ? e : mapApiErrorToUserFacingError(e);
50+
4651
if (onSubmitError) {
47-
onSubmitError(e as Error | ApiError);
52+
onSubmitError(mappedError);
4853
} else {
49-
throw e;
54+
throw mappedError;
5055
}
5156
}
5257
},
53-
[onSubmitSuccess, onSubmitError]
58+
[onSubmitSuccess, onSubmitError, apiCallOptions, submitHandler]
5459
);
5560

5661
return onSubmit;
Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,38 @@
1+
import { useCallback } from "react";
2+
13
import { type ApiEndpointProps } from "@thunderstore/thunderstore-api";
2-
import { type ApiEndpoint, useApiCall } from "@thunderstore/ts-api-react";
4+
import {
5+
type ApiEndpoint,
6+
UseApiCallOptions,
7+
useApiCall,
8+
} from "@thunderstore/ts-api-react";
39

410
export type UseApiActionArgs<Params, QueryParams, Data, Return> = {
511
endpoint: ApiEndpoint<Params, QueryParams, Data, Return>;
12+
apiCallOptions?: UseApiCallOptions;
613
};
714

15+
/**
16+
* Hook that adapts `useApiCall` to an async submit handler suitable for form actions.
17+
*/
818
export function useApiAction<Params, QueryParams, Data, Return>(
919
args: UseApiActionArgs<Params, QueryParams, Data, Return>
10-
) {
11-
const apiCall = useApiCall<Params, QueryParams, Data, Return>(args.endpoint);
20+
): (
21+
props: ApiEndpointProps<Params, QueryParams, Data>
22+
) => Promise<Awaited<Return>> {
23+
const apiCall = useApiCall<Params, QueryParams, Data, Return>(
24+
args.endpoint,
25+
args.apiCallOptions
26+
);
1227

13-
const submitHandler = async (
14-
props: ApiEndpointProps<Params, QueryParams, Data>
15-
) => {
16-
return await apiCall(props);
17-
};
28+
const submitHandler = useCallback(
29+
async (
30+
props: ApiEndpointProps<Params, QueryParams, Data>
31+
): Promise<Awaited<Return>> => {
32+
return await apiCall(props);
33+
},
34+
[apiCall, args]
35+
);
1836

1937
return submitHandler;
2038
}

packages/ts-api-react/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ export {
1818
export type { ContextInterface } from "./SessionContext";
1919
export { StorageManager as NamespacedStorageManager } from "./storage";
2020
export { useApiCall } from "./useApiCall";
21-
export type { ApiEndpoint } from "./useApiCall";
21+
export type { ApiEndpoint, UseApiCallOptions } from "./useApiCall";
Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,57 @@
1-
import { type ApiEndpointProps } from "@thunderstore/thunderstore-api";
1+
import {
2+
type ApiEndpointProps,
3+
MapUserFacingErrorOptions,
4+
mapApiErrorToUserFacingError,
5+
} from "@thunderstore/thunderstore-api";
26

7+
/**
8+
* Describes an API endpoint invocation with typed params and data payload.
9+
*/
310
export type ApiEndpoint<Params, QueryParams, Data, Return> = (
411
props: ApiEndpointProps<Params, QueryParams, Data>
512
) => Return;
613

14+
/**
15+
* Options that control automatic error mapping for `useApiCall`.
16+
*/
17+
export type UseApiCallOptions = {
18+
mapErrors?: boolean;
19+
errorOptions?: MapUserFacingErrorOptions;
20+
};
21+
22+
/**
23+
* Wraps API endpoints to optionally map thrown errors into `UserFacingError` instances.
24+
*/
725
export function useApiCall<Params, QueryParams, Data, Return>(
8-
endpoint: ApiEndpoint<Params, QueryParams, Data, Return>
26+
endpoint: ApiEndpoint<Params, QueryParams, Data, Return>,
27+
options: UseApiCallOptions = {}
928
): (props: ApiEndpointProps<Params, QueryParams, Data>) => Return {
29+
const shouldMapErrors = options.mapErrors ?? true;
30+
1031
return (props: ApiEndpointProps<Params, QueryParams, Data>) => {
11-
return endpoint(props);
32+
try {
33+
const result = endpoint(props);
34+
35+
if (shouldMapErrors && isPromise(result)) {
36+
return result.catch((error) => {
37+
throw mapApiErrorToUserFacingError(error, options.errorOptions);
38+
}) as Return;
39+
}
40+
41+
return result;
42+
} catch (error) {
43+
if (!shouldMapErrors) {
44+
throw error;
45+
}
46+
throw mapApiErrorToUserFacingError(error, options.errorOptions);
47+
}
1248
};
1349
}
50+
51+
function isPromise(value: unknown): value is Promise<unknown> {
52+
return (
53+
typeof value === "object" &&
54+
value !== null &&
55+
typeof (value as Promise<unknown>).then === "function"
56+
);
57+
}

0 commit comments

Comments
 (0)