├── .babelrc ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SECURITY.md ├── docs ├── advanced-action-creators.md ├── advanced-setups.md ├── api.md ├── react-addons-api.md └── react-examples.md ├── package-lock.json ├── package.json ├── react-addons.js ├── src ├── actionCreators.js ├── addons │ └── react-addons.js ├── constants.js ├── errors.js ├── fetchUtils.js ├── index.js ├── promiseKeeper.js ├── reducers.js ├── reduxful.js ├── requestAdapter.js ├── selectors.js ├── types.js └── utils.js ├── tests ├── actionCreators.spec.js ├── fetchUtils.spec.js ├── fixtures │ └── mockApi.js ├── promiseKeeper.spec.js ├── react-addons.spec.js ├── reducers.spec.js ├── reduxful.spec.js ├── requestAdapter.spec.js ├── selectors.spec.js └── utils.spec.js ├── typings ├── fetchUtils.test.ts ├── index.d.ts ├── reduxful.test.ts └── utils.test.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 'godaddy', 'plugin:jest/recommended' ], 3 | rules : { 4 | "max-params": [ 2, 5 ] 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @godaddy/gasket-maintainers -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ## Summary 7 | 8 | 11 | 12 | ## Changelog 13 | 14 | 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Workflow to run CI and tests for all branches on push and on pull requests 2 | 3 | name: CI 4 | 5 | on: 6 | pull_request: 7 | push: 8 | branches: 9 | - 'main' 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [20, 22, 24] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'yarn' 24 | cache-dependency-path: 'yarn.lock' 25 | - run: yarn 26 | - run: yarn test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | .idea 4 | *.iml 5 | *.log 6 | *.bak 7 | .DS_Store 8 | 9 | lib 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | .idea 4 | *.iml 5 | *.log 6 | *.bak 7 | .DS_Store 8 | 9 | !lib 10 | 11 | ### ▲ .gitignore contents above 12 | ### ▼ additional ingore files for publishing 13 | 14 | tests 15 | .babelrc 16 | .eslintrc.js 17 | .travis.yml 18 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ### 1.5.0 4 | 5 | - Remove response.clone ([#65]) 6 | 7 | ### 1.4.4 8 | 9 | - Added additional catch for unknown errors on json decoding ([#62]) 10 | 11 | ### 1.4.3 12 | 13 | - Catch JSON decode for null responses ([#61]) 14 | 15 | ### 1.4.2 16 | 17 | - Upgrade dependencies 18 | 19 | ### 1.4.1 20 | 21 | - Add a generic to Resource ([#30]) 22 | 23 | ### 1.4.0 24 | 25 | - Make fetch options available to transformFn ([#29]) 26 | 27 | ### 1.3.6 28 | 29 | - Fix reducer action pattern check ([#23]) 30 | 31 | ### 1.3.5 32 | 33 | - Fix debouncing for same action name across instances ([#21]) 34 | - Fix reducing similar action names ([#22]) 35 | 36 | ### 1.3.4 37 | 38 | - Bump dependencies - regen lock file 39 | 40 | ### 1.3.3 41 | 42 | - Fix to have unique promise keeper instance per store ([#19]) 43 | - Update dependencies ([#17], [#18]) 44 | 45 | ### 1.3.2 46 | 47 | - Fix to always return null for 204 status ([#16]) 48 | 49 | ### 1.3.1 50 | 51 | - Fetch adapter handles 204 no content ([#15]) 52 | 53 | ### 1.3.0 54 | 55 | - Add TypeScript support ([#9]) 56 | 57 | ### 1.2.2 58 | 59 | - Normalize method names to uppercase ([#7]) 60 | 61 | ### 1.2.1 62 | 63 | - Use simple local polyfills for browser compatibility ([#4]) 64 | - Docs and lock fixes ([#5], [#6]) 65 | 66 | ### 1.2.0 67 | 68 | - No longer setting default headers by `makeFetchAdapter` ([#3]) 69 | Instead use `options` in [ApiConfig]. 70 | 71 | ### 1.1.0 72 | 73 | - Switch to use [transform-url] for url templating ([#1]) 74 | 75 | ### 1.0.0 76 | 77 | - Initial release. 78 | 79 | 80 | [#1]:https://github.com/godaddy/reduxful/pull/1 81 | [#3]:https://github.com/godaddy/reduxful/pull/3 82 | [#4]:https://github.com/godaddy/reduxful/pull/4 83 | [#5]:https://github.com/godaddy/reduxful/pull/5 84 | [#6]:https://github.com/godaddy/reduxful/pull/6 85 | [#7]:https://github.com/godaddy/reduxful/pull/7 86 | [#9]:https://github.com/godaddy/reduxful/pull/9 87 | [#15]:https://github.com/godaddy/reduxful/pull/15 88 | [#16]:https://github.com/godaddy/reduxful/pull/16 89 | [#17]:https://github.com/godaddy/reduxful/pull/17 90 | [#18]:https://github.com/godaddy/reduxful/pull/18 91 | [#19]:https://github.com/godaddy/reduxful/pull/19 92 | [#21]:https://github.com/godaddy/reduxful/pull/21 93 | [#22]:https://github.com/godaddy/reduxful/pull/22 94 | [#23]:https://github.com/godaddy/reduxful/pull/23 95 | [#29]:https://github.com/godaddy/reduxful/pull/29 96 | [#30]:https://github.com/godaddy/reduxful/pull/30 97 | [#61]:https://github.com/godaddy/reduxful/pull/61 98 | [#62]:https://github.com/godaddy/reduxful/pull/62 99 | [#65]:https://github.com/godaddy/reduxful/pull/65 100 | 101 | [transform-url]:https://github.com/godaddy/transform-url#readme 102 | [ApiConfig]:https://github.com/godaddy/reduxful/blob/master/docs/api.md#apiconfig--object 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 GoDaddy Operating Company, LLC. 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 | # Reduxful 2 | 3 | [![Version npm](https://img.shields.io/npm/v/reduxful.svg?style=flat-square)](https://www.npmjs.com/package/reduxful) 4 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/godaddy/reduxful/CI?style=flat-square)](https://github.com/godaddy/reduxful/actions/workflows/ci.yml) 5 | [![Coverage Status](https://img.shields.io/coveralls/godaddy/reduxful/master.svg?style=flat-square)](https://coveralls.io/r/godaddy/reduxful?branch=master) 6 | 7 | Client-side state is often related to data requested from RESTful web services. 8 | Reduxful aims to reduce the boilerplate for managing requested data in Redux 9 | state by generating **actions**, **reducers**, and **selectors** for you. 10 | 11 | ## Installation 12 | 13 | Install `reduxful` along with `redux-thunk` middleware. 14 | 15 | ```bash 16 | yarn add reduxful redux-thunk 17 | ``` 18 | or 19 | ```bash 20 | npm install --save reduxful redux-thunk 21 | ``` 22 | 23 | ## Documentation 24 | 25 | - [API Docs] 26 | - [Advanced setups] 27 | - [Advanced actionCreators] 28 | - React 29 | - [React Addons Docs] 30 | - [React Examples] 31 | 32 | ## Usage 33 | 34 | ### Describe an API 35 | 36 | ```js 37 | // doodad-api.js 38 | 39 | const apiDesc = { 40 | getDoodad: { 41 | url: 'http://api.my-service.com/doodads/:id' 42 | }, 43 | getDoodadList: { 44 | url: 'http://api.my-service.com/doodads' 45 | } 46 | }; 47 | ``` 48 | 49 | In its purest form, an [API description] is an object of keys following the 50 | _verbNoun_ convention by which to name a resource. The values associated 51 | with these are the [request description], which at a minimum requires an `url`. 52 | 53 | These resource names are also the names generated for the `actionCreators` 54 | and `selectors`. 55 | 56 | ### Make a Request Adapter 57 | 58 | Before we can call our endpoints, we need to set up a [Request Adapter] to use 59 | our AJAX or Fetch library of choice. A [convenience function][makeFetchAdapter] 60 | is available to make an adapter for the [Fetch API]. 61 | 62 | ```js 63 | // my-request-adapter.js 64 | 65 | import fetch from 'cross-fetch'; 66 | import { makeFetchAdapter } from 'reduxful'; 67 | 68 | export default makeFetchAdapter(fetch); 69 | ``` 70 | 71 | In this example, we are using [cross-fetch] which allows universal fetching 72 | on both server and browser. Any other library can be used, as long as 73 | an adapter is implemented for adjusting params and returning the expected 74 | Promise. 75 | 76 | ### Setup Reduxful Instance 77 | 78 | To generate Redux tooling around an API Description, pass it along with the 79 | name you want your Redux state property to be. Also, include the request adapter 80 | in the [api config] argument. 81 | 82 | ```js 83 | // doodad-api.js 84 | 85 | import Reduxful from 'reduxful'; 86 | import requestAdapter from './my-request-adapter'; 87 | 88 | const apiConfig = { requestAdapter }; 89 | const doodadApi = new Reduxful('doodadApi', apiDesc, apiConfig); 90 | 91 | export default doodadApi; 92 | ``` 93 | 94 | The variable that you assign the Reduxful instance to has the 95 | `actions`, `reducers`, and `selectors` need for working with Redux. 96 | 97 | ### Attach to Store 98 | 99 | The first thing to using the Reduxful instance with Redux is to attach your 100 | reducers to a Redux store. 101 | 102 | ```js 103 | // store.js 104 | 105 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 106 | import thunk from 'redux-thunk'; 107 | import doodadApi from './doodad-api'; 108 | 109 | const rootReducer = combineReducers(doodadApi.reducers); 110 | const store = createStore( 111 | rootReducer, 112 | applyMiddleware(thunk) 113 | ); 114 | 115 | export default store; 116 | ``` 117 | 118 | Reduxful depends on the `redux-thunk` middleware and uses it for the 119 | actionCreators. 120 | 121 | ### Dispatch Actions 122 | 123 | The Reduxful instance has an `actionCreators` property (or _`actions`_ as an 124 | alias) from which you can dispatch action with the Redux store. 125 | 126 | ```js 127 | import doodadApi from './doodad-api'; 128 | import store from './store'; 129 | 130 | store.dispatch(doodadApi.actionCreators.getDoodadList()); 131 | 132 | store.dispatch(doodadApi.actionCreators.getDoodad({ id: '123' })); 133 | ``` 134 | 135 | Our list resource description does not require any path or query params. 136 | However, our single resource does require an id as a path param, so we this 137 | in the params object as the first argument to the `actionCreator`. 138 | 139 | ### Select from State 140 | 141 | The Reduxful instance has a `selectors` property by which you can easily 142 | select resources from the Redux state. 143 | 144 | ```js 145 | import doodadApi from './doodad-api'; 146 | import store from './store'; 147 | 148 | const doodadList = doodadApi.selectors.getDoodadList(store.getState()); 149 | 150 | const doodad = doodadApi.selectors.getDoodad(store.getState(), { id: '123' }); 151 | ``` 152 | 153 | To select the list resource, we only need to pass the store's state. For our 154 | single resource, we also pass the same params used in the `actionCreator`. 155 | Params are used to key a resource in the redux state, which allows 156 | multiple requests to the same endpoint with different details to be managed. 157 | 158 | ### Resources 159 | 160 | [Resources] are the objects selected from Redux state and which track a 161 | request and resulting data from its response, with properties indicating 162 | status: `isUpdating`, `isLoaded`, and `hasError`. 163 | 164 | ```js 165 | import doodadApi from './doodad-api'; 166 | import store from './store'; 167 | 168 | function outputResultsExample(resource) { 169 | if (!resource) { 170 | return 'No resource found.'; 171 | } else if (resource.isUpdating && !(resource.isLoaded || resource.hasError)) { 172 | return 'Loading...'; 173 | } else if (resource.isLoaded) { 174 | return resource.value; 175 | } else if (resource.hasError) { 176 | return resource.error; 177 | } 178 | } 179 | 180 | const doodadList = doodadApi.selectors.getDoodadList(store.getState()); 181 | 182 | outputResultsExample(doodadList); 183 | ``` 184 | 185 | In this example, except for the first request, we always return the value 186 | or error data even if an update is occurring. When a first request is made, 187 | or for any subsequent requests, `isUpdating` will be true. After a response, 188 | the resource will have either `isLoaded` or `hasError` set to true. 189 | 190 | If an action has not been dispatch, there will be no resource selected from 191 | state. In the situation, the utility functions [isUpdating], [isLoaded], and 192 | [hasError] can be used for checking status safely against an undefined resource. 193 | 194 | ### From here 195 | 196 | Continue learning about Reduxful's more [advanced setups]. 197 | 198 | 199 | 200 | [API Docs]:docs/api.md 201 | [React Addons Docs]:docs/react-addons-api.md 202 | [React Examples]:docs/react-examples.md 203 | [Advanced setups]:docs/advanced-setups.md 204 | [Advanced actionCreators]:docs/advanced-action-creators.md 205 | 206 | [API Description]:docs/api.md#ApiDescription 207 | [API Config]:docs/api.md#ApiDescription 208 | [Request Description]:docs/api.md#RequestDescription 209 | [Request Adapter]:docs/api.md#RequestAdapter 210 | [makeFetchAdapter]:docs/api.md#makeFetchAdapter 211 | [Resource]:docs/api.md#Resource 212 | [Resources]:docs/api.md#Resource 213 | [isLoaded]:docs/api.md#isLoaded 214 | [isUpdating]:docs/api.md#isUpdating 215 | [hasError]:docs/api.md#hasError 216 | 217 | 218 | 219 | [cross-fetch]:https://github.com/lquixada/cross-fetch#cross-fetch 220 | [redux-thunk]:https://github.com/reduxjs/redux-thunk#redux-thunk 221 | [Fetch API]:https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API 222 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | We take security very seriously at GoDaddy. We appreciate your efforts to 4 | responsibly disclose your findings, and will make every effort to acknowledge 5 | your contributions. 6 | 7 | ## Where should I report security issues? 8 | 9 | In order to give the community time to respond and upgrade, we strongly urge you 10 | report all security issues privately. 11 | 12 | To report a security issue in one of our Open Source projects email us directly 13 | at **oss@godaddy.com** and include the word "SECURITY" in the subject line. 14 | 15 | This mail is delivered to our Open Source Security team. 16 | 17 | After the initial reply to your report, the team will keep you informed of the 18 | progress being made towards a fix and announcement, and may ask for additional 19 | information or guidance. 20 | -------------------------------------------------------------------------------- /docs/advanced-action-creators.md: -------------------------------------------------------------------------------- 1 | # Advanced actionCreators 2 | 3 | Additionally, Since [redux-thunk] is a dependency of Reduxful, it is also for 4 | use for some advanced situations. The actionCreators generated by Reduxful 5 | can be used for polling or chaining along with other dispatch-able actions. 6 | 7 | 8 | ### Chaining actionCreators 9 | 10 | Continuing with our `doodadApi` from the [advanced setups], 11 | let's say we want to be able to create a new doodad, and refresh our list 12 | of them, all with one actionCreator. 13 | 14 | ```js 15 | import { doodadApi } from './doodadApi'; 16 | import store from './store'; 17 | 18 | const { createDoodad, getDoodadList } = doodadApi.actionCreators; 19 | 20 | function createAndListDoodad(body) { 21 | return dispatch => { 22 | return dispatch(createDoodad(null, { body })) 23 | .then(() => dispatch(getDoodadList())); 24 | }; 25 | } 26 | 27 | store.dispatch(createAndListDoodad({ name: 'my-doodad' })); 28 | ``` 29 | 30 | 31 | ### Handling response actions 32 | 33 | Now in this example, we will create a doodad, then inspect the response 34 | [action], to see how the payload compares to our request. Action objects in 35 | Reduxful follow the [Flux Standard Action][FSA] design. 36 | 37 | ```js 38 | import { doodadApi } from './doodadApi'; 39 | import store from './store'; 40 | 41 | const { createDoodad } = doodadApi.actionCreators; 42 | 43 | function createAndVerifyDoodad(body) { 44 | return dispatch => { 45 | return dispatch(createDoodad(null, { body })) 46 | .then(responseAction => { 47 | // the response body will be in the action payload 48 | const { payload } = responseAction; 49 | if (payload.name !== body.name) { 50 | // Oops, the API didn't save the name like we ask!!! 51 | console.warn('Malformed doodad name'); 52 | } 53 | } 54 | ); 55 | }; 56 | } 57 | 58 | store.dispatch(createAndVerifyDoodad({ name: 'my-doodad' })); 59 | ```` 60 | 61 | Generally, for case like this where you want to display a result of your 62 | request it would be better to listen and select the createDoodad resource 63 | from state. However, this example introduces how you could use response actions 64 | to conditionally execute additional actionCreators, as we will see next. 65 | 66 | 67 | ## Polling actionCreators 68 | 69 | Now, let's say we want to perform some action on our doodad which happens 70 | asynchronously on the API, thus requiring us to poll for its results. Let's 71 | use our update endpoint, that will tell someone in our doodad warehouse to 72 | get a certain doodad packaged up for shipping or something. From the warehouse, 73 | someone will mark the requested doodad as packaged when they finish, then the 74 | API will update it. From our side, we will poll that doodad until completion, 75 | checking an `isPackaged` attribute. 76 | 77 | ```js 78 | import { doodadApi } from './doodadApi'; 79 | import store from './store'; 80 | const { updateDoodad, getDoodad } = doodadApi.actionCreators; 81 | function pollDoodadPackaging(id) { 82 | return dispatch => { 83 | return dispatch(getDoodad({ id })) 84 | .then(responseAction => { 85 | const { error, payload } = responseAction; 86 | // check if doodad is packaged yet 87 | if (!error && !payload.isPackaged) { 88 | // if not, then in 5 seconds, ask again 89 | setTimeout(() => { 90 | dispatch(pollDoodadPackaging(id)); 91 | }, 5000); 92 | } 93 | } 94 | ); 95 | }; 96 | } 97 | function packageAndPollDoodad(id) { 98 | const body = { package: true }; 99 | return dispatch => { 100 | return dispatch(updateDoodad({ id }, { body })) 101 | .then(() => dispatch(pollDoodadPackaging(id))); 102 | }; 103 | } 104 | store.dispatch(packageAndPollDoodad('123')); 105 | ``` 106 | 107 | ## From here 108 | 109 | View the [API docs] for more details. 110 | 111 | 112 | 113 | [API Docs]:api.md 114 | [Advanced setups]:advanced-setups.md 115 | [Action]:api.md#Action 116 | [Actions]:api.md#Action 117 | 118 | 119 | [redux-thunk]:https://github.com/reduxjs/redux-thunk#redux-thunk 120 | [FSA]:https://github.com/redux-utilities/flux-standard-action 121 | -------------------------------------------------------------------------------- /docs/advanced-setups.md: -------------------------------------------------------------------------------- 1 | # Advanced Setups 2 | 3 | There are several options available when describing an API's endpoints. 4 | 5 | Continuing from our `doodadApi` setup from the [usage docs], let us look at 6 | using some additional options to fill out our API description. 7 | 8 | 9 | ## `method` 10 | 11 | Let's add an endpoint to create new doodads. To do so, we need to set up a POST 12 | to our API. Simply add a `createDoodad` entry with and set the request 13 | description [method] to `POST`. 14 | 15 | 16 | ```diff 17 | const apiDesc = { 18 | getDoodad: { 19 | url: 'http://api.my-service.com/doodads/:id' 20 | }, 21 | getDoodadList: { 22 | url: 'http://api.my-service.com/doodads' 23 | + }, 24 | + createDoodad: { 25 | + url: 'http://api.my-service.com/doodads', 26 | + method: 'POST' 27 | } 28 | }; 29 | ``` 30 | 31 | Now, we can dispatch the new **actionCreator** with [body options] to create a 32 | new doodad, and use the **selector** to get results from state. 33 | 34 | ```js 35 | import doodadApi from './doodad-api'; 36 | import store from './store'; 37 | 38 | const { actionCreators, selectors } = doodadApi; 39 | 40 | // Make a new doodad 41 | 42 | const body = { name: 'some-name', details: { stuff: 'here' }}; 43 | store.dispatch(actionCreators.createDoodad(null, { body })); 44 | 45 | // Select resulting new doodad resource 46 | 47 | const newDoodadResource = selectors.createDoodad(store.getState()); 48 | ``` 49 | 50 | Because no query or path params are needed for this endpoint, we pass `null` 51 | as the first param to the actionCreator. Remember, however, params are used to 52 | key a particular resource, so this means all new POSTed doodads will update 53 | the resource on redux state. 54 | 55 | There are a couple things you can do from here depending on your applications 56 | needs. See [advanced actionCreators] for some examples. 57 | 58 | 59 | ## `resourceAlias` 60 | 61 | Now that we are creating new doodads, let us look at tying into our API to 62 | update them. Resources are keyed by params, but also by the endpoint name. If 63 | we want an PUT request to modify a resource we already have from a GET, 64 | can set the [resourceAlias] in our request description. 65 | 66 | ```diff 67 | const apiDesc = { 68 | getDoodad: { 69 | url: 'http://api.my-service.com/doodads/:id' 70 | }, 71 | getDoodadList: { 72 | url: 'http://api.my-service.com/doodads' 73 | }, 74 | createDoodad: { 75 | url: 'http://api.my-service.com/doodads', 76 | method: 'POST' 77 | + }, 78 | + updateDoodad: { 79 | + url: 'http://api.my-service.com/doodads/:id', 80 | + method: 'PUT', 81 | + resourceAlias: 'getDoodad' 82 | } 83 | }; 84 | ``` 85 | 86 | Now, any change from updateDoodad will end up in redux state under the 87 | corresponding getDoodad resource. So say we have a doodad whose name we want 88 | to change: 89 | 90 | ```js 91 | import doodadApi from './doodad-api'; 92 | import store from './store'; 93 | 94 | const { actionCreators, selectors } = doodadApi; 95 | 96 | // Select a doodad 97 | 98 | let doodad = selectors.getDoodad(store.getState(), { id: '123' }); 99 | console.log(doodad.value.name, doodad.isUpdating); // some-name, false 100 | 101 | // Dispatch an update to the doodad's name 102 | 103 | const body = { name: 'different-name' }; 104 | store.dispatch(actionCreators.updateDoodad({ id: '123' }, { body })); 105 | 106 | // While the request is out, the current name value remains 107 | 108 | doodad = selectors.getDoodad(store.getState(), { id: '123' }); 109 | console.log(doodad.value.name, doodad.isUpdating); // some-name, true 110 | 111 | // When the response returns, name value will be updated 112 | 113 | doodad = selectors.getDoodad(store.getState(), { id: '123' }); 114 | console.log(doodad.value.name, doodad.isUpdating); // different-name, false 115 | ``` 116 | 117 | While a selector for the endpoint is available, it will retrieve the same data 118 | from state as the resourceAlias endpoint selector. 119 | 120 | ```js 121 | import doodadApi from './doodad-api'; 122 | import store from './store'; 123 | 124 | const { getDoodad, updateDoodad } = doodadApi.selectors; 125 | const params = { id: '123' }; 126 | const state = store.getState(); 127 | 128 | console.log( getDoodad(state, params) === updateDoodad(state, params)); // true 129 | ``` 130 | 131 | 132 | ## `dataTransform` 133 | 134 | Sometimes, the data from your API response is not formatted in such a way that 135 | is suited to your app. In this case, you can use [dataTransform] to shape it to 136 | your app's needs with a [transform function][TransformFn]. 137 | 138 | ```diff 139 | const apiDesc = { 140 | getDoodad: { 141 | url: 'http://api.my-service.com/doodads/:id' 142 | }, 143 | getDoodadList: { 144 | url: 'http://api.my-service.com/doodads', 145 | + dataTransform: (data) => fixup(data) 146 | }, 147 | ``` 148 | 149 | In the same vein, [errorTransform] is available to fix up the data from an 150 | error response. 151 | 152 | 153 | ## `repeatRequestDelay` 154 | 155 | To avoid writing code to manage if an action needs to make a new request, or if 156 | if a request is already pending, Reduxful handles this for you. 157 | 158 | By default, repeated requests are throttled at 3 seconds (3000 ms) between 159 | requests. This can be adjusted by setting the [repeatRequestDelay] to a 160 | different duration in milliseconds. 161 | 162 | ```js 163 | const apiDesc = { 164 | getDoodad: { 165 | url: 'http://api.my-service.com/doodads/:id', 166 | repeatRequestDelay: 5 * 60 * 1000 // limit repeat requests to 5 minutes 167 | }, 168 | getDoodadList: { 169 | url: 'http://api.my-service.com/doodads', 170 | repeatRequestDelay: 5000 // limit repeat requests to 5 seconds 171 | } 172 | }; 173 | ``` 174 | 175 | 176 | ### Throttling 177 | 178 | When you have a response, and your app happens to execute another actionCreator, 179 | Reduxful will recognize this and not issue a new request. A resolved response 180 | will be returned with the previously dispatched action. 181 | 182 | ```js 183 | const isEqual = require('lodash.isequal'); 184 | 185 | const { getDoodadList } = doodadApi.actionCreators; 186 | 187 | const promiseA = store.dispatch(getDoodadList()); 188 | 189 | // After a response... 190 | 191 | const promiseB = store.dispatch(getDoodadList()); 192 | 193 | promiseB.then(bAction => { 194 | promiseA.then(aAction => { 195 | console.log(isEqual(aAction, bAction)); // true 196 | }); 197 | }); 198 | ``` 199 | 200 | 201 | ### Debouncing 202 | 203 | When you have a request in flight, and your app happens to dispatch a second 204 | action, Reduxful will recognize this and return the promise from the first 205 | dispatch and not issue a new request. 206 | 207 | ```js 208 | const { getDoodadList } = doodadApi.actionCreators; 209 | 210 | const promiseA = store.dispatch(getDoodadList()); 211 | 212 | const promiseB = store.dispatch(getDoodadList()); 213 | 214 | console.log(promiseA === promiseB); // true 215 | ``` 216 | 217 | 218 | ## `options` 219 | 220 | To pass additional request options along to Fetch or your request library 221 | of choice, you can do so by setting the [options object] in either: 222 | 223 | 1. [ApiDescription] 224 | 2. [RequestDescription] 225 | 3. [actionCreator params][ActionCreatorFn] 226 | 227 | Duplicate options will be resolved in that order. 228 | 229 | Alternatively, [options can be a function][OptionsFn] which receives the 230 | store's `getState` function and a context, and which should return an 231 | options object. 232 | 233 | ```js 234 | const apiDesc = { 235 | getDoodadList: { 236 | url: 'http://api.my-service.com/doodads', 237 | options: { 238 | headers: { 239 | 'X-Extra-Data': 'some fixed value' 240 | } 241 | } 242 | }, 243 | getDoodad: { 244 | url: 'http://api.my-service.com/doodads/:id', 245 | options: (getState, { params }) => { 246 | const { id } = params; 247 | return { 248 | headers: { 249 | 'X-ID': id, 250 | 'X-Extra-Data': getState().some.dynamic.value 251 | } 252 | }; 253 | } 254 | } 255 | }; 256 | 257 | const apiConfig = { 258 | requestAdapter, 259 | options: { 260 | credentials: 'include' 261 | } 262 | }; 263 | 264 | const doodadApi = setupApi('doodadApi', apiDesc, apiConfig); 265 | ``` 266 | 267 | ## From here 268 | 269 | Continue learning about [advanced actionCreators] using Reduxful. 270 | 271 | 272 | 273 | [usage docs]:../README.md#Usage 274 | [Advanced actionCreators]:advanced-action-creators.md 275 | [Action]:api.md#Action 276 | [Actions]:api.md#Action 277 | [ApiDescription]:api.md#ApiDescription 278 | [RequestDescription]:api.md#RequestDescription 279 | [method]:api.md#RequestDescription 280 | [resourceAlias]:api.md#RequestDescription 281 | [repeatRequestDelay]:api.md#RequestDescription 282 | [dataTransform]:api.md#RequestDescription 283 | [errorTransform]:api.md#RequestDescription 284 | [options]:api.md#RequestDescription 285 | [ActionCreatorFn]:api.md#ActionCreatorFn 286 | [TransformFn]:api.md#TransformFn 287 | [OptionsFn]:api.md#OptionsFn 288 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | ## Classes 2 | 3 |
4 |
Reduxful
5 |

Main class which manages RESTful requests and state with Redux.

6 |
7 |
8 | 9 | ## Functions 10 | 11 |
12 |
makeFetchAdapter(fetcher, [defaultOptions])function
13 |

Make an adapter to match the RequestAdapter interface using Fetch

14 |
15 |
setupApi(apiName, apiDesc, [config])Reduxful
16 |

Create new RESTful configuration for Redux.

17 |
18 |
setRequestAdapter(requestAdapter)
19 |

Register an ajax request adapter.

20 |
21 |
getResourceKey(reqName, params)String
22 |

Builds the resource key based on the parameters passed.

23 |
24 |
isLoaded(resource)Boolean
25 |

Helper function to check if a Resource has been loaded.

26 |
27 |
isUpdating(resource)Boolean
28 |

Helper function to check of a Resource is being updated.

29 |
30 |
hasError(resource)Boolean
31 |

Helper function to check if a Resource has an error.

32 |
33 |
escapeRegExp(string)string
34 |

Escape special characters for RegExp

35 |
36 |
37 | 38 | ## Typedefs 39 | 40 |
41 |
ActionCreatorFnActionCreatorThunkFn
42 |

Action creator which kicks off request and creates lifecycle actions. 43 | The sub-action creators are used during the lifecycle of a request to dispatch 44 | updates to a resource in redux state. They are also exposed should 45 | direct developer access be needed.

46 |
47 |
ActionCreatorThunkFnPromise.<Action>
48 |

Thunk will actually dispatch an Action only if:

49 |
    50 |
  1. it is not debounced
  2. 51 |
  3. it is not throttled
  4. 52 |
53 |

Thunk will always return a resolving promise with either:

54 |
    55 |
  1. new action being dispatched
  2. 56 |
  3. same action being dispatched if debounced
  4. 57 |
  5. previous action dispatched if throttled
  6. 58 |
59 |
60 |
RequestAdapterPromise
61 |

RequestAdapter structure

62 |
63 |
RequestAdapterOptions : Object
64 |

RequestAdapter Options

65 |
66 |
Resource : Object
67 |

Resource object

68 |
69 |
RequestDescription : Object
70 |

Request Description object

71 |
72 |
ApiDescription : Object
73 |

Api Description object

74 |
75 |
ApiConfig : Object
76 |

Api Config object

77 |
78 |
Action : Object
79 |

Flux Standard Action compliant action.

80 |
81 |
OptionsFnObject
82 |

Function to create request options object which can read from Redux state

83 |
84 |
UrlTemplateFnString
85 |

Function to create url template string which can read from Redux state. 86 | Placeholders can be set, with final URL built by transform-url.

87 |
88 |
SubActionCreatorFnObject
89 |

Sub action creator function

90 |
91 |
SelectorFnResource
92 |

Selector function to retrieve a resource from Redux state

93 |
94 |
ReducerFnObject
95 |

Redux Reducer function

96 |
97 |
TransformFn*
98 |

Transform function

99 |
100 |
101 | 102 | 103 | 104 | ## Reduxful 105 | Main class which manages RESTful requests and state with Redux. 106 | 107 | **Kind**: global class 108 | **Access**: public 109 | 110 | * [Reduxful](#Reduxful) 111 | * [new Reduxful(apiName, apiDesc, [apiConfig])](#new_Reduxful_new) 112 | * [.actionCreators](#Reduxful+actionCreators) ⇒ Object.<String, ActionCreatorFn> 113 | * [.actions](#Reduxful+actions) ⇒ Object.<String, ActionCreatorFn> 114 | * [.reducers](#Reduxful+reducers) ⇒ Object.<String, ReducerFn> 115 | * [.reducerMap](#Reduxful+reducerMap) ⇒ Object.<String, ReducerFn> 116 | * [.selectors](#Reduxful+selectors) ⇒ Object.<String, SelectorFn> 117 | 118 | 119 | 120 | ### new Reduxful(apiName, apiDesc, [apiConfig]) 121 | Create new RESTful configuration for Redux. 122 | 123 | 124 | | Param | Type | Description | 125 | | --- | --- | --- | 126 | | apiName | String | Name of the REST API | 127 | | apiDesc | [ApiDescription](#ApiDescription) | Description object of target REST API | 128 | | [apiConfig] | [ApiConfig](#ApiConfig) | Optional configuration settings | 129 | | [apiConfig.requestAdapter] | [RequestAdapter](#RequestAdapter) | Request adapter to use | 130 | | [apiConfig.options] | Object \| [OptionsFn](#OptionsFn) | Options to be passed to the request adapter | 131 | 132 | 133 | 134 | ### reduxful.actionCreators ⇒ Object.<String, ActionCreatorFn> 135 | Property 136 | 137 | **Kind**: instance property of [Reduxful](#Reduxful) 138 | **Returns**: Object.<String, ActionCreatorFn> - redux action creators 139 | 140 | 141 | ### reduxful.actions ⇒ Object.<String, ActionCreatorFn> 142 | Alias to actionCreators 143 | 144 | **Kind**: instance property of [Reduxful](#Reduxful) 145 | **Returns**: Object.<String, ActionCreatorFn> - redux action creators 146 | 147 | 148 | ### reduxful.reducers ⇒ Object.<String, ReducerFn> 149 | Property 150 | 151 | **Kind**: instance property of [Reduxful](#Reduxful) 152 | **Returns**: Object.<String, ReducerFn> - redux reducers 153 | 154 | 155 | ### reduxful.reducerMap ⇒ Object.<String, ReducerFn> 156 | Alias to reducers 157 | 158 | **Kind**: instance property of [Reduxful](#Reduxful) 159 | **Returns**: Object.<String, ReducerFn> - redux reducers 160 | 161 | 162 | ### reduxful.selectors ⇒ Object.<String, SelectorFn> 163 | Property 164 | 165 | **Kind**: instance property of [Reduxful](#Reduxful) 166 | **Returns**: Object.<String, SelectorFn> - redux selectors 167 | 168 | 169 | ## makeFetchAdapter(fetcher, [defaultOptions]) ⇒ function 170 | Make an adapter to match the RequestAdapter interface using Fetch 171 | 172 | **Kind**: global function 173 | **Returns**: function - fetchAdapter 174 | 175 | | Param | Type | Description | 176 | | --- | --- | --- | 177 | | fetcher | function | Fetch API or ponyfill | 178 | | [defaultOptions] | Object | Any default request options | 179 | 180 | 181 | 182 | ## setupApi(apiName, apiDesc, [config]) ⇒ [Reduxful](#Reduxful) 183 | Create new RESTful configuration for Redux. 184 | 185 | **Kind**: global function 186 | **Returns**: [Reduxful](#Reduxful) - instance 187 | 188 | | Param | Type | Description | 189 | | --- | --- | --- | 190 | | apiName | String | Name of the REST API | 191 | | apiDesc | [ApiDescription](#ApiDescription) | Description object of target REST API | 192 | | [config] | [ApiConfig](#ApiConfig) | Optional configuration settings | 193 | | [config.requestAdapter] | [RequestAdapter](#RequestAdapter) | Request adapter to use | 194 | | [config.options] | Object \| [OptionsFn](#OptionsFn) | Options to be passed to the request adapter | 195 | 196 | 197 | 198 | ## setRequestAdapter(requestAdapter) 199 | Register an ajax request adapter. 200 | 201 | **Kind**: global function 202 | **Access**: public 203 | 204 | | Param | Type | Description | 205 | | --- | --- | --- | 206 | | requestAdapter | [RequestAdapter](#RequestAdapter) | Request adapter to use | 207 | 208 | 209 | 210 | ## getResourceKey(reqName, params) ⇒ String 211 | Builds the resource key based on the parameters passed. 212 | 213 | **Kind**: global function 214 | **Returns**: String - resource key 215 | 216 | | Param | Type | Description | 217 | | --- | --- | --- | 218 | | reqName | String | Name of the API request. | 219 | | params | Object.<String, (String\|Number)> | Parameters used as URL or Query params | 220 | 221 | 222 | 223 | ## isLoaded(resource) ⇒ Boolean 224 | Helper function to check if a Resource has been loaded. 225 | 226 | **Kind**: global function 227 | **Returns**: Boolean - result 228 | 229 | | Param | Type | Description | 230 | | --- | --- | --- | 231 | | resource | [Resource](#Resource) | Resource object | 232 | 233 | 234 | 235 | ## isUpdating(resource) ⇒ Boolean 236 | Helper function to check of a Resource is being updated. 237 | 238 | **Kind**: global function 239 | **Returns**: Boolean - result 240 | 241 | | Param | Type | Description | 242 | | --- | --- | --- | 243 | | resource | [Resource](#Resource) | Resource object | 244 | 245 | 246 | 247 | ## hasError(resource) ⇒ Boolean 248 | Helper function to check if a Resource has an error. 249 | 250 | **Kind**: global function 251 | **Returns**: Boolean - result 252 | 253 | | Param | Type | Description | 254 | | --- | --- | --- | 255 | | resource | [Resource](#Resource) | Resource object | 256 | 257 | 258 | 259 | ## escapeRegExp(string) ⇒ string 260 | Escape special characters for RegExp 261 | 262 | **Kind**: global function 263 | **Returns**: string - escaped 264 | **See:**: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping 265 | 266 | | Param | Type | Description | 267 | | --- | --- | --- | 268 | | string | string | Intended for expression | 269 | 270 | 271 | 272 | ## ActionCreatorFn ⇒ [ActionCreatorThunkFn](#ActionCreatorThunkFn) 273 | Action creator which kicks off request and creates lifecycle actions. 274 | The sub-action creators are used during the lifecycle of a request to dispatch 275 | updates to a resource in redux state. They are also exposed should 276 | direct developer access be needed. 277 | 278 | **Kind**: global typedef 279 | **Returns**: [ActionCreatorThunkFn](#ActionCreatorThunkFn) - thunk 280 | 281 | | Param | Type | Description | 282 | | --- | --- | --- | 283 | | params | Object | Params applied to the url path or query | 284 | | [options] | Object \| [OptionsFn](#OptionsFn) | Options to be passed to the request adapter | 285 | 286 | **Properties** 287 | 288 | | Name | Type | Description | 289 | | --- | --- | --- | 290 | | reset | [SubActionCreatorFn](#SubActionCreatorFn) | Reset resource to baseline | 291 | | start | [SubActionCreatorFn](#SubActionCreatorFn) | Set resource to isUpdate | 292 | | success | [SubActionCreatorFn](#SubActionCreatorFn) | Update resource to be loaded with value from a response | 293 | | fail | [SubActionCreatorFn](#SubActionCreatorFn) | Update resource with error as returned from a response | 294 | 295 | 296 | 297 | ## ActionCreatorThunkFn ⇒ [Promise.<Action>](#Action) 298 | Thunk will actually dispatch an Action only if: 299 | 1. it is not debounced 300 | 2. it is not throttled 301 | 302 | Thunk will always return a resolving promise with either: 303 | 1. new action being dispatched 304 | 2. same action being dispatched if debounced 305 | 3. previous action dispatched if throttled 306 | 307 | **Kind**: global typedef 308 | **Returns**: [Promise.<Action>](#Action) - promise 309 | 310 | | Param | Type | Description | 311 | | --- | --- | --- | 312 | | dispatch | function | Redux store dispatcher | 313 | | getState | function | Get redux store state | 314 | 315 | 316 | 317 | ## RequestAdapter ⇒ Promise 318 | RequestAdapter structure 319 | 320 | **Kind**: global typedef 321 | 322 | | Param | Type | 323 | | --- | --- | 324 | | options | [RequestAdapterOptions](#RequestAdapterOptions) | 325 | 326 | 327 | 328 | ## RequestAdapterOptions : Object 329 | RequestAdapter Options 330 | 331 | **Kind**: global typedef 332 | **Properties** 333 | 334 | | Name | Type | Description | 335 | | --- | --- | --- | 336 | | options | Object | | 337 | | options.method | String | HTTP method to use (GET, POST, etc.) | 338 | | options.url | String | URL to call | 339 | | options.headers | Object.<String, String> | Header for request | 340 | | options.withCredentials | Boolean | Should cookies be passed for cross-origin requests | 341 | | options.body | \* | Optional body of request | 342 | 343 | 344 | 345 | ## Resource : Object 346 | Resource object 347 | 348 | **Kind**: global typedef 349 | **Properties** 350 | 351 | | Name | Type | Description | 352 | | --- | --- | --- | 353 | | value | Object \| Array \| \* | Body of the api response | 354 | | error | Object \| Array \| \* | Body of the api error response | 355 | | hasError | Boolean | True if api response returned as an error | 356 | | isLoaded | Boolean | True if api response returned as success | 357 | | isUpdating | Boolean | True if a request is pending | 358 | | requestTime | Number \| null | Timestamp when new request started | 359 | | responseTime | Number \| null | Timestamp when response received | 360 | 361 | 362 | 363 | ## RequestDescription : Object 364 | Request Description object 365 | 366 | **Kind**: global typedef 367 | **Properties** 368 | 369 | | Name | Type | Description | 370 | | --- | --- | --- | 371 | | url | String \| [UrlTemplateFn](#UrlTemplateFn) | URL template of the REST endpoint. Placeholders can be set, with final URL built by [transform-url](https://github.com/godaddy/transform-url#readme). | 372 | | [method] | String | HTTP method to use | 373 | | [resourceAlias] | String | Resource name alias | 374 | | [resourceData] | Object \| Array \| \* | Optional initial resource data | 375 | | [dataTransform] | [TransformFn](#TransformFn) | Function to fixup request response | 376 | | [errorTransform] | [TransformFn](#TransformFn) | Function to fixup request error response | 377 | | [repeatRequestDelay] | Number | Required delay time in milliseconds between repeated requests | 378 | | [options] | Object \| [OptionsFn](#OptionsFn) | Options to be passed to the request adapter | 379 | 380 | 381 | 382 | ## ApiDescription : Object 383 | Api Description object 384 | 385 | **Kind**: global typedef 386 | 387 | 388 | ## ApiConfig : Object 389 | Api Config object 390 | 391 | **Kind**: global typedef 392 | **Properties** 393 | 394 | | Name | Type | Description | 395 | | --- | --- | --- | 396 | | [requestAdapter] | [RequestAdapter](#RequestAdapter) | Adapter for request library | 397 | | [options] | Object \| [OptionsFn](#OptionsFn) | Options to be passed to the request adapter | 398 | 399 | 400 | 401 | ## Action : Object 402 | [Flux Standard Action](https://github.com/redux-utilities/flux-standard-action) compliant action. 403 | 404 | **Kind**: global typedef 405 | **Properties** 406 | 407 | | Name | Type | Description | 408 | | --- | --- | --- | 409 | | type | String | The type of action in the format `__` | 410 | | payload | String | Transformed resource value or error; body of response. | 411 | | meta | Object | Action metadata | 412 | | meta.key | String | Key of the particular resource | 413 | | [error] | Boolean | Whether the action is an error | 414 | 415 | 416 | 417 | ## OptionsFn ⇒ Object 418 | Function to create request options object which can read from Redux state 419 | 420 | **Kind**: global typedef 421 | **Returns**: Object - options 422 | 423 | | Param | Type | Description | 424 | | --- | --- | --- | 425 | | getState | function | Gets the current redux state | 426 | 427 | 428 | 429 | ## UrlTemplateFn ⇒ String 430 | Function to create url template string which can read from Redux state. 431 | Placeholders can be set, with final URL built by [transform-url](https://github.com/godaddy/transform-url#readme). 432 | 433 | **Kind**: global typedef 434 | **Returns**: String - urlTemplate 435 | 436 | | Param | Type | Description | 437 | | --- | --- | --- | 438 | | getState | function | Gets the current redux state | 439 | 440 | 441 | 442 | ## SubActionCreatorFn ⇒ Object 443 | Sub action creator function 444 | 445 | **Kind**: global typedef 446 | **Returns**: Object - action 447 | 448 | | Param | Type | Description | 449 | | --- | --- | --- | 450 | | params | Object | Params applied to the url path or query | 451 | | payload | Object | Transformed resource value or error; body of response. | 452 | 453 | 454 | 455 | ## SelectorFn ⇒ [Resource](#Resource) 456 | Selector function to retrieve a resource from Redux state 457 | 458 | **Kind**: global typedef 459 | **Returns**: [Resource](#Resource) - resource 460 | 461 | | Param | Type | Description | 462 | | --- | --- | --- | 463 | | state | Object | Redux state to select resource from | 464 | | params | Object | Params used to key a particular resource request | 465 | 466 | 467 | 468 | ## ReducerFn ⇒ Object 469 | Redux Reducer function 470 | 471 | **Kind**: global typedef 472 | **Returns**: Object - newState 473 | 474 | | Param | Type | Description | 475 | | --- | --- | --- | 476 | | state | Object | State | 477 | | action | Object | Action | 478 | 479 | 480 | 481 | ## TransformFn ⇒ \* 482 | Transform function 483 | 484 | **Kind**: global typedef 485 | **Returns**: \* - data 486 | 487 | | Param | Type | Description | 488 | | --- | --- | --- | 489 | | data | Object \| Array \| \* | Body of the api response | 490 | | [context] | Object | Context | 491 | | [context.params] | Object | Params from action for request | 492 | 493 | -------------------------------------------------------------------------------- /docs/react-addons-api.md: -------------------------------------------------------------------------------- 1 | ## Constants 2 | 3 |
4 |
resourceShape
5 |

Base propTypes shape used for validation of resources in React components.

6 |
7 |
8 | 9 | ## Functions 10 | 11 |
12 |
extendResourceShape(propTypes)shape
13 |

Use to extend the resourceShape to define value and/or error structures.

14 |
15 |
16 | 17 | 18 | 19 | ## resourceShape 20 | Base propTypes shape used for validation of resources in React components. 21 | 22 | **Kind**: global constant 23 | **Properties** 24 | 25 | | Name | Type | 26 | | --- | --- | 27 | | value | Object \| Array \| String \| \* | 28 | | error | Object \| Array \| String \| \* | 29 | | hasError | Boolean | 30 | | isLoaded | Boolean | 31 | | isUpdating | Boolean | 32 | | requestTime | Number | 33 | | responseTime | Number | 34 | 35 | 36 | 37 | ## extendResourceShape(propTypes) ⇒ shape 38 | Use to extend the `resourceShape` to define `value` and/or `error` structures. 39 | 40 | **Kind**: global function 41 | **Returns**: shape - shape 42 | 43 | | Param | Type | Description | 44 | | --- | --- | --- | 45 | | propTypes | Object | PropTypes to override resource shapes | 46 | | [propTypes.value] | Object | Shape of expected value | 47 | | [propTypes.error] | Object | Shape of expected error | 48 | 49 | -------------------------------------------------------------------------------- /docs/react-examples.md: -------------------------------------------------------------------------------- 1 | # Using with React 2 | 3 | Using [react-redux] to bind your React components to actionCreators and 4 | selectors is pretty straight-forward. We'll walk through some example of 5 | how to do so in this guide. 6 | 7 | Also checkout out the [React addons docs][react-addons] 8 | for React propType validation tools. 9 | 10 | ## Connect resource selectors 11 | 12 | These examples continue where we left off with the `doodadApi` 13 | from the [usage docs]. 14 | 15 | To start with we will have a component that displays the details of a Doodad. 16 | The component has a `doodadId` prop, by which the Doodad to lookup is 17 | determined. i.e. ``. 18 | 19 | ```jsx harmony 20 | // doodad-view.js 21 | 22 | import React from 'react'; 23 | import PropTypes from 'prop-types'; 24 | import { connect } from 'react-redux'; 25 | import { isLoaded } from 'reduxful'; 26 | import doodadApi from './doodad-api'; 27 | 28 | export class DoodadView extends React.Component { 29 | 30 | render() { 31 | const { doodadId, doodad } = this.props; 32 | 33 | if (!doodadId) { 34 | return 'Select a doodad'; 35 | } 36 | 37 | if (!isLoaded(doodad)) { 38 | return 'Loading details...'; 39 | } 40 | 41 | // 42 | // Destructure attributes of the resource value to render. 43 | // These are particular to our hypothetical Doodad data and will 44 | // be different for whatever data your app will be consuming. 45 | const { id, details } = doodad.value; 46 | 47 | return ( 48 |
49 | ID: {id} 50 | Details: {details} 51 |
52 | ); 53 | } 54 | } 55 | 56 | DoodadView.propTypes = { 57 | doodadId: PropTypes.string, 58 | // injected 59 | doodad: PropTypes.object 60 | }; 61 | 62 | const mapStateToProps = (state, ownProps) => { 63 | const { doodadId } = ownProps; 64 | return { 65 | doodad: doodadApi.selectors.getDoodad(state, { id: doodadId }) 66 | }; 67 | }; 68 | export default connect(mapStateToProps)(DoodadView); 69 | ``` 70 | 71 | We are using `connect` to get access to the Redux state, and using our selector 72 | for grabbing the correct doodad resource from state, passing the param (id) 73 | by which the resource is keyed. 74 | 75 | The DoodadView component assumes that a request for the doodad specified has 76 | already been made, and all the component has to do is watch redux state for the 77 | resource to be updated with a response. However, we could make the component 78 | also dispatch the action to initiate the request. 79 | 80 | ## Connect actionCreator dispatchers 81 | 82 | Using the `mapDispatchToProps` argument with `connect`, we can easily make 83 | our actionCreators available to the component. 84 | Because `doodadApi.actionsCreators` is an object, we could just pass it as 85 | the second argument. However, that would result in spreading all the 86 | actionsCreators from our API description. So instead, we will pick out only the 87 | particular one we need. 88 | 89 | ```jsx harmony 90 | export default connect( 91 | mapStateToProps, 92 | { getDoodad: doodadApi.actionCreators.getDoodad } 93 | )(DoodadView); 94 | ``` 95 | 96 | And don't forget to update the components propTypes: 97 | 98 | ```diff 99 | DoodadView.propTypes = { 100 | doodadId: PropTypes.string, 101 | // injected 102 | + getDoodad: PropTypes.func, 103 | doodad: PropTypes.object 104 | }; 105 | ``` 106 | 107 | Now that we are injecting our actionCreator mapped to dispatch into the 108 | component, all we have to do now is tell the component when to execute it. 109 | We will want to dispatch an action when the component mounts, but also if the 110 | `doodadId` passed to our component changes. To do this, we will need to tie 111 | into two React component lifecycles. 112 | 113 | ```jsx harmony 114 | componentDidMount() { 115 | const { getDoodad, doodadId } = this.props; 116 | getDoodad({id: doodadId}); 117 | } 118 | 119 | componentDidUpdate(prevProps) { 120 | const { getDoodad, doodadId } = this.props; 121 | if (doodadId !== prevProps.doodadId) { 122 | getDoodad({id: doodadId}); 123 | } 124 | } 125 | ``` 126 | 127 | Now our component will dispatch the action to make our API request. Even if the 128 | action was dispatched outside of the component, Reduxful will automatically 129 | debounce requests in flight, and throttle repeat requests so as not to spam the 130 | network with unnecessary or unwanted requests. 131 | 132 | ## Specify PropTypes 133 | 134 | Currently, we are getting away with setting our doodad resource to a simple 135 | object propType. There are a couple things we could do to make this component 136 | contract more explicit. 137 | 138 | As a first option, we could simply set the doodad propType to `resourceShape` 139 | from the [react-addons]. 140 | 141 | ```diff 142 | + import { resourceShape } from 'reduxful/react-addons'; 143 | 144 | DoodadView.propTypes = { 145 | doodadId: PropTypes.string, 146 | // injected 147 | getDoodad: PropTypes.func, 148 | - doodad: PropTypes.object 149 | + doodad: resourceShape 150 | }; 151 | ``` 152 | 153 | As a more refined option, we could use `extendResourceShape` to detailed 154 | `value` and `error` shapes for our expected resource. In this example, our 155 | resource value _is required_ to have an `id`, however `details` are optional. 156 | 157 | ```jsx harmony 158 | import { extendResourceShape } from 'reduxful/react-addons'; 159 | 160 | const doodadShape = extendResourceShape({ 161 | value: PropTypes.shape({ 162 | id: PropTypes.string.isRequired, 163 | details: PropTypes.object 164 | }), 165 | error: PropTypes.string 166 | }); 167 | ``` 168 | 169 | ```diff 170 | DoodadView.propTypes = { 171 | doodadId: PropTypes.string, 172 | // injected 173 | getDoodad: PropTypes.func, 174 | - doodad: resourceShape 175 | + doodad: doodadShape 176 | }; 177 | ``` 178 | 179 | ## From here 180 | 181 | Be sure to check out the [react-redux docs] for more details on using connect 182 | and for setting up the store provider. 183 | 184 | 185 | 186 | [usage docs]:../README.md#usage 187 | [react-addons]:react-addons-api.md 188 | 189 | 190 | [react-redux]:https://github.com/reduxjs/react-redux 191 | [react-redux docs]:https://github.com/reduxjs/react-redux/blob/master/docs/api.md 192 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reduxful", 3 | "version": "1.5.0", 4 | "description": "Redux state from RESTful services", 5 | "main": "./lib", 6 | "browser": "./lib", 7 | "module": "./src", 8 | "types": "./typings/index.d.ts", 9 | "license": "MIT", 10 | "author": "GoDaddy Operating Company, LLC", 11 | "contributors": [ 12 | "Andrew Gerard " 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/godaddy/reduxful.git" 17 | }, 18 | "keywords": [ 19 | "redux", 20 | "state", 21 | "rest", 22 | "api", 23 | "action", 24 | "reducer", 25 | "selector", 26 | "flux" 27 | ], 28 | "scripts": { 29 | "build": "babel src -d lib --ignore '**/*.spec.js'", 30 | "lint": "eslint src/ tests/ *.js", 31 | "lint:fix": "yarn lint --fix", 32 | "prepublish": "yarn build", 33 | "pretest": "yarn lint", 34 | "test": "yarn test:jest && yarn test:types", 35 | "test:jest": "jest tests", 36 | "test:types": "tsc --lib es2015,dom --noEmit ./typings/*.ts", 37 | "test:watch": "jest --watch", 38 | "test:coverage": "jest --coverage", 39 | "docs:react-addons": "jsdoc2md src/addons/react-addons.js > docs/react-addons-api.md", 40 | "docs:api": "jsdoc2md src/*.js > docs/api.md", 41 | "docs": "yarn docs:api && yarn docs:react-addons" 42 | }, 43 | "dependencies": { 44 | "es6-error": "^4.1.1", 45 | "prop-types": "^15.8.1", 46 | "transform-url": "^1.1.3" 47 | }, 48 | "devDependencies": { 49 | "@babel/cli": "^7.18.10", 50 | "@babel/core": "^7.18.13", 51 | "@babel/preset-env": "^7.18.10", 52 | "babel-jest": "^29.0.0", 53 | "eslint": "^8.22.0", 54 | "eslint-config-godaddy": "^7.0.0", 55 | "eslint-plugin-jest": "^26.8.7", 56 | "eslint-plugin-json": "^3.1.0", 57 | "eslint-plugin-mocha": "^10.1.0", 58 | "jest": "^29.0.0", 59 | "jsdoc-to-markdown": "^8.0.0", 60 | "typescript": "^4.7.4", 61 | "yarn": "^1.22.22" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /react-addons.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/addons/react-addons'); 2 | -------------------------------------------------------------------------------- /src/actionCreators.js: -------------------------------------------------------------------------------- 1 | import { getResourceKey, getUrlTemplate, isFunction } from './utils'; 2 | import transformUrl from 'transform-url'; 3 | import { makeRequest } from './requestAdapter'; 4 | import * as constants from './constants'; 5 | import PromiseKeeper from './promiseKeeper'; 6 | 7 | const defaultTransformer = f => f; 8 | const _promiseKeepers = new WeakMap(); 9 | 10 | /** 11 | * Weakly map a promiseKeeper to Redux store to ensure debounced fetches are 12 | * unique to store instance. 13 | * 14 | * @param {function} dispatch - Dispatch from Redux store 15 | * @returns {PromiseKeeper} promiseKeeper - Instance for provided dispatch 16 | * @private 17 | */ 18 | export function getPromiseKeeper(dispatch) { 19 | if (!_promiseKeepers.has(dispatch)) { 20 | _promiseKeepers.set(dispatch, new PromiseKeeper()); 21 | } 22 | return _promiseKeepers.get(dispatch); 23 | } 24 | 25 | export function makeSubActionsCreators(apiName, resourceName, reqDesc) { 26 | const subActions = {}; 27 | 28 | subActions.reset = function (params, payload) { 29 | return { 30 | type: `${apiName}_${resourceName}_RESET`, 31 | meta: { 32 | key: getResourceKey(resourceName, params) 33 | }, 34 | payload: payload || reqDesc.resourceData || {} 35 | }; 36 | }; 37 | 38 | subActions.start = function (params, payload) { 39 | return { 40 | type: `${apiName}_${resourceName}_START`, 41 | meta: { 42 | key: getResourceKey(resourceName, params) 43 | }, 44 | payload: payload || reqDesc.resourceData || {} 45 | }; 46 | }; 47 | 48 | subActions.success = function (params, payload) { 49 | return { 50 | type: `${apiName}_${resourceName}_SUCCESS`, 51 | meta: { 52 | key: getResourceKey(resourceName, params) 53 | }, 54 | payload 55 | }; 56 | }; 57 | 58 | subActions.fail = function (params, payload) { 59 | return { 60 | type: `${apiName}_${resourceName}_FAIL`, 61 | meta: { 62 | key: getResourceKey(resourceName, params) 63 | }, 64 | payload, 65 | error: true 66 | }; 67 | }; 68 | 69 | return subActions; 70 | } 71 | 72 | /** 73 | * Creates dispatches for the sub actions 74 | * 75 | * @param {Function} dispatch - Dispatch from store 76 | * @param {RequestDescription} reqDesc - Request description 77 | * @param {Object} subActions - Generated subActions for action creators 78 | * @param {Object} params - Request path and query params 79 | * @param {Object} options - Request options 80 | * @returns {Function[]} dispatchers 81 | * @private 82 | */ 83 | export function createDispatchers(dispatch, reqDesc, subActions, params, options) { 84 | const dataTransform = reqDesc.dataTransform || defaultTransformer; 85 | const errorTransform = reqDesc.errorTransform || defaultTransformer; 86 | 87 | function onStart() { 88 | return dispatch(subActions.start(params)); 89 | } 90 | 91 | function onResolved(data) { 92 | return dispatch(subActions.success(params, dataTransform(data, { params, options }))); 93 | } 94 | 95 | function onRejected(data) { 96 | return dispatch(subActions.fail(params, errorTransform(data, { params, options }))); 97 | } 98 | 99 | return [onStart, onResolved, onRejected]; 100 | } 101 | 102 | /** 103 | * Check if duration between request and last response is less than required delay 104 | * 105 | * @param {String} key - Request state key 106 | * @param {RequestDescription} reqDesc - Request description 107 | * @param {Resource} resource - Resource 108 | * @param {Object} force - If the request delay should be ignored 109 | * @returns {Boolean} result 110 | * @private 111 | */ 112 | export function shouldThrottle(key, reqDesc, resource, force) { 113 | if (!resource || force) return false; 114 | 115 | const repeatRequestDelay = 'repeatRequestDelay' in reqDesc 116 | ? reqDesc.repeatRequestDelay : constants.REPEAT_REQUEST_DELAY_DEFAULT; 117 | const responseDuration = Date.now() - resource.responseTime; 118 | return !!resource && responseDuration < repeatRequestDelay; 119 | } 120 | 121 | /** 122 | * Get request options allow for functions with access to redux getState 123 | * 124 | * @param {Object|Function} apiOptions - Options define at API level 125 | * @param {Object|Function} reqOptions - Options define at resource level 126 | * @param {Object|Function} actionOptions - Options set by action 127 | * @param {Function} getState - Get Redux state 128 | * @returns {Object} options 129 | * @private 130 | */ 131 | export function getRequestOptions(apiOptions, reqOptions, actionOptions, getState) { 132 | return { 133 | ...(isFunction(apiOptions) ? apiOptions(getState) : apiOptions), 134 | ...(isFunction(reqOptions) ? reqOptions(getState) : reqOptions), 135 | ...(isFunction(actionOptions) ? actionOptions(getState) : actionOptions) 136 | }; 137 | } 138 | 139 | /** 140 | * Generate the actionCreator functions 141 | * 142 | * @param {String} apiName - Name of the REST API 143 | * @param {ApiDescription} apiDesc - Description object of target REST API 144 | * @param {ApiConfig} [apiConfig] - Optional configuration settings 145 | * @returns {Object.} actionCreators 146 | * @private 147 | */ 148 | export default function createActionCreators(apiName, apiDesc, apiConfig = {}) { 149 | const { options: apiOptions = {} } = apiConfig; 150 | return Object.keys(apiDesc).reduce((acc, name) => { 151 | const reqDesc = apiDesc[name]; 152 | const { options: reqOptions = {} } = reqDesc; 153 | const resourceName = reqDesc.resourceAlias || name; 154 | const subActions = makeSubActionsCreators(apiName, resourceName, reqDesc); 155 | 156 | /** 157 | * Action creator which kicks off request and creates lifecycle actions. 158 | * The sub-action creators are used during the lifecycle of a request to dispatch 159 | * updates to a resource in redux state. They are also exposed should 160 | * direct developer access be needed. 161 | * 162 | * @typedef {Function} ActionCreatorFn 163 | * 164 | * @param {Object} params - Params applied to the url path or query 165 | * @param {Object|OptionsFn} [options] - Options to be passed to the request adapter 166 | * @returns {ActionCreatorThunkFn} thunk 167 | * 168 | * @property {SubActionCreatorFn} reset - Reset resource to baseline 169 | * @property {SubActionCreatorFn} start - Set resource to isUpdate 170 | * @property {SubActionCreatorFn} success - Update resource to be loaded with value from a response 171 | * @property {SubActionCreatorFn} fail - Update resource with error as returned from a response 172 | */ 173 | acc[name] = (params, options = {}) => { 174 | const { force = false } = options; 175 | 176 | /** 177 | * Thunk will actually dispatch an Action only if: 178 | * 1. it is not debounced 179 | * 2. it is not throttled 180 | * 181 | * Thunk will always return a resolving promise with either: 182 | * 1. new action being dispatched 183 | * 2. same action being dispatched if debounced 184 | * 3. previous action dispatched if throttled 185 | * 186 | * @typedef {Function} ActionCreatorThunkFn 187 | * 188 | * @param {Function} dispatch - Redux store dispatcher 189 | * @param {Function} getState - Get redux store state 190 | * @returns {Promise.} promise 191 | */ 192 | return (dispatch, getState) => { 193 | const promiseKeeper = getPromiseKeeper(dispatch); 194 | const key = getResourceKey(resourceName, params); 195 | const promiseKey = `${apiName}-${key}`; 196 | const resource = getState()[apiName] ? getState()[apiName][key] : null; 197 | 198 | // debounce request 199 | if (promiseKeeper.has(promiseKey)) return promiseKeeper.get(promiseKey); 200 | 201 | if (shouldThrottle(key, reqDesc, resource, force)) { 202 | return Promise.resolve(resource.hasError ? 203 | subActions.fail(params, resource.error) : 204 | subActions.success(params, resource.value) 205 | ); 206 | } 207 | 208 | const allOptions = getRequestOptions(apiOptions, reqOptions, options, getState); 209 | const [onStart, onResolved, onRejected] = createDispatchers(dispatch, reqDesc, subActions, params, allOptions); 210 | 211 | onStart(); 212 | const url = transformUrl(getUrlTemplate(reqDesc.url, getState), params); 213 | const promise = makeRequest(reqDesc.method, url, { ...allOptions }, apiConfig) 214 | .then(onResolved, onRejected); 215 | promiseKeeper.set(promiseKey, promise); 216 | return promise; 217 | }; 218 | }; 219 | 220 | // Expose subActions statically from action 221 | Object.keys(subActions).map(k => { acc[name][k] = subActions[k]; }); 222 | acc[name].subActions = subActions; 223 | return acc; 224 | }, {}); 225 | } 226 | -------------------------------------------------------------------------------- /src/addons/react-addons.js: -------------------------------------------------------------------------------- 1 | const PropTypes = require('prop-types'); 2 | const { array, number, bool, object, oneOfType, shape, node } = PropTypes; 3 | 4 | 5 | const resourcePropTypes = { 6 | value: oneOfType([object, array, node]), 7 | error: oneOfType([object, array, node]), 8 | hasError: bool, 9 | isLoaded: bool.isRequired, 10 | isUpdating: bool.isRequired, 11 | requestTime: number, 12 | responseTime: number 13 | }; 14 | 15 | /** 16 | * Use to extend the `resourceShape` to define `value` and/or `error` structures. 17 | * 18 | * @param {Object} propTypes - PropTypes to override resource shapes 19 | * @param {Object} [propTypes.value] - Shape of expected value 20 | * @param {Object} [propTypes.error] - Shape of expected error 21 | * @returns {shape} shape 22 | */ 23 | function extendResourceShape(propTypes = {}) { 24 | return shape({ 25 | ...resourcePropTypes, 26 | value: propTypes.value || resourcePropTypes.value, 27 | error: propTypes.error || resourcePropTypes.error 28 | }); 29 | } 30 | 31 | /** 32 | * Base propTypes shape used for validation of resources in React components. 33 | * 34 | * @property {Object|Array|String|*} value 35 | * @property {Object|Array|String|*} error 36 | * @property {Boolean} hasError 37 | * @property {Boolean} isLoaded 38 | * @property {Boolean} isUpdating 39 | * @property {Number} requestTime 40 | * @property {Number} responseTime 41 | */ 42 | const resourceShape = shape(resourcePropTypes); 43 | 44 | module.exports = { 45 | resourceShape, 46 | extendResourceShape 47 | }; 48 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const REPEAT_REQUEST_DELAY_DEFAULT = 3000; 2 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | import ExtendableError from 'es6-error'; 2 | 3 | export class RequestAdapterError extends ExtendableError {} 4 | -------------------------------------------------------------------------------- /src/fetchUtils.js: -------------------------------------------------------------------------------- 1 | export const handlers = {}; 2 | 3 | /** 4 | * Reject promise if Fetch response is not `ok` 5 | * 6 | * @param {Response} response - Fetch response 7 | * @param {Boolean} response.ok - Did response have a bad status 8 | * @param {JSON|string} data - Decoded response body 9 | * @returns {Promise} promise 10 | */ 11 | handlers.finish = function (response, data) { 12 | return response.ok ? Promise.resolve(data) : Promise.reject(data); 13 | }; 14 | 15 | /** 16 | * Decode fetch response content in order to store to redux state then pass to finish handler 17 | * 18 | * @param {Response} response - Fetch response 19 | * @returns {Promise} promise 20 | */ 21 | handlers.decode = function (response) { 22 | // No content, ignore response and return success 23 | if (response.status === 204) { 24 | return handlers.finish(response, null); 25 | } 26 | 27 | const contentType = response.headers.get('content-type'); 28 | 29 | if (contentType.includes('application/json')) { 30 | return response.json() 31 | .then(data => handlers.finish(response, data)) 32 | .catch(e => { 33 | // No content, ignore response and return success 34 | if (response.status === 202) { 35 | return handlers.finish(response, null); 36 | } 37 | throw e; 38 | }); 39 | } 40 | 41 | try { 42 | return response.text() 43 | .then(data => handlers.finish(response, data)); 44 | } catch (err) { 45 | return Promise.reject(new Error(`Content-type ${contentType} not supported`)); 46 | } 47 | }; 48 | 49 | /** 50 | * Make an adapter to match the RequestAdapter interface using Fetch 51 | * 52 | * @param {Function} fetcher - Fetch API or ponyfill 53 | * @param {Object} [defaultOptions] - Any default request options 54 | * @returns {Function} fetchAdapter 55 | */ 56 | export function makeFetchAdapter(fetcher, defaultOptions = {}) { 57 | /** 58 | * The RequestAdapter using Fetch 59 | * 60 | * @param {RequestAdapterOptions} options - Options from the request call 61 | * @returns {Promise} promise 62 | * @private 63 | */ 64 | function fetchAdapter(options) { 65 | const { url, withCredentials, ...rest } = options; 66 | const outOpts = { ...defaultOptions, ...rest }; 67 | outOpts.credentials = withCredentials ? 'include' : outOpts.credentials || 'same-origin'; 68 | return fetcher(url, outOpts).then(handlers.decode); 69 | } 70 | 71 | return fetchAdapter; 72 | } 73 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Reduxful, { setupApi } from './reduxful'; 2 | export { 3 | Reduxful as default, 4 | setupApi 5 | }; 6 | 7 | export { isLoaded, isUpdating, hasError, getResourceKey } from './utils'; 8 | export { makeFetchAdapter } from './fetchUtils'; 9 | -------------------------------------------------------------------------------- /src/promiseKeeper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Keeps track of unfulfilled promises. 3 | * 4 | * Implements part of the Map API. 5 | * 6 | * Usage: 7 | * Hand off a Promise with a key to a PromiseKeeper instance. 8 | * 9 | * ```js 10 | * const keeper = new PromiseKeeper(); 11 | * keeper.set('myKey', aPromise) 12 | * ``` 13 | * 14 | * The keeper will hold onto the promise until it is fulfilled. 15 | * 16 | * ```js 17 | * keeper.has('myKey') # true 18 | * 19 | * # after aPromise resolves or rejects 20 | * 21 | * keeper.has('myKey') # false 22 | * ``` 23 | */ 24 | export default class PromiseKeeper { 25 | 26 | constructor() { 27 | this._map = new Map(); 28 | } 29 | 30 | get(key) { 31 | if (this._map.has(key)) { 32 | return this._map.get(key); 33 | } 34 | } 35 | 36 | set(key, promise) { 37 | const remove = () => this._map.delete(key); 38 | promise.then(remove, remove); 39 | this._map.set(key, promise); 40 | } 41 | 42 | has(key) { 43 | return this._map.has(key); 44 | } 45 | 46 | clear() { 47 | return this._map.clear(); 48 | } 49 | 50 | delete(key) { 51 | return this._map.delete(key); 52 | } 53 | 54 | get size() { 55 | return this._map.size; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/reducers.js: -------------------------------------------------------------------------------- 1 | import { escapeRegExp } from './utils'; 2 | 3 | export const handlers = {}; 4 | 5 | handlers.onReset = function (state, action) { 6 | const { meta: { key }, payload: value } = action; 7 | return { 8 | ...state, 9 | [key]: { 10 | isLoaded: false, 11 | hasError: false, 12 | isUpdating: false, 13 | value, 14 | error: null, 15 | requestTime: null, 16 | responseTime: null 17 | } 18 | }; 19 | }; 20 | 21 | handlers.onStart = function (state, action) { 22 | const { meta: { key }, payload: value } = action; 23 | return { 24 | ...state, 25 | [key]: { 26 | isLoaded: false, 27 | hasError: false, 28 | value, 29 | ...state[key], 30 | isUpdating: true, 31 | requestTime: Date.now(), 32 | responseTime: null 33 | } 34 | }; 35 | }; 36 | 37 | handlers.onComplete = function (state, action) { 38 | const { meta: { key }, payload, error } = action; 39 | const hasError = error === true; 40 | return { 41 | ...state, 42 | [key]: { 43 | ...state[key], 44 | value: !hasError ? payload : null, 45 | error: hasError ? payload : null, 46 | isLoaded: !hasError, 47 | hasError, 48 | isUpdating: false, 49 | responseTime: Date.now() 50 | } 51 | }; 52 | }; 53 | 54 | /** 55 | * Creates a reducer which handles actions for a given api name. 56 | * 57 | * @param {String} apiName - Name of the api 58 | * @returns {ReducerFn} reducer 59 | * @private 60 | */ 61 | export default function createReducer(apiName) { 62 | const safeName = escapeRegExp(apiName); 63 | const reApiAction = new RegExp(`^${safeName}_(?:.+)_(RESET|START|SUCCESS|FAIL)$`); 64 | return function reducer(state = {}, action) { 65 | const match = reApiAction.exec(action.type); 66 | if (match) { 67 | const type = match[1]; 68 | if (type === 'RESET') return handlers.onReset(state, action); 69 | if (type === 'START') return handlers.onStart(state, action); 70 | return handlers.onComplete(state, action); 71 | } 72 | return state; 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /src/reduxful.js: -------------------------------------------------------------------------------- 1 | import { parseApiDesc } from './utils'; 2 | import { setRequestAdapter } from './requestAdapter'; 3 | import createActionCreators from './actionCreators'; 4 | import createReducer from './reducers'; 5 | import createSelectors from './selectors'; 6 | 7 | /** 8 | * Main class which manages RESTful requests and state with Redux. 9 | * 10 | * @public 11 | */ 12 | class Reduxful { 13 | 14 | /** 15 | * Create new RESTful configuration for Redux. 16 | * 17 | * @param {String} apiName - Name of the REST API 18 | * @param {ApiDescription} apiDesc - Description object of target REST API 19 | * @param {ApiConfig} [apiConfig] - Optional configuration settings 20 | * @param {RequestAdapter} [apiConfig.requestAdapter] - Request adapter to use 21 | * @param {Object|OptionsFn} [apiConfig.options] - Options to be passed to the request adapter 22 | */ 23 | constructor(apiName, apiDesc, apiConfig = {}) { 24 | this.name = apiName; 25 | this._apiDesc = parseApiDesc(apiDesc); 26 | this._actionCreators = createActionCreators(this.name, this._apiDesc, apiConfig); 27 | this._reducer = createReducer(this.name); 28 | this._selectors = createSelectors(this.name, this._apiDesc); 29 | } 30 | 31 | /** 32 | * Property 33 | * 34 | * @returns {Object.} redux action creators 35 | */ 36 | get actionCreators() { 37 | return this._actionCreators; 38 | } 39 | 40 | /** 41 | * Alias to actionCreators 42 | * 43 | * @returns {Object.} redux action creators 44 | */ 45 | get actions() { 46 | return this._actionCreators; 47 | } 48 | 49 | /** 50 | * Property 51 | * 52 | * @returns {Object.} redux reducers 53 | */ 54 | get reducers() { 55 | return { [this.name]: this._reducer }; 56 | } 57 | 58 | /** 59 | * Alias to reducers 60 | * 61 | * @returns {Object.} redux reducers 62 | */ 63 | get reducerMap() { 64 | return this.reducers; 65 | } 66 | 67 | /** 68 | * Property 69 | * 70 | * @returns {Object.} redux selectors 71 | */ 72 | get selectors() { 73 | return this._selectors; 74 | } 75 | } 76 | 77 | Reduxful.setRequestAdapter = setRequestAdapter; 78 | 79 | /** 80 | * Create new RESTful configuration for Redux. 81 | * 82 | * @param {String} apiName - Name of the REST API 83 | * @param {ApiDescription} apiDesc - Description object of target REST API 84 | * @param {ApiConfig} [config] - Optional configuration settings 85 | * @param {RequestAdapter} [config.requestAdapter] - Request adapter to use 86 | * @param {Object|OptionsFn} [config.options] - Options to be passed to the request adapter 87 | * @returns {Reduxful} instance 88 | */ 89 | function setupApi(apiName, apiDesc, config) { 90 | return new Reduxful(apiName, apiDesc, config); 91 | } 92 | 93 | 94 | export { 95 | Reduxful as default, 96 | setupApi 97 | }; 98 | -------------------------------------------------------------------------------- /src/requestAdapter.js: -------------------------------------------------------------------------------- 1 | import { RequestAdapterError } from './errors'; 2 | 3 | global.Reduxful = global.Reduxful || {}; 4 | 5 | /** 6 | * RequestAdapter structure 7 | * 8 | * @typedef {Function} RequestAdapter 9 | * @param {RequestAdapterOptions} options 10 | * @returns {Promise} 11 | */ 12 | 13 | /** 14 | * RequestAdapter Options 15 | * 16 | * @typedef {Object} RequestAdapterOptions 17 | * @property {Object} options 18 | * @property {String} options.method - HTTP method to use (GET, POST, etc.) 19 | * @property {String} options.url - URL to call 20 | * @property {Object.} options.headers - Header for request 21 | * @property {Boolean} options.withCredentials - Should cookies be passed for cross-origin requests 22 | * @property {*} options.body - Optional body of request 23 | */ 24 | 25 | /** 26 | * Get the registered request adapter. 27 | * 28 | * @returns {RequestAdapter} requestAdapter 29 | * @private 30 | */ 31 | export function getRequestAdapter() { 32 | if (!global.Reduxful.requestAdapter) { 33 | throw new RequestAdapterError('No Request Adapter has been set.'); 34 | } 35 | return global.Reduxful.requestAdapter; 36 | } 37 | 38 | /** 39 | * Register an ajax request adapter. 40 | * 41 | * @param {RequestAdapter} requestAdapter - Request adapter to use 42 | * @public 43 | */ 44 | export function setRequestAdapter(requestAdapter) { 45 | if (!global.Reduxful.requestAdapter) { 46 | global.Reduxful.requestAdapter = requestAdapter; 47 | return; 48 | } 49 | throw new RequestAdapterError('Request Adapter already set.'); 50 | } 51 | 52 | /** 53 | * Makes a request using the request adapter defined by api config the global default. 54 | * 55 | * @param {String} method - Request HTTP Method 56 | * @param {String} url - Transformed URL of the rest endpoint 57 | * @param {Object} [options] - Additional request options 58 | * @param {ApiConfig} [config] - Api configuration 59 | * @returns {Promise} promise 60 | * @private 61 | */ 62 | export function makeRequest(method, url, options = {}, config = {}) { 63 | let { requestAdapter } = config; 64 | if (!requestAdapter) { 65 | requestAdapter = getRequestAdapter(); 66 | } 67 | 68 | const requestOptions = { 69 | method, 70 | url, 71 | ...options 72 | }; 73 | 74 | return requestAdapter(requestOptions); 75 | } 76 | -------------------------------------------------------------------------------- /src/selectors.js: -------------------------------------------------------------------------------- 1 | import { getResourceKey } from './utils'; 2 | 3 | 4 | export default function createSelectors(apiName, apiDesc) { 5 | return Object.keys(apiDesc).reduce((acc, name) => { 6 | const reqDesc = apiDesc[name]; 7 | const { resourceAlias } = reqDesc; 8 | 9 | acc[name] = function selector(state, params) { 10 | const key = getResourceKey(resourceAlias || name, params); 11 | return (state[apiName] || {})[key]; 12 | }; 13 | 14 | if (resourceAlias && !acc[resourceAlias]) acc[resourceAlias] = acc[name]; 15 | 16 | return acc; 17 | }, {}); 18 | } 19 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Resource object 3 | * 4 | * @typedef {Object} Resource 5 | * 6 | * @property {Object|Array|*} value - Body of the api response 7 | * @property {Object|Array|*} error - Body of the api error response 8 | * @property {Boolean} hasError - True if api response returned as an error 9 | * @property {Boolean} isLoaded - True if api response returned as success 10 | * @property {Boolean} isUpdating - True if a request is pending 11 | * @property {Number|null} requestTime - Timestamp when new request started 12 | * @property {Number|null} responseTime - Timestamp when response received 13 | */ 14 | 15 | 16 | /** 17 | * Request Description object 18 | * 19 | * @typedef {Object} RequestDescription 20 | * 21 | * @property {String|UrlTemplateFn} url - URL template of the REST endpoint. 22 | * Placeholders can be set, with final URL built by [transform-url](https://github.com/godaddy/transform-url#readme). 23 | * @property {String} [method] - HTTP method to use 24 | * @property {String} [resourceAlias] - Resource name alias 25 | * @property {Object|Array|*} [resourceData] - Optional initial resource data 26 | * @property {TransformFn} [dataTransform] - Function to fixup request response 27 | * @property {TransformFn} [errorTransform] - Function to fixup request error response 28 | * @property {Number} [repeatRequestDelay] - Required delay time in milliseconds between repeated requests 29 | * @property {Object|OptionsFn} [options] - Options to be passed to the request adapter 30 | */ 31 | 32 | 33 | /** 34 | * Api Description object 35 | * 36 | * @typedef {Object} ApiDescription 37 | * 38 | * @type {Object.} - requestName: reqDesc 39 | */ 40 | 41 | 42 | /** 43 | * Api Config object 44 | * 45 | * @typedef {Object} ApiConfig 46 | * 47 | * @property {RequestAdapter} [requestAdapter] - Adapter for request library 48 | * @property {Object|OptionsFn} [options] - Options to be passed to the request adapter 49 | */ 50 | 51 | 52 | /** 53 | * [Flux Standard Action](https://github.com/redux-utilities/flux-standard-action) compliant action. 54 | * 55 | * @typedef {Object} Action 56 | * 57 | * @property {String} type - The type of action in the format `__` 58 | * @property {String} payload - Transformed resource value or error; body of response. 59 | * @property {Object} meta - Action metadata 60 | * @property {String} meta.key - Key of the particular resource 61 | * @property {Boolean} [error] - Whether the action is an error 62 | */ 63 | 64 | 65 | /** 66 | * Function to create request options object which can read from Redux state 67 | * 68 | * @typedef {Function} OptionsFn 69 | * 70 | * @param {Function} getState - Gets the current redux state 71 | * @returns {Object} options 72 | */ 73 | 74 | 75 | /** 76 | * Function to create url template string which can read from Redux state. 77 | * Placeholders can be set, with final URL built by [transform-url](https://github.com/godaddy/transform-url#readme). 78 | * 79 | * @typedef {Function} UrlTemplateFn 80 | * 81 | * @param {Function} getState - Gets the current redux state 82 | * @returns {String} urlTemplate 83 | */ 84 | 85 | 86 | /** 87 | * Sub action creator function 88 | * 89 | * @typedef {Function} SubActionCreatorFn 90 | * 91 | * @param {Object} params - Params applied to the url path or query 92 | * @param {Object} payload - Transformed resource value or error; body of response. 93 | * @returns {Object} action 94 | */ 95 | 96 | 97 | /** 98 | * Selector function to retrieve a resource from Redux state 99 | * 100 | * @typedef {Function} SelectorFn 101 | * 102 | * @param {Object} state - Redux state to select resource from 103 | * @param {Object} params - Params used to key a particular resource request 104 | * @returns {Resource} resource 105 | */ 106 | 107 | 108 | /** 109 | * Redux Reducer function 110 | * 111 | * @typedef {Function} ReducerFn 112 | * 113 | * @param {Object} state - State 114 | * @param {Object} action - Action 115 | * @returns {Object} newState 116 | */ 117 | 118 | 119 | /** 120 | * Transform function 121 | * 122 | * @typedef {Function} TransformFn 123 | * 124 | * @param {Object|Array|*} data - Body of the api response 125 | * @param {Object} [context] - Context 126 | * @param {Object} [context.params] - Params from action for request 127 | * @returns {*} data 128 | */ 129 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if object is a function. 3 | * 4 | * @param {*} maybeFn - Potential function 5 | * @returns {Boolean} result 6 | * @private 7 | */ 8 | export function isFunction(maybeFn) { 9 | return typeof maybeFn === 'function'; 10 | } 11 | 12 | /** 13 | * Builds the resource key based on the parameters passed. 14 | * 15 | * @param {String} reqName - Name of the API request. 16 | * @param {Object.} params - Parameters used as URL or Query params 17 | * @returns {String} resource key 18 | */ 19 | export function getResourceKey(reqName, params) { 20 | if (!params) return reqName; 21 | 22 | return Object.keys(params).sort().reduce((acc, cur) => { 23 | acc += `__${cur}:${params[cur]}`; 24 | return acc; 25 | }, reqName); 26 | } 27 | 28 | /** 29 | * Helper function to check if a Resource has been loaded. 30 | * 31 | * @param {Resource} resource - Resource object 32 | * @returns {Boolean} result 33 | */ 34 | export function isLoaded(resource) { 35 | return !!resource && resource.isLoaded; 36 | } 37 | 38 | /** 39 | * Helper function to check of a Resource is being updated. 40 | * 41 | * @param {Resource} resource - Resource object 42 | * @returns {Boolean} result 43 | */ 44 | export function isUpdating(resource) { 45 | return !!resource && resource.isUpdating; 46 | } 47 | 48 | /** 49 | * Helper function to check if a Resource has an error. 50 | * 51 | * @param {Resource} resource - Resource object 52 | * @returns {Boolean} result 53 | */ 54 | export function hasError(resource) { 55 | return !!resource && resource.hasError; 56 | } 57 | 58 | /** 59 | * Inspect and align Request Description object. 60 | * 61 | * @param {RequestDescription} reqDesc - Request Description object 62 | * @returns {RequestDescription} reqDesc 63 | * @private 64 | */ 65 | export function parseReqDesc(reqDesc) { 66 | reqDesc.method = (reqDesc.method || 'GET').toUpperCase(); 67 | 68 | if ('withCredentials' in reqDesc) { 69 | // eslint-disable-next-line no-console 70 | console.warn('`withCredentials` on RequestDescription is being deprecated. Set in `options` instead.'); 71 | if (!isFunction(reqDesc.options)) { 72 | reqDesc.options = { withCredentials: reqDesc.withCredentials, ...reqDesc.options || {} }; 73 | } 74 | } 75 | 76 | return reqDesc; 77 | } 78 | 79 | /** 80 | * Inspect and align API Description object. 81 | * 82 | * @param {ApiDescription} apiDesc - Api Description object 83 | * @returns {ApiDescription} apiDesc 84 | * @private 85 | */ 86 | export function parseApiDesc(apiDesc) { 87 | return Object.keys(apiDesc).reduce((acc, name) => { 88 | const reqDesc = apiDesc[name]; 89 | acc[name] = parseReqDesc(reqDesc); 90 | return acc; 91 | }, {}); 92 | } 93 | 94 | /** 95 | * Get the url template from static string or dynamically with redux state. 96 | * 97 | * @param {String|Function} url - Url template 98 | * @param {Function} getState - Get Redux state 99 | * @returns {String} url 100 | * @private 101 | */ 102 | export function getUrlTemplate(url, getState) { 103 | if (isFunction(url)) return url(getState); 104 | return url; 105 | } 106 | 107 | /** 108 | * Escape special characters for RegExp 109 | * 110 | * @see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping 111 | * @param {string} string - Intended for expression 112 | * @returns {string} escaped 113 | */ 114 | export function escapeRegExp(string) { 115 | return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 116 | } 117 | -------------------------------------------------------------------------------- /tests/actionCreators.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env, max-nested-callbacks */ 2 | 3 | import createActionCreators, { 4 | makeSubActionsCreators, 5 | createDispatchers, 6 | getRequestOptions, 7 | shouldThrottle 8 | } from '../src/actionCreators'; 9 | import * as requestAdapter from '../src/requestAdapter'; 10 | import { mockApiName, mockApiDesc } from './fixtures/mockApi'; 11 | 12 | const mockPayload = { some: 'data' }; 13 | const mockParams = { id: 1234 }; 14 | const mockOptions = { }; 15 | const mockKey = 'getFruit__id:1234'; 16 | 17 | const fsaProperties = ['type', 'payload', 'error', 'meta']; 18 | 19 | describe('ActionCreators', () => { 20 | let results; 21 | 22 | describe('subActions', () => { 23 | let subActions; 24 | 25 | beforeAll(() => { 26 | subActions = makeSubActionsCreators(mockApiName, 'getFruit', mockApiDesc.getFruit); 27 | }); 28 | 29 | describe('makeSubActionsCreators', () => { 30 | it('returns an object of actionCreator functions', () => { 31 | results = makeSubActionsCreators(mockApiName, 'getFruit', mockApiDesc.getFruit); 32 | expect(results).toBeInstanceOf(Object); 33 | expect(results).toHaveProperty('start', expect.any(Function)); 34 | expect(results).toHaveProperty('success', expect.any(Function)); 35 | expect(results).toHaveProperty('fail', expect.any(Function)); 36 | }); 37 | }); 38 | 39 | describe('START', () => { 40 | 41 | it('returns start action type for api name', () => { 42 | results = subActions.start(); 43 | expect(results).toHaveProperty('type', 'mockApi_getFruit_START'); 44 | }); 45 | 46 | it('returns key in action meta', () => { 47 | results = subActions.start(mockParams); 48 | expect(results).toHaveProperty('meta', expect.any(Object)); 49 | expect(results.meta).toHaveProperty('key', mockKey); 50 | }); 51 | 52 | it('only contains FSA properties', () => { 53 | const action = subActions.start(mockParams); 54 | const keys = Object.keys(action); 55 | expect(keys.every(k => fsaProperties.includes(k))).toBe(true); 56 | }); 57 | 58 | it('returns passed payload', () => { 59 | results = subActions.start(mockParams, mockPayload); 60 | expect(results).toHaveProperty('payload', mockPayload); 61 | }); 62 | 63 | it('returns default payload if no payload provided', () => { 64 | results = subActions.start(mockParams); 65 | expect(results).toHaveProperty('payload', {}); 66 | }); 67 | }); 68 | 69 | describe('RESET', () => { 70 | 71 | it('returns reset action type for api name', () => { 72 | results = subActions.reset(); 73 | expect(results).toHaveProperty('type', 'mockApi_getFruit_RESET'); 74 | }); 75 | 76 | it('returns key in action meta', () => { 77 | results = subActions.reset(mockParams); 78 | expect(results).toHaveProperty('meta', expect.any(Object)); 79 | expect(results.meta).toHaveProperty('key', mockKey); 80 | }); 81 | 82 | it('only contains FSA properties', () => { 83 | const action = subActions.reset(mockParams); 84 | const keys = Object.keys(action); 85 | expect(keys.every(k => fsaProperties.includes(k))).toBe(true); 86 | }); 87 | 88 | it('returns passed payload', () => { 89 | results = subActions.reset(mockParams, mockPayload); 90 | expect(results).toHaveProperty('payload', mockPayload); 91 | }); 92 | 93 | it('returns default payload if no payload provided', () => { 94 | results = subActions.reset(mockParams); 95 | expect(results).toHaveProperty('payload', {}); 96 | }); 97 | }); 98 | 99 | describe('SUCCESS', () => { 100 | 101 | it('returns complete action type for api name', () => { 102 | results = subActions.success(mockParams); 103 | expect(results).toHaveProperty('type', 'mockApi_getFruit_SUCCESS'); 104 | }); 105 | 106 | it('returns key in action meta', () => { 107 | results = subActions.success(mockParams); 108 | expect(results).toHaveProperty('meta', expect.any(Object)); 109 | expect(results.meta).toHaveProperty('key', mockKey); 110 | }); 111 | 112 | it('only contains FSA properties', () => { 113 | const action = subActions.success(mockParams); 114 | const keys = Object.keys(action); 115 | expect(keys.every(k => fsaProperties.includes(k))).toBe(true); 116 | }); 117 | 118 | it('returns payload', () => { 119 | results = subActions.success(mockParams, mockPayload); 120 | expect(results).toHaveProperty('payload', mockPayload); 121 | }); 122 | 123 | it('does not return error in action', () => { 124 | results = subActions.success(mockParams); 125 | expect(results).not.toHaveProperty('error'); 126 | }); 127 | }); 128 | 129 | describe('FAIL', () => { 130 | 131 | it('returns complete action type for api name', () => { 132 | results = subActions.fail(mockParams); 133 | expect(results).toHaveProperty('type', 'mockApi_getFruit_FAIL'); 134 | }); 135 | 136 | it('returns key in action meta', () => { 137 | results = subActions.fail(mockParams); 138 | expect(results).toHaveProperty('meta', expect.any(Object)); 139 | expect(results.meta).toHaveProperty('key', mockKey); 140 | }); 141 | 142 | it('only contains FSA properties', () => { 143 | const action = subActions.fail(mockParams); 144 | const keys = Object.keys(action); 145 | expect(keys.every(k => fsaProperties.includes(k))).toBe(true); 146 | }); 147 | 148 | it('returns payload', () => { 149 | results = subActions.fail(mockParams, mockPayload); 150 | expect(results).toHaveProperty('payload', mockPayload); 151 | }); 152 | 153 | it('returns error=true in action', () => { 154 | results = subActions.fail(mockParams); 155 | expect(results).toHaveProperty('error', true); 156 | }); 157 | }); 158 | }); 159 | 160 | describe('getRequestOptions', () => { 161 | let mockGetState; 162 | 163 | beforeEach(() => { 164 | mockGetState = jest.fn(); 165 | }); 166 | 167 | it('allows options as objects', () => { 168 | results = getRequestOptions({ api: 1 }, { req: 2 }, { action: 3 }, mockGetState); 169 | expect(results).toEqual({ 170 | api: 1, 171 | req: 2, 172 | action: 3 173 | }); 174 | }); 175 | 176 | it('allows options as functions', () => { 177 | results = getRequestOptions(() => ({ api: 1 }), () => ({ req: 2 }), () => ({ action: 3 }), mockGetState); 178 | expect(results).toEqual({ 179 | api: 1, 180 | req: 2, 181 | action: 3 182 | }); 183 | }); 184 | 185 | it('action options override request options', () => { 186 | results = getRequestOptions({}, { bogus: 'B' }, { bogus: 'C' }, mockGetState); 187 | expect(results).toEqual({ bogus: 'C' }); 188 | }); 189 | 190 | it('request options override api options', () => { 191 | results = getRequestOptions({ bogus: 'A' }, { bogus: 'B' }, {}, mockGetState); 192 | expect(results).toEqual({ bogus: 'B' }); 193 | }); 194 | 195 | it('functions access redux getState', () => { 196 | mockGetState 197 | .mockReturnValueOnce({ api: 1 }) 198 | .mockReturnValueOnce({ req: 2 }) 199 | .mockReturnValue({ action: 3 }); 200 | 201 | const f = (getState) => getState(); 202 | results = getRequestOptions(f, f, f, mockGetState); 203 | 204 | expect(mockGetState).toHaveBeenCalledTimes(3); 205 | expect(results).toEqual({ 206 | api: 1, 207 | req: 2, 208 | action: 3 209 | }); 210 | }); 211 | }); 212 | 213 | describe('factory', () => { 214 | 215 | it('creates a map of functions', () => { 216 | const actions = createActionCreators(mockApiName, mockApiDesc); 217 | expect(actions).toBeInstanceOf(Object); 218 | expect(actions).toHaveProperty('getFruit'); 219 | expect(actions.getFruit).toBeInstanceOf(Function); 220 | }); 221 | 222 | it('attaches sub-actionCreator functions', () => { 223 | const actions = createActionCreators(mockApiName, mockApiDesc); 224 | expect(actions.getFruit).toHaveProperty('start', expect.any(Function)); 225 | expect(actions.getFruit).toHaveProperty('success', expect.any(Function)); 226 | expect(actions.getFruit).toHaveProperty('fail', expect.any(Function)); 227 | }); 228 | }); 229 | 230 | describe('dispatchers', () => { 231 | let mockDataTransform, mockErrorTransform, dispatch, subActions, onStart, onResolved, onRejected; 232 | 233 | beforeEach(() => { 234 | dispatch = jest.fn(); 235 | mockDataTransform = jest.fn(); 236 | mockErrorTransform = jest.fn(); 237 | const mockReqDesc = { 238 | url: '/proto/fruits/:id', 239 | dataTransform: mockDataTransform, 240 | errorTransform: mockErrorTransform 241 | }; 242 | subActions = { 243 | start: jest.fn(), 244 | success: jest.fn(), 245 | fail: jest.fn() 246 | }; 247 | [onStart, onResolved, onRejected] = createDispatchers(dispatch, mockReqDesc, subActions, mockParams, mockOptions); 248 | }); 249 | 250 | describe('onStart', () => { 251 | it('dispatches start', () => { 252 | onStart(); 253 | expect(subActions.start).toHaveBeenCalled(); 254 | }); 255 | }); 256 | 257 | describe('onResolved', () => { 258 | it('dispatches success', () => { 259 | onResolved(); 260 | expect(subActions.success).toHaveBeenCalled(); 261 | }); 262 | 263 | it('executes dataTransform', () => { 264 | const mockData = { bogus: 'BOGUS' }; 265 | onResolved(mockData); 266 | expect(mockDataTransform).toHaveBeenCalledWith(mockData, { params: mockParams, options: mockOptions }); 267 | }); 268 | }); 269 | 270 | describe('onRejected', () => { 271 | it('dispatches success', () => { 272 | onRejected(); 273 | expect(subActions.fail).toHaveBeenCalled(); 274 | }); 275 | 276 | it('executes errorTransform', () => { 277 | const mockError = { something: 'terrible' }; 278 | onRejected(mockError); 279 | expect(mockErrorTransform).toHaveBeenCalledWith(mockError, { params: mockParams, options: mockOptions }); 280 | }); 281 | }); 282 | }); 283 | 284 | describe('generated', () => { 285 | let actions, dispatch, requestSpy, getState; 286 | 287 | beforeAll(() => { 288 | actions = createActionCreators(mockApiName, mockApiDesc); 289 | dispatch = jest.fn(); 290 | ['getFruit', 'getFruits'].forEach(k => { 291 | ['start', 'success', 'fail'].forEach(sub => jest.spyOn(actions[k].subActions, sub)); 292 | }); 293 | }); 294 | 295 | beforeEach(() => { 296 | requestSpy = jest.spyOn(requestAdapter, 'makeRequest').mockImplementation().mockResolvedValue({ name: 'Apricot' }); 297 | dispatch.mockReset(); 298 | dispatch.mockResolvedValue(); 299 | getState = () => ({}); 300 | }); 301 | 302 | afterAll(() => { 303 | jest.restoreAllMocks(); 304 | }); 305 | 306 | it('returns a thunk', () => { 307 | const thunk = actions.getFruit(mockParams); 308 | expect(thunk).toBeInstanceOf(Function); 309 | }); 310 | 311 | it('thunk executes dispatch', () => { 312 | const thunk = actions.getFruit(mockParams); 313 | thunk(dispatch, getState); 314 | expect(dispatch).toHaveBeenCalled(); 315 | }); 316 | 317 | it('thunk dispatches start actionCreator', () => { 318 | const thunk = actions.getFruit(mockParams); 319 | thunk(dispatch, getState); 320 | expect(actions.getFruit.subActions.start).toHaveBeenCalledWith(mockParams); 321 | }); 322 | 323 | it('thunk dispatches start actionCreator with default resource data', () => { 324 | const thunk = actions.getFruits(); 325 | thunk(dispatch, getState); 326 | expect(actions.getFruits.subActions.start).toHaveBeenCalledWith(expect.undefined); 327 | }); 328 | 329 | it('thunk makes request', () => { 330 | const thunk = actions.getFruit(mockParams); 331 | thunk(dispatch, getState); 332 | expect(requestAdapter.makeRequest).toHaveBeenCalled(); 333 | }); 334 | 335 | it('dispatches success actionCreator on successful response', async () => { 336 | const thunk = actions.getFruit(mockParams); 337 | await thunk(dispatch, getState); 338 | expect(actions.getFruit.subActions.success).toHaveBeenCalled(); 339 | }); 340 | 341 | it('dispatches fail actionCreator on erroneous response', async () => { 342 | requestSpy.mockReset().mockRejectedValueOnce(false); 343 | 344 | const thunk = actions.getFruit(mockParams); 345 | await thunk(dispatch, getState); 346 | expect(actions.getFruit.subActions.fail).toHaveBeenCalled(); 347 | }); 348 | 349 | describe('Throttling', () => { 350 | let nodeEnv, mockState; 351 | 352 | beforeAll(() => { 353 | nodeEnv = process.env.NODE_ENV; 354 | }); 355 | 356 | beforeEach(() => { 357 | mockState = { 358 | [mockApiName]: { 359 | [mockKey]: { 360 | isLoaded: true, 361 | responseTime: Date.now() 362 | } 363 | } 364 | }; 365 | getState = () => (mockState); 366 | }); 367 | 368 | afterEach(() => { 369 | process.env.NODE_ENV = nodeEnv; 370 | }); 371 | 372 | it('uses repeatRequestDelay on reqDesc', () => { 373 | const mockReqDesc = { 374 | url: '/proto/fruits/:id', 375 | repeatRequestDelay: 123 376 | }; 377 | results = shouldThrottle(mockKey, mockReqDesc, { isLoaded: true, responseTime: Date.now() }); 378 | expect(results).toBe(true); 379 | }); 380 | 381 | it('uses default repeatRequestDelay if not on reqDesc', () => { 382 | const mockReqDesc = { 383 | url: '/proto/fruits/:id' 384 | }; 385 | results = shouldThrottle(mockKey, mockReqDesc, { isLoaded: true, responseTime: Date.now() }); 386 | expect(results).toBe(true); 387 | }); 388 | 389 | it('does not throttle of no resource', () => { 390 | const mockReqDesc = { 391 | url: '/proto/fruits/:id' 392 | }; 393 | results = shouldThrottle(mockKey, mockReqDesc, null); 394 | expect(results).toBe(false); 395 | }); 396 | 397 | it('does not throttle of no resource.responseTime', () => { 398 | const mockReqDesc = { 399 | url: '/proto/fruits/:id' 400 | }; 401 | results = shouldThrottle(mockKey, mockReqDesc, { value: 'bogus' }); 402 | expect(results).toBe(false); 403 | }); 404 | 405 | it('does throttle if early resource.responseTime', () => { 406 | const mockReqDesc = { 407 | url: '/proto/fruits/:id' 408 | }; 409 | results = shouldThrottle(mockKey, mockReqDesc, { value: 'bogus', responseTime: Date.now() }); 410 | expect(results).toBe(true); 411 | }); 412 | 413 | it('does not throttle of no force=true', () => { 414 | const mockReqDesc = { 415 | url: '/proto/fruits/:id' 416 | }; 417 | results = shouldThrottle(mockKey, mockReqDesc, { value: 'bogus', responseTime: Date.now() }, true); 418 | expect(results).toBe(false); 419 | }); 420 | 421 | it('does not start request if repeated within delay', () => { 422 | const thunk = actions.getFruit(mockParams); 423 | thunk(dispatch, getState); 424 | expect(dispatch).not.toHaveBeenCalled(); 425 | }); 426 | 427 | it('starts request if repeated within delay but force=true', () => { 428 | const thunk = actions.getFruit(mockParams, { force: true }); 429 | thunk(dispatch, getState); 430 | expect(dispatch).toHaveBeenCalled(); 431 | }); 432 | 433 | it('returns a promise even if throttled', () => { 434 | const thunk = actions.getFruit(mockParams); 435 | const firstPromise = thunk(dispatch, getState); 436 | const nextPromise = thunk(dispatch, getState); 437 | expect(firstPromise).toBeInstanceOf(Promise); 438 | expect(nextPromise).toBeInstanceOf(Promise); 439 | expect(firstPromise).not.toBe(nextPromise); 440 | }); 441 | 442 | it('action from throttle promise should be the look the same', async () => { 443 | const thunk = actions.getFruit(mockParams); 444 | const firstAction = await thunk(dispatch, getState); 445 | const nextAction = await thunk(dispatch, getState); 446 | expect(firstAction).not.toBe(nextAction); 447 | expect(firstAction).toEqual(nextAction); 448 | }); 449 | 450 | it('returned throttle promise can resolve a fail action', async () => { 451 | mockState[mockApiName][mockKey].hasError = true; 452 | mockState[mockApiName][mockKey].error = 'An error'; 453 | 454 | const thunk = actions.getFruit(mockParams); 455 | const firstAction = await thunk(dispatch, getState); 456 | expect(firstAction.error).toBe(true); 457 | expect(firstAction.payload).toEqual('An error'); 458 | }); 459 | }); 460 | 461 | describe('Debouncing', () => { 462 | 463 | it('thunk returns promise', () => { 464 | const thunk = actions.getFruit(mockParams); 465 | const promise = thunk(dispatch, getState); 466 | expect(promise).toHaveProperty('then'); 467 | expect(promise).toHaveProperty('catch'); 468 | }); 469 | 470 | it('does not make new request if promise in flight', () => { 471 | const thunk = actions.getFruit(mockParams); 472 | thunk(dispatch, getState); 473 | thunk(dispatch, getState); 474 | expect(dispatch).toHaveBeenCalledTimes(1); 475 | }); 476 | 477 | it('returns existing promise if in flight', () => { 478 | const thunk = actions.getFruit(mockParams); 479 | const firstPromise = thunk(dispatch, getState); 480 | const nextPromise = thunk(dispatch, getState); 481 | expect(firstPromise).toBe(nextPromise); 482 | }); 483 | 484 | it('does not debounce if dispatched from a different store', () => { 485 | const otherDispatch = jest.fn(); 486 | const thunk = actions.getFruit(mockParams); 487 | const firstPromise = thunk(dispatch, getState); 488 | const nextPromise = thunk(otherDispatch, getState); 489 | expect(firstPromise).not.toBe(nextPromise); 490 | }); 491 | 492 | it('does not debounce if dispatched from a different instance', () => { 493 | const otherActions = createActionCreators('api2', mockApiDesc); 494 | 495 | const thunk = actions.getFruit(mockParams); 496 | const thunk2 = otherActions.getFruit(mockParams); 497 | 498 | const firstPromise = thunk(dispatch, getState); 499 | const nextPromise = thunk2(dispatch, getState); 500 | expect(firstPromise).not.toBe(nextPromise); 501 | }); 502 | }); 503 | }); 504 | }); 505 | -------------------------------------------------------------------------------- /tests/fetchUtils.spec.js: -------------------------------------------------------------------------------- 1 | import { makeFetchAdapter, handlers, defaultHeaders } from '../src/fetchUtils'; 2 | 3 | const mockUrl = 'https://example.com/api'; 4 | const mockDefaultOptions = { bogusDefault: 'BOGUS DEFAULT' }; 5 | const mockOptions = { bogus: 'BOGUS' }; 6 | const mockFooHeaders = { Foo: 'foo' }; 7 | const mockFetch = jest.fn(); 8 | const mockJsonData = { bogus: 'JSON' }; 9 | const mockTextData = 'bogus text'; 10 | 11 | describe('fetchUtils', () => { 12 | let fetchAdapter; 13 | 14 | beforeAll(() => { 15 | jest.spyOn(handlers, 'decode'); 16 | }); 17 | 18 | beforeEach(() => { 19 | mockFetch.mockReset(); 20 | mockFetch.mockResolvedValue(); 21 | fetchAdapter = makeFetchAdapter(mockFetch, mockDefaultOptions); 22 | }); 23 | 24 | describe('makeFetchAdapter', () => { 25 | 26 | it('returns a function', () => { 27 | const result = makeFetchAdapter(mockFetch); 28 | expect(result).toBeInstanceOf(Function); 29 | }); 30 | it('accepts options as an object', () => { 31 | const result = makeFetchAdapter(mockFetch, {}); 32 | expect(result).toBeInstanceOf(Function); 33 | }); 34 | }); 35 | 36 | describe('fetchAdapter', () => { 37 | 38 | beforeAll(() => { 39 | handlers.decode.mockImplementation().mockResolvedValue('bogus text'); 40 | }); 41 | 42 | afterAll(() => { 43 | jest.restoreAllMocks(); 44 | }); 45 | 46 | it('calls wrapped function', () => { 47 | fetchAdapter({ url: mockUrl }); 48 | expect(mockFetch).toHaveBeenCalled(); 49 | }); 50 | 51 | it('calls wrapped function with url from options as first arg', () => { 52 | fetchAdapter({ url: mockUrl }); 53 | expect(mockFetch).toHaveBeenCalledWith(mockUrl, expect.any(Object)); 54 | }); 55 | 56 | it('calls wrapped function with options', () => { 57 | fetchAdapter({ url: mockUrl, ...mockOptions }); 58 | expect(mockFetch).toHaveBeenCalledWith(mockUrl, expect.objectContaining(mockOptions)); 59 | }); 60 | 61 | it('calls wrapped function with no default headers', () => { 62 | fetchAdapter({ url: mockUrl, ...mockOptions }); 63 | expect(mockFetch).toHaveBeenCalledWith(mockUrl, expect.not.objectContaining({ headers: expect.any(Object) })); 64 | }); 65 | 66 | it('calls wrapped function with default options', () => { 67 | fetchAdapter({ url: mockUrl, ...mockOptions }); 68 | expect(mockFetch).toHaveBeenCalledWith(mockUrl, expect.objectContaining(mockDefaultOptions)); 69 | }); 70 | 71 | it('overrides default options with request options', () => { 72 | fetchAdapter({ url: mockUrl, ...mockOptions, bogusDefault: 'New Bogus' }); 73 | expect(mockFetch).not.toHaveBeenCalledWith(mockUrl, expect.objectContaining(mockDefaultOptions)); 74 | expect(mockFetch.mock.calls[0][1]).toHaveProperty('bogusDefault', 'New Bogus'); 75 | }); 76 | 77 | it('appends option headers to defaults', () => { 78 | fetchAdapter({ url: mockUrl, ...mockOptions, headers: mockFooHeaders }); 79 | expect(mockFetch).toHaveBeenCalledWith(mockUrl, 80 | expect.objectContaining({ headers: { ...defaultHeaders, ...mockFooHeaders } })); 81 | }); 82 | 83 | it('defaults credentials to same-origin', () => { 84 | fetchAdapter({ url: mockUrl }); 85 | expect(mockFetch).toHaveBeenCalledWith(mockUrl, 86 | expect.objectContaining({ credentials: 'same-origin' })); 87 | }); 88 | 89 | it('sets credentials to include if withCredentials=true', () => { 90 | fetchAdapter({ url: mockUrl, withCredentials: true, credentials: 'bogus' }); 91 | expect(mockFetch).toHaveBeenCalledWith(mockUrl, 92 | expect.objectContaining({ credentials: 'include' })); 93 | }); 94 | 95 | it('sets credentials to options if withCredentials=false', () => { 96 | fetchAdapter({ url: mockUrl, withCredentials: false, credentials: 'bogus' }); 97 | expect(mockFetch).toHaveBeenCalledWith(mockUrl, 98 | expect.objectContaining({ credentials: 'bogus' })); 99 | }); 100 | }); 101 | 102 | describe('Decode Handler', () => { 103 | let mockResponse; 104 | 105 | beforeEach(() => { 106 | mockResponse = { 107 | ok: true, 108 | headers: new Map(), 109 | json: jest.fn(), 110 | text: jest.fn(), 111 | clone: jest.fn() 112 | }; 113 | mockResponse.json.mockResolvedValue(mockJsonData); 114 | mockResponse.text.mockResolvedValue(mockTextData); 115 | mockResponse.clone.mockImplementation(() => mockResponse); 116 | }); 117 | 118 | it('decodes and resolves JSON content', async () => { 119 | mockResponse.headers.set('content-type', 'application/json'); 120 | const data = await handlers.decode(mockResponse); 121 | expect(data).toBe(mockJsonData); 122 | }); 123 | 124 | it('decodes and resolves TEXT content', async () => { 125 | mockResponse.headers.set('content-type', 'text/html'); 126 | const data = await handlers.decode(mockResponse); 127 | expect(data).toBe(mockTextData); 128 | }); 129 | 130 | it('decodes and resolves PLAIN TEXT content', async () => { 131 | mockResponse.headers.set('content-type', 'text/plain'); 132 | const data = await handlers.decode(mockResponse); 133 | expect(data).toBe(mockTextData); 134 | }); 135 | 136 | it('decodes and resolves no content on a 204 response', async () => { 137 | mockResponse.status = 204; 138 | const data = await handlers.decode(mockResponse); 139 | expect(data).toBe(null); 140 | }); 141 | 142 | it('rejects for unhandled content types', async () => { 143 | mockResponse.text = jest.fn().mockImplementation(() => { 144 | throw new Error('`text` not implemented'); 145 | }); 146 | mockResponse.headers.set('content-type', 'some/type'); 147 | const result = handlers.decode(mockResponse); 148 | await expect(result).rejects.toEqual(expect.objectContaining( 149 | { message: 'Content-type some/type not supported' } 150 | )); 151 | }); 152 | 153 | it('decodes and resolves no content on a 202 response', async () => { 154 | mockResponse.headers.set('content-type', 'application/json'); 155 | mockResponse.status = 202; 156 | mockResponse.json.mockResolvedValue(Promise.reject(new Error())); 157 | 158 | const data = await handlers.decode(mockResponse); 159 | expect(data).toBe(null); 160 | }); 161 | 162 | it('decodes and resolves JSON content on a 202 response', async () => { 163 | mockResponse.headers.set('content-type', 'application/json'); 164 | mockResponse.status = 202; 165 | const data = await handlers.decode(mockResponse); 166 | expect(data).toBe(mockJsonData); 167 | }); 168 | 169 | it('decodes and resolves JSON throws invalid error', async () => { 170 | mockResponse.headers.set('content-type', 'application/json'); 171 | mockResponse.json.mockResolvedValue(Promise.reject(new Error('bad json format'))); 172 | mockResponse.text.mockResolvedValue(Promise.resolve('NOT JSON')); 173 | 174 | const result = handlers.decode(mockResponse); 175 | await expect(result).rejects.toEqual(expect.objectContaining( 176 | { message: 'bad json format' } 177 | )); 178 | }); 179 | }); 180 | 181 | describe('Finish Handler', () => { 182 | 183 | it('resolves if response ok', async () => { 184 | const result = handlers.finish({ ok: true }, mockTextData); 185 | await expect(result).resolves.toBe(mockTextData); 186 | }); 187 | 188 | it('rejects if response not ok', async () => { 189 | const result = handlers.finish({ ok: false }, mockTextData); 190 | await expect(result).rejects.toBe(mockTextData); 191 | }); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /tests/fixtures/mockApi.js: -------------------------------------------------------------------------------- 1 | 2 | export const mockApiName = 'mockApi'; 3 | 4 | export const mockApiDesc = { 5 | getFruit: { 6 | url: '/proto/fruits/:id', 7 | repeatRequestDelay: 1000 8 | }, 9 | getFruits: { 10 | url: '/proto/fruits', 11 | resourceData: [] 12 | }, 13 | updateFruit: { 14 | method: 'PUT', 15 | url: '/proto/fruits/:id', 16 | resourceAlias: 'getFruit' 17 | }, 18 | patchFruit: { 19 | method: 'PATCH', 20 | url: '/proto/fruits/:id', 21 | resourceAlias: 'getFruit' 22 | }, 23 | addFruit: { 24 | method: 'POST', 25 | url: '/proto/fruits' 26 | }, 27 | deleteFruit: { 28 | method: 'DELETE', 29 | url: '/proto/fruits/:id' 30 | }, 31 | otherFruit: { 32 | url: '/proto/fruits/:id', 33 | resourceAlias: 'someOtherFruit' 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /tests/promiseKeeper.spec.js: -------------------------------------------------------------------------------- 1 | import PromiseKeeper from '../src/promiseKeeper'; 2 | 3 | const mockKey = 'mockKeyName'; 4 | 5 | describe('PromiseKeeper', () => { 6 | let promise, doResolve, doReject, _promiseKeeper; 7 | 8 | beforeEach(() => { 9 | _promiseKeeper = new PromiseKeeper(); 10 | 11 | promise = new Promise((resolve, reject) => { 12 | doResolve = () => resolve(); 13 | doReject = () => reject(new Error('bogus')); 14 | }); 15 | }); 16 | 17 | it('creates new instance of class', () => { 18 | const instance = new PromiseKeeper(); 19 | expect(instance).toBeInstanceOf(PromiseKeeper); 20 | }); 21 | 22 | it('sets new promises', () => { 23 | expect(_promiseKeeper.size).toBe(0); 24 | _promiseKeeper.set(mockKey, promise); 25 | expect(_promiseKeeper.size).toBe(1); 26 | }); 27 | 28 | it('gets existing promises', () => { 29 | _promiseKeeper.set(mockKey, promise); 30 | const expected = _promiseKeeper.get(mockKey); 31 | expect(expected).toBe(promise); 32 | }); 33 | 34 | it('gets undefined for missing promises', () => { 35 | const expected = _promiseKeeper.get(mockKey); 36 | expect(expected).toBeUndefined(); 37 | }); 38 | 39 | it('returns false for missing promises', () => { 40 | const expected = _promiseKeeper.has(mockKey); 41 | expect(expected).toBe(false); 42 | }); 43 | 44 | it('returns true for stored promises', () => { 45 | _promiseKeeper.set(mockKey, promise); 46 | const expected = _promiseKeeper.has(mockKey); 47 | expect(expected).toBe(true); 48 | }); 49 | 50 | it('deletes keys', () => { 51 | _promiseKeeper.set(mockKey, promise); 52 | let expected = _promiseKeeper.has(mockKey); 53 | expect(expected).toBe(true); 54 | _promiseKeeper.delete(mockKey); 55 | expected = _promiseKeeper.has(mockKey); 56 | expect(expected).toBe(false); 57 | }); 58 | 59 | it('clears keys', () => { 60 | _promiseKeeper.set(mockKey, promise); 61 | let expected = _promiseKeeper.has(mockKey); 62 | expect(expected).toBe(true); 63 | _promiseKeeper.clear(); 64 | expected = _promiseKeeper.has(mockKey); 65 | expect(expected).toBe(false); 66 | }); 67 | 68 | it('resolved promises are removed from store', async () => { 69 | _promiseKeeper.set(mockKey, promise); 70 | expect(_promiseKeeper.has(mockKey)).toBe(true); 71 | await doResolve(); 72 | expect(_promiseKeeper.has(mockKey)).toBe(false); 73 | }); 74 | 75 | it('rejected promises are removed from store', async () => { 76 | _promiseKeeper.set(mockKey, promise); 77 | expect(_promiseKeeper.has(mockKey)).toBe(true); 78 | await doReject(); 79 | expect(_promiseKeeper.has(mockKey)).toBe(false); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/react-addons.spec.js: -------------------------------------------------------------------------------- 1 | import { resourceShape, extendResourceShape } from '../src/addons/react-addons'; 2 | 3 | const PropTypes = require('prop-types'); 4 | 5 | const mockResource = { 6 | isLoaded: true, 7 | hasError: false, 8 | isUpdating: false 9 | }; 10 | 11 | function checkPropTypes(propTypes, resource) { 12 | PropTypes.checkPropTypes( 13 | { bogus: propTypes }, 14 | { bogus: resource }, 15 | 'prop', 16 | Math.random().toString()); // https://github.com/facebook/react/issues/7047#issuecomment-228614964 17 | } 18 | 19 | describe('React Addons', () => { 20 | let consoleErrorSpy; 21 | 22 | beforeAll(() => { 23 | consoleErrorSpy = jest.spyOn(global.console, 'error').mockImplementation(); 24 | }); 25 | 26 | afterEach(() => { 27 | consoleErrorSpy.mockReset(); 28 | }); 29 | 30 | afterAll(() => { 31 | consoleErrorSpy.mockRestore(); 32 | }); 33 | 34 | describe('resourceShape', () => { 35 | 36 | it('is a shape', () => { 37 | expect(resourceShape).toBeInstanceOf(Function); 38 | expect(resourceShape.name).toEqual(PropTypes.shape().name); 39 | }); 40 | 41 | it('passes expected props', () => { 42 | checkPropTypes(resourceShape, { ...mockResource, isUpdating: true }); 43 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 44 | }); 45 | 46 | it('errors failed props', () => { 47 | checkPropTypes(resourceShape, { ...mockResource, isUpdating: 'wrong' }); 48 | expect(consoleErrorSpy).toHaveBeenCalledTimes(1); 49 | expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed prop type')); 50 | expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('expected `boolean`')); 51 | }); 52 | }); 53 | 54 | describe('extendResourceShape', () => { 55 | 56 | it('returns a shape', () => { 57 | const result = extendResourceShape({ value: PropTypes.string }); 58 | expect(result).toBeInstanceOf(Function); 59 | expect(result.name).toEqual(resourceShape.name); 60 | expect(result.name).toEqual(PropTypes.shape().name); 61 | }); 62 | 63 | it('passes valid default props', () => { 64 | const customShape = extendResourceShape(); 65 | checkPropTypes(customShape, { ...mockResource, value: 'ABCD' }); 66 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 67 | }); 68 | 69 | it('fails invalid default props', () => { 70 | const customShape = extendResourceShape(); 71 | checkPropTypes(customShape, { ...mockResource, value: f => f }); 72 | expect(consoleErrorSpy).toHaveBeenCalledTimes(1); 73 | expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid prop `bogus.value`')); 74 | }); 75 | 76 | it('passes valid overridden props', () => { 77 | const customShape = extendResourceShape({ value: PropTypes.string }); 78 | checkPropTypes(customShape, { ...mockResource, value: 'ABCD' }); 79 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 80 | }); 81 | 82 | it('fails invalid overridden props', () => { 83 | const customShape = extendResourceShape({ value: PropTypes.number }); 84 | checkPropTypes(customShape, { ...mockResource, value: 'ABCD' }); 85 | expect(consoleErrorSpy).toHaveBeenCalledTimes(1); 86 | expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid prop `bogus.value`')); 87 | expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('expected `number`')); 88 | }); 89 | 90 | it('only allows overriding `value` or `error` propTypes', () => { 91 | const customShape = extendResourceShape({ value: PropTypes.number, error: PropTypes.number, isUpdating: PropTypes.string }); 92 | 93 | checkPropTypes(customShape, { ...mockResource, value: 'ABCD' }); 94 | expect(consoleErrorSpy).toHaveBeenCalledTimes(1); 95 | expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid prop `bogus.value`')); 96 | 97 | checkPropTypes(customShape, { ...mockResource, error: 'ABCD' }); 98 | expect(consoleErrorSpy).toHaveBeenCalledTimes(2); 99 | expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid prop `bogus.error`')); 100 | 101 | checkPropTypes(customShape, { ...mockResource, isUpdating: 'ABCD' }); 102 | expect(consoleErrorSpy).toHaveBeenCalledTimes(3); 103 | expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid prop `bogus.isUpdating`')); 104 | 105 | checkPropTypes(customShape, { ...mockResource, isUpdating: true }); 106 | expect(consoleErrorSpy).toHaveBeenCalledTimes(3); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /tests/reducers.spec.js: -------------------------------------------------------------------------------- 1 | import createReducer, { handlers } from '../src/reducers'; 2 | import { mockApiName } from './fixtures/mockApi'; 3 | 4 | const mockStartActionType = 'mockApi_getItem_START'; 5 | const mockSuccessActionType = 'mockApi_getItem_SUCCESS'; 6 | const mockFailActionType = 'mockApi_getItem_FAIL'; 7 | const mockResetActionType = 'mockApi_getItem_RESET'; 8 | const mockKey = 'getSomething__var:one'; 9 | const mockValue = { someData: 'bogus data string' }; 10 | const mockError = { code: 500, message: 'Something terrible happened' }; 11 | const mockStartAction = { 12 | meta: { 13 | key: mockKey 14 | }, 15 | payload: {} 16 | }; 17 | const mockSuccessAction = { 18 | meta: { 19 | key: mockKey 20 | }, 21 | payload: mockValue 22 | }; 23 | const mockErrorAction = { 24 | meta: { 25 | key: mockKey 26 | }, 27 | payload: mockError, 28 | error: true 29 | }; 30 | const mockResetAction = { 31 | meta: { 32 | key: mockKey 33 | }, 34 | payload: {} 35 | }; 36 | const mockStartResource = { 37 | value: {}, 38 | isLoaded: false, 39 | isUpdating: true 40 | }; 41 | const mockSuccessResource = { 42 | value: mockValue, 43 | error: null, 44 | isLoaded: true, 45 | isUpdating: false 46 | }; 47 | const mockFailResource = { 48 | value: null, 49 | error: mockError, 50 | isLoaded: true, 51 | isUpdating: false, 52 | hasError: true 53 | }; 54 | 55 | describe('Reducers', () => { 56 | describe('createReducer', () => { 57 | 58 | it('creates a reducer', () => { 59 | const reducer = createReducer(mockApiName); 60 | expect(reducer).toBeInstanceOf(Function); 61 | }); 62 | }); 63 | describe('reducer', () => { 64 | let reducer; 65 | beforeEach(() => { 66 | reducer = createReducer(mockApiName); 67 | jest.spyOn(handlers, 'onStart'); 68 | jest.spyOn(handlers, 'onComplete'); 69 | jest.spyOn(handlers, 'onReset'); 70 | }); 71 | afterEach(() => { 72 | handlers.onStart.mockReset(); 73 | handlers.onComplete.mockReset(); 74 | handlers.onReset.mockReset(); 75 | }); 76 | afterAll(() => { 77 | jest.restoreAllMocks(); 78 | }); 79 | 80 | it('defaults state to empty object if undefined', () => { 81 | // eslint-disable-next-line no-undefined 82 | const outState = reducer(undefined, { type: 'BAD_ACTION' }); 83 | expect(outState).toBeInstanceOf(Object); 84 | expect(outState).toEqual({}); 85 | }); 86 | 87 | it('returns original state if unsupported action prefix', () => { 88 | const inState = { bogus: 'bogus' }; 89 | const outState = reducer(inState, { type: 'BAD_ACTION' }); 90 | expect(outState).toBe(inState); 91 | }); 92 | 93 | it('returns original state if unsupported action suffix', () => { 94 | const inState = { bogus: 'bogus' }; 95 | const outState = reducer(inState, { type: 'mockApi_BOGUS' }); 96 | expect(outState).toBe(inState); 97 | }); 98 | 99 | it('calls start handler for START actions', () => { 100 | const inState = { bogus: 'bogus' }; 101 | const outState = reducer(inState, { type: mockStartActionType }); 102 | expect(outState).not.toBe(inState); 103 | expect(handlers.onStart).toHaveBeenCalled(); 104 | expect(handlers.onComplete).not.toHaveBeenCalled(); 105 | }); 106 | 107 | it('calls complete handler for SUCCESS actions', () => { 108 | const inState = { bogus: 'bogus' }; 109 | const outState = reducer(inState, { type: mockSuccessActionType }); 110 | expect(outState).not.toBe(inState); 111 | expect(handlers.onStart).not.toHaveBeenCalled(); 112 | expect(handlers.onComplete).toHaveBeenCalled(); 113 | }); 114 | 115 | it('calls complete handler for FAIL actions', () => { 116 | const inState = { bogus: 'bogus' }; 117 | const outState = reducer(inState, { type: mockFailActionType }); 118 | expect(outState).not.toBe(inState); 119 | expect(handlers.onStart).not.toHaveBeenCalled(); 120 | expect(handlers.onComplete).toHaveBeenCalled(); 121 | }); 122 | 123 | it('calls reset handler for RESET actions', () => { 124 | const inState = { bogus: 'bogus' }; 125 | const outState = reducer(inState, { type: mockResetActionType }); 126 | expect(outState).not.toBe(inState); 127 | expect(handlers.onStart).not.toHaveBeenCalled(); 128 | expect(handlers.onComplete).not.toHaveBeenCalled(); 129 | expect(handlers.onReset).toHaveBeenCalled(); 130 | }); 131 | 132 | it('handles special characters in api names', () => { 133 | const inState = { bogus: 'bogus' }; 134 | reducer = createReducer('a*b+c'); 135 | reducer(inState, { type: 'a*b+c_getItem_START' }); 136 | expect(handlers.onStart).toHaveBeenCalled(); 137 | 138 | reducer = createReducer('api(v2)'); 139 | reducer(inState, { type: 'api(v2)_getItem_SUCCESS' }); 140 | expect(handlers.onComplete).toHaveBeenCalled(); 141 | }); 142 | 143 | it('expects resource name', () => { 144 | const inState = { bogus: 'bogus' }; 145 | reducer(inState, { type: 'mockApi__START' }); 146 | expect(handlers.onStart).not.toHaveBeenCalled(); 147 | }); 148 | 149 | it('handles special characters in resource names', () => { 150 | const inState = { bogus: 'bogus' }; 151 | reducer(inState, { type: 'mockApi_getItems*_START' }); 152 | reducer(inState, { type: 'mockApi_get-items_START' }); 153 | reducer(inState, { type: 'mockApi_@get%items_START' }); 154 | expect(handlers.onStart).toHaveBeenCalledTimes(3); 155 | }); 156 | 157 | it('does not handle for similarly named apis', () => { 158 | const inState = { bogus: 'bogus' }; 159 | reducer(inState, { type: '2mockApi_getItem_START' }); 160 | reducer(inState, { type: 'mockApi-2_getItem_START' }); 161 | reducer(inState, { type: 'mockApi2_getItem_START' }); 162 | reducer(inState, { type: 'MOCKAPI_getItem_START' }); 163 | expect(handlers.onStart).not.toHaveBeenCalled(); 164 | }); 165 | 166 | it('does not handle for unknown actions', () => { 167 | const inState = { bogus: 'bogus' }; 168 | reducer(inState, { type: 'mockApi_getItem_2START' }); 169 | reducer(inState, { type: 'mockApi_getItem_START2' }); 170 | reducer(inState, { type: 'mockApi_getItem_DOSTART' }); 171 | reducer(inState, { type: 'mockApi_getItem_start' }); 172 | expect(handlers.onStart).not.toHaveBeenCalled(); 173 | }); 174 | }); 175 | describe('handleStart', () => { 176 | 177 | it('returns a modified state', () => { 178 | const inState = { bogus: 'bogus' }; 179 | const outState = handlers.onStart(inState, mockStartAction); 180 | expect(outState).not.toBe(inState); 181 | expect(outState).not.toEqual(inState); 182 | }); 183 | 184 | it('add new resource entry if does not exists', () => { 185 | const inState = {}; 186 | const outState = handlers.onStart(inState, mockStartAction); 187 | expect(inState).not.toHaveProperty(mockKey); 188 | expect(outState).toHaveProperty(mockKey); 189 | }); 190 | 191 | it('modifies previous resource entry if exists', () => { 192 | const inState = { [mockKey]: mockSuccessResource }; 193 | const outState = handlers.onStart(inState, mockStartAction); 194 | expect(inState).toHaveProperty(mockKey); 195 | expect(outState).toHaveProperty(mockKey); 196 | }); 197 | 198 | it('sets isUpdating to true', () => { 199 | const inState = { [mockKey]: mockSuccessResource }; 200 | const outState = handlers.onStart(inState, mockStartAction); 201 | expect(inState[mockKey]).toHaveProperty('isUpdating', false); 202 | expect(outState[mockKey]).toHaveProperty('isUpdating', true); 203 | }); 204 | 205 | it('sets isLoaded to false for new resources', () => { 206 | const inState = { }; 207 | const outState = handlers.onStart(inState, mockStartAction); 208 | expect(outState[mockKey]).toHaveProperty('isLoaded', false); 209 | }); 210 | 211 | it('maintains previous isLoaded status for existing resources', () => { 212 | const inState = { [mockKey]: mockSuccessResource }; 213 | const outState = handlers.onStart(inState, mockStartAction); 214 | expect(outState[mockKey]).toHaveProperty('isLoaded', true); 215 | }); 216 | 217 | it('maintains previous hasError status for existing resources', () => { 218 | const inState = { [mockKey]: { ...mockSuccessResource, hasError: true } }; 219 | const outState = handlers.onStart(inState, mockStartAction); 220 | expect(outState[mockKey]).toHaveProperty('hasError', true); 221 | }); 222 | 223 | it('maintains previous value for existing resources', () => { 224 | const inState = { [mockKey]: mockSuccessResource }; 225 | const outState = handlers.onStart(inState, mockStartAction); 226 | expect(outState[mockKey]).toHaveProperty('value', mockValue); 227 | }); 228 | 229 | it('maintains previous error for existing resources', () => { 230 | const inState = { [mockKey]: mockFailResource }; 231 | const outState = handlers.onStart(inState, mockStartAction); 232 | expect(outState[mockKey]).toHaveProperty('error', mockError); 233 | }); 234 | 235 | it('sets requestTime', () => { 236 | const inState = { [mockKey]: mockSuccessResource }; 237 | const outState = handlers.onStart(inState, mockStartAction); 238 | expect(outState[mockKey]).toHaveProperty('requestTime'); 239 | expect(Number.isInteger(outState[mockKey].requestTime)).toBe(true); 240 | }); 241 | 242 | it('updates requestTime', () => { 243 | const inState = { [mockKey]: { ...mockSuccessResource, requestTime: 123456 } }; 244 | const outState = handlers.onStart(inState, mockStartAction); 245 | expect(inState[mockKey]).toHaveProperty('requestTime', 123456); 246 | expect(outState[mockKey]).not.toHaveProperty('requestTime', 123456); 247 | }); 248 | }); 249 | describe('handleComplete', () => { 250 | 251 | it('returns a modified state', () => { 252 | const inState = { bogus: 'bogus' }; 253 | const outState = handlers.onComplete(inState, mockSuccessAction); 254 | expect(outState).not.toBe(inState); 255 | expect(outState).not.toEqual(inState); 256 | }); 257 | 258 | it('sets isUpdating to false', () => { 259 | const inState = { [mockKey]: mockStartResource }; 260 | const outState = handlers.onComplete(inState, mockSuccessAction); 261 | expect(inState[mockKey]).toHaveProperty('isUpdating', true); 262 | expect(outState[mockKey]).toHaveProperty('isUpdating', false); 263 | }); 264 | 265 | it('sets isLoaded true', () => { 266 | const inState = { [mockKey]: mockStartResource }; 267 | const outState = handlers.onComplete(inState, mockSuccessAction); 268 | expect(inState[mockKey]).toHaveProperty('isLoaded', false); 269 | expect(outState[mockKey]).toHaveProperty('isLoaded', true); 270 | }); 271 | 272 | it('sets hasError false by default', () => { 273 | const inState = { [mockKey]: mockStartResource }; 274 | const outState = handlers.onComplete(inState, mockSuccessAction); 275 | expect(outState[mockKey]).toHaveProperty('hasError', false); 276 | }); 277 | 278 | it('sets hasError true if error in action', () => { 279 | const inState = { [mockKey]: mockStartResource }; 280 | const outState = handlers.onComplete(inState, mockErrorAction); 281 | expect(outState[mockKey]).toHaveProperty('hasError', true); 282 | }); 283 | 284 | it('updates value from payload', () => { 285 | const inState = { [mockKey]: mockStartResource }; 286 | const outState = handlers.onComplete(inState, mockSuccessAction); 287 | expect(outState[mockKey]).toHaveProperty('value', mockValue); 288 | }); 289 | 290 | it('updates error to null', () => { 291 | const inState = { [mockKey]: mockFailResource }; 292 | const outState = handlers.onComplete(inState, mockSuccessAction); 293 | expect(outState[mockKey]).toHaveProperty('error', null); 294 | }); 295 | 296 | it('if error, updates value to null', () => { 297 | const inState = { [mockKey]: mockStartResource }; 298 | const outState = handlers.onComplete(inState, mockErrorAction); 299 | expect(outState[mockKey]).toHaveProperty('value', null); 300 | }); 301 | 302 | it('if error, updates error from payload', () => { 303 | const inState = { [mockKey]: mockStartResource }; 304 | const outState = handlers.onComplete(inState, mockErrorAction); 305 | expect(outState[mockKey]).toHaveProperty('error', mockError); 306 | }); 307 | 308 | it('updates responseTime', () => { 309 | const inState = { [mockKey]: { ...mockSuccessResource, responseTime: 123456 } }; 310 | const outState = handlers.onStart(inState, mockStartAction); 311 | expect(inState[mockKey]).toHaveProperty('responseTime', 123456); 312 | expect(outState[mockKey]).not.toHaveProperty('responseTime', 123456); 313 | }); 314 | }); 315 | describe('handleReset', () => { 316 | 317 | it('returns a modified state', () => { 318 | const inState = { bogus: 'bogus' }; 319 | const outState = handlers.onReset(inState, mockResetAction); 320 | expect(outState).not.toBe(inState); 321 | expect(outState).not.toEqual(inState); 322 | }); 323 | 324 | it('sets isUpdating to false', () => { 325 | const inState = { [mockKey]: mockStartResource }; 326 | const outState = handlers.onReset(inState, mockResetAction); 327 | expect(inState[mockKey]).toHaveProperty('isUpdating', true); 328 | expect(outState[mockKey]).toHaveProperty('isUpdating', false); 329 | }); 330 | 331 | it('sets isLoaded to false', () => { 332 | const inState = { [mockKey]: mockSuccessResource }; 333 | const outState = handlers.onReset(inState, mockResetAction); 334 | expect(inState[mockKey]).toHaveProperty('isLoaded', true); 335 | expect(outState[mockKey]).toHaveProperty('isLoaded', false); 336 | }); 337 | 338 | it('sets hasLoaded to false', () => { 339 | const inState = { [mockKey]: mockFailResource }; 340 | const outState = handlers.onReset(inState, mockResetAction); 341 | expect(inState[mockKey]).toHaveProperty('hasError', true); 342 | expect(outState[mockKey]).toHaveProperty('hasError', false); 343 | }); 344 | 345 | it('sets error to null', () => { 346 | const inState = { [mockKey]: mockFailResource }; 347 | const outState = handlers.onReset(inState, mockResetAction); 348 | expect(inState[mockKey]).toHaveProperty('error', mockError); 349 | expect(outState[mockKey]).toHaveProperty('error', null); 350 | }); 351 | 352 | it('sets value to payload', () => { 353 | const inState = { [mockKey]: mockSuccessResource }; 354 | expect(inState[mockKey]).toHaveProperty('value', mockValue); 355 | 356 | let outState = handlers.onReset(inState, mockResetAction); 357 | expect(outState[mockKey]).toHaveProperty('value', {}); 358 | outState = handlers.onReset(inState, { ...mockResetAction, payload: 'BOGUS' }); 359 | expect(outState[mockKey]).toHaveProperty('value', 'BOGUS'); 360 | }); 361 | }); 362 | }); 363 | -------------------------------------------------------------------------------- /tests/reduxful.spec.js: -------------------------------------------------------------------------------- 1 | import Reduxful, { setupApi } from '../src/reduxful'; 2 | import { mockApiName, mockApiDesc } from './fixtures/mockApi'; 3 | 4 | 5 | function tests(setup) { 6 | let testApi; 7 | 8 | beforeEach(() => { 9 | testApi = setup(); 10 | }); 11 | 12 | it('creates instance', () => { 13 | expect(testApi).toBeInstanceOf(Reduxful); 14 | }); 15 | 16 | it('creates named actionCreators', () => { 17 | expect(testApi.actionCreators).toHaveProperty('updateFruit'); 18 | expect(Object.keys(testApi.actionCreators).length).toBeGreaterThanOrEqual(6); 19 | }); 20 | 21 | it('exposes actions alias', () => { 22 | expect(testApi.actions).toHaveProperty('updateFruit'); 23 | expect(Object.keys(testApi.actions).length).toBeGreaterThanOrEqual(6); 24 | }); 25 | 26 | it('creates named selectors', () => { 27 | expect(testApi.selectors).toHaveProperty('updateFruit'); 28 | expect(Object.keys(testApi.selectors).length).toBeGreaterThanOrEqual(6); 29 | }); 30 | 31 | it('creates a reducer', () => { 32 | expect(testApi.reducers).toHaveProperty(mockApiName, expect.any(Function)); 33 | }); 34 | 35 | it('exposes reducers', () => { 36 | expect(testApi.reducers).toBeInstanceOf(Object); 37 | expect(testApi.reducers).toHaveProperty(mockApiName); 38 | }); 39 | 40 | it('exposes reducerMap alias', () => { 41 | expect(testApi.reducerMap).toBeInstanceOf(Object); 42 | expect(testApi.reducerMap).toHaveProperty(mockApiName); 43 | }); 44 | } 45 | 46 | describe('Reduxful', () => { 47 | tests(() => new Reduxful(mockApiName, mockApiDesc)); 48 | }); 49 | 50 | describe('setupApi', () => { 51 | tests(() => setupApi(mockApiName, mockApiDesc)); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/requestAdapter.spec.js: -------------------------------------------------------------------------------- 1 | import { setRequestAdapter, getRequestAdapter, makeRequest } from '../src/requestAdapter'; 2 | import { RequestAdapterError } from '../src/errors'; 3 | 4 | const mockUrl = 'some/api/'; 5 | 6 | describe('Request Adapter', () => { 7 | 8 | let mockRequestAdapter, mockGlobalPromise; 9 | 10 | beforeAll(() => { 11 | mockGlobalPromise = new Promise(() => {}, () => {}); 12 | }); 13 | 14 | beforeEach(() => { 15 | mockRequestAdapter = jest.fn().mockReturnValue(mockGlobalPromise); 16 | }); 17 | 18 | afterEach(() => { 19 | delete global.Reduxful.requestAdapter; 20 | mockRequestAdapter.mockReset(); 21 | }); 22 | 23 | describe('#setRequestAdapter', () => { 24 | 25 | it('sets the global request adapter', () => { 26 | expect(global.Reduxful.requestAdapter).toBeUndefined(); 27 | setRequestAdapter(mockRequestAdapter); 28 | expect(global.Reduxful.requestAdapter).toBe(mockRequestAdapter); 29 | }); 30 | 31 | it('throws exception if request adapter has already been set', () => { 32 | setRequestAdapter(mockRequestAdapter); 33 | expect(() => setRequestAdapter(mockRequestAdapter)).toThrow(RequestAdapterError); 34 | }); 35 | }); 36 | 37 | describe('#getRequestAdapter', () => { 38 | 39 | it('returns the request adapter', () => { 40 | expect(global.Reduxful.requestAdapter).toBeUndefined(); 41 | setRequestAdapter(mockRequestAdapter); 42 | const result = getRequestAdapter(); 43 | expect(result).toBe(mockRequestAdapter); 44 | }); 45 | 46 | it('throws exception if request adapter has not been set', () => { 47 | expect(() => getRequestAdapter()).toThrow(RequestAdapterError); 48 | }); 49 | }); 50 | 51 | describe('#makeRequest', () => { 52 | 53 | it('calls requestAdapter', () => { 54 | setRequestAdapter(mockRequestAdapter); 55 | makeRequest('get', mockUrl, {}); 56 | expect(mockRequestAdapter).toHaveBeenCalledTimes(1); 57 | }); 58 | 59 | it('returns requestAdapter results', () => { 60 | setRequestAdapter(mockRequestAdapter); 61 | const result = makeRequest('get', mockUrl, {}); 62 | expect(result).toBeInstanceOf(Promise); 63 | }); 64 | 65 | it('uses global request adapter', () => { 66 | setRequestAdapter(mockRequestAdapter); 67 | const result = makeRequest('get', mockUrl, {}); 68 | expect(result).toBeInstanceOf(Promise); 69 | expect(result).toBe(mockGlobalPromise); 70 | expect(mockRequestAdapter).toHaveBeenCalledTimes(1); 71 | }); 72 | 73 | it('uses api overridden request adapter', () => { 74 | setRequestAdapter(mockRequestAdapter); 75 | const mockApiPromise = new Promise(() => {}, () => {}); 76 | const mockApiRequestAdapter = jest.fn().mockReturnValue(mockApiPromise); 77 | const result = makeRequest('get', mockUrl, {}, { requestAdapter: mockApiRequestAdapter }); 78 | expect(result).toBeInstanceOf(Promise); 79 | expect(result).toBe(mockApiPromise); 80 | expect(mockApiRequestAdapter).toHaveBeenCalledTimes(1); 81 | }); 82 | 83 | it('normalizes url and method for adapter options', () => { 84 | setRequestAdapter(mockRequestAdapter); 85 | makeRequest('get', mockUrl, {}); 86 | expect(mockRequestAdapter).toBeCalledWith(expect.objectContaining({ method: 'get' })); 87 | expect(mockRequestAdapter).toBeCalledWith(expect.objectContaining({ url: mockUrl })); 88 | }); 89 | 90 | it('defaults options to empty object', () => { 91 | setRequestAdapter(mockRequestAdapter); 92 | makeRequest('get', mockUrl); 93 | expect(mockRequestAdapter).toBeCalled(); 94 | }); 95 | 96 | it('passes headers if part of options', () => { 97 | const headers = { BOGUS: 'true' }; 98 | setRequestAdapter(mockRequestAdapter); 99 | makeRequest('get', mockUrl, { headers }); 100 | expect(mockRequestAdapter).toBeCalledWith(expect.objectContaining({ headers })); 101 | }); 102 | 103 | it('passes body if part of options', () => { 104 | const body = 'BOGUS BODY'; 105 | setRequestAdapter(mockRequestAdapter); 106 | makeRequest('post', mockUrl, { body }); 107 | expect(mockRequestAdapter).toBeCalledWith(expect.objectContaining({ body })); 108 | }); 109 | 110 | it('passes withCredentials if part of options', () => { 111 | setRequestAdapter(mockRequestAdapter); 112 | makeRequest('get', mockUrl, { withCredentials: true }); 113 | expect(mockRequestAdapter).toBeCalledWith(expect.objectContaining({ withCredentials: true })); 114 | }); 115 | 116 | it('passes any custom options', () => { 117 | setRequestAdapter(mockRequestAdapter); 118 | makeRequest('get', mockUrl, { bogus: true }); 119 | expect(mockRequestAdapter).toBeCalledWith(expect.objectContaining({ bogus: true })); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /tests/selectors.spec.js: -------------------------------------------------------------------------------- 1 | import createSelectors from '../src/selectors'; 2 | import { mockApiName, mockApiDesc } from './fixtures/mockApi'; 3 | 4 | const mockState = { 5 | mockApi: { 6 | getFruit__1234: { 7 | value: 'BOGUS' 8 | } 9 | } 10 | }; 11 | 12 | describe('Selectors', () => { 13 | describe('createSelectors', () => { 14 | 15 | it('creates a map of functions', () => { 16 | const selectors = createSelectors(mockApiName, mockApiDesc); 17 | expect(selectors).toBeInstanceOf(Object); 18 | expect(selectors).toHaveProperty('getFruit'); 19 | expect(selectors.getFruit).toBeInstanceOf(Function); 20 | }); 21 | 22 | it('adds selector for unique resource alias', () => { 23 | const selectors = createSelectors(mockApiName, mockApiDesc); 24 | expect(selectors).toHaveProperty('otherFruit'); 25 | expect(selectors).toHaveProperty('someOtherFruit'); 26 | }); 27 | }); 28 | 29 | describe('selector', () => { 30 | let selectors, result; 31 | beforeAll(() => { 32 | selectors = createSelectors(mockApiName, mockApiDesc); 33 | }); 34 | 35 | it('returns undefined if no api in state', () => { 36 | result = selectors.getFruit({}, { id: 1234 }); 37 | expect(result).toBeUndefined(); 38 | }); 39 | 40 | it('returns undefined if no resource in api in state', () => { 41 | result = selectors.getFruit({ mockApi: {} }, { id: 1234 }); 42 | expect(result).toBeUndefined(); 43 | }); 44 | 45 | it('returns resource for matching resource in state', () => { 46 | result = selectors.getFruit(mockState, { id: 1234 }); 47 | expect(result).toBe(mockState.mockApi['getFruit__id:1234']); 48 | }); 49 | 50 | it('returns undefined for mis-matched resource in state', () => { 51 | result = selectors.getFruit(mockState, { id: 5678 }); 52 | expect(result).toBeUndefined(); 53 | }); 54 | 55 | it('returns proper aliased resource', () => { 56 | result = selectors.updateFruit(mockState, { id: 1234 }); 57 | expect(result).toBe(mockState.mockApi['getFruit__id:1234']); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/utils.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | getResourceKey, 3 | isLoaded, 4 | isUpdating, 5 | hasError, 6 | isFunction, 7 | getUrlTemplate, 8 | parseReqDesc, 9 | parseApiDesc, 10 | escapeRegExp 11 | } from '../src/utils'; 12 | import * as utils from '../src/utils'; 13 | 14 | const params = { 15 | id: 'user123', 16 | count: 777 17 | }; 18 | 19 | describe('Utils', () => { 20 | 21 | describe('getResourceKey', () => { 22 | 23 | it('builds and returns the resource key based on all parameters', () => { 24 | const expectedResourceKey = 'getItem__count:777__id:user123'; 25 | const resourceKey = getResourceKey('getItem', params); 26 | expect(resourceKey).toEqual(expectedResourceKey); 27 | }); 28 | 29 | it('builds and returns the resource key based on all parameters using alias', () => { 30 | const expectedResourceKey = 'testResourceName__count:777__id:user123'; 31 | const resourceKey = getResourceKey('testResourceName', params); 32 | expect(resourceKey).toEqual(expectedResourceKey); 33 | }); 34 | 35 | it('builds and returns the resource key with empty parameter', () => { 36 | const expectedResourceKey = 'getItems'; 37 | const resourceKey = getResourceKey('getItems'); 38 | expect(resourceKey).toEqual(expectedResourceKey); 39 | }); 40 | }); 41 | 42 | describe('isLoaded', () => { 43 | 44 | it('false if resource not set', () => { 45 | expect(isLoaded(null)).toBeFalsy(); 46 | expect(isLoaded()).toBeFalsy(); 47 | }); 48 | 49 | it('false if resource set but not loaded', () => { 50 | expect(isLoaded({ isLoaded: false })).toBeFalsy(); 51 | }); 52 | 53 | it('true if resource loaded', () => { 54 | expect(isLoaded({ isLoaded: true })).toBeTruthy(); 55 | }); 56 | }); 57 | 58 | describe('isUpdating', () => { 59 | 60 | it('false if resource not set', () => { 61 | expect(isUpdating(null)).toBeFalsy(); 62 | expect(isUpdating()).toBeFalsy(); 63 | }); 64 | 65 | it('false if resource set but not loaded', () => { 66 | expect(isUpdating({ isUpdating: false })).toBeFalsy(); 67 | }); 68 | 69 | it('true if resource loaded', () => { 70 | expect(isUpdating({ isUpdating: true })).toBeTruthy(); 71 | }); 72 | }); 73 | 74 | describe('hasError', () => { 75 | 76 | it('false if resource is not set', () => { 77 | expect(hasError(null)).toBeFalsy(); 78 | expect(hasError()).toBeFalsy(); 79 | }); 80 | 81 | it('false if resource is set but not loaded', () => { 82 | expect(hasError({ hasError: false })).toBeFalsy(); 83 | }); 84 | 85 | it('true if resource has error', () => { 86 | expect(hasError({ hasError: true })).toBeTruthy(); 87 | }); 88 | }); 89 | 90 | describe('isFunction', () => { 91 | 92 | it('false if not a function', () => { 93 | expect(isFunction('a string')).toBeFalsy(); 94 | expect(isFunction({})).toBeFalsy(); 95 | expect(isFunction()).toBeFalsy(); 96 | }); 97 | 98 | it('true if function', () => { 99 | expect(isFunction(() => {})).toBeTruthy(); 100 | expect(isFunction(function () {})).toBeTruthy(); 101 | }); 102 | }); 103 | 104 | describe('getUrlTemplate', () => { 105 | 106 | it('returns same url if string', () => { 107 | expect(getUrlTemplate('a string')).toEqual('a string'); 108 | }); 109 | 110 | it('returns url string if a function', () => { 111 | const fn = () => ('generated string'); 112 | expect(getUrlTemplate(fn)).toEqual('generated string'); 113 | }); 114 | 115 | it('passes getState to url function', () => { 116 | const fn = (getState) => (getState()); 117 | const mockGetState = jest.fn(); 118 | getUrlTemplate(fn, mockGetState); 119 | expect(mockGetState).toHaveBeenCalled(); 120 | }); 121 | }); 122 | 123 | describe('parseReqDesc', () => { 124 | let results, consoleSpy; 125 | 126 | beforeEach(() => { 127 | consoleSpy = jest.spyOn(global.console, 'warn').mockImplementation(); 128 | }); 129 | 130 | it('returns object', () => { 131 | results = parseReqDesc({}); 132 | expect(results).toBeInstanceOf(Object); 133 | }); 134 | 135 | it('normalizes method name uppercase', () => { 136 | results = parseReqDesc({ 137 | method: 'Post' 138 | }); 139 | 140 | expect(results).toHaveProperty('method', 'POST'); 141 | }); 142 | 143 | it('normalizes method as GET if missing', () => { 144 | results = parseReqDesc({}); 145 | 146 | expect(results).toHaveProperty('method', 'GET'); 147 | }); 148 | 149 | it('issues deprecation warning if withCredentials sets', () => { 150 | results = parseReqDesc({}); 151 | expect(consoleSpy).toHaveBeenCalledTimes(0); 152 | 153 | results = parseReqDesc({ 154 | withCredentials: true 155 | }); 156 | expect(consoleSpy).toHaveBeenCalledTimes(1); 157 | expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('deprecated')); 158 | }); 159 | 160 | it('moves top level withCredentials to options if an object', () => { 161 | results = parseReqDesc({ 162 | withCredentials: true, 163 | options: { 164 | bogus: true 165 | } 166 | }); 167 | expect(results).toHaveProperty('options', { 168 | withCredentials: true, 169 | bogus: true 170 | }); 171 | }); 172 | 173 | it('moves top level withCredentials to options if not set', () => { 174 | results = parseReqDesc({ 175 | withCredentials: true 176 | }); 177 | expect(results).toHaveProperty('options', { 178 | withCredentials: true 179 | }); 180 | }); 181 | 182 | it('does not moves top level withCredentials to options if a function', () => { 183 | results = parseReqDesc({ 184 | withCredentials: true, 185 | options: f => f 186 | }); 187 | expect(results.options).toEqual(expect.any(Function)); 188 | expect(results.options).not.toHaveProperty('withCredentials'); 189 | }); 190 | }); 191 | 192 | describe('parseApiDesc', () => { 193 | let results; 194 | 195 | it('passes entries through parseReqDesc', () => { 196 | jest.spyOn(utils, 'parseReqDesc'); 197 | results = parseApiDesc({ 198 | test1: {}, 199 | test2: { 200 | method: 'Post' 201 | }, 202 | test3: { 203 | method: 'patch' 204 | } 205 | }); 206 | 207 | expect(results).toHaveProperty('test1', expect.objectContaining({ 208 | method: 'GET' 209 | })); 210 | 211 | expect(results).toHaveProperty('test2', expect.objectContaining({ 212 | method: 'POST' 213 | })); 214 | 215 | expect(results).toHaveProperty('test3', expect.objectContaining({ 216 | method: 'PATCH' 217 | })); 218 | }); 219 | }); 220 | 221 | describe('escapeRegExp', () => { 222 | 223 | it('escaped special characters', () => { 224 | expect(escapeRegExp('a*b+c')).toEqual('a\\*b\\+c'); 225 | }); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /typings/fetchUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { makeFetchAdapter } from 'reduxful'; 2 | 3 | makeFetchAdapter(fetch, {}); 4 | 5 | export default makeFetchAdapter(fetch); 6 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'reduxful' { 2 | // fetchUtils 3 | export interface RequestAdapterOptions { 4 | method: string; 5 | url: string; 6 | headers: { [key: string]: string }; 7 | withCredentials: boolean; 8 | body: any; 9 | } 10 | 11 | export type RequestAdapter = (options: RequestAdapterOptions) => Promise; 12 | 13 | export function makeFetchAdapter( 14 | fetcher: typeof fetch, 15 | defaultOptions?: Object 16 | ): RequestAdapter; 17 | 18 | // utils 19 | export interface Resource { 20 | isLoaded?: boolean; 21 | isUpdating?: boolean; 22 | hasError?: boolean; 23 | value?: ValueType; 24 | } 25 | 26 | export function isLoaded(resource?: Resource): boolean; 27 | 28 | export function isUpdating(resource?: Resource): boolean; 29 | 30 | export function hasError(resource?: Resource): boolean; 31 | 32 | export function getResourceKey( 33 | reqName: string, 34 | params?: { [key: string]: string | number } 35 | ): string; 36 | 37 | // reduxful 38 | export type TransformFn = ( 39 | data: any, 40 | context?: { params?: Object; options?: Object } 41 | ) => any; 42 | 43 | export type UrlTemplateFn = (getState: () => any) => string; 44 | 45 | export type OptionsFn = (getState: () => any) => Options; 46 | 47 | export interface RequestDescription { 48 | url: string | UrlTemplateFn; 49 | method?: string; 50 | resourceAlias?: string; 51 | resourceData?: any; 52 | dataTransform?: TransformFn; 53 | errorTransform?: TransformFn; 54 | repeatRequestDelay?: number; 55 | options?: Options | OptionsFn; 56 | } 57 | 58 | export interface ApiDescription { 59 | [key: string]: RequestDescription; 60 | } 61 | 62 | export interface ApiConfig { 63 | requestAdapter?: RequestAdapter; 64 | options?: Options | OptionsFn; 65 | } 66 | 67 | export interface Action { 68 | type: string; 69 | payload: string; 70 | meta: { key: string }; 71 | error?: boolean; 72 | } 73 | 74 | export type ActionCreatorThunkFn = ( 75 | dispatch: (action: ActionCreatorThunkFn) => any, 76 | getState: () => State 77 | ) => Promise; 78 | 79 | export type ActionCreatorFn = ( 80 | params: { [paramName: string]: any }, 81 | options?: Options | OptionsFn 82 | ) => ActionCreatorThunkFn; 83 | 84 | export type ReducerFn = (state: S, action: Object) => S; 85 | 86 | export type SelectorFn = (state: Object, params: Object) => Resource; 87 | 88 | export interface ReduxfulProps { 89 | actionCreators: { [key: string]: ActionCreatorFn }; 90 | actions: { [key: string]: ActionCreatorFn }; 91 | reducers: { [key: string]: ReducerFn }; 92 | reducerMap: { [key: string]: ReducerFn }; 93 | selectors: { [key: string]: SelectorFn }; 94 | } 95 | 96 | class Reduxful implements ReduxfulProps { 97 | public actionCreators: { [key: string]: ActionCreatorFn }; 98 | public actions: { [key: string]: ActionCreatorFn }; 99 | public reducers: { [key: string]: ReducerFn }; 100 | public reducerMap: { [key: string]: ReducerFn }; 101 | public selectors: { [key: string]: SelectorFn }; 102 | constructor( 103 | apiName: string, 104 | apiDesc: ApiDescription, 105 | apiConfig?: ApiConfig 106 | ); 107 | } 108 | 109 | export function setupApi( 110 | apiName: string, 111 | apiDesc: ApiDescription, 112 | config?: ApiConfig 113 | ): Reduxful; 114 | 115 | export default Reduxful; 116 | } 117 | -------------------------------------------------------------------------------- /typings/reduxful.test.ts: -------------------------------------------------------------------------------- 1 | import Reduxful, { Resource, setupApi } from 'reduxful'; 2 | import requestAdapter from './fetchUtils.test'; 3 | 4 | const apiDesc = { 5 | getDoodad: { 6 | url: 'http://api.my-service.com/doodads/:id', 7 | }, 8 | getDoodadList: { 9 | url: 'http://api.my-service.com/doodads', 10 | }, 11 | }; 12 | 13 | const apiConfig = { requestAdapter }; 14 | const doodadApi = new Reduxful('doodadApi', apiDesc, apiConfig); 15 | 16 | export const reduxFul = setupApi('test-name', {}); 17 | 18 | export default doodadApi; 19 | 20 | const testResource: Resource = { value: 'test', isLoaded: true, hasError: false, isUpdating: false }; 21 | 22 | const testResourceWithShape: Resource<{ success: boolean }> = { 23 | value: { success: true }, 24 | isLoaded: true, 25 | hasError: false, 26 | isUpdating: false 27 | }; 28 | -------------------------------------------------------------------------------- /typings/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { isLoaded, isUpdating, hasError, getResourceKey } from 'reduxful'; 2 | 3 | isLoaded(); 4 | isLoaded(null); 5 | isLoaded({ isLoaded: false }); 6 | 7 | isUpdating(); 8 | isUpdating(null); 9 | isUpdating({ isUpdating: false }); 10 | 11 | hasError(); 12 | hasError(null); 13 | hasError({ hasError: false }); 14 | 15 | const params = { 16 | id: 'user123', 17 | count: 777, 18 | }; 19 | 20 | getResourceKey('test', params); 21 | getResourceKey('test'); 22 | --------------------------------------------------------------------------------