Skip to content

Commit 7d42452

Browse files
Introduce request.fetchOptions feature to createJson* methods
1 parent 8d531b1 commit 7d42452

File tree

10 files changed

+579
-6
lines changed

10 files changed

+579
-6
lines changed

.changeset/wise-adults-doubt.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@farfetched/core': minor
3+
---
4+
5+
Introduce `request.fetchOptions` feature to createJson\* methods

apps/website/docs/api/factories/create_json_mutation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Config fields:
2020
- `omit` — do not include credentials
2121
- `same-origin` — include credentials only if the request URL is the same origin
2222
- `include` — include credentials on all requests
23+
- `fetchOptions?`: <Badge type="tip" text="since v0.14.3" /> _Object or [Store](https://effector.dev/docs/api/effector/Store) with Object_, additional [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#options) options to pass to the underlying fetch request. This allows configuring options like `mode`, `cache`, `redirect`, `referrerPolicy`, `integrity`, `keepalive`, etc. If `credentials` is specified both at the top level and in `fetchOptions`, the top-level value takes precedence.
2324

2425
- `response`: declarative rules to handle response from the API.
2526
- `contract`: [_Contract_](/api/primitives/contract) allows you to validate the response and decide how your application should treat it — as a success response or as a failed one.

apps/website/docs/api/factories/create_json_query.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Config fields:
2222
- `omit` — do not include credentials
2323
- `same-origin` — include credentials only if the request URL is the same origin
2424
- `include` — include credentials on all requests
25+
- `fetchOptions?`: <Badge type="tip" text="since v0.14.3" /> _Object or [Store](https://effector.dev/docs/api/effector/Store) with Object_, additional [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#options) options to pass to the underlying fetch request. This allows configuring options like `mode`, `cache`, `redirect`, `referrerPolicy`, `integrity`, `keepalive`, etc. If `credentials` is specified both at the top level and in `fetchOptions`, the top-level value takes precedence.
2526

2627
- `response`: declarative rules to handle response from the API.
2728
- `contract`: [_Contract_](/api/primitives/contract) allows you to validate the response and decide how your application should treat it — as a success response or as a failed one.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { allSettled, createStore, fork } from 'effector';
2+
import { describe, test, expect, vi } from 'vitest';
3+
4+
import { createApiRequest, type FetchOptions } from '../api';
5+
import { fetchFx } from '../fetch';
6+
7+
describe('fetch/api.request.fetchOptions', () => {
8+
// Does not matter
9+
const mapBody = () => 'any body';
10+
const url = 'https://api.salo.com';
11+
const method = 'GET';
12+
13+
// Does not matter
14+
const response = {
15+
extract: async <T>(v: T) => v,
16+
};
17+
18+
test('pass static fetchOptions on creation to request', async () => {
19+
const callApiFx = createApiRequest({
20+
request: {
21+
mapBody,
22+
method,
23+
url,
24+
fetchOptions: {
25+
mode: 'cors',
26+
cache: 'no-cache',
27+
referrerPolicy: 'no-referrer',
28+
},
29+
},
30+
response,
31+
});
32+
33+
const fetchMock = vi.fn().mockResolvedValue(new Response('test'));
34+
35+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
36+
37+
await allSettled(callApiFx, { scope, params: {} });
38+
39+
const request = fetchMock.mock.calls[0][0] as Request;
40+
expect(request.mode).toEqual('cors');
41+
expect(request.cache).toEqual('no-cache');
42+
expect(request.referrerPolicy).toEqual('no-referrer');
43+
});
44+
45+
test('pass reactive fetchOptions on creation to request', async () => {
46+
const $fetchOptions = createStore<FetchOptions>({
47+
mode: 'cors',
48+
cache: 'no-cache',
49+
});
50+
51+
const callApiFx = createApiRequest({
52+
request: { mapBody, method, url, fetchOptions: $fetchOptions },
53+
response,
54+
});
55+
56+
const fetchMock = vi.fn().mockResolvedValue(new Response('test'));
57+
58+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
59+
60+
// with original value
61+
await allSettled(callApiFx, { scope, params: {} });
62+
let request = fetchMock.mock.calls[0][0] as Request;
63+
expect(request.mode).toEqual('cors');
64+
expect(request.cache).toEqual('no-cache');
65+
66+
// with new value
67+
await allSettled($fetchOptions, {
68+
scope,
69+
params: { mode: 'no-cors', cache: 'force-cache' },
70+
});
71+
await allSettled(callApiFx, { scope, params: {} });
72+
request = fetchMock.mock.calls[1][0] as Request;
73+
expect(request.mode).toEqual('no-cors');
74+
expect(request.cache).toEqual('force-cache');
75+
});
76+
77+
test('top-level credentials takes precedence over fetchOptions.credentials', async () => {
78+
const callApiFx = createApiRequest({
79+
request: {
80+
mapBody,
81+
method,
82+
url,
83+
credentials: 'include',
84+
fetchOptions: {
85+
credentials: 'omit',
86+
cache: 'no-cache',
87+
},
88+
},
89+
response,
90+
});
91+
92+
const fetchMock = vi.fn().mockResolvedValue(new Response('test'));
93+
94+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
95+
96+
await allSettled(callApiFx, { scope, params: {} });
97+
98+
const request = fetchMock.mock.calls[0][0] as Request;
99+
// top-level credentials should win
100+
expect(request.credentials).toEqual('include');
101+
// other fetchOptions should still apply
102+
expect(request.cache).toEqual('no-cache');
103+
});
104+
105+
test('fetchOptions.credentials is used when top-level credentials is not set', async () => {
106+
const callApiFx = createApiRequest({
107+
request: {
108+
mapBody,
109+
method,
110+
url,
111+
fetchOptions: {
112+
credentials: 'include',
113+
},
114+
},
115+
response,
116+
});
117+
118+
const fetchMock = vi.fn().mockResolvedValue(new Response('test'));
119+
120+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
121+
122+
await allSettled(callApiFx, { scope, params: {} });
123+
124+
const request = fetchMock.mock.calls[0][0] as Request;
125+
expect(request.credentials).toEqual('include');
126+
});
127+
128+
test('pass fetchOptions with keepalive option', async () => {
129+
const callApiFx = createApiRequest({
130+
request: {
131+
mapBody,
132+
method,
133+
url,
134+
fetchOptions: {
135+
keepalive: true,
136+
},
137+
},
138+
response,
139+
});
140+
141+
const fetchMock = vi.fn().mockResolvedValue(new Response('test'));
142+
143+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
144+
145+
await allSettled(callApiFx, { scope, params: {} });
146+
147+
const request = fetchMock.mock.calls[0][0] as Request;
148+
expect(request.keepalive).toEqual(true);
149+
});
150+
151+
test('pass fetchOptions with redirect option', async () => {
152+
const callApiFx = createApiRequest({
153+
request: {
154+
mapBody,
155+
method,
156+
url,
157+
fetchOptions: {
158+
redirect: 'manual',
159+
},
160+
},
161+
response,
162+
});
163+
164+
const fetchMock = vi.fn().mockResolvedValue(new Response('test'));
165+
166+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
167+
168+
await allSettled(callApiFx, { scope, params: {} });
169+
170+
const request = fetchMock.mock.calls[0][0] as Request;
171+
expect(request.redirect).toEqual('manual');
172+
});
173+
174+
test('pass fetchOptions with integrity option', async () => {
175+
const integrityValue =
176+
'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';
177+
178+
const callApiFx = createApiRequest({
179+
request: {
180+
mapBody,
181+
method,
182+
url,
183+
fetchOptions: {
184+
integrity: integrityValue,
185+
},
186+
},
187+
response,
188+
});
189+
190+
const fetchMock = vi.fn().mockResolvedValue(new Response('test'));
191+
192+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
193+
194+
await allSettled(callApiFx, { scope, params: {} });
195+
196+
const request = fetchMock.mock.calls[0][0] as Request;
197+
expect(request.integrity).toEqual(integrityValue);
198+
});
199+
});

packages/core/src/fetch/api.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ export type HttpMethod =
3333

3434
export type RequestBody = Blob | BufferSource | FormData | string;
3535

36+
// Future-proof: automatically includes any new RequestInit fields from the browser
37+
export type FetchOptions = Omit<
38+
RequestInit,
39+
'method' | 'headers' | 'body' | 'signal'
40+
>;
41+
3642
// These settings can be defined only statically
3743
export interface StaticOnlyRequestConfig<B> {
3844
method: StaticOrReactive<HttpMethod>;
@@ -43,6 +49,7 @@ export interface StaticOnlyRequestConfig<B> {
4349
export interface ExclusiveRequestConfigShared {
4450
url: string;
4551
credentials?: RequestCredentials;
52+
fetchOptions?: FetchOptions;
4653
abortController?: AbortController;
4754
}
4855

@@ -139,17 +146,23 @@ export function createApiRequest<
139146
query,
140147
headers,
141148
credentials,
149+
fetchOptions,
142150
body,
143151
abortController,
144152
}) => {
145153
const mappedBody = body ? config.request.mapBody(body) : null;
146154

147155
const request = new Request(formatUrl(url, query), {
156+
...fetchOptions,
148157
method,
149158
headers: formatHeaders(headers),
150-
credentials,
151159
body: mappedBody,
152160
signal: abortController?.signal,
161+
/**
162+
* `credentials` is available both in `fetchOptions` and in the top-level config.
163+
* The top-level config was introduced much earlier, so it takes precedence.
164+
*/
165+
...(credentials !== undefined ? { credentials } : {}),
153166
});
154167

155168
const response = await requestFx(request).catch((cause: RequestError) => {
@@ -240,6 +253,7 @@ export function createApiRequest<
240253
query: normalizeStaticOrReactive(config.request.query),
241254
headers: normalizeStaticOrReactive(config.request.headers),
242255
credentials: normalizeStaticOrReactive(config.request.credentials),
256+
fetchOptions: normalizeStaticOrReactive(config.request.fetchOptions),
243257
body: normalizeStaticOrReactive(config.request.body),
244258
},
245259
mapParams(dynamicConfig: ApiRequestParams, staticConfig) {
@@ -250,11 +264,16 @@ export function createApiRequest<
250264
// @ts-expect-error TS cannot infer type correctly, but there is always field in staticConfig or dynamicConfig
251265
dynamicConfig.url;
252266

253-
const credentials: RequestCredentials =
267+
const credentials: RequestCredentials | undefined =
254268
staticConfig.credentials ??
255269
// @ts-expect-error TS cannot infer type correctly, but there is always field in staticConfig or dynamicConfig
256270
dynamicConfig.credentials;
257271

272+
const fetchOptions: FetchOptions | undefined =
273+
staticConfig.fetchOptions ??
274+
// @ts-expect-error TS cannot infer type correctly, but there is always field in staticConfig or dynamicConfig
275+
dynamicConfig.fetchOptions;
276+
258277
const body: B =
259278
staticConfig.body ??
260279
// @ts-expect-error TS cannot infer type correctly, but there is always field in staticConfig or dynamicConfig
@@ -276,6 +295,7 @@ export function createApiRequest<
276295
query,
277296
headers,
278297
credentials,
298+
fetchOptions,
279299
body,
280300
abortController,
281301
};

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export { type ValidationResult, type Validator } from './validation/type';
6868
export { type Json } from 'effector';
6969
export { type JsonObject } from './fetch/json';
7070
export { type FetchApiRecord } from './fetch/lib';
71-
export { type JsonApiRequestError } from './fetch/api';
71+
export { type JsonApiRequestError, type FetchOptions } from './fetch/api';
7272
export { fetchFx } from './fetch/fetch';
7373

7474
// Exposed errors

0 commit comments

Comments
 (0)