1 | # Flighty ✈️
|
2 |
|
3 | [![NPM Version](https://img.shields.io/npm/v/flighty.svg?branch=master)](https://www.npmjs.com/package/flighty)
|
4 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
|
5 | [![dependencies Status](https://david-dm.org/akmjenkins/flighty/status.svg)](https://david-dm.org/akmjenkins/flighty)
|
6 | [![codecov](https://codecov.io/gh/akmjenkins/flighty/branch/master/graph/badge.svg)](https://codecov.io/gh/akmjenkins/flighty)
|
7 | [![Build Status](https://travis-ci.org/akmjenkins/flighty.svg?branch=master)](https://travis-ci.org/akmjenkins/flighty)
|
8 |
|
9 | Simple (and tiny) fetch wrapper with nifty features such as intercepts, ***easy*** aborts, and retries, for everywhere.
|
10 |
|
11 | ## Motivation
|
12 |
|
13 | Yet another fetch wrapping library? Well, various fetch-wrapping libraries have some of the above features, but few (if any) have them all.
|
14 |
|
15 | More importantly, almost all fetch wrapping libraries investigated include their polyfills right in the main packages (or don't include polyfill's at all requiring you to find out what you're missing). Flighty has an opt-in polyfill for [fetch](https://www.npmjs.com/package/cross-fetch) (and tiny polyfills for [AbortController](https://www.npmjs.com/package/abortcontroller-polyfill) and [ES6 promise](https://github.com/taylorhakes/promise-polyfill), because you'll likely need those, too if you don't have fetch), so you don't have to bloat your code if you don't absolutely need to.
|
16 |
|
17 | So, Flighty is BYOF - bring your own fetch - if you use it as is, but you can always opt-in to a fetch-polyfill if you aren't sure what environment your code will ultimately be running in:
|
18 |
|
19 | ```js
|
20 | // without polyfill
|
21 | import Flighty from "flighty";
|
22 |
|
23 | // with polyfill
|
24 | import Flighty from "flighty/fetch";
|
25 | ```
|
26 |
|
27 | Boom, now you've got a full featured fetch-wrapping library everywhere.
|
28 |
|
29 | If you're using Flighty as a standalone library in the browser, you can relax, it weighs just **5kb** minified and gzipped and less than 9kb if you're supporting old-IE and want to include a fetch (and abortcontroller and promise) polyfill.
|
30 |
|
31 | ## Use it in unit testing
|
32 |
|
33 | Keep it short - don't mock Flighty. It'd be over-complicated and unnecessary to mock it's features - so just mock the fetch and let Flighty do it's thing in your tests. I recommend [jest-fetch-mock](https://www.npmjs.com/package/jest-fetch-mock).
|
34 |
|
35 | ## Features
|
36 |
|
37 | **Drop in replacement** for fetch. This works:
|
38 |
|
39 | ```js
|
40 | const res = await fetch('/somepath',{some options});
|
41 |
|
42 | const api = new Flighty({some options});
|
43 | const res = await api.get('/somepath',{some options});
|
44 | ```
|
45 |
|
46 | This works because Flighty returns the standard [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) but with the addition of the **flighty object**.
|
47 |
|
48 | The drop in replacement makes this library relatively simple to add/remove from your codebase. If you keep your use of the flighty object on the response limited to interceptors and refactoring Flighty into/out of your codebase becomes a breeze.
|
49 |
|
50 | ### flighty object
|
51 |
|
52 | ```js
|
53 | res.flighty = {
|
54 | method, // the method you called flighty with - e.g. get, post, put
|
55 | retryCount, // the number of times this request has been retried
|
56 | json, // what you'd normally get from await res.json()
|
57 | text, // what you'd normally get from await res.text()
|
58 | // the values the original flighty request was called with
|
59 | call: {
|
60 | path,
|
61 | options,
|
62 | extra
|
63 | },
|
64 | // retry method - calls the same request you made the first time again - hello JWT 401s
|
65 | retry: async ()
|
66 | }
|
67 |
|
68 | ```
|
69 |
|
70 | ### Abort
|
71 |
|
72 | Flighty comes with two abort APIs. `abortAll()` which cancels all ongoing requests and cancellation via an `abortToken` (similar to [axios cancellation token](https://github.com/axios/axios#cancellation) but easier!).
|
73 |
|
74 | Aborting Fetch requests comes with a hairy, verbose [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) API that requires you to construct, pass in a signal to the fetch, and then abort the controller like so:
|
75 |
|
76 | ```js
|
77 | const controller = new AbortController();
|
78 | const req = fetch('/',{controller.signal})
|
79 |
|
80 | controller.abort();
|
81 |
|
82 | try {
|
83 | const res = await req;
|
84 | } catch(err) {
|
85 | // AbortError!
|
86 | }
|
87 |
|
88 | ```
|
89 |
|
90 | Flighty allows you to pass in a token (any Symbol) and then call `abort(token)` to cancel the request.
|
91 |
|
92 | ```js
|
93 | const req = flighty.get('/',{abortToken:"my token"});
|
94 | api.abort("my token");
|
95 |
|
96 | try {
|
97 | const res = await req;
|
98 | } catch(err) {
|
99 | // AbortError!
|
100 | }
|
101 | ```
|
102 |
|
103 | Tokens, like AbortController signals, can be used to abort multiple requests. Let Flighty automate the creation and management of AbortController's for your requests. Just pass in a token and your request is then easy to abort.
|
104 |
|
105 | ```js
|
106 | const abortToken = "some token";
|
107 | const reqOne = flighty('/pathone',{abortToken})
|
108 | const reqTwo = flighty('/pathtwo',{abortToken})
|
109 | const reqThree = flighty('/paththree',{abortToken})
|
110 |
|
111 | // cancel them all!
|
112 | flighty.abort(abortToken);
|
113 |
|
114 | ```
|
115 |
|
116 | ### Interceptors
|
117 |
|
118 | Drop in replacement for anybody using Frisbee interceptors or [fetch-intercept](https://www.npmjs.com/package/fetch-intercept), but with a couple of extra things:
|
119 |
|
120 | ```js
|
121 | const interceptor = {
|
122 | request: (path,options,extra,retryCount) => {
|
123 |
|
124 | // extra is an immutable object of the data passed in when
|
125 | // creating the request - e.g. flighty('/mypath',{myFetchOptions},{someExtraData})
|
126 | // it doesn't get changed between interceptors if you modify it.
|
127 |
|
128 | // retryCount is the number of times this request has been
|
129 | // retried via res.flighty.retry() or by using the retry parameters
|
130 | // and is also immutable between interceptors
|
131 |
|
132 |
|
133 | return [path,options];
|
134 | }
|
135 | }
|
136 | ```
|
137 |
|
138 | Here's an example interceptor object:
|
139 | ```js
|
140 | {
|
141 | request: function (path, options, extra, retryCount) {
|
142 | // remember - extra and retryCount are immutable and will
|
143 | // be passed to each interceptor the same
|
144 | return [path, options];
|
145 | },
|
146 | requestError: function (err) {
|
147 | // Handle an error occurred in the request method
|
148 | return Promise.reject(err);
|
149 | },
|
150 | response: function (response) {
|
151 | // do something with response (or res.flighty!)
|
152 | return response;
|
153 | },
|
154 | responseError: function (err) {
|
155 | // Handle error occurred in the last ran requestInterceptor, or the fetch itself
|
156 | return Promise.reject(err);
|
157 | }
|
158 | ```
|
159 |
|
160 | ### Retries
|
161 |
|
162 | Flighty implements the same retry parameters found in [fetch-retry](https://www.npmjs.com/package/fetch-retry) but it adds two important features:
|
163 |
|
164 | 1) Doesn't continue to retry if the request was aborted via an AbortController signal (or token)
|
165 | 2) Add's an optional asynchronous `retryFn` that will be executed between retries
|
166 |
|
167 | #### Retry API
|
168 |
|
169 | * `retries` - the maximum number of retries to perform on a fetch (default 0 - do not retry)
|
170 |
|
171 | * `retryDelay` - a timeout in ms to wait between retries (default 1000ms)
|
172 |
|
173 | * `retryOn` - an array of HTTP status codes that you want to retry (default you only retry if there was a network error)
|
174 |
|
175 | * `retryFn` - a function that gets called in between the failure and the retry. This function is `await`ed so you can do some asynchronous work before the retry. Combine this with retryOn:[401] and you've got yourself a(nother) recipe to refresh JWTs (more at the end of this README):
|
176 |
|
177 | ```js
|
178 | res = await api.get('/path-requiring-authentication',{
|
179 | retries:1,
|
180 | retryOn:[401],
|
181 | retryFn:() => api.get('/path_to_refresh_you_token')
|
182 | })
|
183 | ```
|
184 |
|
185 | The Flighty object also has a retry method to make it simply to retry a request:
|
186 |
|
187 | ```js
|
188 | let res;
|
189 | const api = new Flighty();
|
190 | res = await api.get('/');
|
191 |
|
192 | if(!res.ok && res.flighty.retryCount === 0) {
|
193 | // try it one more time...
|
194 | res = res.flighty.retry();
|
195 | }
|
196 | ```
|
197 |
|
198 | ---
|
199 |
|
200 | ## API
|
201 |
|
202 | * `Flighty` - accepts an `options` object, with the following accepted options:
|
203 |
|
204 | * `baseURI` - the default URI use to prefix all your paths
|
205 |
|
206 | * `headers` - an object containing default headers to send with every request
|
207 |
|
208 | * `arrayFormat` - how to stringify array in passed body. See [qs][qs-url] for available formats
|
209 |
|
210 | Upon being invoked, `Flighty` has the following methods
|
211 |
|
212 | * `jwt(token)` - Set your Bearer Authorization header via this method. Pass in a token and Flighty will add the header for you, pass in something false-y and Flighty won't automatically add an auth header (in case you want to do it yourself)
|
213 |
|
214 | * `auth(username,password)` - Set your Basic Authorization header via this method. Pass in a username and password and Flighty will add the header `Authorization Basic bas64encoded username and password` for you, pass in something false-y and Flighty won't automatically add an auth header (in case you want to do it yourself)
|
215 |
|
216 | * `abort` - method that accepts an abortToken to abort specific requests.
|
217 |
|
218 | * `abortAll` - aborts all in-progress requests controlled by this instance.
|
219 |
|
220 | * HTTP wrapper methods (e.g. get, post, put, delete, etc) require a `path` string, and accept two optional plain object arguments - `options` and `extra`
|
221 |
|
222 | * Accepted method arguments:
|
223 |
|
224 | * `path` **required** - the path for the HTTP request (e.g. `/v1/login`, will be prefixed with the value of `baseURI` if set)
|
225 |
|
226 | * `options` _optional_ - everything you'd pass into fetch's [init](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) plus optional `abortToken` and retry parameters: `retries`,`retryFn`,`retryDelay`,`retryFn`
|
227 |
|
228 | * `extra` _optional_ object - sometimes you have some meta data about a request that you may want interceptors or other various listeners to know about. Whatever you pass in here will come out the other end attached the to `res.flighty.call` object and will also be passed along to all interceptors along the way
|
229 |
|
230 | * List of available HTTP methods:
|
231 |
|
232 | * `api.get(path, options, extra)` - GET
|
233 | * `api.head(path, options, extra)` - HEAD
|
234 | * `api.post(path, options, extra)` - POST
|
235 | * `api.put(path, options, extra)` - PUT
|
236 | * `api.del(path, options, extra)` - DELETE
|
237 | * `api.options(path, options, extra)` - OPTIONS
|
238 | * `api.patch(path, options, extra)` - PATCH
|
239 |
|
240 | * `registerInterceptor` - method that accepts an `interceptor` and calls it on every fetch. Returns a function that allows you to remove it:
|
241 |
|
242 | ```js
|
243 | const api = new Flighty({});
|
244 | const undo = api.registerInterceptor(someInterceptor);
|
245 | await api.get('/'); // your interceptor will run
|
246 | undo(); // your interceptor is gone!
|
247 | ```
|
248 |
|
249 | * `removeInterceptor` - method that accepts an reference to interceptor and removes it
|
250 |
|
251 |
|
252 | * `clearInterceptors` - removes all interceptors.
|
253 |
|
254 | For convenience, Flighty has exposed an `interceptor` property that has the same API as frisbee to register and unregister interceptors.
|
255 |
|
256 | ---
|
257 |
|
258 | ## Recipes
|
259 |
|
260 | ### Throw if not 2xx recipe
|
261 |
|
262 | Don't know about you, but I found it annoying that I always had to check `res.ok` to handle my error conditions - why not just throw if the response isn't ok? Interceptor!
|
263 |
|
264 | Before:
|
265 | ```js
|
266 |
|
267 | const res = await fetch('/');
|
268 | if(res.ok) {
|
269 | // do some good stuff
|
270 | } else {
|
271 | // do some bad stuff
|
272 | }
|
273 |
|
274 | ```
|
275 |
|
276 | After:
|
277 | ```js
|
278 | const api = new Flighty();
|
279 | api.registerInterceptor({
|
280 | response:res {
|
281 | if(!res.ok) {
|
282 | throw res;
|
283 | }
|
284 | return res;
|
285 | }
|
286 | });
|
287 |
|
288 | // Now all my responses throw if I get a non-2xx response
|
289 | try {
|
290 | const res = await api.get('/');
|
291 | } catch(e) {
|
292 | // response returned non-2xx
|
293 | }
|
294 | ```
|
295 |
|
296 | ### JWT Recipe with retry() and Interceptors
|
297 |
|
298 | ```js
|
299 | const api = new Flighty();
|
300 |
|
301 | const interceptor = {
|
302 | request:(path,options) {
|
303 | api.jwt(path === REFRESH_ENDPOINT ? myRefreshToken : myAccessToken);
|
304 | return [path,options]
|
305 | },
|
306 | response:async res {
|
307 |
|
308 | // our only job when hitting the login path is to set the tokens locally
|
309 | if(path === LOGIN_ENDPOINT) {
|
310 | if(res.ok) {
|
311 | // store your access and refresh tokens
|
312 | setTokensLocally()
|
313 | }
|
314 |
|
315 | return res;
|
316 | }
|
317 |
|
318 | // if we get a 401 and we're not trying to refresh and this is our first retry
|
319 | if (res.status === 401 && path !== REFRESH_TOKEN_PATH && res.flighty.retryCount === 0) {
|
320 | try {
|
321 | await requestToRefreshYourToken();
|
322 | return response.flighty.retry()
|
323 | } catch(e) {
|
324 | // log the user out
|
325 | }
|
326 | }
|
327 |
|
328 | return res;
|
329 | }
|
330 | }
|
331 |
|
332 | ```
|
333 |
|
334 | ### Alternate JWT Recipe and Interceptors
|
335 | ```js
|
336 | const api = new Flighty();
|
337 |
|
338 | // same request interceptor as before
|
339 | const interceptor = {
|
340 | request:(path,options) {
|
341 | api.jwt(path === REFRESH_ENDPOINT ? myRefreshToken : myAccessToken);
|
342 | return [path,options]
|
343 | }
|
344 | }
|
345 |
|
346 | const authenticatedApiRequest = (path,options,extra) => {
|
347 | return api(
|
348 | path,
|
349 | {
|
350 | ...options,
|
351 | // retry the request 1 time
|
352 | retries:1,
|
353 | // if a 401 or network error is received
|
354 | retryOn:[401],
|
355 | // and request a new token in between
|
356 | retryFn:() => api.get(REFRESH_TOKEN_ENDPOINT)
|
357 | }
|
358 | extra)
|
359 | };
|
360 |
|
361 | const myRequest = authenticatedApiRequest('/some-path-requiring-authentication');
|
362 |
|
363 | ```
|