├── .all-contributorsrc ├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── __test_utils__ ├── .eslintrc ├── buildUrl.js ├── jestUtils.js ├── thwackBase.js └── thwackEvents.js ├── __tests__ ├── .eslintrc ├── buildUrl.js ├── compatParser.js ├── computeContentType.test.js ├── computeParser.js ├── computeStreamParser.test.js ├── deepSpreadOptions.test.js ├── jsdom.test.js ├── node.test.js └── sortByEntry.test.js ├── node-test.js ├── node ├── index.js └── package.json ├── node10 ├── index.js └── package.json ├── package-lock.json ├── package.json ├── src ├── core │ ├── Thwack.js │ ├── ThwackErrors │ │ └── ThwackResponseError.js │ ├── ThwackEvents │ │ ├── ThwackDataEvent.js │ │ ├── ThwackErrorEvent.js │ │ ├── ThwackEvent.js │ │ ├── ThwackRequestEvent.js │ │ ├── ThwackResponseBaseEvent.js │ │ └── ThwackResponseEvent.js │ ├── ThwackResponse.js │ ├── defaults.js │ ├── events.js │ ├── fetcher.js │ ├── index.js │ ├── request.js │ ├── returnResponse.js │ └── utils │ │ ├── buildUrl │ │ ├── defaultParamsSerializer.js │ │ ├── index.js │ │ ├── joinSearch.js │ │ ├── sortByEntry.js │ │ └── substituteParamsInPath.js │ │ ├── combineOptions.js │ │ ├── compatParser.js │ │ ├── computeContentType.js │ │ ├── computeParser.js │ │ ├── deepSpreadOptions.js │ │ ├── defaultValidateStatus.js │ │ └── resolveOptionsFromArgs.js ├── default │ └── index.js └── index.js └── types └── index.d.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "donavon", 10 | "name": "Donavon West", 11 | "avatar_url": "https://avatars3.githubusercontent.com/u/887639?v=4", 12 | "profile": "https://donavon.com", 13 | "contributions": [ 14 | "infra", 15 | "test", 16 | "example", 17 | "ideas", 18 | "maintenance", 19 | "review", 20 | "tool", 21 | "code" 22 | ] 23 | }, 24 | { 25 | "login": "jetpacmonkey", 26 | "name": "Jeremy Tice", 27 | "avatar_url": "https://avatars0.githubusercontent.com/u/1740479?v=4", 28 | "profile": "http://jeremytice.com", 29 | "contributions": [ 30 | "doc" 31 | ] 32 | }, 33 | { 34 | "login": "yurm04", 35 | "name": "Yuraima Estevez", 36 | "avatar_url": "https://avatars0.githubusercontent.com/u/4642404?v=4", 37 | "profile": "https://github.com/yurm04", 38 | "contributions": [ 39 | "doc" 40 | ] 41 | }, 42 | { 43 | "login": "bargar", 44 | "name": "Jeremy Bargar", 45 | "avatar_url": "https://avatars2.githubusercontent.com/u/1666818?v=4", 46 | "profile": "https://github.com/bargar", 47 | "contributions": [ 48 | "doc" 49 | ] 50 | }, 51 | { 52 | "login": "brookescarlett", 53 | "name": "Brooke Scarlett Yalof", 54 | "avatar_url": "https://avatars1.githubusercontent.com/u/26016393?v=4", 55 | "profile": "https://github.com/brookescarlett", 56 | "contributions": [ 57 | "doc" 58 | ] 59 | }, 60 | { 61 | "login": "karlhorky", 62 | "name": "Karl Horky", 63 | "avatar_url": "https://avatars2.githubusercontent.com/u/1935696?v=4", 64 | "profile": "https://twitter.com/karlhorky", 65 | "contributions": [ 66 | "doc" 67 | ] 68 | }, 69 | { 70 | "login": "koji", 71 | "name": "Koji", 72 | "avatar_url": "https://avatars0.githubusercontent.com/u/474225?v=4", 73 | "profile": "https://kojikanao.netlify.com/", 74 | "contributions": [ 75 | "doc", 76 | "code" 77 | ] 78 | }, 79 | { 80 | "login": "tomByrer", 81 | "name": "Tom Byrer", 82 | "avatar_url": "https://avatars2.githubusercontent.com/u/1308419?v=4", 83 | "profile": "https://github.com/tomByrer", 84 | "contributions": [ 85 | "doc" 86 | ] 87 | }, 88 | { 89 | "login": "iansu", 90 | "name": "Ian Sutherland", 91 | "avatar_url": "https://avatars2.githubusercontent.com/u/433725?v=4", 92 | "profile": "https://iansutherland.ca/", 93 | "contributions": [ 94 | "code" 95 | ] 96 | }, 97 | { 98 | "login": "blakeyoder", 99 | "name": "Blake Yoder", 100 | "avatar_url": "https://avatars0.githubusercontent.com/u/5393338?v=4", 101 | "profile": "https:///www.blakeyoder.com", 102 | "contributions": [ 103 | "code" 104 | ] 105 | }, 106 | { 107 | "login": "ryhinchey", 108 | "name": "Ryan Hinchey", 109 | "avatar_url": "https://avatars0.githubusercontent.com/u/3943764?v=4", 110 | "profile": "http://www.ryanhinchey.co", 111 | "contributions": [ 112 | "doc" 113 | ] 114 | }, 115 | { 116 | "login": "MiroDojkic", 117 | "name": "Miro Dojkic", 118 | "avatar_url": "https://avatars2.githubusercontent.com/u/9119913?v=4", 119 | "profile": "https://github.com/MiroDojkic", 120 | "contributions": [ 121 | "code" 122 | ] 123 | }, 124 | { 125 | "login": "santicevic", 126 | "name": "santicevic", 127 | "avatar_url": "https://avatars3.githubusercontent.com/u/45316219?v=4", 128 | "profile": "https://github.com/santicevic", 129 | "contributions": [ 130 | "doc", 131 | "code" 132 | ] 133 | } 134 | ], 135 | "contributorsPerLine": 7, 136 | "projectName": "thwack", 137 | "projectOwner": "donavon", 138 | "repoType": "github", 139 | "repoHost": "https://github.com", 140 | "skipCi": true 141 | } 142 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { "node": "current" } 7 | } 8 | ] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_size = 2 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parser": "babel-eslint", 4 | "rules": { 5 | "consistent-return": 0, 6 | "import/prefer-default-export": 0, 7 | "indent": 0, 8 | "object-curly-newline": "off", 9 | "operator-linebreak": 0, 10 | "comma-dangle": 0, 11 | "implicit-arrow-linebreak": "off", 12 | "no-confusing-arrow": "off", 13 | "no-sparse-arrays": "off", 14 | "lines-between-class-members": "off", 15 | "no-plusplus": "off", 16 | "func-names": "off", 17 | "no-underscore-dangle": "off", 18 | "no-void": "off", 19 | "no-return-assign": "off", 20 | "no-throw-literal": "off", 21 | "no-console": "error", 22 | "comma-style": "off" 23 | }, 24 | "env": { 25 | "browser": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | .DS_Store 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | # next.js build output 62 | .next 63 | 64 | # lib 65 | lib/ 66 | dist/ 67 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "arrowParens": "always", 11 | "fluid": false 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | os: linux 4 | 5 | node_js: 6 | - '10' 7 | - '12' 8 | 9 | jobs: 10 | include: 11 | - stage: test 12 | script: npm t 13 | - stage: publish to npm 14 | deploy: 15 | skip_cleanup: true 16 | provider: npm 17 | email: $NPM_EMAIL 18 | api_token: $NPM_TOKEN 19 | on: 20 | tags: true 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present Donavon West 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Thwack logo 3 |

4 | 5 |

6 | Thwack. A tiny modern data fetching solution 7 |

