Skip to content

Commit

Permalink
feat: abstract validation and add new resultMode for allWithoutResponse
Browse files Browse the repository at this point in the history
This commit introduces data validation for both success and error responses using schemas and validators. It also refactors error handling to provide more context and flexibility.

- Implemented `handleValidation` function to validate response data against schemas and validators.
- Modified `createFetchClient` and `createFetchClientWithOptions` to include validation of success and error data.
- Updated `resolveErrorResult` to provide more detailed error information.
- Refactored dedupe strategy to improve readability and maintainability.
- Added `omitKeys` and `pickKeys` utility functions for object manipulation.
- Updated types and contexts to reflect the changes in error handling and validation.
- Increased size limit in `package.json` to accommodate the new features.
  • Loading branch information
Ryan-Zayne committed Mar 1, 2025
1 parent ee06427 commit da89812
Show file tree
Hide file tree
Showing 9 changed files with 120 additions and 98 deletions.
2 changes: 1 addition & 1 deletion packages/callapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
"size-limit": [
{
"path": "./src/index.ts",
"limit": "3.66 kb"
"limit": "4 kb"
},
{
"path": "./src/options/index.ts",
Expand Down
62 changes: 31 additions & 31 deletions packages/callapi/src/createFetchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import type {
CallApiRequestOptions,
CallApiRequestOptionsForHooks,
CombinedCallApiExtraOptions,
ErrorContext,
GetCallApiResult,
Interceptors,
ResultModeUnion,
SuccessContext,
} from "./types/common";
import type {
DefaultDataType,
Expand All @@ -35,6 +37,7 @@ import {
type CallApiSchemas,
type InferSchemaResult,
createExtensibleSchemasAndValidators,
handleValidation,
} from "./validation";

export const createFetchClient = <
Expand Down Expand Up @@ -172,13 +175,10 @@ export const createFetchClient = <
signal: combinedSignal,
} satisfies CallApiRequestOptionsForHooks;

const {
handleRequestCancelDedupeStrategy,
handleRequestDeferDedupeStrategy,
removeDedupeKeyFromCache,
} = await createDedupeStrategy({ $RequestInfoCache, newFetchController, options, request });
const { handleRequestCancelStrategy, handleRequestDeferStrategy, removeDedupeKeyFromCache } =
await createDedupeStrategy({ $RequestInfoCache, newFetchController, options, request });

handleRequestCancelDedupeStrategy();
await handleRequestCancelStrategy();

try {
await executeHooks(options.onRequest({ options, request }));
Expand All @@ -191,7 +191,7 @@ export const createFetchClient = <
headers: request.headers,
});

const response = await handleRequestDeferDedupeStrategy();
const response = await handleRequestDeferStrategy();

// == Also clone response when dedupeStrategy is set to "defer", to avoid error thrown from reading response.(whatever) more than once
const shouldCloneResponse = options.dedupeStrategy === "defer" || options.cloneResponse;
Expand All @@ -202,33 +202,37 @@ export const createFetchClient = <
const errorData = await resolveResponseData<TErrorData>(
shouldCloneResponse ? response.clone() : response,
options.responseType,
options.responseParser,
options.responseParser
);

const validErrorData = await handleValidation(
errorData,
schemas?.errorData,
validators?.errorData
);

// == Push all error handling responsibilities to the catch block if not retrying
throw new HTTPError({
defaultErrorMessage: options.defaultErrorMessage,
errorData,
errorData: validErrorData,
response,
});
}

const successData = await resolveResponseData<TData>(
shouldCloneResponse ? response.clone() : response,
options.responseType,
options.responseParser,
schemas?.data,
validators?.data
options.responseParser
);

const validSuccessData = await handleValidation(successData, schemas?.data, validators?.data);

const successContext = {
data: successData as never,
data: validSuccessData,
options,
request,
response: options.cloneResponse ? response.clone() : response,
};
} satisfies SuccessContext<unknown>;

