fch 0.3.2 → 2.0.0

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.
package/fetch.js CHANGED
@@ -1,34 +1,25 @@
1
- import magic from './node_modules/magic-promises/magic.js';
2
-
3
- if (typeof require !== 'undefined') {
4
- require('isomorphic-fetch');
5
- }
1
+ import swear from "swear";
6
2
 
7
3
  // To avoid making parallel requests to the same url if one is ongoing
8
4
  const ongoing = new Map();
9
5
 
10
6
  // Plain-ish object
11
- const hasPlainBody = options => {
12
- if (options.headers['content-type']) return;
13
- if (typeof options.body !== 'object') return;
7
+ const hasPlainBody = (options) => {
8
+ if (options.headers["content-type"]) return;
9
+ if (typeof options.body !== "object") return;
14
10
  if (options.body instanceof FormData) return;
15
11
  return true;
16
12
  };
17
13
 
18
- const fch = (url, options = {}) => {
19
-
20
- options = {
21
- method: 'get',
22
- headers: {},
23
- ...(typeof options === 'object' ? options : {})
24
- };
25
-
26
- // GET requests should not have race conditions
27
- if (options.method.toLowerCase() === 'get') {
28
- if (ongoing.get(url)) return ongoing.get(url);
29
- }
14
+ const createUrl = (path, base) => {
15
+ if (!base) return path;
16
+ const url = new URL(path, base);
17
+ return url.href;
18
+ };
30
19
 
31
- const headers = options.headers;
20
+ const createHeaders = (user, base) => {
21
+ // User-set headers overwrite the base headers
22
+ const headers = { ...base, ...user };
32
23
 
33
24
  // Make the headers lowercase
34
25
  for (let key in headers) {
@@ -37,43 +28,132 @@ const fch = (url, options = {}) => {
37
28
  headers[key.toLowerCase()] = value;
38
29
  }
39
30
 
40
- // JSON-encode plain objects
41
- if (hasPlainBody(options)) {
42
- options.body = JSON.stringify(options.body);
43
- headers['content-type'] = 'application/json; charset=utf-8';
44
- }
31
+ return headers;
32
+ };
45
33
 
46
- ongoing.set(url, magic(fetch(url, { ...options, headers }).then(res => {
34
+ const createFetch = (request, { after, dedupe, error, output }) => {
35
+ return fetch(request.url, request).then(async (res) => {
47
36
  // No longer ongoing at this point
48
- ongoing.delete(url);
37
+ if (dedupe) dedupe.clear();
38
+
39
+ // Need to manually create it to set some things like the proper response
40
+ let response = {
41
+ status: res.status,
42
+ statusText: res.statusText,
43
+ headers: {},
44
+ };
45
+ for (let key of res.headers.keys()) {
46
+ response.headers[key.toLowerCase()] = res.headers.get(key);
47
+ }
49
48
 
50
- // Everything is good, just keep going
49
+ // Oops, throw it
51
50
  if (!res.ok) {
52
- // Oops, throw it
53
- const error = new Error(res.statusText);
54
- error.response = res;
55
- return Promise.reject(error);
51
+ const err = new Error(res.statusText);
52
+ err.response = response;
53
+ return error(err);
56
54
  }
57
55
 
58
- const mem = new Map();
59
- return new Proxy(res, { get: (target, key) => {
60
- if (['then', 'catch', 'finally'].includes(key)) return res[key];
61
- return () => {
62
- if (!mem.get(key)) {
63
- mem.set(key, target[key]());
64
- }
65
- return mem.get(key);
66
- };
67
- }});
68
- })));
69
-
70
- return ongoing.get(url);
56
+ // Automatically parse the response
57
+ const isJson = res.headers.get("content-type").includes("application/json");
58
+ response.body = await (isJson ? res.json() : res.text());
59
+
60
+ // Hijack the response and modify it
61
+ if (after) {
62
+ response = after(response);
63
+ }
64
+
65
+ if (output === "body") {
66
+ return response.body;
67
+ } else {
68
+ return response;
69
+ }
70
+ });
71
71
  };
72
72
 
73
- fch.get = (url, options = {}) => fch(url, { ...options, method: 'get' });
74
- fch.post = (url, options = {}) => fch(url, { ...options, method: 'post' });
75
- fch.patch = (url, options = {}) => fch(url, { ...options, method: 'patch' });
76
- fch.put = (url, options = {}) => fch(url, { ...options, method: 'put' });
77
- fch.del = (url, options = {}) => fch(url, { ...options, method: 'delete' });
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();
130
+
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
+ });
138
+
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" });
145
+
146
+ // Default values
147
+ fch.method = "get";
148
+ fch.headers = {};
149
+
150
+ // Default options
151
+ fch.dedupe = true;
152
+ fch.output = "body";
153
+
154
+ // Interceptors
155
+ fch.before = (request) => request;
156
+ fch.after = (response) => response;
157
+ fch.error = (error) => Promise.reject(error);
78
158
 
79
159
  export default fch;
package/fetch.test.js CHANGED
@@ -1,140 +1,370 @@
1
- import fch from './fetch.min';
2
- global.fetch = require('jest-fetch-mock');
1
+ import fch from "./fetch.js";
2
+ import mock from "jest-fetch-mock";
3
3
 
4
- describe('fetch()', () => {
4
+ mock.enableMocks();
5
+
6
+ const jsonHeaders = { headers: { "Content-Type": "application/json" } };
7
+
8
+ describe("fetch()", () => {
5
9
  beforeEach(() => {
6
10
  fetch.resetMocks();
7
11
  });
8
12
 
9
- it('works', async () => {
10
- fetch.once(JSON.stringify({ secret: '12345' }));
11
- const res = await fch('https://google.com/').json();
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/");
12
49
 
13
- expect(res).toEqual({ secret: '12345' });
50
+ expect(data).toEqual({ secret: "12345" });
14
51
  expect(fetch.mock.calls.length).toEqual(1);
15
- expect(fetch.mock.calls[0][0]).toEqual('https://google.com/');
52
+ expect(fetch.mock.calls[0][0]).toEqual("https://google.com/");
16
53
  });
17
54
 
18
- it('can use the `fetch.get()` shorthand', async () => {
19
- fetch.once('get');
20
- expect(await fch.get('/').text()).toBe('get');
21
- expect(fetch.mock.calls[0][1].method).toEqual('get');
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");
22
59
  });
23
60
 
24
- it('can use the `fetch.patch()` shorthand', async () => {
25
- fetch.once('patch');
26
- expect(await fch.patch('/').text()).toBe('patch');
27
- expect(fetch.mock.calls[0][1].method).toEqual('patch');
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;
28
69
  });
29
70
 
30
- it('can use the `fetch.put()` shorthand', async () => {
31
- fetch.once('put');
32
- expect(await fch.put('/').text()).toBe('put');
33
- expect(fetch.mock.calls[0][1].method).toEqual('put');
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");
34
76
  });
35
77
 
36
- it('can use the `fetch.post()` shorthand', async () => {
37
- fetch.once('post');
38
- expect(await fch.post('/').text()).toBe('post');
39
- expect(fetch.mock.calls[0][1].method).toEqual('post');
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");
40
82
  });
41
83
 
42
- it('can use the `fetch.del()` shorthand', async () => {
43
- fetch.once('del');
44
- expect(await fch.del('/').text()).toBe('del');
45
- expect(fetch.mock.calls[0][1].method).toEqual('delete');
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");
46
88
  });
47
89
 
48
- it('ignores invalid options', async () => {
49
- fetch.once(JSON.stringify({ secret: '12345' }));
50
- const res = await fch('https://google.com/', 10).json();
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
+ });
51
101
 
52
- expect(res).toEqual({ secret: '12345' });
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" });
53
107
  expect(fetch.mock.calls.length).toEqual(1);
54
- expect(fetch.mock.calls[0][0]).toEqual('https://google.com/');
108
+ expect(fetch.mock.calls[0][0]).toEqual("https://google.com/");
55
109
  });
56
110
 
57
- it('will not overwrite if it is FormData', async () => {
58
- fetch.once(JSON.stringify({ secret: '12345' }));
59
- const res = await fch('/', { method: 'POST', body: new FormData() }).json();
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() });
60
114
 
61
- expect(res).toEqual({ secret: '12345' });
115
+ expect(res).toEqual({ secret: "12345" });
62
116
  expect(fetch.mock.calls.length).toEqual(1);
63
117
  const [url, opts] = fetch.mock.calls[0];
64
118
  expect(opts).toMatchObject({ body: expect.any(FormData) });
65
119
  });
66
120
 
67
- it('will not overwrite if content-type is set', async () => {
68
- fetch.once(JSON.stringify({ secret: '12345' }));
69
- const res = await fch('/', {
70
- method: 'POST',
71
- body: { a: 'b'},
72
- headers: { 'Content-Type': 'xxx' }
73
- }).json();
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
+ });
74
128
 
75
- expect(res).toEqual({ secret: '12345' });
129
+ expect(res).toEqual({ secret: "12345" });
76
130
  expect(fetch.mock.calls.length).toEqual(1);
77
131
  const [url, opts] = fetch.mock.calls[0];
78
- expect(url).toEqual('/');
132
+ expect(url).toEqual("/");
79
133
  expect(opts).toMatchObject({
80
- method: 'POST',
81
- body: { a: 'b' },
82
- headers: { 'content-type': 'xxx' }
134
+ method: "post",
135
+ body: { a: "b" },
136
+ headers: { "content-type": "xxx" },
83
137
  });
84
138
  });
