Skip to content

Commit

Permalink
feat(interceptors)!: ✨ add array-based hook preservation
Browse files Browse the repository at this point in the history
- 🔒 feat: prevent base interceptors from being overridden when defined as arrays
- ♻️ refactor: change hook execution from Set to Array for better predictability
- 🔄 refactor: improve duplicate request error messages
- 📦 chore(deps): update Next.js to 15.1.3 and other dependencies
- 📝 docs: improve landing page description copy
  • Loading branch information
Ryan-Zayne committed Dec 30, 2024
1 parent 9635cfd commit d6a591f
Show file tree
Hide file tree
Showing 20 changed files with 870 additions and 730 deletions.
5 changes: 3 additions & 2 deletions dev/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createFetchClient, defineCallApiPlugin } from "@zayne-labs/callapi";
// import { createFetchClient, defineCallApiPlugin } from "./src";

const plugin = defineCallApiPlugin({
hooks: {
Expand All @@ -10,7 +9,8 @@ const plugin = defineCallApiPlugin({
});

const callApi = createFetchClient({
dedupeStrategy: "none",
dedupeStrategy: "cancel",
onRequest: [() => console.info("OnBaseRequest")],
plugins: [plugin],
});

Expand Down Expand Up @@ -46,6 +46,7 @@ const [foo1, foo2, foo3, foo4] = await Promise.all([
}),
callApi("https://dummyjson.com/products/:id", {
method: "GET",
onRequest: () => console.info("OnRequest"),
params: [1],
}),
]);
Expand Down
4 changes: 2 additions & 2 deletions docs/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export default function HomePage() {
<h1 className="mb-4 text-center text-5xl font-bold">CallApi</h1>

<p className="mx-auto max-w-2xl text-center text-muted-foreground">
A minimal Fetch API wrapper with dozens of convenience features Built for developers who
want a lightweight (like fetch()) but capable HTTP client.
A minimal Fetch API wrapper with dozens of convenience features. Built for developers who
want a lightweight but convenient interface for making HTTP requests.
</p>

<div className="flex w-full items-center justify-center gap-4 py-4">
Expand Down
10 changes: 5 additions & 5 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@
"@iconify-json/lucide": "^1.2.20",
"@iconify/react": "^5.1.0",
"@iconify/utils": "^2.2.1",
"@zayne-labs/toolkit": "^0.8.10",
"@zayne-labs/toolkit": "^0.8.20",
"clsx": "^2.1.1",
"fumadocs-core": "14.6.2",
"fumadocs-core": "14.6.8",
"fumadocs-docgen": "^1.3.3",
"fumadocs-mdx": "11.1.2",
"fumadocs-ui": "14.6.2",
"fumadocs-mdx": "11.2.1",
"fumadocs-ui": "14.6.8",
"geist": "^1.3.1",
"motion": "^11.15.0",
"next": "15.1.2",
"next": "15.1.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwind-merge": "^2.5.5",
Expand Down
2 changes: 1 addition & 1 deletion packages/callapi-legacy/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zayne-labs/callapi-legacy",
"type": "module",
"version": "1.0.0-rc.46",
"version": "1.0.0-rc.68",
"description": "A lightweight wrapper over fetch with quality of life improvements like built-in request cancellation, retries, interceptors and more",
"author": "Ryan Zayne",
"license": "MIT",
Expand Down
26 changes: 17 additions & 9 deletions packages/callapi-legacy/src/createFetchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import type {
import { mergeUrlWithParamsAndQuery } from "./url";
import {
HTTPError,
executeInterceptors,
executeHooks,
flattenHooks,
generateRequestKey,
getFetchImpl,
getResponseData,
Expand Down Expand Up @@ -57,11 +58,11 @@ export const createFetchClient = <
>(
...parameters: CallApiParameters<TData, TErrorData, TResultMode>
): Promise<GetCallApiResult<TData, TErrorData, TResultMode>> => {
const [initURL, config = {}] = parameters;
const [initURL, config] = parameters;

type CallApiResult = never;

const [fetchConfig, extraOptions] = splitConfig(config);
const [fetchConfig, extraOptions] = splitConfig(config ?? {});

const { body = baseBody, headers, signal = baseSignal, ...restOfFetchConfig } = fetchConfig;

Expand All @@ -82,6 +83,13 @@ export const createFetchClient = <

...baseExtraOptions,
...extraOptions,

onError: flattenHooks(baseExtraOptions.onError, extraOptions.onError),
onRequest: flattenHooks(baseExtraOptions.onRequest, extraOptions.onRequest),
onRequestError: flattenHooks(baseExtraOptions.onRequestError, extraOptions.onRequestError),
onResponse: flattenHooks(baseExtraOptions.onResponse, extraOptions.onResponse),
onResponseError: flattenHooks(baseExtraOptions.onResponseError, extraOptions.onResponseError),
onSuccess: flattenHooks(baseExtraOptions.onSuccess, extraOptions.onSuccess),
} satisfies CombinedCallApiExtraOptions;

const { interceptors, resolvedOptions, resolvedRequestOptions, url } = await initializePlugins({
Expand Down Expand Up @@ -125,8 +133,8 @@ export const createFetchClient = <

if (prevRequestInfo && options.dedupeStrategy === "cancel") {
const message = options.requestKey
? `Request aborted as another request with the same request key: '${requestKey}' was initiated while the current request was in progress.`
: `Request aborted as another request to the endpoint: '${fullURL}', with the same request options was initiated while the current request was in progress.`;
? `Duplicate request detected - Aborting previous request with key '${requestKey}' as a new request was initiated`
: `Duplicate request detected - Aborting previous request to '${fullURL}' as a new request with identical options was initiated`;

const reason = new DOMException(message, "AbortError");

Expand All @@ -148,7 +156,7 @@ export const createFetchClient = <
const fetch = getFetchImpl(options.customFetchImpl);

try {
await executeInterceptors(options.onRequest({ options, request }));
await executeHooks(options.onRequest({ options, request }));

// == Apply determined headers
request.headers = mergeAndResolveHeaders({
Expand Down Expand Up @@ -211,7 +219,7 @@ export const createFetchClient = <
options.responseValidator
);

await executeInterceptors(
await executeHooks(
options.onSuccess({
data: successData,
options,
Expand Down Expand Up @@ -264,7 +272,7 @@ export const createFetchClient = <
const possibleHttpError = (generalErrorResult as { error: PossibleHTTPError<TErrorData> })
.error;

await executeInterceptors(
await executeHooks(
options.onResponseError({
error: possibleHttpError,
options,
Expand Down Expand Up @@ -315,7 +323,7 @@ export const createFetchClient = <

const possibleJavascriptError = (generalErrorResult as { error: PossibleJavaScriptError }).error;

await executeInterceptors(
await executeHooks(
// == At this point only the request errors exist, so the request error interceptor is called
options.onRequestError({
error: possibleJavascriptError,
Expand Down
3 changes: 3 additions & 0 deletions packages/callapi-legacy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export { defineCallApiPlugin } from "./plugins";
export type { CallApiPlugin, PluginInitContext } from "./plugins";

export type {
BaseCallApiConfig,
BaseCallApiExtraOptions,
CallApiConfig,
CallApiExtraOptions,
CallApiParameters,
Expand All @@ -12,6 +14,7 @@ export type {
CallApiResultErrorVariant,
CallApiResultModeUnion,
CallApiResultSuccessVariant,
CombinedCallApiExtraOptions,
ErrorContext,
Register,
RequestContext,
Expand Down
67 changes: 37 additions & 30 deletions packages/callapi-legacy/src/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { CallApiRequestOptionsForHooks, CombinedCallApiExtraOptions, Interceptors } from "./types";
import type {
CallApiRequestOptionsForHooks,
CombinedCallApiExtraOptions,
Interceptors,
InterceptorsArray,
} from "./types";
import { isFunction, isPlainObject, isString } from "./utils/type-guards";
import type { AnyFunction, Awaitable } from "./utils/type-helpers";

Expand Down Expand Up @@ -58,7 +63,7 @@ export const defineCallApiPlugin = <
};

const createMergedInterceptor = (
interceptors: Set<AnyFunction<Awaitable<unknown>> | undefined>,
interceptors: Array<AnyFunction<Awaitable<unknown>> | undefined>,
mergedInterceptorsExecutionMode: CombinedCallApiExtraOptions["mergedInterceptorsExecutionMode"]
) => {
return async (ctx: Record<string, unknown>) => {
Expand All @@ -80,7 +85,9 @@ const createMergedInterceptor = (
};

type PluginHooks<TData, TErrorData> = {
[Key in keyof Interceptors<TData, TErrorData>]: Set<Interceptors<TData, TErrorData>[Key]>;
[Key in keyof Interceptors<TData, TErrorData>]: Array<
Interceptors<TData, TErrorData>[Key] | InterceptorsArray<TData, TErrorData>[Key]
>;
};

export const initializePlugins = async <TData, TErrorData>(
Expand All @@ -89,39 +96,39 @@ export const initializePlugins = async <TData, TErrorData>(
const { initURL, options, request } = context;

const hookRegistry = {
onError: new Set([]),
onRequest: new Set([]),
onRequestError: new Set([]),
onResponse: new Set([]),
onResponseError: new Set([]),
onSuccess: new Set([]),
onError: [],
onRequest: [],
onRequestError: [],
onResponse: [],
onResponseError: [],
onSuccess: [],
} satisfies PluginHooks<TData, TErrorData> as Required<PluginHooks<TData, TErrorData>>;

const addMainInterceptors = () => {
hookRegistry.onRequest.add(options.onRequest);
hookRegistry.onRequestError.add(options.onRequestError);
hookRegistry.onResponse.add(options.onResponse);
hookRegistry.onResponseError.add(options.onResponseError);
hookRegistry.onSuccess.add(options.onSuccess);
hookRegistry.onError.add(options.onError);
hookRegistry.onRequest.push(options.onRequest);
hookRegistry.onRequestError.push(options.onRequestError);
hookRegistry.onResponse.push(options.onResponse);
hookRegistry.onResponseError.push(options.onResponseError);
hookRegistry.onSuccess.push(options.onSuccess);
hookRegistry.onError.push(options.onError);
};

const addPluginInterceptors = (pluginHooks: Interceptors<TData, TErrorData>) => {
hookRegistry.onRequest.add(pluginHooks.onRequest);
hookRegistry.onRequestError.add(pluginHooks.onRequestError);
hookRegistry.onResponse.add(pluginHooks.onResponse);
hookRegistry.onResponseError.add(pluginHooks.onResponseError);
hookRegistry.onSuccess.add(pluginHooks.onSuccess);
hookRegistry.onError.add(pluginHooks.onError);
hookRegistry.onRequest.push(pluginHooks.onRequest);
hookRegistry.onRequestError.push(pluginHooks.onRequestError);
hookRegistry.onResponse.push(pluginHooks.onResponse);
hookRegistry.onResponseError.push(pluginHooks.onResponseError);
hookRegistry.onSuccess.push(pluginHooks.onSuccess);
hookRegistry.onError.push(pluginHooks.onError);
};

if (options.mergedInterceptorsExecutionOrder === "mainInterceptorFirst") {
addMainInterceptors();
}

const resolvedPlugins = isFunction(options.plugins)
? [...options.plugins({ initURL, options, request }), ...(options.extend?.plugins ?? [])]
: [...(options.plugins ?? []), ...(options.extend?.plugins ?? [])];
? [options.plugins({ initURL, options, request }), options.extend?.plugins ?? []].flat()
: [options.plugins ?? [], options.extend?.plugins ?? []].flat();

let resolvedUrl = initURL;
let resolvedOptions = options;
Expand Down Expand Up @@ -163,7 +170,7 @@ export const initializePlugins = async <TData, TErrorData>(
addMainInterceptors();
}

const handleInterceptorsMerge = (interceptors: Set<AnyFunction<Awaitable<unknown>> | undefined>) => {
const handleInterceptorsMerge = (interceptors: Array<AnyFunction<Awaitable<unknown>> | undefined>) => {
const mergedInterceptor = createMergedInterceptor(
interceptors,
options.mergedInterceptorsExecutionMode
Expand All @@ -173,12 +180,12 @@ export const initializePlugins = async <TData, TErrorData>(
};

const interceptors = {
onError: handleInterceptorsMerge(hookRegistry.onError),
onRequest: handleInterceptorsMerge(hookRegistry.onRequest),
onRequestError: handleInterceptorsMerge(hookRegistry.onRequestError),
onResponse: handleInterceptorsMerge(hookRegistry.onResponse),
onResponseError: handleInterceptorsMerge(hookRegistry.onResponseError),
onSuccess: handleInterceptorsMerge(hookRegistry.onSuccess),
onError: handleInterceptorsMerge(hookRegistry.onError.flat()),
onRequest: handleInterceptorsMerge(hookRegistry.onRequest.flat()),
onRequestError: handleInterceptorsMerge(hookRegistry.onRequestError.flat()),
onResponse: handleInterceptorsMerge(hookRegistry.onResponse.flat()),
onResponseError: handleInterceptorsMerge(hookRegistry.onResponseError.flat()),
onSuccess: handleInterceptorsMerge(hookRegistry.onSuccess.flat()),
} satisfies Interceptors<TData, TErrorData>;

return {
Expand Down
Loading

0 comments on commit d6a591f

Please sign in to comment.