├── .babelrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── package.json ├── spec ├── createRequestPromise.test.js ├── index.test.js └── utils.test.js ├── src ├── createRequestPromise.js ├── index.js ├── log.js └── utils.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "env": { 6 | "test": { 7 | "presets": [ 8 | [ 9 | "@babel/preset-env", 10 | { 11 | "targets": { 12 | "node": "current" 13 | } 14 | } 15 | ] 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | *.log 4 | *.swp 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | spec 3 | *.log 4 | *.swp 5 | *.swo 6 | yarn.lock 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8.11.4" 4 | script: npm run test:ci 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/CodementorIO/redux-api-middleman.svg?branch=master)](https://travis-ci.org/CodementorIO/redux-api-middleman) 2 | [![npm version](https://badge.fury.io/js/redux-api-middleman.svg)](https://www.npmjs.com/package/redux-api-middleman) 3 | [![minzip bundle size](https://img.shields.io/bundlephobia/minzip/redux-api-middleman.svg)](https://www.npmjs.com/package/redux-api-middleman) [![Greenkeeper badge](https://badges.greenkeeper.io/CodementorIO/redux-api-middleman.svg)](https://greenkeeper.io/) 4 | 5 | # Redux API Middleman 6 | 7 | A Redux middleware extracting the asynchronous behavior of sending API requests. 8 | 9 | # Usage 10 | 11 | ## Get Started 12 | 13 | - Create the middleware and put into your middleware chain: 14 | 15 | ```javascript 16 | import { createStore, applyMiddleware } from 'redux' 17 | import createApiMiddleman from 'redux-api-middleman' 18 | 19 | let apiMiddleware = createApiMiddleman({ 20 | baseUrl: 'http://api.myapp.com', 21 | }) 22 | 23 | const store = applyMiddleware( 24 | [ apiMiddleware ] 25 | )(createStore)() 26 | ``` 27 | 28 | - Use it in your action creators: 29 | 30 | ```javascript 31 | // user action 32 | 33 | import { CALL_API } from 'redux-api-middleman' 34 | 35 | export const GETTING_MY_INFO = 'GETTING_MY_INFO' 36 | export const GET_MY_INFO_SUCCESS = 'GET_MY_INFO_SUCCESS' 37 | export const GET_MY_INFO_FAILED = 'GET_MY_INFO_FAILED' 38 | 39 | export function getMyInfo() { 40 | return { 41 | [CALL_API]: { 42 | method: 'get', 43 | path: '/me', 44 | sendingType: GETTING_MY_INFO, 45 | successType: GET_CONTRACTS_SUCCESS, 46 | errorType: GET_MY_INFO_FAILED 47 | } 48 | } 49 | } 50 | ``` 51 | 52 | - Handle it in your reducer: 53 | 54 | ```javascript 55 | // user reducer 56 | 57 | import { GET_CONTRACTS_SUCCESS } from 'actions/users' 58 | 59 | const defaultState = {} 60 | 61 | export default function(state = defaultState, action) { 62 | switch(action.type) { 63 | case GET_CONTRACTS_SUCCESS: 64 | return action.response 65 | default: 66 | return state 67 | } 68 | } 69 | 70 | ``` 71 | 72 | The code above would send a `GET` request to `http://api.myapp.com/me`, 73 | when success, it would dispatch an action: 74 | 75 | ```javascript 76 | { 77 | type: GET_CONTRACTS_SUCCESS, 78 | response: { the-camelized-response-body } 79 | } 80 | ``` 81 | 82 | # Features 83 | 84 | - Async to Sync: Abstract the async nature of sending API to make it easier to implement/test 85 | - Universal Rendering Friendly 86 | - Support chaining(successive) API calls 87 | - Side Effect Friendly 88 | - Replay request optionally when failed 89 | - Tweek request/response format when needed 90 | 91 | # API Documentation 92 | 93 | ## Creation 94 | 95 | A middleware can be created like this: 96 | 97 | ```javascript 98 | import apiMiddleware from 'redux-api-middleman' 99 | 100 | apiMiddleware({ 101 | baseUrl: 'https://api.myapp.com', 102 | errorInterceptor: ({ err, proceedError, replay, getState })=> { 103 | // handle replay here 104 | }, 105 | generateDefaultParams: ({ getState })=> { 106 | return { 107 | headers: { 'X-Requested-From': 'my-killer-app' }, 108 | } 109 | }, 110 | maxReplayTimes: 5 111 | }) 112 | ``` 113 | 114 | ### Options 115 | 116 | #### `baseUrl`: The base url of api calls(required) 117 | 118 | #### `errorInterceptor`(optional) 119 | 120 | When provided, this function would be invoked whenever an API call fails. 121 | The function signature looks like this: 122 | 123 | ```javascript 124 | ({ err, proceedError, replay, getState })=> { 125 | 126 | } 127 | ``` 128 | 129 | Where: 130 | 131 | `err` is the error object returned by [`superagent`](https://visionmedia.github.io/superagent/), 132 | `replay()` can be used to replay the request with the same method/parameters, 133 | `proceedError()` can be used to proceed error to reducers 134 | 135 | For example, to refresh access token when server responds 401: 136 | 137 | ```javascript 138 | ({ err, proceedError, replay, getState })=> { 139 | if(err.status === 401) { 140 | refreshAccessToken().then((res)=> { 141 | // here you can pass additional headers if you want 142 | let headers = { 143 | 'x-access-token': res.token, 144 | } 145 | replay({ headers }) 146 | }) 147 | } else { 148 | proceedError() 149 | } 150 | } 151 | ``` 152 | 153 | The code above would do the token refreshing whenever err is 401, 154 | and proceed the original error otherwise. 155 | 156 | #### `generateDefaultParams`(optional) 157 | 158 | A function which takes `({ getState })` and returns an object like this: 159 | 160 | ```javascript 161 | { 162 | headers: { 'x-header-key': 'header-val' }, 163 | query: { queryKey: 'query-val' }, 164 | body: { bodyKey: 'body-val' } 165 | } 166 | ``` 167 | 168 | On each request, the object returned by this function would be merged into the request's `header`, `query`, and `body`, respectively. 169 | 170 | ---- 171 | 172 | ## Usage In Action Creators 173 | 174 | In Action Creators, we can use the following code to send a single request: 175 | 176 | ```javascript 177 | import { CALL_API } from 'redux-api-middleman' 178 | 179 | export const ON_REQUEST_SUCCESS = 'ON_REQUEST_SUCCESS' 180 | export const ON_REQUEST_FAILED = 'ON_REQUEST_FAILED' 181 | export const ON_SENDING_REQUEST = 'ON_SENDING_REQUEST' 182 | 183 | export function getInfo({ username }) { 184 | return { 185 | extraKey: 'extra-val', 186 | 187 | [CALL_API]: { 188 | method: 'get', 189 | path: `/users/${username}/info`, 190 | successType: ON_REQUEST_SUCCESS, 191 | errorType: ON_REQUEST_FAILED, 192 | sendingType: ON_REQUEST_FAILED, 193 | afterSuccess: ({ getState, dispatch, response }) => { 194 | //... 195 | }, 196 | afterError: ({ getState, error })=> { 197 | //... 198 | } 199 | } 200 | } 201 | } 202 | ``` 203 | 204 | In short, just return an action object with `CALL_API`. 205 | 206 | ### Options 207 | 208 | ### method(required) 209 | Http verb to use, can be `get`, `post`, `put` or `del` 210 | 211 | ### path(optional) 212 | Request path to be concated with `baseUrl` 213 | 214 | ### url 215 | Full url of request, will take precedence over `path` and will ignore `baseUrl` 216 | 217 | ### camelizeResponse(optional) 218 | Camelize response keys of the request. default to `true` 219 | 220 | Transform `{ user_name: 'name' }` to `{ userName: 'name' }` 221 | 222 | ### decamelizeRequest(optional) 223 | Decamelize request payload keys. default to `true` 224 | 225 | Transform `{ userName: 'name' }` to `{ user_name: 'name' }` 226 | 227 | ### withCredentials(optional) 228 | Enable Access-Control requests or not. default to `true` 229 | 230 | ### sendingType(optional) 231 | Action type to be dispatched immediately after sending the request 232 | 233 | ### successType(required) 234 | Action type to be dispatched after the API call success 235 | 236 | ### errorType(optional) 237 | Action type to be dispatched after the API call fails 238 | 239 | ### afterSuccess(optional) 240 | A callback function to be invoked after dispatching the action with type `successType`. 241 | `({ getState, dispatch, response })` would be passed into this callback function. 242 | This is a good place to handle request-related side effects such as route pushing. 243 | 244 | ### afterError(optional) 245 | A callback function to be invoked after dispatching the action with type `errorType`. 246 | `({ getState, error })` would be passed into this callback function. 247 | 248 | 249 | ## Sending Chaining Requests 250 | 251 | To send chaining requests, just return an action with `CHAIN_API`-keyed object like this: 252 | 253 | ```javascript 254 | import { CALL_API, CHAIN_API } from 'redux-api-middleman' 255 | 256 | export const ON_REQUEST_SUCCESS1 = 'ON_REQUEST_SUCCESS1' 257 | export const ON_REQUEST_SUCCESS2 = 'ON_REQUEST_SUCCESS2' 258 | 259 | export function getInfo({ username }) { 260 | return { 261 | [CHAIN_API]: [ 262 | ()=> { 263 | return { 264 | extraKey: 'extra-val', 265 | [CALL_API]: { 266 | method: 'get', 267 | path: `/users/${username}/info`, 268 | successType: ON_REQUEST_SUCCESS1 269 | } 270 | } 271 | }, 272 | (responseOfFirstReq)=> { 273 | return { 274 | [CALL_API]: { 275 | method: 'get', 276 | path: `/blogs/${responseOfFirstReq.blogId}`, 277 | successType: ON_REQUEST_SUCCESS2 278 | } 279 | } 280 | } 281 | ] 282 | } 283 | } 284 | ``` 285 | 286 | In the code above, we send an API to `/users/${username}/info` to fetch user info containing a key `blogId`. 287 | After the first request is finished, we then send the second request with the `blogId` returned by server. 288 | 289 | --- 290 | 291 | ## Usage In Reducers 292 | 293 | During the life cycle of an API call, several types of actions would be dispatched: 294 | 295 | ### `sendingType` action 296 | 297 | After the request has been sent, an action of type `sendingType` would be dispatched immediately. 298 | The action would contain the key-val pairs other than `CALL_API` in the action object. 299 | 300 | For example, if our action object looks like this: 301 | 302 | ```javascript 303 | { 304 | extraKey1: 'extra-val-1', 305 | extraKey2: 'extra-val-2', 306 | [CALL_API]: { 307 | ... 308 | } 309 | } 310 | ``` 311 | 312 | then the `sendingType` action would be: 313 | 314 | ```javascript 315 | { 316 | type: sendingType, 317 | extraKey1: 'extra-val-1', 318 | extraKey2: 'extra-val-2' 319 | } 320 | ``` 321 | 322 | ### `successType` action 323 | 324 | After the server responds successfully, an action of type `successType` would be dispatched. 325 | The action would contain: 326 | 327 | - the key-val pairs other than `CALL_API` in the action object 328 | - an extra `response` key, with its value be the server response 329 | 330 | For example, if the server responds with a body like this: 331 | 332 | ```javascript 333 | { 334 | responseKey: 'response-val' 335 | } 336 | ``` 337 | 338 | then the `successType` action would be: 339 | 340 | ```javascript 341 | { 342 | type: successType, 343 | extraKey1: 'extra-val-1', 344 | extraKey2: 'extra-val-2', 345 | response: { 346 | responseKey: 'response-val' 347 | } 348 | } 349 | ``` 350 | 351 | ### `errorType` action 352 | 353 | After the server responds fails, an action of type `errorType` would be dispatched. 354 | The action would contain: 355 | 356 | - the key-val pairs other than `CALL_API` in the action object 357 | - an extra `error` key, with its value be the error object returned by [`axios`](https://github.com/axios/axios) 358 | 359 | # LICENCE: 360 | MIT 361 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-api-middleman", 3 | "version": "3.2.3", 4 | "description": "A Redux middleware making sending request a breeze", 5 | "main": "lib/index.js", 6 | "jest": { 7 | "testEnvironment": "node" 8 | }, 9 | "size-limit": [ 10 | { 11 | "name": "minified", 12 | "path": "src/index.js", 13 | "gzip": false 14 | }, 15 | { 16 | "name": "gzipped", 17 | "path": "src/index.js" 18 | } 19 | ], 20 | "scripts": { 21 | "test": "jest --watch --verbose false", 22 | "test:ci": "yarn lint && jest", 23 | "lint": "standard --fix --verbose | snazzy", 24 | "size": "size-limit", 25 | "size:why": "size-limit --why", 26 | "prebuild": "yarn clear", 27 | "build": "babel src --out-dir lib", 28 | "clear": "rm -rf lib/*", 29 | "release:pre": "yarn build && release pre && npm publish", 30 | "release:patch": "yarn build && release patch && npm publish", 31 | "release:minor": "yarn build && release minor && npm publish", 32 | "release:major": "yarn build && release major && npm publish" 33 | }, 34 | "standard": { 35 | "env": [ 36 | "jest" 37 | ] 38 | }, 39 | "author": { 40 | "name": "YangHsing Lin", 41 | "email": "yanghsing.lin@gmail.com" 42 | }, 43 | "homepage": "https://github.com/CodementorIO/redux-api-middleman", 44 | "license": "ISC", 45 | "devDependencies": { 46 | "@babel/cli": "^7.0.0", 47 | "@babel/core": "^7.0.0", 48 | "@babel/preset-env": "^7.0.0", 49 | "babel-core": "^7.0.0-bridge.0", 50 | "babel-jest": "^24.8.0", 51 | "jest": "^24.8.0", 52 | "mockdate": "^3.0.2", 53 | "nock": "^10.0.6", 54 | "release": "^6.0.1", 55 | "size-limit": "^2.0.2", 56 | "snazzy": "^8.0.0", 57 | "standard": "^13.1.0" 58 | }, 59 | "dependencies": { 60 | "axios": "^0.19.0", 61 | "es6-promise": "^4.2.8", 62 | "humps": "^2.0.1", 63 | "object.omit": "^3.0.0", 64 | "qs": "^6.5.2" 65 | }, 66 | "licence": "MIT" 67 | } 68 | -------------------------------------------------------------------------------- /spec/createRequestPromise.test.js: -------------------------------------------------------------------------------- 1 | import Promise from 'es6-promise' 2 | import { CALL_API } from '../src' 3 | import createRequestPromise from '../src/createRequestPromise' 4 | import axios from 'axios' 5 | import MockDate from 'mockdate' 6 | import * as utils from '../src/utils' 7 | 8 | jest.mock('axios') 9 | jest.mock('../src/log') 10 | 11 | const getMockAxiosPromise = ({ error, config } = {}) => { 12 | return new Promise((resolve, reject) => { 13 | if (error) { 14 | const _error = new Error('Mock error') 15 | _error.config = config 16 | _error.response = { 17 | data: { 18 | key_1: 'val_1' 19 | } 20 | } 21 | process.nextTick(() => reject(_error)) 22 | } else { 23 | process.nextTick(() => resolve({ 24 | data: { 25 | key_1: 'val_1' 26 | } 27 | })) 28 | } 29 | }) 30 | } 31 | 32 | const getLastCall = (mockFunction) => { 33 | return mockFunction.mock.calls[mockFunction.mock.calls.length - 1] 34 | } 35 | 36 | describe('createRequestPromise', () => { 37 | let timeout 38 | let generateDefaultParams 39 | let createCallApiAction 40 | let getState 41 | let dispatch 42 | let errorInterceptor 43 | let extractParams 44 | let maxReplayTimes 45 | let mockApiAction, mockParams, mockDefaultParams 46 | let mockPrevBody 47 | beforeEach(() => { 48 | axios.mockReturnValue(getMockAxiosPromise()) 49 | mockApiAction = { 50 | [CALL_API]: {} 51 | } 52 | mockParams = { 53 | method: 'get', 54 | sendingType: 'sendingType', 55 | camelizeResponse: true 56 | } 57 | mockDefaultParams = { 58 | headers: {}, 59 | body: {}, 60 | query: {} 61 | } 62 | createCallApiAction = jest.fn().mockReturnValue(mockApiAction) 63 | generateDefaultParams = jest.fn().mockReturnValue(mockDefaultParams) 64 | errorInterceptor = jest.fn() 65 | extractParams = jest.fn().mockReturnValue(mockParams) 66 | dispatch = jest.fn() 67 | getState = jest.fn() 68 | mockPrevBody = {} 69 | }) 70 | it('should return a Promise', () => { 71 | const promise = createRequestPromise({ 72 | timeout, 73 | generateDefaultParams, 74 | createCallApiAction, 75 | getState, 76 | dispatch, 77 | errorInterceptor, 78 | extractParams, 79 | maxReplayTimes 80 | })(mockPrevBody) 81 | expect(promise).toBeInstanceOf(Promise) 82 | }) 83 | it('should call axios without `data` key when method is `get`', () => { 84 | mockParams.method = 'get' 85 | createRequestPromise({ 86 | timeout, 87 | generateDefaultParams, 88 | createCallApiAction, 89 | getState, 90 | dispatch, 91 | errorInterceptor, 92 | extractParams, 93 | maxReplayTimes 94 | })(mockPrevBody) 95 | const firstArgument = getLastCall(axios)[0] 96 | expect(firstArgument).not.toHaveProperty('data') 97 | }) 98 | it('should call axios with `data` key when method is `post`', () => { 99 | mockParams.method = 'post' 100 | createRequestPromise({ 101 | timeout, 102 | generateDefaultParams, 103 | createCallApiAction, 104 | getState, 105 | dispatch, 106 | errorInterceptor, 107 | extractParams, 108 | maxReplayTimes 109 | })(mockPrevBody) 110 | const firstArgument = getLastCall(axios)[0] 111 | expect(firstArgument).toHaveProperty('data') 112 | }) 113 | it('should stringify body when `Content-Type = application/x-www-form-urlencoded`', () => { 114 | const params = Object.assign({}, mockParams, { 115 | method: 'post', 116 | body: { 117 | key: 'val' 118 | }, 119 | headers: { 120 | 'Content-Type': 'application/x-www-form-urlencoded' 121 | } 122 | }) 123 | extractParams.mockReturnValueOnce(Object.assign({}, params)) 124 | createRequestPromise({ 125 | timeout, 126 | generateDefaultParams, 127 | createCallApiAction, 128 | getState, 129 | dispatch, 130 | errorInterceptor, 131 | extractParams, 132 | maxReplayTimes 133 | })(mockPrevBody) 134 | const firstArgument = getLastCall(axios)[0] 135 | expect(firstArgument.data).toBe('key=val') 136 | }) 137 | it('should set body to data when `Content-Type = application/json`', () => { 138 | const body = { 139 | key: 'val' 140 | } 141 | const params = Object.assign({}, mockParams, { 142 | method: 'post', 143 | body, 144 | headers: { 145 | 'Content-Type': 'application/json' 146 | } 147 | }) 148 | extractParams.mockReturnValueOnce(Object.assign({}, params)) 149 | createRequestPromise({ 150 | timeout, 151 | generateDefaultParams, 152 | createCallApiAction, 153 | getState, 154 | dispatch, 155 | errorInterceptor, 156 | extractParams, 157 | maxReplayTimes 158 | })(mockPrevBody) 159 | const firstArgument = getLastCall(axios)[0] 160 | expect(firstArgument.data).toEqual(body) 161 | }) 162 | 163 | describe('when axios catches error', () => { 164 | let config 165 | beforeEach(() => { 166 | config = { key: 'value' } 167 | axios.mockReturnValue(getMockAxiosPromise({ error: true, config })) 168 | }) 169 | 170 | it('should call errorInterceptor', async () => { 171 | const errorInterceptor = jest.fn(({ proceedError }) => { 172 | proceedError() 173 | }) 174 | await createRequestPromise({ 175 | timeout, 176 | generateDefaultParams, 177 | createCallApiAction, 178 | getState, 179 | dispatch, 180 | errorInterceptor, 181 | extractParams, 182 | maxReplayTimes 183 | })(mockPrevBody) 184 | .catch(() => { 185 | expect(errorInterceptor).toHaveBeenCalledTimes(1) 186 | expect(errorInterceptor.mock.calls[0][0]).toEqual({ 187 | err: { 188 | config, 189 | data: { 190 | key1: 'val_1' 191 | } 192 | }, 193 | getState, 194 | proceedError: expect.any(Function), 195 | replay: expect.any(Function) 196 | }) 197 | }) 198 | }) 199 | }) 200 | 201 | describe('revalidate behavior', () => { 202 | const currentime = 1579508700000 203 | let path; let testSetCount = 0 204 | 205 | beforeEach(() => { 206 | testSetCount++ 207 | path = `/the-path${testSetCount}` 208 | MockDate.set(currentime) 209 | utils.window = {} 210 | mockParams = { 211 | method: 'get', 212 | path, 213 | sendingType: 'sendingType', 214 | camelizeResponse: true 215 | } 216 | jest.clearAllMocks() 217 | }) 218 | 219 | function createRequest ({ revalidate, revalidateDisabled } = {}) { 220 | extractParams = jest.fn().mockReturnValue({ ...mockParams, revalidate }) 221 | createRequestPromise({ 222 | revalidateDisabled, 223 | timeout, 224 | generateDefaultParams, 225 | createCallApiAction, 226 | getState, 227 | dispatch, 228 | errorInterceptor, 229 | extractParams, 230 | maxReplayTimes 231 | })(mockPrevBody) 232 | } 233 | 234 | it('sends request every calls when revalidate is undefined', async () => { 235 | await createRequest() 236 | expect(axios).toHaveBeenCalled() 237 | 238 | jest.clearAllMocks() 239 | MockDate.set(currentime + (6 * 1000)) 240 | 241 | await createRequest() 242 | expect(axios).toHaveBeenCalled() 243 | }) 244 | 245 | it('sends request only for the first call when revalidate is "never"', async () => { 246 | const revalidate = 'never' 247 | await createRequest({ revalidate }) 248 | expect(axios).toHaveBeenCalled() 249 | 250 | jest.clearAllMocks() 251 | MockDate.set(currentime + (6 * 1000)) 252 | 253 | await createRequest({ revalidate }) 254 | expect(axios).not.toHaveBeenCalled() 255 | }) 256 | 257 | it('always send request if revalidateDisabled = true', async () => { 258 | const revalidateDisabled = true 259 | const revalidate = 'never' 260 | await createRequest({ revalidate, revalidateDisabled }) 261 | expect(axios).toHaveBeenCalled() 262 | 263 | jest.clearAllMocks() 264 | MockDate.set(currentime + (6 * 1000)) 265 | 266 | await createRequest({ revalidate }) 267 | expect(axios).toHaveBeenCalled() 268 | }) 269 | 270 | it('always send request if window does not exist', async () => { 271 | utils.window = null 272 | const revalidate = 'never' 273 | await createRequest({ revalidate }) 274 | expect(axios).toHaveBeenCalled() 275 | 276 | jest.clearAllMocks() 277 | MockDate.set(currentime + (6 * 1000)) 278 | 279 | await createRequest({ revalidate }) 280 | expect(axios).toHaveBeenCalled() 281 | }) 282 | 283 | it('sends request only after revalidate time when revalidate is defined', async () => { 284 | const revalidate = 5 285 | await createRequest({ revalidate }) 286 | expect(axios).toHaveBeenCalled() 287 | 288 | jest.clearAllMocks() 289 | MockDate.set(currentime + (1 * 1000)) 290 | await createRequest({ revalidate }) 291 | expect(axios).not.toHaveBeenCalled() 292 | 293 | jest.clearAllMocks() 294 | MockDate.set(currentime + (3 * 1000)) 295 | await createRequest({ revalidate }) 296 | expect(axios).not.toHaveBeenCalled() 297 | 298 | jest.clearAllMocks() 299 | MockDate.set(currentime + (6 * 1000)) 300 | await createRequest({ revalidate }) 301 | expect(axios).toHaveBeenCalled() 302 | }) 303 | }) 304 | }) 305 | -------------------------------------------------------------------------------- /spec/index.test.js: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | import { camelizeKeys, decamelizeKeys } from 'humps' 3 | 4 | import log from '../src/log' 5 | import createApiMiddleware, { 6 | CALL_API, 7 | CHAIN_API 8 | } from '../src' 9 | 10 | import * as utils from '../src/utils' 11 | 12 | const createRequestPromise = require('../src/createRequestPromise') 13 | 14 | jest.mock('../src/log', () => ({ 15 | error: jest.fn() 16 | })) 17 | 18 | jest.mock('../src/log', () => ({ 19 | error: jest.fn() 20 | })) 21 | 22 | export const BASE_URL = 'http://localhost:3000' 23 | 24 | describe('Middleware::Api', () => { 25 | let apiMiddleware 26 | let dispatch, getState, next 27 | let action 28 | 29 | beforeEach(() => { 30 | apiMiddleware = createApiMiddleware({ baseUrl: BASE_URL }) 31 | dispatch = jest.fn() 32 | getState = jest.fn() 33 | next = jest.fn() 34 | utils.window = {} 35 | }) 36 | 37 | describe('when called with [CHAIN_API]', () => { 38 | const successType1 = 'ON_SUCCESS_1' 39 | const successType2 = 'ON_SUCCESS_2' 40 | const sendingType1 = 'ON_SENDING_1' 41 | const sendingType2 = 'ON_SENDING_2' 42 | const errorType2 = 'ON_ERROR_2' 43 | 44 | let nockScope1, nockScope2 45 | 46 | let afterSuccess1, afterSuccess2 47 | const response1 = { id: 'the-id-1', to_be_camelized: 'snake-val' } 48 | const response2 = { id: 'the-res-2' } 49 | const path1 = '/the-url/path-1' 50 | const path2 = `/the-url/${response1.id}` 51 | 52 | let afterError1 53 | let afterError2 54 | 55 | beforeEach(() => { 56 | afterSuccess1 = jest.fn() 57 | afterSuccess2 = jest.fn() 58 | afterError1 = jest.fn() 59 | afterError2 = jest.fn() 60 | action = { 61 | [CHAIN_API]: [ 62 | () => { 63 | return { 64 | extra1: 'val1', 65 | [CALL_API]: { 66 | method: 'post', 67 | body: { bodyKey: 'body-val' }, 68 | query: decamelizeKeys({ queryKey: 'query-val' }), 69 | path: path1, 70 | afterSuccess: afterSuccess1, 71 | afterError: afterError1, 72 | successType: successType1, 73 | sendingType: sendingType1 74 | } 75 | } 76 | }, 77 | (_resBody1) => { 78 | return { 79 | extra2: 'val2', 80 | [CALL_API]: { 81 | method: 'get', 82 | path: path2, 83 | afterSuccess: afterSuccess2, 84 | afterError: afterError2, 85 | successType: successType2, 86 | sendingType: sendingType2, 87 | errorType: errorType2 88 | } 89 | } 90 | } 91 | ] 92 | } 93 | }) 94 | 95 | function nockRequest1 () { 96 | return nock(BASE_URL).post(path1) 97 | .query(decamelizeKeys({ queryKey: 'query-val' })) 98 | .reply(200, response1) 99 | } 100 | function nockRequest2 (status = 200, payload) { 101 | return nock(BASE_URL).get('/the-url/the-id-1') 102 | .reply(status, payload || response2) 103 | } 104 | 105 | afterEach(() => { 106 | nock.cleanAll() 107 | }) 108 | 109 | describe('when sending GET request', () => { 110 | const host = 'http://get-request-host.com' 111 | const path = '/the-path' 112 | let nockScope 113 | beforeEach(() => { 114 | action = { 115 | [CHAIN_API]: [ 116 | () => { 117 | return { 118 | [CALL_API]: { 119 | url: `${host}${path}`, 120 | method: 'get', 121 | successType: successType1 122 | } 123 | } 124 | } 125 | ] 126 | } 127 | }) 128 | it('does not send body', async () => { 129 | nockScope = nock(host).get(path, body => !body).reply(200, response1) 130 | await apiMiddleware({ dispatch, getState })(next)(action) 131 | nockScope.done() 132 | }) 133 | }) 134 | 135 | describe('when `url` is given in CALL_API', () => { 136 | const host = 'http://another-host.com' 137 | const path = '/the-path' 138 | let nockScope 139 | 140 | beforeEach(() => { 141 | action = { 142 | [CHAIN_API]: [ 143 | () => { 144 | return { 145 | [CALL_API]: { 146 | url: `${host}${path}`, 147 | method: 'get', 148 | successType: successType1 149 | } 150 | } 151 | }] 152 | } 153 | nockScope = nock(host).get(path).reply(200, response1) 154 | }) 155 | it('takes precedence over path', async () => { 156 | await apiMiddleware({ dispatch, getState })(next)(action) 157 | nockScope.done() 158 | }) 159 | }) 160 | 161 | describe('when `camelizeResponse` is false', () => { 162 | const path = '/the-path' 163 | let nockScope 164 | 165 | beforeEach(() => { 166 | action = { 167 | [CHAIN_API]: [ 168 | () => { 169 | return { 170 | [CALL_API]: { 171 | path: `${path}`, 172 | method: 'get', 173 | camelizeResponse: false, 174 | successType: successType1 175 | } 176 | } 177 | }] 178 | } 179 | nockScope = nock(BASE_URL).get(path).reply(200, response1) 180 | }) 181 | it('does not camelize response', async () => { 182 | await apiMiddleware({ dispatch, getState })(next)(action) 183 | expect(dispatch).toBeCalledWith({ 184 | type: successType1, 185 | response: response1 186 | }) 187 | nockScope.done() 188 | }) 189 | }) 190 | 191 | describe('when `decamelizeRequest` is false', () => { 192 | const path = '/the-path' 193 | 194 | beforeEach(() => { 195 | action = { 196 | [CHAIN_API]: [ 197 | () => { 198 | return { 199 | [CALL_API]: { 200 | path, 201 | method: 'post', 202 | body: { camelCase: 'OYOYO' }, 203 | decamelizeRequest: false, 204 | successType: successType1 205 | } 206 | } 207 | }] 208 | } 209 | nock(BASE_URL).post(path, { camelCase: 'OYOYO' }).reply(200, response1) 210 | }) 211 | it('should pass', async () => { 212 | await apiMiddleware({ dispatch, getState })(next)(action) 213 | expect(dispatch).toBeCalledWith({ 214 | type: successType1, 215 | response: camelizeKeys(response1) 216 | }) 217 | }) 218 | }) 219 | 220 | describe('when generateDefaultParams is provided', () => { 221 | const path = '/the-path' 222 | let nockScope 223 | let generateDefaultParams 224 | beforeEach(() => { 225 | generateDefaultParams = jest.fn(() => ({ 226 | body: { additionalBodyKey: 'additionalBodyVal' }, 227 | query: { additionalKey: 'additionalVal' }, 228 | headers: { additionalHeadersKey: 'additionalHeadersVal' } 229 | })) 230 | apiMiddleware = createApiMiddleware({ 231 | baseUrl: BASE_URL, 232 | generateDefaultParams 233 | }) 234 | }) 235 | 236 | beforeEach(() => { 237 | action = { 238 | [CHAIN_API]: [ 239 | () => { 240 | return { 241 | [CALL_API]: { 242 | path: `${path}`, 243 | method: 'post', 244 | body: { bodyKey: 'bodyVal' }, 245 | headers: { headersKey: 'headersVal' }, 246 | successType: successType1 247 | } 248 | } 249 | } 250 | ] 251 | } 252 | 253 | nockScope = nock(BASE_URL) 254 | .matchHeader('additionalHeadersKey', 'additionalHeadersVal') 255 | .matchHeader('headersKey', 'headersVal') 256 | .post(path, decamelizeKeys({ 257 | additionalBodyKey: 'additionalBodyVal', 258 | bodyKey: 'bodyVal' 259 | })) 260 | .query(decamelizeKeys({ 261 | additionalKey: 'additionalVal' 262 | })) 263 | .reply(200, response1) 264 | }) 265 | it('merge generateDefaultParams into request', async () => { 266 | await apiMiddleware({ dispatch, getState })(next)(action) 267 | expect(dispatch).toBeCalledWith({ 268 | type: successType1, 269 | response: camelizeKeys(response1) 270 | }) 271 | nockScope.done() 272 | }) 273 | }) 274 | 275 | describe('when all API calls are success', () => { 276 | beforeEach(() => { 277 | nockScope1 = nockRequest1() 278 | nockScope2 = nockRequest2() 279 | }) 280 | 281 | it('sends requests to all endpoints', async () => { 282 | await apiMiddleware({ dispatch, getState })(next)(action) 283 | nockScope1.done() 284 | nockScope2.done() 285 | }) 286 | 287 | it('trigger afterSuccess for all endpoints', async () => { 288 | await apiMiddleware({ dispatch, getState })(next)(action) 289 | expect(afterSuccess1).toBeCalledWith({ 290 | getState, dispatch, response: camelizeKeys(response1) 291 | }) 292 | expect(afterSuccess2).toBeCalledWith({ 293 | getState, dispatch, response: camelizeKeys(response2) 294 | }) 295 | }) 296 | 297 | it('only catches request error', async () => { 298 | afterSuccess1.mockImplementation(() => { 299 | throw new Error('error casued by afterSuccess') 300 | }) 301 | await apiMiddleware({ dispatch, getState })(next)(action) 302 | expect(afterError1).not.toBeCalled() 303 | expect(afterSuccess1).toBeCalled() 304 | expect(log.error).toBeCalled() 305 | }) 306 | it('trigger sendintType for all endpoints', async () => { 307 | await apiMiddleware({ dispatch, getState })(next)(action) 308 | expect(dispatch).toBeCalledWith({ type: sendingType1, extra1: 'val1' }) 309 | expect(dispatch).toBeCalledWith({ type: sendingType2, extra2: 'val2' }) 310 | }) 311 | 312 | it('dispatch successType for all endpoints', async () => { 313 | await apiMiddleware({ dispatch, getState })(next)(action) 314 | expect(dispatch).toBeCalledWith({ 315 | type: successType1, response: camelizeKeys(response1), extra1: 'val1' 316 | }) 317 | expect(dispatch).toBeCalledWith({ 318 | type: successType2, response: camelizeKeys(response2), extra2: 'val2' 319 | }) 320 | }) 321 | }) 322 | 323 | describe('when one of the apis timeout', () => { 324 | const timeout = 50 325 | const host = 'http://another-host.com' 326 | const path = '/the-path' 327 | const timeoutErrorType = 'TIMEOUT_ERROR' 328 | let nockScope 329 | let dispatchedAction 330 | 331 | beforeEach(() => { 332 | dispatch = (a) => { 333 | dispatchedAction = a 334 | } 335 | apiMiddleware = createApiMiddleware({ 336 | baseUrl: BASE_URL, 337 | timeout 338 | }) 339 | action = { 340 | [CHAIN_API]: [ 341 | () => { 342 | return { 343 | [CALL_API]: { 344 | url: `${host}${path}`, 345 | method: 'get', 346 | errorType: timeoutErrorType 347 | } 348 | } 349 | } 350 | ] 351 | } 352 | nockScope = nock(host).get(path).delay(timeout + 1).reply(200) 353 | }) 354 | 355 | it('dispatch error when timeout', async () => { 356 | await apiMiddleware({ dispatch, getState })(next)(action) 357 | expect(dispatchedAction.type).toEqual(timeoutErrorType) 358 | nockScope.done() 359 | }) 360 | }) 361 | 362 | describe('when one of the apis failed', () => { 363 | let errorPayload 364 | beforeEach(() => { 365 | errorPayload = { 366 | data: { 367 | AAA: 'AAAAAAAAAA' 368 | } 369 | } 370 | nockScope1 = nockRequest1() 371 | nockScope2 = nockRequest2(400, errorPayload) 372 | }) 373 | 374 | it('sends request until it is failed', async () => { 375 | await apiMiddleware({ getState, dispatch })(next)(action) 376 | nockScope1.done() 377 | nockScope2.done() 378 | }) 379 | 380 | it('triggers afterSuccess and dispatches success for the ok ones', async () => { 381 | await apiMiddleware({ dispatch, getState })(next)(action) 382 | expect(dispatch).toBeCalledWith({ 383 | extra1: 'val1', 384 | type: successType1, 385 | response: camelizeKeys(response1) 386 | }) 387 | expect(afterSuccess1).toBeCalledWith({ 388 | getState, dispatch, response: camelizeKeys(response1) 389 | }) 390 | }) 391 | 392 | it('trigger afterError of path2', async () => { 393 | await apiMiddleware({ dispatch, getState })(next)(action) 394 | expect(afterError2).toBeCalledWith({ 395 | getState, 396 | dispatch, 397 | error: expect.objectContaining({ 398 | data: camelizeKeys(errorPayload) 399 | }) 400 | }) 401 | }) 402 | 403 | it('dispatches errorType of path2', async () => { 404 | let dispatchedAction 405 | dispatch = (a) => { 406 | dispatchedAction = a 407 | } 408 | await apiMiddleware({ dispatch, getState })(next)(action) 409 | expect(dispatchedAction.type).toEqual(errorType2) 410 | expect(dispatchedAction.error.status).toEqual(400) 411 | }) 412 | 413 | it('dispatches errorType with backward compatible error payload', async () => { 414 | let dispatchedAction 415 | dispatch = (a) => { 416 | dispatchedAction = a 417 | } 418 | await apiMiddleware({ dispatch, getState })(next)(action) 419 | expect(dispatchedAction.type).toEqual(errorType2) 420 | expect(dispatchedAction.error.data).toEqual(camelizeKeys(errorPayload)) 421 | }) 422 | 423 | describe('errorInterceptor behaviors', () => { 424 | it('handles dispatch and rejection stuff via `proceedError`', async () => { 425 | const spy = jest.fn() 426 | let dispatchedAction 427 | dispatch = (a) => { 428 | dispatchedAction = a 429 | } 430 | apiMiddleware = createApiMiddleware({ 431 | baseUrl: BASE_URL, 432 | errorInterceptor: ({ proceedError, err, replay, getState }) => { 433 | spy() 434 | expect(getState).toEqual(getState) 435 | proceedError() 436 | } 437 | }) 438 | await apiMiddleware({ dispatch, getState })(next)(action) 439 | expect(spy).toBeCalled() 440 | expect(dispatchedAction.type).toEqual(errorType2) 441 | expect(dispatchedAction.error.status).toEqual(400) 442 | }) 443 | 444 | describe('replay', () => { 445 | function repeat (times, fn) { 446 | for (var i = 0; i < times; i += 1) { 447 | fn() 448 | } 449 | } 450 | it('resend the request', async () => { 451 | nockRequest2(400) 452 | let errTime = 0 453 | apiMiddleware = createApiMiddleware({ 454 | baseUrl: BASE_URL, 455 | errorInterceptor: ({ proceedError, err, replay, getState }) => { 456 | if (errTime === 1) { 457 | proceedError() 458 | } else { 459 | replay() 460 | errTime++ 461 | } 462 | } 463 | }) 464 | 465 | await apiMiddleware({ dispatch, getState })(next)(action) 466 | expect(errTime).toEqual(1) 467 | }) 468 | it('replay no more than `maxReplayTimes`', async () => { 469 | let replayTimes = 0 470 | const maxReplayTimes = 6 471 | let dispatchedAction 472 | repeat(6, () => nockRequest2(400)) 473 | dispatch = (a) => { 474 | dispatchedAction = a 475 | } 476 | apiMiddleware = createApiMiddleware({ 477 | baseUrl: BASE_URL, 478 | maxReplayTimes, 479 | errorInterceptor: ({ proceedError, replay, _getState }) => { 480 | replayTimes++ 481 | replay() 482 | } 483 | }) 484 | await apiMiddleware({ dispatch, getState })(next)(action) 485 | expect(replayTimes).toEqual(6) 486 | expect(dispatchedAction.type).toEqual(errorType2) 487 | expect(dispatchedAction.error).toBeInstanceOf(Error) 488 | expect(dispatchedAction.error.message).toEqual( 489 | `reached MAX_REPLAY_TIMES = ${maxReplayTimes}` 490 | ) 491 | }) 492 | }) 493 | }) 494 | }) 495 | }) 496 | 497 | describe('when action is without CALL_API and CHAIN_API', () => { 498 | it('passes the action to next middleware', async () => { 499 | const nextRetResult = {} 500 | next.mockReturnValue(nextRetResult) 501 | action = { type: 'not-CALL_API' } 502 | const result = await apiMiddleware({ dispatch, getState })(next)(action) 503 | 504 | expect(next).toBeCalledWith(action) 505 | expect(result).toEqual(nextRetResult) 506 | }) 507 | }) 508 | 509 | describe('when action is with `CALL_API`', () => { 510 | const successType = 'ON_SUCCESS' 511 | const path = '/the-url/path' 512 | let dispatchedAction 513 | 514 | beforeEach(() => { 515 | dispatch = function (a) { 516 | dispatchedAction = a 517 | } 518 | action = { 519 | [CALL_API]: { 520 | method: 'get', 521 | path, 522 | successType 523 | } 524 | } 525 | }) 526 | it('forwards it to CHAIN_API as a special case', async () => { 527 | await apiMiddleware({ dispatch, getState })(next)(action) 528 | expect(dispatchedAction[CHAIN_API].length).toEqual(1) 529 | expect(dispatchedAction[CHAIN_API][0]()).toEqual(action) 530 | }) 531 | }) 532 | 533 | describe('revalidateDisabled behavior', () => { 534 | beforeEach(() => { 535 | jest.spyOn(createRequestPromise, 'default').mockReturnValue(jest.fn()) 536 | jest.clearAllMocks() 537 | }) 538 | it('calls createRequestPromise with revalidateDisabled = true if action.revalidateDisabled = true', async () => { 539 | const action = { 540 | revalidateDisabled: true, 541 | [CHAIN_API]: [ 542 | () => ({ [CALL_API]: {} }) 543 | ] 544 | } 545 | await apiMiddleware({ dispatch, getState })(next)(action) 546 | expect(createRequestPromise.default.mock.calls[0][0].revalidateDisabled).toBe(true) 547 | }) 548 | it('calls createRequestPromise with revalidateDisabled = false if action.revalidateDisabled = false', async () => { 549 | const action = { 550 | revalidateDisabled: false, 551 | [CHAIN_API]: [ 552 | () => ({ [CALL_API]: {} }) 553 | ] 554 | } 555 | await apiMiddleware({ dispatch, getState })(next)(action) 556 | expect(createRequestPromise.default.mock.calls[0][0].revalidateDisabled).toBe(false) 557 | }) 558 | it('dispatches CHAIN_API with revalidateDisabled = true if action type is CALL_API', async () => { 559 | const action = { [CHAIN_API]: [ 560 | () => ({ [CALL_API]: {} }) 561 | ] } 562 | await apiMiddleware({ dispatch, getState })(next)(action) 563 | expect(createRequestPromise.default.mock.calls[0][0].revalidateDisabled).toBe(true) 564 | }) 565 | }) 566 | }) 567 | -------------------------------------------------------------------------------- /spec/utils.test.js: -------------------------------------------------------------------------------- 1 | import { paramsExtractor, actionWith } from '../src/utils' 2 | import { CALL_API } from '../src' 3 | 4 | describe('Utils', () => { 5 | describe('#paramsExtractor', () => { 6 | let params 7 | const baseUrl = 'http://base' 8 | const callApi = { 9 | path: '/path' 10 | } 11 | beforeEach(() => { 12 | params = paramsExtractor({ baseUrl })(callApi) 13 | }) 14 | it('sets `url` with prefix baseUrl', () => { 15 | expect(params.url).toEqual( 16 | `${baseUrl}${callApi.path}` 17 | ) 18 | }) 19 | it('defaults to set withCredentials to ture', () => { 20 | expect(params.withCredentials).toEqual(true) 21 | }) 22 | }) 23 | 24 | describe('#actionWith', () => { 25 | it('removes CALL_API and merges payload', () => { 26 | const action = { 27 | extra: 'extra', 28 | [CALL_API]: { 29 | path: 'path' 30 | } 31 | } 32 | const payload = { 33 | type: 'type' 34 | } 35 | expect(actionWith(action, payload)).toEqual({ 36 | extra: 'extra', 37 | ...payload 38 | }) 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/createRequestPromise.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import Promise from 'es6-promise' 3 | import omit from 'object.omit' 4 | import { camelizeKeys, decamelizeKeys } from 'humps' 5 | 6 | import { CALL_API } from './' 7 | import { actionWith, generateBody, window } from './utils' 8 | import log from './log' 9 | 10 | function isFunction (v) { 11 | return typeof v === 'function' 12 | } 13 | const lastRevalidateTimeMap = {} 14 | 15 | export default function ({ 16 | timeout, 17 | generateDefaultParams, 18 | createCallApiAction, 19 | getState, 20 | dispatch, 21 | errorInterceptor, 22 | extractParams, 23 | maxReplayTimes, 24 | revalidateDisabled = false 25 | }) { 26 | return (prevBody) => { 27 | const apiAction = createCallApiAction(prevBody) 28 | const params = extractParams(apiAction[CALL_API]) 29 | let replayTimes = 0 30 | 31 | const now = Math.floor(new Date().getTime() / 1000) 32 | if (!!params.revalidate && !!window && !revalidateDisabled) { 33 | const revalidationKey = _getRevalidationKey(params) 34 | const lastRevalidateTime = lastRevalidateTimeMap[revalidationKey] || 0 35 | if (params.revalidate === 'never' && !!lastRevalidateTime) { 36 | return () => Promise.resolve() 37 | } 38 | if (Number.isInteger(params.revalidate)) { 39 | const shouldNotRevalidate = (now - lastRevalidateTime) < params.revalidate 40 | if (shouldNotRevalidate) { 41 | return () => Promise.resolve() 42 | } 43 | } 44 | lastRevalidateTimeMap[revalidationKey] = now 45 | } 46 | 47 | return new Promise((resolve, reject) => { 48 | function sendRequest (interceptorParams = {}) { 49 | if (params.sendingType) { 50 | dispatch(actionWith(apiAction, { type: params.sendingType })) 51 | } 52 | 53 | const defaultParams = getExtendedParams() 54 | 55 | let queryObject = Object.assign({}, defaultParams.query, params.query) 56 | let sendObject = Object.assign({}, defaultParams.body, params.body) 57 | const headersObject = Object.assign({}, 58 | defaultParams.headers, 59 | params.headers, 60 | interceptorParams.headers 61 | ) 62 | 63 | if (params.decamelizeRequest) { 64 | queryObject = decamelizeKeys(queryObject) 65 | sendObject = decamelizeKeys(sendObject) 66 | } 67 | 68 | const omitKeys = params.method.toLowerCase() === 'get' ? ['data'] : [] 69 | 70 | const config = omit({ 71 | headers: headersObject, 72 | method: params.method, 73 | url: params.url, 74 | params: queryObject, 75 | data: generateBody({ headersObject, sendObject }), 76 | withCredentials: params.withCredentials, 77 | timeout 78 | }, omitKeys) 79 | 80 | axios(config) 81 | .then((res) => { 82 | const resBody = params.camelizeResponse ? camelizeKeys(res.data) : res.data 83 | dispatchSuccessType(resBody) 84 | processAfterSuccess(resBody) 85 | resolve(resBody) 86 | }).catch((error) => { 87 | // https://github.com/axios/axios#handling-errors 88 | const serverError = !!error.response || !!error.request 89 | 90 | if (!serverError) { 91 | return handleOperationError(error) 92 | } 93 | 94 | if (replayTimes === maxReplayTimes) { 95 | return handleError( 96 | new Error(`reached MAX_REPLAY_TIMES = ${maxReplayTimes}`) 97 | ) 98 | } 99 | 100 | const err = prepareErrorPayload({ error, camelize: params.camelizeResponse }) 101 | replayTimes += 1 102 | errorInterceptor({ 103 | proceedError: () => handleError(err), 104 | err, 105 | getState, 106 | replay: sendRequest 107 | }) 108 | }) 109 | } 110 | 111 | sendRequest() 112 | 113 | function handleOperationError (error) { 114 | log.error(error) 115 | reject(error) 116 | } 117 | 118 | function prepareErrorPayload ({ error, camelize }) { 119 | const res = error.response || {} 120 | res.config = error.config 121 | if (camelize) { 122 | res.data = camelizeKeys(res.data) 123 | } 124 | return res 125 | } 126 | 127 | function handleError (err) { 128 | dispatchErrorType(err) 129 | processAfterError(err) 130 | reject(err) 131 | } 132 | 133 | function dispatchErrorType (error) { 134 | if (params.errorType) { 135 | dispatch(actionWith(apiAction, { 136 | type: params.errorType, 137 | error 138 | })) 139 | } 140 | } 141 | function processAfterError (error) { 142 | if (isFunction(params.afterError)) { 143 | params.afterError({ getState, dispatch, error }) 144 | } 145 | } 146 | function dispatchSuccessType (resBody) { 147 | dispatch(actionWith(apiAction, { 148 | type: params.successType, 149 | response: resBody 150 | })) 151 | } 152 | function processAfterSuccess (response) { 153 | if (isFunction(params.afterSuccess)) { 154 | params.afterSuccess({ getState, dispatch, response }) 155 | } 156 | } 157 | function getExtendedParams () { 158 | let { headers, body, query } = generateDefaultParams({ getState }) 159 | headers = headers || {} 160 | body = body || {} 161 | query = query || {} 162 | return { headers, body, query } 163 | } 164 | }) 165 | } 166 | } 167 | 168 | function _getRevalidationKey (actionObj) { 169 | const { 170 | method, 171 | path, 172 | url, 173 | params, 174 | data 175 | } = actionObj 176 | return JSON.stringify({ 177 | method, 178 | path, 179 | url, 180 | params, 181 | data 182 | }) 183 | } 184 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Promise from 'es6-promise' 2 | import createRequestPromise from './createRequestPromise' 3 | import { paramsExtractor } from './utils' 4 | 5 | export const CALL_API = Symbol('CALL_API') 6 | export const CHAIN_API = Symbol('CHAIN_API') 7 | export const DEFAULT_MAX_REPLAY_TIMES = 2 8 | export const DEFAULT_TIMEOUT = 20000 // ms 9 | 10 | const defaultInterceptor = function ({ proceedError, err, replay, getState }) { 11 | proceedError() 12 | } 13 | const noopDefaultParams = () => { 14 | return {} 15 | } 16 | 17 | export default ({ 18 | baseUrl, 19 | timeout = DEFAULT_TIMEOUT, 20 | errorInterceptor = defaultInterceptor, 21 | generateDefaultParams = noopDefaultParams, 22 | maxReplayTimes = DEFAULT_MAX_REPLAY_TIMES 23 | }) => { 24 | const extractParams = paramsExtractor({ baseUrl }) 25 | 26 | return ({ dispatch, getState }) => next => action => { 27 | if (action[CALL_API]) { 28 | return dispatch({ 29 | revalidateDisabled: false, 30 | [CHAIN_API]: [ 31 | () => action 32 | ] 33 | }) 34 | } 35 | 36 | if (!action[CHAIN_API]) { 37 | return next(action) 38 | } 39 | 40 | return new Promise((resolve, reject) => { 41 | const promiseCreators = action[CHAIN_API].map((createCallApiAction) => { 42 | return createRequestPromise({ 43 | revalidateDisabled: action.revalidateDisabled === undefined ? true : action.revalidateDisabled, 44 | timeout, 45 | generateDefaultParams, 46 | createCallApiAction, 47 | getState, 48 | dispatch, 49 | errorInterceptor, 50 | extractParams, 51 | maxReplayTimes 52 | }) 53 | }) 54 | 55 | const overall = promiseCreators.reduce((promise, createReqPromise) => { 56 | return promise.then(createReqPromise) 57 | }, Promise.resolve()) 58 | 59 | overall.finally(resolve).catch(reject) 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/log.js: -------------------------------------------------------------------------------- 1 | 2 | export default console 3 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import qs from 'qs' 2 | import { CALL_API } from './' 3 | 4 | export const log = console 5 | 6 | export function actionWith (action, toMerge) { 7 | const { [CALL_API]: api, ...extra } = action 8 | return { 9 | ...extra, 10 | ...toMerge 11 | } 12 | } 13 | 14 | function _isUrlencodedContentType (headersObject) { 15 | const contentTypeKey = Object.keys(headersObject).find( 16 | key => key.toLowerCase() === 'content-type' 17 | ) 18 | if (!contentTypeKey) { 19 | return false 20 | } 21 | return headersObject[contentTypeKey] === 'application/x-www-form-urlencoded' 22 | } 23 | 24 | export function generateBody ({ headersObject, sendObject }) { 25 | const isUrlencoded = _isUrlencodedContentType(headersObject) 26 | return isUrlencoded ? qs.stringify(sendObject) : sendObject 27 | } 28 | 29 | export function paramsExtractor ({ baseUrl }) { 30 | return (callApi) => { 31 | let { 32 | method, 33 | path, 34 | query, 35 | body, 36 | headers, 37 | url, 38 | revalidate, 39 | camelizeResponse = true, 40 | decamelizeRequest = true, 41 | withCredentials = true, 42 | successType, 43 | sendingType, 44 | errorType, 45 | afterSuccess, 46 | afterError 47 | } = callApi 48 | 49 | url = url || `${baseUrl}${path}` 50 | 51 | return { 52 | method, 53 | url, 54 | query, 55 | body, 56 | headers, 57 | successType, 58 | sendingType, 59 | errorType, 60 | afterSuccess, 61 | revalidate, 62 | camelizeResponse, 63 | decamelizeRequest, 64 | withCredentials, 65 | afterError 66 | } 67 | } 68 | } 69 | 70 | const _window = typeof window === 'undefined' ? null : window 71 | 72 | export { _window as window } 73 | --------------------------------------------------------------------------------