await executeHooks(
options.onSuccess(successContext),
Expand All @@ -244,30 +248,26 @@ export const createFetchClient = <

// == Exhaustive Error handling
} catch (error) {
const { errorVariantDetails, getErrorResult } = resolveErrorResult({
const { apiDetails, getErrorResult } = resolveErrorResult({
cloneResponse: options.cloneResponse,
defaultErrorMessage: options.defaultErrorMessage,
error,
resultMode: options.resultMode,
});

const errorContext = {
error: errorVariantDetails.error as never,
error: apiDetails.error as never,
options,
request,
};

const errorContextWithResponse = {
...errorContext,
response: errorVariantDetails.response as NonNullable<typeof errorVariantDetails.response>,
};
response: apiDetails.response as never,
} satisfies ErrorContext<unknown>;

const { getDelay, shouldAttemptRetry } = createRetryStrategy(options, errorContextWithResponse);
const { getDelay, shouldAttemptRetry } = createRetryStrategy(options, errorContext);

const shouldRetry = !combinedSignal.aborted && (await shouldAttemptRetry());

if (shouldRetry) {
await executeHooks(options.onRetry(errorContextWithResponse));
await executeHooks(options.onRetry(errorContext));

const delay = getDelay();

Expand All @@ -282,24 +282,24 @@ export const createFetchClient = <
}

const shouldThrowOnError = isFunction(options.throwOnError)
? options.throwOnError(errorContextWithResponse)
? options.throwOnError(errorContext)
: options.throwOnError;

// eslint-disable-next-line unicorn/consistent-function-scoping -- False alarm: this function is depends on this scope
const handleThrowOnError = () => {
if (!shouldThrowOnError) return;

// eslint-disable-next-line ts-eslint/only-throw-error -- It's fine to throw this
throw errorVariantDetails.error;
throw apiDetails.error;
};

if (isHTTPErrorInstance<TErrorData>(error)) {
await executeHooks(
options.onResponseError(errorContextWithResponse),
options.onResponseError(errorContext),

options.onError(errorContextWithResponse),
options.onError(errorContext),

options.onResponse({ ...errorContextWithResponse, data: null })
options.onResponse({ ...errorContext, data: null })
);

handleThrowOnError();
Expand Down Expand Up @@ -329,10 +329,10 @@ export const createFetchClient = <

await executeHooks(
// == At this point only the request errors exist, so the request error interceptor is called
options.onRequestError(errorContext),
options.onRequestError(errorContext as never),

// == Also call the onError interceptor
options.onError(errorContextWithResponse)
options.onError(errorContext)
);

handleThrowOnError();
Expand Down
25 changes: 14 additions & 11 deletions packages/callapi/src/dedupe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,24 @@ export const createDedupeStrategy = async (context: DedupeContext) => {

const prevRequestInfo = $RequestInfoCacheOrNull?.get(dedupeKey);

const handleRequestCancelDedupeStrategy = () => {
const handleRequestCancelStrategy = () => {
const shouldCancelRequest = prevRequestInfo && options.dedupeStrategy === "cancel";

if (shouldCancelRequest) {
const message = options.dedupeKey
? `Duplicate request detected - Aborting previous request with key '${options.dedupeKey}' as a new request was initiated`
: `Duplicate request detected - Aborting previous request to '${options.fullURL}' as a new request with identical options was initiated`;
if (!shouldCancelRequest) return;

const reason = new DOMException(message, "AbortError");
const message = options.dedupeKey
? `Duplicate request detected - Aborting previous request with key '${options.dedupeKey}' as a new request was initiated`
: `Duplicate request detected - Aborting previous request to '${options.fullURL}' as a new request with identical options was initiated`;

prevRequestInfo.controller.abort(reason);
}
const reason = new DOMException(message, "AbortError");

prevRequestInfo.controller.abort(reason);

// == Adding this just so that eslint forces me put await when calling the function (it looks better that way tbh)
return Promise.resolve();
};

const handleRequestDeferDedupeStrategy = () => {
const handleRequestDeferStrategy = () => {
const fetchApi = getFetchImpl(options.customFetchImpl);

const shouldUsePromiseFromCache = prevRequestInfo && options.dedupeStrategy === "defer";
Expand All @@ -75,8 +78,8 @@ export const createDedupeStrategy = async (context: DedupeContext) => {
const removeDedupeKeyFromCache = () => $RequestInfoCacheOrNull?.delete(dedupeKey);

return {
handleRequestCancelDedupeStrategy,
handleRequestDeferDedupeStrategy,
handleRequestCancelStrategy,
handleRequestDeferStrategy,
removeDedupeKeyFromCache,
};
};
22 changes: 12 additions & 10 deletions packages/callapi/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
PossibleJavascriptErrorNames,
ResultModeMap,
} from "./types/common";
import { omitKeys } from "./utils/common";
import { isHTTPErrorInstance, isObject } from "./utils/type-guards";

type ErrorInfo = {
Expand All @@ -17,7 +18,7 @@ type ErrorInfo = {
export const resolveErrorResult = <TCallApiResult = never>(info: ErrorInfo) => {
const { cloneResponse, defaultErrorMessage, error, message: customErrorMessage, resultMode } = info;

let errorVariantDetails: CallApiResultErrorVariant<unknown> = {
let apiDetails: CallApiResultErrorVariant<unknown> = {
data: null,
error: {
errorData: error as Error,
Expand All @@ -30,7 +31,7 @@ export const resolveErrorResult = <TCallApiResult = never>(info: ErrorInfo) => {
if (isHTTPErrorInstance<never>(error)) {
const { errorData, message = defaultErrorMessage, name, response } = error;

errorVariantDetails = {
apiDetails = {
data: null,
error: {
errorData,
Expand All @@ -42,13 +43,14 @@ export const resolveErrorResult = <TCallApiResult = never>(info: ErrorInfo) => {
}

const resultModeMap = {
all: errorVariantDetails,
allWithException: errorVariantDetails as never,
onlyError: errorVariantDetails.error,
onlyResponse: errorVariantDetails.response,
onlyResponseWithException: errorVariantDetails.response as never,
onlySuccess: errorVariantDetails.data,
onlySuccessWithException: errorVariantDetails.data,
all: apiDetails,
allWithException: apiDetails as never,
allWithoutResponse: omitKeys(apiDetails, ["response"]),
onlyError: apiDetails.error,
onlyResponse: apiDetails.response,
onlyResponseWithException: apiDetails.response as never,
onlySuccess: apiDetails.data,
onlySuccessWithException: apiDetails.data,
} satisfies ResultModeMap;

const getErrorResult = (customInfo?: Pick<ErrorInfo, "message">) => {
Expand All @@ -57,7 +59,7 @@ export const resolveErrorResult = <TCallApiResult = never>(info: ErrorInfo) => {
return isObject(customInfo) ? { ...errorResult, ...customInfo } : errorResult;
};

return { errorVariantDetails, getErrorResult };
return { apiDetails, getErrorResult };
};

type ErrorDetails<TErrorResponse> = {
Expand Down
Loading

0 comments on commit da89812

Please sign in to comment.