diff --git a/README.md b/README.md index fabef24..0f1b3c8 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,36 @@ +
logo -Fast, lightweight and reusable data fetching +

Fast, lightweight (~3 KB gzipped) and reusable data fetching

+ +"fetchff" stands for "fetch fast & flexibly" [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://img.shields.io/badge/coverage-96.35-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? -Managing multiple API endpoints can be complex and time-consuming. `fetchff` simplifies this process by offering a straightforward, declarative approach to API handling using Repository Pattern. It reduces the need for extensive setup and middlewares, allowing developers to focus on data manipulation and application logic. +Managing multitude of API connections in large Frontend Application can be complex, time-consuming and hard to scale. `fetchff` simplifies the process by offering a simple, declarative approach to API handling using Repository Pattern. It reduces the need for extensive setup, middlewares, retries, custom caching, and heavy plugins, and lets developers focus on data handling and application logic. **Key Benefits:** -**✅ Simplicity:** Minimal code footprint for managing extensive APIs. +✅ **Small:** Minimal code footprint of ~3KB gzipped for managing extensive APIs. + +✅ **Immutable:** Every request has its own instance. + +✅ **Isomorphic:** Comptabile with Node.js, Deno and modern browsers. + +✅ **Type Safe:** Strongly typed and written in TypeScript. -**✅ Productivity:** Streamlines API interactions, enhancing developer efficiency. +✅ **Scalable:** Easily scales from a few calls to complex API networks with thousands of APIs. -**✅ Scalability:** Easily scales from a few endpoints to complex API networks. +✅ **Tested:** Battle tested in large projects, fully covered by unit tests. + +✅ **Maintained:** Since 2021 publicly through Github. ## ✔️ Features @@ -26,13 +39,13 @@ Managing multiple API endpoints can be complex and time-consuming. `fetchff` sim - **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. +- **Error Handling**: Flexible error management at both global and individual request levels. - **Automatic Request Cancellation**: Utilizes `AbortController` to cancel previous requests automatically. -- **Global and Per Request Timeouts**: Set timeouts globally or per request to prevent hanging operations. +- **Timeouts**: Set timeouts globally or per request to prevent hanging operations. - **Multiple Fetching Strategies**: Handle failed requests with various strategies - promise rejection, silent hang, soft fail, or default response. - **Multiple Requests Chaining**: Easily chain multiple requests using promises for complex API interactions. -- **Supports All Axios Options**: Fully compatible with all Axios configuration options for seamless integration. +- **Native `fetch()` Support**: Utilizes the built-in `fetch()` API, providing a modern and native solution for making HTTP requests. +- **Customizable**: Fully compatible with a wide range of HTTP request configuration options, allowing for flexible and detailed request customization. - **Lightweight**: Minimal footprint, only a few KBs when gzipped, ensuring quick load times. - **Framework Independent**: Pure JavaScript solution, compatible with any framework or library. - **Cross-Framework compatible**: Makes it easy to integration with Frameworks and Libraries, both Client Side and Server Side. @@ -145,15 +158,15 @@ const api = createApiFetcher({ }, // Define more endpoints as needed }, - // You can set some settings globally. They will - strategy: 'softFail', // no try/catch required + // You can set all settings globally + strategy: 'softFail', // no try/catch required in case of errors }); // Make a GET request to http://example.com/api/user-details/2/?rating[]=1&rating[]=2 -const { data, error } = await api.getUser( - { rating: [1, 2] }, // Append some Query Params. Passed arrays, objects etc. will be parsed automatically - { id: 2 }, // URL Path Params, replaces :id in the URL with 2 -); +const { data, error } = await api.getUser({ + params: { rating: [1, 2] }, // Passed arrays, objects etc. will be parsed automatically + urlPathParams: { id: 2 }, // Replace :id with 2 in the URL +}); ``` #### Multiple API Specific Settings @@ -162,174 +175,179 @@ const { data, error } = await api.getUser( Click to expand
-There are only 2 extra settings for `createApiFetcher()`: +All the Request Settings can be used directly in the function or in the `endpoints` property (on per-endpoint basis). There are also two extra global settings for `createApiFetcher()`: | Name | 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. | - - #### How It Works -
- Click to expand -
+The `createApiFetcher()` automatically creates and returns API methods based on the `endpoints` object provided. It also exposes some extra methods and properties that are useful to handle global config, dynamically add and remove endpoints etc. -The `createApiFetcher()` automatically creates and returns API methods based on the endpoints provided. It also exposes some extra methods and properties that are useful to handle global config, dynamically add and remove endpoints etc. +#### `api.yourEndpoint(requestConfig)` -#### `api.myEndpointName(queryParamsOrBodyPayload, urlPathParams, requestConfig)` +Where `yourEndpoint` is the name of your endpoint, the key from `endpoints` object passed to the `createApiFetcher()`. -Where "myEndpointName" is the name of your endpoint from `endpoints` object passed to the `createApiFetcher()`. +**`requestConfig`** (optional) `object` - To have more granular control over specific endpoints you can pass Request Config for particular endpoint. Check Basic Settings below for more information. -**`queryParams`** / **`bodyPayload`** (optional) - Query Parameters or Body Payload for POST requests. +Returns: Response Object (see below). -The first argument of API functions is an object that can serve different purposes based on the type of request being made: +#### `api.request(endpointNameOrUrl, requestConfig)` -- For `GET` and `HEAD` Requests: This object will be treated as query parameters. You can pass key-value pairs where the values can be strings, numbers, or arrays. For example, if you pass { foo: [1, 2] }, it will be automatically serialized into foo[]=1&foo[]=2 in the URL. +The `api.request()` helper function is a versatile method provided for making API requests with customizable configurations. It allows you to perform HTTP requests to any endpoint defined in your API setup and provides a straightforward way to handle queries, path parameters, and request configurations dynamically. -- For `POST` (and similar) Requests: This object is used as the data payload. It will be sent in the body of the request. If your request also requires query parameters, you can still pass those in the first argument and then use the requestConfig.body or requestConfig.data for the payload. +##### Example -**Note:** If you need to use Query Params in the `POST` (and similar) requests, you can pass them in this argument and then use `body` in `requestConfig` (third argument). +```typescript +import { createApiFetcher } from 'fetchff'; -**`urlPathParams`** (optional) - Dynamic URL Path Parameters, e.g. `/user-details/update/:userId` +const api = createApiFetcher({ + apiUrl: 'https://example.com/api', + endpoints: { + updateUserData: { + url: '/update-user/:id', + method: 'POST', + }, + // Define more endpoints as needed + }, +}); -The urlPathParams option allows you to dynamically replace parts of your URL with specific values in a declarative and straightforward way. This feature is particularly useful when you need to construct URLs that include variables or identifiers within the path. +// Using api.request to make a POST request +const { data, error } = await api.request('updateUserData', { + body: { + name: 'John Doe', // Data Payload + }, + urlPathParams: { + id: '123', // URL Path Param :id will be replaced with 123 + }, +}); -For example, consider the following URL template: `/user-details/update/:userId`. By using urlPathParams, you can replace `:userId` with an actual value when the API request is made. +// Using api.request to make a GET request to an external API +const { data, error } = await api.request('https://example.com/api/user', { + params: { + name: 'John Smith', // Query Params + }, +}); +``` -**`requestConfig`** (optional) - Request Configuration to overwrite global config in case -To have more granular control over specific endpoints you can pass Request Config for particular endpoint. See the Settings below for more information. +#### `api.config` -Returns: **`response`** or **`data`** object, depending on `flattenResponse` setting. +You can access `api.config` property directly, so to modify global headers, and other settings on fly. Please mind it is a property, not a function. -##### Response Object without `flattenResponse` (default) +#### `api.endpoints` -When `flattenResponse` is disabled, the response object includes a more detailed structure, encapsulating various aspects of the response: +You can access `api.endpoints` property directly, so to modify endpoints list. It can be useful if you want to append or remove global endpoints. Please mind it is a property, not a function. -- **`data`**: +#### `api.getInstance()` - - Contains the actual data returned from the API request. +If you initialize API handler with your custom `fetcher`, then this function will return the instance is created using `fetcher.create()` function. Your fetcher can include anything. It will be triggering `fetcher.request()` instead of native fetch() that is available by default. It gives you ultimate flexibility on how you want your requests to be made. -- **`error`**: +
- - An object with details about any error that occurred or `null` otherwise. - - **`name`**: The name of the error (e.g., 'ResponseError'). - - **`message`**: A descriptive message about the error. - - **`status`**: The HTTP status code of the response (e.g., 404, 500). - - **`statusText`**: The HTTP status text of the response (e.g., 'Not Found', 'Internal Server Error'). - - **`request`**: Details about the HTTP request that was sent (e.g., URL, method, headers). - - **`config`**: The configuration object used for the request, including URL, method, headers, and query parameters. - - **`response`**: The full response object received from the server, including all headers and body. +## 🛠️ Plugin API Architecture -- **`config`**: +
+ Click to expand +
- - The configuration object with all settings used for the request, including URL, method, headers, and query parameters. +![Example SVG](./docs/api-architecture.png) -- **`request`**: +
- - An alias for `config`. +## ⚙️ Basic Settings -- **`headers`**: - - The response headers returned by the server, such as content type and caching information returned as simple key-value object. +You can pass the settings: -##### Response Object with `flattenResponse` +- globally for all requests when calling `createApiFetcher()` +- per-endpoint basis defined under `endpoints` in global config when calling `createApiFetcher()` +- per-request basis when calling `fetchf()` (second argument of the function) or in the `api.yourEndpoint()` (third argument) -When the `flattenResponse` option is enabled, the `data` from the API response is directly exposed as the top-level property of the response object. This simplifies access to the actual data, as it is not nested within additional response metadata. +You can also use all native `fetch()` settings. -##### Key Points +| | Type | Default | Description | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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.yourEndpoint()` function. You can still pass configuration in 3rd argument if want to.

You can pass key-value pairs where the values can be strings, numbers, or arrays. For example, if you pass `{ foo: [1, 2] }`, it will be automatically serialized into `foo[]=1&foo[]=2` in the URL. | +| 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` | `{}` | It lets you dynamically replace segments of your URL with specific values in a clear and declarative manner. This feature is especially handy for constructing URLs with variable components or identifiers.

For example, suppose you need to update user details and have a URL template like `/user-details/update/:userId`. With `urlPathParams`, you can replace `:userId` with a real user ID, such as `123`, resulting in the URL `/user-details/update/123`. | +| flattenResponse | `boolean` | `false` | When set to `true`, this option flattens the nested response data. This means you can access the data directly without having to use `response.data.data`. It works only if the response structure includes a single `data` property. | +| 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). | +| 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. | + +## 🏷️ Headers -- **With `flattenResponse` Enabled**: +
+ Click to expand +
- - **`data`**: Directly contains the API response data. +`fetchff` provides robust support for handling HTTP headers in your requests. You can configure and manipulate headers at both global and per-request levels. Here’s a detailed overview of how to work with headers using `fetchff`. -- **With `flattenResponse` Disabled**: - - **`data`**: Contains the API response data nested within a broader response structure. - - **`error`**: Provides detailed information about any errors encountered. - - **`config`**: Shows the request configuration. - - **`request`**: Details the actual HTTP request sent. - - **`headers`**: Includes the response headers from the server. +**Note:** Header keys are case-sensitive when specified in request objects. Ensure that the keys are provided in the correct case to avoid issues with header handling. -The `flattenResponse` option provides a more streamlined response object by placing the data directly at the top level, while disabling it gives a more comprehensive response structure with additional metadata. +### How to Set Per-Request Headers -#### `api.config` +To set headers for a specific request, include the `headers` option in the request configuration. This option accepts an `object` where the keys are the header names and the values are the corresponding header values. -You can access `api.config` property directly, so to modify global headers, and other settings on fly. Please mind it is a property, not a function. +### Default Headers -#### `api.endpoints` +The `fetchff` plugin automatically injects a set of default headers into every request. These default headers help ensure that requests are consistent and include necessary information for the server to process them correctly. -You can access `api.endpoints` property directly, so to modify endpoints list. It can be useful if you want to append or remove global endpoints. Please mind it is a property, not a function. +#### Default Headers Injected -#### `api.getInstance()` +- **`Content-Type`**: `application/json;charset=utf-8` + Specifies that the request body contains JSON data and sets the character encoding to UTF-8. -When API handler is firstly initialized, a new custom `fetcher` instance is created. You can call `api.getInstance()` if you want to get that instance directly, for example to add some interceptors. The instance of `fetcher` is created using `fetcher.create()` functions. Your fetcher can include anything. It will be triggered instead of native fetch() that is available by default. +- **`Accept`**: `application/json, text/plain, */*` + Indicates the media types that the client is willing to receive from the server. This includes JSON, plain text, and any other types. -#### `api.request()` +- **`Accept-Encoding`**: `gzip, deflate, br` + Specifies the content encoding that the client can understand, including gzip, deflate, and Brotli compression. -The `api.request()` helper function is a versatile method provided for making API requests with customizable configurations. It allows you to perform HTTP requests to any endpoint defined in your API setup and provides a straightforward way to handle queries, path parameters, and request configurations dynamically. +### Setting Headers Globally -##### Example +You can set default headers that will be included in all requests made with a specific `createApiFetcher` instance. This is useful for setting common headers like authentication tokens or content types. + +#### Example: Setting Headers Globally ```typescript import { createApiFetcher } from 'fetchff'; const api = createApiFetcher({ - apiUrl: 'https://example.com/api', - endpoints: { - updateUserData: { - url: '/update-user/:id', - method: 'POST', - }, - // Define more endpoints as needed - }, -}); - -// Using api.request to make a POST request -const { data, error } = await api.request( - 'updateUserData', - { - name: 'John Doe', // Data Payload - }, - { - id: '123', // URL Path Param :id + baseURL: 'https://api.example.com/', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer YOUR_TOKEN', }, -); - -// Using api.request to make a GET request to an external API -const { data, error } = await api.request('https://example.com/api/user', { - name: 'John Smith', // Query Params + // other configurations }); ``` -
+### Setting Per-Request Headers -## ⚙️ Basic Settings +In addition to global default headers, you can also specify headers on a per-request basis. This allows you to override global headers or set specific headers for individual requests. -You can pass the settings: +#### Example: Setting Per-Request Headers -- globally for all requests when calling `createApiFetcher()` -- per-endpoint basis defined under `endpoints` in global config when calling `createApiFetcher()` -- per-request basis when calling `fetchf()` (second argument of the function) or in the `api.yourEndpoint()` (third argument) +```typescript +import { fetchf } from 'fetchff'; -You can also use all native `fetch()` settings. +// Example of making a GET request with custom headers +const { data } = await fetchf('https://api.example.com/endpoint', { + headers: { + Authorization: 'Bearer YOUR_ACCESS_TOKEN', + 'Custom-Header': 'CustomValue', + }, +}); +``` -| | Type | Default | Description | -| -------------------------- | ------------------------------------------------------------------------------------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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` | `{}` | It lets you dynamically replace segments of your URL with specific values in a clear and declarative manner. This feature is especially handy for constructing URLs with variable components or identifiers.

For example, suppose you need to update user details and have a URL template like `/user-details/update/:userId`. With `urlPathParams`, you can replace `:userId` with a real user ID, such as `123`, resulting in the URL `/user-details/update/123`. | -| 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). | -| 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. | + ## 🌀 Interceptors @@ -451,7 +469,7 @@ _Default:_ `reject`.
Click to expand
- The caching mechanism in fetchf() and createApiFetcher() enhances performance by reducing redundant network requests and reusing previously fetched data when appropriate. This system ensures that cached responses are managed efficiently and only used when considered "fresh." Below is a breakdown of the key parameters that control caching behavior and their default values. + The caching mechanism in fetchf() and createApiFetcher() enhances performance by reducing redundant network requests and reusing previously fetched data when appropriate. This system ensures that cached responses are managed efficiently and only used when considered "fresh". Below is a breakdown of the key parameters that control caching behavior and their default values. ### Example @@ -491,7 +509,7 @@ The caching system can be fine-tuned using the following options when configurin ### How It Works 1. **Request and Cache Check**: - When a request is made, the cache is first checked for an existing entry. If a valid cache entry is found and is still "fresh" (based on `cacheTime`), the cached response is returned immediately. + When a request is made, the cache is first checked for an existing entry. If a valid cache entry is found and is still "fresh" (based on `cacheTime`), the cached response is returned immediately. Note that when the native `fetch()` setting called `cache` is set to `reload` the request will automatically skip the internal cache. 2. **Cache Key**: A cache key uniquely identifies each request. By default, the key is generated based on the URL and other relevant request options. Custom keys can be provided using the `cacheKey` function. @@ -507,6 +525,54 @@ The caching system can be fine-tuned using the following options when configurin
+## ✋ Automatic Request Cancellation + +
+ Click to expand +
+fetchff simplifies making API requests by allowing customizable features such as request cancellation, retries, and response flattening. When a new request is made to the same API endpoint, the plugin automatically cancels any previous requests that haven't completed, ensuring that only the most recent request is processed. +

+It also supports: + +- Automatic retries for failed requests with configurable delay and exponential backoff. +- Optional flattening of response data for easier access, removing nested `data` fields. + +You can choose to reject cancelled requests or return a default response instead through the `defaultResponse` setting. + +### Example + +```javascript +import { fetchf } from 'fetchff'; + +// Function to send the request +const sendRequest = () => { + // In this example, the previous requests are automatically cancelled + // You can also control "dedupeTime" setting in order to fire the requests more or less frequently + fetchf('https://example.com/api/messages/update', { + method: 'POST', + cancellable: true, + rejectCancelled: true, + }); +}; + +// Attach keydown event listener to the input element with id "message" +document.getElementById('message')?.addEventListener('keydown', sendRequest); +``` + +### Configuration + +- **`cancellable`**: + Type: `boolean` + Default: `false` + If set to `true`, any ongoing previous requests to the same API endpoint will be automatically cancelled when a subsequent request is made before the first one completes. This is useful in scenarios where repeated requests are made to the same endpoint (e.g., search inputs) and only the latest response is needed, avoiding unnecessary requests to the backend. + +- **`rejectCancelled`**: + Type: `boolean` + Default: `false` + Works in conjunction with the `cancellable` option. If set to `true`, the promise of a cancelled request will be rejected. By default (`false`), when a request is cancelled, instead of rejecting the promise, a `defaultResponse` will be returned, allowing graceful handling of cancellation without errors. + +
+ ## 📶 Polling Configuration
@@ -643,33 +709,119 @@ The retry mechanism is configured via the `retry` option when instantiating the
-## Typings +## 🧩 Response Data Transformation + +
+ Click to expand +
+ +The `fetchff` plugin automatically handles response data transformation for any instance of `Response` returned by the `fetch()` (or a custom `fetcher`) based on the `Content-Type` header, ensuring that data is parsed correctly according to its format. + +### **How It Works** + +- **JSON (`application/json`):** Parses the response as JSON. +- **Form Data (`multipart/form-data`):** Parses the response as `FormData`. +- **Binary Data (`application/octet-stream`):** Parses the response as a `Blob`. +- **URL-encoded Form Data (`application/x-www-form-urlencoded`):** Parses the response as `FormData`. +- **Text (`text/*`):** Parses the response as plain text. + +If the `Content-Type` header is missing or not recognized, the plugin defaults to attempting JSON parsing. If that fails, it will try to parse the response as text. + +This approach ensures that the `fetchff` plugin can handle a variety of response formats, providing a flexible and reliable method for processing data from API requests. + +### `onResponse` Interceptor + +You can use the `onResponse` interceptor to customize how the response is handled before it reaches your application. This interceptor gives you access to the raw `Response` object, allowing you to transform the data or modify the response behavior based on your needs. + +
+ +## 📄 Response Object + +
+ Click to expand +
+Each request returns the following Response Object of type FetchResponse<ResponseData> where ResponseData is usually your custom interface or `object`. + +### Structure of the Response Object + +- **`data`**: + + - **Type**: `ResponseData` (or your custom type passed through generic) + + - Contains the actual data returned from the API request, `null` or value of `defaultResponse` setting, if nothing is found. + +- **`error`**: + + - **Type**: `ResponseErr` + + - An object with details about any error that occurred or `null` otherwise. + - **`name`**: The name of the error (e.g., 'ResponseError'). + - **`message`**: A descriptive message about the error. + - **`status`**: The HTTP status code of the response (e.g., 404, 500). + - **`statusText`**: The HTTP status text of the response (e.g., 'Not Found', 'Internal Server Error'). + - **`request`**: Details about the HTTP request that was sent (e.g., URL, method, headers). + - **`config`**: The configuration object used for the request, including URL, method, headers, and query parameters. + - **`response`**: The full response object received from the server, including all headers and body. + +- **`config`**: + + - **Type**: `RequestConfig` + - The configuration object with all settings used for the request, including URL, method, headers, and query parameters. + +- **`status`**: + + - **Type**: `number` + - The HTTP status code of the response (e.g., 404, 500). + +- **`statusText`**: + + - **Type**: `string` + - The HTTP status text of the response (e.g., 'Not Found', 'Internal Server Error'). + +- **`request`**: + + - **Type**: `RequestConfig` + - An alias for `config`. + +- **`headers`**: + + - **Type**: `HeadersObject` + - The response headers returned by the server, such as content type and caching information returned as simple key-value object. + +The whole response of the native `fetch()` is attached as well. + +
+ +## 📦 Typings
Click to expand
-The `fetchff` package provides comprehensive TypeScript typings to enhance development experience and ensure type safety. Below are details on the available types for both `createApiFetcher()` and `fetchf()`. +The `fetchff` package provides comprehensive TypeScript typings to enhance development experience and ensure type safety. Below are details on the available, exportable types for both `createApiFetcher()` and `fetchf()`. ### Generic Typings The `fetchff` package includes several generic types to handle various aspects of API requests and responses: -- **`QueryParams`**: Represents query parameters for requests. Can be an object, `URLSearchParams`, an array of name-value pairs, or `null`. -- **`BodyPayload`**: Represents the request body. Can be `BodyInit`, an object, an array, a string, or `null`. -- **`QueryParamsOrBody`**: Union type for query parameters or body payload. Can be either `QueryParams` or `BodyPayload`. -- **`UrlPathParams`**: Represents URL path parameters. Can be an object or `null`. +- **`QueryParams`**: Represents query parameters for requests. Can be an object, `URLSearchParams`, an array of name-value pairs, or `null`. +- **`BodyPayload`**: Represents the request body. Can be `BodyInit`, an object, an array, a string, or `null`. +- **`UrlPathParams`**: Represents URL path parameters. Can be an object or `null`. +- **`DefaultResponse`**: Default response for all requests. Default is: `any`. ### Typings for `createApiFetcher()` -The `createApiFetcher()` function provides a robust set of types to define and manage API interactions. The key types available are: +The `createApiFetcher()` function provides a robust set of types to define and manage API interactions. + +The key types are: -- **`Endpoint`**: Represents an API endpoint, allowing functions to be defined with optional query parameters, URL path parameters, and request configurations. -- **`EndpointsMethods`**: Represents the list of API endpoints with their respective settings. It is your own interface that you can pass. It will be cross-checked against the `endpoints` property of the `createApiFetcher()` configuration. Each endpoint can be configured with its own specific settings such as query parameters, URL path parameters, and request configurations. -- **`EndpointsCfg`**: Configuration for API endpoints, including query parameters, URL path parameters, and additional request configurations. +- **`EndpointsMethods`**: Represents the list of API endpoints with their respective settings. It is your own interface that you can pass to this generic. It will be cross-checked against the `endpoints` object in your `createApiFetcher()` configuration.

Each endpoint can be configured with its own specific settings such as Response Payload, Query Parameters and URL Path Parameters. +- **`Endpoint`**: Represents an API endpoint function, allowing to be defined with optional query parameters, URL path parameters, request configuration (settings), and request body (data). +- **`EndpointsSettings`**: Configuration for API endpoints, including query parameters, URL path parameters, and additional request configurations. Default is `typeof endpoints`. - **`RequestInterceptor`**: Function to modify request configurations before they are sent. - **`ResponseInterceptor`**: Function to process responses before they are handled by the application. - **`ErrorInterceptor`**: Function to handle errors when a request fails. +- **`CreatedCustomFetcherInstance`**: Represents the custom `fetcher` instance created by its `create()` function. For a full list of types and detailed definitions, refer to the [api-handler.ts](https://github.com/MattCCC/fetchff/blob/docs-update/src/types/api-handler.ts) file. @@ -677,9 +829,8 @@ For a full list of types and detailed definitions, refer to the [api-handler.ts] The `fetchf()` function includes types that help configure and manage network requests effectively: +- **`RequestHandlerConfig`**: Main configuration options for the `fetchf()` function, including request settings, interceptors, and retry configurations. - **`RetryConfig`**: Configuration options for retry mechanisms, including the number of retries, delay between retries, and backoff strategies. -- **`FetchfConfig`**: Configuration options for the `fetchf()` function, including request settings, interceptors, and retry configurations. -- **`RequestHandler`**: Represents the request handler instance created by `fetchf()`. This includes methods for making requests and managing configurations. - **`CacheConfig`**: Configuration options for caching, including cache time, custom cache keys, and cache invalidation rules. - **`PollingConfig`**: Configuration options for polling, including polling intervals and conditions to stop polling. - **`ErrorStrategy`**: Defines strategies for handling errors, such as rejection, soft fail, default response, and silent modes. @@ -739,7 +890,6 @@ Here’s an example of configuring and using the `createApiFetcher()` with all a ```typescript const api = createApiFetcher({ baseURL: 'https://api.example.com/', - retry: retryConfig, endpoints: { getBooks: { url: 'books/all', @@ -841,9 +991,14 @@ const api = createApiFetcher({ // Make a wrapper function and call your API async function sendAndGetMessage() { - await api.sendMessage({ message: 'Text' }, { postId: 1 }); + await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + }); - const { data } = await api.getMessage({ postId: 1 }); + const { data } = await api.getMessage({ + params: { postId: 1 }, + }); } // Invoke your wrapper function @@ -884,7 +1039,7 @@ interface BookPathParams { ```typescript // api.ts -import type { DefaultEndpoints } from 'fetchff'; +import type { Endpoint } from 'fetchff'; import { createApiFetcher } from 'fetchff'; const endpoints = { @@ -901,17 +1056,22 @@ interface EndpointsList { fetchBook: Endpoint; } -const api = createApiFetcher({ +type EndpointsConfiguration = typeof endpoints; + +const api = createApiFetcher({ apiUrl: 'https://example.com/api/', endpoints, }); ``` ```typescript -const book = await api.fetchBook({ newBook: true }, { bookId: 1 }); +const book = await api.fetchBook({ + params: { newBook: true }, + urlPathParams: { bookId: 1 }, +}); // Will return an error since "rating" does not exist in "BookQueryParams" -const anotherBook = await api.fetchBook({ rating: 5 }); +const anotherBook = await api.fetchBook({ params: { rating: 5 } }); // You can also pass generic type directly to the request const books = await api.fetchBooks(); @@ -950,7 +1110,9 @@ interface EndpointsList { getPosts: Endpoint; } -const api = createApiFetcher({ +type EndpointsConfiguration = typeof endpoints; + +const api = createApiFetcher({ apiUrl: 'https://example.com/api', endpoints, onError(error) { @@ -963,20 +1125,26 @@ const api = createApiFetcher({ // Fetch user data - "data" will return data directly // GET to: http://example.com/api/user-details?userId=1&ratings[]=1&ratings[]=2 -const { data } = await api.getUser({ userId: 1, ratings: [1, 2] }); +const { data } = await api.getUser({ params: { userId: 1, ratings: [1, 2] } }); // Fetch posts - "data" will return data directly // GET to: http://example.com/api/posts/myTestSubject?additionalInfo=something -const { data } = await api.getPosts( - { additionalInfo: 'something' }, - { subject: 'test' }, -); +const { data } = await api.getPosts({ + params: { additionalInfo: 'something' }, + urlPathParams: { subject: 'test' }, +}); // Send POST request to update userId "1" -await api.updateUserDetails({ name: 'Mark' }, { userId: 1 }); +await api.updateUserDetails({ + body: { name: 'Mark' }, + urlPathParams: { userId: 1 }, +}); // Send POST request to update array of user ratings for userId "1" -await api.updateUserDetails({ name: 'Mark', ratings: [1, 2] }, { userId: 1 }); +await api.updateUserDetails({ + body: { name: 'Mark', ratings: [1, 2] }, + urlPathParams: { userId: 1 }, +}); ``` In the example above we fetch data from an API for user with an ID of 1. We also make a GET request to fetch some posts, update user's name to Mark. If you want to use more strict typings, please check TypeScript Usage section below. @@ -1048,7 +1216,10 @@ const api = createApiFetcher({ async function sendMessage() { try { - await api.sendMessage({ message: 'Text' }, { postId: 1 }); + await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + }); console.log('Message sent successfully'); } catch (error) { @@ -1082,10 +1253,10 @@ const api = createApiFetcher({ }); async function sendMessage() { - const { data, error } = await api.sendMessage( - { message: 'Text' }, - { postId: 1 }, - ); + const { data, error } = await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + }); if (error) { console.error('Request Error', error); @@ -1122,19 +1293,17 @@ const api = createApiFetcher({ }); async function sendMessage() { - const { data } = await api.sendMessage( - { message: 'Text' }, - { postId: 1 }, - { - strategy: 'defaultResponse', - // null is a default setting, you can change it to empty {} or anything - // defaultResponse: null, - onError(error) { - // Callback is still triggered here - console.log(error); - }, + const { data } = await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + strategy: 'defaultResponse', + // null is a default setting, you can change it to empty {} or anything + // defaultResponse: null, + onError(error) { + // Callback is still triggered here + console.log(error); }, - ); + }); if (data === null) { // Because of the strategy, if API call fails, it will just return null @@ -1173,16 +1342,14 @@ const api = createApiFetcher({ }); async function sendMessage() { - await api.sendMessage( - { message: 'Text' }, - { postId: 1 }, - { - strategy: 'silent', - onError(error) { - console.log(error); - }, + await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + strategy: 'silent', + onError(error) { + console.log(error); }, - ); + }); // Because of the strategy, if API call fails, it will never reach this point. Otherwise try/catch would need to be required. console.log('Message sent successfully'); @@ -1214,17 +1381,15 @@ const api = createApiFetcher({ }); async function sendMessage() { - await api.sendMessage( - { message: 'Text' }, - { postId: 1 }, - { - onError(error) { - console.log('Error', error.message); - console.log(error.response); - console.log(error.config); - }, + await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + onError(error) { + console.log('Error', error.message); + console.log(error.response); + console.log(error.config); }, - ); + }); console.log('Message sent successfully'); } @@ -1234,6 +1399,47 @@ sendMessage();
+#### Request Chaining + +
+ Click to expand +
+ +In this example, we make an initial request to get a user's details, then use that data to fetch additional information in a subsequent request. This pattern allows you to perform multiple asynchronous operations in sequence, using the result of one request to drive the next. + +```typescript +import { createApiFetcher } from 'fetchff'; + +// Initialize API fetcher with endpoints +const api = createApiFetcher({ + endpoints: { + getUser: { url: '/user' }, + createPost: { url: '/post' }, + }, + apiUrl: 'https://example.com/api', +}); + +async function fetchUserAndCreatePost(userId: number, postData: any) { + // Fetch user data + const { data: userData } = await api.getUser({ params: { userId } }); + + // Create a new post with the fetched user data + return await api.createPost({ + body: { + ...postData, + userId: userData.id, // Use the user's ID from the response + }, + }); +} + +// Example usage +fetchUserAndCreatePost(1, { title: 'New Post', content: 'This is a new post.' }) + .then((response) => console.log('Post created:', response)) + .catch((error) => console.error('Error:', error)); +``` + +
+ ### Example Usage with Frameworks and Libraries `fetchff` is designed to seamlessly integrate with any popular frameworks like Next.js, libraries like React, Vue, React Query and SWR. It is written in pure JS so you can effortlessly manage API requests with minimal setup, and without any dependencies. @@ -1243,12 +1449,14 @@ sendMessage();
Click to expand
- You can implement a `useApi()` hook to handle the data fetching. Since this package has everything included, you don't really need anything more than a simple hook to utilize. + You can implement a `useApi()` hook to handle the data fetching. Since this package has everything included, you don't really need anything more than a simple hook to utilize.

-```typescript +Create `api.ts` file: + +```tsx import { createApiFetcher } from 'fetchff'; -const api = createApiFetcher({ +export const api = createApiFetcher({ apiUrl: 'https://example.com/api', strategy: 'softFail', endpoints: { @@ -1257,10 +1465,14 @@ const api = createApiFetcher({ }, }, }); +``` +Create `useApi.ts` file: + +```tsx export const useApi = (apiFunction) => { const [data, setData] = useState(null); - const [error,] = useState(null); + const [error] = useState(null); const [isLoading, setLoading] = useState(true); useEffect(() => { @@ -1270,9 +1482,9 @@ export const useApi = (apiFunction) => { const { data, error } = await apiFunction(); if (error) { - setError(error); + setError(error); } else { - setData(data); + setData(data); } setLoading(false); @@ -1281,18 +1493,25 @@ export const useApi = (apiFunction) => { fetchData(); }, [apiFunction]); - return {data, error, isLoading, setData}; + return { data, error, isLoading, setData }; }; +``` -const ProfileComponent = ({ id }) => { - const { data: profile, error, isLoading } = useApi(() => api.getProfile({ id })); +Call the API in the components: + +```tsx +export const ProfileComponent = ({ id }) => { + const { + data: profile, + error, + isLoading, + } = useApi(() => api.getProfile({ urlPathParams: { id } })); if (isLoading) return
Loading...
; if (error) return
Error: {error.message}
; return
{JSON.stringify(profile)}
; }; - ```
@@ -1305,7 +1524,7 @@ const ProfileComponent = ({ id }) => { Integrate `fetchff` with React Query to streamline your data fetching: -```typescript +```tsx import { createApiFetcher } from 'fetchff'; const api = createApiFetcher({ @@ -1318,7 +1537,9 @@ const api = createApiFetcher({ }); export const useProfile = ({ id }) => { - return useQuery(['profile', id], () => api.getProfile({ id })); + return useQuery(['profile', id], () => + api.getProfile({ urlPathParams: { id } }), + ); }; ``` @@ -1336,7 +1557,7 @@ Single calls: ```typescript const fetchProfile = ({ id }) => - fetchff('https://example.com/api/profile/:id', { urlPathParams: id }); + fetchf('https://example.com/api/profile/:id', { urlPathParams: id }); export const useProfile = ({ id }) => { const { data, error } = useSWR(['profile', id], fetchProfile); @@ -1351,7 +1572,7 @@ export const useProfile = ({ id }) => { Many endpoints: -```typescript +```tsx import { createApiFetcher } from 'fetchff'; import useSWR from 'swr'; @@ -1365,7 +1586,7 @@ const api = createApiFetcher({ }); export const useProfile = ({ id }) => { - const fetcher = () => api.getProfile({ id }); + const fetcher = () => api.getProfile({ urlPathParams: { id } }); const { data, error } = useSWR(['profile', id], fetcher); @@ -1411,7 +1632,7 @@ export function useProfile(id: number) { const isError = ref(null); const fetchProfile = async () => { - const { data, error } = await api.getProfile({ id }); + const { data, error } = await api.getProfile({ urlPathParams: { id } }); if (error) isError.value = error; else if (data) profile.value = data; @@ -1454,6 +1675,43 @@ export function useProfile(id: number) { +## 🛠️ Compatibility and Polyfills + +
+ Click to expand + +### Compatibility + +While `fetchff` is designed to work seamlessly with modern environments (ES2018+), some older browsers or specific edge cases might require additional support. + +Currently, `fetchff` offers three types of builds: + +1. Browser ESM build (.mjs): Ideal for modern browsers and module-based environments (when you use the [type="module"](https://caniuse.com/?search=type%3D%22module%22) attribute). + Location: `dist/browser/index.mjs` + Compatibility: `ES2018+` + +2. Standard Browser build: A global UMD bundle, compatible with older browsers. + Location: `dist/browser/index.global.js` + Compatibility: `ES2018+` + +3. Node.js CJS build: Designed for Node.js environments that rely on CommonJS modules. + Location: `dist/node/index.js` + Compatibility: `Node.js 18+` + +For projects that need to support older browsers, especially those predating ES2018, additional polyfills or transpilation may be necessary. Consider using tools like Babel, SWC or core-js to ensure compatibility with environments that do not natively support ES2018+ features. Bundlers like Webpack or Rollup usually handle these concerns out of the box. + +You can check [Can I Use ES2018](https://github.com/github/fetch) to verify browser support for specific ES2018 features. + +### Polyfills + +For environments that do not support modern JavaScript features or APIs, you might need to include polyfills. Some common polyfills include: + +- **Fetch Polyfill**: For environments that do not support the native `fetch` API. You can use libraries like [whatwg-fetch](https://github.com/github/fetch) to provide a fetch implementation. +- **Promise Polyfill**: For older browsers that do not support Promises. Libraries like [es6-promise](https://github.com/stefanpenner/es6-promise) can be used. +- **AbortController Polyfill**: For environments that do not support the `AbortController` API used for aborting fetch requests. You can use the [abort-controller](https://github.com/mysticatea/abort-controller) polyfill. + +
+ ## ✔️ Support and collaboration If you have any idea for an improvement, please file an issue. Feel free to make a PR if you are willing to collaborate on the project. Thank you :) diff --git a/SECURITY.md b/SECURITY.md index 7cb53b6..4bd3fc4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,8 +7,8 @@ currently being supported with security updates. | Version | Supported | | ------- | ------------------ | -| 2.5.x | :white_check_mark: | -| < 2.5 | :x: | +| 3.0.x | :white_check_mark: | +| < 3 | :x: | ## Reporting a Vulnerability diff --git a/docs/api-architecture.png b/docs/api-architecture.png new file mode 100644 index 0000000..f0e6178 Binary files /dev/null and b/docs/api-architecture.png differ diff --git a/docs/api-architecture.puml b/docs/api-architecture.puml new file mode 100644 index 0000000..7e03da3 --- /dev/null +++ b/docs/api-architecture.puml @@ -0,0 +1,50 @@ +@startuml + +!define RECTANGLE class + +RECTANGLE GlobalConfig { + +baseURL: string + +fetcher: Fetcher + +endpoints: EndpointsSettings + ... + [other RequestConfig settings] +} + +note left of GlobalConfig + Configurations can be defined at three levels: + 1. Global via createApiFetcher() + 2. Per-endpoint in EndpointConfig + 3. Per-request in fetchf() or api.yourEndpoint() +end note + +RECTANGLE EndpointConfig { + +url: string + ... + [other RequestConfig settings] +} + +RECTANGLE RequestConfig { + +timeout: number + +params: object + +body: object + ... + [all other RequestConfig settings] +} + +RECTANGLE ApiFetcher { + +fetchf(endpointNameOrUrl: string, requestConfig: RequestConfig) +} + +RECTANGLE ApiEndpoints { + +api.request(endpointNameOrUrl: string, requestConfig: RequestConfig) + +api.yourEndpoint(requestConfig: RequestConfig) +} + +GlobalConfig --|> EndpointConfig : defines +EndpointConfig --|> RequestConfig : provides defaults +GlobalConfig ..> ApiEndpoints : applies globally +EndpointConfig ..> ApiEndpoints : applies globally +RequestConfig ..> ApiFetcher : per-request settings +RequestConfig ..> ApiEndpoints : per-request settings + +@enduml diff --git a/docs/examples/examples.ts b/docs/examples/examples.ts index 83cecc7..9723cf5 100644 --- a/docs/examples/examples.ts +++ b/docs/examples/examples.ts @@ -1,3 +1,6 @@ +/** + * This file contains various examples together with tests for typings declarations + */ import { createApiFetcher, fetchf } from '../../src'; import type { Endpoint } from '../../src/types'; @@ -88,7 +91,9 @@ async function example3() { fetchBooks: Endpoint; } - const api = createApiFetcher({ + type EndpointsConfiguration = typeof endpoints; + + const api = createApiFetcher({ apiUrl: '', endpoints, }); @@ -100,29 +105,65 @@ async function example3() { const { data } = await api.ping(); // Defined in EndpointsList with query param and url path param - const { data: book } = (await api.fetchBook( - { newBook: true }, - { bookId: 1 }, - )) satisfies Book; + const { data: book } = await api.fetchBook({ + params: { newBook: true }, + urlPathParams: { bookId: 1 }, + }); - // Defined in "endpoints" but not in EndpointsList. You don't need to add "fetchMovies: Endpoint;" explicitly. + // Defined in "endpoints" but not in EndpointsList so there is no need to add "fetchMovies: Endpoint;" explicitly. const { data: movies1 } = await api.fetchMovies(); + + // With dynamically inferred type const { data: movies } = await api.fetchMovies(); const { data: movies3 }: { data: Movies } = await api.fetchMovies(); + // With custom params not defined in any interface + const { data: movies4 } = await api.fetchMovies({ + params: { + all: true, + }, + }); + // @ts-expect-error This will result in an error as endpoint is not defined const { data: movies2 } = await api.nonExistentEndpoint(); - const { data: book1 } = (await api.fetchBook( + interface NewBook { + alternativeInterface: string; + } + + interface NewBookQueryParams { + color: string; + } + + // Overwrite response of existing endpoint + const { data: book1 } = await api.fetchBook( { newBook: true }, // @ts-expect-error should verify that bookId cannot be text { bookId: 'text' }, - )) satisfies Book; + ); + + // Overwrite response and query params of existing endpoint + const { data: book11 } = await api.fetchBook({ + params: { + // @ts-expect-error Should not allow old param + newBook: true, + color: 'green', + // TODO: @ts-expect-error Should not allow non-existent param + type: 'red', + }, + }); - // @ts-expect-error will result in an error since "someParams" is not defined - const { data: books } = (await api.fetchBooks({ - someParams: 1, - })) satisfies Books; + // Standard fetch with predefined response and query params + const { data: books } = await api.fetchBooks({ + // TODO: @ts-expect-error Non-existent setting + test: true, + params: { + // This param exists + all: true, + // @ts-expect-error Should not allow non-existent param + randomParam: 1, + }, + }); const { data: book2 } = await api.fetchBook( { newBook: true }, @@ -130,15 +171,23 @@ async function example3() { { bookId: 'text' }, ); - const { data: book3 } = await api.fetchBook( + const { data: book3 } = await api.fetchBook({ // @ts-expect-error Error as newBook is not a boolean - { newBook: 'true' }, - { bookId: 1 }, - ); + params: { newBook: 'true' }, + urlPathParams: { bookId: 1 }, + }); console.log('Example 3', data, apiConfig, endpointsList); - console.log('Example 3', movies, movies1, movies2, movies3); - console.log('Example 3', books, book, book1, book2, book3); + console.log('Example 3', movies, movies1, movies2, movies3, movies4); + console.log( + 'Example 3', + books satisfies Books, + book satisfies Book, + book1 satisfies NewBook, + book11 satisfies NewBook, + book2 satisfies Book, + book3 satisfies Book, + ); } // createApiFetcher() - direct API request() call to a custom endpoint with flattenResponse == true @@ -147,35 +196,94 @@ async function example4() { fetchBooks: Endpoint; } - const api = createApiFetcher({ + type EndpointsConfiguration = typeof endpoints; + + const api = createApiFetcher({ apiUrl: '', endpoints, flattenResponse: true, }); - const books = await api.request('fetchBooks'); - const data1 = await api.request('https://example.com/api/custom-endpoint'); + // Existing endpoint generic + const { data: books } = await api.request('fetchBooks'); - // Specify generic - const data2 = await api.request<{ myData: true }>( + // Custom URL + const { data: data1 } = await api.request( 'https://example.com/api/custom-endpoint', ); - const data3 = await fetchf<{ myData: true }>( + interface OtherEndpointData { + myData: true; + } + + // Explicitly defined empty config + const { data: data4 } = await api.request('fetchBooks', { + params: { + anyParam: true, + }, + }); + + // Dynamically added Response to a generic + const { data: data2 } = await api.request( 'https://example.com/api/custom-endpoint', ); - console.log('Example 4', books); - console.log('Example 4', data1, data2, data3); + // Dynamically added Response to a generic using fetchf() + const { data: data3 } = await fetchf( + 'https://example.com/api/custom-endpoint', + ); + + // Existing endpoint with custom params + interface DynamicQueryParams { + param1: string; + } + + interface DynamicUrlParams { + urlparam2: number; + } + + const { data: books2 } = await api.request< + Books, + DynamicQueryParams, + DynamicUrlParams + >('fetchBooks', { + // Native fetch() setting + cache: 'no-store', + // Extended fetch setting + cacheTime: 86000, + // TODO: @ts-expect-error Non-existent setting + something: true, + urlPathParams: { + // @ts-expect-error Non-existent param + urlparam1: '1', + urlparam2: 1, + }, + params: { + param1: '1', + // @ts-expect-error Non-existent param + param2: 1, + }, + }); + + console.log('Example 4', books satisfies Books, books2 satisfies Books); + console.log( + 'Example 4', + data1, + data2 satisfies OtherEndpointData, + data3 satisfies OtherEndpointData, + data4, + ); } // createApiFetcher() - direct API request() call to a custom endpoint with flattenResponse == false async function example5() { - interface Endpoints5 { + interface MyEndpoints { fetchBooks: Endpoint; } - const api = createApiFetcher({ + type EndpointsConfiguration = typeof endpoints; + + const api = createApiFetcher({ apiUrl: '', endpoints, }); @@ -195,7 +303,7 @@ async function example5() { console.log('Example 5', data1, data2); } -// fetchf() - direct fetchf() request with flattenResponse == false +// fetchf() - direct fetchf() request async function example6() { const { data: books } = await fetchf('fetchBooks'); const { data: data1 } = await fetchf( @@ -207,7 +315,17 @@ async function example6() { 'https://example.com/api/custom-endpoint', ); - console.log('Example 6', books); + // Fetch with custom settings + const { data: books2 } = await fetchf('fetchBooks', { + // Native fetch() setting + cache: 'no-store', + // Extended fetch setting + cacheTime: 86000, + // @ts-expect-error Non-existent setting + something: true, + }); + + console.log('Example 6', books satisfies Books, books2 satisfies Books); console.log('Example 6', data1, data2); } diff --git a/package-lock.json b/package-lock.json index 08a1966..01c29a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,24 +8,21 @@ "name": "fetchff", "version": "2.9.0", "license": "UNLICENSED", - "dependencies": { - "@rollup/rollup-linux-x64-gnu": "*" - }, "devDependencies": { - "@size-limit/preset-small-lib": "11.1.4", - "@types/jest": "29.5.12", - "eslint": "9.10.0", + "@size-limit/preset-small-lib": "11.1.5", + "@types/jest": "29.5.13", + "eslint": "9.11.1", "eslint-config-prettier": "9.1.0", "eslint-plugin-prettier": "5.2.1", - "fetch-mock": "11.0.0", + "fetch-mock": "11.1.5", "jest": "29.7.0", "prettier": "3.3.3", - "size-limit": "11.1.4", - "ts-jest": "29.2.4", + "size-limit": "11.1.5", + "ts-jest": "29.2.5", "tslib": "2.7.0", - "tsup": "8.2.4", + "tsup": "8.3.0", "typescript": "5.6.2", - "typescript-eslint": "8.5.0" + "typescript-eslint": "8.8.0" }, "engines": { "node": ">=18" @@ -744,6 +741,16 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz", + "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", @@ -769,9 +776,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.10.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.10.0.tgz", - "integrity": "sha512-fuXtbiP5GWIn8Fz+LWoOMVf/Jxm+aajZYkhi6CuEm4SxymFM+eUWzbO9qXT+L0iCkL5+KGYMCSGxo686H19S1g==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.11.1.tgz", + "integrity": "sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA==", "dev": true, "license": "MIT", "engines": { @@ -789,9 +796,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.1.0.tgz", - "integrity": "sha512-autAXT203ixhqei9xt+qkYOvY8l6LAFIdT2UXc/RPNeUVfqRF1BV94GTJyVPFKT8nFM6MyVJhjLj9E8JWvf5zQ==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1457,9 +1464,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.20.0.tgz", - "integrity": "sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -1471,9 +1478,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.20.0.tgz", - "integrity": "sha512-u00Ro/nok7oGzVuh/FMYfNoGqxU5CPWz1mxV85S2w9LxHR8OoMQBuSk+3BKVIDYgkpeOET5yXkx90OYFc+ytpQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -1485,9 +1492,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.20.0.tgz", - "integrity": "sha512-uFVfvzvsdGtlSLuL0ZlvPJvl6ZmrH4CBwLGEFPe7hUmf7htGAN+aXo43R/V6LATyxlKVC/m6UsLb7jbG+LG39Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -1499,9 +1506,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.20.0.tgz", - "integrity": "sha512-xbrMDdlev53vNXexEa6l0LffojxhqDTBeL+VUxuuIXys4x6xyvbKq5XqTXBCEUA8ty8iEJblHvFaWRJTk/icAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -1513,9 +1520,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.20.0.tgz", - "integrity": "sha512-jMYvxZwGmoHFBTbr12Xc6wOdc2xA5tF5F2q6t7Rcfab68TT0n+r7dgawD4qhPEvasDsVpQi+MgDzj2faOLsZjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -1527,9 +1534,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.20.0.tgz", - "integrity": "sha512-1asSTl4HKuIHIB1GcdFHNNZhxAYEdqML/MW4QmPS4G0ivbEcBr1JKlFLKsIRqjSwOBkdItn3/ZDlyvZ/N6KPlw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -1541,9 +1548,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.20.0.tgz", - "integrity": "sha512-COBb8Bkx56KldOYJfMf6wKeYJrtJ9vEgBRAOkfw6Ens0tnmzPqvlpjZiLgkhg6cA3DGzCmLmmd319pmHvKWWlQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -1555,9 +1562,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.20.0.tgz", - "integrity": "sha512-+it+mBSyMslVQa8wSPvBx53fYuZK/oLTu5RJoXogjk6x7Q7sz1GNRsXWjn6SwyJm8E/oMjNVwPhmNdIjwP135Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -1569,9 +1576,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.20.0.tgz", - "integrity": "sha512-yAMvqhPfGKsAxHN8I4+jE0CpLWD8cv4z7CK7BMmhjDuz606Q2tFKkWRY8bHR9JQXYcoLfopo5TTqzxgPUjUMfw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -1583,9 +1590,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.20.0.tgz", - "integrity": "sha512-qmuxFpfmi/2SUkAw95TtNq/w/I7Gpjurx609OOOV7U4vhvUhBcftcmXwl3rqAek+ADBwSjIC4IVNLiszoj3dPA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -1597,9 +1604,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.20.0.tgz", - "integrity": "sha512-I0BtGXddHSHjV1mqTNkgUZLnS3WtsqebAXv11D5BZE/gfw5KoyXSAXVqyJximQXNvNzUo4GKlCK/dIwXlz+jlg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -1623,10 +1630,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.20.0.tgz", - "integrity": "sha512-psegMvP+Ik/Bg7QRJbv8w8PAytPA7Uo8fpFjXyCRHWm6Nt42L+JtoqH8eDQ5hRP7/XW2UiIriy1Z46jf0Oa1kA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -1638,9 +1659,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.20.0.tgz", - "integrity": "sha512-GabekH3w4lgAJpVxkk7hUzUf2hICSQO0a/BLFA11/RMxQT92MabKAqyubzDZmMOC/hcJNlc+rrypzNzYl4Dx7A==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -1652,9 +1673,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.20.0.tgz", - "integrity": "sha512-aJ1EJSuTdGnM6qbVC4B5DSmozPTqIag9fSzXRNNo+humQLG89XpPgdt16Ia56ORD7s+H8Pmyx44uczDQ0yDzpg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -1672,19 +1693,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -1706,104 +1714,48 @@ } }, "node_modules/@size-limit/esbuild": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/@size-limit/esbuild/-/esbuild-11.1.4.tgz", - "integrity": "sha512-Nxh+Fw4Z7sFjRLeT7GDZIy297VXyJrMvG20UDSWP31QgglriEBDkW9U77T7W6js5FaEr89bYVrGzpHfmE1CLFw==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/@size-limit/esbuild/-/esbuild-11.1.5.tgz", + "integrity": "sha512-AywMXRGzJmgAXb8bPAHjK+zxPwuPmIazL2BKDT3zp//8Fb3B/8ld1D4yXMYro4QgJEp47W2KZAZdM5RGrc6Z/A==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", + "esbuild": "^0.23.1", "nanoid": "^5.0.7" }, "engines": { "node": "^18.0.0 || >=20.0.0" }, "peerDependencies": { - "size-limit": "11.1.4" - } - }, - "node_modules/@size-limit/esbuild/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@size-limit/esbuild/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "size-limit": "11.1.5" } }, "node_modules/@size-limit/file": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-11.1.4.tgz", - "integrity": "sha512-QxnGj9cxhCEuqMAV01gqonXIKcc+caZqFHZpV51oL2ZJNGSPP9Q/yyf+7HbVe00faOFd1dZZwMwzZmX7HQ9LbA==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-11.1.5.tgz", + "integrity": "sha512-oz/XBVUJh95GpzDb9/f4sEQD/ACJ9zEKSRgBtvMUTN0c+O/9uq+RzvFeXFN2Kjpx3Dmur1ta+oZsp3zQFxlb3Q==", "dev": true, "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" }, "peerDependencies": { - "size-limit": "11.1.4" + "size-limit": "11.1.5" } }, "node_modules/@size-limit/preset-small-lib": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/@size-limit/preset-small-lib/-/preset-small-lib-11.1.4.tgz", - "integrity": "sha512-wELW374esv+2Nlzf7g+qW4Af9L69duLoO9F52f0sGk/nzb6et7u8gLRvweWrBfm3itUrqHCpGSSVabTsIU8kNw==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/@size-limit/preset-small-lib/-/preset-small-lib-11.1.5.tgz", + "integrity": "sha512-++IMlbAQpCFQp8UN9XHrcZ3SHY+u/ZzxSUA8zIHXDjZJdkb9WIW12CJXwJADj8tMRgWHWC4ixbi1DdnHYJ3ZpA==", "dev": true, "license": "MIT", "dependencies": { - "@size-limit/esbuild": "11.1.4", - "@size-limit/file": "11.1.4", - "size-limit": "11.1.4" + "@size-limit/esbuild": "11.1.5", + "@size-limit/file": "11.1.5", + "size-limit": "11.1.5" }, "peerDependencies": { - "size-limit": "11.1.4" + "size-limit": "11.1.5" } }, "node_modules/@swc/core": { @@ -1979,6 +1931,20 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/glob-to-regexp": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz", + "integrity": "sha512-nDKoaKJYbnn1MZxUY0cA1bPmmgZbg0cTq7Rh13d0KWYNOiKbqoR+2d89SnRPszGh7ROzSwZ/GOjZ4jPbmmZ6Eg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -2017,9 +1983,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.12", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", - "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", + "version": "29.5.13", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz", + "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==", "dev": true, "license": "MIT", "dependencies": { @@ -2027,6 +1993,13 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.14.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", @@ -2062,17 +2035,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", + "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/type-utils": "8.8.0", + "@typescript-eslint/utils": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2096,16 +2069,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz", + "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4" }, "engines": { @@ -2125,14 +2098,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", + "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2143,14 +2116,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz", + "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -2168,9 +2141,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", + "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==", "dev": true, "license": "MIT", "engines": { @@ -2182,14 +2155,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz", + "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2250,16 +2223,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", + "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2273,13 +2246,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz", + "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2437,16 +2410,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", @@ -3064,29 +3027,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dir-glob/node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3211,21 +3151,24 @@ } }, "node_modules/eslint": { - "version": "9.10.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.10.0.tgz", - "integrity": "sha512-Y4D0IgtBZfOcOUAIQTSXBKoNGfY0REGqHJG6+Q81vNippW5YlKjHFj4soMxamKK1NXHUWuBZTLdU3Km+L/pcHw==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.1.tgz", + "integrity": "sha512-MobhYKIoAO1s1e4VUrgx1l1Sk2JBR/Gqjjgw8+mfgoLE2xwsHur4gdfTxyTgShrhvdVFTaJSgMiQBl1jv/AWxg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.6.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.10.0", - "@eslint/plugin-kit": "^0.1.0", + "@eslint/js": "9.11.1", + "@eslint/plugin-kit": "^0.2.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -3344,6 +3287,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", @@ -3577,15 +3527,15 @@ } }, "node_modules/fetch-mock": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-11.0.0.tgz", - "integrity": "sha512-AD2Gh1xDQBLBs4iJpSxar19cTOH/Gu9hf1ko2J4hHW1UbR+ZHOfmIAqfT+Wlku4U8cYbffjaTbGv7mqe5kPi3w==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-11.1.5.tgz", + "integrity": "sha512-KHmZDnZ1ry0pCTrX4YG5DtThHi0MH+GNI9caESnzX/nMJBrvppUHMvLx47M0WY9oAtKOMiPfZDRpxhlHg89BOA==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.1.1", + "@types/glob-to-regexp": "^0.4.4", "dequal": "^2.0.3", - "globrex": "^0.1.2", + "glob-to-regexp": "^0.4.1", "is-subset": "^0.1.1", "regexparam": "^3.0.0" }, @@ -3835,6 +3785,13 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -3848,47 +3805,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", - "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.2", - "ignore": "^5.2.4", - "path-type": "^5.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true, - "license": "MIT" - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -5479,23 +5395,10 @@ "node": "14 || >=16.14" } }, - "node_modules/path-type": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", - "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true, "license": "ISC" }, @@ -5875,6 +5778,56 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5947,19 +5900,19 @@ "license": "MIT" }, "node_modules/size-limit": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-11.1.4.tgz", - "integrity": "sha512-V2JAI/Z7h8sEuxU3V+Ig3XKA5FcYbI4CZ7sh6s7wvuy+TUwDZYqw7sAqrHhQ4cgcNfPKIAHAaH8VaqOdbcwJDA==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-11.1.5.tgz", + "integrity": "sha512-dtw/Tcm+9aonYySPG6wQCe1BwogK5HRGSrSqr0zXGfKtynJGvKAsyHCTGxdphFEHjHRoHFWua3D3zqYLUVVIig==", "dev": true, "license": "MIT", "dependencies": { "bytes-iec": "^3.1.1", "chokidar": "^3.6.0", - "globby": "^14.0.1", - "jiti": "^1.21.0", - "lilconfig": "^3.1.1", + "jiti": "^1.21.6", + "lilconfig": "^3.1.2", "nanospinner": "^1.1.0", - "picocolors": "^1.0.1" + "picocolors": "^1.1.0", + "tinyglobby": "^0.2.6" }, "bin": { "size-limit": "bin.js" @@ -6295,6 +6248,48 @@ "node": ">=0.8" } }, + "node_modules/tinyglobby": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.6.tgz", + "integrity": "sha512-NbBoFBpqfcgd1tCiO8Lkfdk+xrA7mlLR9zgvZcZWQQwU63XAfUePyd6wZBaU93Hqw347lHnwFzttAkemHzzz4g==", + "dev": true, + "license": "ISC", + "dependencies": { + "fdir": "^6.3.0", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.3.0.tgz", + "integrity": "sha512-QOnuT+BOtivR77wYvCWHfGt9s4Pz1VIMbD463vegT5MLqNXy8rYFT/lPVEqf/bhYeT6qmqrNHhsX+rWwe3rOCQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6366,21 +6361,21 @@ "license": "Apache-2.0" }, "node_modules/ts-jest": { - "version": "29.2.4", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.4.tgz", - "integrity": "sha512-3d6tgDyhCI29HlpwIq87sNuI+3Q6GLTTCeYRHCs7vDz+/3GCMwEtV9jezLyl4ZtnBgx00I7hm8PCP8cTksMGrw==", + "version": "29.2.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", + "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", "dev": true, "license": "MIT", "dependencies": { - "bs-logger": "0.x", + "bs-logger": "^0.2.6", "ejs": "^3.1.10", - "fast-json-stable-stringify": "2.x", + "fast-json-stable-stringify": "^2.1.0", "jest-util": "^29.0.0", "json5": "^2.2.3", - "lodash.memoize": "4.x", - "make-error": "1.x", - "semver": "^7.5.3", - "yargs-parser": "^21.0.1" + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.6.3", + "yargs-parser": "^21.1.1" }, "bin": { "ts-jest": "cli.js" @@ -6415,9 +6410,9 @@ } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "license": "ISC", "bin": { @@ -6481,9 +6476,9 @@ "license": "0BSD" }, "node_modules/tsup": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.2.4.tgz", - "integrity": "sha512-akpCPePnBnC/CXgRrcy72ZSntgIEUa1jN0oJbbvpALWKNOz1B7aM+UVDWGRGIO/T/PZugAESWDJUAb5FD48o8Q==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.3.0.tgz", + "integrity": "sha512-ALscEeyS03IomcuNdFdc0YWGVIkwH1Ws7nfTbAPuoILvEV2hpGQAY72LIOjglGo4ShWpZfpBqP/jpQVCzqYQag==", "dev": true, "license": "MIT", "dependencies": { @@ -6494,7 +6489,6 @@ "debug": "^4.3.5", "esbuild": "^0.23.0", "execa": "^5.1.1", - "globby": "^11.1.0", "joycon": "^3.1.1", "picocolors": "^1.0.1", "postcss-load-config": "^6.0.1", @@ -6502,6 +6496,7 @@ "rollup": "^4.19.0", "source-map": "0.8.0-beta.0", "sucrase": "^3.35.0", + "tinyglobby": "^0.2.1", "tree-kill": "^1.2.2" }, "bin": { @@ -6532,62 +6527,6 @@ } } }, - "node_modules/tsup/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.20.0.tgz", - "integrity": "sha512-y+eoL2I3iphUg9tN9GB6ku1FA8kOfmF4oUEWhztDJ4KXJy1agk/9+pejOuZkNFhRwHAOxMsBPLbXPd6mJiCwew==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/tsup/node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.20.0.tgz", - "integrity": "sha512-hM3nhW40kBNYUkZb/r9k2FKK+/MnKglX7UYd4ZUy5DJs8/sMsIbqWK2piZtVGE3kcXVNj3B2IrUYROJMMCikNg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/tsup/node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tsup/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tsup/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -6598,42 +6537,6 @@ "node": ">=8" } }, - "node_modules/tsup/node_modules/rollup": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.20.0.tgz", - "integrity": "sha512-6rbWBChcnSGzIlXeIdNIZTopKYad8ZG8ajhl78lGRLsI2rX8IkaotQhVas2Ma+GPxJav19wrSzvRvuiv0YKzWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.5" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.20.0", - "@rollup/rollup-android-arm64": "4.20.0", - "@rollup/rollup-darwin-arm64": "4.20.0", - "@rollup/rollup-darwin-x64": "4.20.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.20.0", - "@rollup/rollup-linux-arm-musleabihf": "4.20.0", - "@rollup/rollup-linux-arm64-gnu": "4.20.0", - "@rollup/rollup-linux-arm64-musl": "4.20.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.20.0", - "@rollup/rollup-linux-riscv64-gnu": "4.20.0", - "@rollup/rollup-linux-s390x-gnu": "4.20.0", - "@rollup/rollup-linux-x64-gnu": "4.20.0", - "@rollup/rollup-linux-x64-musl": "4.20.0", - "@rollup/rollup-win32-arm64-msvc": "4.20.0", - "@rollup/rollup-win32-ia32-msvc": "4.20.0", - "@rollup/rollup-win32-x64-msvc": "4.20.0", - "fsevents": "~2.3.2" - } - }, "node_modules/tsup/node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -6685,15 +6588,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.5.0.tgz", - "integrity": "sha512-uD+XxEoSIvqtm4KE97etm32Tn5MfaZWgWfMMREStLxR6JzvHkc2Tkj7zhTEK5XmtpTmKHNnG8Sot6qDfhHtR1Q==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.8.0.tgz", + "integrity": "sha512-BjIT/VwJ8+0rVO01ZQ2ZVnjE1svFBiRczcpr1t1Yxt7sT25VSbPfrJtDsQ8uQTy2pilX5nI9gwxhUyLULNentw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.5.0", - "@typescript-eslint/parser": "8.5.0", - "@typescript-eslint/utils": "8.5.0" + "@typescript-eslint/eslint-plugin": "8.8.0", + "@typescript-eslint/parser": "8.8.0", + "@typescript-eslint/utils": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6715,19 +6618,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", diff --git a/package.json b/package.json index 1dab8ec..d4d6e8e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { + "name": "fetchff", "version": "2.9.0", "license": "UNLICENSED", - "name": "fetchff", "author": "Matt Czapliński ", "repository": { "type": "git", @@ -11,24 +11,32 @@ "browser": "dist/browser/index.mjs", "module": "dist/browser/index.mjs", "types": "dist/index.d.ts", + "unpkg": "./dist/browser/index.mjs", "keywords": [ "fetch", "fetchff", "fetch-wrapper", + "fetch-client", "request", "cache", + "fetch-cache", + "fetch-retry", "api", "api-handler", + "http-request", + "http-client", "browser", - "node" + "node", + "nodejs" ], "engines": { "node": ">=18" }, + "sideEffects": false, "scripts": { "build": "npm run build:node && npm run build:browser && npm run build:cleanup", "build:browser": "tsup --format esm,iife --out-dir dist/browser --env.NODE_ENV production", - "build:node": "tsup --format cjs --out-dir dist/node --env.NODE_ENV production", + "build:node": "tsup --format cjs --out-dir dist/node --env.NODE_ENV production --target node18", "build:cleanup": "rm -f dist/browser/index.d.mts dist/node/index.d.ts && mv dist/browser/index.d.ts dist/index.d.ts", "type-check": "tsc --noEmit", "test": "jest --forceExit --coverage --detectOpenHandles", @@ -47,28 +55,32 @@ "size-limit": [ { "path": "dist/browser/index.mjs", - "limit": "10 KB" + "limit": "5 KB" }, { "path": "dist/browser/index.global.js", - "limit": "50 KB" + "limit": "5 KB" + }, + { + "path": "dist/node/index.js", + "limit": "5 KB" } ], "devDependencies": { - "@size-limit/preset-small-lib": "11.1.4", - "@types/jest": "29.5.12", - "eslint": "9.10.0", + "@size-limit/preset-small-lib": "11.1.5", + "@types/jest": "29.5.13", + "eslint": "9.11.1", "eslint-config-prettier": "9.1.0", "eslint-plugin-prettier": "5.2.1", - "fetch-mock": "11.0.0", + "fetch-mock": "11.1.5", "jest": "29.7.0", "prettier": "3.3.3", - "size-limit": "11.1.4", - "ts-jest": "29.2.4", + "size-limit": "11.1.5", + "ts-jest": "29.2.5", "tslib": "2.7.0", - "tsup": "8.2.4", + "tsup": "8.3.0", "typescript": "5.6.2", - "typescript-eslint": "8.5.0" + "typescript-eslint": "8.8.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.21.3" diff --git a/src/api-handler.ts b/src/api-handler.ts index eca0d9c..15f6374 100644 --- a/src/api-handler.ts +++ b/src/api-handler.ts @@ -1,14 +1,19 @@ import type { RequestConfig, FetchResponse, + DefaultResponse, CreatedCustomFetcherInstance, } from './types/request-handler'; import type { ApiHandlerConfig, + ApiHandlerDefaultMethods, ApiHandlerMethods, - ApiHandlerReturnType, - APIResponse, - QueryParamsOrBody, + DefaultPayload, + FallbackValue, + FinalParams, + FinalResponse, + QueryParams, + RequestConfigUrlRequired, UrlPathParams, } from './types/api-handler'; import { createRequestHandler } from './request-handler'; @@ -27,7 +32,7 @@ import { createRequestHandler } from './request-handler'; * @param {number} config.timeout - Request timeout * @param {number} config.dedupeTime - Time window, in milliseconds, during which identical requests are deduplicated (treated as single request). * @param {string} config.strategy - Error Handling Strategy - * @param {string} config.flattenResponse - Whether to flatten response "data" object within "data" one + * @param {string} config.flattenResponse - Whether to flatten response "data" object within "data". It works only if the response structure includes a single data property. * @param {*} config.defaultResponse - Default response when there is no data or when endpoint fails depending on the chosen strategy. It's "null" by default * @param {Object} [config.retry] - Options for retrying requests. * @param {number} [config.retry.retries=0] - Number of retry attempts. No retries by default. @@ -68,7 +73,7 @@ import { createRequestHandler } from './request-handler'; */ function createApiFetcher< EndpointsMethods extends object, - EndpointsCfg = never, + EndpointsSettings = never, >(config: ApiHandlerConfig) { const endpoints = config.endpoints; const requestHandler = createRequestHandler(config); @@ -98,55 +103,43 @@ function createApiFetcher< * Handle Single API Request * It considers settings in following order: per-request settings, global per-endpoint settings, global settings. * - * @param {string} endpointName - The name of the API endpoint to call. - * @param {QueryParamsOrBody} [data={}] - Query parameters to include in the request. - * @param {UrlPathParams} [urlPathParams={}] - URI parameters to include in the request. + * @param {keyof EndpointsMethods | string} endpointName - The name of the API endpoint to call. * @param {EndpointConfig} [requestConfig={}] - Additional configuration for the request. - * @returns {Promise} - A promise that resolves with the response from the API provider. + * @returns {Promise>} - A promise that resolves with the response from the API provider. */ - async function request( + async function request< + ResponseData = never, + QueryParams_ = never, + UrlParams = never, + RequestBody = never, + >( endpointName: keyof EndpointsMethods | string, - data: QueryParamsOrBody = {}, - urlPathParams: UrlPathParams = {}, - requestConfig: RequestConfig = {}, - ): Promise> { + requestConfig: RequestConfig< + FinalResponse, + FinalParams, + FinalParams, + FallbackValue + > = {}, + ): Promise>> { // Use global per-endpoint settings - const endpointConfig = endpoints[endpointName as string]; + const endpointConfig = + endpoints[endpointName] || + ({ url: endpointName as string } as RequestConfigUrlRequired); - const responseData = await requestHandler.request( - endpointConfig.url, - data, - { - ...(endpointConfig || {}), - ...requestConfig, - urlPathParams, - }, - ); + const responseData = await requestHandler.request< + FinalResponse, + FinalParams, + FinalParams, + FallbackValue + >(endpointConfig.url, { + ...endpointConfig, + ...requestConfig, + }); return responseData; } - /** - * Maps all API requests using native Proxy - * - * @param {*} prop Caller - */ - function get(prop: string) { - if (prop in apiHandler) { - return apiHandler[ - prop as unknown as keyof ApiHandlerMethods - ]; - } - - // Prevent handler from triggering non-existent endpoints - if (!endpoints[prop]) { - return handleNonImplemented.bind(null, prop); - } - - return apiHandler.request.bind(null, prop); - } - - const apiHandler: ApiHandlerMethods = { + const apiHandler: ApiHandlerDefaultMethods = { config, endpoints, requestHandler, @@ -154,9 +147,28 @@ function createApiFetcher< request, }; - return new Proxy(apiHandler, { - get: (_target, prop: string) => get(prop), - }) as ApiHandlerReturnType; + /** + * Maps all API requests using native Proxy + * + * @param {*} prop Caller + */ + return new Proxy>( + apiHandler as ApiHandlerMethods, + { + get(_target, prop: string) { + if (prop in apiHandler) { + return apiHandler[prop as unknown as keyof typeof apiHandler]; + } + + // Prevent handler from triggering non-existent endpoints + if (endpoints[prop]) { + return apiHandler.request.bind(null, prop); + } + + return handleNonImplemented.bind(null, prop); + }, + }, + ); } export { createApiFetcher }; diff --git a/src/cache-manager.ts b/src/cache-manager.ts index a6e0436..1337e42 100644 --- a/src/cache-manager.ts +++ b/src/cache-manager.ts @@ -3,7 +3,7 @@ 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 { GET, OBJECT, UNDEFINED } from './constants'; import { shallowSerialize, sortObject } from './utils'; const cache = new Map>(); diff --git a/src/const.ts b/src/constants.ts similarity index 73% rename from src/const.ts rename to src/constants.ts index 41913df..59c1853 100644 --- a/src/const.ts +++ b/src/constants.ts @@ -1,4 +1,6 @@ -export const APPLICATION_JSON = 'application/json'; +export const APPLICATION_CONTENT_TYPE = 'application/'; + +export const APPLICATION_JSON = APPLICATION_CONTENT_TYPE + 'json'; export const CONTENT_TYPE = 'Content-Type'; export const UNDEFINED = 'undefined'; diff --git a/src/index.ts b/src/index.ts index 5d495d8..edeba81 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,9 @@ import { createRequestHandler } from './request-handler'; -import type { APIResponse, FetchResponse, RequestHandlerConfig } from './types'; +import type { + DefaultResponse, + FetchResponse, + RequestHandlerConfig, +} from './types'; /** * Simple wrapper for request fetching. @@ -7,13 +11,13 @@ import type { APIResponse, FetchResponse, RequestHandlerConfig } from './types'; * * @param {string | URL | globalThis.Request} url - Request URL. * @param {RequestHandlerConfig} config - Configuration object for the request handler. - * @returns {Promise>} Response Data. + * @returns {Promise>} Response Data. */ -export async function fetchf( +export async function fetchf( url: string, config: RequestHandlerConfig = {}, -): Promise> { - return createRequestHandler(config).request(url, null, config); +): Promise> { + return createRequestHandler(config).request(url, config); } export * from './types'; diff --git a/src/queue-manager.ts b/src/queue-manager.ts index f7cf15e..892ac53 100644 --- a/src/queue-manager.ts +++ b/src/queue-manager.ts @@ -1,4 +1,4 @@ -import { ABORT_ERROR, TIMEOUT_ERROR } from './const'; +import { ABORT_ERROR, TIMEOUT_ERROR } from './constants'; import type { RequestConfig } from './types'; import type { QueueItem, RequestsQueue } from './types/queue-manager'; diff --git a/src/request-handler.ts b/src/request-handler.ts index aec4b27..b92da7a 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { + DefaultResponse, RequestHandlerConfig, RequestConfig, Method, @@ -11,10 +12,11 @@ import type { FetcherConfig, } from './types/request-handler'; import type { - APIResponse, BodyPayload, + DefaultParams, + DefaultPayload, + DefaultUrlParams, QueryParams, - QueryParamsOrBody, } from './types/api-handler'; import { applyInterceptor } from './interceptor-manager'; import { ResponseErr } from './response-error'; @@ -39,7 +41,7 @@ import { OBJECT, STRING, UNDEFINED, -} from './const'; +} from './constants'; import { parseResponseData } from './response-parser'; import { generateCacheKey, getCache, setCache } from './cache-manager'; @@ -134,13 +136,11 @@ export function createRequestHandler( * Build request configuration * * @param {string} url - Request url - * @param {QueryParamsOrBody} data - Query Params in case of GET and HEAD requests, body payload otherwise * @param {RequestConfig} reqConfig - Request config passed when making the request * @returns {RequestConfig} - Provider's instance */ const buildConfig = ( url: string, - data: QueryParamsOrBody, reqConfig: RequestConfig, ): FetcherConfig => { const method = getConfig( @@ -161,18 +161,12 @@ export function createRequestHandler( const explicitBodyData: BodyPayload = getConfig(reqConfig, 'body') || getConfig(reqConfig, 'data'); - // For convenience, in POST requests the body payload is the "data" - // In edge cases we want to use Query Params in the POST requests - // and use explicitly passed "body" or "data" from request config - const shouldTreatDataAsParams = - data && (isGetAlikeMethod || explicitBodyData) ? true : false; - // Final body data let body: RequestConfig['data']; // Only applicable for request methods 'PUT', 'POST', 'DELETE', and 'PATCH' if (!isGetAlikeMethod) { - body = explicitBodyData || (data as BodyPayload); + body = explicitBodyData; } // Native fetch compatible settings @@ -182,13 +176,9 @@ export function createRequestHandler( ? 'include' : getConfig(reqConfig, 'credentials'); - deleteProperty(reqConfig, 'data'); - deleteProperty(reqConfig, 'withCredentials'); - - const urlPath = - explicitParams || shouldTreatDataAsParams - ? appendQueryParams(dynamicUrl, explicitParams || (data as QueryParams)) - : dynamicUrl; + const urlPath = explicitParams + ? appendQueryParams(dynamicUrl, explicitParams) + : dynamicUrl; const isFullUrl = urlPath.includes('://'); const baseURL = isFullUrl ? '' @@ -218,13 +208,13 @@ export function createRequestHandler( /** * Process global Request Error * - * @param {ResponseError} error Error instance - * @param {RequestConfig} requestConfig Per endpoint request config + * @param {ResponseError} error Error instance + * @param {RequestConfig} requestConfig Per endpoint request config * @returns {Promise} */ - const processError = async ( - error: ResponseError, - requestConfig: RequestConfig, + const processError = async ( + error: ResponseError, + requestConfig: RequestConfig, ): Promise => { if (!isRequestCancelled(error)) { logger('API ERROR', error); @@ -240,15 +230,15 @@ export function createRequestHandler( /** * Output default response in case of an error, depending on chosen strategy * - * @param {ResponseError} error Error instance - * @param {FetchResponse} response Response - * @param {RequestConfig} requestConfig Per endpoint request config - * @returns {*} Error response + * @param {ResponseError} error - Error instance + * @param {FetchResponse | null} response - Response. It may be "null" in case of request being aborted. + * @param {RequestConfig} requestConfig - Per endpoint request config + * @returns {FetchResponse} Response together with the error object */ - const outputErrorResponse = async ( + const outputErrorResponse = async ( error: ResponseError, - response: FetchResponse | null, - requestConfig: RequestConfig, + response: FetchResponse | null, + requestConfig: RequestConfig, ): Promise => { const _isRequestCancelled = isRequestCancelled(error); const errorHandlingStrategy = getConfig(requestConfig, 'strategy'); @@ -269,7 +259,7 @@ export function createRequestHandler( } } - return outputResponse(response, requestConfig, error); + return outputResponse(response, requestConfig, error); }; /** @@ -286,16 +276,24 @@ export function createRequestHandler( * Handle Request depending on used strategy * * @param {string} url - Request url - * @param {QueryParamsOrBody} data - Query Params in case of GET and HEAD requests, body payload otherwise * @param {RequestConfig} reqConfig - Request config * @throws {ResponseError} - * @returns {Promise>} Response Data + * @returns {Promise>} Response Data */ - const request = async ( + const request = async < + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, + >( url: string, - data: QueryParamsOrBody = null, - reqConfig: RequestConfig | null = null, - ): Promise> => { + reqConfig: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + > | null = null, + ): Promise> => { const _reqConfig = reqConfig || {}; const mergedConfig = { ...handlerConfig, @@ -303,7 +301,7 @@ export function createRequestHandler( } as RequestConfig; let response: FetchResponse | null = null; - const fetcherConfig = buildConfig(url, data, mergedConfig); + const fetcherConfig = buildConfig(url, mergedConfig); const { timeout, @@ -328,9 +326,10 @@ export function createRequestHandler( const cacheBuster = mergedConfig.cacheBuster; if (!cacheBuster || !cacheBuster(fetcherConfig)) { - const cachedEntry = getCache< - ResponseData & FetchResponse - >(_cacheKey, cacheTime); + const cachedEntry = getCache>( + _cacheKey, + cacheTime, + ); if (cachedEntry) { // Serve stale data from cache @@ -352,8 +351,9 @@ export function createRequestHandler( let attempt = 0; let pollingAttempt = 0; let waitTime: number = delay; + const _retries = retries > 0 ? retries : 0; - while (attempt <= retries) { + while (attempt <= _retries) { try { // Add the request to the queue. Make sure to handle deduplication, cancellation, timeouts in accordance to retry settings const controller = await addRequest( @@ -362,7 +362,7 @@ export function createRequestHandler( dedupeTime, cancellable, // Reset timeouts by default or when retries are ON - !!(timeout && (!retries || resetTimeout)), + !!(timeout && (!_retries || resetTimeout)), ); // Shallow copy to ensure basic idempotency @@ -425,8 +425,7 @@ export function createRequestHandler( } // If polling is not required, or polling attempts are exhausted - const output = outputResponse(response, requestConfig) as ResponseData & - FetchResponse; + const output = outputResponse(response, requestConfig); if (cacheTime && _cacheKey) { const skipCache = requestConfig.skipCache; @@ -446,11 +445,15 @@ export function createRequestHandler( !(!shouldRetry || (await shouldRetry(error, attempt))) || !retryOn?.includes(status) ) { - await processError(error, fetcherConfig); + await processError(error, fetcherConfig); removeRequest(fetcherConfig); - return outputErrorResponse(error, response, fetcherConfig); + return outputErrorResponse( + error, + response, + fetcherConfig, + ); } logger(`Attempt ${attempt + 1} failed. Retry in ${waitTime}ms.`); @@ -463,38 +466,34 @@ export function createRequestHandler( } } - return outputResponse(response, fetcherConfig) as ResponseData & - FetchResponse; + return outputResponse(response, fetcherConfig); }; /** * Output response * - * @param response - Response payload + * @param Response. It may be "null" in case of request being aborted. * @param {RequestConfig} requestConfig - Request config * @param error - whether the response is erroneous - * @returns {ResponseData | FetchResponse} Response data + * @returns {FetchResponse} Response data */ - const outputResponse = ( + const outputResponse = ( response: FetchResponse | null, requestConfig: RequestConfig, error: ResponseError | null = null, - ): ResponseData | FetchResponse => { + ): FetchResponse => { const defaultResponse = getConfig(requestConfig, 'defaultResponse'); - const flattenResponse = getConfig( - requestConfig, - 'flattenResponse', - ); + // This may happen when request is cancelled. if (!response) { - return flattenResponse - ? defaultResponse - : { - error, - headers: null, - data: defaultResponse, - config: requestConfig, - }; + return { + ok: false, + // Enhance the response with extra information + error, + data: defaultResponse, + headers: null, + config: requestConfig, + } as unknown as FetchResponse; } // Clean up the error object @@ -514,8 +513,13 @@ export function createRequestHandler( } // Return flattened response immediately + const flattenResponse = getConfig( + requestConfig, + 'flattenResponse', + ); + if (flattenResponse) { - return flattenData(data); + response.data = flattenData(data); } // If it's a custom fetcher, and it does not return any Response instance, it may have its own internal handler @@ -541,7 +545,7 @@ export function createRequestHandler( clone: response.clone.bind(response), arrayBuffer: response.arrayBuffer.bind(response), - // Extend with extra information + // Enhance the response with extra information error, data, headers: processHeaders(response.headers), diff --git a/src/response-parser.ts b/src/response-parser.ts index 4d7694d..23e7638 100644 --- a/src/response-parser.ts +++ b/src/response-parser.ts @@ -1,7 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { APPLICATION_JSON, CONTENT_TYPE } from './const'; -import type { APIResponse } from './types/api-handler'; -import type { FetchResponse } from './types/request-handler'; +import { + APPLICATION_CONTENT_TYPE, + APPLICATION_JSON, + CONTENT_TYPE, +} from './constants'; +import type { DefaultResponse, FetchResponse } from './types/request-handler'; /** * Parses the response data based on the Content-Type header. @@ -9,7 +12,7 @@ import type { FetchResponse } from './types/request-handler'; * @param response - The Response object to parse. * @returns A Promise that resolves to the parsed data. */ -export async function parseResponseData( +export async function parseResponseData( response: FetchResponse, ): Promise { // Bail early when body is empty @@ -31,9 +34,13 @@ export async function parseResponseData( data = await response.json(); // Parse JSON response } else if (contentType.includes('multipart/form-data')) { data = await response.formData(); // Parse as FormData - } else if (contentType.includes('application/octet-stream')) { + } else if ( + contentType.includes(APPLICATION_CONTENT_TYPE + 'octet-stream') + ) { data = await response.blob(); // Parse as blob - } else if (contentType.includes('application/x-www-form-urlencoded')) { + } else if ( + contentType.includes(APPLICATION_CONTENT_TYPE + 'x-www-form-urlencoded') + ) { data = await response.formData(); // Handle URL-encoded forms } else if (contentType.includes('text/')) { data = await response.text(); // Parse as text diff --git a/src/tsconfig.json b/src/tsconfig.json index 6a797dd..145f639 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -21,7 +21,7 @@ "noImplicitReturns": true, "noImplicitUseStrict": false, "noFallthroughCasesInSwitch": true, - "lib": ["esnext", "es2017", "dom"] + "lib": ["esnext", "ES2018", "dom"] }, "exclude": ["demo", "scripts", "node_modules"] } diff --git a/src/types/api-handler.ts b/src/types/api-handler.ts index b0cc29b..d786b6f 100644 --- a/src/types/api-handler.ts +++ b/src/types/api-handler.ts @@ -5,108 +5,212 @@ import type { FetchResponse, RequestHandlerReturnType, CreatedCustomFetcherInstance, + DefaultResponse, + ExtendedRequestConfig, } from './request-handler'; // Common type definitions -type NameValuePair = { name: string; value: string }; +interface NameValuePair { + name: string; + value: string; +} declare const emptyObjectSymbol: unique symbol; export type EmptyObject = { [emptyObjectSymbol]?: never }; -export declare type QueryParams = - | (Record & EmptyObject) +export type DefaultParams = Record; +export type DefaultUrlParams = Record; +export type DefaultPayload = Record; + +export declare type QueryParams = + | (ParamsType & EmptyObject) | URLSearchParams | NameValuePair[] | null; -export declare type BodyPayload = +export declare type UrlPathParams = + | ([UrlParamsType] extends [DefaultUrlParams] + ? UrlParamsType & EmptyObject + : UrlParamsType) + | EmptyObject + | null; + +export declare type BodyPayload = | BodyInit - | (Record & EmptyObject) - | T[] - | string + | (PayloadType & EmptyObject) + | PayloadType[] | null; -export declare type QueryParamsOrBody = - | QueryParams - | BodyPayload; +// Helper types declared outside the interface +export type FallbackValue = [T] extends [never] ? U : D; -export declare type UrlPathParams = - | (Record & EmptyObject) - | null; +export type FinalResponse = FallbackValue< + Response, + ResponseData +>; -export declare type APIResponse = unknown; +export type FinalParams = [ + ParamsType, +] extends [never] + ? DefaultParams + : [Response] extends [never] + ? DefaultParams + : ParamsType | EmptyObject; + +interface EndpointFunction< + ResponseData, + QueryParams_, + PathParams, + RequestBody_, +> { + ( + requestConfig?: ExtendedRequestConfig< + FallbackValue, + FinalParams, + FinalParams, + FallbackValue + >, + ): Promise>>; +} -// Endpoint function type +export interface RequestEndpointFunction { + < + ResponseData = never, + QueryParams_ = never, + UrlParams = never, + RequestBody = never, + >( + endpointName: keyof EndpointsMethods | string, + requestConfig?: RequestConfig< + FinalResponse, + FinalParams, + FinalParams, + FallbackValue + >, + ): Promise>>; +} + +/** + * Represents an API endpoint handler with support for customizable query parameters, URL path parameters, + * and request configuration. + * + * The overloads allow customization of the returned data type (`ReturnedData`), query parameters (`T`), + * and URL path parameters (`T2`). + * + * @template ResponseData - The type of the response data (default: `DefaultResponse`). + * @template QueryParams - The type of the query parameters (default: `QueryParams`). + * @template PathParams - The type of the URL path parameters (default: `UrlPathParams`). + * @template RequestBody - The type of the Requesty Body (default: `BodyPayload`). + * + * @example + * interface EndpointsMethods { + * getUser: Endpoint; + * getPosts: Endpoint; + * } + */ export declare type Endpoint< - ResponseData = APIResponse, - QueryParams = QueryParamsOrBody, + ResponseData = DefaultResponse, + QueryParams_ = QueryParams, PathParams = UrlPathParams, -> = - | { - ( - queryParams?: QueryParams, - urlPathParams?: PathParams, - requestConfig?: RequestConfig, - ): Promise>; - } - | { - ( - queryParams?: T, - urlPathParams?: T2, - requestConfig?: RequestConfig, - ): Promise>; - }; - -type EndpointDefaults = Endpoint; + RequestBody = BodyPayload, +> = EndpointFunction; + +// Setting 'unknown here lets us infer typings for non-predefined endpoints with dynamically set generic response data +type EndpointDefaults = Endpoint; + +type AFunction = (...args: any[]) => any; +/** + * Maps the method names from `EndpointsMethods` to their corresponding `Endpoint` type definitions. + * + * @template EndpointsMethods - The object containing endpoint method definitions. + */ type EndpointsRecord = { - [K in keyof EndpointsMethods]: EndpointsMethods[K] extends Endpoint< - infer ResponseData, - infer QueryParams, - infer UrlPathParams - > - ? Endpoint - : Endpoint; + [K in keyof EndpointsMethods]: EndpointsMethods[K] extends AFunction + ? EndpointsMethods[K] // Map function signatures directly + : EndpointsMethods[K] extends Endpoint< + infer ResponseData, + infer QueryParams, + infer UrlPathParams + > + ? Endpoint // Method is an Endpoint type + : EndpointDefaults; // Fallback to default Endpoint type }; -type DefaultEndpoints = { - [K in keyof EndpointsCfg]: EndpointDefaults; +/** + * Defines default endpoints based on the provided `EndpointsSettings`. + * + * This type provides default implementations for endpoints in `EndpointsSettings`, using `EndpointDefaults`. + * + * @template EndpointsSettings - The configuration object for endpoints. + */ +type DefaultEndpoints = { + [K in keyof EndpointsSettings]: EndpointDefaults; }; -type RequestConfigUrlRequired = Omit & { url: string }; +export type RequestConfigUrlRequired = Omit & { + url: string; +}; +/** + * Configuration for API endpoints, where each key is an endpoint name or string, and the value is the request configuration. + * + * @template EndpointsMethods - The object containing endpoint method definitions. + */ export type EndpointsConfig = Record< keyof EndpointsMethods | string, RequestConfigUrlRequired >; -type EndpointsConfigPart = [ - EndpointsCfg, +/** + * Part of the endpoints configuration, derived from `EndpointsSettings` based on the `EndpointsMethods`. + * + * This type handles defaulting to endpoints configuration when particular Endpoints Methods are not provided. + * + * @template EndpointsSettings - The configuration object for endpoints. + * @template EndpointsMethods - The object containing endpoint method definitions. + */ +type EndpointsConfigPart = [ + EndpointsSettings, ] extends [never] ? unknown - : DefaultEndpoints>; - -export type ApiHandlerReturnType< + : DefaultEndpoints>; + +/** + * Provides the methods available from the API handler, combining endpoint record types, endpoints configuration, + * and default methods. + * + * @template EndpointsMethods - The object containing endpoint method definitions. + * @template EndpointsSettings - The configuration object for endpoints. + */ +export type ApiHandlerMethods< EndpointsMethods extends object, - EndpointsCfg, -> = EndpointsRecord & - EndpointsConfigPart & - ApiHandlerMethods; - -export type ApiHandlerMethods = { + EndpointsSettings, +> = EndpointsRecord & // Provided interface + EndpointsConfigPart & // Derived defaults from 'endpoints' + ApiHandlerDefaultMethods; // Returned API Handler methods + +/** + * Defines the default methods available within the API handler. + * + * This includes configuration, endpoint settings, request handler, instance retrieval, and a generic request method. + * + * @template EndpointsMethods - The object containing endpoint method definitions. + */ +export type ApiHandlerDefaultMethods = { config: ApiHandlerConfig; endpoints: EndpointsConfig; requestHandler: RequestHandlerReturnType; getInstance: () => CreatedCustomFetcherInstance | null; - request: ( - endpointName: keyof EndpointsMethods | string, - queryParams?: QueryParams, - urlPathParams?: UrlPathParams, - requestConfig?: RequestConfig, - ) => Promise>; + request: RequestEndpointFunction; }; +/** + * Configuration for the API handler, including API URL and endpoints. + * + * @template EndpointsMethods - The object containing endpoint method definitions. + */ export interface ApiHandlerConfig extends RequestHandlerConfig { apiUrl: string; diff --git a/src/types/interceptor-manager.ts b/src/types/interceptor-manager.ts index 5f04821..2819350 100644 --- a/src/types/interceptor-manager.ts +++ b/src/types/interceptor-manager.ts @@ -5,16 +5,22 @@ import type { ResponseError, } from './request-handler'; -export type RequestInterceptor = ( - config: RequestHandlerConfig, +export type RequestInterceptor = ( + config: RequestHandlerConfig, ) => - | RequestHandlerConfig + | RequestHandlerConfig | void - | Promise> + | Promise> | Promise; -export type ResponseInterceptor = ( - response: FetchResponse, -) => FetchResponse | void | Promise> | Promise; +export type ResponseInterceptor = ( + response: FetchResponse, +) => + | FetchResponse + | void + | Promise> + | Promise; -export type ErrorInterceptor = (error: ResponseError) => unknown; +export type ErrorInterceptor = ( + error: ResponseError, +) => unknown; diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index 3af0e57..51bbf50 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { BodyPayload, + DefaultParams, + DefaultPayload, + DefaultUrlParams, QueryParams, - QueryParamsOrBody, UrlPathParams, } from './api-handler'; import type { @@ -33,18 +35,30 @@ export type Method = | 'unlink' | 'UNLINK'; +export type DefaultResponse = any; + export type NativeFetch = typeof fetch; export interface FetcherInstance { create: ( - config?: BaseRequestHandlerConfig, + config?: RequestHandlerConfig, ) => RequestInstance; } export interface CreatedCustomFetcherInstance { - request( - requestConfig: RequestConfig, - ): FetchResponse | PromiseLike>; + request< + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, + >( + requestConfig: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + >, + ): PromiseLike>; } export type ErrorHandlingStrategy = @@ -57,44 +71,57 @@ export interface HeadersObject { [key: string]: string; } -export interface ExtendedResponse extends Omit { - data: D extends unknown ? any : D; - error: ResponseError | null; +export interface ExtendedResponse + extends Omit { + data: ResponseData extends [unknown] ? any : ResponseData; + error: ResponseError | null; headers: HeadersObject & HeadersInit; - config: ExtendedRequestConfig; + config: ExtendedRequestConfig; } -export type FetchResponse = ExtendedResponse; - -export interface ResponseError extends Error { - config: ExtendedRequestConfig; +/** + * Represents the response from a `fetchf()` request. + * + * @template ResponseData - The type of the data returned in the response. + */ +export type FetchResponse< + ResponseData = any, + RequestBody = any, +> = ExtendedResponse; + +export interface ResponseError + extends Error { + config: ExtendedRequestConfig; code?: string; status?: number; statusText?: string; - request?: ExtendedRequestConfig; - response?: FetchResponse; + request?: ExtendedRequestConfig; + response?: FetchResponse; } -export type RetryFunction = ( - error: ResponseError, +export type RetryFunction = ( + error: ResponseError, attempts: number, ) => Promise; -export type PollingFunction = ( - response: FetchResponse, +export type PollingFunction = ( + response: FetchResponse, attempts: number, - error?: ResponseError, + error?: ResponseError, ) => boolean; export type CacheKeyFunction = (config: FetcherConfig) => string; export type CacheBusterFunction = (config: FetcherConfig) => boolean; -export type CacheSkipFunction = ( +export type CacheSkipFunction = ( data: ResponseData, - config: RequestConfig, + config: RequestConfig, ) => boolean; +/** + * Configuration object for retry related options + */ export interface RetryOptions { /** * Maximum number of retry attempts. @@ -188,13 +215,17 @@ export interface CacheOptions { } /** - * ExtendedRequestConfig + * ExtendedRequestConfig * * 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, +export interface ExtendedRequestConfig< + ResponseData = any, + QueryParams_ = any, + PathParams = any, + RequestBody = any, +> extends Omit, CacheOptions { /** * Custom error handling strategy for the request. @@ -231,7 +262,7 @@ interface ExtendedRequestConfig * An object representing dynamic URL path parameters. * For example, `{ userId: 1 }` would replace `:userId` in the URL with `1`. */ - urlPathParams?: UrlPathParams; + urlPathParams?: UrlPathParams; /** * Configuration options for retrying failed requests. @@ -267,7 +298,7 @@ interface ExtendedRequestConfig /** * Query parameters to include in the request URL. */ - params?: QueryParams; + params?: QueryParams; /** * Indicates whether credentials (such as cookies) should be included with the request. @@ -283,27 +314,33 @@ interface ExtendedRequestConfig * Data to be sent as the request body, extending the native Fetch API's `body` option. * Supports `BodyInit`, objects, arrays, and strings, with automatic serialization. */ - body?: BodyPayload; + body?: BodyPayload; /** * Alias for "body" */ - data?: BodyPayload; + data?: BodyPayload; /** * A function or array of functions to intercept the request before it is sent. */ - onRequest?: RequestInterceptor | RequestInterceptor[]; + onRequest?: + | RequestInterceptor + | RequestInterceptor[]; /** * A function or array of functions to intercept the response before it is resolved. */ - onResponse?: ResponseInterceptor | ResponseInterceptor[]; + onResponse?: + | ResponseInterceptor + | ResponseInterceptor[]; /** * A function to handle errors that occur during the request or response processing. */ - onError?: ErrorInterceptor | ErrorInterceptor[]; + onError?: + | ErrorInterceptor + | ErrorInterceptor[]; /** * The maximum time (in milliseconds) the request can take before automatically being aborted. @@ -328,36 +365,50 @@ interface ExtendedRequestConfig * @param response - The response data. * @returns `true` to stop polling, `false` to continue. */ - shouldStopPolling?: PollingFunction; + shouldStopPolling?: PollingFunction; } -interface BaseRequestHandlerConfig - extends RequestConfig { +export interface RequestHandlerConfig + extends RequestConfig { fetcher?: FetcherInstance | null; logger?: any; } -export type RequestConfig = - ExtendedRequestConfig; - -export type FetcherConfig = Omit & { +export type RequestConfig< + ResponseData = any, + QueryParams = any, + PathParams = any, + RequestBody = any, +> = ExtendedRequestConfig; + +export type FetcherConfig< + ResponseData = any, + QueryParams = any, + PathParams = any, + RequestBody = any, +> = Omit< + ExtendedRequestConfig, + 'url' +> & { url: string; }; -export type RequestHandlerConfig = - BaseRequestHandlerConfig; - export interface RequestHandlerReturnType { config: RequestHandlerConfig; getInstance: () => CreatedCustomFetcherInstance | null; - buildConfig: ( - url: string, - data: QueryParamsOrBody, - config: RequestConfig, - ) => RequestConfig; - request: ( + buildConfig: (url: string, config: RequestConfig) => RequestConfig; + request: < + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, + >( url: string, - data?: QueryParamsOrBody, - config?: RequestConfig | null, - ) => Promise>; + config?: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + > | null, + ) => Promise>; } diff --git a/src/utils.ts b/src/utils.ts index 11d21b0..39e0147 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { OBJECT, STRING, UNDEFINED } from './const'; -import type { HeadersObject, QueryParams, UrlPathParams } from './types'; +import { OBJECT, STRING, UNDEFINED } from './constants'; +import type { + DefaultUrlParams, + HeadersObject, + QueryParams, + UrlPathParams, +} from './types'; export function isSearchParams(data: unknown): boolean { return data instanceof URLSearchParams; @@ -161,7 +166,11 @@ export function replaceUrlPathParams( return url.replace(/:\w+/g, (str): string => { const word = str.substring(1); - return String(urlPathParams[word] ? urlPathParams[word] : str); + if ((urlPathParams as DefaultUrlParams)[word]) { + return String((urlPathParams as DefaultUrlParams)[word]); + } + + return str; }); } diff --git a/test/api-handler.spec.ts b/test/api-handler.spec.ts index 23de74c..aa2af43 100644 --- a/test/api-handler.spec.ts +++ b/test/api-handler.spec.ts @@ -35,11 +35,11 @@ describe('API Handler', () => { jest.spyOn(api, 'request').mockResolvedValueOnce(userDataMock as any); - const response = await api.getUser({ userId: 1 }); + const response = await api.getUser({ params: { userId: 1 } }); expect(api.request).toHaveBeenCalledTimes(1); expect(api.request).toHaveBeenCalledWith('getUser', { - userId: 1, + params: { userId: 1 }, }); expect(response).toBe(userDataMock); }); @@ -70,12 +70,11 @@ describe('API Handler', () => { .spyOn(api.requestHandler, 'request') .mockResolvedValueOnce(userDataMock as any); - const response = await api.getUserByIdAndName(null, urlPathParams); + const response = await api.getUserByIdAndName({ urlPathParams }); expect(api.requestHandler.request).toHaveBeenCalledTimes(1); expect(api.requestHandler.request).toHaveBeenCalledWith( '/user-details/:id/:name', - null, { url: '/user-details/:id/:name', urlPathParams }, ); expect(response).toBe(userDataMock); @@ -96,14 +95,14 @@ describe('API Handler', () => { .spyOn(api.requestHandler, 'request') .mockResolvedValueOnce(userDataMock as any); - const response = await api.getUserByIdAndName(null, urlPathParams, { + const response = await api.getUserByIdAndName({ + urlPathParams, headers, }); expect(api.requestHandler.request).toHaveBeenCalledTimes(1); expect(api.requestHandler.request).toHaveBeenCalledWith( '/user-details/:id/:name', - null, { url: '/user-details/:id/:name', headers, urlPathParams }, ); expect(response).toBe(userDataMock); diff --git a/test/cache-manager.spec.ts b/test/cache-manager.spec.ts index efdb41f..083620e 100644 --- a/test/cache-manager.spec.ts +++ b/test/cache-manager.spec.ts @@ -115,7 +115,6 @@ describe('Cache Manager', () => { const key = generateCacheKey({ url, method: 'POST', - // @ts-expect-error Number body: 10, }); expect(key).toContain('1061505'); diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index 475c376..ab55b18 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -8,7 +8,8 @@ import type { RequestHandlerReturnType, } from '../src/types/request-handler'; import { fetchf } from '../src'; -import { ABORT_ERROR } from '../src/const'; +import { ABORT_ERROR } from '../src/constants'; +import { ResponseErr } from '../src/response-error'; jest.mock('../src/utils', () => { const originalModule = jest.requireActual('../src/utils'); @@ -30,6 +31,15 @@ describe('Request Handler', () => { test: 'data', }, }; + const nestedDataMock = { + data: { + data: { + data: { + test: 'data', + }, + }, + }, + }; console.warn = jest.fn(); @@ -62,9 +72,10 @@ describe('Request Handler', () => { }); const buildConfig = (method: string, url: string, data: any, config: any) => - (requestHandler as any).buildConfig(url, data, { + (requestHandler as any).buildConfig(url, { ...config, method, + data, }); it('should not differ when the same request is made', () => { @@ -89,13 +100,14 @@ describe('Request Handler', () => { const result = buildConfig( 'GET', 'https://example.com/api', - { foo: 'bar' }, + {}, { headers, + params: { foo: 'bar' }, }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api?foo=bar', method: 'GET', headers, @@ -112,7 +124,7 @@ describe('Request Handler', () => { }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api', method: 'POST', headers, @@ -130,7 +142,7 @@ describe('Request Handler', () => { }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api', method: 'PUT', headers, @@ -148,7 +160,7 @@ describe('Request Handler', () => { }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api', method: 'DELETE', headers, @@ -160,14 +172,14 @@ describe('Request Handler', () => { const result = buildConfig( 'POST', 'https://example.com/api', - { foo: 'bar' }, + { additional: 'info' }, { headers: { 'X-CustomHeader': 'Some token' }, - data: { additional: 'info' }, + params: { foo: 'bar' }, }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api?foo=bar', method: 'POST', headers: { @@ -180,7 +192,7 @@ describe('Request Handler', () => { it('should handle empty data and config', () => { const result = buildConfig('POST', 'https://example.com/api', null, {}); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api', method: 'POST', body: null, @@ -195,7 +207,7 @@ describe('Request Handler', () => { {}, ); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api', method: 'POST', body: 'rawData', @@ -206,11 +218,13 @@ describe('Request Handler', () => { const result = buildConfig( 'head', 'https://example.com/api', - { foo: [1, 2] }, {}, + { + params: { foo: [1, 2] }, + }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api?foo[]=1&foo[]=2', method: 'HEAD', }); @@ -220,11 +234,13 @@ describe('Request Handler', () => { const result = buildConfig( 'POST', 'https://example.com/api', - { foo: 'bar' }, - { data: { additional: 'info' } }, + { additional: 'info' }, + { + params: { foo: 'bar' }, + }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api?foo=bar', method: 'POST', body: JSON.stringify({ additional: 'info' }), @@ -236,7 +252,7 @@ describe('Request Handler', () => { withCredentials: true, }); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api', method: 'POST', credentials: 'include', @@ -254,7 +270,7 @@ describe('Request Handler', () => { {}, ); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api', method: 'POST', body: JSON.stringify({ foo: 'bar' }), @@ -266,12 +282,17 @@ describe('Request Handler', () => { 'GET', 'https://example.com/api', { foo: 'bar' }, - { body: { additional: 'info' }, data: { additional: 'info' } }, + { + body: { additional: 'info' }, + data: { additional: 'info' }, + params: { foo: 'bar' }, + }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ url: 'https://example.com/api?foo=bar', method: 'GET', + params: { foo: 'bar' }, }); }); }); @@ -344,7 +365,7 @@ describe('Request Handler', () => { .mockRejectedValue(new Error('Request Failed')); try { - await requestHandler.request(apiUrl, null, { + await requestHandler.request(apiUrl, { strategy: 'reject', }); } catch (error) { @@ -802,7 +823,6 @@ describe('Request Handler', () => { cancellable: true, rejectCancelled: true, strategy: 'reject', - flattenResponse: true, defaultResponse: null, onError: () => {}, }); @@ -823,10 +843,9 @@ describe('Request Handler', () => { }); const url = '/test-endpoint'; - const data = { key: 'value' }; - const config = {}; + const params = { key: 'value' }; - await requestHandler.request(url, data, config); + await requestHandler.request(url, { params }); expect(spy).toHaveBeenCalledTimes(4); }); @@ -838,14 +857,14 @@ describe('Request Handler', () => { }); const url = '/test-endpoint'; - const data = { key: 'value' }; + const params = { key: 'value' }; const config = { onRequest(config) { config.headers = { 'Modified-Header': 'ModifiedValue' }; }, } as RequestConfig; - await requestHandler.request(url, data, config); + await requestHandler.request(url, { ...config, params }); expect(spy).toHaveBeenCalledTimes(4); expect(fetchMock.lastOptions()).toMatchObject({ @@ -854,25 +873,31 @@ describe('Request Handler', () => { }); it('should handle modified response in applyInterceptor', async () => { + const modifiedUrl = 'https://api.example.com/test-endpoint?key=value'; + fetchMock.mock( - 'https://api.example.com/test-endpoint?key=value', + modifiedUrl, new Response(JSON.stringify({ username: 'original response' }), { status: 200, }), ); const url = '/test-endpoint'; - const data = { key: 'value' }; - const config: RequestConfig = { + const params = { key: 'value' }; + const requestConfig: RequestConfig = { async onResponse(response) { response.data = { username: 'modified response' }; }, }; - const response = await requestHandler.request(url, data, config); + const { data, config } = await requestHandler.request(url, { + ...requestConfig, + params, + }); expect(spy).toHaveBeenCalledTimes(4); - expect(response).toMatchObject({ username: 'modified response' }); + expect(data).toMatchObject({ username: 'modified response' }); + expect(config.url).toContain(modifiedUrl); }); it('should handle request failure with interceptors', async () => { @@ -882,10 +907,12 @@ describe('Request Handler', () => { }); const url = '/test-endpoint'; - const data = { key: 'value' }; + const params = { key: 'value' }; const config = {}; - await expect(requestHandler.request(url, data, config)).rejects.toThrow( + await expect( + requestHandler.request(url, { ...config, params }), + ).rejects.toThrow( 'https://api.example.com/test-endpoint?key=value failed! Status: 500', ); @@ -900,10 +927,12 @@ describe('Request Handler', () => { }); const url = '/test-endpoint'; - const data = { key: 'value' }; + const params = { key: 'value' }; const config = {}; - await expect(requestHandler.request(url, data, config)).rejects.toThrow( + await expect( + requestHandler.request(url, { ...config, params }), + ).rejects.toThrow( 'https://api.example.com/test-endpoint?key=value failed! Status: 404', ); @@ -977,7 +1006,7 @@ describe('Request Handler', () => { .mockRejectedValue(new Error('Request Failed')); try { - await requestHandler.request(apiUrl, null, { + await requestHandler.request(apiUrl, { strategy: 'reject', }); } catch (error) { @@ -991,7 +1020,69 @@ describe('Request Handler', () => { globalThis.fetch = jest.fn(); }); - it('should cancel previous request when successive request is made', async () => { + it('should cancel previous request when fetchf() is used', async () => { + const url = 'https://example.com/api/post/send'; + + // Reset fetchMock before each test + fetchMock.reset(); + + let requestCounter = 0; + + // Mock the endpoint with a conditional response + fetchMock.mock( + url, + () => { + // Increment the counter for each request + requestCounter++; + + if (requestCounter === 1) { + // Simulate successful response for the first request + return { + status: 200, + body: { message: 'This response is mocked once' }, + }; + } else { + // Simulate aborted request for subsequent requests + return Promise.reject( + new DOMException('The operation was aborted.', 'AbortError'), + ); + } + }, + { overwriteRoutes: true }, + ); + + // Create an API fetcher with cancellable requests enabled + const sendPost = () => + fetchf(url, { + cancellable: true, + rejectCancelled: true, + }); + + async function sendData() { + const firstRequest = sendPost(); + const secondRequest = sendPost(); + + try { + const secondResponse = await secondRequest; + expect(secondResponse).toMatchObject({ + message: 'This response is mocked once', + }); + + await expect(firstRequest).rejects.toThrow( + 'The operation was aborted.', + ); + } catch (error) { + const err = error as ResponseErr; + + expect(err.message).toBe('The operation was aborted.'); + } + } + + // Execute the sendData function and await its completion + await sendData(); + }); + + it('should cancel previous request and pass a different successive request', async () => { fetchMock.reset(); const requestHandler = createRequestHandler({ @@ -1018,12 +1109,47 @@ describe('Request Handler', () => { ); expect(secondRequest).resolves.toMatchObject({ - username: 'response from second request', + data: { username: 'response from second request' }, }); expect(firstRequest).rejects.toThrow('The operation was aborted.'); }); - it('should cancel previous request when successive request is made through fetchf() and rejectCancelled is false', async () => { + it('should not cancel previous request when cancellable is set to false', async () => { + fetchMock.reset(); + + const requestHandler = createRequestHandler({ + cancellable: false, // No request cancellation + rejectCancelled: true, + flattenResponse: false, + }); + + // Mock the first request + fetchMock.mock('https://example.com/first', { + status: 200, + body: { data: { message: 'response from first request' } }, + }); + + // Mock the second request + fetchMock.mock('https://example.com/second', { + status: 200, + body: { data: { message: 'response from second request' } }, + }); + + const firstRequest = requestHandler.request('https://example.com/first'); + const secondRequest = requestHandler.request( + 'https://example.com/second', + ); + + // Validate both requests resolve successfully without any cancellation + await expect(firstRequest).resolves.toMatchObject({ + data: { data: { message: 'response from first request' } }, + }); + await expect(secondRequest).resolves.toMatchObject({ + data: { data: { message: 'response from second request' } }, + }); + }); + + it('should cancel first request without throwing when successive request is made through fetchf() and rejectCancelled is false', async () => { fetchMock.reset(); const abortedError = new DOMException( @@ -1049,14 +1175,15 @@ describe('Request Handler', () => { flattenResponse: true, defaultResponse: {}, }); + const secondRequest = fetchf('https://example.com/second', { flattenResponse: true, }); - expect(secondRequest).resolves.toEqual({ - username: 'response from second request', + expect(secondRequest).resolves.toMatchObject({ + data: { username: 'response from second request' }, }); - expect(firstRequest).resolves.toEqual({}); + expect(firstRequest).resolves.toMatchObject({ data: {} }); }); }); @@ -1071,30 +1198,13 @@ describe('Request Handler', () => { .fn() .mockResolvedValue(responseMock); - const response = await requestHandler.request(apiUrl, null, { + const response = await requestHandler.request(apiUrl, { method: 'put', }); expect(response).toMatchObject(responseMock); }); - it('should handle nested data if data flattening is on', async () => { - const requestHandler = createRequestHandler({ - fetcher, - flattenResponse: true, - }); - - (requestHandler.getInstance() as any).request = jest - .fn() - .mockResolvedValue(responseMock); - - const response = await requestHandler.request(apiUrl, null, { - method: 'post', - }); - - expect(response).toMatchObject(responseMock.data); - }); - it('should handle deeply nested data if data flattening is on', async () => { const requestHandler = createRequestHandler({ fetcher, @@ -1105,11 +1215,12 @@ describe('Request Handler', () => { .fn() .mockResolvedValue({ data: responseMock }); - const response = await requestHandler.request(apiUrl, null, { + const { data } = await requestHandler.request(apiUrl, { method: 'patch', }); - expect(response).toMatchObject(responseMock.data); + expect(data).toMatchObject(responseMock.data); + expect(data).not.toMatchObject(nestedDataMock); }); it('should return null if there is no data', async () => { @@ -1124,8 +1235,8 @@ describe('Request Handler', () => { .mockResolvedValue({ data: null }); expect( - await requestHandler.request(apiUrl, null, { method: 'head' }), - ).toBe(null); + await requestHandler.request(apiUrl, { method: 'head' }), + ).toMatchObject({ data: null }); }); }); }); diff --git a/tsup.config.ts b/tsup.config.ts index 5384ee5..34b5b0b 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ name: 'fetchff', globalName: 'fetchff', entry: ['src/index.ts'], - target: 'es2017', + target: 'es2018', dts: true, clean: true, sourcemap: true,