diff --git a/README.md b/README.md index 2406397..747655f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [npm-url]: https://npmjs.org/package/fetchff [npm-image]: http://img.shields.io/npm/v/fetchff.svg -[![NPM version][npm-image]][npm-url] [![Blazing Fast](https://badgen.now.sh/badge/speed/blazing%20%F0%9F%94%A5/green)](https://github.com/MattCCC/fetchff) [![Code Coverage](https://badgen.now.sh/badge/coverage/97.68%/blue)](https://github.com/MattCCC/fetchff) [![npm downloads](https://img.shields.io/npm/dm/fetchff.svg?style=flat-square)](http://npm-stat.com/charts.html?package=fetchff) [![gzip size](https://img.shields.io/bundlephobia/minzip/fetchff)](https://bundlephobia.com/result?p=fetchff) +[![NPM version][npm-image]][npm-url] [![Blazing Fast](https://badgen.now.sh/badge/speed/blazing%20%F0%9F%94%A5/green)](https://github.com/MattCCC/fetchff) [![Code Coverage](https://img.shields.io/badge/coverage-97.39-green)](https://github.com/MattCCC/fetchff) [![npm downloads](https://img.shields.io/npm/dm/fetchff.svg?color=lightblue)](http://npm-stat.com/charts.html?package=fetchff) [![gzip size](https://img.shields.io/bundlephobia/minzip/fetchff)](https://bundlephobia.com/result?p=fetchff) ## Why? @@ -22,9 +22,9 @@ Managing multiple API endpoints can be complex and time-consuming. `fetchff` sim ## Features - **100% Performance-Oriented**: Optimized for speed and efficiency, ensuring fast and reliable API interactions. -- **Fully TypeScript Compatible**: Enjoy full TypeScript support for better development experience and type safety. -- **Smart Error Retry**: Features exponential backoff for intelligent error handling and retry mechanisms. +- **Smart Retry Mechanism**: Features exponential backoff for intelligent error handling and retry mechanisms. - **Automatic Request Deduplication**: Set the time during which requests are deduplicated (treated as same request). +- **Smart Cache Management**: Dynamically manage cache with configurable expiration, custom keys, and selective invalidation. - **Dynamic URLs Support**: Easily manage routes with dynamic parameters, such as `/user/:userId`. - **Native `fetch()` Support**: Uses the modern `fetch()` API by default, eliminating the need for libraries like Axios. - **Global and Per Request Error Handling**: Flexible error management at both global and individual request levels. @@ -35,7 +35,8 @@ Managing multiple API endpoints can be complex and time-consuming. `fetchff` sim - **Supports All Axios Options**: Fully compatible with all Axios configuration options for seamless integration. - **Lightweight**: Minimal footprint, only a few KBs when gzipped, ensuring quick load times. - **Framework Independent**: Pure JavaScript solution, compatible with any framework or library. -- **Browser and Node 18+ Compatible**: Works flawlessly in both modern browsers and Node.js environments. +- **Browser and Node.js 18+ Compatible**: Works flawlessly in both modern browsers and Node.js environments. +- **Fully TypeScript Compatible**: Enjoy full TypeScript support for better development experience and type safety. - **Custom Interceptors**: Includes `onRequest`, `onResponse`, and `onError` interceptors for flexible request and response handling. Please open an issue for future requests. @@ -68,8 +69,7 @@ yarn add fetchff import { fetchf } from 'fetchff'; const { data, error, status } = await fetchf( - 'https://example.com/api/v1/getBooks', - { bookId: 1 }, + 'https://example.com/api/v1/books', { timeout: 2000, // Specify some other settings here... @@ -437,36 +437,42 @@ You can also use all native `fetch()` settings. Settings that are global only are marked with star `*`. -| | Type | Default | Description | -| ----------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| baseURL \*
(alias: apiUrl) | string | | Your API base url. | -| endpoints \* | object | | List of your endpoints. Each endpoint accepts all these settings. They can be set globally or per-endpoint when they are called. | -| fetcher \* | Function | fetch | The native `fetch()` is used by default. A custom instance that exposes create() and request() can be used otherwise. | -| url | string | | URL path e.g. /user-details/get | -| method | string | get | Default request method e.g. GET, POST, DELETE, PUT etc. | -| params | object
URLSearchParams | {} | A key-value pairs added to the URL to send extra information with a request. If you pass an object, it will be automatically converted. It works with nested objects, arrays and custom data structures. If you use `createApiFetcher()` then it is the first argument of your api.endpoint() function. You can still pass configuration in 3rd argument if necessary. | -| body
(alias: data) | object
string
FormData
URLSearchParams
Blob
ArrayBuffer
ReadableStream | {} | The body is the data sent with the request, such as JSON, text, or form data, included in the request payload for POST, PUT, or PATCH requests. | -| urlPathParams | object | {} | An object with URL path parameters so to dynamically replace placeholders in the URL path. For example, if URL contains a placeholder like `/users/:userId`, you can provide an object with the `userId` key to replace that placeholder with an actual value. The keys in the `urlPathParams` object should match the placeholders in the URL. This allows for dynamic URL construction based on runtime values. | -| strategy | string | reject | Error handling strategies - basically what to return when an error occurs. It can be a default data, promise can be hanged (nothing would be returned) or rejected so to use try/catch.

Available: `reject`, `softFail`, `defaultResponse`, `silent`.

`reject` - Promises are rejected, and global error handling is triggered. Requires try/catch for handling.

`softFail` - returns a response object with additional properties such as `data`, `error`, `config`, `request`, and `headers` when an error occurs. This approach avoids throwing errors, allowing you to handle error information directly within the response object without the need for try/catch blocks.

`defaultResponse` - returns default response specified in case of an error. Promise will not be rejected. It could be used in conjuction with `flattenResponse` and as `defaultResponse: {}` so to provide a sensible defaults.

`silent` - hangs the promise silently on error, useful for fire-and-forget requests without the need for try/catch. In case of an error, the promise will never be resolved or rejected, and any code after will never be executed. The requests could be dispatched within an asynchronous wrapper functions that do not need to be awaited. If used properly, it prevents excessive usage of try/catch or additional response data checks everywhere. You can use it in combination with `onError` to handle errors separately. | -| cancellable | boolean | false | If `true`, any ongoing previous requests to same API endpoint will be cancelled, if a subsequent request is made meanwhile. This helps you avoid unnecessary requests to the backend. | -| rejectCancelled | boolean | false | If `true` and request is set to `cancellable`, a cancelled requests' promise will be rejected. By default, instead of rejecting the promise, `defaultResponse` is returned. | -| flattenResponse | boolean | false | Flatten nested response data, so you can avoid writing `response.data.data` and obtain response directly. Response is flattened when there is a "data" within response "data", and no other object properties set. | -| defaultResponse | any | null | Default response when there is no data or when endpoint fails depending on the chosen `strategy` | -| withCredentials | boolean | false | Indicates whether credentials (such as cookies) should be included with the request. | -| timeout | int | 30000 | You can set a request timeout for all requests or particular in milliseconds. | -| dedupeTime | int | 1000 | Time window, in milliseconds, during which identical requests are deduplicated (treated as single request). | -| onRequest | function(config) | | You can specify a function that will be triggered before the request is sent. The request configuration object will be sent as the first argument of the function. This is useful for modifying request parameters, headers, etc. | -| onResponse | function(response) | | You can specify a function that will be triggered when the endpoint successfully responds. The full Response Object is sent as the first argument of the function. This is useful for handling the response data, parsing, and error handling based on status codes. | -| onError | function(error) | | You can specify a function or class that will be triggered when endpoint fails. If it's a class it should expose a `process` method. When using native fetch(), the full Response Object is sent as a first argument of the function. | -| logger | object | null | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. | -| retry | object | | The object with retry settings available below. | -| retry.retries | number | 0 | The number of times to retry the request in case of failure. If set to `0` (default), no retries will be attempted. | -| retry.delay | number | 1000 | The initial delay (in milliseconds) between retry attempts. | -| retry.backoff | number | 1.5 | The backoff factor to apply to the delay between retries. For example, if the delay is 100ms and the backoff is 1.5, the next delay will be 150ms, then 225ms, and so on. | -| retry.maxDelay | number | 30000 | The maximum delay (in milliseconds) between retry attempts. | -| retry.resetTimeout | boolean | true | Reset timeout when retrying requests. | -| retry.retryOn | array | [408, 409, 425, 429, 500, 502, 503, 504] | An array of HTTP status codes on which to retry the request. Default values include: 408 (Request Timeout), 409 (Conflict), 425 (Too Early), 429 (Too Many Requests), 500 (Internal Server Error), 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout). | -| retry.shouldRetry | async function | | A custom asynchronous function to determine whether to retry the request. It receives two arguments: `error` (the error object) and `attempts` (the number of attempts made so far). | +| | Type | Default | Description | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoints \* | `object` | | List of your endpoints. Each endpoint accepts all the settings below. They can be set globally, per-endpoint or per-request. | +| fetcher \* | `FetcherInstance` | | A custom adapter (an instance / object) that exposes `create()` function so to create instance of API Fetcher. The `create()` should return `request()` function that would be used when making the requests. The native `fetch()` is used if the fetcher is not provided. | +| baseURL
(alias: apiUrl) | `string` | | Your API base url. | +| url | `string` | | URL path e.g. /user-details/get | +| method | `string` | `GET` | Default request method e.g. GET, POST, DELETE, PUT etc. All methods are supported. | +| params | `object`
`URLSearchParams`
`NameValuePair[]` | `{}` | Query Parameters - a key-value pairs added to the URL to send extra information with a request. If you pass an object, it will be automatically converted. It works with nested objects, arrays and custom data structures similarly to what `jQuery` used to do in the past. If you use `createApiFetcher()` then it is the first argument of your `api.myEndpoint()` function. You can still pass configuration in 3rd argument if want to. | +| body
(alias: data) | `object`
`string`
`FormData`
`URLSearchParams`
`Blob`
`ArrayBuffer`
`ReadableStream` | `{}` | The body is the data sent with the request, such as JSON, text, or form data, included in the request payload for POST, PUT, or PATCH requests. | +| urlPathParams | `object` | `{}` | An object with URL path parameters so to dynamically replace placeholders in the URL path. For example, if URL contains a placeholder like `/users/:userId`, you can provide an object with the `userId` key to replace that placeholder with an actual value. The keys in the `urlPathParams` object should match the placeholders in the URL. This allows for dynamic URL construction based on runtime values. | +| strategy | `string` | `reject` | Error handling strategies - basically what to return when an error occurs. It can be a default data, promise can be hanged (nothing would be returned) or rejected so to use try/catch.