85
139
 
86
- it('will send JSON', async () => {
87
- fetch.once(JSON.stringify({ secret: '12345' }));
88
- const res = await fch('/', { method: 'POST', body: { a: 'b'} }).json();
140
+ it("will send JSON", async () => {
141
+ fetch.once(JSON.stringify({ secret: "12345" }), jsonHeaders);
142
+ const res = await fch("/", { method: "POST", body: { a: "b" } });
89
143
 
90
- expect(res).toEqual({ secret: '12345' });
144
+ expect(res).toEqual({ secret: "12345" });
91
145
  expect(fetch.mock.calls.length).toEqual(1);
92
146
  const [url, opts] = fetch.mock.calls[0];
93
- expect(url).toEqual('/');
147
+ expect(url).toEqual("/");
94
148
  expect(opts).toMatchObject({
95
- method: 'POST',
149
+ method: "post",
96
150
  body: '{"a":"b"}',
97
- headers: { 'content-type': 'application/json; charset=utf-8' }
151
+ headers: { "content-type": "application/json; charset=utf-8" },
98
152
  });
99
153
  });
100
154
 
101
- it('can run in parallel', async () => {
102
- fetch.once(JSON.stringify('a')).once(JSON.stringify('b'));
103
- const res = await Promise.all(['/a', '/b'].map(url => fch(url).json()));
155
+ it("can run in parallel", async () => {
156
+ fetch.once("a").once("b");
157
+ const res = await Promise.all([fch("/a"), fch("/b")]);
104
158
 
105
- expect(res).toEqual(['a', 'b']);
159
+ expect(res).toEqual(["a", "b"]);
106
160
  expect(fetch.mock.calls.length).toEqual(2);
107
161
  });
108
162
 
109
- // There is a bug with node-fetch so this is difficult to test right now
110
- // https://github.com/bitinn/node-fetch/issues/386
111
- it.skip('will not trigger race conditions on get for the same url', async () => {
112
- fetch.once(JSON.stringify('a')).once(JSON.stringify('b'));
113
- const res = await Promise.all(['/', '/'].map(url => fch(url).json()));
163
+ it("can set `accepts` insensitively", async () => {
164
+ fetch.once(JSON.stringify({ secret: "12345" }), jsonHeaders);
165
+ const res = await fch("/", { headers: { Accepts: "text/xml" } });
114
166
 
115
- expect(res).toEqual(['a', 'a']);
116
- expect(fetch.mock.calls.length).toEqual(1);
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
+ });
117
178
  });
118
179
 
119
- it('can set `accepts` insensitively', async () => {
120
- fetch.once(JSON.stringify({ secret: '12345' }));
121
- const res = await fch('/', { headers: { 'Accepts': 'text/xml' } }).json();
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
+ });
122
186
 
123
- const [url, opts] = fetch.mock.calls[0];
124
- expect(opts.headers).toEqual({ 'accepts': 'text/xml' });
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;
125
203
  });
126
204
 
127
- it('can accept network rejections', async () => {
128
- fetch.mockResponseOnce(JSON.stringify("unauthorized"), { status: 401, ok: false });
129
- await expect(fch('/')).rejects.toMatchObject({
130
- message: 'Unauthorized'
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
+ },
131
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({});
132
220
  });
133
221
 
134
- it('can accept rejections', async () => {
135
- fetch.mockRejectOnce(new Error('fake error message'));
136
- await expect(fch('/')).rejects.toMatchObject({
137
- message: 'fake error message'
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
+ },
138
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);
139
369
  });
140
370
  });
package/package.json CHANGED
@@ -1,35 +1,40 @@
1
1
  {
2
2
  "name": "fch",
3
- "version": "0.3.2",
4
- "description": "Fetch interface with get queue, credentials, reject on http error and better promises",
5
- "main": "fetch.min.js",
6
- "scripts": {
7
- "start": "npm run watch # Start ~= Start dev",
8
- "build": "rollup fetch.js --name fch --output.format umd | uglifyjs -o fetch.min.js",
9
- "test": "npm run build && jest --coverage --detectOpenHandles",
10
- "watch": "nodemon --exec \"npm run build && npm test && npm run gzip\" --watch src --watch test --watch webpack.config.js --watch package.json",
11
- "gzip": "gzip -c fetch.min.js | wc -c && echo 'bytes' # Only for Unix"
12
- },
13
- "keywords": [],
3
+ "version": "2.0.0",
4
+ "description": "Fetch interface with better promises, deduplication, defaults, etc.",
5
+ "homepage": "https://github.com/franciscop/fetch",
6
+ "repository": "https://github.com/franciscop/fetch.git",
7
+ "bugs": "https://github.com/franciscop/fetch/issues",
8
+ "funding": "https://www.paypal.me/franciscopresencia/19",
14
9
  "author": "Francisco Presencia <public@francisco.io> (https://francisco.io/)",
15
10
  "license": "MIT",
16
- "repository": {
17
- "type": "git",
18
- "url": "git+https://github.com/franciscop/fetch.git"
11
+ "scripts": {
12
+ "start": "npm run watch # Start ~= Start dev",
13
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --detectOpenHandles",
14
+ "watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch --coverage --detectOpenHandles"
19
15
  },
20
- "bugs": {
21
- "url": "https://github.com/franciscop/fetch/issues"
16
+ "keywords": [
17
+ "fetch",
18
+ "axios",
19
+ "http",
20
+ "https",
21
+ "async",
22
+ "ajax"
23
+ ],
24
+ "main": "fetch.js",
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=18.0.0"
22
28
  },
23
29
  "dependencies": {
24
- "isomorphic-fetch": "^2.2.1",
25
- "magic-promises": "^1.2.3"
30
+ "swear": "^1.1.0"
26
31
  },
27
32
  "devDependencies": {
28
- "babel-core": "^6.26.0",
29
- "babel-jest": "^21.2.0",
30
- "babel-preset-env": "^1.6.1",
31
- "jest": "^23.5.0",
32
- "jest-fetch-mock": "^1.6.6",
33
- "uglify-es": "^3.3.9"
33
+ "jest": "^28.0.1",
34
+ "jest-fetch-mock": "^3.0.3"
35
+ },
36
+ "jest": {
37
+ "testEnvironment": "jest-environment-node",
38
+ "transform": {}
34
39
  }
35
40
  }
package/readme.md CHANGED
@@ -1,80 +1,256 @@
1
- # Fetch [![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.min.js.svg?compression=gzip)](https://github.com/franciscop/fetch/blob/master/fetch.min.js)
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
- `fetch()` greatly improved:
3
+ A library to make API calls easier. Similar to Axios, but tiny size and simpler API:
4
4
 
5
5
  ```js
6
- import { get } from 'fch';
7
- // Example; { "name": "Francisco" }
8
- const url = 'https://api.jsonbin.io/b/5bc69ae7716f9364f8c58651';
6
+ import api from "fch";
9
7
 
10
- (async () => {
11
- // Using magic-promises interface
12
- const name = await get(url).json().name;
13
- console.log(name); // "Francisco"
8
+ api.baseUrl = "https://pokeapi.co/";
14
9
 
15
- // Using plain-old promises
16
- const data = await get(url).then(res => res.json());
17
- console.log(data); // { name: "Francisco" }
18
- })();
10
+ const mew = await api("/pokemon/150");
11
+ console.log(mew);
19
12
  ```
20
13
 
14
+ `fch` is a better `fetch()`:
21
15
 
22
-
23
- ## Better `fetch()`
24
-
25
- - Isomorphic fetch() so it works the same in the server as the browser.
26
- - Automatic `JSON.stringify()` and `Content-Type: 'application/json'` for plain objects.
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.
27
19
  - Await/Async Promise interface works as you know and love.
28
- - Better error handling. `>= 400 and <= 100` will _reject_ the promise with an error instance. Can be caught as normal with `.catch()` or `try {} catch (error) {}`.
29
- - Advanced [magic-promises interface](https://github.com/franciscop/magic-promises) so you can concatenate operations easily.
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.
30
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://...";`.
24
+ - Interceptors: `before` (the request), `after` (the response) and `error` (it fails).
25
+ - Deduplicates parallel GET requests.
26
+ - 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
31
29
 
32
-
33
- ## Getting started
30
+ ## Getting Started
34
31
 
35
32
  Install it in your project:
36
33
 
37
- ```
34
+ ```bash
38
35
  npm install fch
39
36
  ```
40
37
 
41
38
  Then import it to be able to use it in your code:
42
39
 
43
40
  ```js
44
- const { get, post, ... } = require('fch'); // Old school
45
- import { get, post, ... } from 'fch'; // New wave
41
+ import api from 'fch';
42
+
43
+ const data = await api.get('/');
44
+ ```
45
+
46
+
47
+ ### Advanced promise interface
48
+
49
+ This library has a couple of aces up its sleeve. First, it [has a better Promise interface](https://www.npmjs.com/swear):
50
+
51
+ ```js
52
+ import api from "fch";
53
+
54
+ // You can keep performing actions like it was sync
55
+ const firstFriendName = await api.get("/friends")[0].name;
56
+ console.log(firstFriendName);
57
+ ```
58
+
59
+ ### Define shared options
60
+
61
+ You can also define values straight away:
62
+
63
+ ```js
64
+ import api from "fch";
65
+
66
+ api.baseUrl = "https://pokeapi.co/";
67
+
68
+ const mew = await api.get("/pokemon/150");
69
+ console.log(mew);
70
+ ```
71
+
72
+ If you prefer Axios' style of outputting the whole response, you can do:
73
+
74
+ ```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;
78
+
79
+ // Axios-like
80
+ api.output = "response";
81
+ const name = await api.get("/users/1").data.name;
46
82
  ```
47
83
 
48
- Alternatively, include it straight from the CDN for front-end:
84
+ ### Interceptors
85
+
86
+ You can also easily add the interceptors `before`, `after` and `error`:
87
+
88
+ ```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
+ ...
93
+ return req;
94
+ };
95
+
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
+ };
49
102
 
50
- ```html
51
- <script src="https://cdn.jsdelivr.net/npm/fch"></script>
52
- <script>
53
- const { get, post, ... } = fch;
54
- </script>
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;
108
+
109
+ // OR, resolve it as if it didn't fail
110
+ return err.response;
111
+
112
+ // OR, resolve it with a custom value
113
+ return { message: 'Request failed with a code ' + err.response.status };
114
+ };
55
115
  ```
56
116
 
57
117
 
118
+ ## Dedupe
119
+
120
+ 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
+
122
+ ```js
123
+ fetch.mockOnce("a").mockOnce("b");
124
+ const res = await Promise.all([fch("/a"), fch("/a")]);
58
125
 
59
- ## Examples
126
+ // Reuses the first response if two are launched in parallel
127
+ expect(res).toEqual(["a", "a"]);
128
+ ```
60
129
 
61
- Posting some data as JSON and reading the JSON response:
130
+ You can disable this by setting either the global `fch.dedupe` option to `false` or by passing an option per request:
62
131
 
63
132
  ```js
64
- // With this library fetch
65
- import { post } from 'fch';
66
- const data = await post('/url', { body: { a: 'b' } }).json();
67
- console.log(data);
133
+ // Globally set it for all calls
134
+ fch.dedupe = true; // [DEFAULT] Dedupes GET requests
135
+ fch.dedupe = false; // All fetch() calls trigger a network call
136
+
137
+ // Set it on a per-call basis
138
+ fch('/a', { dedupe: true }); // [DEFAULT] Dedupes GET requests
139
+ fch('/a', { dedupe: false }) // All fetch() calls trigger a network call
68
140
  ```
69
141
 
142
+ > We do not support deduping other methods right now besides `GET` right now
143
+
144
+ Note that opting out of deduping a request will _also_ make that request not be reusable, see this test for details:
145
+
70
146
  ```js
71
- // Native example, much longer and cumbersome:
72
- const res = await fetch('/url', {
73
- method: 'POST',
74
- body: JSON.stringify({ a: 'b' }),
75
- headers: { 'content-type': 'application/json; charset=utf-8' }
147
+ it("can opt out locally", async () => {
148
+ fetch.once("a").once("b").once("c");
149
+ const res = await Promise.all([
150
+ fch("/a"),
151
+ fch("/a", { dedupe: false }),
152
+ fch("/a"), // Reuses the 1st response, not the 2nd one
153
+ ]);
154
+
155
+ expect(res).toEqual(["a", "b", "a"]);
156
+ expect(fetch.mock.calls.length).toEqual(2);
76
157
  });
77
- if (!res.ok) throw new Error(res.statusText);
78
- const data = await res.json();
79
- console.log(data);
158
+ ```
159
+
160
+
161
+ ## How to
162
+
163
+ ### Stop errors from throwing
164
+
165
+ While you can handle this on a per-request basis, if you want to overwrite the global behavior you can just do:
166
+
167
+ ```js
168
+ import fch from 'fch';
169
+ fch.error = error => error.response;
170
+
171
+ const res = fch('/notfound');
172
+ expect(res.status).toBe(404);
173
+ ```
174
+
175
+ Just that, then when there's an error it'll just return as usual, e.g.:
176
+
177
+ ```js
178
+ import fch from 'fch';
179
+ fch.error = error => error.response;
180
+
181
+ const res = fch('/notfound');
182
+ expect(res.status).toBe(404);
183
+ ```
184
+
185
+ ### Return the full response
186
+
187
+ By default a successful request will just return the data. However this one is configurable on a global level:
188
+
189
+ ```js
190
+ import fch from 'fch';
191
+ fch.output = 'response'; // Valid values are 'body' (default) or 'response'
192
+
193
+ const res = await fch('/hello');
194
+ ```
195
+
196
+ Or on a per-request level:
197
+
198
+ ```js
199
+ import fch from 'fch';
200
+
201
+ // Valid values are 'body' (default) or 'response'
202
+ const res = await fch('/hello', { output: 'response' });
203
+
204
+ // Does not affect others
205
+ const body = await fch('/hello');
206
+ ```
207
+
208
+
209
+ ### Set a base URL
210
+
211
+ There's a configuration parameter for that:
212
+
213
+ ```js
214
+ import fch from 'fch';
215
+ fch.baseUrl = 'https://api.filemon.io/';
216
+
217
+ // Calls "https://api.filemon.io/blabla/"
218
+ const body = await fch.get('/blabla/');
219
+ ```
220
+
221
+
222
+ ### Set the authorization headers
223
+
224
+ You can set that globally as a header:
225
+
226
+ ```js
227
+ import fch from 'fch';
228
+ fch.headers.Authorization = 'bearer abc';
229
+
230
+ const me = await fch('/users/me');
231
+ ```
232
+
233
+ Globally on a per-request basis, for example if you take the value from localStorage:
234
+
235
+ ```js
236
+ import fch from 'fch';
237
+
238
+ // All the requests will add the Authorization header when the token is
239
+ // in localStorage
240
+ fch.before = req => {
241
+ if (localStorage.get('token')) {
242
+ req.headers.Authorization = 'bearer ' + localStorage.get('token');
243
+ }
244
+ return req;
245
+ };
246
+
247
+ const me = await fch('/users/me');
248
+ ```
249
+
250
+ Or on a per-request basis, though we wouldn't recommend this:
251
+
252
+ ```js
253
+ import fch from 'fch';
254
+
255
+ const me = await fch('/users/me', { headers: { Authorization: 'bearer abc' } });
80
256
  ```
package/.babelrc DELETED
@@ -1,3 +0,0 @@
1
- {
2
- "presets": ["env"]
3
- }
package/fetch.min.js DELETED
@@ -1 +0,0 @@
1
- (function(global,factory){typeof exports==="object"&&typeof module!=="undefined"?module.exports=factory():typeof define==="function"&&define.amd?define(factory):global.fch=factory()})(this,function(){"use strict";const resolve=async value=>{value=await value;if(Array.isArray(value)){return await Promise.all(value.map(resolve))}return value};const reject=message=>Promise.reject(new Error(message));const regexpCallback=cb=>cb instanceof RegExp?cb.test.bind(cb):cb;const extend=(cb,self)=>async(value,i,all)=>({value:value,extra:await regexpCallback(cb).call(self,value,i,all)});const extraUp=({extra:extra})=>extra;const valueUp=({value:value})=>value;const extendArray={filter:(obj,cb,self)=>resolve(obj.map(extend(cb,self))).then(arr=>arr.filter(extraUp).map(valueUp))};const getter=obj=>(target,key)=>{if(key==="then")return(...args)=>{return resolve(obj).then(...args)};if(key==="catch")return(...args)=>{return root(resolve(obj).catch(...args))};return func(resolve(obj).then(obj=>{if(typeof key==="symbol")return obj[key];for(let k in extendArray){if(Array.isArray(obj)&&key===k){return func((cb,self)=>extendArray[k](obj,cb,self))}}if(obj[key]&&obj[key].bind){return func(obj[key].bind(obj))}return func(obj[key])}))};const applier=obj=>(target,self,args)=>{return func(resolve(obj).then(obj=>{if(typeof obj!=="function"){return reject(`\n You tried to call the non-function "${JSON.stringify(obj)}" (${typeof obj}).\n This can happen in several situations like these:\n - await magic(['a'])(); // <= wrong\n - await magic(['a']).map(a => a)(a => a); // <= wrong\n `)}return obj(...args)}))};const root=obj=>new Proxy({},{get:getter(obj)});const func=obj=>new Proxy(()=>{},{get:getter(obj),apply:applier(obj)});if(typeof require!=="undefined"){require("isomorphic-fetch")}const ongoing=new Map;const hasPlainBody=options=>{if(options.headers["content-type"])return;if(typeof options.body!=="object")return;if(options.body instanceof FormData)return;return true};const fch=(url,options={})=>{options={method:"get",headers:{},...typeof options==="object"?options:{}};if(options.method.toLowerCase()==="get"){if(ongoing.get(url))return ongoing.get(url)}const headers=options.headers;for(let key in headers){const value=headers[key];delete headers[key];headers[key.toLowerCase()]=value}if(hasPlainBody(options)){options.body=JSON.stringify(options.body);headers["content-type"]="application/json; charset=utf-8"}ongoing.set(url,root(fetch(url,{...options,headers:headers}).then(res=>{ongoing.delete(url);if(!res.ok){const error=new Error(res.statusText);error.response=res;return Promise.reject(error)}const mem=new Map;return new Proxy(res,{get:(target,key)=>{if(["then","catch","finally"].includes(key))return res[key];return()=>{if(!mem.get(key)){mem.set(key,target[key]())}return mem.get(key)}}})})));return ongoing.get(url)};fch.get=((url,options={})=>fch(url,{...options,method:"get"}));fch.post=((url,options={})=>fch(url,{...options,method:"post"}));fch.patch=((url,options={})=>fch(url,{...options,method:"patch"}));fch.put=((url,options={})=>fch(url,{...options,method:"put"}));fch.del=((url,options={})=>fch(url,{...options,method:"delete"}));return fch});