├── .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 | [](https://www.npmjs.com/package/reduxful)
4 | [](https://github.com/godaddy/reduxful/actions/workflows/ci.yml)
5 | [](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 | - ActionCreatorFn ⇒
ActionCreatorThunkFn
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 | - ActionCreatorThunkFn ⇒
Promise.<Action>
48 | Thunk will actually dispatch an Action only if:
49 |
50 | - it is not debounced
51 | - it is not throttled
52 |
53 | Thunk will always return a resolving promise with either:
54 |
55 | - new action being dispatched
56 | - same action being dispatched if debounced
57 | - previous action dispatched if throttled
58 |
59 |
60 | - RequestAdapter ⇒
Promise
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 | - OptionsFn ⇒
Object
82 | Function to create request options object which can read from Redux state
83 |
84 | - UrlTemplateFn ⇒
String
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 | - SubActionCreatorFn ⇒
Object
89 | Sub action creator function
90 |
91 | - SelectorFn ⇒
Resource
92 | Selector function to retrieve a resource from Redux state
93 |
94 | - ReducerFn ⇒
Object
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 |
--------------------------------------------------------------------------------