8 | 9 | [![npm version](https://badge.fury.io/js/thwack.svg)](https://badge.fury.io/js/thwack) 10 | [![Build Status](https://travis-ci.com/donavon/thwack.svg?branch=master)](https://travis-ci.com/donavon/thwack) 11 | [![All Contributors](https://img.shields.io/badge/all_contributors-11-orange.svg?style=flat-square)](#contributors-) 12 | [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Check%20out%20Thwack%21%20A%20tiny%20modern%20data%20fetching%20solution.&url=https://github.com/donavon/thwack&via=donavon&hashtags=javascript) 13 | [![Github stars](https://img.shields.io/badge/%E2%AD%90%EF%B8%8F-it%20on%20GitHub-blue)](https://github.com/donavon/thwack/stargazers) 14 | 15 |

16 | Thwack logo 17 | TL;DR 18 |

19 | 20 | Thwack is: 21 | 22 | - 💻 Modern — Thwack is an HTTP data fetching solution built for modern browsers 23 | - 🔎 Small — Thwack is only ~1.5k gzipped 24 | - 👩‍🏫 Smarter — Built with modern JavaScript 25 | - 😘 Familiar — Thwack uses an Axios-like interface 26 | - 🅰️ Typed — Easier inclusion for TypeScript projects 27 | - ✨ Support for NodeJS 10 and 12 28 | - 📱 Support for React Native 29 | 30 | > This README is a work in progress. You can also ask me a question [on Twitter](https://twitter.com/donavon). 31 | 32 |

33 | Thwack logo 34 | Installation 35 |

36 | 37 | ```bash 38 | $ npm i thwack 39 | ``` 40 | 41 | or 42 | 43 | ```bash 44 | $ yarn add thwack 45 | ``` 46 | 47 |

48 | Thwack logo 49 | Why Thwack over Axios? 50 |

51 | 52 | Axios was great when it was released back in the day. It gave us a promise based wrapper around `XMLHttpRequest`, which was difficult to use. But that was a long time ago and times have changed — browsers have gotten smarter. Maybe it's time for your data fetching solution to keep up? 53 | 54 | Thwack was built from the ground up with modern browsers in mind. Because of this, it doesn't have the baggage that Axios has. Axios weighs in at around ~5k gzipped. Thwack, on the other hand, is a slender ~1.5k. 55 | 56 | They support the same API, but there are some differences — mainly around `options` — but for the most part, they should be able to be used interchangeably for many applications. 57 | 58 | ~~Thwack doesn't try to solve every problem, like Axios does, but instead provides the solution for 98% of what users _really_ need. This is what gives Thwack its feather-light footprint.~~ 59 | 60 | Scratch that. Thwack provides the same level of power as Axios with a much smaller footprint. And Thwack's promise based event system is easier to use. 61 | 62 |

63 | Thwack logo 64 | Methods 65 |

66 | 67 | The following methods are available on all Thwack instances. 68 | 69 | ### Data fetching 70 | 71 | - `thwack(url: string [,options: ThwackOptions]): Promise;` 72 | 73 | - `thwack.request(options: ThwackOptions): Promise` 74 | - `thwack.get(url: string [,options: ThwackOptions]): Promise;` 75 | 76 | - `thwack.delete(url: string [,options: ThwackOptions]): Promise;` 77 | 78 | - `thwack.head(url: string [,options: ThwackOptions]): Promise;` 79 | 80 | - `thwack.post(url: string, data:any [,options: ThwackOptions]): Promise;` 81 | 82 | - `thwack.put(url: string, data:any [,options: ThwackOptions]): Promise;` 83 | 84 | - `thwack.patch(url: string, data:any [,options: ThwackOptions]): Promise;` 85 | 86 | ### Utility 87 | 88 | - `thwack.create(options: ThwackOptions): ThwackInstance;` 89 | 90 | The `create` method creates (da!) a new child instance of the current Thwack instance with the given `options`. 91 | 92 | - `thwack.getUri(options: ThwackOptions): string;` 93 | 94 | Thwacks URL resolution is [RFC-3986](https://tools.ietf.org/html/rfc3986#appendix-B) compliant. Axios's is not. It's powered by [`@thwack/resolve`](https://www.npmjs.com/package/@thwack/resolve). 95 | 96 | ### Event listeners 97 | 98 | Thwack supports the following event types: `request`, `response`, `data`, and `error`. 99 | 100 | For more information on Thwack's event system, see [Thwack events](#thwack-events) below. 101 | 102 | - `thwack.addEventListener(type: string, callback: (event:ThwackEvent) => Promise ): void;` 103 | 104 | - `thwack.removeEventListener(type: string, callback: (event:ThwackEvent) => Promise ): void;` 105 | 106 |

107 | Thwack logo 108 | Static Methods 109 |

110 | 111 | ### Concurrency 112 | 113 | Thwack has the following helper functions for making simultaneous requests. They are mostly for Axios compatibility. See the "[How To](#how-to)" section below for example usage. 114 | 115 | - `thwack.all(Promise[])` 116 | 117 | - `thwack.spread(callback)` 118 | 119 |

120 | Thwack logo 121 | ThwackOptions 122 |

123 | 124 | The `options` argument has the following properties. 125 | 126 | ### `url` 127 | 128 | This is either a fully qualified or a relative URL. 129 | 130 | ### `baseURL` 131 | 132 | Defines a base URL that will be used to build a fully qualified URL from `url` above. Must be an absolute URL or `undefined`. Defaults to the `origin` + `pathname` of the current web page if running in a browser or `undefined` on Node or React Native. 133 | 134 | For example, if you did this: 135 | 136 | ```js 137 | thwack('foo', { 138 | baseURL: 'http://example.com', 139 | }); 140 | ``` 141 | 142 | the fetched URL will be: 143 | 144 | ``` 145 | http://example.com/foo 146 | ``` 147 | 148 | ### `method` 149 | 150 | A string containing one of the following HTTP methods: `get`, `post`, `put`, `patch`, `delete`, or `head`. 151 | 152 | ### `data` 153 | 154 | If the `method` is `post`, `put`, or `patch`, this is the data that will be used to build the request body. 155 | 156 | ### `headers` 157 | 158 | This is where you can place any optional HTTP request headers. Any header you specify here are merged in with any instance header values. 159 | 160 | For example, if we set a Thwack instance like this: 161 | 162 | ```js 163 | const api = thwack.create({ 164 | headers: { 165 | 'x-app-name': 'My Awesome App', 166 | }, 167 | }); 168 | ``` 169 | 170 | Then later, when you use the instance, you make a call like this: 171 | 172 | ```js 173 | const { data } = await api.get('foo', { 174 | headers: { 175 | 'some-other-header': 'My Awesome App', 176 | }, 177 | }); 178 | ``` 179 | 180 | The headers that would be sent are: 181 | 182 | ``` 183 | x-app-name: My Awesome App 184 | some-other-header': 'My Awesome App' 185 | 186 | ``` 187 | 188 | ### `defaults` 189 | 190 | This allows you to read/set the default options for this instance and, in effect, any child instances. 191 | 192 | Example: 193 | 194 | ```js 195 | thwack.defaults.baseURL = 'https://example.com/api'; 196 | ``` 197 | 198 | For an instance, `defaults` is the same object passed to `create`. For example, the following will output "https://example.com/api". 199 | 200 | ```js 201 | const instance = thwack.create({ 202 | baseURL: 'https://example.com/api', 203 | }); 204 | console.log(instance.defaults.baseURL); 205 | ``` 206 | 207 | Also note that setting `defaults` on an instance (or even passing `options`) to an instance does NOT effect the parent. So for the following example, `thwack.defaults.baseURL` will still be "https://api1.example.net/". 208 | 209 | ```js 210 | thwack.defaults.baseURL = 'https://api1.example.net/'; 211 | const instance = thwack.create(); 212 | instance.defaults.baseURL = 'https://example.com/api'; 213 | 214 | console.log(thwack.defaults.baseURL); 215 | ``` 216 | 217 | ### `params` 218 | 219 | This is an optional object that contains the key/value pairs that will be used to build the fetch URL. Is there are any `:key` segments of the `baseURL` or the `url`, they will be replaced with the value of the matching key. For example, if you did this: 220 | 221 | ```js 222 | thwack('orders/:id', { 223 | params: { id: 123 }, 224 | baseURL: 'http://example.com', 225 | }); 226 | ``` 227 | 228 | the fetched URL will be: 229 | 230 | ``` 231 | http://example.com/orders/123 232 | ``` 233 | 234 | If you don't specify a `:name`, or there are more `param`s than there are `:name`s, then the remaining key/values will be set as search parameters (i.e. `?key=value`). 235 | 236 | ### `maxDepth` 237 | 238 | The maximum level of recursive requests that can be made in a callbck before Thwack throws an error. This is used to prevent an event callback from causing a recursive loop, This if it issues another `request` without proper safeguards in place. Default = 3. 239 | 240 | ### `responseType` 241 | 242 | By default, Thwack will automatically determine how to decode the response data based on the value of the response header `content-type`. However, if the server responds with an incorrect value, you can override the parser by setting `responseType`. Valid values are `arraybuffer`, `document` (i.e. `formdata`), `json`, `text`, `stream`, and `blob`. Defaults to automatic. 243 | 244 | What is returned by Thwack is determined by the following table. The "fetch method" column is what is resolved in `data`. If you do not specify a `responseType`, Thwack will automatically determine the fetch method based on `content-type` and the `responseParserMap` table (see below). 245 | 246 | | Content-Type | `responseType` | `fetch` method | 247 | | :-------------------: | :------------: | :------------------------------------------------------: | 248 | | `application/json` | `json` | `response.json()` | 249 | | `multipart/form-data` | `formdata` | `response.formData()` | 250 | | `text/event-stream` | `stream` | passes back `response.body` as `data` without processing | 251 | | | `blob` | `response.blob()` | 252 | | | `arraybuffer` | `response.arrayBuffer()` | 253 | | `*/*` | `text` | `response.text()` | 254 | 255 | > Note: `stream` is currently unsupported in React Native due to [#27741](https://github.com/facebook/react-native/issues/27741) 256 | 257 | ### `responseParserMap` 258 | 259 | Another useful way to determine which response parser to use is with `responseParserMap`. It allows you to set up a mapping between the resulting `content-type` from the response header and the parser type. 260 | 261 | Thwack uses the following map as the default, which allows `json` and `formdata` decoding. If there are no matches, the response parser defaults to `text`. You may specify a default by setting the special `*/*` key. 262 | 263 | ```json 264 | { 265 | "application/json": "json", 266 | "multipart/form-data": "formdata", 267 | "*/*": "text" 268 | }; 269 | ``` 270 | 271 | Any value you specify in `responseParserMap` is merged into the default map. That is to say that you can override the defaults and/or add new values. 272 | 273 | Let's say, for example, you would like to download an image into a blob. You could set the `baseURL` to your API endpoint and a `responseParserMap` that will download images of any type as blobs, but will still allow `json` downloads (as this is the default for a `content-type: application/json`). 274 | 275 | ```js 276 | import thwack from 'thwack'; 277 | 278 | thwack.defaults.responseParserMap = { 'image/*': 'blob' }; 279 | ``` 280 | 281 | Any URL that you download with an `image/*` content type (e.g. `image/jpeg`, `image/png`, etc) will be parsed with the `blob` parser. 282 | 283 | ```js 284 | const getBlobUrl = async (url) => { 285 | const blob = (await thwack.get(url)).data; 286 | const objectURL = URL.createObjectURL(blob); 287 | return objectURL; 288 | }; 289 | ``` 290 | 291 | See this example running on [CodeSandbox](https://codesandbox.io/s/load-image-as-blob-410uq). 292 | 293 | > Note that you can use this technique for other things other than images. 294 | 295 | As you can see, using `responseParserMap` is a great way to eliminate the need to set `responseType` for different Thwack calls. 296 | 297 | ### `validateStatus` 298 | 299 | This optional function is used to determine what status codes Thwack uses to return a promise or throw. It is passed the response `status`. If this function returns truthy, the promise is resolved, else the promise is rejected. 300 | 301 | The default function throws for any status not in the 2xx (i.e. 200-299) 302 | 303 | ### `paramsSerializer` 304 | 305 | This is an optional function which Thwack will call to serialize the `params`. For example, given an object `{a:1, b:2, foo: 'bar'}`, it should serialize to the string `a=1&b=2&foo=bar`. 306 | 307 | For most people, the [default serializer](https://github.com/donavon/thwack/blob/master/src/core/utils/buildUrl/defaultParamSerializer.js) should work just fine. This is mainly for edge case and Axios compatibility. 308 | 309 | > Note that the default serializer alphabetizes the parameters, which is a good practice to follow. If, however, this doesn't work for your situation, you can roll your own serializer. 310 | 311 | ### `resolver` 312 | 313 | This is a function that you can provide to override the [default resolver](https://github.com/donavon/thwack-resolve) behavior. `resolver` takes two arguments: a `url` and a `baseURL` which must be undefined, or an absolute URL. There should be little reason for you to to replace the resolver, but this is your escape hatch in case you need to. 314 | 315 |

316 | Thwack logo 317 | ThwackResponse 318 |

319 | 320 | ### `status` 321 | 322 | A `number` representing the 3 digit [HTTP status codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes) that was received. 323 | 324 | - 1xx - Informational response 325 | - 2xx - Success 326 | - 3xx - Redirection 327 | - 4xx - Client errors 328 | - 5xx - Server errors 329 | 330 | ### `ok` 331 | 332 | A `boolean` set to true is the `status` code in the 2xx range (i.e. a success). This value is not effected by `validateStatus`. 333 | 334 | ### `statusText` 335 | 336 | A `string` representing the text of the `status` code. You should use the `status` code (or `ok`) in any program logic. 337 | 338 | ### `headers` 339 | 340 | A key/value object with the returned HTTP headers. Any duplicate headers will be concatenated into a single header separated by semicolons. 341 | 342 | ### `data` 343 | 344 | This will hold the returned body of the HTTP response after it has been streamed and converted. The only exception is if you used the `responseType` of `stream`, in which case `data` is set directly to the `body` element. 345 | 346 | If a `ThwackResponseError` was thrown, `data` will be the plain text representation of the response body. 347 | 348 | ### `options` 349 | 350 | The complete `options` object that processed the request. This `options` will be fully merged with any parent instance(s), as well as with `defaults`. 351 | 352 | ### `response` 353 | 354 | The complete HTTP `Response` object as returned by `fetch` or the `response` from a synthetic event callback. 355 | 356 |

357 | Thwack logo 358 | ThwackResponseError 359 |

360 | 361 | If the response from a Thwack request results in a non-2xx `status` code (e.g. 404 Not Found) then a `ThwackResponseError` is thrown. 362 | 363 | > Note: It is possible that other types of errors could be thrown (e.g. a bad event listener callback), so it is a best practice to interrogate the caught error to see if it is of type `ThwackResponseError`. 364 | 365 | ```js 366 | try { 367 | const { data } = await thwack.get(someUrl) 368 | } catch (ex) { 369 | if (ex instanceof thwack.ThwackResponseError) 370 | const { status, message } = ex; 371 | console.log(`Thwack status ${status}: ${message}`); 372 | } else { 373 | throw ex; // If not, rethrow the error 374 | } 375 | } 376 | ``` 377 | 378 | A `ThwackResponseError` has all of the properties of a normal JavaScript `Error` plus a `thwackResponse` property with the same properties as a success status. 379 | 380 |

381 | Thwack logo 382 | Instances 383 |

384 | 385 | Instances created in Thwack are based on the parent instance. A parent's default options pass down through the instances. This can come in handy for setting up options in the parent that can affect the children, such as `baseURL`, 386 | 387 | Inversely, parents can use `addEventListener` to monitor their children (see the [How to log every API call](#how-to-log-every-api-call) below for an example of this). 388 | 389 | flow char 390 | 391 |

392 | Thwack logo 393 | Thwack events 394 |

395 | 396 | Combined with instances, the Thwack event system is what makes Thwack extremely powerful. With it, you can listen for different events. 397 | 398 | Here is the event flow for all events. AS you can see, it is possible for your code to get into an endless loop, should your callback blindly issue a `request()` without checking to see if it's already done so, so take caution. 399 | 400 | ![thwack events](https://user-images.githubusercontent.com/887639/79867660-aee3ce00-83ac-11ea-94fd-4078c1a36244.png) 401 | 402 | ### The `request` event 403 | 404 | Whenever any part of the application calls one of the data fetching methods, a `request` event is fired. Any listeners will get a `ThwackRequestEvent` object which has the `options` of the call in `event.options`. These event listeners can do something as simple as ([log the event](#log-every-request)) or as complicated as preventing the request and returning a response with ([mock data](#return-mock-data)) 405 | 406 | ```js 407 | // callback will be called for every request made in Thwack 408 | thwack.addEventListener('request', callback); 409 | ``` 410 | 411 | > Note that callbacks can be `async` allowing you to defer Thwack so that you might, for example, go out and fetch data a different URL before proceeding. 412 | 413 | ### The `response` event 414 | 415 | The event is fired _after_ the HTTP headers are received, but _before_ the body is streamed and parsed. Listeners will receive a `ThwackResponseEvent` object with a `thwackResponse` key set to the response. 416 | 417 | ### The `data` event 418 | 419 | The event is fired after the body is streamed and parsed. It is fired only if the fetch returned a 2xx status code. Listeners will receive a `ThwackDataEvent` object with a `thwackResponse` key set to the response. 420 | 421 | ### The `error` event 422 | 423 | The event is fired after the body is streamed and parsed. It is fired if the fetch returned a non-2xx status code. Listeners will receive a `ThwackErrorEvent` object with a `thwackResponse` key set to the response. 424 | 425 |

426 | Thwack logo 427 | NodeJS 428 |

429 | 430 | Thwack will work on NodeJS, but requires a polyfill for `window.fetch`. Luckily, there is a wonderful polyfill called [`node-fetch`](https://github.com/node-fetch/node-fetch) that you can use. 431 | 432 | If you are using NodeJS version 10, you will also need a polyfill for `Array#flat` and `Object#fromEntries`. NodeJS version 11+ has these methods and does not require a polyfill. 433 | 434 | You can either provide these polyfills yourself, or use one of the following convenience imports instead. If you are running NodeJS 11+, use: 435 | 436 | ```js 437 | import thwack from 'thwack/node'; // NodeJS version 12+ 438 | ``` 439 | 440 | If you are running on NodeJS 10, use: 441 | 442 | ```js 443 | import thwack from 'thwack/node10'; // NodeJS version 10 444 | ``` 445 | 446 | If you wish to provide these polyfills yourself, then to use Thwack, you must import from `thwack/core` and set `fetch` as the default for `fetch` as so. 447 | 448 | ```js 449 | import thwack from 'thwack/code'; 450 | thwack.defaults.fetch = global.fetch; 451 | ``` 452 | 453 | This should be done in your app startup code, usually `index.js`. 454 | 455 | > Note: The `responseType` of `blob` is not supported on NodeJS. 456 | 457 |

458 | Thwack logo 459 | React Native 460 |

461 | 462 | Thwack is compatible with React Native and needs no additional polyfills. See below for a sample app written in React Native. 463 | 464 | > Note: React Native does not support `stream` due to [#27741](https://github.com/facebook/react-native/issues/27741) 465 | 466 |

467 | Thwack logo 468 | How to 469 |

470 | 471 | ### Multiple concurrent requests 472 | 473 | You can use `thwack.all()` and `thwack.spread()` to make simultaneous requests. Data is then presented to your callback as one array. 474 | 475 | Here we display information for two GitHub users. 476 | 477 | ```js 478 | function displayGitHubUsers() { 479 | return thwack 480 | .all([ 481 | thwack.get('https://api.github.com/users/donavon'), 482 | thwack.get('https://api.github.com/users/revelcw'), 483 | ]) 484 | .then( 485 | thwack.spread((...results) => { 486 | const output = results 487 | .map( 488 | ({ data }) => `${data.login} has ${data.public_repos} public repos` 489 | ) 490 | .join('\n'); 491 | console.log(output); 492 | }) 493 | ); 494 | } 495 | ``` 496 | 497 | Note the these are simply helper functions. If you are using `async`/`await` you can write this without the Thwack helpers using `Promise.all`. 498 | 499 | ```js 500 | async function displayGitHubUsers() { 501 | const results = await Promise.all([ 502 | thwack.get('https://api.github.com/users/donavon'), 503 | thwack.get('https://api.github.com/users/revelcw'), 504 | ]); 505 | const output = results 506 | .map(({ data }) => `${data.login} has ${data.public_repos} public repos`) 507 | .join('\n'); 508 | console.log(output); 509 | } 510 | ``` 511 | 512 | You can see this running live in the [CodeSandbox](https://codesandbox.io/s/thwack-allspread-demo-zx2nt?file=/src/index.js:140-642). 513 | 514 | (Demo inspired by [this blob post](https://blog.logrocket.com/how-to-make-http-requests-like-a-pro-with-axios/) on axios/fetch) 515 | 516 | ### Cancelling a request 517 | 518 | Use an `AbortController` to cancel requests by passing its `signal` in the `thwack` options. 519 | 520 | In the browser, you can use the built-in [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). 521 | 522 | ```js 523 | import thwack from 'thwack'; 524 | 525 | const controller = new AbortController(); 526 | const { signal } = controller; 527 | 528 | thwack(url, { signal }).then(handleResponse).catch(handleError); 529 | 530 | controller.abort(); 531 | ``` 532 | 533 | In NodeJS, you can use something like [abort-controller](https://www.npmjs.com/package/abort-controller). 534 | 535 | ```js 536 | import thwack from 'thwack'; 537 | import AbortController from 'abort-controller'; 538 | 539 | const controller = new AbortController(); 540 | const { signal } = controller; 541 | 542 | thwack(url, { signal }).then(handleResponse).catch(handleError); 543 | 544 | controller.abort(); 545 | ``` 546 | 547 | In case you want to perform some action on request cancellation, you can listen to the `abort` event on `signal` too: 548 | 549 | ```js 550 | signal.addEventListener('abort', handleAbort); 551 | ``` 552 | 553 | ### Log every request 554 | 555 | Add an `addEventListener('request', callback)` and log each request to the console. 556 | 557 | ```js 558 | import thwack from 'thwack'; 559 | 560 | thwack.addEventListener('request', (event) => { 561 | console.log('hitting URL', thwack.getUri(event.options)); 562 | }); 563 | ``` 564 | 565 | If you are using React, here is a Hook that you can "use" in your App that will accomplish the same thing. 566 | 567 | ```js 568 | import { useEffect } from 'react'; 569 | import thwack from 'thwack'; 570 | 571 | const logUrl = (event) => { 572 | const { options } = event; 573 | const fullyQualifiedUrl = thwack.getUri(options); 574 | console.log(`hitting ${fullyQualifiedUrl}`); 575 | }; 576 | 577 | const useThwackLogger = () => { 578 | useEffect(() => { 579 | thwack.addEventListener('request', logUrl); 580 | return () => thwack.removeEventListener('request', logUrl); 581 | }, []); 582 | }; 583 | 584 | export default useThwackLogger; 585 | ``` 586 | 587 | Here is a code snippet on how to use it. 588 | 589 | ```js 590 | const App = () ={ 591 | useThwackLogger() 592 | 593 | return ( 594 |
595 | ... 596 |
597 | ) 598 | } 599 | ``` 600 | 601 | ### Return mock data 602 | 603 | Let's say you have an app that has made a request for some user data. If the app is hitting a specific URL (say `users`) and querying for a particular user ID (say `123`), you would like to prevent the request from hitting the server and instead mock the results. 604 | 605 | The `status` in the `ThwackResponse` defaults to 200, so unless you need to mock a non-OK response, you only need to return `data`. 606 | 607 | ```js 608 | thwack.addEventListener('request', async (event) => { 609 | const { options } = event; 610 | if (options.url === 'users' && options.params.id === 123) { 611 | // tells Thwack to use the returned value instead of handling the event itself 612 | event.preventDefault(); 613 | 614 | // stop other listeners (if any) from further processing 615 | event.stopPropagation(); 616 | 617 | // because we called `preventDefault` above, the caller's request 618 | // will be resolved to this `ThwackResponse` (defaults to status of 200 and ok) 619 | return new thwack.ThwackResponse( 620 | { 621 | data: { 622 | name: 'Fake Username', 623 | email: 'fakeuser@example.com', 624 | }, 625 | }, 626 | options 627 | ); 628 | } 629 | }); 630 | ``` 631 | 632 | ### Convert DTO to Model 633 | 634 | Often it is desirable to convert a DTO (Data Transfer Object) into something easier to consume by the client. In this example below, we convert a complex DTO into `firstName`, `lastName`, `avatar`, and `email`. Other data elements that are returned from the API call, but not needed by the applications, are ignored. 635 | 636 | You can see an example of DTO conversion, logging, and returning fake data in this sample app. 637 | 638 |
639 | Mickey Mouse sample app 640 |
641 | 642 | You can [view the source code](https://codesandbox.io/s/sharp-chatelet-mje3w?file=/src/App.js) on CodeSandbox. 643 | 644 | ### Load an Image as a Blob 645 | 646 | In this example, we have a React Hook that loads an image as a Blob URL. It caches the URL to Blob URL mapping in session storage. Once loaded, any refresh of the page will instantaneously load the image from Blob URL. 647 | 648 | ```js 649 | const useBlobUrl = (imageUrl) => { 650 | const [objectURL, setObjectURL] = useState(''); 651 | 652 | useEffect(() => { 653 | let url = sessionStorage.getItem(imageUrl); 654 | 655 | async function fetchData() { 656 | if (!url) { 657 | const { data } = await thwack.get(imageUrl, { 658 | responseType: 'blob', 659 | }); 660 | url = URL.createObjectURL(data); 661 | sessionStorage.setItem(imageUrl, url); 662 | } 663 | setObjectURL(url); 664 | } 665 | 666 | fetchData(); 667 | }, [imageUrl]); 668 | 669 | return objectURL; 670 | }; 671 | ``` 672 | 673 | See this example on [CodeSandbox](https://codesandbox.io/s/thwack-demo-load-image-as-blob-x0rnl?file=/src/ImageBlob/useBlobUrl.js) 674 | 675 | ### Selective routing 676 | 677 | Right now you have a REST endpoint at `https://api.example.com`. Suppose you've published a new REST endpoint to a different URL and would like to start slowly routing 2% of network traffic to these new servers. 678 | 679 | > Note: normally this would be handled by your load balancer on the back-end. It's shown here for demonstration purposes only. 680 | 681 | We could accomplish this by replacing `options.url` in the request event listener as follows. 682 | 683 | ```js 684 | thwack.addEventListener('request', (event) => { 685 | if (Math.random() >= 0.02) { 686 | return; 687 | } 688 | 689 | // the code will be executed for approximately 2% of the requests 690 | const { options } = event; 691 | const oldUrl = thwack.getUri(options); 692 | const url = new URL('', oldUrl); 693 | url.origin = 'https://api2.example.com'; // point the origin at the new servers 694 | const newUrl = url.href; // Get the fully qualified URL 695 | event.options = { ...event.options, url: newUrl }; // replace `options`] 696 | }); 697 | ``` 698 | 699 | ### React Native sample app 700 | 701 | Along with `use-thwack`, writing a data fetching app for React Native couldn't be easier. 702 | 703 | View the entire app [running on Expo](https://snack.expo.io/@donavon/random-dog). 704 | 705 | good dog app 706 | 707 |

708 | Thwack logo 709 | Credits 710 |

711 | 712 | Thwack is **heavily** inspired by the [Axios](https://github.com/Axios/Axios). Thanks [Matt](https://twitter.com/mzabriskie)! 713 | 714 |

715 | Thwack logo 716 | License 717 |

718 | 719 | Licensed under [MIT](LICENSE) 720 | 721 |

722 | Thwack logo 723 | Contributors ✨ 724 |

725 | 726 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 727 | 728 | 729 | 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | 748 | 749 |

Donavon West

🚇 ⚠️ 💡 🤔 🚧 👀 🔧 💻

Jeremy Tice

📖

Yuraima Estevez

📖

Jeremy Bargar

📖

Brooke Scarlett Yalof

📖

Karl Horky

📖

Koji

📖 💻

Tom Byrer

📖

Ian Sutherland

💻

Blake Yoder

💻

Ryan Hinchey

📖

Miro Dojkic

💻

santicevic

📖 💻
750 | 751 | 752 | 753 | 754 | 755 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 756 | -------------------------------------------------------------------------------- /__test_utils__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest"], 3 | "rules": { 4 | "jest/no-disabled-tests": "warn", 5 | "jest/no-focused-tests": "error", 6 | "jest/no-identical-title": "error", 7 | "jest/prefer-to-have-length": "warn", 8 | "jest/valid-expect": "error", 9 | "implicit-arrow-linebreak": 0, 10 | "function-paren-newline": "off" 11 | }, 12 | "env": { 13 | "jest": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /__test_utils__/buildUrl.js: -------------------------------------------------------------------------------- 1 | import buildUrl from '../src/core/utils/buildUrl'; 2 | 3 | const baseCases = [ 4 | ['bar', 'http://ex.co/foo', {}], 5 | ['bar', 'https://ex.co/foo', {}], 6 | ['bar', 'http://ex.co/foo/', {}], 7 | ['bar', 'http://ex.co/foo?a=b', {}], 8 | ['bar?x=y', 'http://ex.co/foo', {}], 9 | ['bar?x=y', 'http://ex.co/foo?a=b', {}], 10 | 11 | ['bar?x=123', 'http://ex.co/foo', {}], 12 | ['bar', 'http://ex.co/foo', { x: 123 }], 13 | ['bar/:x', 'http://ex.co/foo/', { x: 123 }], 14 | ['bar?x=123', 'http://ex.co/foo', { a: 'a', c: 'c' }], 15 | ['bar?x=123', 'http://ex.co/foo', { c: 'c', a: 'a' }], 16 | ['http://a.ex.co/', 'http://b.ex.co', {}], 17 | ['http://a.ex.co', 'http://b.ex.co/', {}], 18 | ['http://a.ex.co', 'http://b.ex.co', {}], 19 | 20 | ['/bar', 'http://ex.co/foo/', {}], 21 | ['/', 'http://ex.co/foo/', {}], 22 | 23 | // url missing protocol 24 | ['//x.com', 'http://ex.co/', {}], 25 | ['//x.com', 'https://ex.co/', {}], 26 | ['//x.com/foo', 'https://ex.co/', {}], 27 | ['//x.com/foo', 'https://ex.co/bar', {}], 28 | ]; 29 | 30 | const run = (cases, impl, title) => { 31 | const combinedCases = [...baseCases, ...cases]; 32 | 33 | describe(title, () => { 34 | combinedCases.forEach(([url, baseURL, params]) => { 35 | try { 36 | const expectedResult = new URL(url, baseURL).href; 37 | it(`returns ${expectedResult} given buildUrl("${url}", "${baseURL}")`, () => { 38 | const result = buildUrl({ buildURL: impl, url, baseURL, params }); 39 | expect(result).toBe(expectedResult); 40 | }); 41 | } catch (ex) { 42 | expect(() => 43 | buildUrl({ buildURL: impl, url, baseURL, params }) 44 | ).toThrow(); 45 | } 46 | }); 47 | it('defaults URL to an empty string', () => { 48 | const result = buildUrl({ 49 | buildURL: impl, 50 | baseURL: 'http://ex.co/', 51 | }); 52 | expect(result).toBe('http://ex.co/'); 53 | }); 54 | it('allows params to be missing', () => { 55 | const result = buildUrl({ 56 | buildURL: impl, 57 | url: 'foo', 58 | baseURL: 'http://ex.co/', 59 | }); 60 | expect(result).toBe('http://ex.co/foo'); 61 | }); 62 | it('throws if relative url and base is not fully qualified', () => { 63 | expect(() => 64 | buildUrl({ buildURL: impl, url: 'foo', baseURL: 'bar' }) 65 | ).toThrow(); 66 | }); 67 | it('throws if relative url and base is undefined', () => { 68 | expect(() => buildUrl({ buildURL: impl, url: 'foo' })).toThrow(); 69 | }); 70 | it('accepts a base of undefined if url is fully qualified', () => { 71 | const result = buildUrl({ 72 | buildURL: impl, 73 | url: 'http://ex.co/', 74 | }); 75 | expect(result).toBe('http://ex.co/'); 76 | }); 77 | }); 78 | }; 79 | 80 | export default run; 81 | -------------------------------------------------------------------------------- /__test_utils__/jestUtils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /* istanbul ignore file */ 3 | /* eslint-disable no-undef */ 4 | import { defaultOptions } from '../src/core/defaults'; 5 | import deepSpreadOptions from '../src/core/utils/deepSpreadOptions'; 6 | import 'core-js/features/array/flat'; 7 | import 'core-js/features/object/from-entries'; 8 | 9 | export const { headers: defaultHeaders } = defaultOptions; 10 | export const fooBarData = { foo: 'bar' }; 11 | export const defaultFetchOptions = { 12 | ...fooBarData, 13 | headers: defaultHeaders, 14 | }; 15 | 16 | export const mergeDefaults = (options, defaults = defaultOptions) => 17 | deepSpreadOptions(defaults, options); 18 | 19 | export const createMockFetch = (options = {}) => { 20 | const { 21 | status = 200, 22 | ok = true, 23 | statusText = 'ok', 24 | contentType = 'application/json', 25 | jsonResult = { foo: 'bar' }, 26 | textResult = 'text', 27 | body = '(stream)', 28 | } = options; 29 | 30 | const response = { 31 | status, 32 | statusText, 33 | ok, 34 | headers: { 35 | entries: () => [['content-type', contentType]], 36 | }, 37 | json: async () => jsonResult, 38 | text: async () => textResult, 39 | body, 40 | }; 41 | const fetch = jest.fn(() => Promise.resolve(response)); 42 | fetch.response = response; 43 | return fetch; 44 | }; 45 | -------------------------------------------------------------------------------- /__test_utils__/thwackBase.js: -------------------------------------------------------------------------------- 1 | import thwack from '../src/core'; 2 | import ThwackResponse from '../src/core/ThwackResponse'; 3 | import { createMockFetch, fooBarData, defaultHeaders } from './jestUtils'; 4 | 5 | const run = async () => { 6 | describe('Thwack base', () => { 7 | it('returns a promise', () => { 8 | const fetch = createMockFetch(); 9 | const fn = thwack('http://foo.com/', { fetch }); 10 | expect(fn instanceof Promise).toBe(true); 11 | }); 12 | it('thwack(options) resolves with a ThwackResponse object', async () => { 13 | const fetch = createMockFetch(); 14 | const options = { 15 | url: 'http://foo.com/', 16 | fetch, 17 | foo: 'bar', 18 | }; 19 | const data = await thwack(options); 20 | expect(data instanceof ThwackResponse).toBe(true); 21 | }); 22 | it('thwack(url, options) resolves with a ThwackResponse object', async () => { 23 | const fetch = createMockFetch(); 24 | const options = { 25 | url: 'http://foo.com/', 26 | fetch, 27 | foo: 'bar', 28 | }; 29 | const data = await thwack(options); 30 | expect(fetch).toBeCalledWith( 31 | 'http://foo.com/', 32 | expect.objectContaining({ foo: 'bar' }) 33 | ); 34 | expect(data instanceof ThwackResponse).toBe(true); 35 | }); 36 | it('can be passed a fully qualified URL', async () => { 37 | const fetch = createMockFetch(); 38 | await thwack('http://donavon.com/', { fetch }); 39 | expect(fetch).toBeCalledWith( 40 | 'http://donavon.com/', 41 | expect.objectContaining({ 42 | headers: defaultHeaders, 43 | }) 44 | ); 45 | }); 46 | it('can be passed a URL with params in the URL (ex: "/order/:id")', async () => { 47 | const fetch = createMockFetch(); 48 | await thwack('http://donavon.com/foo/:id', { 49 | fetch, 50 | params: { id: 123 }, 51 | }); 52 | expect(fetch).toBeCalledWith( 53 | 'http://donavon.com/foo/123', 54 | expect.objectContaining({ 55 | headers: defaultHeaders, 56 | }) 57 | ); 58 | }); 59 | it('can be passed a URL with params which build them as a search query', async () => { 60 | const fetch = createMockFetch(); 61 | await thwack('http://donavon.com/foo', { fetch, params: { id: 123 } }); 62 | expect(fetch).toBeCalledWith( 63 | 'http://donavon.com/foo?id=123', 64 | expect.objectContaining({ 65 | headers: defaultHeaders, 66 | }) 67 | ); 68 | }); 69 | it('can be passed a URL with an existing search query (ex: "/order?a=456")', async () => { 70 | const fetch = createMockFetch(); 71 | await thwack('http://donavon.com/foo?a=456', { 72 | fetch, 73 | params: { id: 123 }, 74 | }); 75 | expect(fetch).toBeCalledWith( 76 | 'http://donavon.com/foo?a=456&id=123', 77 | expect.objectContaining({ 78 | headers: defaultHeaders, 79 | }) 80 | ); 81 | }); 82 | it('sorts param keys when building search query', async () => { 83 | const fetch = createMockFetch(); 84 | await thwack('http://donavon.com/foo?foo=foo', { 85 | fetch, 86 | params: { b: 'b', c: 'c', a: 'a' }, 87 | }); 88 | expect(fetch).toBeCalledWith( 89 | 'http://donavon.com/foo?foo=foo&a=a&b=b&c=c', 90 | expect.objectContaining({ 91 | headers: defaultHeaders, 92 | }) 93 | ); 94 | }); 95 | it('defaults to POST is data is present and method not specified', async () => { 96 | const fetch = createMockFetch(); 97 | await thwack('http://foo.com/', { 98 | method: 'foo', 99 | fetch, 100 | data: fooBarData, 101 | }); 102 | expect(fetch).toBeCalledWith( 103 | 'http://foo.com/', 104 | expect.objectContaining({ 105 | headers: { 'content-type': 'application/json', ...defaultHeaders }, 106 | body: JSON.stringify(fooBarData), 107 | method: 'foo', 108 | }) 109 | ); 110 | await thwack('http://foo.com/', { fetch, data: fooBarData }); 111 | expect(fetch).toBeCalledWith( 112 | 'http://foo.com/', 113 | expect.objectContaining({ 114 | headers: { 'content-type': 'application/json', ...defaultHeaders }, 115 | body: JSON.stringify(fooBarData), 116 | method: 'POST', 117 | }) 118 | ); 119 | }); 120 | it('encode only if content-type is application/json', async () => { 121 | const fetch = createMockFetch(); 122 | await thwack('http://foo.com/', { 123 | fetch, 124 | headers: { 'content-type': 'text/plain' }, 125 | data: 'this is plain text', 126 | }); 127 | expect(fetch).toBeCalledWith( 128 | 'http://foo.com/', 129 | expect.objectContaining({ 130 | headers: { 'content-type': 'text/plain', ...defaultHeaders }, 131 | body: 'this is plain text', 132 | method: 'POST', 133 | }) 134 | ); 135 | await thwack('http://foo.com/', { 136 | fetch, 137 | headers: { 'content-type': 'application/json' }, 138 | data: fooBarData, 139 | }); 140 | expect(fetch).toBeCalledWith( 141 | 'http://foo.com/', 142 | expect.objectContaining({ 143 | headers: { 'content-type': 'application/json', ...defaultHeaders }, 144 | body: JSON.stringify(fooBarData), 145 | method: 'POST', 146 | }) 147 | ); 148 | }); 149 | 150 | it('does not override method (if specified) when data present', async () => { 151 | const fetch = createMockFetch(); 152 | await thwack('http://foo.com/', { 153 | fetch, 154 | headers: { 'content-type': 'text/plain' }, 155 | data: 'this is plain text', 156 | method: 'FOO', 157 | }); 158 | expect(fetch).toBeCalledWith( 159 | 'http://foo.com/', 160 | expect.objectContaining({ 161 | headers: { 'content-type': 'text/plain', ...defaultHeaders }, 162 | body: 'this is plain text', 163 | method: 'FOO', 164 | }) 165 | ); 166 | }); 167 | 168 | describe('when response does not specify a content-type', () => { 169 | it('defaults to text parsing', async () => { 170 | const fetch = createMockFetch(); 171 | // patch mock fetch to not return any headers 172 | fetch.response.headers = { 173 | entries: () => [], 174 | }; 175 | const { data } = await thwack('http://foo.com/', { fetch }); 176 | expect(data).toEqual('text'); 177 | }); 178 | }); 179 | 180 | describe('when response is application/json', () => { 181 | it('resolves with a parsed JSON object', async () => { 182 | const fetch = createMockFetch(); 183 | const { data } = await thwack('http://foo.com/', { fetch }); 184 | expect(data).toEqual(fooBarData); 185 | }); 186 | it('resolves with a text if options.responseParserMap says it should use "text"', async () => { 187 | const fetch = createMockFetch(); 188 | const { data } = await thwack('http://foo.com/', { 189 | fetch, 190 | responseParserMap: { 'application/json': 'text' }, 191 | }); 192 | expect(data).toEqual('text'); 193 | }); 194 | it('resolves with a text if responseType = "text"', async () => { 195 | const fetch = createMockFetch(); 196 | const { data } = await thwack('http://foo.com/', { 197 | fetch, 198 | responseType: 'text', 199 | }); 200 | expect(data).toEqual('text'); 201 | }); 202 | it('resolves with a stream if responseType = "stream"', async () => { 203 | const fetch = createMockFetch(); 204 | const { data } = await thwack('http://foo.com/', { 205 | fetch, 206 | responseType: 'stream', 207 | }); 208 | expect(data).toEqual('(stream)'); 209 | }); 210 | }); 211 | describe('when response is application/json; charset=utf-8', () => { 212 | it('resolves with a parsed JSON object', async () => { 213 | const fetch = createMockFetch({ 214 | contentType: 'application/json; charset=utf-8', 215 | }); 216 | const { data } = await thwack('http://foo.com/', { fetch }); 217 | expect(data).toEqual(fooBarData); 218 | }); 219 | }); 220 | 221 | describe('when response is NOT application/json', () => { 222 | it('resolves with the body text as a string', async () => { 223 | const fetch = createMockFetch({ 224 | contentType: '', 225 | textResult: 'footext', 226 | }); 227 | const { data } = await thwack('http://foo.com/', { fetch }); 228 | expect(data).toBe('footext'); 229 | }); 230 | }); 231 | 232 | describe('when the fetch returns an non-2xx status', () => { 233 | it('throws a ThwackResponseError', async (done) => { 234 | const fetch = createMockFetch({ status: 400, textResult: 'footext' }); 235 | try { 236 | await thwack('http://foo.com/', { fetch }); 237 | } catch (ex) { 238 | expect(ex instanceof thwack.ThwackResponseError).toBe(true); 239 | done(null, ex); 240 | } 241 | }); 242 | it('throws a ThwackResponseError with message set to the error status', async () => { 243 | const fetch = createMockFetch({ 244 | status: 404, 245 | textResult: 'footext', 246 | }); 247 | await expect(thwack('http://foo.com/', { fetch })).rejects.toThrow( 248 | 'Status 404' 249 | ); 250 | }); 251 | it('throws a ThwackResponseError with error.status', async (done) => { 252 | const fetch = createMockFetch({ status: 400, textResult: 'footext' }); 253 | try { 254 | await thwack('http://foo.com/', { fetch }); 255 | } catch (ex) { 256 | expect(ex.thwackResponse.status).toBe(400); 257 | expect(ex.thwackResponse.ok).toBe(false); 258 | done(null, ex); 259 | } 260 | }); 261 | it('throws a ThwackResponseError with error.data', async (done) => { 262 | const fetch = createMockFetch({ status: 400, textResult: 'footext' }); 263 | try { 264 | await thwack('http://foo.com/', { fetch }); 265 | } catch (ex) { 266 | expect(ex.thwackResponse.data).toEqual(fooBarData); 267 | done(null, ex); 268 | } 269 | }); 270 | it('throws a ThwackResponseError with error.statusText', async (done) => { 271 | const fetch = createMockFetch({ status: 403, textResult: 'footext' }); 272 | try { 273 | await thwack('http://foo.com/', { fetch }); 274 | } catch (ex) { 275 | expect(ex.thwackResponse.statusText).toBe('ok'); 276 | done(null, ex); 277 | } 278 | }); 279 | it('throws a ThwackResponseError with error.headers', async (done) => { 280 | const fetch = createMockFetch({ status: 403, textResult: 'footext' }); 281 | try { 282 | await thwack('http://foo.com/', { fetch }); 283 | } catch (ex) { 284 | expect(ex.thwackResponse.headers).toEqual({ 285 | 'content-type': 'application/json', 286 | }); 287 | done(null, ex); 288 | } 289 | }); 290 | it('throws a ThwackResponseError with error.response', async (done) => { 291 | const fetch = createMockFetch({ status: 403, textResult: 'footext' }); 292 | try { 293 | await thwack('http://foo.com/', { fetch }); 294 | } catch (ex) { 295 | expect(typeof ex.thwackResponse.response).toBe('object'); 296 | done(null, ex); 297 | } 298 | }); 299 | }); 300 | 301 | describe('when options.validateStatus is provided', () => { 302 | it('when returns false throws on a 200 status', async (done) => { 303 | const validateStatus = () => false; 304 | const fetch = createMockFetch({ status: 200 }); 305 | try { 306 | await thwack('http://foo.com/', { fetch, validateStatus }); 307 | } catch (ex) { 308 | expect(ex instanceof thwack.ThwackResponseError).toBe(true); 309 | done(null, ex); 310 | } 311 | }); 312 | it('when returns true does NOT throws on a 500 status', async () => { 313 | const validateStatus = () => true; 314 | const fetch = createMockFetch({ 315 | status: 500, 316 | }); 317 | const { data } = await thwack('http://foo.com/', { 318 | fetch, 319 | validateStatus, 320 | }); 321 | expect(data).toEqual(fooBarData); 322 | }); 323 | }); 324 | 325 | describe('thwack convenience functions', () => { 326 | // eslint-disable-next-line no-restricted-syntax 327 | for (const method of ['POST', 'PUT', 'PATCH']) { 328 | it(`thwack.${method.toLowerCase()}(name, data, options) defaults to ${method} and resolves with a ThwackResponse object`, async () => { 329 | const fetch = createMockFetch(); 330 | const data = await thwack[method.toLowerCase()]('http://foo.com/', 'data', { 331 | fetch, 332 | }); 333 | expect(fetch).toBeCalledWith( 334 | 'http://foo.com/', 335 | expect.objectContaining({ 336 | method, 337 | headers: { 338 | 'content-type': 'application/json', 339 | ...defaultHeaders, 340 | }, 341 | body: JSON.stringify('data'), 342 | }) 343 | ); 344 | expect(data instanceof ThwackResponse).toBe(true); 345 | }); 346 | } 347 | 348 | // eslint-disable-next-line no-restricted-syntax 349 | for (const method of ['GET', 'DELETE', 'HEAD']) { 350 | it(`thwack.${method.toLowerCase()}(name, options) defaults to ${method} and resolves with a ThwackResponse object`, async () => { 351 | const fetch = createMockFetch(); 352 | const data = await thwack[method.toLowerCase()]('http://foo.com/', { fetch }); 353 | expect(fetch).toBeCalledWith( 354 | 'http://foo.com/', 355 | expect.objectContaining({ 356 | method, 357 | }) 358 | ); 359 | expect(data instanceof ThwackResponse).toBe(true); 360 | }); 361 | } 362 | 363 | it('thwack.request(options) resolves with a ThwackResponse object', async () => { 364 | const fetch = createMockFetch(); 365 | const options = { 366 | url: 'http://foo.com/', 367 | fetch, 368 | foo: 'bar', 369 | }; 370 | const data = await thwack.request(options); 371 | expect(fetch).toBeCalledWith( 372 | 'http://foo.com/', 373 | expect.objectContaining({ 374 | foo: 'bar', 375 | }) 376 | ); 377 | expect(data instanceof ThwackResponse).toBe(true); 378 | }); 379 | }); 380 | 381 | describe('thwack.create(options)', () => { 382 | const instance1 = thwack.create({ baseURL: 'http://one.com' }); 383 | const instance2 = thwack.create({ baseURL: 'http://two.com' }); 384 | 385 | it('will use the options as defaults in the new instance', async () => { 386 | const fetch = createMockFetch(); 387 | const options = { 388 | url: 'foo', 389 | fetch, 390 | foo: 'bar', 391 | }; 392 | await instance1(options); 393 | expect(fetch).toBeCalledWith( 394 | 'http://one.com/foo', 395 | expect.objectContaining({ 396 | foo: 'bar', 397 | }) 398 | ); 399 | }); 400 | 401 | it('will be unique per instance', async () => { 402 | const fetch = createMockFetch(); 403 | const options = { 404 | url: 'foo', 405 | fetch, 406 | foo: 'bar', 407 | }; 408 | await instance2(options); 409 | expect(fetch).toBeCalledWith( 410 | 'http://two.com/foo', 411 | expect.objectContaining({ 412 | foo: 'bar', 413 | }) 414 | ); 415 | }); 416 | 417 | it('will not effect the base thwack instance', async () => { 418 | const fetch = createMockFetch(); 419 | const options = { 420 | url: 'http://foo.com/', 421 | fetch, 422 | foo: 'bar', 423 | }; 424 | await thwack(options); 425 | expect(fetch).toBeCalledWith( 426 | 'http://foo.com/', 427 | expect.objectContaining({ 428 | foo: 'bar', 429 | }) 430 | ); 431 | }); 432 | }); 433 | 434 | describe('thwack.getUri', () => { 435 | it('is exposed on the instance', () => { 436 | expect(thwack.getUri({ url: 'http://foo.com/' })).toBe( 437 | 'http://foo.com/' 438 | ); 439 | }); 440 | it('works with a fully qualified URL', () => { 441 | expect(thwack.getUri({ url: 'http://fully-qualified.com/bar' })).toBe( 442 | 'http://fully-qualified.com/bar' 443 | ); 444 | }); 445 | it('works with a fully qualified URL on a child instance', () => { 446 | const instance = thwack.create({ baseURL: 'http:/api.example.com' }); 447 | expect(instance.getUri({ url: 'http://fully-qualified.com/bar' })).toBe( 448 | 'http://fully-qualified.com/bar' 449 | ); 450 | }); 451 | it('works from within an event listener on a parent instance', async () => { 452 | let eventUrl; 453 | const instance = thwack.create({ 454 | baseURL: 'https://example.com/api/', 455 | }); 456 | instance.addEventListener('request', (event) => { 457 | const { options } = event; 458 | eventUrl = thwack.getUri(options); 459 | }); 460 | const fetch = createMockFetch(); 461 | await instance('foo/:id', { 462 | fetch, 463 | foo: 'bar', 464 | params: { id: 123 }, 465 | }); 466 | expect(eventUrl).toBe('https://example.com/api/foo/123'); 467 | }); 468 | 469 | // not needed with buildUrl tests 470 | it.skip('will respect options.buildURL', () => { 471 | const options = { url: 'foo', baseURL: 'bar' }; 472 | const callback = jest.fn((o) => o); 473 | const instance = thwack.create({ buildURL: callback }); 474 | const res = instance.getUri(options); 475 | expect(callback).toBeCalledWith(expect.objectContaining(options)); 476 | expect(res).toEqual(expect.objectContaining(options)); 477 | }); 478 | }); 479 | 480 | describe('thwack.ThwackResponseError', () => { 481 | it('is exported as an instance of Error', () => { 482 | expect(new thwack.ThwackResponseError({}) instanceof Error).toBe(true); 483 | }); 484 | it('is exported on the main instance only', () => { 485 | const fetch = createMockFetch(); 486 | const instance = thwack.create({ fetch }); 487 | expect(instance.ThwackResponseError).toBe(undefined); 488 | }); 489 | }); 490 | }); 491 | 492 | describe('thwack.ThwackResponse', () => { 493 | it('is exported as an instance of ThwackResponse', () => { 494 | expect(new thwack.ThwackResponse({}, {}) instanceof ThwackResponse).toBe( 495 | true 496 | ); 497 | }); 498 | it('use status to determine ok (defaults to 2xx)', () => { 499 | expect(new thwack.ThwackResponse({ status: 199 }, {}).ok).toBe(false); 500 | expect(new thwack.ThwackResponse({ status: 200 }, {}).ok).toBe(true); 501 | expect(new thwack.ThwackResponse({ status: 299 }, {}).ok).toBe(true); 502 | expect(new thwack.ThwackResponse({ status: 300 }, {}).ok).toBe(false); 503 | }); 504 | it('is not effected by options.validateStatus', () => { 505 | const validateStatus = (s) => s >= 400 && s < 500; 506 | expect( 507 | new thwack.ThwackResponse({ status: 199 }, { validateStatus }).ok 508 | ).toBe(false); 509 | expect( 510 | new thwack.ThwackResponse({ status: 200 }, { validateStatus }).ok 511 | ).toBe(true); 512 | expect( 513 | new thwack.ThwackResponse({ status: 299 }, { validateStatus }).ok 514 | ).toBe(true); 515 | expect( 516 | new thwack.ThwackResponse({ status: 300 }, { validateStatus }).ok 517 | ).toBe(false); 518 | }); 519 | it('is exported on the main instance only', () => { 520 | const fetch = createMockFetch(); 521 | const instance = thwack.create({ fetch }); 522 | expect(instance.ThwackResponse).toBe(undefined); 523 | }); 524 | }); 525 | 526 | describe('thwack.all', () => { 527 | it('resolves to an array of results', async () => { 528 | const results = await thwack.all([ 529 | Promise.resolve('foo'), 530 | Promise.resolve('bar'), 531 | ]); 532 | expect(results).toEqual(['foo', 'bar']); 533 | }); 534 | it('is exported on the main instance only', () => { 535 | const fetch = createMockFetch(); 536 | const instance = thwack.create({ fetch }); 537 | expect(instance.all).toBe(undefined); 538 | }); 539 | }); 540 | 541 | describe('thwack.spread', () => { 542 | it('takes a callback returns a function', () => { 543 | expect(typeof thwack.spread()).toBe('function'); 544 | }); 545 | it('calling that function with an array will spread the array to the callback', (done) => { 546 | const callback = (...results) => { 547 | expect(results).toEqual([1, 2, 3]); 548 | done(); 549 | }; 550 | thwack.spread(callback)([1, 2, 3]); 551 | }); 552 | it('is exported on the main instance only', () => { 553 | const fetch = createMockFetch(); 554 | const instance = thwack.create({ fetch }); 555 | expect(instance.spread).toBe(undefined); 556 | }); 557 | }); 558 | }; 559 | 560 | export default run; 561 | -------------------------------------------------------------------------------- /__test_utils__/thwackEvents.js: -------------------------------------------------------------------------------- 1 | import thwack from '../src/core'; 2 | import ThwackResponseEvent from '../src/core/ThwackEvents/ThwackResponseEvent'; 3 | import ThwackRequestEvent from '../src/core/ThwackEvents/ThwackRequestEvent'; 4 | import ThwackDataEvent from '../src/core/ThwackEvents/ThwackDataEvent'; 5 | import ThwackErrorEvent from '../src/core/ThwackEvents/ThwackErrorEvent'; 6 | 7 | import { createMockFetch } from './jestUtils'; 8 | 9 | const { ThwackResponseError, ThwackResponse } = thwack; 10 | 11 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 12 | 13 | const run = async () => { 14 | describe('Thwack events', () => { 15 | describe('calling addEventListener("request")', () => { 16 | it('has its callback called with options before calling fetch', async () => { 17 | const fetch = createMockFetch(); 18 | const callback = jest.fn(); 19 | const options = { 20 | url: 'http://foo.com', 21 | fetch, 22 | }; 23 | thwack.addEventListener('request', callback); 24 | await thwack(options); 25 | thwack.removeEventListener('request', callback); 26 | expect(callback).toHaveBeenCalledTimes(1); 27 | expect(callback).toBeCalledWith(expect.any(ThwackRequestEvent)); 28 | expect(fetch).toHaveBeenCalledTimes(1); 29 | }); 30 | it('callbacks can return new options', async () => { 31 | const fetch = createMockFetch(); 32 | const callback = (event) => { 33 | const options = { ...event.options, url: 'http://bob.com/' }; 34 | return options; 35 | }; 36 | const options = { 37 | url: 'http://foo.com', 38 | fetch, 39 | foo: 'bar', 40 | }; 41 | thwack.addEventListener('request', callback); 42 | await thwack(options); 43 | thwack.removeEventListener('request', callback); 44 | expect(fetch).toBeCalledWith( 45 | 'http://bob.com/', 46 | expect.objectContaining({ 47 | foo: 'bar', 48 | }) 49 | ); 50 | }); 51 | it('callbacks must return a valid options object', async () => { 52 | const fetch = createMockFetch(); 53 | const callback = () => { 54 | const options = 'bob'; 55 | return options; 56 | }; 57 | const options = { 58 | url: 'http://foo.com', 59 | fetch, 60 | foo: 'bar', 61 | }; 62 | thwack.addEventListener('request', callback); 63 | let didThrow; 64 | try { 65 | await thwack(options); 66 | } catch (ex) { 67 | didThrow = true; 68 | } 69 | thwack.removeEventListener('request', callback); 70 | expect(didThrow).toBe(true); 71 | expect(fetch).toHaveBeenCalledTimes(0); 72 | }); 73 | it('async callbacks can alter options', async () => { 74 | const fetch = createMockFetch(); 75 | const callback = async (event) => { 76 | await sleep(100); 77 | const options = { ...event.options, url: 'http://bob.com' }; 78 | return options; 79 | }; 80 | const options = { 81 | url: 'http://foo.com', 82 | fetch, 83 | foo: 'bar', 84 | }; 85 | thwack.addEventListener('request', callback); 86 | await thwack(options); 87 | thwack.removeEventListener('request', callback); 88 | expect(fetch).toBeCalledWith( 89 | 'http://bob.com/', 90 | expect.objectContaining({ 91 | foo: 'bar', 92 | }) 93 | ); 94 | }); 95 | it('can be called multiple times and see the effects from the other callbacks', async () => { 96 | const fetch = createMockFetch(); 97 | const callback1 = jest.fn((e) => { 98 | const options = { ...e.options, url: `${e.options.url}bar` }; 99 | return options; 100 | }); 101 | const callback2 = jest.fn((e) => { 102 | const options = { ...e.options, url: `${e.options.url}bar` }; 103 | return options; 104 | }); 105 | thwack.addEventListener('request', callback1); 106 | thwack.addEventListener('request', callback2); 107 | await thwack('http://foo.com/', { 108 | fetch, 109 | foo: 'bar', 110 | }); 111 | thwack.removeEventListener('request', callback2); 112 | thwack.removeEventListener('request', callback1); 113 | expect(callback1).toHaveBeenCalledTimes(1); 114 | expect(callback2).toHaveBeenCalledTimes(1); 115 | expect(fetch).toBeCalledWith( 116 | 'http://foo.com/barbar', 117 | expect.objectContaining({ 118 | foo: 'bar', 119 | }) 120 | ); 121 | }); 122 | it('a callback can call stopPropagation() to prevent additional callbacks from executing', async () => { 123 | const fetch = createMockFetch(); 124 | const callback1 = jest.fn((e) => { 125 | const options = { ...e.options, url: `${e.options.url}bar` }; 126 | e.stopPropagation(); 127 | return options; 128 | }); 129 | const callback2 = jest.fn((e) => { 130 | const options = { ...e.options, url: `${e.options.url}bar` }; 131 | return options; 132 | }); 133 | thwack.addEventListener('request', callback1); 134 | thwack.addEventListener('request', callback2); 135 | await thwack('http://foo.com/', { 136 | fetch, 137 | foo: 'bar', 138 | }); 139 | thwack.removeEventListener('request', callback2); 140 | thwack.removeEventListener('request', callback1); 141 | expect(callback1).toHaveBeenCalledTimes(1); 142 | expect(callback2).toHaveBeenCalledTimes(0); 143 | expect(fetch).toBeCalledWith( 144 | 'http://foo.com/bar', 145 | expect.objectContaining({ 146 | foo: 'bar', 147 | }) 148 | ); 149 | }); 150 | it('exceptions in callbacks make it out to the process what called request', async () => { 151 | const fetch = createMockFetch(); 152 | const callback1 = jest.fn(() => { 153 | throw new Error('boo!'); 154 | }); 155 | const callback2 = jest.fn((e) => { 156 | e.options = { ...e.options, url: `${e.options.url}bar` }; 157 | }); 158 | thwack.addEventListener('request', callback1); 159 | thwack.addEventListener('request', callback2); 160 | try { 161 | await thwack('http://foo.com', { 162 | fetch, 163 | foo: 'bar', 164 | }); 165 | } catch (ex) { 166 | expect(ex.toString()).toBe('Error: boo!'); 167 | } 168 | thwack.removeEventListener('request', callback2); 169 | thwack.removeEventListener('request', callback1); 170 | expect(callback1).toHaveBeenCalledTimes(1); 171 | expect(callback2).toHaveBeenCalledTimes(0); 172 | expect(fetch).toHaveBeenCalledTimes(0); 173 | }); 174 | it('parent events happen before child events and a stopPropagation on the parent stops child events', async () => { 175 | const fetch = createMockFetch(); 176 | const instance = thwack.create(); 177 | const callback1 = jest.fn((e) => { 178 | const options = { ...e.options, url: `${e.options.url}bar` }; 179 | e.stopPropagation(); 180 | return options; 181 | }); 182 | const callback2 = jest.fn((e) => { 183 | const options = { ...e.options, url: `${e.options.url}bar` }; 184 | return options; 185 | }); 186 | thwack.addEventListener('request', callback1); 187 | instance.addEventListener('request', callback2); 188 | await instance('http://foo.com/', { 189 | fetch, 190 | foo: 'bar', 191 | }); 192 | instance.removeEventListener('request', callback2); 193 | thwack.removeEventListener('request', callback1); 194 | expect(callback1).toHaveBeenCalledTimes(1); 195 | expect(callback2).toHaveBeenCalledTimes(0); 196 | expect(fetch).toBeCalledWith( 197 | 'http://foo.com/bar', 198 | expect.objectContaining({ 199 | foo: 'bar', 200 | }) 201 | ); 202 | }); 203 | 204 | it('multiple parent events happen before child events', async () => { 205 | const fetch = createMockFetch(); 206 | const instanceb = thwack.create(); 207 | const instancec = instanceb.create(); 208 | const callbacka = (e) => { 209 | const options = { ...e.options, url: `${e.options.url}/a` }; 210 | return options; 211 | }; 212 | const callbackb = (e) => { 213 | const options = { ...e.options, url: `${e.options.url}/b` }; 214 | return options; 215 | }; 216 | const callbackc = (e) => { 217 | const options = { ...e.options, url: `${e.options.url}/c` }; 218 | return options; 219 | }; 220 | thwack.addEventListener('request', callbacka); 221 | instanceb.addEventListener('request', callbackb); 222 | instancec.addEventListener('request', callbackc); 223 | await instancec('http://foo.com', { 224 | fetch, 225 | foo: 'bar', 226 | }); 227 | instancec.removeEventListener('request', callbackc); 228 | instanceb.removeEventListener('request', callbackb); 229 | thwack.removeEventListener('request', callbacka); 230 | expect(fetch).toBeCalledWith( 231 | 'http://foo.com/a/b/c', 232 | expect.objectContaining({ 233 | foo: 'bar', 234 | }) 235 | ); 236 | }); 237 | it('events can be async and multiple parent events happen before child events', async () => { 238 | const fetch = createMockFetch(); 239 | const instanceb = thwack.create(); 240 | const instancec = instanceb.create(); 241 | const callbacka = async (e) => { 242 | const options = { ...e.options, url: `${e.options.url}/a` }; 243 | return options; 244 | }; 245 | const callbackb = async (e) => { 246 | await sleep(100); 247 | const options = { ...e.options, url: `${e.options.url}/b` }; 248 | return options; 249 | }; 250 | const callbackc = (e) => { 251 | const options = { ...e.options, url: `${e.options.url}/c` }; 252 | return options; 253 | }; 254 | thwack.addEventListener('request', callbacka); 255 | instanceb.addEventListener('request', callbackb); 256 | instancec.addEventListener('request', callbackc); 257 | await instancec('http://foo.com', { 258 | fetch, 259 | foo: 'bar', 260 | }); 261 | instancec.removeEventListener('request', callbackc); 262 | instanceb.removeEventListener('request', callbackb); 263 | thwack.removeEventListener('request', callbacka); 264 | expect(fetch).toBeCalledWith( 265 | 'http://foo.com/a/b/c', 266 | expect.objectContaining({ 267 | foo: 'bar', 268 | }) 269 | ); 270 | }); 271 | it('multiple parent events happen before child events and a stopPropagation on the parent stops child events', async () => { 272 | const fetch = createMockFetch(); 273 | const instanceb = thwack.create(); 274 | const instancec = instanceb.create(); 275 | const callbacka = jest.fn((e) => { 276 | const options = { ...e.options, url: `${e.options.url}/a` }; 277 | e.stopPropagation(); 278 | return options; 279 | }); 280 | const callbackb = jest.fn((e) => { 281 | const options = { ...e.options, url: `${e.options.url}/b` }; 282 | return options; 283 | }); 284 | const callbackc = jest.fn((e) => { 285 | const options = { ...e.options, url: `${e.options.url}/c` }; 286 | return options; 287 | }); 288 | thwack.addEventListener('request', callbacka); 289 | instanceb.addEventListener('request', callbackb); 290 | instancec.addEventListener('request', callbackc); 291 | await instancec('http://foo.com', { 292 | fetch, 293 | foo: 'bar', 294 | }); 295 | instancec.removeEventListener('request', callbackc); 296 | instanceb.removeEventListener('request', callbackb); 297 | thwack.removeEventListener('request', callbacka); 298 | expect(callbacka).toHaveBeenCalledTimes(1); 299 | expect(callbackb).toHaveBeenCalledTimes(0); 300 | expect(callbackc).toHaveBeenCalledTimes(0); 301 | expect(fetch).toBeCalledWith( 302 | 'http://foo.com/a', 303 | expect.objectContaining({ 304 | foo: 'bar', 305 | }) 306 | ); 307 | }); 308 | it('a callback can call preventDefault() to prevent the fetch from happening', async () => { 309 | const fetch = createMockFetch(); 310 | const callback = async (e) => { 311 | // e.promise = Promise.resolve('preventDefault'); 312 | e.preventDefault(); 313 | const { options } = e; 314 | return new ThwackResponse( 315 | { 316 | ok: true, 317 | status: 200, 318 | data: 'preventDefault', 319 | headers: { foo: 'bar' }, 320 | }, 321 | options 322 | ); 323 | }; 324 | const callback2 = () => {}; 325 | thwack.addEventListener('request', callback); 326 | thwack.addEventListener('request', callback2); 327 | const resp = await thwack('http://foo.com', { 328 | fetch, 329 | foo: 'bar', 330 | }); 331 | thwack.removeEventListener('request', callback2); 332 | thwack.removeEventListener('request', callback); 333 | expect(fetch).toHaveBeenCalledTimes(0); 334 | expect(resp.data).toEqual('preventDefault'); 335 | }); 336 | it('a callback that calls preventDefault() must return a ThwackResponse', async () => { 337 | const fetch = createMockFetch(); 338 | const callback = async (e) => { 339 | // e.promise = Promise.resolve('preventDefault'); 340 | e.preventDefault(); 341 | return 'foofoofoo'; 342 | }; 343 | const callback2 = () => {}; 344 | thwack.addEventListener('request', callback); 345 | thwack.addEventListener('request', callback2); 346 | try { 347 | await thwack('http://foo.com', { 348 | fetch, 349 | foo: 'bar', 350 | }); 351 | } catch (ex) { 352 | expect(fetch).toHaveBeenCalledTimes(0); 353 | } finally { 354 | thwack.removeEventListener('request', callback2); 355 | thwack.removeEventListener('request', callback); 356 | } 357 | }); 358 | it('a callback that calls preventDefault() must return a ThwackResponse with at least status', async () => { 359 | const fetch = createMockFetch(); 360 | const callback = async (e) => { 361 | // e.promise = Promise.resolve('preventDefault'); 362 | e.preventDefault(); 363 | const { options } = e; 364 | return new ThwackResponse({}, options); 365 | }; 366 | const callback2 = () => {}; 367 | thwack.addEventListener('request', callback); 368 | thwack.addEventListener('request', callback2); 369 | const resp = await thwack('http://foo.com', { 370 | fetch, 371 | foo: 'bar', 372 | }); 373 | thwack.removeEventListener('request', callback2); 374 | thwack.removeEventListener('request', callback); 375 | expect(fetch).toHaveBeenCalledTimes(0); 376 | expect(resp.status).toBe(200); 377 | }); 378 | }); 379 | 380 | describe('calling addEventListener("response")', () => { 381 | it('has its callback called with ThwackResponseEvent before returning to caller', async () => { 382 | const fetch = createMockFetch(); 383 | const callback = jest.fn(); 384 | const options = { 385 | url: 'http://foo/com', 386 | fetch, 387 | foo: 'bar', 388 | }; 389 | thwack.addEventListener('response', callback); 390 | await thwack(options); 391 | thwack.removeEventListener('response', callback); 392 | expect(callback).toHaveBeenCalledTimes(1); 393 | expect(callback).toBeCalledWith(expect.any(ThwackResponseEvent)); 394 | }); 395 | it('a callback can call preventDefault() to respond with "fake" data', async () => { 396 | const fetch = createMockFetch(); 397 | const callback = (e) => { 398 | // await sleep(100); 399 | e.preventDefault(); 400 | const { options } = e.thwackResponse; 401 | return new ThwackResponse( 402 | { ok: true, status: 200, data: 'mock response' }, 403 | options 404 | ); 405 | }; 406 | thwack.addEventListener('response', callback); 407 | const resp = await thwack('http://foo.com', { 408 | fetch, 409 | foo: 'bar', 410 | }); 411 | thwack.removeEventListener('response', callback); 412 | expect(resp.data).toEqual('mock response'); 413 | }); 414 | it('an async callback can call preventDefault() to respond with "fake" data', async () => { 415 | const fetch = createMockFetch(); 416 | const callback = async (e) => { 417 | await sleep(100); 418 | e.preventDefault(); 419 | const { options } = e.thwackResponse; 420 | return new ThwackResponse( 421 | { ok: true, status: 200, data: 'mock response' }, 422 | options 423 | ); 424 | }; 425 | thwack.addEventListener('response', callback); 426 | const resp = await thwack('http://foo.com', { 427 | fetch, 428 | foo: 'bar', 429 | }); 430 | thwack.removeEventListener('response', callback); 431 | expect(resp.data).toEqual('mock response'); 432 | }); 433 | it('a callback can call preventDefault() to respond with a "fake" error', async () => { 434 | const fetch = createMockFetch(); 435 | const callback = (e) => { 436 | // await sleep(100); 437 | e.preventDefault(); 438 | const { options } = e.thwackResponse; 439 | return new ThwackResponse({ status: 409, data: 'boo!' }, options); 440 | }; 441 | thwack.addEventListener('response', callback); 442 | try { 443 | await thwack('http://foo.com', { 444 | fetch, 445 | foo: 'bar', 446 | }); 447 | } catch (ex) { 448 | expect(ex instanceof ThwackResponseError).toBe(true); 449 | expect(ex.thwackResponse.data).toBe('boo!'); 450 | } finally { 451 | thwack.removeEventListener('response', callback); 452 | } 453 | }); 454 | it('a callback can call preventDefault() to respond with a "fake" error and not provide data', async () => { 455 | const fetch = createMockFetch(); 456 | const callback = (e) => { 457 | // await sleep(100); 458 | e.preventDefault(); 459 | const { options } = e.thwackResponse; 460 | return new ThwackResponse({ status: 409 }, options); 461 | }; 462 | thwack.addEventListener('response', callback); 463 | try { 464 | await thwack('http://foo.com', { 465 | fetch, 466 | foo: 'bar', 467 | }); 468 | } catch (ex) { 469 | expect(ex instanceof ThwackResponseError).toBe(true); 470 | } finally { 471 | thwack.removeEventListener('response', callback); 472 | } 473 | }); 474 | it('will cause thwack to throw if a callback called preventDefault() but did not return a ThwackResponse', async () => { 475 | const fetch = createMockFetch(); 476 | const callback = async (e) => { 477 | e.preventDefault(); 478 | return 'fred'; 479 | }; 480 | thwack.addEventListener('response', callback); 481 | try { 482 | await thwack('http://foo.com', { 483 | fetch, 484 | foo: 'bar', 485 | }); 486 | } catch (ex) { 487 | expect(ex.name).toBe('Error'); 488 | } finally { 489 | thwack.removeEventListener('response', callback); 490 | } 491 | }); 492 | it('will cause thwack to throw if a callback blindly/recursively executes a request', async () => { 493 | const fetch = createMockFetch(); 494 | const callback = async (e) => { 495 | e.preventDefault(); 496 | return thwack('http://foo.com', { 497 | fetch, 498 | foo: 'bar', 499 | }); 500 | }; 501 | thwack.addEventListener('response', callback); 502 | try { 503 | await thwack('http://foo.com', { 504 | fetch, 505 | foo: 'bar', 506 | }); 507 | } catch (ex) { 508 | expect(ex.name).toBe('Error'); 509 | } finally { 510 | thwack.removeEventListener('response', callback); 511 | } 512 | }); 513 | }); 514 | 515 | describe('calling addEventListener("data")', () => { 516 | it('has its callback called with ThwackDataEvent before returning to caller', async () => { 517 | const fetch = createMockFetch(); 518 | const callback = jest.fn(); 519 | const options = { 520 | url: 'http://foo.com', 521 | fetch, 522 | foo: 'bar', 523 | }; 524 | thwack.addEventListener('data', callback); 525 | await thwack(options); 526 | thwack.removeEventListener('data', callback); 527 | expect(callback).toHaveBeenCalledTimes(1); 528 | expect(callback).toBeCalledWith(expect.any(ThwackDataEvent)); 529 | }); 530 | it('a callback can call preventDefault() to respond with "fake" data', async () => { 531 | const fetch = createMockFetch(); 532 | const callback = (e) => { 533 | // await sleep(100); 534 | e.preventDefault(); 535 | const { options } = e.thwackResponse; 536 | return new ThwackResponse( 537 | { ok: true, status: 200, data: 'mock response' }, 538 | options 539 | ); 540 | }; 541 | thwack.addEventListener('data', callback); 542 | const resp = await thwack('http://foo.com', { 543 | fetch, 544 | foo: 'bar', 545 | }); 546 | thwack.removeEventListener('data', callback); 547 | expect(resp.data).toEqual('mock response'); 548 | }); 549 | it('an async callback can call preventDefault() to respond with "fake" data', async () => { 550 | const fetch = createMockFetch(); 551 | const callback = async (e) => { 552 | await sleep(100); 553 | e.preventDefault(); 554 | const { options } = e.thwackResponse; 555 | return new ThwackResponse( 556 | { ok: true, status: 200, data: 'mock response' }, 557 | options 558 | ); 559 | }; 560 | thwack.addEventListener('data', callback); 561 | const resp = await thwack('http://foo.com', { 562 | fetch, 563 | foo: 'bar', 564 | }); 565 | thwack.removeEventListener('data', callback); 566 | expect(resp.data).toEqual('mock response'); 567 | }); 568 | it('a callback can call preventDefault() to respond with a "fake" error', async () => { 569 | const fetch = createMockFetch(); 570 | const callback = (e) => { 571 | // await sleep(100); 572 | e.preventDefault(); 573 | const { options } = e.thwackResponse; 574 | return new ThwackResponse({ status: 409, data: 'boo!' }, options); 575 | }; 576 | thwack.addEventListener('data', callback); 577 | try { 578 | await thwack('http://foo.com', { 579 | fetch, 580 | foo: 'bar', 581 | }); 582 | } catch (ex) { 583 | expect(ex instanceof ThwackResponseError).toBe(true); 584 | expect(ex.thwackResponse.data).toBe('boo!'); 585 | } finally { 586 | thwack.removeEventListener('data', callback); 587 | } 588 | }); 589 | it('a callback can call preventDefault() to respond with a "fake" error and not provide data', async () => { 590 | const fetch = createMockFetch(); 591 | const callback = (e) => { 592 | // await sleep(100); 593 | e.preventDefault(); 594 | const { options } = e.thwackResponse; 595 | return new ThwackResponse({ status: 409 }, options); 596 | }; 597 | thwack.addEventListener('data', callback); 598 | try { 599 | await thwack('http://foo.com', { 600 | fetch, 601 | foo: 'bar', 602 | }); 603 | } catch (ex) { 604 | expect(ex instanceof ThwackResponseError).toBe(true); 605 | } finally { 606 | thwack.removeEventListener('data', callback); 607 | } 608 | }); 609 | it('will cause thwack to throw if a callback called preventDefault() but did not return a ThwackResponse', async () => { 610 | const fetch = createMockFetch(); 611 | const callback = async (e) => { 612 | e.preventDefault(); 613 | return 'fred'; 614 | }; 615 | thwack.addEventListener('data', callback); 616 | try { 617 | await thwack('http://foo.com', { 618 | fetch, 619 | foo: 'bar', 620 | }); 621 | } catch (ex) { 622 | expect(ex.name).toBe('Error'); 623 | } finally { 624 | thwack.removeEventListener('data', callback); 625 | } 626 | }); 627 | it('will cause thwack to throw if a callback blindly/recursively executes a request', async () => { 628 | const fetch = createMockFetch(); 629 | const callback = async (e) => { 630 | e.preventDefault(); 631 | return thwack('http://foo.com', { 632 | fetch, 633 | foo: 'bar', 634 | }); 635 | }; 636 | thwack.addEventListener('data', callback); 637 | try { 638 | await thwack('http://foo.com', { 639 | fetch, 640 | foo: 'bar', 641 | }); 642 | } catch (ex) { 643 | expect(ex.name).toBe('Error'); 644 | } finally { 645 | thwack.removeEventListener('data', callback); 646 | } 647 | }); 648 | }); 649 | 650 | describe('calling addEventListener("error")', () => { 651 | it('has its callback called with ThwackDataEvent before returning to caller', async () => { 652 | const fetch = createMockFetch({ status: 500 }); 653 | const callback = jest.fn(); 654 | const options = { 655 | url: 'http://error.com', 656 | fetch, 657 | foo: 'bar', 658 | }; 659 | thwack.addEventListener('error', callback); 660 | try { 661 | await thwack(options); 662 | // eslint-disable-next-line no-empty 663 | } catch (ex) { 664 | } finally { 665 | thwack.removeEventListener('error', callback); 666 | } 667 | expect(callback).toHaveBeenCalledTimes(1); 668 | expect(callback).toBeCalledWith(expect.any(ThwackErrorEvent)); 669 | }); 670 | it('a callback can call preventDefault() to respond with "fake" data', async () => { 671 | const fetch = createMockFetch({ status: 500 }); 672 | const callback = (e) => { 673 | e.preventDefault(); 674 | const { options } = e.thwackResponse; 675 | return new ThwackResponse( 676 | { status: 200, data: 'mock response' }, 677 | options 678 | ); 679 | }; 680 | thwack.addEventListener('error', callback); 681 | const resp = await thwack('http://foo.com', { 682 | fetch, 683 | foo: 'bar', 684 | }); 685 | thwack.removeEventListener('error', callback); 686 | expect(resp.data).toEqual('mock response'); 687 | }); 688 | it('a callback can call preventDefault() to respond with "fake" data (defaulting status)', async () => { 689 | const fetch = createMockFetch({ status: 500 }); 690 | const callback = (e) => { 691 | e.preventDefault(); 692 | const { options } = e.thwackResponse; 693 | return new ThwackResponse({ data: 'mock response' }, options); 694 | }; 695 | thwack.addEventListener('error', callback); 696 | const resp = await thwack('http://foo.com', { 697 | fetch, 698 | foo: 'bar', 699 | }); 700 | thwack.removeEventListener('error', callback); 701 | expect(resp.data).toEqual('mock response'); 702 | }); 703 | it('an async callback can call preventDefault() to respond with "fake" data', async () => { 704 | const fetch = createMockFetch({ status: 500 }); 705 | const callback = async (e) => { 706 | await sleep(100); 707 | e.preventDefault(); 708 | const { options } = e.thwackResponse; 709 | return new ThwackResponse( 710 | { ok: true, status: 200, data: 'mock response' }, 711 | options 712 | ); 713 | }; 714 | thwack.addEventListener('error', callback); 715 | const resp = await thwack('http://foo.com', { 716 | fetch, 717 | foo: 'bar', 718 | }); 719 | thwack.removeEventListener('error', callback); 720 | expect(resp.data).toEqual('mock response'); 721 | }); 722 | it('a callback can call preventDefault() to respond with a "fake" error', async () => { 723 | const fetch = createMockFetch({ status: 500 }); 724 | const callback = (e) => { 725 | // await sleep(100); 726 | e.preventDefault(); 727 | const { options } = e.thwackResponse; 728 | return new ThwackResponse({ status: 409, data: 'boo!' }, options); 729 | }; 730 | thwack.addEventListener('error', callback); 731 | try { 732 | await thwack('http://foo.com', { 733 | fetch, 734 | foo: 'bar', 735 | }); 736 | } catch (ex) { 737 | expect(ex instanceof ThwackResponseError).toBe(true); 738 | expect(ex.thwackResponse.status).toBe(409); 739 | expect(ex.thwackResponse.data).toBe('boo!'); 740 | } finally { 741 | thwack.removeEventListener('error', callback); 742 | } 743 | }); 744 | it('a callback can call preventDefault() to respond with a "fake" error and not provide data', async () => { 745 | const fetch = createMockFetch({ status: 500 }); 746 | const callback = (e) => { 747 | // await sleep(100); 748 | e.preventDefault(); 749 | const { options } = e.thwackResponse; 750 | return new ThwackResponse({ status: 409 }, options); 751 | }; 752 | thwack.addEventListener('error', callback); 753 | try { 754 | await thwack('http://foo.com', { 755 | fetch, 756 | foo: 'bar', 757 | }); 758 | } catch (ex) { 759 | expect(ex instanceof ThwackResponseError).toBe(true); 760 | } finally { 761 | thwack.removeEventListener('error', callback); 762 | } 763 | }); 764 | it('will cause thwack to throw if a callback called preventDefault() but did not return a ThwackResponse', async () => { 765 | const fetch = createMockFetch({ status: 500 }); 766 | const callback = async (e) => { 767 | e.preventDefault(); 768 | return 'fred'; 769 | }; 770 | thwack.addEventListener('error', callback); 771 | try { 772 | await thwack('http://foo.com', { 773 | fetch, 774 | foo: 'bar', 775 | }); 776 | } catch (ex) { 777 | expect(ex.name).toBe('Error'); 778 | } finally { 779 | thwack.removeEventListener('error', callback); 780 | } 781 | }); 782 | it('will cause thwack to throw if a callback blindly/recursively executes a request', async () => { 783 | const fetch = createMockFetch({ status: 500 }); 784 | const callback = async (e) => { 785 | e.preventDefault(); 786 | return thwack('http://foo.com', { 787 | fetch, 788 | foo: 'bar', 789 | }); 790 | }; 791 | thwack.addEventListener('error', callback); 792 | try { 793 | await thwack('http://foo.com', { 794 | fetch, 795 | foo: 'bar', 796 | }); 797 | } catch (ex) { 798 | expect(ex.name).toBe('Error'); 799 | } finally { 800 | thwack.removeEventListener('error', callback); 801 | } 802 | }); 803 | }); 804 | 805 | describe('calling removeEventListener', () => { 806 | it('is properly removed', async () => { 807 | const fetch = createMockFetch(); 808 | const callback = jest.fn(); 809 | thwack.addEventListener('request', callback); 810 | await thwack('http://foo.com', { fetch }); 811 | expect(callback).toHaveBeenCalledTimes(1); 812 | thwack.removeEventListener('request', callback); 813 | await thwack('http://foo.com', { fetch }); 814 | expect(callback).toHaveBeenCalledTimes(1); 815 | }); 816 | }); 817 | }); 818 | }; 819 | 820 | export default run; 821 | -------------------------------------------------------------------------------- /__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest"], 3 | "rules": { 4 | "jest/no-disabled-tests": "warn", 5 | "jest/no-focused-tests": "error", 6 | "jest/no-identical-title": "error", 7 | "jest/prefer-to-have-length": "warn", 8 | "jest/valid-expect": "error", 9 | "implicit-arrow-linebreak": 0 10 | }, 11 | "env": { 12 | "jest": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /__tests__/buildUrl.js: -------------------------------------------------------------------------------- 1 | import buildUrl from '../src/core/utils/buildUrl'; 2 | 3 | const cases = [ 4 | ['bar', 'http://ex.co/foo', {}, 'http://ex.co/bar'], 5 | ['bar', 'https://ex.co/foo', {}, 'https://ex.co/bar'], 6 | ['//x.com', 'http://ex.co/', {}, 'http://x.com/'], 7 | ['//x.com', 'https://ex.co/', {}, 'https://x.com/'], 8 | ['bar', 'http://ex.co/foo/', {}, 'http://ex.co/foo/bar'], 9 | ['/bar', 'http://ex.co/foo/', {}, 'http://ex.co/bar'], 10 | ['/', 'http://ex.co/foo/', {}, 'http://ex.co/'], 11 | ['bar?x=123', 'http://ex.co/foo', {}, 'http://ex.co/bar?x=123'], 12 | ['bar', 'http://ex.co/foo', { x: 123 }, 'http://ex.co/bar?x=123'], 13 | ['bar/:x', 'http://ex.co/foo/', { x: 123 }, 'http://ex.co/foo/bar/123'], 14 | [':x/:y', 'http://ex.co/', { x: 1, y: 2 }, 'http://ex.co/1/2'], 15 | ['http://ex.co/bar/:x', undefined, { x: 123 }, 'http://ex.co/bar/123'], 16 | [ 17 | '?x=123', 18 | 'http://ex.co/foo', 19 | { a: 'a', c: 'c' }, 20 | 'http://ex.co/foo?x=123&a=a&c=c', 21 | ], 22 | [ 23 | '?x=123', 24 | 'http://ex.co/foo/', 25 | { a: 'a', c: 'c' }, 26 | 'http://ex.co/foo/?x=123&a=a&c=c', 27 | ], 28 | [ 29 | 'bar?x=123', 30 | 'http://ex.co/foo', 31 | { c: 'c', a: 'a' }, 32 | 'http://ex.co/bar?x=123&a=a&c=c', 33 | ], 34 | ['http://a.ex.co/', 'http://b.ex.co', {}, 'http://a.ex.co/'], 35 | ['http://a.ex.co', 'http://b.ex.co', {}, 'http://a.ex.co/'], 36 | ]; 37 | describe('buildUrl', () => { 38 | cases.forEach(([url, baseURL, params, expectedResult]) => { 39 | it(`returns ${expectedResult} given buildUrl("${url}", "${baseURL}")`, () => { 40 | const result = buildUrl({ url, baseURL, params }); 41 | expect(result).toBe(expectedResult); 42 | }); 43 | }); 44 | it('defaults URL to an empty string', () => { 45 | const result = buildUrl({ 46 | baseURL: 'http://ex.co/', 47 | }); 48 | expect(result).toBe('http://ex.co/'); 49 | }); 50 | it('allows params to be missing', () => { 51 | const result = buildUrl({ 52 | url: 'foo', 53 | baseURL: 'http://ex.co/', 54 | }); 55 | expect(result).toBe('http://ex.co/foo'); 56 | }); 57 | it('throws if base is not fully qualified', () => { 58 | expect(() => buildUrl({ url: 'foo', baseURL: 'bar' })).toThrow(); 59 | }); 60 | it('will respect callback function in options.resolver', () => { 61 | const callback = jest.fn((url, base) => url + base); 62 | const options = { resolver: callback, url: 'foo', baseURL: 'bar' }; 63 | const res = buildUrl(options); 64 | expect(callback).toBeCalledWith('foo', 'bar'); 65 | expect(res).toEqual('foobar'); 66 | }); 67 | it('will respect callback function in options.paramsSerializer', () => { 68 | const callback = jest.fn((params) => JSON.stringify(params)); 69 | const resolver = (url, base) => url + base; 70 | const options = { 71 | resolver, 72 | paramsSerializer: callback, 73 | params: { a: 'b' }, 74 | url: 'foo', 75 | baseURL: 'bar', 76 | }; 77 | const res = buildUrl(options); 78 | expect(callback).toBeCalledWith({ a: 'b' }); 79 | expect(res).toEqual(`foobar?${JSON.stringify({ a: 'b' })}`); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /__tests__/compatParser.js: -------------------------------------------------------------------------------- 1 | import compatParser from '../src/core/utils/compatParser'; 2 | 3 | describe('compatParser', () => { 4 | it('converts parser type "document" to "formData"', () => { 5 | const res = compatParser('document'); 6 | expect(res).toBe('formData'); 7 | }); 8 | it('converts parser type "arraybuffer" to "arrayBuffer"', () => { 9 | const res = compatParser('arraybuffer'); 10 | expect(res).toBe('arrayBuffer'); 11 | }); 12 | it('passes others "as-is"', () => { 13 | const res = compatParser('foo'); 14 | expect(res).toBe('foo'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /__tests__/computeContentType.test.js: -------------------------------------------------------------------------------- 1 | import computeContentType from '../src/core/utils/computeContentType'; 2 | 3 | describe('computeContentType', () => { 4 | it('if passed a Blob, returns the type', () => { 5 | const blob = new Blob(['foo'], { type: 'text/foo' }); 6 | const res = computeContentType(blob); 7 | expect(res).toBe('text/foo'); 8 | }); 9 | it('otherwise returns "application/json"', () => { 10 | const res = computeContentType('foo'); 11 | expect(res).toBe('application/json'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /__tests__/computeParser.js: -------------------------------------------------------------------------------- 1 | import computeParser from '../src/core/utils/computeParser'; 2 | import { defaultParserMap } from '../src/core/defaults'; 3 | 4 | const testMap = { 5 | 'foo/bar': 'foobar', 6 | 'foo/*': 'foo', 7 | '*/*': 'default', 8 | }; 9 | 10 | describe('computeParser', () => { 11 | it('uses the default map if none specified', () => { 12 | const [contentTypeHeader, value] = Object.entries(defaultParserMap)[0]; 13 | const res = computeParser(contentTypeHeader); 14 | expect(res).toBe(value); 15 | }); 16 | it('defaults to "text"', () => { 17 | const res = computeParser('foo'); 18 | expect(res).toBe('text'); 19 | }); 20 | it('uses `parserMap` if specified', () => { 21 | const res = computeParser('foo', testMap); 22 | expect(res).toBe('foo'); 23 | }); 24 | it('works with complete contentType (ex: "application/json")', () => { 25 | const res = computeParser('foo/bar', testMap); 26 | expect(res).toBe('foobar'); 27 | }); 28 | it('works with category from the contentType (ex: "application")', () => { 29 | const res = computeParser('foo', testMap); 30 | expect(res).toBe('foo'); 31 | }); 32 | it('defaults to the category if specic type not found (ex: "application/json" => "application/*"', () => { 33 | const res = computeParser('foo/baz', testMap); 34 | expect(res).toBe('foo'); 35 | }); 36 | it('respects the `*/*` key in the parser map', () => { 37 | const res = computeParser('blah', testMap); 38 | expect(res).toBe('default'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /__tests__/computeStreamParser.test.js: -------------------------------------------------------------------------------- 1 | import computeParse from '../src/core/utils/computeParser'; 2 | 3 | describe('computeStreamParser', () => { 4 | it('if "text/event-stream" is passed to the parser, returns "stream" type', () => { 5 | const contentTypeHeader = 'text/event-stream'; 6 | const res = computeParse(contentTypeHeader, null); 7 | expect(res).toBe('stream'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /__tests__/deepSpreadOptions.test.js: -------------------------------------------------------------------------------- 1 | import deepSpreadOptions from '../src/core/utils/deepSpreadOptions'; 2 | 3 | const cases = [ 4 | [undefined, undefined, {}], 5 | [{ a: 'b' }, { a: 'c' }, { a: 'c' }], 6 | [undefined, { a: 'c' }, { a: 'c' }], 7 | [{ a: 'b' }, undefined, { a: 'b' }], 8 | [{ b: 'b' }, { c: 'c' }, { b: 'b', c: 'c' }], 9 | [{ a: { a: 'a' } }, { a: 'b' }, { a: 'b' }], 10 | [{ a: 'b' }, { a: { a: 'c' } }, { a: { 0: 'b', a: 'c' } }], 11 | [{ a: { a: 'b' } }, { a: { a: 'c' } }, { a: { a: 'c' } }], 12 | [{ a: { a: 'b' } }, {}, { a: { a: 'b' } }], 13 | [{ a: { a: 'a' } }, { a: { b: 'b' } }, { a: { a: 'a', b: 'b' } }], 14 | ]; 15 | 16 | describe('deepSpreadOptions', () => { 17 | cases.forEach(([a, b, expectedResult], idx) => { 18 | it(`works just fine - ${idx}`, () => { 19 | const result = deepSpreadOptions(a, b); 20 | expect(result).toEqual(expectedResult); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /__tests__/jsdom.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import thwack from '../src'; 5 | import { createMockFetch } from '../__test_utils__/jestUtils'; 6 | import runBaseTests from '../__test_utils__/thwackBase'; 7 | import runEventTests from '../__test_utils__/thwackEvents'; 8 | 9 | describe('thwack running on JSDOM', () => { 10 | it('returns a promise', () => { 11 | const fetch = createMockFetch(); 12 | const fn = thwack('http://foo.com', { fetch }); 13 | expect(fn instanceof Promise).toBe(true); 14 | }); 15 | 16 | it('can be passed a relative URL with the origin of the window.location', async () => { 17 | const fetch = createMockFetch(); 18 | const { origin, pathname } = window.location; 19 | await thwack('foo', { fetch }); 20 | expect(fetch).toBeCalledWith( 21 | `${origin}${pathname}foo`, 22 | expect.objectContaining({}) 23 | ); 24 | }); 25 | 26 | it('works if a relative URL is given without specifying baseURL', async () => { 27 | const fetch = createMockFetch(); 28 | let worked = true; 29 | try { 30 | await thwack('foo', { fetch }); 31 | } catch (ex) { 32 | worked = false; 33 | } 34 | expect(worked).toBe(true); 35 | }); 36 | it('works if a relative URL is given but you specify a baseURL', async () => { 37 | const fetch = createMockFetch(); 38 | let worked = true; 39 | try { 40 | await thwack('foo', { fetch, baseURL: 'http://bar.com' }); 41 | } catch (ex) { 42 | worked = false; 43 | } 44 | expect(worked).toBe(true); 45 | }); 46 | runBaseTests(); 47 | runEventTests(); 48 | }); 49 | -------------------------------------------------------------------------------- /__tests__/node.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | import thwack from '../src'; 5 | import { createMockFetch } from '../__test_utils__/jestUtils'; 6 | import runBaseTests from '../__test_utils__/thwackBase'; 7 | import runEventTests from '../__test_utils__/thwackEvents'; 8 | 9 | describe('thwack running on NodeJS', () => { 10 | it('returns a promise', () => { 11 | const fetch = createMockFetch(); 12 | const fn = thwack('http://foo.com', { fetch }); 13 | expect(fn instanceof Promise).toBe(true); 14 | }); 15 | it('throws if a relative URL is given without specifying baseURL', async () => { 16 | const fetch = createMockFetch(); 17 | let worked = true; 18 | try { 19 | await thwack('foo', { fetch }); 20 | } catch (ex) { 21 | worked = false; 22 | } 23 | expect(worked).toBe(false); 24 | }); 25 | it('works if a relative URL is given but you specify a baseURL', async () => { 26 | const fetch = createMockFetch(); 27 | let worked = true; 28 | try { 29 | await thwack('foo', { fetch, baseURL: 'http://bar.com' }); 30 | } catch (ex) { 31 | worked = false; 32 | } 33 | expect(worked).toBe(true); 34 | }); 35 | 36 | runBaseTests(); 37 | runEventTests(); 38 | }); 39 | -------------------------------------------------------------------------------- /__tests__/sortByEntry.test.js: -------------------------------------------------------------------------------- 1 | import sortByEntry from '../src/core/utils/buildUrl/sortByEntry'; 2 | 3 | describe('sortByEntry', () => { 4 | it('returns a -1 if the key from A < the key from B', () => { 5 | const res = sortByEntry(['A', 'A'], ['B', 'B']); 6 | expect(res).toBe(-1); 7 | }); 8 | it('returns a 1 if the key from A > the key from B', () => { 9 | const res = sortByEntry(['B', 'B'], ['A', 'A']); 10 | expect(res).toBe(1); 11 | }); 12 | it('returns a 1 if the key from A = the key from B', () => { 13 | const res = sortByEntry(['A', 'A'], ['A', 'A']); 14 | expect(res).toBe(0); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /node-test.js: -------------------------------------------------------------------------------- 1 | const thwack = require('./node10'); 2 | 3 | (async () => { 4 | const { data } = await thwack.get('http://donavon.com/'); 5 | console.log(data); 6 | })(); 7 | -------------------------------------------------------------------------------- /node/index.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | const thwack = require('..'); 3 | 4 | thwack.defaults.fetch = fetch; 5 | 6 | module.exports = thwack; 7 | -------------------------------------------------------------------------------- /node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": "../types/index.d.ts", 3 | "peerDependencies": { 4 | "node-fetch": "^2.6.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /node10/index.js: -------------------------------------------------------------------------------- 1 | require('core-js/features/array/flat'); 2 | require('core-js/features/object/from-entries'); 3 | 4 | const thwack = require('../node'); 5 | 6 | module.exports = thwack; 7 | -------------------------------------------------------------------------------- /node10/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": "../types/index.d.ts", 3 | "peerDependencies": { 4 | "core-js": "^3.6.5" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thwack", 3 | "publishConfig": { 4 | "access": "public", 5 | "tag": "latest" 6 | }, 7 | "version": "0.6.2", 8 | "description": "A tiny modern data fetching solution.", 9 | "main": "dist/thwack.js", 10 | "umd:main": "dist/thwack.umd.js", 11 | "module": "dist/thwack.m.js", 12 | "source": "src/default/index.js", 13 | "types": "types/index.d.ts", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/donavon/thwack.git" 18 | }, 19 | "scripts": { 20 | "prepublishOnly": "npm run build", 21 | "lint": "eslint src", 22 | "test": "jest --verbose --coverage --silent", 23 | "test:watch": "jest --watch --runInBand --silent", 24 | "prebuild": "npm run lint && rimraf dist && npm t", 25 | "build": "microbundle" 26 | }, 27 | "files": [ 28 | "dist", 29 | "node", 30 | "node10", 31 | "README.md", 32 | "LICENSE", 33 | "package.json", 34 | "types" 35 | ], 36 | "keywords": [ 37 | "utils", 38 | "lib", 39 | "fetch", 40 | "axios", 41 | "xmlhttprequest", 42 | "xhr", 43 | "http", 44 | "ajax", 45 | "promise", 46 | "nodejs", 47 | "react-native" 48 | ], 49 | "engines": { 50 | "node": ">=10" 51 | }, 52 | "author": "Donavon West (https://github.com/donavon)", 53 | "dependencies": { 54 | "@thwack/resolve": "^1.0.0", 55 | "core-js": "^3.6.5", 56 | "node-fetch": "^2.6.1" 57 | }, 58 | "devDependencies": { 59 | "@babel/core": "^7.10.5", 60 | "@babel/preset-env": "^7.10.4", 61 | "babel-core": "^7.0.0-bridge.0", 62 | "babel-eslint": "^10.1.0", 63 | "babel-jest": "^25.5.1", 64 | "eslint": "^6.8.0", 65 | "eslint-config-airbnb-base": "^14.2.0", 66 | "eslint-plugin-import": "^2.22.0", 67 | "eslint-plugin-jest": "^23.18.0", 68 | "jest": "^25.5.4", 69 | "microbundle": "^0.12.3", 70 | "rimraf": "^3.0.2" 71 | }, 72 | "jest": { 73 | "collectCoverageFrom": [ 74 | "**/src/**/*.js" 75 | ], 76 | "coverageThreshold": { 77 | "global": { 78 | "branches": 100, 79 | "functions": 100, 80 | "lines": 100, 81 | "statements": 100 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/core/Thwack.js: -------------------------------------------------------------------------------- 1 | import request from './request'; 2 | import combineOptions from './utils/combineOptions'; 3 | import buildUrl from './utils/buildUrl'; 4 | import resolveOptionsFromArgs from './utils/resolveOptionsFromArgs'; 5 | import { events } from './events'; 6 | 7 | export const createThwack = function (defaults, parent) { 8 | // createThwack returns a function that accepts (url, options) 9 | // or just (options), but calls request with just (options) 10 | const instance = (...args) => instance.request(resolveOptionsFromArgs(args)); 11 | 12 | instance.defaults = defaults; 13 | 14 | instance.request = request.bind(instance); 15 | 16 | // Create convenience methods on this instance 17 | ['GET', 'DELETE', 'HEAD'].forEach((method) => { 18 | const methodKey = method.toLowerCase(); 19 | instance[methodKey] = (url, options) => 20 | instance.request({ ...options, method, url }); 21 | }); 22 | 23 | ['PUT', 'POST', 'PATCH'].forEach((method) => { 24 | const methodKey = method.toLowerCase(); 25 | instance[methodKey] = (url, data, options) => 26 | instance.request({ ...options, method, url, data }); 27 | }); 28 | 29 | instance.getUri = (options) => { 30 | const combinedOptions = combineOptions(instance, options); 31 | return buildUrl(combinedOptions); 32 | }; 33 | 34 | instance.create = (options) => { 35 | const createOptions = combineOptions(instance, options); 36 | return createThwack(createOptions, instance); 37 | }; 38 | 39 | events(instance, parent); 40 | 41 | return instance; 42 | }; 43 | -------------------------------------------------------------------------------- /src/core/ThwackErrors/ThwackResponseError.js: -------------------------------------------------------------------------------- 1 | const thwackResponseError = 'ThwackResponseError'; 2 | 3 | class ThwackResponseError extends Error { 4 | constructor(thwackResponse) { 5 | super(thwackResponseError); 6 | this.message = `Status ${thwackResponse.status}`; 7 | this.name = thwackResponseError; 8 | this.thwackResponse = thwackResponse; 9 | } 10 | } 11 | 12 | export default ThwackResponseError; 13 | -------------------------------------------------------------------------------- /src/core/ThwackEvents/ThwackDataEvent.js: -------------------------------------------------------------------------------- 1 | import ThwackResponseBaseEvent from './ThwackResponseBaseEvent'; 2 | 3 | export default class ThwackDataEvent extends ThwackResponseBaseEvent { 4 | constructor(thwackResponse) { 5 | super('data', thwackResponse); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/ThwackEvents/ThwackErrorEvent.js: -------------------------------------------------------------------------------- 1 | import ThwackResponseBaseEvent from './ThwackResponseBaseEvent'; 2 | 3 | export default class ThwackErrorEvent extends ThwackResponseBaseEvent { 4 | constructor(thwackResponse) { 5 | super('error', thwackResponse); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/ThwackEvents/ThwackEvent.js: -------------------------------------------------------------------------------- 1 | // TODO make this extend CustomEvent and use EventTarget some day? Maybe? 2 | export default class ThwackEvent { 3 | constructor(type, payload) { 4 | this.type = type; 5 | this.defaultPrevented = false; 6 | this.propagationStopped = false; 7 | this._payload = payload; 8 | } 9 | 10 | preventDefault() { 11 | this.defaultPrevented = true; 12 | } 13 | 14 | stopPropagation() { 15 | this.propagationStopped = true; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/core/ThwackEvents/ThwackRequestEvent.js: -------------------------------------------------------------------------------- 1 | import ThwackEvent from './ThwackEvent'; 2 | 3 | export default class ThwackRequestEvent extends ThwackEvent { 4 | constructor(options) { 5 | super('request', options); 6 | 7 | Object.defineProperty(this, 'options', { 8 | get() { 9 | return this._payload; 10 | }, 11 | }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/core/ThwackEvents/ThwackResponseBaseEvent.js: -------------------------------------------------------------------------------- 1 | import ThwackEvent from './ThwackEvent'; 2 | 3 | export default class ThwackResponseBaseEvent extends ThwackEvent { 4 | constructor(type, thwackResponse) { 5 | super(type, thwackResponse); 6 | 7 | Object.defineProperty(this, 'thwackResponse', { 8 | get() { 9 | return this._payload; 10 | }, 11 | }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/core/ThwackEvents/ThwackResponseEvent.js: -------------------------------------------------------------------------------- 1 | import ThwackResponseBaseEvent from './ThwackResponseBaseEvent'; 2 | 3 | export default class ThwackResponseEvent extends ThwackResponseBaseEvent { 4 | constructor(thwackResponse) { 5 | super('response', thwackResponse); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/ThwackResponse.js: -------------------------------------------------------------------------------- 1 | import { defaultValidateStatus } from './utils/defaultValidateStatus'; 2 | 3 | export default class ThwackResponse { 4 | constructor(response, options) { 5 | const { 6 | status = 200, 7 | statusText = `Status ${status}`, 8 | data, 9 | headers = {}, 10 | } = response; 11 | this.status = status; 12 | this.statusText = statusText; 13 | this.ok = defaultValidateStatus(status); 14 | this.data = data; 15 | this.headers = 16 | typeof headers.entries === 'function' // TODO why can't I do `headers instanceof Headers`? 17 | ? Object.fromEntries(headers.entries()) 18 | : headers; 19 | this.options = options; 20 | this.response = response; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/core/defaults.js: -------------------------------------------------------------------------------- 1 | export const APPLICATION_JSON = 'application/json'; 2 | export const CONTENT_TYPE = 'content-type'; 3 | 4 | export const defaultOptions = { 5 | maxDepth: 5, 6 | params: {}, 7 | headers: { 8 | accept: `${APPLICATION_JSON}, text/plain, */*`, 9 | }, 10 | buildURL: 'complete', 11 | }; 12 | 13 | export const defaultParserMap = { 14 | [APPLICATION_JSON]: 'json', 15 | 'multipart/form-data': 'formData', 16 | '*/*': 'text', 17 | 'text/event-stream': 'stream', 18 | }; 19 | -------------------------------------------------------------------------------- /src/core/events.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | export const events = (instance, parent) => { 4 | // private properties 5 | let depth = 0; 6 | const listeners = { 7 | request: [], 8 | response: [], 9 | data: [], 10 | error: [], 11 | }; 12 | 13 | instance.addEventListener = (type, callback) => { 14 | listeners[type].push(callback); 15 | }; 16 | 17 | instance.removeEventListener = (type, callback) => { 18 | listeners[type] = listeners[type].filter( 19 | (listener) => listener !== callback 20 | ); 21 | }; 22 | 23 | instance.dispatchEvent = (event) => 24 | listeners[event.type] 25 | .reduce( 26 | (promise, listener) => 27 | promise 28 | // call our next callback (unless propagationStopped was called) 29 | .then( 30 | // TODO use nullish coalescing when supported by microbundle, like this: 31 | // () => !event.propagationStopped ?? listener(event) 32 | () => { 33 | depth += 1; 34 | if (depth >= 5) { 35 | throw new Error('Thwack: maximum request depth reached'); 36 | } 37 | return event.propagationStopped ? undefined : listener(event); 38 | } 39 | ) 40 | .finally(() => { 41 | depth -= 1; 42 | }) 43 | // if callback returned payload (or a promise that resolves to payload) 44 | // then set the payload in the event object 45 | .then((payload) => { 46 | if (payload !== undefined) { 47 | event._payload = payload; 48 | } 49 | }), 50 | // start with the promise from the parent or a resolved promise if no parent 51 | parent ? parent.dispatchEvent(event) : Promise.resolve() 52 | ) 53 | // return the event payload to the caller 54 | .then(() => event._payload); 55 | }; 56 | -------------------------------------------------------------------------------- /src/core/fetcher.js: -------------------------------------------------------------------------------- 1 | import { APPLICATION_JSON, CONTENT_TYPE } from './defaults'; 2 | import buildUrl from './utils/buildUrl'; 3 | import computeContentType from './utils/computeContentType'; 4 | 5 | const fetcher = async function (options) { 6 | const { 7 | url, 8 | baseURL, 9 | fetch, 10 | data, 11 | headers, 12 | params, 13 | responseParserMap, 14 | responseType, 15 | maxDepth, // don't pass to fetch 16 | ...rest 17 | } = options; 18 | 19 | if (!fetch) { 20 | throw new Error( 21 | 'Thwack: Invalid options object during request. Check your event callbacks.' 22 | ); 23 | } 24 | 25 | // choose content-type based on the type of data 26 | if (data && !headers[CONTENT_TYPE]) { 27 | headers[CONTENT_TYPE] = computeContentType(data); 28 | } 29 | const body = 30 | data && headers[CONTENT_TYPE] === APPLICATION_JSON 31 | ? JSON.stringify(data) 32 | : data; 33 | 34 | const fetchUrl = buildUrl({ ...options, url, baseURL }); 35 | 36 | const fetchOptions = { 37 | ...(Object.keys(headers).length !== 0 && { headers }), // add if not empty object 38 | ...(!!body && { body, method: 'POST' }), // if body not empty add it and default method to POST 39 | ...rest, 40 | }; 41 | 42 | return fetch(fetchUrl, fetchOptions); 43 | }; 44 | 45 | export default fetcher; 46 | -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | import { createThwack } from './Thwack'; 2 | import ThwackResponseError from './ThwackErrors/ThwackResponseError'; 3 | import ThwackResponse from './ThwackResponse'; 4 | import { defaultOptions } from './defaults'; 5 | 6 | // expose a single Thwack instance using top level defaults 7 | const mainInstance = createThwack(defaultOptions); 8 | 9 | // export these "static" methods only on the main instance 10 | mainInstance.ThwackResponseError = ThwackResponseError; 11 | mainInstance.ThwackResponse = ThwackResponse; 12 | mainInstance.all = (promises) => Promise.all(promises); 13 | mainInstance.spread = (callback) => (results) => callback(...results); 14 | 15 | export default mainInstance; 16 | -------------------------------------------------------------------------------- /src/core/request.js: -------------------------------------------------------------------------------- 1 | import fetcher from './fetcher'; 2 | import returnResponse from './returnResponse'; 3 | import ThwackResponseEvent from './ThwackEvents/ThwackResponseEvent'; 4 | import ThwackRequestEvent from './ThwackEvents/ThwackRequestEvent'; 5 | import combineOptions from './utils/combineOptions'; 6 | import ThwackResponse from './ThwackResponse'; 7 | 8 | const request = async function (requestOptions) { 9 | // Compute the options to use 10 | // 1. combine options from: 11 | // a. passed `requestOptions` 12 | // b. `this.defaults` (i.e. when instance was created) 13 | // c. and all parents' `this.defaults` 14 | // 2. dispatch those options to any listeners (they _may_ change them) 15 | // by returning a new options as payload 16 | // 3. set options to the returned payload unless... 17 | // payload is a ThwackResponse, in which skip calling fetcher 18 | const { maxDepth, ...combinedOptions } = combineOptions(this, requestOptions); 19 | 20 | // before we do anything, dispatch a "request" event with the combined options 21 | // one of the callbacks may optionally return a ThwackResponse (if preventDefault) 22 | // or possibly alter the options 23 | const requestEvent = new ThwackRequestEvent(combinedOptions); 24 | const payload = await this.dispatchEvent(requestEvent); 25 | const { defaultPrevented } = requestEvent; 26 | if (defaultPrevented) { 27 | // bail out with a ThwackResponse (will be checked in returnResponse) 28 | return returnResponse.call(this, payload); 29 | } 30 | 31 | // assume payload is options (fetcher will check for sure) 32 | const options = payload; 33 | const response = await fetcher(options); 34 | const responseEvent = new ThwackResponseEvent( 35 | new ThwackResponse(response, options) 36 | ); 37 | 38 | // no need to check preventDefault here as both the default and the alternative 39 | // would both lead to returnResponse 40 | return returnResponse.call(this, await this.dispatchEvent(responseEvent)); 41 | }; 42 | 43 | export default request; 44 | -------------------------------------------------------------------------------- /src/core/returnResponse.js: -------------------------------------------------------------------------------- 1 | import { CONTENT_TYPE } from './defaults'; 2 | import { defaultValidateStatus } from './utils/defaultValidateStatus'; 3 | import computeParser from './utils/computeParser'; 4 | import compatParser from './utils/compatParser'; 5 | import ThwackResponse from './ThwackResponse'; 6 | import ThwackDataEvent from './ThwackEvents/ThwackDataEvent'; 7 | import ThwackErrorEvent from './ThwackEvents/ThwackErrorEvent'; 8 | import ThwackResponseError from './ThwackErrors/ThwackResponseError'; 9 | 10 | async function fetchResponseData(thwackResponse) { 11 | const { response, options } = thwackResponse; 12 | 13 | // if `response` is NOT a instance of `Response` then it is "synthetic" 14 | // (i.e. created from a mock source) so don't stream and parse the body. 15 | // Instead, return the synthetic `thwackResponse.data` field. 16 | // Note: React Native does NOT expose `body` so stream is unsupported 17 | // see https://github.com/facebook/react-native/issues/27741 18 | const { responseType, responseParserMap } = options; 19 | const contentTypeHeader = thwackResponse.headers[CONTENT_TYPE]; 20 | const responseParserType = 21 | responseType || computeParser(contentTypeHeader, responseParserMap); 22 | const compatResponseParserType = compatParser(responseParserType); // axios > thwack mapping 23 | 24 | if (compatResponseParserType === 'stream') { 25 | return response.body; 26 | } 27 | 28 | if (response[compatResponseParserType]) { 29 | return response[compatResponseParserType](); 30 | } 31 | 32 | // Return the synthetic data 33 | return thwackResponse.data; 34 | } 35 | 36 | // return either a thwackResponse (if 2xx) or throw a ThwackResponseError 37 | const returnResponse = async function (thwackResponse) { 38 | if (!(thwackResponse instanceof ThwackResponse)) { 39 | throw new Error('Thwack: callback must return a ThwackResponse'); 40 | } 41 | // eslint-disable-next-line no-param-reassign 42 | thwackResponse.data = await fetchResponseData(thwackResponse); 43 | 44 | const { validateStatus = defaultValidateStatus } = thwackResponse.options; 45 | 46 | // should we 47 | if (validateStatus(thwackResponse.status)) { 48 | // dispatch a "data" event here 49 | const dataEvent = new ThwackDataEvent(thwackResponse); 50 | const payload = await this.dispatchEvent(dataEvent); 51 | const { defaultPrevented } = dataEvent; 52 | if (defaultPrevented && !payload.ok) { 53 | return returnResponse.call(this, payload); 54 | } 55 | return payload; 56 | } 57 | 58 | // if NOT ok then throw, but first dispatch an "error" event 59 | const errorEvent = new ThwackErrorEvent(thwackResponse); 60 | const payload = await this.dispatchEvent(errorEvent); 61 | const { defaultPrevented } = errorEvent; 62 | if (defaultPrevented && payload.ok) { 63 | return returnResponse.call(this, payload); 64 | } 65 | 66 | if (!(payload instanceof ThwackResponse)) { 67 | throw new Error('Thwack: callback must return a ThwackResponse'); 68 | } 69 | 70 | throw new ThwackResponseError(payload); 71 | }; 72 | 73 | export default returnResponse; 74 | -------------------------------------------------------------------------------- /src/core/utils/buildUrl/defaultParamsSerializer.js: -------------------------------------------------------------------------------- 1 | import sortByEntry from './sortByEntry'; 2 | 3 | export const defaultParamSerializer = (params) => 4 | Object.entries(params) 5 | .sort(sortByEntry) 6 | .map( 7 | ([key, value]) => 8 | `${encodeURIComponent(key)}=${encodeURIComponent(value)}` 9 | ) 10 | .join('&'); 11 | -------------------------------------------------------------------------------- /src/core/utils/buildUrl/index.js: -------------------------------------------------------------------------------- 1 | import { resolve } from '@thwack/resolve'; 2 | import joinSearch from './joinSearch'; 3 | import { defaultParamSerializer } from './defaultParamsSerializer'; 4 | import { substituteParamsInPath } from './substituteParamsInPath'; 5 | 6 | const buildUrl = (options) => { 7 | const { 8 | url, 9 | baseURL = url, // resolver throws if baseURL is empty (per spec) 10 | params, 11 | resolver = resolve, 12 | paramsSerializer = defaultParamSerializer, 13 | } = options; 14 | 15 | // resolve url=foo base=http://ex.co => http://ex.co/foo 16 | const absoluteUrl = resolver(url, baseURL); 17 | 18 | // convert http://ex.co/foo:id => http://ex.co/foo/123 19 | const [moreUrl, remainingParams] = substituteParamsInPath( 20 | absoluteUrl, 21 | params 22 | ); 23 | 24 | // url=http://ex.co/foo/123 params={y:2, x:1} => http://ex.co/foo/123?x=1&y=2 25 | const search = paramsSerializer(remainingParams); 26 | return joinSearch(moreUrl, search); 27 | }; 28 | 29 | export default buildUrl; 30 | -------------------------------------------------------------------------------- /src/core/utils/buildUrl/joinSearch.js: -------------------------------------------------------------------------------- 1 | const joinSearch = (url, search) => { 2 | const concatChar = url.includes('?') ? '&' : '?'; 3 | return `${url}${search ? concatChar : ''}${search}`; 4 | }; 5 | 6 | export default joinSearch; 7 | -------------------------------------------------------------------------------- /src/core/utils/buildUrl/sortByEntry.js: -------------------------------------------------------------------------------- 1 | // take a pair from Array.entries (key/value) and sort the key alphabetically 2 | const sortByEntry = ([a], [b]) => { 3 | if (a < b) { 4 | return -1; 5 | } 6 | if (a > b) { 7 | return 1; 8 | } 9 | return 0; 10 | }; 11 | 12 | export default sortByEntry; 13 | -------------------------------------------------------------------------------- /src/core/utils/buildUrl/substituteParamsInPath.js: -------------------------------------------------------------------------------- 1 | export const substituteParamsInPath = (path, params) => { 2 | // substitute any :name in the path for params.name 3 | const remainingParams = { ...params }; 4 | const newPath = path 5 | .split('/') 6 | .map((segment) => { 7 | if (segment.startsWith(':')) { 8 | const key = segment.substr(1); 9 | const value = remainingParams[key]; 10 | delete remainingParams[key]; 11 | return encodeURIComponent(value); 12 | } 13 | return segment; 14 | }) 15 | .join('/'); 16 | 17 | return [newPath, remainingParams]; 18 | }; 19 | -------------------------------------------------------------------------------- /src/core/utils/combineOptions.js: -------------------------------------------------------------------------------- 1 | import deepSpreadOptions from './deepSpreadOptions'; 2 | 3 | const combineOptions = (instance, options) => 4 | deepSpreadOptions(instance.defaults, options); 5 | 6 | export default combineOptions; 7 | -------------------------------------------------------------------------------- /src/core/utils/compatParser.js: -------------------------------------------------------------------------------- 1 | // Axios options for options.responseType are: 2 | // 'arraybuffer', 'document', 'json', 'text', 'stream', 'blob' 3 | 4 | const compatMap = { 5 | arraybuffer: 'arrayBuffer', 6 | document: 'formData', 7 | }; 8 | 9 | const compatParser = (responseParserType) => 10 | compatMap[responseParserType] || responseParserType; 11 | 12 | export default compatParser; 13 | -------------------------------------------------------------------------------- /src/core/utils/computeContentType.js: -------------------------------------------------------------------------------- 1 | import { APPLICATION_JSON } from '../defaults'; 2 | 3 | // Only test for instanceodf Blob if running on a system that supports them. 4 | // Blobs are not supported on NodeJS, for example. 5 | const isBlobSupported = typeof Blob !== 'undefined'; 6 | 7 | // If the data is a Blob, grab its type 8 | // else default to 'application/json' 9 | const computeContentType = (data) => 10 | isBlobSupported && data instanceof Blob ? data.type : APPLICATION_JSON; 11 | 12 | export default computeContentType; 13 | -------------------------------------------------------------------------------- /src/core/utils/computeParser.js: -------------------------------------------------------------------------------- 1 | import { defaultParserMap } from '../defaults'; 2 | 3 | const computeParser = (contentTypeHeader = '', parserMap) => { 4 | // grab just the actual type 5 | // ex: 'application/json; charset=utf-8' => 'application/json' 6 | const [contentType] = contentTypeHeader.split(';'); 7 | const contentTypeTrimmed = contentType.trim(); // just in case there's a misbehaving server 8 | 9 | // grab just the "category" 10 | // ex: 'application/json' => 'application' 11 | const [contentTypeMajor] = contentTypeTrimmed.split('/'); 12 | const mergedParserMap = { ...defaultParserMap, ...parserMap }; 13 | 14 | const parserType = 15 | mergedParserMap[contentTypeTrimmed] || // ex: 'application/json' 16 | mergedParserMap[`${contentTypeMajor}/*`] || // ex: 'application/*' 17 | mergedParserMap['*/*']; // default to parser type specified by '*/*' 18 | 19 | return parserType; 20 | }; 21 | 22 | export default computeParser; 23 | -------------------------------------------------------------------------------- /src/core/utils/deepSpreadOptions.js: -------------------------------------------------------------------------------- 1 | const isObject = (obj) => 2 | Object.prototype.toString.call(obj) === '[object Object]'; 3 | 4 | const deepSpreadOptions = (createOptions = {}, options = {}) => { 5 | const allKeys = Object.keys({ 6 | ...createOptions, 7 | ...options, 8 | }); 9 | 10 | return allKeys.reduce((combined, key) => { 11 | // TODO use nullish coalescing when supported by microbundle, like this: 12 | // const value = options[key] ?? createOptions[key]; 13 | const value = options[key] != null ? options[key] : createOptions[key]; 14 | 15 | return { 16 | ...combined, 17 | [key]: isObject(value) 18 | ? deepSpreadOptions(createOptions[key], options[key]) 19 | : value, 20 | }; 21 | }, {}); 22 | }; 23 | 24 | export default deepSpreadOptions; 25 | -------------------------------------------------------------------------------- /src/core/utils/defaultValidateStatus.js: -------------------------------------------------------------------------------- 1 | export const defaultValidateStatus = (status) => status >= 200 && status < 300; 2 | -------------------------------------------------------------------------------- /src/core/utils/resolveOptionsFromArgs.js: -------------------------------------------------------------------------------- 1 | const resolveOptionsFromArgs = (args) => 2 | args.length > 1 // url, options? 3 | ? { ...args[1], url: args[0] } // yes, use a combined options 4 | : args[0]; // no, use the original options 5 | 6 | export default resolveOptionsFromArgs; 7 | -------------------------------------------------------------------------------- /src/default/index.js: -------------------------------------------------------------------------------- 1 | // This is the file used when you import from 'thwack' 2 | import core from '../core'; 3 | 4 | if (typeof window !== 'undefined') { 5 | const { fetch, location } = window; 6 | core.defaults.fetch = fetch; 7 | 8 | /* istanbul ignore next */ 9 | if (location) { 10 | const { origin, pathname } = location; 11 | core.defaults.baseURL = `${origin}${pathname}`; 12 | } 13 | } 14 | 15 | export default core; 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import thwack from './default'; 2 | 3 | export default thwack; 4 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface KeyValue { 2 | [key: string]: string; 3 | } 4 | 5 | export type ThwackEventType = 'request' | 'response' | 'data' | 'error'; 6 | 7 | export type ResponseType = 8 | | 'arraybuffer' 9 | | 'arrayBuffer' 10 | | 'formdata' 11 | | 'formData' 12 | | 'json' 13 | | 'text' 14 | | 'stream' 15 | | 'blob'; 16 | 17 | export type Method = 18 | | 'get' 19 | | 'GET' 20 | | 'delete' 21 | | 'DELETE' 22 | | 'head' 23 | | 'HEAD' 24 | | 'post' 25 | | 'POST' 26 | | 'put' 27 | | 'PUT' 28 | | 'PATCH'; 29 | 30 | export interface ThwackOptions extends RequestInit { 31 | method?: Method; // One of the supported HTTP request methods. 32 | url?: string; // A string containing a URL with optional `:name` params. 33 | baseURL?: string; // A string containing a base URL to build a FQ URL 34 | fetch?: (url: string, options?: RequestInit) => Promise; // A function that implements `window.fetch`. Default = `window.fetch`. 35 | params?: KeyValue; // A key/value object used for search parameters. 36 | data?: any; // The data that you would like to send. Not valid for GET and HEAD methods. 37 | responseParserMap?: KeyValue; 38 | responseType?: ResponseType; 39 | headers?: KeyValue | Headers; 40 | } 41 | 42 | interface ThwackSyntheticResponse { 43 | status: number; 44 | statusText?: string; 45 | data?: T; 46 | headers?: KeyValue | Headers; 47 | } 48 | 49 | export interface ThwackResponse { 50 | status: number; 51 | statusText: string; 52 | headers: KeyValue; 53 | data: T; 54 | response: Response | ThwackSyntheticResponse; 55 | options: ThwackOptions; 56 | } 57 | 58 | interface ThwackResponseConstructor { 59 | new ( 60 | response: Response | ThwackSyntheticResponse, 61 | options: ThwackOptions 62 | ): ThwackResponse; 63 | } 64 | 65 | export interface ThwackResponseError extends Error { 66 | thwackResponse: ThwackResponse; 67 | } 68 | 69 | export interface ThwackEvent extends Event {} 70 | export interface ThwackResponseBaseEvent extends ThwackEvent { 71 | thwackResponse: ThwackResponse; 72 | } 73 | export interface ThwackRequestEvent extends ThwackEvent { 74 | type: 'request'; 75 | options: ThwackOptions; 76 | } 77 | export interface ThwackResponseEvent extends ThwackResponseBaseEvent { 78 | type: 'response'; 79 | } 80 | export interface ThwackDataEvent extends ThwackResponseBaseEvent { 81 | type: 'data'; 82 | } 83 | export interface ThwackErrorEvent extends ThwackResponseBaseEvent { 84 | type: 'error'; 85 | } 86 | 87 | export type ThwackCallbackType = 88 | | void 89 | | ThwackResponse 90 | | Promise; 91 | 92 | export type ThwackRequestCallbackType = 93 | | void 94 | | ThwackResponse 95 | | Promise 96 | | ThwackOptions 97 | | Promise; 98 | 99 | export interface ThwackInstance { 100 | (url: string, options?: ThwackOptions): Promise; 101 | 102 | request>(config: ThwackOptions): Promise; 103 | get>( 104 | url: string, 105 | config?: ThwackOptions 106 | ): Promise; 107 | delete>( 108 | url: string, 109 | config?: ThwackOptions 110 | ): Promise; 111 | head>( 112 | url: string, 113 | config?: ThwackOptions 114 | ): Promise; 115 | post>( 116 | url: string, 117 | data?: any, 118 | config?: ThwackOptions 119 | ): Promise; 120 | put>( 121 | url: string, 122 | data?: any, 123 | config?: ThwackOptions 124 | ): Promise; 125 | patch>( 126 | url: string, 127 | data?: any, 128 | config?: ThwackOptions 129 | ): Promise; 130 | 131 | create(config?: ThwackOptions): ThwackInstance; 132 | getUri(config?: ThwackOptions): string; 133 | 134 | defaults: ThwackOptions; 135 | 136 | addEventListener( 137 | type: 'request', 138 | callback: (event: ThwackRequestEvent) => ThwackRequestCallbackType 139 | ): void; 140 | removeEventListener( 141 | type: 'request', 142 | callback: (event: ThwackRequestEvent) => ThwackRequestCallbackType 143 | ): void; 144 | 145 | addEventListener( 146 | type: 'response', 147 | callback: (event: ThwackResponseEvent) => ThwackCallbackType 148 | ): void; 149 | removeEventListener( 150 | type: 'response', 151 | callback: (event: ThwackResponseEvent) => ThwackCallbackType 152 | ): void; 153 | 154 | addEventListener( 155 | type: 'data', 156 | callback: (event: ThwackDataEvent) => ThwackCallbackType 157 | ): void; 158 | removeEventListener( 159 | type: 'data', 160 | callback: (event: ThwackDataEvent) => ThwackCallbackType 161 | ): void; 162 | 163 | addEventListener( 164 | type: 'error', 165 | callback: (event: ThwackErrorEvent) => ThwackCallbackType 166 | ): void; 167 | removeEventListener( 168 | type: 'error', 169 | callback: (event: ThwackErrorEvent) => ThwackCallbackType 170 | ): void; 171 | } 172 | 173 | export interface ThwackMainInstance extends ThwackInstance { 174 | ThwackResponse: ThwackResponseConstructor; 175 | ThwackResponseError: ThwackResponseError; 176 | all(values: (T | Promise)[]): Promise; 177 | spread(callback: (...args: T[]) => R): (array: T[]) => R; 178 | } 179 | 180 | declare const thwack: ThwackMainInstance; 181 | 182 | export default thwack; 183 | --------------------------------------------------------------------------------