Available: `reject`, `softFail`, `defaultResponse`, `silent`.

`reject` - Promises are rejected, and global error handling is triggered. Requires try/catch for handling.

`softFail` - returns a response object with additional properties such as `data`, `error`, `config`, `request`, and `headers` when an error occurs. This approach avoids throwing errors, allowing you to handle error information directly within the response object without the need for try/catch blocks.

`defaultResponse` - returns default response specified in case of an error. Promise will not be rejected. It could be used in conjuction with `flattenResponse` and as `defaultResponse: {}` so to provide a sensible defaults.

`silent` - hangs the promise silently on error, useful for fire-and-forget requests without the need for try/catch. In case of an error, the promise will never be resolved or rejected, and any code after will never be executed. The requests could be dispatched within an asynchronous wrapper functions that do not need to be awaited. If used properly, it prevents excessive usage of try/catch or additional response data checks everywhere. You can use it in combination with `onError` to handle errors separately. | +| cancellable | `boolean` | `false` | If `true`, any ongoing previous requests to same API endpoint will be cancelled, if a subsequent request is made meanwhile. This helps you avoid unnecessary requests to the backend. | +| rejectCancelled | `boolean` | `false` | If `true` and request is set to `cancellable`, a cancelled requests' promise will be rejected. By default, instead of rejecting the promise, `defaultResponse` is returned. | +| flattenResponse | `boolean` | `false` | Flatten nested response data, so you can avoid writing `response.data.data` and obtain response directly. Response is flattened when there is a "data" within response "data", and no other object properties set. | +| defaultResponse | `any` | `null` | Default response when there is no data or when endpoint fails depending on the chosen `strategy` | +| withCredentials | `boolean` | `false` | Indicates whether credentials (such as cookies) should be included with the request. | +| timeout | `number` | `30000` | You can set a request timeout for all requests or particular in milliseconds. | +| dedupeTime | `number` | `1000` | Time window, in milliseconds, during which identical requests are deduplicated (treated as single request). | +| pollingInterval | `number` | `0` | Interval in milliseconds between polling attempts. Setting `0` disables polling. | +| shouldStopPolling | `PollingFunction` | `(response, error, attempt) => false` | Function to determine if polling should stop based on the response. Returns `true` to stop polling, `false` to continue. | +| cacheTime | `number` | `0` | Maximum time, in seconds, a cache entry is considered fresh. After this time, the entry may be considered stale. | +| cacheKey | `CacheKeyFunction` | | Function to customize the cache key. If not provided, it automatically generates a unique cache key based on `Method`, `URL`, `Query Params`, `Dynamic Path Params`, `mode`, `credentials`, `cache`, `redirect`, `referrer`, `integrity`, `headers` and `body`, selectively using a very fast hashing variant of djb2 authored by Daniel J. Bernstein. | +| cacheBuster | `CacheBusterFunction` | `(config) => false` | Function to invalidate or refresh cache under certain conditions. Defaults to no cache busting. | +| skipCache | `CacheSkipFunction` | `(response, config) => false` | Function to determine whether to set or skip setting caching based on the response. Defaults to no skipping. | +| onRequest | `RequestInterceptor`
`RequestInterceptor[]` | `(config) => config` | A function or an array of functions that are invoked before sending a request. Each function receives the request configuration object as its argument, allowing you to modify request parameters, headers, or other settings. The function should return the updated configuration. | +| onResponse | `ResponseInterceptor`
`ResponseInterceptor[]` | `(response) => response` | A function or an array of functions that are invoked when a response is received. Each function receives the full response object, enabling you to process the response, handle status codes, or parse data as needed. The function should return the processed response. | +| onError | `ErrorInterceptor` | `(error) => void` | You can specify a function or class that will be triggered when endpoint fails. If it's a class it should expose a `process` method. When using native fetch(), the full Response Object is sent as a first argument of the function. | +| logger | `object` | `null` | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. | +| retry | `object` | | The object with retry settings available below. | +| retry.retries | `number` | `0` | The number of times to retry the request in case of failure. If set to `0` (default), no retries will be attempted. | +| retry.delay | `number` | `1000` | The initial delay (in milliseconds) between retry attempts. | +| retry.backoff | `number` | `1.5` | The backoff factor to apply to the delay between retries. For example, if the delay is 100ms and the backoff is 1.5, the next delay will be 150ms, then 225ms, and so on. | +| retry.maxDelay | `number` | `30000` | The maximum delay (in milliseconds) between retry attempts. Default is equal to timeout. | +| retry.resetTimeout | `boolean` | `true` | Reset timeout when retrying requests. | +| retry.retryOn | `number[]` | `[408, 409, 425, 429, 500, 502, 503, 504]` | An array of HTTP status codes on which to retry the request. Default values include: 408 (Request Timeout), 409 (Conflict), 425 (Too Early), 429 (Too Many Requests), 500 (Internal Server Error), 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout). | +| retry.shouldRetry | `RetryFunction` | `(error, attempt) => true` | A custom asynchronous function to determine whether to retry the request. It receives two arguments: `error` - the error object, and `attempts` - the current attempts made, starting from `0`. | ## ✔️ Retry Mechanism @@ -691,14 +697,10 @@ try { ```typescript import { fetchf } from 'fetchff'; -const books = await fetchf( - 'https://example.com/api/v1/getBooks', - { bookId: 1 }, - { - timeout: 2000, - // Specify some other settings here... - }, -); +const books = await fetchf('https://example.com/api/v1/books', { + timeout: 2000, + // Specify some other settings here... +}); ``` ### Multiple APIs Handler from different API sources diff --git a/src/cache-manager.ts b/src/cache-manager.ts new file mode 100644 index 0000000..a6e0436 --- /dev/null +++ b/src/cache-manager.ts @@ -0,0 +1,216 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { hash } from './hash'; +import { fetchf } from './index'; +import type { FetcherConfig } from './types/request-handler'; +import type { CacheEntry } from './types/cache-manager'; +import { GET, OBJECT, UNDEFINED } from './const'; +import { shallowSerialize, sortObject } from './utils'; + +const cache = new Map>(); + +/** + * Generates a cache key for a given URL and fetch options, ensuring that key factors + * like method, headers, body, and other options are included in the cache key. + * Headers and other objects are sorted by key to ensure consistent cache keys. + * + * @param options - The fetch options that may affect the request. The most important are: + * @property {string} [method="GET"] - The HTTP method (GET, POST, etc.). + * @property {HeadersInit} [headers={}] - The request headers. + * @property {BodyInit | null} [body=""] - The body of the request (only for methods like POST, PUT). + * @property {RequestMode} [mode="cors"] - The mode for the request (e.g., cors, no-cors, include). + * @property {RequestCredentials} [credentials="include"] - Whether to include credentials like cookies. + * @property {RequestCache} [cache="default"] - The cache mode (e.g., default, no-store, reload). + * @property {RequestRedirect} [redirect="follow"] - How to handle redirects (e.g., follow, error, manual). + * @property {string} [referrer=""] - The referrer URL to send with the request. + * @property {string} [integrity=""] - Subresource integrity value (a cryptographic hash for resource validation). + * @returns {string} - A unique cache key based on the URL and request options. Empty if cache is to be burst. + * + * @example + * const cacheKey = generateCacheKey({ + * url: 'https://api.example.com/data', + * method: 'POST', + * headers: { 'Content-Type': 'application/json' }, + * body: JSON.stringify({ name: 'Alice' }), + * mode: 'cors', + * credentials: 'include', + * }); + * console.log(cacheKey); + */ +export function generateCacheKey(options: FetcherConfig): string { + const { + url = '', + method = GET, + headers = {}, + body = '', + mode = 'cors', + credentials = 'include', + cache = 'default', + redirect = 'follow', + referrer = '', + integrity = '', + } = options; + + // Bail early if cache should be burst + if (cache === 'reload') { + return ''; + } + + // Sort headers and body + convert sorted to strings for hashing purposes + // Native serializer is on avg. 3.5x faster than a Fast Hash or FNV-1a + const headersString = shallowSerialize(sortObject(headers)); + + let bodyString = ''; + + // In majority of cases we do not cache body + if (body !== null) { + if (typeof body === 'string') { + bodyString = hash(body); + } else if (body instanceof FormData) { + body.forEach((value, key) => { + // Append key=value and '&' directly to the result + bodyString += key + '=' + value + '&'; + }); + bodyString = hash(bodyString); + } else if ( + (typeof Blob !== UNDEFINED && body instanceof Blob) || + (typeof File !== UNDEFINED && body instanceof File) + ) { + bodyString = 'BF' + body.size + body.type; + } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + bodyString = 'AB' + body.byteLength; + } else { + const o = typeof body === OBJECT ? sortObject(body) : String(body); + bodyString = hash(JSON.stringify(o)); + } + } + + // Concatenate all key parts into a cache key string + // Template literals are apparently slower + return ( + method + + url + + mode + + credentials + + cache + + redirect + + referrer + + integrity + + headersString + + bodyString + ); +} + +/** + * Checks if the cache entry is expired based on its timestamp and the maximum stale time. + * + * @param {number} timestamp - The timestamp of the cache entry. + * @param {number} maxStaleTime - The maximum stale time in seconds. + * @returns {boolean} - Returns true if the cache entry is expired, false otherwise. + */ +function isCacheExpired(timestamp: number, maxStaleTime: number): boolean { + if (!maxStaleTime) { + return false; + } + + return Date.now() - timestamp > maxStaleTime * 1000; +} + +/** + * Retrieves a cache entry if it exists and is not expired. + * + * @param {string} key Cache key to utilize + * @param {FetcherConfig} cacheTime - Maximum time to cache entry. + * @returns {CacheEntry | null} - The cache entry if it exists and is not expired, null otherwise. + */ +export function getCache( + key: string, + cacheTime: number, +): CacheEntry | null { + const entry = cache.get(key); + + if (entry) { + if (!isCacheExpired(entry.timestamp, cacheTime)) { + return entry; + } + + cache.delete(key); + } + + return null; +} + +/** + * Sets a new cache entry or updates an existing one. + * + * @param {string} key Cache key to utilize + * @param {T} data - The data to be cached. + * @param {boolean} isLoading - Indicates if the data is currently being fetched. + */ +export function setCache( + key: string, + data: T, + isLoading: boolean = false, +): void { + cache.set(key, { + data, + isLoading, + timestamp: Date.now(), + }); +} + +/** + * Revalidates a cache entry by fetching fresh data and updating the cache. + * + * @param {string} key Cache key to utilize + * @param {FetcherConfig} config - The request configuration object. + * @returns {Promise} - A promise that resolves when the revalidation is complete. + */ +export async function revalidate( + key: string, + config: FetcherConfig, +): Promise { + try { + // Fetch fresh data + const newData = await fetchf(config.url, { + ...config, + cache: 'reload', + }); + + setCache(key, newData); + } catch (error) { + console.error(`Error revalidating ${config.url}:`, error); + + // Rethrow the error to forward it + throw error; + } +} + +/** + * Invalidates (deletes) a cache entry. + * + * @param {string} key Cache key to utilize + */ +export function deleteCache(key: string): void { + cache.delete(key); +} + +/** + * Mutates a cache entry with new data and optionally revalidates it. + * + * @param {string} key Cache key to utilize + * @param {FetcherConfig} config - The request configuration object. + * @param {T} newData - The new data to be cached. + * @param {boolean} revalidateAfter - If true, triggers revalidation after mutation. + */ +export function mutate( + key: string, + config: FetcherConfig, + newData: T, + revalidateAfter: boolean = false, +): void { + setCache(key, newData); + + if (revalidateAfter) { + revalidate(key, config); + } +} diff --git a/src/const.ts b/src/const.ts index f4e8f9f..41913df 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,9 +1,13 @@ export const APPLICATION_JSON = 'application/json'; export const CONTENT_TYPE = 'Content-Type'; + export const UNDEFINED = 'undefined'; export const OBJECT = 'object'; +export const STRING = 'string'; + export const ABORT_ERROR = 'AbortError'; export const TIMEOUT_ERROR = 'TimeoutError'; export const CANCELLED_ERROR = 'CanceledError'; + export const GET = 'GET'; export const HEAD = 'HEAD'; diff --git a/src/hash.ts b/src/hash.ts index 0719759..a79c9ad 100644 --- a/src/hash.ts +++ b/src/hash.ts @@ -1,68 +1,20 @@ -import { UNDEFINED } from './const'; -import { RequestConfig } from './types'; - -// Garbage collected hash cache table -// Since we use hashtable, it is really fast -const hashCache = new WeakMap(); +const PRIME_MULTIPLIER = 31; /** - * FNV-1a Hash Function Implementation - * It's non-crypto and very fast - * We avoid BigInt here due to compatibility issues (ES2020), and the bundle size - * @url https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function + * Computes a hash value for a given string using the variant of djb2 hash function. + * This hash function is non-cryptographic and designed for speed. + * @author Daniel J. Bernstein (of djb2) * - * @param input Input string to hash + * @param str Input string to hash * @returns {string} Hash */ -export function hash(input: string): string { - // FNV-1a 32-bit offset basis - let hash = 2166136261; - - // FNV-1a 32-bit prime - const FNV_32_PRIME = 16777619; - - for (const char of input) { - hash ^= char.charCodeAt(0); - hash *= FNV_32_PRIME; - // Ensure the hash stays within 32-bit unsigned integer range - hash = hash >>> 0; - } - - // Convert hash to hexadecimal string, pad to ensure it's always 8 chars long - return hash.toString(16).padStart(8, '0'); -} - -/** - * Computes and retrieves a hash for a given `RequestConfig` object. - * - * This function first checks if the hash for the provided `requestConfig` object is - * already cached. If it is not found in the cache, it serializes the `requestConfig` - * object into a JSON string, computes a hash for that string using the FNV-1a algorithm, - * and stores the resulting hash in the cache for future use. The function utilizes a - * `WeakMap` to manage the cache, ensuring automatic garbage collection of cache entries - * when the associated `requestConfig` objects are no longer in use. - * - * @param {RequestConfig} requestConfig - The configuration object to hash. This object - * should have the following properties: - * - `method` {string} - The HTTP method (e.g., 'GET', 'POST'). - * - `baseURL` {string} - The base URL of the API. - * - `url` {string} - The specific URL path. - * - `params` {object} - The query parameters as key-value pairs. - * - `data` {object} - The request body data as key-value pairs. - * - * @returns {string} The computed hash for the `requestConfig` object. The hash is - * represented as a hexadecimal string of 8 characters, ensuring a fixed length for - * consistency and easier comparison. - */ -export function hashFromConfig(requestConfig: RequestConfig): string { - let key = hashCache.get(requestConfig); - - if (typeof key === UNDEFINED) { - const keyString = JSON.stringify(requestConfig); +export function hash(str: string): string { + let hash = 0; - key = hash(keyString); - hashCache.set(requestConfig, key); + for (let i = 0, len = str.length; i < len; i++) { + const char = str.charCodeAt(i); + hash = (hash * PRIME_MULTIPLIER + char) | 0; } - return key; + return String(hash); } diff --git a/src/interceptor-manager.ts b/src/interceptor-manager.ts index 230ae93..1220b34 100644 --- a/src/interceptor-manager.ts +++ b/src/interceptor-manager.ts @@ -1,59 +1,34 @@ -import type { RequestHandlerConfig, FetchResponse } from './types'; -import type { - RequestInterceptor, - ResponseInterceptor, -} from './types/interceptor-manager'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +type InterceptorFunction = (object: T) => Promise; /** - * Applies a series of request interceptors to the provided configuration. - * @param {RequestHandlerConfig} config - The initial request configuration. - * @param {RequestInterceptor | RequestInterceptor[]} interceptors - The request interceptor function(s) to apply. - * @returns {Promise} - The modified request configuration. + * Applies interceptors to the object. Interceptors can be a single function or an array of functions. + * + * @template T - Type of the object. + * @template I - Type of interceptors. + * + * @param {T} object - The object to process. + * @param {InterceptorFunction | InterceptorFunction[]} [interceptors] - Interceptor function(s). + * + * @returns {Promise} - The modified object. */ -export async function interceptRequest( - config: RequestHandlerConfig, - interceptors?: RequestInterceptor | RequestInterceptor[], -): Promise { +export async function applyInterceptor< + T = any, + I = InterceptorFunction | InterceptorFunction[], +>(object: T, interceptors?: I): Promise { if (!interceptors) { - return config; + return object; } - const interceptorList = Array.isArray(interceptors) - ? interceptors - : [interceptors]; - - let interceptedConfig = { ...config }; - - for (const interceptor of interceptorList) { - interceptedConfig = await interceptor(interceptedConfig); + if (typeof interceptors === 'function') { + return await interceptors(object); } - return interceptedConfig; -} - -/** - * Applies a series of response interceptors to the provided response. - * @param {FetchResponse} response - The initial response object. - * @param {ResponseInterceptor | ResponseInterceptor[]} interceptors - The response interceptor function(s) to apply. - * @returns {Promise>} - The modified response object. - */ -export async function interceptResponse( - response: FetchResponse, - interceptors?: ResponseInterceptor | ResponseInterceptor[], -): Promise> { - if (!interceptors) { - return response; - } - - const interceptorList = Array.isArray(interceptors) - ? interceptors - : [interceptors]; - - let interceptedResponse = response; - - for (const interceptor of interceptorList) { - interceptedResponse = await interceptor(interceptedResponse); + if (Array.isArray(interceptors)) { + for (const interceptor of interceptors) { + object = await interceptor(object); + } } - return interceptedResponse; + return object; } diff --git a/src/queue-manager.ts b/src/queue-manager.ts index c428802..f7cf15e 100644 --- a/src/queue-manager.ts +++ b/src/queue-manager.ts @@ -24,7 +24,7 @@ const queue: RequestsQueue = new Map(); */ export async function addRequest( config: RequestConfig, - timeout: number, + timeout: number | undefined, dedupeTime: number = 0, isCancellable: boolean = false, isTimeoutEnabled: boolean = true, diff --git a/src/request-handler.ts b/src/request-handler.ts index f32e4f2..4d35950 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -8,7 +8,7 @@ import type { ResponseError, RequestHandlerReturnType, CreatedCustomFetcherInstance, - PollingFunction, + FetcherConfig, } from './types/request-handler'; import type { APIResponse, @@ -16,7 +16,7 @@ import type { QueryParams, QueryParamsOrBody, } from './types/api-handler'; -import { interceptRequest, interceptResponse } from './interceptor-manager'; +import { applyInterceptor } from './interceptor-manager'; import { ResponseErr } from './response-error'; import { appendQueryParams, @@ -36,24 +36,25 @@ import { CONTENT_TYPE, GET, HEAD, + OBJECT, + STRING, UNDEFINED, } from './const'; import { parseResponseData } from './response-parser'; +import { generateCacheKey, getCache, setCache } from './cache-manager'; const defaultConfig: RequestHandlerConfig = { method: GET, strategy: 'reject', timeout: 30000, dedupeTime: 1000, - rejectCancelled: false, - withCredentials: false, - flattenResponse: false, defaultResponse: null, - logger: null, - fetcher: null, - baseURL: '', + headers: { + Accept: APPLICATION_JSON + ', text/plain, */*', + 'Accept-Encoding': 'gzip, deflate, br', + [CONTENT_TYPE]: APPLICATION_JSON + ';charset=utf-8', + }, retry: { - retries: 0, delay: 1000, maxDelay: 30000, resetTimeout: true, @@ -70,23 +71,20 @@ const defaultConfig: RequestHandlerConfig = { 503, // Service Unavailable 504, // Gateway Timeout ], - - shouldRetry: async () => true, }, }; /** - * Create a Request Handler + * Create Request Handler * * @param {RequestHandlerConfig} config - Configuration object for the request handler * @returns {Object} An object with methods for handling requests */ -function createRequestHandler( +export function createRequestHandler( config: RequestHandlerConfig, ): RequestHandlerReturnType { const handlerConfig: RequestHandlerConfig = { ...defaultConfig, - baseURL: config.apiUrl || '', ...config, }; @@ -94,13 +92,7 @@ function createRequestHandler( * Immediately create instance of custom fetcher if it is defined */ const customFetcher = handlerConfig.fetcher; - - const requestInstance = - customFetcher?.create({ - ...config, - baseURL: handlerConfig.baseURL, - timeout: handlerConfig.timeout, - }) || null; + const requestInstance = customFetcher?.create(handlerConfig) || null; /** * Get Provider Instance @@ -150,7 +142,7 @@ function createRequestHandler( url: string, data: QueryParamsOrBody, reqConfig: RequestConfig, - ): RequestConfig => { + ): FetcherConfig => { const method = getConfig( reqConfig, 'method', @@ -198,12 +190,15 @@ function createRequestHandler( ? appendQueryParams(dynamicUrl, explicitParams || (data as QueryParams)) : dynamicUrl; const isFullUrl = urlPath.includes('://'); - const baseURL = isFullUrl ? '' : getConfig(reqConfig, 'baseURL'); + const baseURL = isFullUrl + ? '' + : getConfig(reqConfig, 'baseURL') || + getConfig(reqConfig, 'apiUrl'); // Automatically stringify request body, if possible and when not dealing with strings if ( body && - typeof body !== 'string' && + typeof body !== STRING && !isSearchParams(body) && isJSONSerializable(body) ) { @@ -217,15 +212,6 @@ function createRequestHandler( method, url: baseURL + urlPath, - - // Add sensible defaults - headers: { - Accept: APPLICATION_JSON + ', text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - [CONTENT_TYPE]: APPLICATION_JSON + ';charset=utf-8', - ...(handlerConfig.headers || {}), - ...(reqConfig.headers || {}), - }, }; }; @@ -314,35 +300,58 @@ function createRequestHandler( data: QueryParamsOrBody = null, reqConfig: RequestConfig | null = null, ): Promise> => { - let response: FetchResponse | null = null; const _reqConfig = reqConfig || {}; - const fetcherConfig = buildConfig(url, data, _reqConfig); - - const timeout = getConfig(fetcherConfig, 'timeout'); - const isCancellable = getConfig(fetcherConfig, 'cancellable'); - const dedupeTime = getConfig(fetcherConfig, 'dedupeTime'); - const pollingInterval = getConfig(fetcherConfig, 'pollingInterval'); - const shouldStopPolling = getConfig( - fetcherConfig, - 'shouldStopPolling', - ); + const mergedConfig = { + ...handlerConfig, + ..._reqConfig, + } as RequestConfig; + + let response: FetchResponse | null = null; + const fetcherConfig = buildConfig(url, data, mergedConfig); + + const { + timeout, + cancellable, + dedupeTime, + pollingInterval, + shouldStopPolling, + cacheTime, + cacheKey, + } = mergedConfig; + + // Prevent performance overhead of cache access + let _cacheKey: string; + + if (cacheKey) { + _cacheKey = cacheKey(fetcherConfig); + } else { + _cacheKey = generateCacheKey(fetcherConfig); + } + + if (cacheTime && _cacheKey) { + const cacheBuster = mergedConfig.cacheBuster; + + if (!cacheBuster || !cacheBuster(fetcherConfig)) { + const cachedEntry = getCache< + ResponseData & FetchResponse + >(_cacheKey, cacheTime); + + if (cachedEntry) { + // Serve stale data from cache + return cachedEntry.data; + } + } + } const { - retries, + retries = 0, delay, backoff, retryOn, shouldRetry, maxDelay, resetTimeout, - } = ( - fetcherConfig.retry - ? { - ...handlerConfig.retry, - ...fetcherConfig.retry, - } - : handlerConfig.retry - ) as Required; + } = mergedConfig.retry as Required; let attempt = 0; let pollingAttempt = 0; @@ -355,9 +364,9 @@ function createRequestHandler( fetcherConfig, timeout, dedupeTime, - isCancellable, + cancellable, // Reset timeouts by default or when retries are ON - timeout > 0 && (!retries || resetTimeout), + !!(timeout && (!retries || resetTimeout)), ); // Shallow copy to ensure basic idempotency @@ -367,13 +376,13 @@ function createRequestHandler( }; // Local interceptors - requestConfig = await interceptRequest( + requestConfig = await applyInterceptor( requestConfig, - requestConfig?.onRequest, + _reqConfig?.onRequest, ); // Global interceptors - requestConfig = await interceptRequest( + requestConfig = await applyInterceptor( requestConfig, handlerConfig?.onRequest, ); @@ -403,10 +412,10 @@ function createRequestHandler( } // Local interceptors - response = await interceptResponse(response, requestConfig?.onResponse); + response = await applyInterceptor(response, _reqConfig?.onResponse); // Global interceptors - response = await interceptResponse(response, handlerConfig?.onResponse); + response = await applyInterceptor(response, handlerConfig?.onResponse); removeRequest(fetcherConfig); @@ -426,15 +435,25 @@ function createRequestHandler( } // If polling is not required, or polling attempts are exhausted - return outputResponse(response, requestConfig) as ResponseData & + const output = outputResponse(response, requestConfig) as ResponseData & FetchResponse; + + if (cacheTime && _cacheKey) { + const skipCache = requestConfig.skipCache; + + if (!skipCache || !skipCache(output, requestConfig)) { + setCache(_cacheKey, output); + } + } + + return output; } catch (err) { const error = err as ResponseErr; const status = error?.response?.status || error?.status || 0; if ( attempt === retries || - !(await shouldRetry(error, attempt)) || + !(!shouldRetry || (await shouldRetry(error, attempt))) || !retryOn?.includes(status) ) { processError(error, fetcherConfig); @@ -499,7 +518,7 @@ function createRequestHandler( if ( data === undefined || data === null || - (typeof data === 'object' && Object.keys(data).length === 0) + (typeof data === OBJECT && Object.keys(data).length === 0) ) { data = defaultResponse; } @@ -547,5 +566,3 @@ function createRequestHandler( request, }; } - -export { createRequestHandler }; diff --git a/src/types/cache-manager.ts b/src/types/cache-manager.ts new file mode 100644 index 0000000..9ebb191 --- /dev/null +++ b/src/types/cache-manager.ts @@ -0,0 +1,5 @@ +export interface CacheEntry { + data: T; + timestamp: number; + isLoading: boolean; +} diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index dc511e9..beef901 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -52,7 +52,7 @@ export type ErrorHandlingStrategy = | 'defaultResponse' | 'softFail'; -type ErrorHandlerInterceptor = (error: ResponseError) => unknown; +type ErrorInterceptor = (error: ResponseError) => unknown; export interface HeadersObject { [key: string]: string; @@ -77,6 +77,26 @@ export interface ResponseError extends Error { response?: FetchResponse; } +export type RetryFunction = ( + error: ResponseError, + attempts: number, +) => Promise; + +export type PollingFunction = ( + response: FetchResponse, + attempts: number, + error?: ResponseError, +) => boolean; + +export type CacheKeyFunction = (config: FetcherConfig) => string; + +export type CacheBusterFunction = (config: FetcherConfig) => boolean; + +export type CacheSkipFunction = ( + data: ResponseData, + config: RequestConfig, +) => boolean; + export interface RetryOptions { /** * Maximum number of retry attempts. @@ -110,6 +130,7 @@ export interface RetryOptions { /** * Retry only on specific status codes. + * @url https://developer.mozilla.org/en-US/docs/Web/HTTP/Status * @default [ * 408, // Request Timeout * 409, // Conflict @@ -126,17 +147,47 @@ export interface RetryOptions { /** * A function to determine whether to retry based on the error and attempt number. */ - shouldRetry?: ( - error: ResponseError, - attempt: number, - ) => Promise; + shouldRetry?: RetryFunction; } -export type PollingFunction = ( - response: FetchResponse, - attempt: number, - error?: ResponseError, -) => boolean; +/** + * Configuration object for cache related options + */ +export interface CacheOptions { + /** + * Maximum time, in seconds, a cache entry is considered fresh (valid). + * After this time, the entry may be considered stale (expired). + * + * @default 0 (no cache) + */ + cacheTime?: number; + + /** + * Cache key + * It provides a way to customize caching behavior dynamically according to different criteria. + * @param config - Request configuration. + * @default null By default it generates a unique cache key for HTTP requests based on: + * Method, URL, Query Params, Dynamic Path Params, mode, credentials, cache, redirect, referrer, integrity, headers and body + */ + cacheKey?: CacheKeyFunction; + + /** + * Cache Buster Function + * It is called when a cache entry exists and you want to invalidate or refresh the cache under certain conditions + * @param config - Request configuration. + * @default (config)=>false Busting cache is disabled by default. Return true to change that + */ + cacheBuster?: CacheBusterFunction; + + /** + * Skip Cache Function + * Determines whether to set or skip setting caching for a request based on the response. + * @param response - Parsed Response. + * @param config - Request configuration. + * @default (response,config)=>false Bypassing cache is disabled by default. Return true to skip cache + */ + skipCache?: CacheSkipFunction; +} /** * ExtendedRequestConfig @@ -144,7 +195,9 @@ export type PollingFunction = ( * This interface extends the standard `RequestInit` from the Fetch API, providing additional options * for handling requests, including custom error handling strategies, request interception, and more. */ -interface ExtendedRequestConfig extends Omit { +interface ExtendedRequestConfig + extends Omit, + CacheOptions { /** * Custom error handling strategy for the request. * - `'reject'`: Rejects the promise with an error. @@ -203,6 +256,11 @@ interface ExtendedRequestConfig extends Omit { */ baseURL?: string; + /** + * Alias for base URL. + */ + apiUrl?: string; + /** * An object representing the headers to include with the request. */ @@ -213,11 +271,6 @@ interface ExtendedRequestConfig extends Omit { */ params?: QueryParams; - /** - * The maximum time (in milliseconds) the request can take before automatically being aborted. - */ - timeout?: number; - /** * Indicates whether credentials (such as cookies) should be included with the request. */ @@ -252,7 +305,12 @@ interface ExtendedRequestConfig extends Omit { /** * A function to handle errors that occur during the request or response processing. */ - onError?: ErrorHandlerInterceptor; + onError?: ErrorInterceptor; + + /** + * The maximum time (in milliseconds) the request can take before automatically being aborted. + */ + timeout?: number; /** * Time window, in miliseconds, during which identical requests are deduplicated (treated as single request). @@ -277,12 +335,15 @@ interface ExtendedRequestConfig extends Omit { interface BaseRequestHandlerConfig extends RequestConfig { fetcher?: FetcherInstance | null; - apiUrl?: string; logger?: any; } export type RequestConfig = ExtendedRequestConfig; +export type FetcherConfig = Omit & { + url: string; +}; + export type RequestHandlerConfig = BaseRequestHandlerConfig; export interface RequestHandlerReturnType { diff --git a/src/utils.ts b/src/utils.ts index ec6fab5..11d21b0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,17 +1,78 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { OBJECT, UNDEFINED } from './const'; +import { OBJECT, STRING, UNDEFINED } from './const'; import type { HeadersObject, QueryParams, UrlPathParams } from './types'; export function isSearchParams(data: unknown): boolean { return data instanceof URLSearchParams; } -function makeUrl(url: string, encodedQueryString: string) { - return url.includes('?') - ? `${url}&${encodedQueryString}` - : encodedQueryString - ? `${url}?${encodedQueryString}` - : url; +/** + * Determines if a value is a non-null object. + * + * @param {any} value - The value to check. + * @returns {boolean} - True if the value is a non-null object. + */ +export function isObject(value: any): value is Record { + return value !== null && typeof value === OBJECT; +} + +/** + * Shallowly serializes an object by converting its key-value pairs into a string representation. + * This function does not recursively serialize nested objects. + * + * @param obj - The object to serialize. + * @returns A string representation of the object's top-level properties. + */ +export function shallowSerialize(obj: Record): string { + let result = ''; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + result += key + ':' + obj[key]; + } + } + + return result; +} + +/** + * Sorts the keys of an object and returns a new object with sorted keys. + * + * This function is optimized for performance by minimizing the number of object operations + * and using a single pass to create the sorted object. + * + * @param {Object} obj - The object to be sorted by keys. + * @returns {Object} - A new object with keys sorted in ascending order. + */ +export function sortObject(obj: Record): object { + const sortedObj = {} as Record; + const keys = Object.keys(obj); + + keys.sort(); + + for (let i = 0, len = keys.length; i < len; i++) { + const key = keys[i]; + sortedObj[key] = obj[key]; + } + + return sortedObj; +} + +/** + * Appends a query string to a URL, ensuring proper handling of existing query parameters. + * + * @param baseUrl - The base URL to which the query string will be appended. + * @param queryString - The encoded query string to append. + * @returns The URL with the appended query string, or the original URL if no query string is provided. + */ +function appendQueryStringToUrl(baseUrl: string, queryString: string): string { + if (!queryString) { + return baseUrl; + } + + return baseUrl.includes('?') + ? `${baseUrl}&${queryString}` + : `${baseUrl}?${queryString}`; } /** @@ -30,13 +91,13 @@ export function appendQueryParams(url: string, params: QueryParams): string { if (isSearchParams(params)) { const encodedQueryString = params.toString(); - return makeUrl(url, encodedQueryString); + return appendQueryStringToUrl(url, encodedQueryString); } // This is exact copy of what JQ used to do. It works much better than URLSearchParams const s: string[] = []; const encode = encodeURIComponent; - const add = function (k: string, v: any) { + const add = (k: string, v: any) => { v = typeof v === 'function' ? v() : v; v = v === null ? '' : v === undefined ? '' : v; s[s.length] = encode(k) + '=' + encode(v); @@ -77,7 +138,7 @@ export function appendQueryParams(url: string, params: QueryParams): string { // Encode special characters as per RFC 3986, https://datatracker.ietf.org/doc/html/rfc3986 const encodedQueryString = queryStringParts.replace(/%5B%5D/g, '[]'); // Keep '[]' for arrays - return makeUrl(url, encodedQueryString); + return appendQueryStringToUrl(url, encodedQueryString); } /** @@ -97,7 +158,7 @@ export function replaceUrlPathParams( return url; } - return url.replace(/:[a-zA-Z]+/gi, (str): string => { + return url.replace(/:\w+/g, (str): string => { const word = str.substring(1); return String(urlPathParams[word] ? urlPathParams[word] : str); @@ -123,7 +184,7 @@ export function isJSONSerializable(value: any): boolean { return false; } - if (t === 'string' || t === 'number' || t === 'boolean') { + if (t === STRING || t === 'number' || t === 'boolean') { return true; } diff --git a/test/cache-manager.spec.ts b/test/cache-manager.spec.ts new file mode 100644 index 0000000..efdb41f --- /dev/null +++ b/test/cache-manager.spec.ts @@ -0,0 +1,289 @@ +import { + generateCacheKey, + getCache, + setCache, + revalidate, + deleteCache, + mutate, +} from '../src/cache-manager'; +import { fetchf } from '../src/index'; +import * as hashM from '../src/hash'; +import * as utils from '../src/utils'; + +jest.mock('../src/index'); + +describe('Cache Manager', () => { + beforeAll(() => { + global.console = { + ...global.console, + error: jest.fn(), + }; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('generateCacheKey', () => { + const url = 'https://api.example.com/data'; + + it('should generate a cache key for basic GET request', () => { + const key = generateCacheKey({ + url, + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + expect(key).toContain('GEThttps://api.example.com/datacorsinclude'); + }); + + it('should generate a cache key for basic GET request with empty url', () => { + const key = generateCacheKey({ + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } as never); + expect(key).not.toContain('http'); + }); + + it('should return an empty string if cache is reload', () => { + const key = generateCacheKey({ + url, + cache: 'reload', + }); + expect(key).toBe(''); + }); + + it('should generate a cache key with sorted headers', () => { + const shallowSerialize = jest.spyOn(utils, 'shallowSerialize'); + + const key = generateCacheKey({ + url, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + expect(key).toContain('POSThttps://api.example.com/datacorsinclude'); + expect(shallowSerialize).toHaveBeenCalledWith({ + 'Content-Type': 'application/json', + }); + }); + + it('should hash the body if provided', () => { + // (hash as jest.Mock).mockReturnValue('hashedBody'); + const spy = jest.spyOn(hashM, 'hash'); + + const key = generateCacheKey({ + url, + method: 'POST', + body: JSON.stringify({ name: 'Alice' }), + }); + expect(spy).toHaveBeenCalled(); + expect(key).toContain('1008044925'); + }); + + it('should convert FormData body to string', () => { + const formData = new FormData(); + formData.set('something', '1'); + + const key = generateCacheKey({ + url, + method: 'POST', + body: formData, + }); + expect(key).toContain('-818489256'); + }); + + it('should handle Blob body', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + const key = generateCacheKey({ + url, + method: 'POST', + body: blob, + }); + expect(key).toContain('BF4text/plain'); + }); + + it('should handle ArrayBuffer body', () => { + const buffer = new ArrayBuffer(8); + const key = generateCacheKey({ + url, + method: 'POST', + body: buffer, + }); + expect(key).toContain('AB8'); + }); + + it('should handle numbers', () => { + const key = generateCacheKey({ + url, + method: 'POST', + // @ts-expect-error Number + body: 10, + }); + expect(key).toContain('1061505'); + }); + + it('should handle Array body', () => { + const arrayBody = [1, 2, 3]; + const key = generateCacheKey({ + url, + method: 'POST', + body: arrayBody, + }); + expect(key).toContain('1004020241'); + }); + + it('should handle Object body and sort properties', () => { + const objectBody = { b: 2, a: 1 }; + const key = generateCacheKey({ + url, + method: 'POST', + body: objectBody, + }); + + expect(key).toContain('1268505936'); + }); + }); + + describe('getCache', () => { + afterEach(() => { + deleteCache('key'); + }); + + it('should return cache entry if not expired', () => { + setCache('key', { data: 'test' }, false); + const result = getCache('key', 0); + expect(result).not.toBeNull(); + expect(result?.data).toEqual({ data: 'test' }); + }); + + it('should return null and delete cache if expired', () => { + setCache('key', { data: 'test' }, false); + const result = getCache('key', -1); + expect(result).toBeNull(); + }); + + it('should return null if no cache entry exists', () => { + const result = getCache('nonExistentKey', 60); + expect(result).toBeNull(); + }); + + it('should delete expired cache entry', () => { + setCache('key', { data: 'test' }, false); + deleteCache('key'); + expect(getCache('key', 60)).toBe(null); + }); + }); + + describe('setCache', () => { + afterEach(() => { + deleteCache('key'); + }); + + it('should set cache with proper data', () => { + const data = { foo: 'bar' }; + setCache('key', data); + const entry = getCache('key', 60); + expect(entry?.data).toEqual(data); + expect(entry?.isLoading).toBe(false); + }); + + it('should handle isLoading state', () => { + setCache('key', { foo: 'bar' }, true); + const entry = getCache('key', 60); + expect(entry?.isLoading).toBe(true); + }); + + it('should set timestamp when caching data', () => { + const timestampBefore = Date.now(); + setCache('key', { foo: 'bar' }); + const entry = getCache('key', 60); + expect(entry?.timestamp).toBeGreaterThanOrEqual(timestampBefore); + }); + }); + + describe('revalidate', () => { + afterEach(() => { + deleteCache('key'); + }); + + it('should fetch fresh data and update cache', async () => { + const mockResponse = { data: 'newData' }; + (fetchf as jest.Mock).mockResolvedValue(mockResponse); + + await revalidate('key', { url: 'https://api.example.com' }); + const entry = getCache('key', 60); + expect(entry?.data).toEqual(mockResponse); + }); + + it('should handle fetch errors during revalidation', async () => { + const errorMessage = 'Fetch failed'; + (fetchf as jest.Mock).mockRejectedValue(new Error(errorMessage)); + + await expect( + revalidate('key', { url: 'https://api.example.com' }), + ).rejects.toThrow(errorMessage); + const entry = getCache('key', 60); + expect(entry?.data).toBeUndefined(); + }); + + it('should not update cache if revalidation fails', async () => { + const errorMessage = 'Fetch failed'; + const oldData = { data: 'oldData' }; + + (fetchf as jest.Mock).mockRejectedValue(new Error(errorMessage)); + setCache('key', oldData); + + await expect( + revalidate('key', { url: 'https://api.example.com' }), + ).rejects.toThrow(errorMessage); + const entry = getCache('key', 60); + expect(entry?.data).toEqual(oldData); + }); + }); + + describe('deleteCache', () => { + it('should delete cache entry', () => { + setCache('key', { data: 'test' }); + deleteCache('key'); + expect(getCache('key', 60)).toBe(null); + }); + + it('should do nothing if cache key does not exist', () => { + deleteCache('nonExistentKey'); + expect(getCache('nonExistentKey', 60)).toBe(null); + }); + }); + + describe('mutate', () => { + it('should mutate cache entry with new data', () => { + setCache('key', { data: 'oldData' }); + mutate('key', { url: 'https://api.example.com' }, { data: 'newData' }); + const entry = getCache('key', 60); + expect(entry?.data).toEqual({ data: 'newData' }); + }); + + it('should revalidate after mutation if revalidateAfter is true', async () => { + const mockResponse = { data: 'newData' }; + (fetchf as jest.Mock).mockResolvedValue(mockResponse); + + await mutate( + 'key', + { url: 'https://api.example.com' }, + { data: 'mutatedData' }, + true, + ); + const entry = getCache('key', 60); + expect(entry?.data).toEqual(mockResponse); + }); + + it('should not revalidate after mutation if revalidateAfter is false', async () => { + setCache('key', { data: 'oldData' }); + mutate( + 'key', + { url: 'https://api.example.com' }, + { data: 'newData' }, + false, + ); + expect(fetchf).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/hash.spec.ts b/test/hash.spec.ts index 3d5f613..61b8f5d 100644 --- a/test/hash.spec.ts +++ b/test/hash.spec.ts @@ -1,5 +1,4 @@ -import { hash, hashFromConfig } from '../src/hash'; -import type { RequestConfig } from '../src/types/request-handler'; +import { hash } from '../src/hash'; describe('hash function', () => { it('should return a consistent hash for the same input', () => { @@ -16,7 +15,7 @@ describe('hash function', () => { it('should handle an empty string', () => { const input = ''; - const expectedHash = '811c9dc5'; // Update with the correct expected hash value + const expectedHash = '0'; expect(hash(input)).toBe(expectedHash); }); @@ -26,134 +25,3 @@ describe('hash function', () => { expect(hash(input).length).toBeLessThan(500); }); }); - -describe('hashFromConfig function', () => { - it('should return a hash for a given RequestConfig', () => { - const requestConfig: RequestConfig = { - method: 'GET', - baseURL: 'https://api.example.com', - url: '/endpoint', - params: { query: 'value' }, - data: { key: 'value' }, - }; - - expect(hashFromConfig(requestConfig)).toBeTruthy(); - expect(hashFromConfig(requestConfig)).toEqual( - hashFromConfig(requestConfig), - ); - }); - - it('should cache results to avoid rehashing', () => { - const requestConfig: RequestConfig = { - method: 'POST', - baseURL: 'https://api.example.com', - url: '/another-endpoint', - params: { query: 'anotherValue' }, - data: { anotherKey: 'anotherValue' }, - }; - - const firstHash = hashFromConfig(requestConfig); - const secondHash = hashFromConfig(requestConfig); - - expect(firstHash).toBe(secondHash); - }); - - it('should produce different hashes for different RequestConfig objects', () => { - const config1: RequestConfig = { - method: 'GET', - baseURL: 'https://api.example.com', - url: '/endpoint1', - params: { query: 'value1' }, - data: { key: 'value1' }, - }; - - const config2: RequestConfig = { - method: 'POST', - baseURL: 'https://api.example.com', - url: '/endpoint2', - params: { query: 'value2' }, - data: { key: 'value2' }, - }; - - expect(hashFromConfig(config1)).not.toBe(hashFromConfig(config2)); - }); -}); - -describe('hashFromConfig with cache', () => { - let hashCacheGetSpy: unknown; - let hashCacheSetSpy: unknown; - - beforeEach(() => { - // Reset spies before each test - hashCacheGetSpy = jest.spyOn(WeakMap.prototype, 'get'); - hashCacheSetSpy = jest.spyOn(WeakMap.prototype, 'set'); - }); - - afterEach(() => { - // Restore the original implementation after each test - jest.restoreAllMocks(); - }); - - it('should compute and cache the hash for a new RequestConfig', () => { - const requestConfig: RequestConfig = { - method: 'GET', - baseURL: 'https://api.example.com', - url: '/endpoint', - params: { query: 'value' }, - data: { key: 'value' }, - }; - - const firstHash = hashFromConfig(requestConfig); - expect(firstHash).toBeDefined(); - expect(hashCacheGetSpy).toHaveBeenCalledTimes(1); - expect(hashCacheSetSpy).toHaveBeenCalledTimes(1); - }); - - it('should return the cached hash for the same RequestConfig', () => { - const requestConfig: RequestConfig = { - method: 'GET', - baseURL: 'https://api.example.com', - url: '/endpoint', - params: { query: 'value' }, - data: { key: 'value' }, - }; - - const firstHash = hashFromConfig(requestConfig); - expect(firstHash).toBeDefined(); - expect(hashCacheGetSpy).toHaveBeenCalledTimes(1); - expect(hashCacheSetSpy).toHaveBeenCalledTimes(1); - - const secondHash = hashFromConfig(requestConfig); - expect(secondHash).toBe(firstHash); - expect(hashCacheGetSpy).toHaveBeenCalledTimes(2); // Verify cache get was called again - expect(hashCacheSetSpy).toHaveBeenCalledTimes(1); // Verify cache set was not called again - }); - - it('should compute and cache the hash for a different RequestConfig', () => { - const requestConfig1: RequestConfig = { - method: 'GET', - baseURL: 'https://api.example.com', - url: '/endpoint', - params: { query: 'value1' }, - data: { key: 'value1' }, - }; - - const requestConfig2: RequestConfig = { - method: 'POST', - baseURL: 'https://api.example.com', - url: '/endpoint', - params: { query: 'value2' }, - data: { key: 'value2' }, - }; - - const firstHash = hashFromConfig(requestConfig1); - expect(firstHash).toBeDefined(); - expect(hashCacheGetSpy).toHaveBeenCalledTimes(1); - expect(hashCacheSetSpy).toHaveBeenCalledTimes(1); - - const secondHash = hashFromConfig(requestConfig2); - expect(secondHash).toBeDefined(); - expect(hashCacheGetSpy).toHaveBeenCalledTimes(2); - expect(hashCacheSetSpy).toHaveBeenCalledTimes(2); - }); -}); diff --git a/test/interceptor-manager.spec.ts b/test/interceptor-manager.spec.ts index 5c3ad8a..fae783c 100644 --- a/test/interceptor-manager.spec.ts +++ b/test/interceptor-manager.spec.ts @@ -3,10 +3,7 @@ import type { FetchResponse, RequestHandlerConfig, } from '../src/types/request-handler'; -import { - interceptRequest, - interceptResponse, -} from '../src/interceptor-manager'; +import { applyInterceptor } from '../src/interceptor-manager'; import type { RequestInterceptor, ResponseInterceptor, @@ -16,6 +13,8 @@ import type { type ResponseData = any; describe('Interceptor Functions', () => { + const interceptResponse = applyInterceptor; + const interceptRequest = applyInterceptor; let requestInterceptors: RequestInterceptor[] = []; let responseInterceptors: ResponseInterceptor[] = []; @@ -135,7 +134,6 @@ describe('Interceptor Functions', () => { status: 500, }) as ExtendedResponse; - // Test interceptResponse handling of errors await expect( interceptResponse(errorResponse, responseInterceptors), ).rejects.toThrow('Response Error'); diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index 727e930..1d25909 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -1,18 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { createRequestHandler } from '../src/request-handler'; import fetchMock from 'fetch-mock'; -import { - interceptRequest, - interceptResponse, -} from '../src/interceptor-manager'; +import { applyInterceptor } from '../src/interceptor-manager'; import { delayInvocation } from '../src/utils'; import type { RequestHandlerReturnType } from '../src/types/request-handler'; import { fetchf } from '../src'; import { ABORT_ERROR } from '../src/const'; jest.mock('../src/interceptor-manager', () => ({ - interceptRequest: jest.fn().mockImplementation(async (config) => config), - interceptResponse: jest.fn().mockImplementation(async (response) => response), + applyInterceptor: jest.fn().mockImplementation(async (response) => response), })); jest.mock('../src/utils', () => { @@ -56,6 +52,11 @@ describe('Request Handler', () => { describe('buildConfig() with native fetch()', () => { let requestHandler: RequestHandlerReturnType | null = null; + const headers = { + Accept: 'application/json, text/plain, */*', + 'Accept-Encoding': 'gzip, deflate, br', + 'Content-Type': 'application/json;charset=utf-8', + }; beforeAll(() => { requestHandler = createRequestHandler({}); @@ -90,17 +91,15 @@ describe('Request Handler', () => { 'GET', 'https://example.com/api', { foo: 'bar' }, - {}, + { + headers, + }, ); expect(result).toEqual({ url: 'https://example.com/api?foo=bar', method: 'GET', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, + headers, }); }); @@ -109,17 +108,15 @@ describe('Request Handler', () => { 'POST', 'https://example.com/api', { foo: 'bar' }, - {}, + { + headers, + }, ); expect(result).toEqual({ url: 'https://example.com/api', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, + headers, body: JSON.stringify({ foo: 'bar' }), }); }); @@ -129,17 +126,15 @@ describe('Request Handler', () => { 'PUT', 'https://example.com/api', { foo: 'bar' }, - {}, + { + headers, + }, ); expect(result).toEqual({ url: 'https://example.com/api', method: 'PUT', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, + headers, body: JSON.stringify({ foo: 'bar' }), }); }); @@ -149,17 +144,15 @@ describe('Request Handler', () => { 'DELETE', 'https://example.com/api', { foo: 'bar' }, - {}, + { + headers, + }, ); expect(result).toEqual({ url: 'https://example.com/api', method: 'DELETE', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, + headers, body: JSON.stringify({ foo: 'bar' }), }); }); @@ -170,7 +163,7 @@ describe('Request Handler', () => { 'https://example.com/api', { foo: 'bar' }, { - headers: { Authorization: 'Bearer token' }, + headers: { 'X-CustomHeader': 'Some token' }, data: { additional: 'info' }, }, ); @@ -179,10 +172,7 @@ describe('Request Handler', () => { url: 'https://example.com/api?foo=bar', method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - Authorization: 'Bearer token', + 'X-CustomHeader': 'Some token', }, body: JSON.stringify({ additional: 'info' }), }); @@ -194,11 +184,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, body: null, }); }); @@ -214,11 +199,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, body: 'rawData', }); }); @@ -234,11 +214,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api?foo[]=1&foo[]=2', method: 'HEAD', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, }); }); @@ -253,11 +228,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api?foo=bar', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, body: JSON.stringify({ additional: 'info' }), }); }); @@ -270,11 +240,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, credentials: 'include', body: null, }); @@ -293,11 +258,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, body: JSON.stringify({ foo: 'bar' }), }); }); @@ -313,11 +273,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api?foo=bar', method: 'GET', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, }); }); }); @@ -841,6 +796,10 @@ describe('Request Handler', () => { let requestHandler: RequestHandlerReturnType; beforeEach(() => { + (applyInterceptor as jest.Mock).mockReset(); + (applyInterceptor as jest.Mock).mockImplementation( + async (response) => response, + ); requestHandler = createRequestHandler({ baseURL: 'https://api.example.com', timeout: 5000, @@ -858,22 +817,11 @@ describe('Request Handler', () => { afterEach(() => { jest.useRealTimers(); - (interceptRequest as jest.Mock).mockReset(); - (interceptResponse as jest.Mock).mockReset(); - (interceptRequest as jest.Mock).mockImplementation( - async (config) => config, - ); - (interceptResponse as jest.Mock).mockImplementation( - async (response) => response, - ); }); it('should apply interceptors correctly', async () => { // Set up mock implementations if needed - (interceptRequest as jest.Mock).mockImplementation( - async (config) => config, - ); - (interceptResponse as jest.Mock).mockImplementation( + (applyInterceptor as jest.Mock).mockImplementation( async (response) => response, ); @@ -889,19 +837,15 @@ describe('Request Handler', () => { // Call the request method await requestHandler.request(url, data, config); - // Verify that interceptRequest and interceptResponse were called - expect(interceptRequest).toHaveBeenCalled(); - expect(interceptResponse).toHaveBeenCalled(); + // Verify that interceptRequest and applyInterceptor were called + expect(applyInterceptor).toHaveBeenCalledTimes(4); }); it('should handle modified config in interceptRequest', async () => { - (interceptRequest as jest.Mock).mockImplementation(async (config) => ({ + (applyInterceptor as jest.Mock).mockImplementation(async (config) => ({ ...config, headers: { 'Modified-Header': 'ModifiedValue' }, })); - (interceptResponse as jest.Mock).mockImplementation( - async (response) => response, - ); fetchMock.mock('https://api.example.com/test-endpoint?key=value', { status: 200, @@ -914,19 +858,15 @@ describe('Request Handler', () => { await requestHandler.request(url, data, config); - expect(interceptRequest).toHaveBeenCalled(); - expect(interceptResponse).toHaveBeenCalled(); + expect(applyInterceptor).toHaveBeenCalledTimes(4); // Verify that fetch was called with modified headers expect(fetchMock.lastOptions()).toMatchObject({ headers: { 'Modified-Header': 'ModifiedValue' }, }); }); - it('should handle modified response in interceptResponse', async () => { - (interceptRequest as jest.Mock).mockImplementation( - async (config) => config, - ); - (interceptResponse as jest.Mock).mockImplementation(async (response) => ({ + it('should handle modified response in applyInterceptor', async () => { + (applyInterceptor as jest.Mock).mockImplementation(async (response) => ({ ...response, data: { username: 'modified response' }, })); @@ -942,16 +882,12 @@ describe('Request Handler', () => { const response = await requestHandler.request(url, data, config); - expect(interceptRequest).toHaveBeenCalled(); - expect(interceptResponse).toHaveBeenCalled(); + expect(applyInterceptor).toHaveBeenCalledTimes(4); expect(response).toMatchObject({ username: 'modified response' }); }); it('should handle request failure with interceptors', async () => { - (interceptRequest as jest.Mock).mockImplementation( - async (config) => config, - ); - (interceptResponse as jest.Mock).mockImplementation( + (applyInterceptor as jest.Mock).mockImplementation( async (response) => response, ); @@ -968,15 +904,12 @@ describe('Request Handler', () => { 'https://api.example.com/test-endpoint?key=value failed! Status: 500', ); - expect(interceptRequest).toHaveBeenCalled(); - expect(interceptResponse).not.toHaveBeenCalled(); + // Only request interceptors are called (2 because 1 local and 1 global) + expect(applyInterceptor).toHaveBeenCalledTimes(2); }); it('should handle request with different response status', async () => { - (interceptRequest as jest.Mock).mockImplementation( - async (config) => config, - ); - (interceptResponse as jest.Mock).mockImplementation( + (applyInterceptor as jest.Mock).mockImplementation( async (response) => response, ); @@ -993,8 +926,8 @@ describe('Request Handler', () => { 'https://api.example.com/test-endpoint?key=value failed! Status: 404', ); - expect(interceptRequest).toHaveBeenCalled(); - expect(interceptResponse).not.toHaveBeenCalled(); + // Only request interceptors are called (2 because 1 local and 1 global) + expect(applyInterceptor).toHaveBeenCalledTimes(2); }); }); diff --git a/test/utils.spec.ts b/test/utils.spec.ts index 648b570..db32bff 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -6,11 +6,6 @@ import { processHeaders, } from '../src/utils'; -jest.mock('../src/interceptor-manager', () => ({ - interceptRequest: jest.fn().mockImplementation(async (config) => config), - interceptResponse: jest.fn().mockImplementation(async (response) => response), -})); - describe('Utils', () => { console.warn = jest.fn();