fch 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/fetch.js +137 -90
  2. package/package.json +7 -5
  3. package/readme.md +350 -73
  4. package/fetch.test.js +0 -370
package/fetch.js CHANGED
@@ -1,20 +1,26 @@
1
- import swear from "swear";
2
-
3
- // To avoid making parallel requests to the same url if one is ongoing
4
- const ongoing = new Map();
5
-
6
1
  // Plain-ish object
7
2
  const hasPlainBody = (options) => {
8
3
  if (options.headers["content-type"]) return;
9
- if (typeof options.body !== "object") return;
4
+ if (!["object", "array"].includes(typeof options.body)) return;
10
5
  if (options.body instanceof FormData) return;
11
6
  return true;
12
7
  };
13
8
 
14
- const createUrl = (path, base) => {
9
+ const createUrl = (url, query, base) => {
10
+ let [path, urlQuery = {}] = url.split("?");
11
+
12
+ // Merge global params with passed params with url params
13
+ const entries = new URLSearchParams({
14
+ ...Object.fromEntries(new URLSearchParams(query)),
15
+ ...Object.fromEntries(new URLSearchParams(urlQuery)),
16
+ }).toString();
17
+ if (entries) {
18
+ path = path + "?" + entries;
19
+ }
20
+
15
21
  if (!base) return path;
16
- const url = new URL(path, base);
17
- return url.href;
22
+ const fullUrl = new URL(path, base);
23
+ return fullUrl.href;
18
24
  };
19
25
 
20
26
  const createHeaders = (user, base) => {
@@ -31,6 +37,15 @@ const createHeaders = (user, base) => {
31
37
  return headers;
32
38
  };
33
39
 
40
+ const createDedupe = (ongoing, url) => ({
41
+ save: (prom) => {
42
+ ongoing.set(url, prom);
43
+ return prom;
44
+ },
45
+ get: () => ongoing.get(url),
46
+ clear: () => ongoing.delete(url),
47
+ });
48
+
34
49
  const createFetch = (request, { after, dedupe, error, output }) => {
35
50
  return fetch(request.url, request).then(async (res) => {
36
51
  // No longer ongoing at this point
@@ -70,90 +85,122 @@ const createFetch = (request, { after, dedupe, error, output }) => {
70
85
  });
71
86
  };
72
87
 
73
- const fch = swear((url, options = {}) => {
74
- // Second parameter always has to be an object, even when it defaults
75
- if (typeof options !== "object") options = {};
76
-
77
- // Accept either fch(options) or fch(url, options)
78
- options = typeof url === "string" ? { url, ...options } : url;
79
-
80
- // Absolute URL if possible; Default method; merge the default headers
81
- options.url = createUrl(options.url, fch.baseUrl);
82
- options.method = (options.method ?? fch.method).toLowerCase();
83
- options.headers = createHeaders(options.headers, fch.headers);
84
-
85
- let {
86
- dedupe = fch.dedupe,
87
- output = fch.output,
88
-
89
- before = fch.before,
90
- after = fch.after,
91
- error = fch.error,
92
-
93
- ...request
94
- } = options; // Local option OR global value (including defaults)
95
-
96
- if (request.method !== "get") {
97
- dedupe = false;
98
- }
99
- if (dedupe) {
100
- dedupe = {
101
- save: (prom) => {
102
- ongoing.set(request.url, prom);
103
- return prom;
104
- },
105
- get: () => ongoing.get(request.url),
106
- clear: () => ongoing.delete(request.url),
107
- };
108
- }
109
-
110
- if (!["body", "response"].includes(output)) {
111
- const msg = `options.output needs to be either "body" (default) or "response", not "${output}"`;
112
- throw new Error(msg);
113
- }
114
-
115
- // JSON-encode plain objects
116
- if (hasPlainBody(request)) {
117
- request.body = JSON.stringify(request.body);
118
- request.headers["content-type"] = "application/json; charset=utf-8";
119
- }
120
-
121
- // Hijack the requeset and modify it
122
- if (before) {
123
- request = before(request);
124
- }
125
-
126
- // It should be cached
127
- if (dedupe) {
128
- // It's already cached! Just return it
129
- if (dedupe.get()) return dedupe.get();
88
+ const create = (defaults = {}) => {
89
+ // DEDUPLICATION is created on a per-instance basis
90
+ // To avoid making parallel requests to the same url if one is ongoing
91
+ const ongoing = new Map();
92
+
93
+ const fch = async (url, options = {}) => {
94
+ // Second parameter always has to be an object, even when it defaults
95
+ if (typeof options !== "object") options = {};
96
+
97
+ // Accept either fch(options) or fch(url, options)
98
+ options = typeof url === "string" ? { url, ...options } : url || {};
99
+
100
+ // Exctract the options
101
+ let {
102
+ dedupe = fch.dedupe,
103
+ output = fch.output,
104
+ baseURL = fch.baseURL, // DO NOT USE; it's here only for user friendliness
105
+ baseUrl = baseURL || fch.baseUrl,
106
+
107
+ // Extract it since it should not be part of fetch()
108
+ query = {},
109
+
110
+ // Interceptors can also be passed as parameters
111
+ before = fch.before,
112
+ after = fch.after,
113
+ error = fch.error,
114
+
115
+ ...request
116
+ } = options; // Local option OR global value (including defaults)
117
+
118
+ // Merge it, first the global and then the local
119
+ query = { ...fch.query, ...query };
120
+ // Absolute URL if possible; Default method; merge the default headers
121
+ request.url = createUrl(request.url || "/", query, baseUrl);
122
+ request.method = (request.method ?? fch.method).toLowerCase();
123
+ request.headers = createHeaders(request.headers, fch.headers);
124
+
125
+ if (request.method !== "get") {
126
+ dedupe = false;
127
+ }
128
+ if (dedupe) {
129
+ dedupe = createDedupe(ongoing, request.url);
130
+ }
130
131
 
131
- // Otherwise, save it in the cache and return the promise
132
- return dedupe.save(createFetch(request, { dedupe, output, error, after }));
133
- } else {
134
- // PUT, POST, etc should never dedupe and just return the plain request
135
- return createFetch(request, { output, error, after });
136
- }
137
- });
132
+ if (!["body", "response"].includes(output)) {
133
+ const msg = `options.output needs to be either "body" (default) or "response", not "${output}"`;
134
+ throw new Error(msg);
135
+ }
138
136
 
139
- fch.get = (url, options = {}) => fch(url, { ...options, method: "get" });
140
- fch.head = (url, options = {}) => fch(url, { ...options, method: "head" });
141
- fch.post = (url, options = {}) => fch(url, { ...options, method: "post" });
142
- fch.patch = (url, options = {}) => fch(url, { ...options, method: "patch" });
143
- fch.put = (url, options = {}) => fch(url, { ...options, method: "put" });
144
- fch.del = (url, options = {}) => fch(url, { ...options, method: "delete" });
137
+ // JSON-encode plain objects
138
+ if (hasPlainBody(request)) {
139
+ request.body = JSON.stringify(request.body);
140
+ request.headers["content-type"] = "application/json; charset=utf-8";
141
+ }
145
142
 
146
- // Default values
147
- fch.method = "get";
148
- fch.headers = {};
143
+ // Hijack the requeset and modify it
144
+ if (before) {
145
+ request = before(request);
146
+ }
149
147
 
150
- // Default options
151
- fch.dedupe = true;
152
- fch.output = "body";
148
+ // It should be cached and it's not being manually manipulated
149
+ if (dedupe && !request.signal) {
150
+ // It's already cached! Just return it
151
+ if (dedupe.get()) return dedupe.get();
153
152
 
154
- // Interceptors
155
- fch.before = (request) => request;
156
- fch.after = (response) => response;
157
- fch.error = (error) => Promise.reject(error);
153
+ // Otherwise, save it in the cache and return the promise
154
+ return dedupe.save(
155
+ createFetch(request, { dedupe, output, error, after })
156
+ );
157
+ } else {
158
+ // PUT, POST, etc should never dedupe and just return the plain request
159
+ return createFetch(request, { output, error, after });
160
+ }
161
+ };
162
+
163
+ // Default values
164
+ fch.method = defaults.method ?? "get";
165
+ fch.query = defaults.query ?? {};
166
+ fch.headers = defaults.headers ?? {};
167
+
168
+ // Default options
169
+ fch.dedupe = defaults.dedupe ?? true;
170
+ fch.output = defaults.output ?? "body";
171
+ fch.credentials = defaults.credentials ?? "include";
172
+
173
+ // Interceptors
174
+ fch.before = defaults.before ?? ((request) => request);
175
+ fch.after = defaults.after ?? ((response) => response);
176
+ fch.error = defaults.error ?? ((error) => Promise.reject(error));
177
+
178
+ const get = (url, opts = {}) => fch(url, { ...opts });
179
+ const head = (url, opts = {}) => fch(url, { ...opts, method: "head" });
180
+ const post = (url, opts = {}) => fch(url, { ...opts, method: "post" });
181
+ const patch = (url, opts = {}) => fch(url, { ...opts, method: "patch" });
182
+ const put = (url, opts = {}) => fch(url, { ...opts, method: "put" });
183
+ const del = (url, opts = {}) => fch(url, { ...opts, method: "delete" });
184
+
185
+ fch.get = get;
186
+ fch.head = head;
187
+ fch.post = post;
188
+ fch.patch = patch;
189
+ fch.put = put;
190
+ fch.del = del;
191
+
192
+ return fch;
193
+ };
158
194
 
159
- export default fch;
195
+ // Need to export it globally with `global`, since if we use export default then
196
+ // we cannot load it in the browser as a normal <script>, and if we load it in
197
+ // the browser as a <script module> then we cannot run a normal script after it
198
+ // since the modules are deferred by default. Basically this is a big mess and
199
+ // I wish I could just do if `(typeof window !== 'undefined') window.fch = fch`,
200
+ // but unfortunately that's not possible now and I need this as a traditionally
201
+ // global definition, and then another file to import it as ESM. UGHHH
202
+ let glob = {};
203
+ if (typeof global !== "undefined") glob = global;
204
+ if (typeof window !== "undefined") glob = window;
205
+ glob.fch = create();
206
+ glob.fch.create = create;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fch",
3
- "version": "2.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Fetch interface with better promises, deduplication, defaults, etc.",
5
5
  "homepage": "https://github.com/franciscop/fetch",
6
6
  "repository": "https://github.com/franciscop/fetch.git",
@@ -21,14 +21,16 @@
21
21
  "async",
22
22
  "ajax"
23
23
  ],
24
- "main": "fetch.js",
24
+ "main": "./index.js",
25
+ "browser": "./fetch.js",
26
+ "files": [
27
+ "fetch.js"
28
+ ],
25
29
  "type": "module",
26
30
  "engines": {
27
31
  "node": ">=18.0.0"
28
32
  },
29
- "dependencies": {
30
- "swear": "^1.1.0"
31
- },
33
+ "dependencies": {},
32
34
  "devDependencies": {
33
35
  "jest": "^28.0.1",
34
36
  "jest-fetch-mock": "^3.0.3"
package/readme.md CHANGED
@@ -1,31 +1,48 @@
1
1
  # Fch [![npm install fch](https://img.shields.io/badge/npm%20install-fch-blue.svg)](https://www.npmjs.com/package/fch) [![gzip size](https://img.badgesize.io/franciscop/fetch/master/fetch.js.svg?compression=gzip)](https://github.com/franciscop/fetch/blob/master/fetch.js)
2
2
 
3
- A library to make API calls easier. Similar to Axios, but tiny size and simpler API:
3
+ A tiny library to make API calls easier. Similar to Axios, but tiny size and simpler API:
4
4
 
5
5
  ```js
6
6
  import api from "fch";
7
-
8
- api.baseUrl = "https://pokeapi.co/";
9
-
10
- const mew = await api("/pokemon/150");
7
+ const mew = await api.get("https://pokeapi.co/pokemon/150");
11
8
  console.log(mew);
12
9
  ```
13
10
 
14
- `fch` is a better `fetch()`:
15
-
16
- - Automatically `JSON.stringify()` and `Content-Type: 'application/json'` for plain objects.
17
- - Automatically parse server response as json if it includes the headers, or text otherwise.
18
- - Isomorphic fetch(); it works the same way in the browser and server.
19
- - Await/Async Promise interface works as you know and love.
20
- - Better error handling. `>= 400 and <= 100` will _reject_ the promise with an error instance.
21
- - Advanced [Promise interface](https://www.npmjs.com/swear) for better scripting.
22
- - Import with the shorthand for tighter syntax. `import { get, post } from 'fch';`.
23
- - Easily define shared options straight on the root `fetch.baseUrl = "https://...";`.
11
+ - Automatically `JSON.stringify()` and `Content-Type: 'application/json'` for objects.
12
+ - Automatically parse server response taking into account the headers.
13
+ - Works the same way in Node.js and the browser.
14
+ - Await/Async Promises. `>= 400 and <= 100` will _reject_ the promise and throw an error.
15
+ - No dependencies; include it with a simple `<script>`
16
+ - Easily define shared options straight on the root `fch.baseUrl = "https://...";`.
24
17
  - Interceptors: `before` (the request), `after` (the response) and `error` (it fails).
25
18
  - Deduplicates parallel GET requests.
26
19
  - Configurable to return either just the body, or the full response.
27
- - [TODO]: cache engine with "highs" and "lows", great for scrapping
28
- - [TODO]: rate-limiting of requests (N-second, or N-parallel), great for scrapping
20
+
21
+ ```js
22
+ import api from 'fch';
23
+
24
+ api.get(url, { headers, ...options })
25
+ api.head(url, { headers, ...options })
26
+ api.post(url, { body, headers, ...options })
27
+ api.patch(url, { body, headers, ...options })
28
+ api.put(url, { body, headers, ...options })
29
+ api.del(url, { body, headers, ...options })
30
+
31
+ api.create({ url, body, headers, ...options})
32
+ ```
33
+
34
+ | Options | Default | Description |
35
+ |-----------|--------------------|---------------------------------------------|
36
+ | `url` | `null` | The path or full url for the request |
37
+ | `baseUrl` | `null` | The shared base of the API |
38
+ | `method` | `"get"` | Default method to use for the call |
39
+ | `query` | `{}` | Add query parameters to the URL |
40
+ | `headers` | `{}` | Shared headers across all requests |
41
+ | `dedupe` | `true` | Reuse GET requests made concurrently |
42
+ | `output` | `"body"` | The return value of the API call |
43
+ | `before` | `req => req` | Process the request before sending it |
44
+ | `after` | `res => res` | Process the response before returning it |
45
+ | `error` | `err => throw err` | Process errors before returning them |
29
46
 
30
47
  ## Getting Started
31
48
 
@@ -37,85 +54,227 @@ npm install fch
37
54
 
38
55
  Then import it to be able to use it in your code:
39
56
 
57
+ ```js
58
+ import fch from 'fch';
59
+ const body = await fch.get('/');
60
+ ```
61
+
62
+ On the browser you can add it with a script and it will be available as `fch`:
63
+
64
+ ```html
65
+ <!-- Import it as usual -->
66
+ <script src="https://cdn.jsdelivr.net/npm/fch"></script>
67
+ <script>
68
+ fch('/hello');
69
+ </script>
70
+ ```
71
+
72
+ ## Options
73
+
40
74
  ```js
41
75
  import api from 'fch';
42
76
 
43
- const data = await api.get('/');
77
+ // General options with their defaults; all of these are also parameters:
78
+ api.baseUrl = null; // Set an API base URL reused all across requests
79
+ api.method = 'get'; // Default method to use for api()
80
+ api.query = {}; // Is merged with the query parameters passed manually
81
+ api.headers = {}; // Is merged with the headers on a per-request basis
82
+
83
+ // Control simple variables
84
+ api.dedupe = true; // Avoid parallel GET requests to the same path
85
+ api.output = 'body'; // Return the body; use 'response' for the full response
86
+
87
+ // Interceptors
88
+ api.before = req => req;
89
+ api.after = res => res;
90
+ api.error = err => Promise.reject(err);
91
+
92
+ // Similar API to fetch()
93
+ api(url, { method, body, headers, ... });
94
+
95
+ // Our highly recommended style:
96
+ api.get(url, { headers, ... });
97
+ api.post(url, { body, headers, ... });
98
+ api.put(url, { body, headers, ... });
99
+ // ...
44
100
  ```
45
101
 
102
+ ### Method
46
103
 
47
- ### Advanced promise interface
104
+ The HTTP method to make the request. When using the shorthand, it defaults to `GET`. We recommend using the method syntax:
48
105
 
49
- This library has a couple of aces up its sleeve. First, it [has a better Promise interface](https://www.npmjs.com/swear):
106
+ ```js
107
+ import api from 'fch';
108
+
109
+ api.get('/cats');
110
+ api.post('/cats', { body: { name: 'snowball' } });
111
+ api.put(`/cats/3`, { body: { name: 'snowball' }});
112
+ ```
113
+
114
+ You can use it with the plain function as an option parameter. The methods are all lowercase but the option as a parameter is case insensitive; it can be either uppercase or lowercase:
50
115
 
51
116
  ```js
52
- import api from "fch";
117
+ // Recommended way of dealing with methods:
118
+ api.get(...);
119
+
120
+ // INVALID; won't work
121
+ api.GET(...);
53
122
 
54
- // You can keep performing actions like it was sync
55
- const firstFriendName = await api.get("/friends")[0].name;
56
- console.log(firstFriendName);
123
+ // Both of these are valid:
124
+ api({ method; 'GET' })
125
+ api({ method; 'get'})
57
126
  ```
58
127
 
59
- ### Define shared options
128
+ Example: adding a new cat and fixing a typo:
60
129
 
61
- You can also define values straight away:
130
+ ```js
131
+ import api from 'fch';
132
+
133
+ const cats = await api.get('/cats');
134
+ console.log(cats);
135
+ const { id } = await api.post('/cats', { body: { name: 'snowbll' } });
136
+ await api.put(`/cats/${id}`, { body: { name: 'snowball' }})
137
+ ```
138
+
139
+ ### Url
140
+
141
+ Specify where to send the request to. It's normally the first argument, though technically you can use both styles:
62
142
 
63
143
  ```js
64
- import api from "fch";
144
+ import api from 'fch';
65
145
 
66
- api.baseUrl = "https://pokeapi.co/";
146
+ // Recommended way of specifying the Url
147
+ await api.post('/hello', { body: '...', headers: {} })
67
148
 
68
- const mew = await api.get("/pokemon/150");
69
- console.log(mew);
149
+ // These are also valid if you prefer their style; we won't judge
150
+ await api('/hello', { method: 'post', body: '...', headers: {} });
151
+ await api({ url: '/hello', method: 'post', headers: {}, body: '...' });
152
+ await api.post({ url: '/hello', headers: {}, body: '...' });
70
153
  ```
71
154
 
72
- If you prefer Axios' style of outputting the whole response, you can do:
155
+ It can be either absolute or relative, in which case it'll use the local one in the page. It's recommending to set `baseUrl`:
73
156
 
74
157
  ```js
75
- // Default, already only returns the data on a successful call
76
- api.output = "data";
77
- const name = await api.get("/users/1").name;
158
+ import api from 'fch';
159
+ api.baseUrl = 'https//api.filemon.io/';
160
+ api.get('/hello');
161
+ // Called https//api.filemon.io/hello
162
+ ```
78
163
 
79
- // Axios-like
80
- api.output = "response";
81
- const name = await api.get("/users/1").data.name;
164
+ > Note: with Node.js you need to either set an absolute baseUrl or make the URL absolute
165
+
166
+ ### Body
167
+
168
+ The `body` can be a string, a plain object|array or a FormData instance. If it's an array or object, it'll be stringified and the header `application/json` will be added. Otherwise it'll be sent as plain text:
169
+
170
+ ```js
171
+ import api from 'api';
172
+
173
+ // Sending plain text
174
+ await api.post('/houses', { body: 'plain text' });
175
+
176
+ // Will JSON.stringify it internally, and add the JSON headers
177
+ await api.post('/houses', { body: { id: 1, name: 'Cute Cottage' } });
178
+
179
+ // Send it as FormData
180
+ form.onsubmit = e => {
181
+ await api.post('/houses', { body: new FormData(e.target) });
182
+ };
82
183
  ```
83
184
 
84
- ### Interceptors
185
+ The methods `GET` and `HEAD` do not accept a body and it'll be ignored.
85
186
 
86
- You can also easily add the interceptors `before`, `after` and `error`:
187
+ The **response body** will be returned by default as the output of the call:
87
188
 
88
189
  ```js
89
- // Perform an action or request transformation before the request is sent
90
- fch.before = async req => {
91
- // Normalized request ready to be sent
92
- ...
190
+ const body = await api.get('/cats');
191
+ console.log(body);
192
+ // [{ id: 1, }, ...]
193
+ ```
194
+
195
+ When the server specifies the header `Content-Type` as `application/json`, then we'll attempt to parse the response body and return that as the variable. Otherwise, the plain text will be returned.
196
+
197
+ When the function returns the response (if you set `output: "response"` as an option), then the body can be accessed as `response.body`:
198
+
199
+ ```js
200
+ const response = await api.get('/cats', { output: 'response' });
201
+ console.log(response.body);
202
+ // [{ id: 1, }, ...]
203
+ ```
204
+
205
+
206
+ ### Query
207
+
208
+ You can easily pass GET query parameters by using the option `query`:
209
+
210
+ ```js
211
+ api.get('/cats', { query: { limit: 3 } });
212
+ // /cats?limit=3
213
+ ```
214
+
215
+ While rare, some times you might want to persist a query parameter across requests and always include it; in that case, you can define it globally and it'll be added to every request:
216
+
217
+ ```js
218
+ import api from 'fch';
219
+ api.query.myparam = 'abc';
220
+
221
+ api.get('/cats', { query: { limit: 3 } });
222
+ // /cats?limit=3&myparam=abc
223
+ ```
224
+
225
+
226
+ ### Headers
227
+
228
+ You can define headers globally, in which case they'll be added to every request, or locally, so that they are only added to the current request. You can also add them in the `before` callback:
229
+
230
+
231
+ ```js
232
+ import api from 'fch';
233
+
234
+ // Globally, so they are reused across all requests
235
+ api.headers.a = 'b';
236
+
237
+ // With an interceptor, in case you need dynamic headers per-request
238
+ api.before = req => {
239
+ req.headers.c = 'd';
93
240
  return req;
94
241
  };
95
242
 
96
- // Perform an action or data transformation after the request is finished
97
- fch.after = async res => {
98
- // Full response as just after the request is made
99
- ...
100
- return res;
101
- };
243
+ // Set them for this single request:
244
+ api.get('/hello', { headers: { e: 'f' } });
245
+ // Total headers on the request:
246
+ // { a: 'b', c: 'd', e: 'f' }
247
+ ```
102
248
 
103
- // Perform an action or data transformation when an error is thrown
104
- fch.error = async err => {
105
- // Need to re-throw if we want to throw on error
106
- ...
107
- throw err;
249
+ When to use each?
108
250
 
109
- // OR, resolve it as if it didn't fail
110
- return err.response;
251
+ - If you need headers shared across all requests, like an API key, then the global one is the best place.
252
+ - When you need to extract them dynamically from somewhere it's better to use the .before() interceptor. An example would be the user Authorization token.
253
+ - When it changes on each request, it's not consistent or it's an one-off, use the option argument.
111
254
 
112
- // OR, resolve it with a custom value
113
- return { message: 'Request failed with a code ' + err.response.status };
114
- };
255
+
256
+ ### Output
257
+
258
+ This controls whether the call returns just the body (default), or the whole response. It can be controlled globally or on a per-request basis:
259
+
260
+ ```js
261
+ import api from 'fch';
262
+
263
+ // "body" (default) or "response"
264
+ api.output = 'body';
265
+
266
+ // Return only the body, this is the default
267
+ const body = await api.get('/data');
268
+
269
+ // Return the whole response (with .body):
270
+ const response = await api.get('/data', { output: 'response' });
271
+
272
+ // Throws error
273
+ const invalid = await api.get('/data', { output: 'invalid' });
115
274
  ```
116
275
 
117
276
 
118
- ## Dedupe
277
+ ### Dedupe
119
278
 
120
279
  When you perform a GET request to a given URL, but another GET request *to the same* URL is ongoing, it'll **not** create a new request and instead reuse the response when the first one is finished:
121
280
 
@@ -139,7 +298,7 @@ fch('/a', { dedupe: true }); // [DEFAULT] Dedupes GET requests
139
298
  fch('/a', { dedupe: false }) // All fetch() calls trigger a network call
140
299
  ```
141
300
 
142
- > We do not support deduping other methods right now besides `GET` right now
301
+ > We do not support deduping other methods besides `GET` right now
143
302
 
144
303
  Note that opting out of deduping a request will _also_ make that request not be reusable, see this test for details:
145
304
 
@@ -157,28 +316,77 @@ it("can opt out locally", async () => {
157
316
  });
158
317
  ```
159
318
 
319
+ ### Interceptors
160
320
 
161
- ## How to
321
+ You can also easily add the interceptors `before`, `after` and `error`:
162
322
 
163
- ### Stop errors from throwing
323
+ ```js
324
+ // Perform an action or request transformation before the request is sent
325
+ fch.before = async req => {
326
+ // Normalized request ready to be sent
327
+ ...
328
+ return req;
329
+ };
164
330
 
165
- While you can handle this on a per-request basis, if you want to overwrite the global behavior you can just do:
331
+ // Perform an action or data transformation after the request is finished
332
+ fch.after = async res => {
333
+ // Full response as just after the request is made
334
+ ...
335
+ return res;
336
+ };
337
+
338
+ // Perform an action or data transformation when an error is thrown
339
+ fch.error = async err => {
340
+ // Need to re-throw if we want to throw on error
341
+ ...
342
+ throw err;
343
+
344
+ // OR, resolve it as if it didn't fail
345
+ return err.response;
346
+
347
+ // OR, resolve it with a custom value
348
+ return { message: 'Request failed with a code ' + err.response.status };
349
+ };
350
+ ```
351
+
352
+
353
+ ### Define shared options
354
+
355
+ You can also define values straight away:
166
356
 
167
357
  ```js
168
- import fch from 'fch';
169
- fch.error = error => error.response;
358
+ import api from "fch";
170
359
 
171
- const res = fch('/notfound');
172
- expect(res.status).toBe(404);
360
+ api.baseUrl = "https://pokeapi.co/";
361
+
362
+ const mew = await api.get("/pokemon/150");
363
+ console.log(mew);
173
364
  ```
174
365
 
175
- Just that, then when there's an error it'll just return as usual, e.g.:
366
+ If you prefer Axios' style of outputting the whole response, you can do:
367
+
368
+ ```js
369
+ // Default, already only returns the data on a successful call
370
+ api.output = "data";
371
+ const name = await api.get("/users/1").name;
372
+
373
+ // Axios-like
374
+ api.output = "response";
375
+ const name = await api.get("/users/1").data.name;
376
+ ```
377
+
378
+
379
+ ## How to
380
+
381
+ ### Stop errors from throwing
382
+
383
+ While you can handle this on a per-request basis, if you want to overwrite the global behavior you can write a interceptor:
176
384
 
177
385
  ```js
178
386
  import fch from 'fch';
179
387
  fch.error = error => error.response;
180
388
 
181
- const res = fch('/notfound');
389
+ const res = await fch('/notfound');
182
390
  expect(res.status).toBe(404);
183
391
  ```
184
392
 
@@ -214,8 +422,8 @@ There's a configuration parameter for that:
214
422
  import fch from 'fch';
215
423
  fch.baseUrl = 'https://api.filemon.io/';
216
424
 
217
- // Calls "https://api.filemon.io/blabla/"
218
- const body = await fch.get('/blabla/');
425
+ // Calls "https://api.filemon.io/blabla"
426
+ const body = await fch.get('/blabla');
219
427
  ```
220
428
 
221
429
 
@@ -230,7 +438,7 @@ fch.headers.Authorization = 'bearer abc';
230
438
  const me = await fch('/users/me');
231
439
  ```
232
440
 
233
- Globally on a per-request basis, for example if you take the value from localStorage:
441
+ Or globally on a per-request basis, for example if you take the value from localStorage:
234
442
 
235
443
  ```js
236
444
  import fch from 'fch';
@@ -254,3 +462,72 @@ import fch from 'fch';
254
462
 
255
463
  const me = await fch('/users/me', { headers: { Authorization: 'bearer abc' } });
256
464
  ```
465
+
466
+
467
+ ### Create an instance
468
+
469
+ You can create an instance with its own defaults and global options easily. It's common when writing an API that you want to encapsulate away:
470
+
471
+ ```js
472
+ import fch from 'fch';
473
+
474
+ const api = fch.create({
475
+ baseUrl: 'https://api.filemon.io/',
476
+ ...
477
+ });
478
+
479
+ api.get('/hello'); // Gets https://api.filemon.io/hello
480
+ fch.get('/hello'); // Gets http://localhost:3000/hello (or wherever you are)
481
+ ```
482
+
483
+ Note: for server-side (Node.js) usage, you always want to set `baseUrl`.
484
+
485
+ ### How to cancel an ongoing request?
486
+
487
+ You can cancel ongoing requests [similarly to native fetch()](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort#examples), by passing it a signal:
488
+
489
+ ```js
490
+ import api from 'fch';
491
+
492
+ const controller = new AbortController();
493
+ const signal = controller.signal;
494
+
495
+ abortButton.addEventListener('click', () => {
496
+ controller.abort();
497
+ console.log('Download aborted');
498
+ });
499
+
500
+ api.get(url, { signal });
501
+ ```
502
+
503
+ ### What are the differences in Node.js vs Browser?
504
+
505
+ First, we use the native Node.js' fetch() and the browser's native fetch(), so any difference between those also applies to this library. For example, if you were to call `"/"` in the browser it'd refer to the current URL, while in Node.js it'd fail since you need to specify the full URL. Some other places where you might find differences: CORS, cache, etc.
506
+
507
+ In the library itself there's nothing different between the browser and Node.js, but it might be interesting to note that (if/when implemented) things like cache, etc. in Node.js are normally long-lived and shared, while in a browser request it'd bound to the request itself.
508
+
509
+
510
+ ### What are the differences with Axios?
511
+
512
+ The main difference is that things are simplified with fch:
513
+
514
+ ```js
515
+ // Modify headers
516
+ axios.defaults.headers.Authorization = '...';
517
+ fch.headers.Authorization = '...';
518
+
519
+ // Set a base URL
520
+ axios.defaults.baseURL = '...';
521
+ fch.baseUrl = '...';
522
+
523
+ // Add an interceptor
524
+ axios.interceptors.request.use(fn);
525
+ fch.before = fn;
526
+ ```
527
+
528
+ API size is also strikingly different, with **7.8kb** for Axios and **1.9kb** for fch.
529
+
530
+ As disadvantages, I can think of two major ones for `fch`:
531
+
532
+ - Requires Node.js 18+, which is the version that includes `fetch()` by default.
533
+ - Does not support many of the more advanced options, like `onUploadProgress` nor `onDownloadProgress`.
package/fetch.test.js DELETED
@@ -1,370 +0,0 @@
1
- import fch from "./fetch.js";
2
- import mock from "jest-fetch-mock";
3
-
4
- mock.enableMocks();
5
-
6
- const jsonHeaders = { headers: { "Content-Type": "application/json" } };
7
-
8
- describe("fetch()", () => {
9
- beforeEach(() => {
10
- fetch.resetMocks();
11
- });
12
-
13
- it("can create a basic request", async () => {
14
- fetch.once("hello");
15
- const data = await fch("/");
16
-
17
- expect(data).toEqual("hello");
18
- expect(fetch.mock.calls.length).toEqual(1);
19
- expect(fetch.mock.calls[0][0]).toEqual("/");
20
- expect(fetch.mock.calls[0][1].method).toEqual("get");
21
- expect(fetch.mock.calls[0][1].headers).toEqual({});
22
- });
23
-
24
- it("accepts Axios syntax as well", async () => {
25
- fetch.once("hello");
26
- const data = await fch({ url: "/" });
27
-
28
- expect(data).toEqual("hello");
29
- expect(fetch.mock.calls.length).toEqual(1);
30
- expect(fetch.mock.calls[0][0]).toEqual("/");
31
- expect(fetch.mock.calls[0][1].method).toEqual("get");
32
- expect(fetch.mock.calls[0][1].headers).toEqual({});
33
- });
34
-
35
- it("can receive a full response", async () => {
36
- fetch.once("hello", { status: 200, headers: { hello: "world" } });
37
- const res = await fch("/", { output: "response" });
38
-
39
- expect(res.body).toEqual("hello");
40
- expect(res.status).toEqual(200);
41
- expect(res.statusText).toEqual("OK");
42
- expect(res.headers.hello).toEqual("world");
43
- expect(fetch.mock.calls.length).toEqual(1);
44
- });
45
-
46
- it("works with JSON", async () => {
47
- fetch.once(JSON.stringify({ secret: "12345" }), jsonHeaders);
48
- const data = await fch("https://google.com/");
49
-
50
- expect(data).toEqual({ secret: "12345" });
51
- expect(fetch.mock.calls.length).toEqual(1);
52
- expect(fetch.mock.calls[0][0]).toEqual("https://google.com/");
53
- });
54
-
55
- it("can use the swear interface", async () => {
56
- fetch.once(JSON.stringify({ secret: "12345" }), jsonHeaders);
57
- const hello = await fch("/").secret;
58
- expect(hello).toEqual("12345");
59
- });
60
-
61
- it("can use the baseUrl", async () => {
62
- fetch.once("hi");
63
- fch.baseUrl = "https://google.com/";
64
- const data = await fch.get("/hello");
65
- expect(data).toBe("hi");
66
- expect(fetch.mock.calls[0][0]).toBe("https://google.com/hello");
67
- expect(fetch.mock.calls[0][1].method).toEqual("get");
68
- fch.baseUrl = null;
69
- });
70
-
71
- it("can use the `fetch.get()` shorthand", async () => {
72
- fetch.once("my-data");
73
- const data = await fch.get("/");
74
- expect(data).toBe("my-data");
75
- expect(fetch.mock.calls[0][1].method).toEqual("get");
76
- });
77
-
78
- it("can use the `fetch.patch()` shorthand", async () => {
79
- fetch.once("my-data");
80
- expect(await fch.patch("/")).toBe("my-data");
81
- expect(fetch.mock.calls[0][1].method).toEqual("patch");
82
- });
83
-
84
- it("can use the `fetch.put()` shorthand", async () => {
85
- fetch.once("my-data");
86
- expect(await fch.put("/")).toBe("my-data");
87
- expect(fetch.mock.calls[0][1].method).toEqual("put");
88
- });
89
-
90
- it("can use the `fetch.post()` shorthand", async () => {
91
- fetch.once("my-data");
92
- expect(await fch.post("/")).toBe("my-data");
93
- expect(fetch.mock.calls[0][1].method).toEqual("post");
94
- });
95
-
96
- it("can use the `fetch.del()` shorthand", async () => {
97
- fetch.once("my-data");
98
- expect(await fch.del("/")).toBe("my-data");
99
- expect(fetch.mock.calls[0][1].method).toEqual("delete");
100
- });
101
-
102
- it("ignores invalid options", async () => {
103
- fetch.once(JSON.stringify({ secret: "12345" }), jsonHeaders);
104
- const res = await fch("https://google.com/", 10);
105
-
106
- expect(res).toEqual({ secret: "12345" });
107
- expect(fetch.mock.calls.length).toEqual(1);
108
- expect(fetch.mock.calls[0][0]).toEqual("https://google.com/");
109
- });
110
-
111
- it("will not overwrite if it is FormData", async () => {
112
- fetch.once(JSON.stringify({ secret: "12345" }), jsonHeaders);
113
- const res = await fch("/", { method: "post", body: new FormData() });
114
-
115
- expect(res).toEqual({ secret: "12345" });
116
- expect(fetch.mock.calls.length).toEqual(1);
117
- const [url, opts] = fetch.mock.calls[0];
118
- expect(opts).toMatchObject({ body: expect.any(FormData) });
119
- });
120
-
121
- it("will not overwrite if content-type is set", async () => {
122
- fetch.once(JSON.stringify({ secret: "12345" }), jsonHeaders);
123
- const res = await fch("/", {
124
- method: "POST",
125
- body: { a: "b" },
126
- headers: { "Content-Type": "xxx" },
127
- });
128
-
129
- expect(res).toEqual({ secret: "12345" });
130
- expect(fetch.mock.calls.length).toEqual(1);
131
- const [url, opts] = fetch.mock.calls[0];
132
- expect(url).toEqual("/");
133
- expect(opts).toMatchObject({
134
- method: "post",
135
- body: { a: "b" },
136
- headers: { "content-type": "xxx" },
137
- });
138
- });
139
-
140
- it("will send JSON", async () => {
141
- fetch.once(JSON.stringify({ secret: "12345" }), jsonHeaders);
142
- const res = await fch("/", { method: "POST", body: { a: "b" } });
143
-
144
- expect(res).toEqual({ secret: "12345" });
145
- expect(fetch.mock.calls.length).toEqual(1);
146
- const [url, opts] = fetch.mock.calls[0];
147
- expect(url).toEqual("/");
148
- expect(opts).toMatchObject({
149
- method: "post",
150
- body: '{"a":"b"}',
151
- headers: { "content-type": "application/json; charset=utf-8" },
152
- });
153
- });
154
-
155
- it("can run in parallel", async () => {
156
- fetch.once("a").once("b");
157
- const res = await Promise.all([fch("/a"), fch("/b")]);
158
-
159
- expect(res).toEqual(["a", "b"]);
160
- expect(fetch.mock.calls.length).toEqual(2);
161
- });
162
-
163
- it("can set `accepts` insensitively", async () => {
164
- fetch.once(JSON.stringify({ secret: "12345" }), jsonHeaders);
165
- const res = await fch("/", { headers: { Accepts: "text/xml" } });
166
-
167
- expect(fetch.mock.calls[0][1].headers).toEqual({ accepts: "text/xml" });
168
- });
169
-
170
- it("can accept network rejections", async () => {
171
- fetch.mockResponseOnce(JSON.stringify("unauthorized"), {
172
- status: 401,
173
- ok: false,
174
- });
175
- await expect(fch("/")).rejects.toMatchObject({
176
- message: "Unauthorized",
177
- });
178
- });
179
-
180
- it("throws with the wrong 'output' option", async () => {
181
- fetch.once("hello");
182
- await expect(fch("/", { output: "abc" })).rejects.toMatchObject({
183
- message: `options.output needs to be either "body" (default) or "response", not "abc"`,
184
- });
185
- });
186
-
187
- it("can accept rejections", async () => {
188
- fetch.mockRejectOnce(new Error("fake error message"));
189
- await expect(fch("/error")).rejects.toMatchObject({
190
- message: "fake error message",
191
- });
192
- });
193
- });
194
-
195
- describe("interceptors", () => {
196
- beforeEach(() => {
197
- fetch.resetMocks();
198
- });
199
- afterEach(() => {
200
- fetch.resetMocks();
201
- delete fch.before;
202
- delete fch.after;
203
- });
204
-
205
- it("can create a before interceptor", async () => {
206
- fetch.once("hello");
207
- const data = await fch("/", {
208
- before: (req) => {
209
- req.url = "/hello";
210
- req.method = "put";
211
- return req;
212
- },
213
- });
214
-
215
- expect(data).toEqual("hello");
216
- expect(fetch.mock.calls.length).toEqual(1);
217
- expect(fetch.mock.calls[0][0]).toEqual("/hello");
218
- expect(fetch.mock.calls[0][1].method).toEqual("put");
219
- expect(fetch.mock.calls[0][1].headers).toEqual({});
220
- });
221
-
222
- it("can create a global before interceptor", async () => {
223
- fetch.once("hello");
224
- fch.before = (req) => {
225
- req.url = "/hello";
226
- req.method = "put";
227
- return req;
228
- };
229
- const data = await fch("/");
230
-
231
- expect(data).toEqual("hello");
232
- expect(fetch.mock.calls.length).toEqual(1);
233
- expect(fetch.mock.calls[0][0]).toEqual("/hello");
234
- expect(fetch.mock.calls[0][1].method).toEqual("put");
235
- expect(fetch.mock.calls[0][1].headers).toEqual({});
236
-
237
- delete fch.before;
238
- });
239
-
240
- it("can create an after interceptor", async () => {
241
- fetch.once("hello", { status: 201, headers: { hello: "world" } });
242
- const res = await fch("/", {
243
- output: "response",
244
- after: (res) => {
245
- res.body = "bye";
246
- res.status = 200;
247
- res.headers.hello = "world";
248
- return res;
249
- },
250
- });
251
-
252
- expect(res.body).toEqual("bye");
253
- expect(res.status).toEqual(200);
254
- expect(res.statusText).toEqual("Created");
255
- expect(res.headers.hello).toEqual("world");
256
- expect(fetch.mock.calls.length).toEqual(1);
257
- expect(fetch.mock.calls[0][0]).toEqual("/");
258
- expect(fetch.mock.calls[0][1].method).toEqual("get");
259
- expect(fetch.mock.calls[0][1].headers).toEqual({});
260
- });
261
-
262
- it("can create a global after interceptor", async () => {
263
- fetch.once("hello", { status: 201, headers: { hello: "world" } });
264
- fch.after = (res) => {
265
- res.body = "bye";
266
- res.status = 200;
267
- res.headers.hello = "world";
268
- return res;
269
- };
270
- const res = await fch("/", { output: "response" });
271
-
272
- expect(res.body).toEqual("bye");
273
- expect(res.status).toEqual(200);
274
- expect(res.statusText).toEqual("Created");
275
- expect(res.headers.hello).toEqual("world");
276
- expect(fetch.mock.calls.length).toEqual(1);
277
- expect(fetch.mock.calls[0][0]).toEqual("/");
278
- expect(fetch.mock.calls[0][1].method).toEqual("get");
279
- expect(fetch.mock.calls[0][1].headers).toEqual({});
280
- });
281
- });
282
-
283
- describe("dedupe network calls", () => {
284
- beforeEach(() => {
285
- fetch.resetMocks();
286
- });
287
-
288
- it("dedupes ongoing/parallel calls", async () => {
289
- fetch.once("a").once("b");
290
- const res = await Promise.all([fch("/a"), fch("/a")]);
291
-
292
- expect(res).toEqual(["a", "a"]);
293
- expect(fetch.mock.calls.length).toEqual(1);
294
- });
295
-
296
- it("dedupes named calls", async () => {
297
- fetch.once("a").once("b");
298
- const res = await Promise.all([fch.get("/a"), fch.get("/a")]);
299
-
300
- expect(res).toEqual(["a", "a"]);
301
- expect(fetch.mock.calls.length).toEqual(1);
302
- });
303
-
304
- it("can opt out locally", async () => {
305
- fetch.once("a").once("b");
306
- const res = await Promise.all([fch("/a"), fch("/a", { dedupe: false })]);
307
-
308
- expect(res).toEqual(["a", "b"]);
309
- expect(fetch.mock.calls.length).toEqual(2);
310
- });
311
-
312
- it("cannot reuse an opted out call", async () => {
313
- fetch.once("a").once("b");
314
- const res = await Promise.all([fch("/a", { dedupe: false }), fch("/a")]);
315
-
316
- expect(res).toEqual(["a", "b"]);
317
- expect(fetch.mock.calls.length).toEqual(2);
318
- });
319
-
320
- it("will reuse the last opt-in call", async () => {
321
- fetch.once("a").once("b").once("c");
322
- const res = await Promise.all([
323
- fch("/a"),
324
- fch("/a", { dedupe: false }),
325
- fch("/a"),
326
- ]);
327
-
328
- expect(res).toEqual(["a", "b", "a"]);
329
- expect(fetch.mock.calls.length).toEqual(2);
330
- });
331
-
332
- it("can opt out globally", async () => {
333
- fetch.once("a").once("b").once("c");
334
- fch.dedupe = false;
335
- const res = await Promise.all([fch("/a"), fch("/a"), fch("/a")]);
336
-
337
- expect(res).toEqual(["a", "b", "c"]);
338
- expect(fetch.mock.calls.length).toEqual(3);
339
- fch.dedupe = true;
340
- });
341
-
342
- it("does NOT dedupe/cache serial requests", async () => {
343
- fetch.once("a").once("b");
344
- const resa = await fch("/a");
345
- const resb = await fch("/a");
346
-
347
- expect([resa, resb]).toEqual(["a", "b"]);
348
- expect(fetch.mock.calls.length).toEqual(2);
349
- });
350
-
351
- it("does NOT dedupe/cache other request types", async () => {
352
- fetch.once("a").once("b");
353
- const res = await Promise.all([fch.get("/a"), fch.post("/a")]);
354
-
355
- expect(res).toEqual(["a", "b"]);
356
- expect(fetch.mock.calls.length).toEqual(2);
357
- });
358
-
359
- it("NOT deduping are invisible for other request", async () => {
360
- fetch.once("a").once("b").once("c");
361
- const res = await Promise.all([
362
- fch.get("/a"),
363
- fch.post("/a"),
364
- fch.get("/a"),
365
- ]);
366
-
367
- expect(res).toEqual(["a", "b", "a"]);
368
- expect(fetch.mock.calls.length).toEqual(2);
369
- });
370
- });