├── .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 | [![Coverage Status](https://coveralls.io/repos/github/labcodes/react-redux-api-tools/badge.svg?branch=master)](https://coveralls.io/github/labcodes/react-redux-api-tools?branch=master) [![Build Status](https://travis-ci.org/labcodes/react-redux-api-tools.svg?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 | [![labcodes github banner](labcodes-github-banner.jpg)](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 | --------------------------------------------------------------------------------