Skip to content

Commit e7b4bf7

Browse files
committed
feat: add custom response parser option and enhance binary data handling
1 parent c2d0c3b commit e7b4bf7

File tree

5 files changed

+110
-17
lines changed

5 files changed

+110
-17
lines changed

README.md

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,7 @@ You can also use all native [`fetch()` settings](https://developer.mozilla.org/e
572572
| refetchOnReconnect | `boolean` | `false` | When set to `true`, automatically revalidates (refetches) data when the browser regains internet connectivity after being offline. **This uses background revalidation to silently update data** without showing loading states to users. Helps ensure your application displays fresh data after network interruptions. Works by listening to the browser's `online` event. |
573573
| logger | `Logger` | `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. |
574574
| fetcher | `CustomFetcher` | `undefined` | A custom fetcher async function. By default, the native `fetch()` is used. If you use your own fetcher, default response parsing e.g. `await response.json()` call will be skipped. Your fetcher should return response object / data directly. |
575+
| parser | `(response: Response) => Promise<any>` | `undefined` | A custom response parser function. When provided, it replaces the built-in content-type based parsing entirely. Receives the raw `Response` object and should return the parsed data. Useful for handling custom formats like XML, CSV, or proprietary data. Can be set globally or per-request. |
575576

576577
> 📋 **Additional Settings Available:**
577578
> The table above shows the most commonly used settings. Many more advanced configuration options are available and documented in their respective sections below, including:
@@ -1753,23 +1754,65 @@ If the header is an HTTP-date, the delay will be calculated as the difference be
17531754
<summary><span style="cursor:pointer">Click to expand</span></summary>
17541755
<br>
17551756
1756-
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.
1757+
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. It returns functions like `response.json()` that can be called idempotently without throwing errors or consuming the underlying stream multiple times. You can also use `parser` option to fully control returned response (useful for response streaming).
17571758
17581759
### **How It Works**
17591760
17601761
- **JSON (`application/json`):** Parses the response as JSON.
17611762
- **Form Data (`multipart/form-data`):** Parses the response as `FormData`.
1762-
- **Binary Data (`application/octet-stream`):** Parses the response as a `Blob`.
1763+
- **Binary Data (`application/octet-stream`, images, video, audio, pdf, zip):** Parses the response as an `ArrayBuffer`. Use `response.blob()` to get a `Blob`, or `response.bytes()` to get a `Uint8Array`.
17631764
- **URL-encoded Form Data (`application/x-www-form-urlencoded`):** Parses the response as `FormData`.
17641765
- **Text (`text/*`):** Parses the response as plain text.
1766+
- **Other/Custom types (XML, CSV, etc.):** Returns raw text. Use the `parser` option for custom parsing.
17651767
1766-
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.
1768+
If the `Content-Type` header is missing or not recognized, the plugin defaults to returning text. If the text looks like JSON (starts with `{` or `[`), it will be auto-parsed as JSON.
17671769
17681770
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.
17691771
17701772
> ⚠️ **When using in Node.js:**
17711773
> In Node.js, using FormData, Blob, or ReadableStream may require additional polyfills or will not work unless your fetch polyfill supports them.
17721774
1775+
### Custom `parser` Option
1776+
1777+
You can provide a custom `parser` function to override the default content-type based parsing. This is useful for formats like XML, CSV, or any proprietary format. The `parser` can be set globally (via `createApiFetcher()` or `setDefaultConfig()`) or per-request.
1778+
1779+
```typescript
1780+
import { fetchf } from 'fetchff';
1781+
1782+
// Example: Parse XML responses
1783+
const { data } = await fetchf('/api/data.xml', {
1784+
async parser(response) {
1785+
const text = await response.text();
1786+
return new DOMParser().parseFromString(text, 'application/xml');
1787+
},
1788+
});
1789+
1790+
// Example: Parse CSV responses
1791+
const { data: csvData } = await fetchf('/api/report.csv', {
1792+
async parser(response) {
1793+
const text = await response.text();
1794+
return text.split('\n').map((row) => row.split(','));
1795+
},
1796+
});
1797+
```
1798+
1799+
You can also set it globally:
1800+
1801+
```typescript
1802+
import { createApiFetcher } from 'fetchff';
1803+
1804+
const api = createApiFetcher({
1805+
apiUrl: 'https://example.com/api',
1806+
parser: async (response) => {
1807+
const text = await response.text();
1808+
return new DOMParser().parseFromString(text, 'application/xml');
1809+
},
1810+
endpoints: {
1811+
getReport: { url: '/report' },
1812+
},
1813+
});
1814+
```
1815+
17731816
### `onResponse` Interceptor
17741817
17751818
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.

src/request-handler.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,9 @@ export async function fetchf<
242242
if (isObject(response)) {
243243
// Case 1: Native Response instance
244244
if (typeof Response === FUNCTION && response instanceof Response) {
245-
response.data = await parseResponseData(response);
245+
response.data = requestConfig.parser
246+
? await requestConfig.parser(response)
247+
: await parseResponseData(response);
246248
} else if (fn) {
247249
// Case 2: Custom fetcher that returns a response object
248250
if (!('data' in response && 'body' in response)) {

src/response-parser.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,14 @@ export async function parseResponseData<
6565
) {
6666
data = await response.formData();
6767
} else if (
68-
mimeType.includes(APPLICATION_CONTENT_TYPE + 'octet-stream') &&
69-
typeof response.blob === FUNCTION
68+
mimeType.startsWith('image/') ||
69+
mimeType.startsWith('video/') ||
70+
mimeType.startsWith('audio/') ||
71+
mimeType.includes(APPLICATION_CONTENT_TYPE + 'octet-stream') ||
72+
mimeType.includes('pdf') ||
73+
mimeType.includes('zip')
7074
) {
71-
data = await response.blob(); // Parse as blob
75+
data = await response.arrayBuffer(); // Parse as ArrayBuffer for binary types
7276
} else {
7377
data = await response.text();
7478

@@ -191,13 +195,16 @@ export const prepareResponse = <
191195
statusText: response.statusText,
192196

193197
// Convert methods to use arrow functions to preserve correct return types
194-
blob: () => response.blob(),
195-
json: () => response.json(),
196-
text: () => response.text(),
198+
blob: async () =>
199+
data instanceof ArrayBuffer ? new Blob([data]) : new Blob(), // Lazily construct Blob from ArrayBuffer
200+
json: () => Promise.resolve(data) as Promise<ResponseData>, // Return the already parsed JSON data
201+
text: () => Promise.resolve(data) as Promise<string>, // Return the already parsed text data
197202
clone: () => response.clone(),
198-
arrayBuffer: () => response.arrayBuffer(),
199-
formData: () => response.formData(),
200-
bytes: () => response.bytes(),
203+
arrayBuffer: async () =>
204+
data instanceof ArrayBuffer ? data : new ArrayBuffer(0), // Return the ArrayBuffer directly
205+
formData: async () => (data instanceof FormData ? data : new FormData()), // Return the already parsed FormData
206+
bytes: async () =>
207+
data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(0), // Return bytes from ArrayBuffer
201208

202209
// Enhance the response with extra information
203210
error,

src/types/request-handler.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,23 @@ export interface ExtendedRequestConfig<
671671
PathParams
672672
>;
673673

674+
/**
675+
* A custom response parser function to handle response data parsing.
676+
* When provided, this function is used instead of the default content-type based parsing.
677+
* Useful for handling custom response formats like XML, CSV, or proprietary data formats.
678+
*
679+
* @example:
680+
* // Parse XML responses
681+
* const xmlParser = async (response: Response) => {
682+
* const text = await response.text();
683+
* return new DOMParser().parseFromString(text, 'application/xml');
684+
* };
685+
* const { data } = await fetchf('/api/data.xml', { parser: xmlParser });
686+
*
687+
* @default undefined (uses built-in content-type based parsing)
688+
*/
689+
parser?: (response: Response) => Promise<any>;
690+
674691
/**
675692
* A custom fetcher instance to handle requests instead of the default implementation.
676693
* When `null`, the default fetch behavior is used.

test/request-parser.spec.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
12
import { parseResponseData, prepareResponse } from '../src/response-parser';
23
import type {
34
FetchResponse,
@@ -52,15 +53,37 @@ describe('parseData()', () => {
5253
expect(data).toEqual(expectedData);
5354
});
5455

55-
it('should parse Blob when Content-Type is application/octet-stream', async () => {
56+
it('should parse ArrayBuffer when Content-Type is application/octet-stream', async () => {
5657
(mockResponse.headers.get as jest.Mock).mockReturnValue(
5758
'application/octet-stream',
5859
);
59-
const expectedData = new Blob(['test']);
60-
(mockResponse.blob as jest.Mock).mockResolvedValue(expectedData);
60+
const expectedArrayBuffer = new ArrayBuffer(8);
61+
mockResponse.arrayBuffer = jest.fn().mockResolvedValue(expectedArrayBuffer);
6162

6263
const data = await parseResponseData(mockResponse);
63-
expect(data).toEqual(expectedData);
64+
expect(data).toBeInstanceOf(ArrayBuffer);
65+
expect(data).toEqual(expectedArrayBuffer);
66+
});
67+
68+
it('should return a Blob from .blob() when binary ArrayBuffer is parsed', async () => {
69+
// Use a real Response object to pass instanceof Response
70+
const expectedArrayBuffer = new ArrayBuffer(8);
71+
const realResponse = new Response(expectedArrayBuffer, {
72+
status: 200,
73+
headers: { 'Content-Type': 'application/octet-stream' },
74+
});
75+
// parseResponseData returns ArrayBuffer, but prepareResponse wraps it
76+
const arrayBuffer = await parseResponseData(realResponse as any);
77+
(realResponse as any).data = arrayBuffer;
78+
const wrapped = prepareResponse(realResponse as any, {
79+
cacheKey: 'test',
80+
url: '/test',
81+
method: 'GET',
82+
});
83+
84+
const blob = await wrapped.blob();
85+
expect(blob).toBeInstanceOf(Blob);
86+
expect(blob.size).toBe(expectedArrayBuffer.byteLength);
6487
});
6588

6689
it('should parse FormData when Content-Type is application/x-www-form-urlencoded', async () => {
@@ -192,6 +215,7 @@ describe('prepareResponse()', () => {
192215
} as unknown as FetchResponse;
193216
const config = {
194217
...baseConfig,
218+
195219
select: (data: any) => data.items,
196220
};
197221

0 commit comments

Comments
 (0)