Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -303,4 +303,57 @@ describe("moduleFromEndpoints", () => {
})
);
});

it("should throw an error if the error handler is not provided", async () => {
expect.assertions(1);

const error = new Error("Test error");
const customHttpClient = jest.fn().mockRejectedValue(error);
const sdkConfig = {
commerce: buildModule(moduleFromEndpoints<Endpoints>, {
apiUrl: "http://localhost:8181/commerce",
httpClient: customHttpClient,
}),
};
const sdk = initSDK(sdkConfig);

try {
await sdk.commerce.getProduct({ id: 1 });
} catch (err) {
expect(err).toEqual(error);
}
});

it("should allow to use custom error handler", async () => {
const error = new Error("Test error");
const customErrorHandler = jest
.fn()
.mockResolvedValue({ id: 1, name: "Error handler did a good job" });
const customHttpClient = jest.fn().mockRejectedValue(error);
const sdkConfig = {
commerce: buildModule(moduleFromEndpoints<Endpoints>, {
apiUrl: "http://localhost:8181/commerce",
httpClient: customHttpClient,
errorHandler: customErrorHandler,
}),
};
const sdk = initSDK(sdkConfig);

const res = await sdk.commerce.getProduct({ id: 1 });
expect(customErrorHandler).toHaveBeenCalledWith({
error,
methodName: "getProduct",
url: "http://localhost:8181/commerce/getProduct",
params: [{ id: 1 }],
config: {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
},
httpClient: customHttpClient,
});
expect(res).toEqual({ id: 1, name: "Error handler did a good job" });
});
});
74 changes: 72 additions & 2 deletions packages/sdk/src/modules/moduleFromEndpoints/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,54 @@ export type HTTPClient = (
/**
* Parameters for the POST request.
*/
params: any[],
params: unknown[],
/**
* Config for the request.
*/
config?: ComputedConfig
) => Promise<any>;

/**
* Provides context for error handling, encapsulating details relevant to the failed HTTP request.
*/
export type ErrorHandlerContext = {
/**
* The error that was thrown during the HTTP request.
*/
error: unknown;
/**
* The name of the method that was called to make the HTTP request.
*/
methodName: string;
/**
* The URL of the HTTP request that resulted in an error.
*/
url: string;
/**
* The parameters passed to the HTTP POST request.
* @remarks
* This is only relevant for POST requests, as GET requests do not have a body.
* Query parameters are part of the URL and are not included here.
*/
params: unknown[];
/**
* The computed configuration used for the HTTP request, after processing user inputs.
*/
config: ComputedConfig;
/**
* The HTTP client function that was used to make the request.
* @remarks
* This allows for possible retry logic or logging.
*/
httpClient: HTTPClient;
};

/**
* Defines a generic error handler function type. This abstraction allows for custom error handling logic,
* which can be implemented by the consumer of the HTTP client.
*/
export type ErrorHandler = (context: ErrorHandlerContext) => Promise<any>;

/**
* Options for the `moduleFromEndpoints`.
*/
Expand All @@ -92,9 +133,10 @@ export type Options = {

/**
* Base URL for the API in the server side rendering.
* It's optional and it will use the `apiUrl` if it's not provided.
*
* @remarks
* It's optional and it will use the `apiUrl` if it's not provided.
*
* This may be useful during implementation of a multi-store feature based on domains.
*
* `apiUrl` could be set to `/api` and on the client side, the HTTP Client would use the current domain.
Expand All @@ -108,6 +150,7 @@ export type Options = {
/**
* Custom HTTP Client.
*
* @remarks
* It's optional and it will use the default HTTP Client if it's not provided.
*
* @example
Expand All @@ -134,6 +177,33 @@ export type Options = {
* Default request config for each request.
*/
defaultRequestConfig?: IncomingConfig;

/**
* An optional custom error handler for HTTP requests.
*
* @remarks
* If not provided, errors will be thrown as is.
*
* This enables custom error handling, like retrying the request or refreshing tokens, depending on the error type and details of the request that failed.
*
* @example
* ```typescript
* const options: Options = {
* apiUrl: "https://api.example.com",
* errorHandler: async ({ error, methodName, url, params, config, httpClient }) => {
* if (error.status === 401 && methodName !== "login") {
* // Refresh token
* await refreshToken();
* // Retry the request
* return httpClient(url, params, config);
* }
*
* throw error;
* },
* };
* ```
*/
errorHandler?: ErrorHandler;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BaseConfig,
ComputedConfig,
HTTPClient,
ErrorHandler,
} from "../types";

export const getHTTPClient = (options: Options) => {
Expand All @@ -12,7 +13,7 @@ export const getHTTPClient = (options: Options) => {
const getUrl = (
path: string,
method: BaseConfig["method"],
params: any[]
params: unknown[]
): string => {
// Determine the base URL based on the environment
const baseUrl =
Expand Down Expand Up @@ -63,7 +64,7 @@ export const getHTTPClient = (options: Options) => {

const defaultHTTPClient: HTTPClient = async (
url: string,
params: any[],
params: unknown[],
config?: ComputedConfig
) => {
const response = await fetch(url, {
Expand All @@ -74,15 +75,35 @@ export const getHTTPClient = (options: Options) => {
return response.json();
};

return async (methodName: string, params: any[], config?: IncomingConfig) => {
const { httpClient = defaultHTTPClient } = options;
const defaultErrorHandler: ErrorHandler = async ({ error }) => {
throw error;
};

return async (
methodName: string,
params: unknown[],
config?: IncomingConfig
) => {
const {
httpClient = defaultHTTPClient,
errorHandler = defaultErrorHandler,
} = options;
const { method = "POST", headers = {}, ...restConfig } = config ?? {};
const computedParams = method === "GET" ? [] : params;
const finalUrl = getUrl(methodName, method, params);
const finalConfig = getConfig({ method, headers, ...restConfig });

return httpClient(
getUrl(methodName, method, params),
computedParams,
getConfig({ method, headers, ...restConfig })
);
try {
return await httpClient(finalUrl, computedParams, finalConfig);
} catch (error) {
return await errorHandler({
error,
methodName,
url: finalUrl,
params: computedParams,
config: finalConfig,
httpClient,
});
}
};
};