Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve interceptors #57

Merged
merged 5 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions docs/examples/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,19 @@ async function example6() {
'https://example.com/api/custom-endpoint',
);

console.log('Example 5', books);
console.log('Example 5', data1, data2);
console.log('Example 6', books);
console.log('Example 6', data1, data2);
}

// fetchf() - direct fetchf() request with interceptor
async function example7() {
const response = await fetchf('https://example.com/api/custom-endpoint', {
onResponse(response) {
response.data = { username: 'modified response' };
},
});

console.log('Example 7', response);
}

example1();
Expand All @@ -217,3 +228,4 @@ example3();
example4();
example5();
example6();
example7();
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { APIResponse, FetchResponse, RequestHandlerConfig } from './types';
*/
export async function fetchf<ResponseData = APIResponse>(
url: string,
config: RequestHandlerConfig = {},
config: RequestHandlerConfig<ResponseData> = {},
): Promise<ResponseData & FetchResponse<ResponseData>> {
return createRequestHandler(config).request<ResponseData>(url, null, config);
}
Expand Down
25 changes: 14 additions & 11 deletions src/interceptor-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
type InterceptorFunction<T> = (object: T) => Promise<T>;

/**
Expand All @@ -10,25 +9,29 @@ type InterceptorFunction<T> = (object: T) => Promise<T>;
* @param {T} object - The object to process.
* @param {InterceptorFunction<T> | InterceptorFunction<T>[]} [interceptors] - Interceptor function(s).
*
* @returns {Promise<T>} - The modified object.
* @returns {Promise<void>} - Nothing as the function is non-idempotent.
*/
export async function applyInterceptor<
T = any,
T extends object,
I = InterceptorFunction<T> | InterceptorFunction<T>[],
>(object: T, interceptors?: I): Promise<T> {
>(object: T, interceptors?: I): Promise<void> {
if (!interceptors) {
return object;
return;
}

if (typeof interceptors === 'function') {
return await interceptors(object);
}
const value = await interceptors(object);

if (Array.isArray(interceptors)) {
if (value) {
Object.assign(object, value);
}
} else if (Array.isArray(interceptors)) {
for (const interceptor of interceptors) {
object = await interceptor(object);
const value = await interceptor(object);

if (value) {
Object.assign(object, value);
}
}
}

return object;
}
36 changes: 13 additions & 23 deletions src/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,25 +220,21 @@ export function createRequestHandler(
*
* @param {ResponseError} error Error instance
* @param {RequestConfig} requestConfig Per endpoint request config
* @returns {void}
* @returns {Promise<void>}
*/
const processError = (
const processError = async (
error: ResponseError,
requestConfig: RequestConfig,
): void => {
): Promise<void> => {
if (!isRequestCancelled(error)) {
logger('API ERROR', error);
}

// Invoke per request "onError" interceptor
if (requestConfig.onError) {
requestConfig.onError(error);
}
// Local interceptors
await applyInterceptor(error, requestConfig?.onError);

// Invoke global "onError" interceptor
if (handlerConfig.onError) {
handlerConfig.onError(error);
}
// Global interceptors
await applyInterceptor(error, handlerConfig?.onError);
};

/**
Expand Down Expand Up @@ -370,22 +366,16 @@ export function createRequestHandler(
);

// Shallow copy to ensure basic idempotency
let requestConfig: RequestConfig = {
const requestConfig: RequestConfig = {
signal: controller.signal,
...fetcherConfig,
};

// Local interceptors
requestConfig = await applyInterceptor(
requestConfig,
_reqConfig?.onRequest,
);
await applyInterceptor(requestConfig, _reqConfig?.onRequest);

// Global interceptors
requestConfig = await applyInterceptor(
requestConfig,
handlerConfig?.onRequest,
);
await applyInterceptor(requestConfig, handlerConfig?.onRequest);

if (customFetcher !== null && requestInstance !== null) {
response = await requestInstance.request(requestConfig);
Expand All @@ -412,10 +402,10 @@ export function createRequestHandler(
}

// Local interceptors
response = await applyInterceptor(response, _reqConfig?.onResponse);
await applyInterceptor(response, _reqConfig?.onResponse);

// Global interceptors
response = await applyInterceptor(response, handlerConfig?.onResponse);
await applyInterceptor(response, handlerConfig?.onResponse);

removeRequest(fetcherConfig);

Expand Down Expand Up @@ -456,7 +446,7 @@ export function createRequestHandler(
!(!shouldRetry || (await shouldRetry(error, attempt))) ||
!retryOn?.includes(status)
) {
processError(error, fetcherConfig);
await processError(error, fetcherConfig);

removeRequest(fetcherConfig);

Expand Down
14 changes: 10 additions & 4 deletions src/types/api-handler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
RequestConfig,
RequestHandlerConfig,
Expand All @@ -9,16 +10,19 @@ import type {
// Common type definitions
type NameValuePair = { name: string; value: string };

declare const emptyObjectSymbol: unique symbol;

export type EmptyObject = { [emptyObjectSymbol]?: never };

export declare type QueryParams<T = unknown> =
| Record<string, T>
| (Record<string, T> & EmptyObject)
| URLSearchParams
| NameValuePair[]
| null;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export declare type BodyPayload<T = any> =
| BodyInit
| Record<string, T>
| (Record<string, T> & EmptyObject)
| T[]
| string
| null;
Expand All @@ -27,7 +31,9 @@ export declare type QueryParamsOrBody<T = unknown> =
| QueryParams<T>
| BodyPayload<T>;

export declare type UrlPathParams<T = unknown> = Record<string, T> | null;
export declare type UrlPathParams<T = unknown> =
| (Record<string, T> & EmptyObject)
| null;

export declare type APIResponse = unknown;

Expand Down
25 changes: 18 additions & 7 deletions src/types/interceptor-manager.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import type { FetchResponse, RequestHandlerConfig } from './request-handler';
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
FetchResponse,
RequestHandlerConfig,
ResponseError,
} from './request-handler';

export type RequestInterceptor = (
config: RequestHandlerConfig,
) => RequestHandlerConfig | Promise<RequestHandlerConfig>;
export type RequestInterceptor<D = any> = (
config: RequestHandlerConfig<D>,
) =>
| RequestHandlerConfig<D>
| void
| Promise<RequestHandlerConfig<D>>
| Promise<void>;

export type ResponseInterceptor = <ResponseData = unknown>(
response: FetchResponse<ResponseData>,
) => Promise<FetchResponse<ResponseData>>;
export type ResponseInterceptor<D = any> = (
response: FetchResponse<D>,
) => FetchResponse<D> | void | Promise<FetchResponse<D>> | Promise<void>;

export type ErrorInterceptor = (error: ResponseError) => unknown;
37 changes: 19 additions & 18 deletions src/types/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
UrlPathParams,
} from './api-handler';
import type {
ErrorInterceptor,
RequestInterceptor,
ResponseInterceptor,
} from './interceptor-manager';
Expand Down Expand Up @@ -52,37 +53,34 @@ export type ErrorHandlingStrategy =
| 'defaultResponse'
| 'softFail';

type ErrorInterceptor = (error: ResponseError) => unknown;

export interface HeadersObject {
[key: string]: string;
}

export interface ExtendedResponse<T = any> extends Omit<Response, 'headers'> {
data: T;
error: ResponseError<T> | null;
export interface ExtendedResponse<D = any> extends Omit<Response, 'headers'> {
data: D extends unknown ? any : D;
error: ResponseError<D> | null;
headers: HeadersObject & HeadersInit;
config: ExtendedRequestConfig;
request?: ExtendedRequestConfig;
config: ExtendedRequestConfig<D>;
}

export type FetchResponse<T = any> = ExtendedResponse<T>;

export interface ResponseError<T = any> extends Error {
config: ExtendedRequestConfig;
export interface ResponseError<D = any> extends Error {
config: ExtendedRequestConfig<D>;
code?: string;
status?: number;
statusText?: string;
request?: ExtendedRequestConfig;
response?: FetchResponse<T>;
request?: ExtendedRequestConfig<D>;
response?: FetchResponse<D>;
}

export type RetryFunction = <T = any>(
error: ResponseError<T>,
attempts: number,
) => Promise<boolean>;

export type PollingFunction = <ResponseData = unknown>(
export type PollingFunction = <ResponseData = any>(
response: FetchResponse<ResponseData>,
attempts: number,
error?: ResponseError,
Expand Down Expand Up @@ -295,17 +293,17 @@ interface ExtendedRequestConfig<D = any>
/**
* A function or array of functions to intercept the request before it is sent.
*/
onRequest?: RequestInterceptor | RequestInterceptor[];
onRequest?: RequestInterceptor<D> | RequestInterceptor<D>[];

/**
* A function or array of functions to intercept the response before it is resolved.
*/
onResponse?: ResponseInterceptor | ResponseInterceptor[];
onResponse?: ResponseInterceptor<D> | ResponseInterceptor<D>[];

/**
* A function to handle errors that occur during the request or response processing.
*/
onError?: ErrorInterceptor;
onError?: ErrorInterceptor | ErrorInterceptor[];

/**
* The maximum time (in milliseconds) the request can take before automatically being aborted.
Expand Down Expand Up @@ -333,18 +331,21 @@ interface ExtendedRequestConfig<D = any>
shouldStopPolling?: PollingFunction;
}

interface BaseRequestHandlerConfig extends RequestConfig {
interface BaseRequestHandlerConfig<RequestData = any>
extends RequestConfig<RequestData> {
fetcher?: FetcherInstance | null;
logger?: any;
}

export type RequestConfig = ExtendedRequestConfig;
export type RequestConfig<RequestData = any> =
ExtendedRequestConfig<RequestData>;

export type FetcherConfig = Omit<ExtendedRequestConfig, 'url'> & {
url: string;
};

export type RequestHandlerConfig = BaseRequestHandlerConfig;
export type RequestHandlerConfig<RequestData = any> =
BaseRequestHandlerConfig<RequestData>;

export interface RequestHandlerReturnType {
config: RequestHandlerConfig;
Expand Down
14 changes: 4 additions & 10 deletions test/interceptor-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,10 @@ describe('Interceptor Functions', () => {

// Prepare a request configuration
const initialConfig = { method: 'GET' };
const interceptedConfig = await interceptRequest(
initialConfig,
requestInterceptors,
);
await interceptRequest(initialConfig, requestInterceptors);

// Validate the intercepted configuration
expect(interceptedConfig).toEqual({
expect(initialConfig).toEqual({
method: 'GET',
headers: {
Authorization: 'Bearer token',
Expand Down Expand Up @@ -86,11 +83,8 @@ describe('Interceptor Functions', () => {
}) as ExtendedResponse;

// Apply response interceptors
const finalResponse = await interceptResponse(
mockResponse,
responseInterceptors,
);
const data = await finalResponse.json();
await interceptResponse(mockResponse, responseInterceptors);
const data = await mockResponse.json();

// Validate the final response data
expect(data).toEqual({
Expand Down
Loading
Loading