├── .babelrc
├── .coveralls.yml
├── .editorconfig
├── .eslintrc.js
├── .gitignore
├── .nvmrc
├── .prettierrc
├── .travis.yml
├── LICENSE
├── README.md
├── __tests__
├── api.test.js
└── middleware.test.js
├── dist
├── api.js
└── middleware.js
├── index.js
├── labcodes-github-banner.jpg
├── lib
├── api.js
└── middleware.js
├── package.json
└── setupJest.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "browsers": "IE 11"
8 | }
9 | }
10 | ]
11 | ],
12 | "env": {
13 | "production": {
14 | "plugins": [
15 | "@babel/plugin-transform-runtime",
16 | [
17 | "@babel/plugin-proposal-class-properties",
18 | {
19 | "loose": true
20 | }
21 | ]
22 | ]
23 | },
24 | "test": {
25 | "plugins": [
26 | "@babel/plugin-transform-runtime",
27 | [
28 | "@babel/plugin-proposal-class-properties",
29 | {
30 | "loose": true
31 | }
32 | ]
33 | ]
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/.coveralls.yml:
--------------------------------------------------------------------------------
1 | repo_token: Bn0yjmlwMyda9F4v9czf1TI4nqoRdKSsa
2 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['airbnb-base', 'prettier'],
3 | plugins: ['prettier'],
4 | rules: {
5 | 'prettier/prettier': 'error',
6 | 'arrow-body-style': ['error', 'as-needed'],
7 | 'no-param-reassign': 'off',
8 | 'no-console': ['error', { allow: ['error'] }],
9 | 'no-underscore-dangle': 'off',
10 | },
11 | env: {
12 | es6: true,
13 | jest: true,
14 | browser: true,
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # VS Code settings
30 | .vscode
31 |
32 | # node-waf configuration
33 | .lock-wscript
34 |
35 | # Compiled binary addons (https://nodejs.org/api/addons.html)
36 | build/Release
37 |
38 | # Dependency directories
39 | node_modules/
40 | jspm_packages/
41 |
42 | # TypeScript v1 declaration files
43 | typings/
44 |
45 | # Optional npm cache directory
46 | .npm
47 |
48 | # Optional eslint cache
49 | .eslintcache
50 |
51 | # Optional REPL history
52 | .node_repl_history
53 |
54 | # Output of 'npm pack'
55 | *.tgz
56 |
57 | # Yarn Integrity file
58 | .yarn-integrity
59 | yarn.lock
60 |
61 | # dotenv environment variables file
62 | .env
63 |
64 | # next.js build output
65 | .next
66 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/*
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "singleQuote": true,
4 | "trailingComma": "all"
5 | }
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "current"
4 | script:
5 | - npm run coveralls
6 | before_script:
7 | - yarn run lint
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Luciano Ratamero
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 | # react-redux-api-tools
2 |
3 | [](https://coveralls.io/github/labcodes/react-redux-api-tools?branch=master) [](https://travis-ci.org/labcodes/react-redux-api-tools)
4 |
5 |
6 | This project provides a middleware and a request helper to streamline react-redux data fetching.
7 |
8 | ### Installing
9 |
10 | Just run `npm install --save react-redux-api-tools` and you're good to go.
11 |
12 | ### Using the `fetchFromApi` helper
13 |
14 | One of the problems of using the default fetch implementation is that it does **not** reject if the status code is 4xx.
15 |
16 | It makes our reducers not exactly semantic, since a 400 Bad Request will be interpreted as a successful request.
17 |
18 | For that case, we provide the `fetchFromApi` helper, which overrides fetch to reject on anything with status equal or above 400.
19 |
20 | To use it, import it and use it on the `apiCallFunction` key, inside your actions:
21 |
22 | ##### /store/actions.js
23 |
24 | ```js
25 | import { fetchFromApi } from "react-redux-api-tools";
26 |
27 | // we declare a new action called createProduct that will POST to the backend
28 | export const createProduct = (product) => {
29 |
30 | // first, we consolidate the request data inside a dict.
31 | // we follow the Request object API
32 | // https://developer.mozilla.org/en-US/docs/Web/API/Request
33 |
34 | // by default, the method is 'GET' and we use "content-type: application/json" headers,
35 | // but you may overwrite the headers as needed
36 | const requestData = {
37 | method: 'POST',
38 | body: JSON.stringify(product)
39 | }
40 |
41 | return {
42 | types: {
43 | request: 'CREATE_PRODUCTS',
44 | success: 'CREATE_PRODUCTS_SUCCESS',
45 | failure: 'CREATE_PRODUCTS_FAILURE',
46 | },
47 | // here is where we use it
48 | apiCallFunction: () => fetchFromApi(`/api/${product.brand}/inventory/`, requestData),
49 | };
50 | }
51 | ```
52 |
53 |
54 | ### Using the middleware
55 |
56 | #### Middleware capabilities
57 |
58 | The middleware bundles three actions (`request`, `success` and `failure`) into one action call.
59 |
60 | Let me show you with code. This is what a request action would look like when you're using the middleware:
61 |
62 | ```js
63 | // we declare a new action called fetchProducts that will fetch data
64 | export const fetchProducts = () => {
65 | return {
66 | // instead of returning the key 'type' with one action type,
67 | // we return three, one for each step of the request
68 | // inside the 'types' key
69 | types: {
70 | request: 'FETCH_PRODUCTS',
71 | success: 'FETCH_PRODUCTS_SUCCESS',
72 | failure: 'FETCH_PRODUCTS_FAILURE',
73 | },
74 | // we also declare a function that will implement the proper request
75 | apiCallFunction: () => fetchFromApi('/api/inventory/')
76 | };
77 | }
78 | ```
79 |
80 | That will:
81 |
82 | - trigger the `FETCH_PRODUCTS` reducer on request start;
83 | - trigger `FETCH_PRODUCTS_SUCCESS` if the request succeeds;
84 | - trigger `FETCH_PRODUCTS_FAILURE` if it doesn't.
85 |
86 |
87 | #### Making multiple requests at the same time
88 |
89 | If you want to make multiple requests in the same call, instead of returning a single `fetchFromApi` call, you may use [`Promise.all`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) passing a list of `fetchFromApi` calls:
90 |
91 | ```js
92 | export const fetchMultipleProducts = () => {
93 | return {
94 | types: {
95 | request: 'FETCH_MULTIPLE_PRODUCTS',
96 | success: 'FETCH_MULTIPLE_PRODUCTS_SUCCESS',
97 | failure: 'FETCH_MULTIPLE_PRODUCTS_FAILURE',
98 | },
99 | apiCallFunction: () => Promise.all([ fetchFromApi('/api/inventory/1'), fetchFromApi('/api/inventory/2'), ])
100 | };
101 | }
102 | ```
103 |
104 | When all of them are successful, the `'FETCH_MULTIPLE_PRODUCTS_SUCCESS'` reducer will be called with a list of `Response` objects. When one of them fails, the `'FETCH_MULTIPLE_PRODUCTS_FAILURE'` will be triggered passing the `Error` instance (most probably a `TypeError: Failed to fetch` error).
105 |
106 |
107 | #### Setup
108 |
109 | Assuming you've already installed react and redux, to use it, you'll need to install `redux-thunk` first:
110 |
111 | `npm install --save redux-thunk`
112 |
113 | Then, we need to apply the middleware when the application starts:
114 |
115 | ##### App.js
116 |
117 | ```js
118 | // on your app setup:
119 | import React from 'react';
120 | import thunk from 'redux-thunk';
121 | import { Provider } from 'react-redux';
122 | // we import the applyMiddleware function from redux
123 | // and the apiMiddleware from our tools
124 | import { createStore, applyMiddleware } from 'redux';
125 | import { apiMiddleware } from 'react-redux-api-tools';
126 |
127 | import Routes from './routes';
128 | import rootReducer from './store/reducers';
129 |
130 | // then, when we create the base store, we apply the middleware
131 | const store = createStore(rootReducer, applyMiddleware(thunk, apiMiddleware));
132 |
133 |
134 | class App extends React.Component {
135 | render () {
136 | return (
137 |
138 |
139 |
140 | )
141 | }
142 | }
143 | ```
144 |
145 | Then, every time you want to create an action that calls an api, you just need to pass the three types and an api call function as we have stated above:
146 |
147 | ##### /store/actions.js
148 |
149 | ```js
150 | // we declare a new action called fetchProducts that will fetch data
151 | export const fetchProducts = (brand) => {
152 | return {
153 | // instead of returning the key 'type' with one action type,
154 | // we return three, one for each step of the request
155 | // inside the 'types' key
156 | types: {
157 | request: 'FETCH_PRODUCTS',
158 | success: 'FETCH_PRODUCTS_SUCCESS',
159 | failure: 'FETCH_PRODUCTS_FAILURE',
160 | },
161 | // we also declare a function that will implement the proper request
162 | apiCallFunction: () => fetchFromApi(`/api/${brand}/inventory/`),
163 |
164 | // there is an optional callback so we can stop a request if we don't need to refetch the data
165 | // it works both for request and default actions
166 | shouldDispatch: (appState, action) => { return !appState.products.items.length },
167 |
168 | // and you may as well pass some extra data, if needed
169 | extraData: {
170 | brand,
171 | anything: 'could go here, and it will be available on the action.extraData attribute'
172 | }
173 | };
174 | }
175 | ```
176 |
177 | On the reducers side, nothing much changes. We pass the `response` (always) and `error` (when the request fails) objects to the action, so we can get the response/error data on the reducers:
178 |
179 | ##### /store/reducers.js
180 |
181 | ```js
182 | const productReducer = (state = { isLoading: false }, action) => {
183 | switch(action.type) {
184 |
185 | case 'FETCH_PRODUCTS':
186 | return {
187 | ...state,
188 | isLoading: true,
189 | error: null,
190 | // the extraData will be available in all related reducers
191 | // so, if you need it...
192 | brand: action.extraData.brand
193 | };
194 |
195 | case 'FETCH_PRODUCTS_SUCCESS':
196 | return {
197 | ...state,
198 | isLoading: false,
199 | // the action has the response built in
200 | // and the middleware detects if the response is a json response
201 | // so it can populate the response.data without needing to resolve promises
202 | items: action.response.data
203 | };
204 |
205 | case 'FETCH_PRODUCTS_FAILURE':
206 | return {
207 | ...state,
208 | isLoading: false,
209 | // same thing for errors, but on the error key,
210 | // so you can check if it exists on the component side
211 | error: action.error.data,
212 | // though the response key will always be populated
213 | response: action.response,
214 | items: []
215 | };
216 |
217 | default:
218 | return state;
219 | }
220 | }
221 | ```
222 |
223 | Finally, on the component, you'll just need to bind it with redux and the code will be **so** clean:
224 |
225 | ##### /components/ProductsList.js
226 |
227 | ```jsx
228 | import React from 'react';
229 | import { connect } from 'react-redux'
230 | import { fetchProducts } from '../store/actions';
231 |
232 |
233 | class ProductsList extends React.Component {
234 |
235 | componentDidMount(){
236 | // we just need to dispatch it. ONCE. no redux management here.
237 | this.props.fetchProducts();
238 | }
239 |
240 | render(){
241 | const { isLoading, error, items } = this.props;
242 |
243 | if (isLoading) {
244 | return
Loading...
245 | }
246 |
247 | if (error) {
248 | return {JSON.stringify(error.data)}
249 | }
250 |
251 | return (
252 |
253 | Products List
254 | {items.map(product => (
255 | {product.name}
256 | )
257 | )}
258 |
259 | );
260 | }
261 | }
262 |
263 | // here, we bind the redux state as component props:
264 | const mapStateToProps = (state) => ({
265 | isLoading: state.isLoading,
266 | items: state.items,
267 | error: state.error,
268 | });
269 | // and bind the action dispatch to a prop as well:
270 | const mapDispatchToProps = (dispatch) => ({
271 | fetchProducts: () => dispatch(fetchProducts())
272 | });
273 |
274 | // and connect it to redux :)
275 | export default connect(mapStateToProps, mapDispatchToProps)(ProductsList);
276 | ```
277 |
278 | ### Contributing
279 |
280 | Don't hesitate to open an issue for bugs!
281 |
282 | But if you would like a new feature, it would be nice to discuss it before accepting PRs. We reserve ourselves the right to reject a feature that was not discussed or that will impact the code in a meaningful way. In that case, open an issue so we can discuss. Thanks. <3
283 |
284 | [](https://labcodes.com.br/?utm_source=github&utm_medium=cpc&utm_campaign=react_redux_api)
285 |
--------------------------------------------------------------------------------
/__tests__/api.test.js:
--------------------------------------------------------------------------------
1 | import fetchFromApi from '../lib/api';
2 |
3 | describe('fetchFromApi', () => {
4 | beforeEach(() => {
5 | fetch.resetMocks();
6 | });
7 |
8 | it('should resolve fetch and return data with error', async () => {
9 | fetch.mockResolvedValue({
10 | status: 400,
11 | message: 'The name is empty',
12 | });
13 |
14 | const requestData = {
15 | method: 'POST',
16 | body: { name: '' },
17 | };
18 | try {
19 | await fetchFromApi('http://localhost:3000/products', requestData);
20 | } catch (e) {
21 | expect(e).toEqual({
22 | status: 400,
23 | message: 'The name is empty',
24 | });
25 | }
26 | });
27 |
28 | it('should resolve fetch and return data', async () => {
29 | fetch.mockResolvedValue({
30 | data: [{ name: 'Xbox' }],
31 | });
32 |
33 | const response = await fetchFromApi('http://localhost:3000/products');
34 |
35 | expect(response).toEqual({
36 | data: [{ name: 'Xbox' }],
37 | });
38 | });
39 |
40 | it('should define application/json as default headers', async () => {
41 | fetch.mockResolvedValue({
42 | data: [{ name: 'Xbox' }],
43 | });
44 | const requestData = {};
45 |
46 | await fetchFromApi('http://localhost:3000/products', requestData);
47 |
48 | expect(requestData).toEqual({
49 | headers: {
50 | 'content-type': 'application/json',
51 | },
52 | });
53 | });
54 |
55 | it('should not overwrite content type if specified in requestData', async () => {
56 | fetch.mockResolvedValue({
57 | data: [{ name: 'Xbox' }],
58 | });
59 | const requestData = {
60 | headers: {
61 | 'content-type': 'application/x-www-form-urlencoded',
62 | },
63 | };
64 |
65 | await fetchFromApi('http://localhost:3000/products', requestData);
66 |
67 | expect(requestData).toEqual({
68 | headers: {
69 | 'content-type': 'application/x-www-form-urlencoded',
70 | },
71 | });
72 | });
73 |
74 | it('should reject fetch and catch by error', async () => {
75 | fetch.mockRejectedValue(new Error('404 Not found'));
76 | try {
77 | await fetchFromApi('http://localhost:3000/products/999');
78 | } catch (e) {
79 | expect(e).toEqual(new Error('404 Not found'));
80 | }
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/__tests__/middleware.test.js:
--------------------------------------------------------------------------------
1 | import apiMiddleware from '../lib/middleware';
2 |
3 | describe('apiMiddleware', () => {
4 | let dispatch;
5 | let getState;
6 | let next;
7 |
8 | beforeEach(() => {
9 | dispatch = jest.fn();
10 | getState = jest.fn();
11 | next = jest.fn();
12 | });
13 |
14 | it('Should dispatch action success and return the data body - no json', async () => {
15 | function get() {
16 | return 'application/x-www-form-urlencoded';
17 | }
18 |
19 | const apiCallFunction = jest.fn().mockResolvedValue({
20 | headers: {
21 | get,
22 | },
23 | });
24 |
25 | const action = {
26 | types: {
27 | request: 'REQUEST',
28 | success: 'SUCCESS',
29 | failure: 'FAILURE',
30 | },
31 | apiCallFunction,
32 | };
33 |
34 | const response = await apiMiddleware({ dispatch, getState })(next)(action);
35 |
36 | expect(next).not.toBeCalled();
37 | expect(getState).toBeCalled();
38 |
39 | const expectedResponse = {
40 | headers: {
41 | get,
42 | },
43 | };
44 | expect(dispatch).toBeCalledWith({
45 | extraData: {},
46 | type: 'REQUEST',
47 | });
48 | expect(dispatch).toBeCalledWith({
49 | extraData: {},
50 | response: expectedResponse,
51 | type: 'SUCCESS',
52 | });
53 | expect(response).toEqual(expectedResponse);
54 | });
55 |
56 | it('Should dispatch action success and return the data body - json', async () => {
57 | function get() {
58 | return 'application/json; charset=utf-8';
59 | }
60 |
61 | const body = {
62 | data: [{ id: 1, name: 'Xbox' }],
63 | };
64 |
65 | function json() {
66 | return Promise.resolve(body);
67 | }
68 |
69 | const apiCallFunction = jest.fn().mockResolvedValue({
70 | headers: {
71 | get,
72 | },
73 | json,
74 | });
75 |
76 | const action = {
77 | types: {
78 | request: 'REQUEST',
79 | success: 'SUCCESS',
80 | failure: 'FAILURE',
81 | },
82 | apiCallFunction,
83 | };
84 |
85 | const response = await apiMiddleware({ dispatch, getState })(next)(action);
86 |
87 | expect(next).not.toBeCalled();
88 | expect(getState).toBeCalled();
89 |
90 | const expectedResponse = {
91 | data: body,
92 | headers: {
93 | get,
94 | },
95 | json,
96 | };
97 | expect(dispatch).toBeCalledWith({
98 | extraData: {},
99 | type: 'REQUEST',
100 | });
101 | expect(dispatch).toBeCalledWith({
102 | extraData: {},
103 | response: expectedResponse,
104 | type: 'SUCCESS',
105 | });
106 | expect(response).toEqual(expectedResponse);
107 | });
108 |
109 | it('Should dispatch action success when API returns 204', async () => {
110 | const get = () => {};
111 |
112 | const apiCallFunction = jest.fn().mockResolvedValue({
113 | headers: {
114 | get,
115 | status: 204,
116 | },
117 | });
118 |
119 | const action = {
120 | types: {
121 | request: 'REQUEST',
122 | success: 'SUCCESS',
123 | failure: 'FAILURE',
124 | },
125 | apiCallFunction,
126 | };
127 |
128 | const response = await apiMiddleware({ dispatch, getState })(next)(action);
129 |
130 | expect(next).not.toBeCalled();
131 | expect(getState).toBeCalled();
132 |
133 | const expectedResponse = {
134 | headers: {
135 | get,
136 | status: 204,
137 | },
138 | };
139 | expect(dispatch).toBeCalledWith({
140 | extraData: {},
141 | type: 'REQUEST',
142 | });
143 | expect(dispatch).toBeCalledWith({
144 | extraData: {},
145 | response: expectedResponse,
146 | type: 'SUCCESS',
147 | });
148 | expect(response).toEqual(expectedResponse);
149 | });
150 |
151 | it('Should dispatch action failure when has some error on request - no json', async () => {
152 | function get() {
153 | return 'application/x-www-form-urlencoded';
154 | }
155 |
156 | const body = {
157 | status: 500,
158 | message: 'Internal Error',
159 | };
160 |
161 | function json() {
162 | return Promise.resolve(body);
163 | }
164 |
165 | const apiCallFunction = jest.fn().mockRejectedValue({
166 | headers: {
167 | get,
168 | },
169 | json,
170 | });
171 |
172 | const action = {
173 | types: {
174 | request: 'REQUEST',
175 | success: 'SUCCESS',
176 | failure: 'FAILURE',
177 | },
178 | apiCallFunction,
179 | };
180 |
181 | const expectedResponse = {
182 | headers: {
183 | get,
184 | },
185 | json,
186 | };
187 |
188 | try {
189 | await apiMiddleware({ dispatch, getState })(next)(action);
190 | } catch (error) {
191 | expect(error).toEqual(expectedResponse);
192 | }
193 |
194 | expect(next).not.toBeCalled();
195 | expect(getState).toBeCalled();
196 | expect(dispatch).toBeCalledWith({
197 | extraData: {},
198 | type: 'REQUEST',
199 | });
200 | expect(dispatch).toBeCalledWith({
201 | extraData: {},
202 | response: expectedResponse,
203 | error: expectedResponse,
204 | type: 'FAILURE',
205 | });
206 | });
207 |
208 | it('Should dispatch action failure when has some error on request - ', async () => {
209 | function get() {
210 | return 'application/json';
211 | }
212 |
213 | const body = {
214 | status: 500,
215 | message: 'Internal Error',
216 | };
217 |
218 | function json() {
219 | return Promise.resolve(body);
220 | }
221 |
222 | const apiCallFunction = jest.fn().mockRejectedValue({
223 | headers: {
224 | get,
225 | },
226 | json,
227 | });
228 |
229 | const action = {
230 | types: {
231 | request: 'REQUEST',
232 | success: 'SUCCESS',
233 | failure: 'FAILURE',
234 | },
235 | apiCallFunction,
236 | };
237 |
238 | const expectedResponse = {
239 | data: body,
240 | headers: {
241 | get,
242 | },
243 | json,
244 | };
245 |
246 | try {
247 | await apiMiddleware({ dispatch, getState })(next)(action);
248 | } catch (error) {
249 | expect(error).toEqual(expectedResponse);
250 | }
251 |
252 | expect(next).not.toBeCalled();
253 | expect(getState).toBeCalled();
254 | expect(dispatch).toBeCalledWith({
255 | extraData: {},
256 | type: 'REQUEST',
257 | });
258 | expect(dispatch).toBeCalledWith({
259 | extraData: {},
260 | response: expectedResponse,
261 | error: expectedResponse,
262 | type: 'FAILURE',
263 | });
264 | });
265 |
266 | it("Should catch error when it doesn't pass all types actions", async () => {
267 | const action = {
268 | types: {},
269 | };
270 |
271 | try {
272 | await apiMiddleware({ dispatch, getState })(next)(action);
273 | } catch (error) {
274 | expect(error).toEqual(
275 | new Error(
276 | 'Expected action.types to be an object/dict with three keys (request, success and failure), and the values should be strings.',
277 | ),
278 | );
279 | }
280 | });
281 |
282 | it('Should pass action forward if no types are defined', async () => {
283 | const action = {
284 | type: 'REQUEST',
285 | };
286 | apiMiddleware({ dispatch, getState })(next)(action);
287 | expect(next).toBeCalledWith(action);
288 | });
289 |
290 | it('Should catch error when apiCallFunction dependency is not a function', async () => {
291 | const action = {
292 | types: {
293 | request: 'REQUEST',
294 | success: 'SUCCESS',
295 | failure: 'FAILURE',
296 | },
297 | };
298 |
299 | try {
300 | await apiMiddleware({ dispatch, getState })(next)(action);
301 | } catch (error) {
302 | expect(error).toEqual(new Error('Expected `apiCallFunction` to be a function.'));
303 | }
304 | });
305 |
306 | it('Should not call api is shouldDispatch returns false', async () => {
307 | function get() {
308 | return 'application/json';
309 | }
310 |
311 | const body = {
312 | data: [{ id: 1, name: 'Xbox' }],
313 | };
314 |
315 | function json() {
316 | return Promise.resolve(body);
317 | }
318 |
319 | const apiCallFunction = jest.fn().mockResolvedValue({
320 | headers: {
321 | get,
322 | },
323 | json,
324 | });
325 |
326 | const action = {
327 | types: {
328 | request: 'REQUEST',
329 | success: 'SUCCESS',
330 | failure: 'FAILURE',
331 | },
332 | apiCallFunction,
333 | shouldDispatch: () => false,
334 | };
335 |
336 | apiMiddleware({ dispatch, getState })(next)(action);
337 |
338 | expect(next).not.toBeCalled();
339 | expect(getState).toBeCalled();
340 | expect(dispatch).not.toBeCalled();
341 | });
342 | });
343 |
--------------------------------------------------------------------------------
/dist/api.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4 |
5 | Object.defineProperty(exports, "__esModule", {
6 | value: true
7 | });
8 | exports.default = void 0;
9 |
10 | var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
11 |
12 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; }
13 |
14 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { (0, _defineProperty2.default)(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
15 |
16 | function buildRequest(url, requestData) {
17 | var request = new Request(url, _objectSpread({}, requestData));
18 | return request;
19 | }
20 |
21 | function fetchFromApi(url) {
22 | var requestData = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {
23 | method: 'GET'
24 | };
25 |
26 | if (!requestData.headers) {
27 | requestData.headers = {};
28 | }
29 |
30 | if (!requestData.headers['content-type']) {
31 | requestData.headers['content-type'] = 'application/json';
32 | }
33 |
34 | return new Promise(function (resolve, reject) {
35 | fetch(buildRequest(url, requestData)).then(function (response) {
36 | // here, we prepare fetch to reject when the status is 4xx or above
37 | if (response.status >= 400) {
38 | return reject(response);
39 | }
40 |
41 | return resolve(response);
42 | }).catch(function (err) {
43 | return reject(err);
44 | });
45 | });
46 | }
47 |
48 | var _default = fetchFromApi;
49 | exports.default = _default;
--------------------------------------------------------------------------------
/dist/middleware.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 | exports.default = apiMiddleware;
7 |
8 | var validateAction = function validateAction(action) {
9 | var types = action.types,
10 | apiCallFunction = action.apiCallFunction;
11 | var expectedTypes = ['request', 'success', 'failure'];
12 |
13 | if (Object.values(types).length !== 3 || !Object.keys(types).every(function (type) {
14 | return expectedTypes.includes(type);
15 | }) || !Object.values(types).every(function (type) {
16 | return typeof type === 'string';
17 | })) {
18 | throw new Error('Expected action.types to be an object/dict with three keys (request, success and failure), and the values should be strings.');
19 | }
20 |
21 | if (typeof apiCallFunction !== 'function') {
22 | throw new Error('Expected `apiCallFunction` to be a function.');
23 | }
24 | };
25 |
26 | function apiMiddleware(_ref) {
27 | var dispatch = _ref.dispatch,
28 | getState = _ref.getState;
29 | return function (next) {
30 | return function (action) {
31 | var types = action.types,
32 | apiCallFunction = action.apiCallFunction,
33 | _action$shouldDispatc = action.shouldDispatch,
34 | shouldDispatch = _action$shouldDispatc === void 0 ? function () {
35 | return true;
36 | } : _action$shouldDispatc,
37 | _action$extraData = action.extraData,
38 | extraData = _action$extraData === void 0 ? {} : _action$extraData; // to prevent accidental dispatches,
39 | // we can check the state before calling
40 |
41 | if (!shouldDispatch(getState(), action)) {
42 | return new Promise(function () {}); // so we can still call .then when we dispatch
43 | } // this is exclusively for our rel-event library
44 | // and so is marked as unsafe
45 |
46 |
47 | action.__UNSAFE_dispatch = dispatch;
48 |
49 | if (!types) {
50 | // if this is a normal use of redux, we just pass on the action
51 | return next(action);
52 | } // here, we validate the dependencies for the middleware
53 |
54 |
55 | validateAction(action); // we dispatch the request action, so the interface can react to it
56 |
57 | dispatch({
58 | extraData: extraData,
59 | type: types.request
60 | }); // at last, we return a promise with the proper api call
61 |
62 | return new Promise(function (resolve, reject) {
63 | apiCallFunction(dispatch).then(function (response) {
64 | // if it's a json response, we unpack and parse it
65 | var contentType = response.headers && typeof response.headers.get === 'function' && response.headers.get('content-type');
66 |
67 | if (contentType && contentType.startsWith('application/json')) {
68 | response.json().then(function (data) {
69 | response.data = data; // from backend response
70 |
71 | dispatch({
72 | extraData: extraData,
73 | response: response,
74 | type: types.success
75 | });
76 | resolve(response);
77 | });
78 | } else {
79 | dispatch({
80 | extraData: extraData,
81 | response: response,
82 | type: types.success
83 | });
84 | resolve(response);
85 | }
86 | }).catch(function (error) {
87 | // if it's a json response, we unpack and parse it
88 | var contentType = error.headers && typeof error.headers.get === 'function' && error.headers.get('content-type');
89 |
90 | if (contentType && contentType.startsWith('application/json')) {
91 | error.json().then(function (data) {
92 | error.data = data; // form backend error
93 |
94 | dispatch({
95 | extraData: extraData,
96 | error: error,
97 | response: error,
98 | type: types.failure
99 | });
100 | reject(error);
101 | });
102 | } else {
103 | dispatch({
104 | extraData: extraData,
105 | error: error,
106 | response: error,
107 | type: types.failure
108 | });
109 | reject(error);
110 | }
111 | });
112 | });
113 | };
114 | };
115 | }
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 | Object.defineProperty(exports, "fetchFromApi", {
7 | enumerable: true,
8 | get: function get() {
9 | return _api.default;
10 | }
11 | });
12 | Object.defineProperty(exports, "apiMiddleware", {
13 | enumerable: true,
14 | get: function get() {
15 | return _middleware.default;
16 | }
17 | });
18 |
19 | var _api = _interopRequireDefault(require("./dist/api"));
20 |
21 | var _middleware = _interopRequireDefault(require("./dist/middleware"));
22 |
23 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
24 |
--------------------------------------------------------------------------------
/labcodes-github-banner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/labcodes/react-redux-api-tools/c584c24eb60d1e1ffd8c07cd37654fe413592b60/labcodes-github-banner.jpg
--------------------------------------------------------------------------------
/lib/api.js:
--------------------------------------------------------------------------------
1 | function buildRequest(url, requestData) {
2 | const request = new Request(url, { ...requestData });
3 | return request;
4 | }
5 |
6 | function fetchFromApi(url, requestData = { method: 'GET' }) {
7 | if (!requestData.headers) {
8 | requestData.headers = {};
9 | }
10 |
11 | if (!requestData.headers['content-type']) {
12 | requestData.headers['content-type'] = 'application/json';
13 | }
14 |
15 | return new Promise((resolve, reject) => {
16 | fetch(buildRequest(url, requestData))
17 | .then(response => {
18 | // here, we prepare fetch to reject when the status is 4xx or above
19 | if (response.status >= 400) {
20 | return reject(response);
21 | }
22 | return resolve(response);
23 | })
24 | .catch(err => reject(err));
25 | });
26 | }
27 |
28 | export default fetchFromApi;
29 |
--------------------------------------------------------------------------------
/lib/middleware.js:
--------------------------------------------------------------------------------
1 | const validateAction = action => {
2 | const { types, apiCallFunction } = action;
3 | const expectedTypes = ['request', 'success', 'failure'];
4 |
5 | if (
6 | Object.values(types).length !== 3 ||
7 | !Object.keys(types).every(type => expectedTypes.includes(type)) ||
8 | !Object.values(types).every(type => typeof type === 'string')
9 | ) {
10 | throw new Error(
11 | 'Expected action.types to be an object/dict with three keys (request, success and failure), and the values should be strings.',
12 | );
13 | }
14 |
15 | if (typeof apiCallFunction !== 'function') {
16 | throw new Error('Expected `apiCallFunction` to be a function.');
17 | }
18 | };
19 |
20 | export default function apiMiddleware({ dispatch, getState }) {
21 | return next => action => {
22 | const { types, apiCallFunction, shouldDispatch = () => true, extraData = {} } = action;
23 |
24 | // to prevent accidental dispatches,
25 | // we can check the state before calling
26 | if (!shouldDispatch(getState(), action)) {
27 | return new Promise(() => {}); // so we can still call .then when we dispatch
28 | }
29 |
30 | // this is exclusively for our rel-event library
31 | // and so is marked as unsafe
32 | action.__UNSAFE_dispatch = dispatch;
33 |
34 | if (!types) {
35 | // if this is a normal use of redux, we just pass on the action
36 | return next(action);
37 | }
38 |
39 | // here, we validate the dependencies for the middleware
40 | validateAction(action);
41 |
42 | // we dispatch the request action, so the interface can react to it
43 | dispatch({ extraData, type: types.request });
44 |
45 | // at last, we return a promise with the proper api call
46 | return new Promise((resolve, reject) => {
47 | apiCallFunction(dispatch)
48 | .then(response => {
49 | // if it's a json response, we unpack and parse it
50 | const contentType =
51 | response.headers &&
52 | typeof response.headers.get === 'function' &&
53 | response.headers.get('content-type');
54 |
55 | if (contentType && contentType.startsWith('application/json')) {
56 | response.json().then(data => {
57 | response.data = data; // from backend response
58 | dispatch({ extraData, response, type: types.success });
59 | resolve(response);
60 | });
61 | } else {
62 | dispatch({ extraData, response, type: types.success });
63 | resolve(response);
64 | }
65 | })
66 | .catch(error => {
67 | // if it's a json response, we unpack and parse it
68 | const contentType =
69 | error.headers &&
70 | typeof error.headers.get === 'function' &&
71 | error.headers.get('content-type');
72 |
73 | if (contentType && contentType.startsWith('application/json')) {
74 | error.json().then(data => {
75 | error.data = data; // form backend error
76 | dispatch({ extraData, error, response: error, type: types.failure });
77 | reject(error);
78 | });
79 | } else {
80 | dispatch({ extraData, error, response: error, type: types.failure });
81 | reject(error);
82 | }
83 | });
84 | });
85 | };
86 | }
87 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-redux-api-tools",
3 | "version": "2.1.3",
4 | "description": "Middleware and helpers to improve the React-Redux flow when communicating with APIs.",
5 | "main": "index.js",
6 | "scripts": {
7 | "coveralls": "npm run test && cat ./coverage/lcov.info | coveralls",
8 | "lint": "eslint lib/** __tests__/**",
9 | "test": "jest --coverage",
10 | "jest": "jest",
11 | "dist": "NODE_ENV=production ./node_modules/.bin/babel lib -d dist",
12 | "test_debug": "node --inspect node_modules/.bin/jest"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/labcodes/react-redux-api-tools.git"
17 | },
18 | "keywords": [
19 | "react",
20 | "redux",
21 | "api",
22 | "tools",
23 | "middleware"
24 | ],
25 | "author": "Luciano Ratamero",
26 | "license": "MIT",
27 | "bugs": {
28 | "url": "https://github.com/labcodes/react-redux-api-tools/issues"
29 | },
30 | "homepage": "https://github.com/labcodes/react-redux-api-tools#readme",
31 | "devDependencies": {
32 | "@babel/cli": "^7.7.0",
33 | "@babel/core": "^7.5.4",
34 | "@babel/plugin-proposal-class-properties": "^7.7.0",
35 | "@babel/plugin-transform-runtime": "^7.5.0",
36 | "@babel/preset-env": "^7.5.4",
37 | "@babel/runtime": "^7.5.4",
38 | "coveralls": "^3.0.5",
39 | "eslint": "^6.0.1",
40 | "eslint-config-airbnb-base": "^13.2.0",
41 | "eslint-config-prettier": "^6.0.0",
42 | "eslint-plugin-import": "^2.18.0",
43 | "eslint-plugin-prettier": "^3.1.0",
44 | "husky": "^3.0.1",
45 | "jest": "^24.8.0",
46 | "jest-fetch-mock": "^2.1.2",
47 | "prettier": "^1.18.2"
48 | },
49 | "jest": {
50 | "automock": false,
51 | "setupFiles": [
52 | "/setupJest.js"
53 | ],
54 | "coverageThreshold": {
55 | "global": {
56 | "branches": 100,
57 | "functions": 100,
58 | "lines": 100,
59 | "statements": 100
60 | }
61 | }
62 | },
63 | "husky": {
64 | "hooks": {
65 | "pre-commit": "npm run lint && npm run dist && npm run test"
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/setupJest.js:
--------------------------------------------------------------------------------
1 | global.fetch = require('jest-fetch-mock');
2 |
--------------------------------------------------------------------------------