├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── AUTHORS.txt ├── HISTORY.md ├── LICENSE.txt ├── README.md ├── lib ├── itemStatus.js └── reduxRest.js ├── package.json ├── scripts ├── build ├── lint ├── test └── test-cov ├── src ├── itemStatus.js └── reduxRest.js └── test ├── ActionCreators.spec.js ├── ActionTypes.spec.js ├── CollectionReducer.spec.js ├── Endpoint.spec.js ├── Flux.spec.js ├── ItemReducer.spec.js └── ReduxIntegration.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015" 4 | ], 5 | "plugins": [ 6 | "transform-class-properties", 7 | "transform-object-rest-spread", 8 | "transform-decorators" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb", 3 | "env": { 4 | "browser": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "react/jsx-uses-react": 2, 10 | "react/jsx-uses-vars": 2, 11 | "react/react-in-jsx-scope": 2, 12 | 13 | // Temporarily disabled for test/* until babel/babel-eslint#33 is resolved 14 | "padded-blocks": 0, 15 | }, 16 | "plugins": [ 17 | "react" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | node_modules 3 | coverage 4 | npm-debug.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kvoti/redux-rest/a733ba8a55f7f0a3b8428db27e023a74eec3b016/.npmignore -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "iojs" -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Mark Allison https://github.com/mallison 2 | Bhoomit https://github.com/bhoomit 3 | Jimbo Freedman https://github.com/jimbofreedman 4 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | ## 0.0.1-alpha.9 4 | 5 | - Rename main class from Flux to API 6 | - Remove all mentions of Flux 7 | - Require thunk middleware 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) <2015> 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Kvoti/redux-rest.svg?branch=master)](https://travis-ci.org/Kvoti/redux-rest) 2 | 3 | # redux-rest 4 | 5 | **NOTE this requires redux 1.0.0-rc or above** 6 | 7 | **NOTE POST/PUT requests currently tied to Django Rest Framework's CSRF handling and response content** 8 | 9 | Create Redux action constants, action creators and reducers for your 10 | REST API with no boilerplate. 11 | 12 | ## Install 13 | ``` 14 | npm install redux-rest 15 | ``` 16 | 17 | ## Example 18 | ```js 19 | import React from 'react'; 20 | import { connect, Provider } from 'react-redux'; 21 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 22 | import thunkMiddleware from 'redux-thunk'; 23 | import API from 'redux-rest'; 24 | 25 | 26 | // This is a super simple app that displays a list of users from an API and 27 | // lets you add new users. Until a success response is recevied from the API 28 | // endpoint new users are show as pending. 29 | 30 | // To create a store with redux for this app all you have to do is 31 | // describe your API endpoints as key value pairs where the key is an 32 | // identifier and the value is the URL of the endpoint. 33 | const myAPI = { 34 | users: '/api/users/' 35 | }; 36 | 37 | // Then create an API instance. This automatically creates 38 | // action creators and reducers for each endpoint. No boilerplate! 39 | const api = new API(myAPI); 40 | 41 | // UserApp uses the api object to fetch the users and create new ones 42 | // using the automatically created action creators. 43 | class UserApp extends React.component { 44 | 45 | componentDidMount() { 46 | // Request the list of users when this component mounts 47 | this.props.dispatch(api.actionCreators.users.list()); 48 | } 49 | 50 | render() { 51 | let users = this.props.users; 52 | let pendingUsers = users.filter(u => u.status === 'pending'); 53 | let currentUsers = users.filter(u => u.status !== 'pending'); 54 | return ( 55 |
56 | {pendingUsers.map(user =>

Saving {user.username}...

)} 57 | 60 | 61 | 62 |
63 | ); 64 | } 65 | 66 | _addUser() { 67 | let inputNode = React.findDOMNode(this.refs.username); 68 | let val = inputNode.value; 69 | this.props.dispatch( 70 | api.actionCreators.users.create( 71 | {username: val} 72 | ) 73 | ); 74 | inputNode.val = ''; 75 | } 76 | } 77 | 78 | // The api object also has reducers to handle the standard REST actions 79 | // So we can configure redux and connect our UserApp to it. 80 | let reducers = combineReducers(api.reducers); 81 | 82 | // To integrate with redux we require the thunk middleware to handle 83 | // action creators that return functions. 84 | let createStoreWithMiddleware = applyMiddleware( 85 | thunkMiddleware 86 | )(createStore); 87 | 88 | let store = createStoreWithMiddleware(reducers); 89 | 90 | // Which props do we want to inject, given the global state? 91 | function select(state) { 92 | // Each endpoint has an _items and _collection reducer. Here we only need 93 | // the user items so we only pull out users_items. 94 | return { 95 | users: state.users_items 96 | }; 97 | }) 98 | 99 | // Wrap UserApp to inject dispatch and state into it 100 | UserApp = connect(select)(UserApp); 101 | 102 | export default class App extends React.Component { 103 | render() { 104 | // To render UserApp we need to wrap it in redux's Provider. 105 | return ( 106 | 107 | {() => } 108 | 109 | ); 110 | } 111 | } 112 | ``` 113 | 114 | ## What is the api object? 115 | 116 | The api object encapsulates common patterns when dealing with REST APIs. 117 | 118 | When created with a description of your API you can call all the actions you'd 119 | expect and there are reducers that automatically handle those actions, including 120 | 'pending', 'success' and 'failure' states. 121 | 122 | ```js 123 | import API from 'redux-rest'; 124 | 125 | const myAPI = { 126 | users: '/api/users/', 127 | } 128 | 129 | const api = new API(myAPI); 130 | ``` 131 | 132 | This creates a pair of reducers for each API endpoint; a _collection_ 133 | reducer to handle actions at the collection level and and _item_ 134 | reducer to handle actions on individual items. 135 | 136 | TODO not sure about the item/collection stuff. Needs a rethink. 137 | 138 | Calling actions is as simple as 139 | 140 | ```js 141 | api.actionCreators.users.create(userData); 142 | ``` 143 | 144 | ### Status of API requests 145 | 146 | Each action creator triggers an API request and immediately dispatches 147 | an action so the UI can reflect the change straight away. During the 148 | request the state change is marked as pending. For example, creating a 149 | new object, 150 | 151 | ```js 152 | api.actionCreators.users.create({username: 'mark'}); 153 | ``` 154 | 155 | will add, 156 | 157 | ```js 158 | { 159 | username: 'mark', 160 | status: 'pending' 161 | } 162 | ``` 163 | 164 | to the state. 165 | 166 | TODO what if 'status' is already a field of user? 167 | 168 | On completion of the request the status is updated to ```saved``` or 169 | ```failed``` as appropriate. E.g. 170 | 171 | ```js 172 | { 173 | username: 'mark', 174 | status: 'saved' 175 | } 176 | ``` 177 | 178 | ### Available actions 179 | 180 | The standard set of REST actions is available; ```list```, 181 | ```retrieve```, ```create``` and ```update```. 182 | 183 | ## TODO 184 | - add `delete` action 185 | - add a `revert` action to revert optimistic changes if API request 186 | fails. 187 | - support APIs with custom endpoints 188 | - integrate normalizr for flat object storage? 189 | -------------------------------------------------------------------------------- /lib/itemStatus.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | // Status values for items added/changed/removed via API request 7 | var itemStatus = { 8 | pending: 'pending', 9 | saved: 'saved', 10 | failed: 'failed' 11 | }; 12 | 13 | exports.default = itemStatus; -------------------------------------------------------------------------------- /lib/reduxRest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.CollectionReducer = exports.ItemReducer = exports.ActionCreators = exports.ActionTypes = exports.Endpoint = exports.asyncDispatch = exports.itemStatus = undefined; 7 | 8 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 9 | 10 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); /** 11 | * Utility class to automatically create Redux reducers for REST API endpoints. 12 | */ 13 | // TODO make ajax library pluggable 14 | 15 | 16 | var _superagent = require('superagent'); 17 | 18 | var _superagent2 = _interopRequireDefault(_superagent); 19 | 20 | var _itemStatus = require('./itemStatus'); 21 | 22 | var _itemStatus2 = _interopRequireDefault(_itemStatus); 23 | 24 | require('core-js/fn/array/find-index'); 25 | 26 | require('core-js/fn/array/find'); 27 | 28 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 29 | 30 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 31 | 32 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 33 | 34 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 35 | 36 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 37 | 38 | exports.itemStatus = _itemStatus2.default; 39 | var asyncDispatch = exports.asyncDispatch = function asyncDispatch(store) { 40 | return function (next) { 41 | return function (action) { 42 | return typeof action === 'function' ? action(store.dispatch, store.getState) : next(action); 43 | }; 44 | }; 45 | }; 46 | 47 | var Endpoint = exports.Endpoint = function () { 48 | function Endpoint(url) { 49 | var _ref = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; 50 | 51 | var _ref$withCSRF = _ref.withCSRF; 52 | var withCSRF = _ref$withCSRF === undefined ? false : _ref$withCSRF; 53 | var _ref$CSRFHeaderName = _ref.CSRFHeaderName; 54 | var CSRFHeaderName = _ref$CSRFHeaderName === undefined ? 'X-CSRFToken' : _ref$CSRFHeaderName; 55 | var _ref$CSRFCookieName = _ref.CSRFCookieName; 56 | var CSRFCookieName = _ref$CSRFCookieName === undefined ? 'csrftoken' : _ref$CSRFCookieName; 57 | var setHeaders = _ref.setHeaders; 58 | 59 | _classCallCheck(this, Endpoint); 60 | 61 | this.withCSRF = withCSRF; 62 | this.CSRFHeaderName = CSRFHeaderName; 63 | this.CSRFCookieName = CSRFCookieName; 64 | this.setHeaders = setHeaders; 65 | this.url = url; 66 | } 67 | 68 | _createClass(Endpoint, [{ 69 | key: 'list', 70 | value: function list(params) { 71 | return this._prepareRequest(_superagent2.default.get(this.url)).query(params); 72 | } 73 | }, { 74 | key: 'retrieve', 75 | value: function retrieve(id) { 76 | return this._prepareRequest(_superagent2.default.get(this._getObjectURL(id))); 77 | } 78 | }, { 79 | key: 'create', 80 | value: function create(conf) { 81 | return this._prepareRequest(_superagent2.default.post(this.url)).send(conf); 82 | } 83 | }, { 84 | key: 'update', 85 | value: function update(conf, id) { 86 | return this._prepareRequest(_superagent2.default.put(this._getObjectURL(id))).send(conf); 87 | } 88 | }, { 89 | key: '_prepareRequest', 90 | value: function _prepareRequest(request) { 91 | if (this.setHeaders) { 92 | request = this.setHeaders(request); 93 | } 94 | request = this._setCSRFHeader(request); 95 | return request; 96 | } 97 | }, { 98 | key: '_getObjectURL', 99 | value: function _getObjectURL(id) { 100 | var slash = ''; 101 | if (!this.url.endsWith('/')) { 102 | slash = '/'; 103 | } 104 | return '' + this.url + slash + id; 105 | } 106 | }, { 107 | key: '_setCSRFHeader', 108 | value: function _setCSRFHeader(request) { 109 | if (!this.withCSRF) { 110 | return request; 111 | } 112 | if (!this._csrfSafeMethod(request.method)) { 113 | // && !this.crossDomain) { 114 | request.set(this.CSRFHeaderName, this._getCookie(this.CSRFCookieName)); 115 | } 116 | return request; 117 | } 118 | 119 | // Set csrf token for ajax requests 120 | // See https://docs.djangoproject.com/en/dev/ref/csrf/#ajax 121 | 122 | }, { 123 | key: '_getCookie', 124 | value: function _getCookie(name) { 125 | var cookieValue = null; 126 | if (document.cookie && document.cookie !== '') { 127 | var cookies = document.cookie.split(';'); 128 | for (var i = 0; i < cookies.length; i++) { 129 | var cookie = cookies[i].trim(); 130 | // Does this cookie string begin with the name we want? 131 | if (cookie.substring(0, name.length + 1) === name + '=') { 132 | cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); 133 | break; 134 | } 135 | } 136 | } 137 | return cookieValue; 138 | } 139 | }, { 140 | key: '_csrfSafeMethod', 141 | value: function _csrfSafeMethod(method) { 142 | // these HTTP methods do not require CSRF protection 143 | return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method) 144 | ); 145 | } 146 | }]); 147 | 148 | return Endpoint; 149 | }(); 150 | 151 | var ActionTypes = exports.ActionTypes = function () { 152 | function ActionTypes(endpointName) { 153 | var _this = this; 154 | 155 | _classCallCheck(this, ActionTypes); 156 | 157 | this.endpointName = endpointName; 158 | ['list', 'retrieve', 'create', 'update'].forEach(function (action) { 159 | _this['' + action] = _this.getConstant(action); 160 | ['success', 'failure'].forEach(function (result) { 161 | _this[action + '_' + result] = _this.getConstant(action, result); 162 | }); 163 | }); 164 | } 165 | 166 | _createClass(ActionTypes, [{ 167 | key: 'getConstant', 168 | value: function getConstant(action, result) { 169 | var constant = this.endpointName + '_' + action; 170 | if (result) { 171 | constant = constant + '_' + result; 172 | } 173 | return constant; 174 | } 175 | }]); 176 | 177 | return ActionTypes; 178 | }(); 179 | 180 | var ActionCreators = exports.ActionCreators = function () { 181 | function ActionCreators(endpointName, API, actionTypes) { 182 | var _this2 = this; 183 | 184 | _classCallCheck(this, ActionCreators); 185 | 186 | this.actionTypes = actionTypes; 187 | this._pendingID = 0; 188 | ['list', 'retrieve', 'create', 'update'].forEach(function (action) { 189 | _this2[action] = _this2._createAction.bind(_this2, action, API[action].bind(API)); 190 | }); 191 | } 192 | 193 | _createClass(ActionCreators, [{ 194 | key: '_createAction', 195 | value: function _createAction(action, apiRequest, payload, objectID) { 196 | var _this3 = this; 197 | 198 | return function (dispatch) { 199 | var pendingID = _this3._getPendingID(); 200 | var call = apiRequest(payload, objectID).end(function (err, res) { 201 | if (err) { 202 | dispatch(_this3._failure(action, 'error', pendingID)); 203 | } else { 204 | dispatch(_this3._success(action, res.body, pendingID)); 205 | } 206 | }); 207 | dispatch(_this3._pending(action, payload, pendingID)); 208 | return call; 209 | }; 210 | } 211 | }, { 212 | key: '_success', 213 | value: function _success() { 214 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 215 | args[_key] = arguments[_key]; 216 | } 217 | 218 | return this._makeActionObject.apply(this, args.concat(['success'])); 219 | } 220 | }, { 221 | key: '_failure', 222 | value: function _failure() { 223 | for (var _len2 = arguments.length, args = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { 224 | args[_key2] = arguments[_key2]; 225 | } 226 | 227 | return this._makeActionObject.apply(this, args.concat(['failure'])); 228 | } 229 | }, { 230 | key: '_pending', 231 | value: function _pending() { 232 | return this._makeActionObject.apply(this, arguments); 233 | } 234 | }, { 235 | key: '_makeActionObject', 236 | value: function _makeActionObject(action, payload, pendingID, result) { 237 | var actionType = this.actionTypes.getConstant(action, result); 238 | return { 239 | type: actionType, 240 | payload: payload, 241 | pendingID: pendingID 242 | }; 243 | } 244 | }, { 245 | key: '_getPendingID', 246 | value: function _getPendingID() { 247 | this._pendingID += 1; 248 | return this._pendingID; 249 | } 250 | }]); 251 | 252 | return ActionCreators; 253 | }(); 254 | 255 | var BaseReducer = function () { 256 | function BaseReducer(actionTypes) { 257 | _classCallCheck(this, BaseReducer); 258 | 259 | this.actionTypes = actionTypes; 260 | } 261 | 262 | _createClass(BaseReducer, [{ 263 | key: 'getReducer', 264 | value: function getReducer() { 265 | return this._reducer.bind(this); 266 | } 267 | }, { 268 | key: '_getItem', 269 | value: function _getItem(state, key, value) { 270 | return state.find(function (item) { 271 | return item[key] === value; 272 | }); 273 | } 274 | }, { 275 | key: '_replaceItem', 276 | value: function _replaceItem(state, key, value, newItem) { 277 | var index = state.findIndex(function (item) { 278 | return item[key] === value; 279 | }); 280 | var newState = [].concat(_toConsumableArray(state)); 281 | newState.splice(index, 1, newItem); 282 | return newState; 283 | } 284 | }]); 285 | 286 | return BaseReducer; 287 | }(); 288 | 289 | var ItemReducer = exports.ItemReducer = function (_BaseReducer) { 290 | _inherits(ItemReducer, _BaseReducer); 291 | 292 | function ItemReducer() { 293 | _classCallCheck(this, ItemReducer); 294 | 295 | return _possibleConstructorReturn(this, Object.getPrototypeOf(ItemReducer).apply(this, arguments)); 296 | } 297 | 298 | _createClass(ItemReducer, [{ 299 | key: '_reducer', 300 | value: function _reducer() { 301 | var state = arguments.length <= 0 || arguments[0] === undefined ? [] : arguments[0]; 302 | var action = arguments[1]; 303 | 304 | var item = void 0; 305 | if (action.type === this.actionTypes.create) { 306 | item = _extends({}, action.payload, { status: _itemStatus2.default.pending, pendingID: action.pendingID }); 307 | return [].concat(_toConsumableArray(state), [item]); 308 | } else if (action.type === this.actionTypes.create_success) { 309 | item = _extends({}, action.payload, { status: _itemStatus2.default.saved }); 310 | return this._replaceItem(state, 'pendingID', action.pendingID, item); 311 | } else if (action.type === this.actionTypes.create_failure) { 312 | item = this._getItem(state, 'pendingID', action.pendingID); 313 | item.status = _itemStatus2.default.failed; 314 | return this._replaceItem(state, 'pendingID', action.pendingID, item); 315 | } else if (action.type === this.actionTypes.update) { 316 | item = _extends({}, action.payload, { status: _itemStatus2.default.pending }); 317 | // TODO shouldn't hardcode 'id' field 318 | return this._replaceItem(state, 'id', item.id, item); 319 | } else if (action.type === this.actionTypes.update_success) { 320 | item = _extends({}, action.payload, { status: _itemStatus2.default.saved }); 321 | // TODO shouldn't hardcode 'id' field 322 | return this._replaceItem(state, 'id', item.id, item); 323 | } else if (action.type === this.actionTypes.update_failure) { 324 | item = _extends({}, action.payload, { status: _itemStatus2.default.failed }); 325 | // TODO shouldn't hardcode 'id' field 326 | return this._replaceItem(state, 'id', item.id, item); 327 | } else if (action.type === this.actionTypes.retrieve) { 328 | item = _extends({}, action.payload, { status: _itemStatus2.default.pending }); 329 | // TODO shouldn't hardcode 'id' field 330 | return this._replaceItem(state, 'id', item.id, item); 331 | } else if (action.type === this.actionTypes.retrieve_success) { 332 | item = _extends({}, action.payload, { status: _itemStatus2.default.saved }); 333 | // TODO shouldn't hardcode 'id' field 334 | return this._replaceItem(state, 'id', item.id, item); 335 | } else if (action.type === this.actionTypes.retrieve_failure) { 336 | item = _extends({}, action.payload, { status: _itemStatus2.default.failed }); 337 | // TODO shouldn't hardcode 'id' field 338 | return this._replaceItem(state, 'id', item.id, item); 339 | } else if (action.type === this.actionTypes.list_success) { 340 | return [].concat(_toConsumableArray(action.payload)); 341 | } 342 | return state; 343 | } 344 | }]); 345 | 346 | return ItemReducer; 347 | }(BaseReducer); 348 | 349 | var CollectionReducer = exports.CollectionReducer = function (_BaseReducer2) { 350 | _inherits(CollectionReducer, _BaseReducer2); 351 | 352 | function CollectionReducer() { 353 | _classCallCheck(this, CollectionReducer); 354 | 355 | return _possibleConstructorReturn(this, Object.getPrototypeOf(CollectionReducer).apply(this, arguments)); 356 | } 357 | 358 | _createClass(CollectionReducer, [{ 359 | key: '_reducer', 360 | value: function _reducer() { 361 | var state = arguments.length <= 0 || arguments[0] === undefined ? [] : arguments[0]; 362 | var action = arguments[1]; 363 | 364 | var item = void 0; 365 | if (action.type === this.actionTypes.list) { 366 | item = { 367 | action: 'list', 368 | status: _itemStatus2.default.pending, 369 | pendingID: action.pendingID 370 | }; 371 | return [].concat(_toConsumableArray(state), [item]); 372 | } else if (action.type === this.actionTypes.list_success) { 373 | item = { action: 'list', status: _itemStatus2.default.saved }; 374 | return this._replaceItem(state, 'pendingID', action.pendingID, item); 375 | } else if (action.type === this.actionTypes.list_failure) { 376 | item = { action: 'list', status: _itemStatus2.default.failed }; 377 | return this._replaceItem(state, 'pendingID', action.pendingID, item); 378 | } 379 | 380 | return state; 381 | } 382 | }]); 383 | 384 | return CollectionReducer; 385 | }(BaseReducer); 386 | 387 | var API = function API(APIConf, CSRFOptions) { 388 | _classCallCheck(this, API); 389 | 390 | this.API = {}; 391 | this.actionTypes = {}; 392 | this.actionCreators = {}; 393 | this.reducers = {}; 394 | for (var endpointName in APIConf) { 395 | if (APIConf.hasOwnProperty(endpointName)) { 396 | var url = APIConf[endpointName]; 397 | this.API[endpointName] = new Endpoint(url, CSRFOptions); 398 | this.actionTypes[endpointName] = new ActionTypes(endpointName); 399 | this.actionCreators[endpointName] = new ActionCreators(endpointName, this.API[endpointName], this.actionTypes[endpointName]); 400 | this.reducers[endpointName + '_items'] = new ItemReducer(this.actionTypes[endpointName]).getReducer(); 401 | this.reducers[endpointName + '_collection'] = new CollectionReducer(this.actionTypes[endpointName]).getReducer(); 402 | } 403 | } 404 | }; 405 | 406 | exports.default = API; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-rest", 3 | "version": "0.0.1-alpha.9", 4 | "description": "Create Redux action constants, action creators and reducers for your REST API with no boilerplate.", 5 | "main": "lib/reduxRest.js", 6 | "scripts": { 7 | "build": "scripts/build", 8 | "test": "scripts/test", 9 | "lint": "scripts/lint", 10 | "test-cov": "scripts/test-cov" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/Kvoti/redux-rest" 15 | }, 16 | "keywords": [ 17 | "React", 18 | "Redux", 19 | "API", 20 | "REST" 21 | ], 22 | "author": "Mark Allison", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/Kvoti/redux-rest/issues" 26 | }, 27 | "homepage": "https://github.com/Kvoti/redux-rest", 28 | "devDependencies": { 29 | "babel-cli": "^6.6.0", 30 | "babel-core": "^6.0.0", 31 | "babel-eslint": "^4.0.5", 32 | "babel-loader": "^6.1.0", 33 | "babel-plugin-transform-class-properties": "^6.6.0", 34 | "babel-plugin-transform-decorators": "^6.6.0", 35 | "babel-plugin-transform-object-rest-spread": "^6.5.0", 36 | "babel-preset-es2015": "^6.6.0", 37 | "core-js": "^2.1.3", 38 | "eslint": "^0.24.1", 39 | "eslint-config-airbnb": "0.0.6", 40 | "eslint-plugin-react": "^3.1.0", 41 | "expect": "^1.8.0", 42 | "isparta": "^3.0.3", 43 | "mocha": "^2.2.5", 44 | "nock": "^2.9.1", 45 | "react": "^0.13.0", 46 | "redux": ">=1.0.0-rc", 47 | "sinon": "^1.15.4", 48 | "webpack": "^1.9.6" 49 | }, 50 | "dependencies": { 51 | "babel-plugin-transform-class-properties": "^6.6.0", 52 | "babel-plugin-transform-decorators": "^6.6.0", 53 | "babel-plugin-transform-object-rest-spread": "^6.5.0", 54 | "superagent": "^1.2.0" 55 | }, 56 | "peerDependencies": { 57 | "redux": ">=1.0.0-rc", 58 | "redux-thunk": ">=1.0.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | rm -rf lib 4 | `npm bin`/babel src --out-dir lib 5 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | `npm bin`/eslint src test 4 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | NODE_ENV=test `npm bin`/mocha --compilers js:babel-core/register --recursive -------------------------------------------------------------------------------- /scripts/test-cov: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | `npm bin`/babel-node `npm bin`/isparta cover `npm bin`/_mocha -- --recursive 4 | -------------------------------------------------------------------------------- /src/itemStatus.js: -------------------------------------------------------------------------------- 1 | // Status values for items added/changed/removed via API request 2 | const itemStatus = { 3 | pending: 'pending', 4 | saved: 'saved', 5 | failed: 'failed' 6 | }; 7 | 8 | export default itemStatus; 9 | -------------------------------------------------------------------------------- /src/reduxRest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility class to automatically create Redux reducers for REST API endpoints. 3 | */ 4 | // TODO make ajax library pluggable 5 | import agent from 'superagent'; 6 | import itemStatus from './itemStatus'; 7 | import 'core-js/fn/array/find-index'; 8 | import 'core-js/fn/array/find'; 9 | 10 | export { itemStatus }; 11 | 12 | export const asyncDispatch = store => next => action => 13 | typeof action === 'function' ? 14 | action(store.dispatch, store.getState) : 15 | next(action); 16 | 17 | 18 | export class Endpoint { 19 | constructor(url, 20 | { 21 | withCSRF = false, 22 | CSRFHeaderName = 'X-CSRFToken', 23 | CSRFCookieName = 'csrftoken', 24 | setHeaders 25 | } = {}) { 26 | this.withCSRF = withCSRF; 27 | this.CSRFHeaderName = CSRFHeaderName; 28 | this.CSRFCookieName = CSRFCookieName; 29 | this.setHeaders = setHeaders; 30 | this.url = url; 31 | } 32 | 33 | list(params) { 34 | return this._prepareRequest(agent.get(this.url)).query(params); 35 | } 36 | 37 | retrieve(id) { 38 | return this._prepareRequest(agent.get(this._getObjectURL(id))); 39 | } 40 | 41 | create(conf) { 42 | return this._prepareRequest(agent.post(this.url)).send(conf); 43 | } 44 | 45 | update(conf, id) { 46 | return this._prepareRequest(agent.put(this._getObjectURL(id))).send(conf); 47 | } 48 | 49 | _prepareRequest(request) { 50 | if (this.setHeaders) { 51 | request = this.setHeaders(request); 52 | } 53 | request = this._setCSRFHeader(request); 54 | return request; 55 | } 56 | 57 | _getObjectURL(id) { 58 | let slash = ''; 59 | if (!this.url.endsWith('/')) { 60 | slash = '/'; 61 | } 62 | return `${this.url}${slash}${id}`; 63 | } 64 | 65 | _setCSRFHeader(request) { 66 | if (!this.withCSRF) { 67 | return request; 68 | } 69 | if (!this._csrfSafeMethod(request.method)) { // && !this.crossDomain) { 70 | request.set(this.CSRFHeaderName, this._getCookie(this.CSRFCookieName)); 71 | } 72 | return request; 73 | } 74 | 75 | // Set csrf token for ajax requests 76 | // See https://docs.djangoproject.com/en/dev/ref/csrf/#ajax 77 | _getCookie(name) { 78 | let cookieValue = null; 79 | if (document.cookie && document.cookie !== '') { 80 | let cookies = document.cookie.split(';'); 81 | for (let i = 0; i < cookies.length; i++) { 82 | let cookie = cookies[i].trim(); 83 | // Does this cookie string begin with the name we want? 84 | if (cookie.substring(0, name.length + 1) === (name + '=')) { 85 | cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); 86 | break; 87 | } 88 | } 89 | } 90 | return cookieValue; 91 | } 92 | 93 | _csrfSafeMethod(method) { 94 | // these HTTP methods do not require CSRF protection 95 | return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); 96 | } 97 | } 98 | 99 | export class ActionTypes { 100 | constructor(endpointName) { 101 | this.endpointName = endpointName; 102 | ['list', 'retrieve', 'create', 'update'].forEach(action => { 103 | this[`${action}`] = this.getConstant(action); 104 | ['success', 'failure'].forEach(result => { 105 | this[`${action}_${result}`] = this.getConstant(action, result); 106 | }); 107 | }); 108 | } 109 | 110 | getConstant(action, result) { 111 | let constant = `${this.endpointName}_${action}`; 112 | if (result) { 113 | constant = `${constant}_${result}`; 114 | } 115 | return constant; 116 | } 117 | } 118 | 119 | export class ActionCreators { 120 | constructor(endpointName, API, actionTypes) { 121 | this.actionTypes = actionTypes; 122 | this._pendingID = 0; 123 | ['list', 'retrieve', 'create', 'update'].forEach(action => { 124 | this[action] = this._createAction.bind(this, action, API[action].bind(API)); 125 | }); 126 | } 127 | 128 | _createAction(action, apiRequest, payload, objectID) { 129 | return (dispatch) => { 130 | let pendingID = this._getPendingID(); 131 | let call = apiRequest(payload, objectID) 132 | .end((err, res) => { 133 | if (err) { 134 | dispatch(this._failure(action, 'error', pendingID)); 135 | } else { 136 | dispatch(this._success(action, res.body, pendingID)); 137 | } 138 | }); 139 | dispatch(this._pending(action, payload, pendingID)); 140 | return call; 141 | }; 142 | } 143 | 144 | _success(...args) { 145 | return this._makeActionObject(...args, 'success'); 146 | } 147 | 148 | _failure(...args) { 149 | return this._makeActionObject(...args, 'failure'); 150 | } 151 | 152 | _pending(...args) { 153 | return this._makeActionObject(...args); 154 | } 155 | 156 | _makeActionObject(action, payload, pendingID, result) { 157 | let actionType = this.actionTypes.getConstant(action, result); 158 | return { 159 | type: actionType, 160 | payload: payload, 161 | pendingID: pendingID 162 | }; 163 | } 164 | 165 | _getPendingID() { 166 | this._pendingID += 1; 167 | return this._pendingID; 168 | } 169 | } 170 | 171 | class BaseReducer { 172 | constructor(actionTypes) { 173 | this.actionTypes = actionTypes; 174 | } 175 | 176 | getReducer() { 177 | return this._reducer.bind(this); 178 | } 179 | 180 | _getItem(state, key, value) { 181 | return state.find(item => item[key] === value); 182 | } 183 | 184 | _replaceItem(state, key, value, newItem) { 185 | let index = state.findIndex(item => item[key] === value); 186 | let newState = [...state]; 187 | newState.splice(index, 1, newItem); 188 | return newState; 189 | } 190 | } 191 | 192 | export class ItemReducer extends BaseReducer { 193 | 194 | _reducer(state = [], action) { 195 | let item; 196 | if (action.type === this.actionTypes.create) { 197 | item = {...action.payload, status: itemStatus.pending, pendingID: action.pendingID}; 198 | return [...state, item]; 199 | 200 | } else if (action.type === this.actionTypes.create_success) { 201 | item = {...action.payload, status: itemStatus.saved}; 202 | return this._replaceItem(state, 'pendingID', action.pendingID, item); 203 | 204 | } else if (action.type === this.actionTypes.create_failure) { 205 | item = this._getItem(state, 'pendingID', action.pendingID); 206 | item.status = itemStatus.failed; 207 | return this._replaceItem(state, 'pendingID', action.pendingID, item); 208 | 209 | } else if (action.type === this.actionTypes.update) { 210 | item = {...action.payload, status: itemStatus.pending}; 211 | // TODO shouldn't hardcode 'id' field 212 | return this._replaceItem(state, 'id', item.id, item); 213 | 214 | } else if (action.type === this.actionTypes.update_success) { 215 | item = {...action.payload, status: itemStatus.saved}; 216 | // TODO shouldn't hardcode 'id' field 217 | return this._replaceItem(state, 'id', item.id, item); 218 | 219 | } else if (action.type === this.actionTypes.update_failure) { 220 | item = {...action.payload, status: itemStatus.failed}; 221 | // TODO shouldn't hardcode 'id' field 222 | return this._replaceItem(state, 'id', item.id, item); 223 | 224 | } else if (action.type === this.actionTypes.retrieve) { 225 | item = {...action.payload, status: itemStatus.pending}; 226 | // TODO shouldn't hardcode 'id' field 227 | return this._replaceItem(state, 'id', item.id, item); 228 | 229 | } else if (action.type === this.actionTypes.retrieve_success) { 230 | item = {...action.payload, status: itemStatus.saved}; 231 | // TODO shouldn't hardcode 'id' field 232 | return this._replaceItem(state, 'id', item.id, item); 233 | 234 | } else if (action.type === this.actionTypes.retrieve_failure) { 235 | item = {...action.payload, status: itemStatus.failed}; 236 | // TODO shouldn't hardcode 'id' field 237 | return this._replaceItem(state, 'id', item.id, item); 238 | 239 | } else if (action.type === this.actionTypes.list_success) { 240 | return [...action.payload]; 241 | 242 | } 243 | return state; 244 | } 245 | } 246 | 247 | export class CollectionReducer extends BaseReducer { 248 | 249 | _reducer(state = [], action) { 250 | let item; 251 | if (action.type === this.actionTypes.list) { 252 | item = { 253 | action: 'list', 254 | status: itemStatus.pending, 255 | pendingID: action.pendingID 256 | }; 257 | return [...state, item]; 258 | 259 | } else if (action.type === this.actionTypes.list_success) { 260 | item = {action: 'list', status: itemStatus.saved}; 261 | return this._replaceItem(state, 'pendingID', action.pendingID, item); 262 | 263 | } else if (action.type === this.actionTypes.list_failure) { 264 | item = {action: 'list', status: itemStatus.failed}; 265 | return this._replaceItem(state, 'pendingID', action.pendingID, item); 266 | } 267 | 268 | return state; 269 | } 270 | } 271 | 272 | export default class API { 273 | constructor(APIConf, CSRFOptions) { 274 | this.API = {}; 275 | this.actionTypes = {}; 276 | this.actionCreators = {}; 277 | this.reducers = {}; 278 | for (let endpointName in APIConf) { 279 | if (APIConf.hasOwnProperty(endpointName)) { 280 | let url = APIConf[endpointName]; 281 | this.API[endpointName] = new Endpoint(url, CSRFOptions); 282 | this.actionTypes[endpointName] = new ActionTypes(endpointName); 283 | this.actionCreators[endpointName] = new ActionCreators( 284 | endpointName, 285 | this.API[endpointName], 286 | this.actionTypes[endpointName] 287 | ); 288 | this.reducers[`${endpointName}_items`] = new ItemReducer( 289 | this.actionTypes[endpointName]).getReducer(); 290 | this.reducers[`${endpointName}_collection`] = new CollectionReducer( 291 | this.actionTypes[endpointName]).getReducer(); 292 | } 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /test/ActionCreators.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import expect from 'expect'; 3 | import sinon from 'sinon'; 4 | import nock from 'nock'; 5 | 6 | import { Endpoint, ActionTypes, ActionCreators } from '../src/reduxRest'; 7 | 8 | describe('ActionCreators', () => { 9 | describe('constructor', () => { 10 | ['list', 'retrieve', 'create', 'update'].forEach(action => { 11 | 12 | it(`should create a '${action}' method`, () => { 13 | let endpoint = new Endpoint(''); 14 | let creators = new ActionCreators('endpoint', endpoint); 15 | assert.doesNotThrow( 16 | () => creators[action] 17 | ); 18 | }); 19 | 20 | }); 21 | }); 22 | 23 | let tests = [ 24 | { 25 | actionType: 'list', 26 | actionArgs: [], 27 | expectedHTTPMethod: 'get', 28 | expectedURL: '/endpoint', 29 | expectedRequestBody: '', 30 | response: [{id: 1}], 31 | expectedPayload: { 32 | pending: undefined, 33 | success: [{id: 1}], 34 | failure: 'error' 35 | } 36 | }, 37 | { 38 | actionType: 'create', 39 | actionArgs: [{id: 1}], 40 | expectedHTTPMethod: 'post', 41 | expectedURL: '/endpoint', 42 | expectedRequestBody: {id: 1}, 43 | response: {id: 1}, 44 | expectedPayload: { 45 | pending: {id: 1}, 46 | success: {id: 1}, 47 | failure: 'error' 48 | } 49 | }, 50 | { 51 | actionType: 'retrieve', 52 | actionArgs: [1], 53 | expectedHTTPMethod: 'get', 54 | expectedURL: '/endpoint/1', 55 | expectedRequestBody: '', 56 | response: {id: 1}, 57 | expectedPayload: { 58 | pending: 1, 59 | success: {id: 1}, 60 | failure: 'error' 61 | } 62 | }, 63 | { 64 | actionType: 'update', 65 | actionArgs: [{id: 1, key: 'value'}, 1], 66 | expectedHTTPMethod: 'put', 67 | expectedURL: '/endpoint/1', 68 | expectedRequestBody: {id: 1, key: 'value'}, 69 | response: {id: 1, key: 'value'}, 70 | expectedPayload: { 71 | pending: {id: 1, key: 'value'}, 72 | success: {id: 1, key: 'value'}, 73 | failure: 'error' 74 | } 75 | } 76 | ]; 77 | 78 | tests.forEach(test => { 79 | describe(`${test.actionType}()`, () => { 80 | let actionCreators; 81 | 82 | beforeEach(() => { 83 | // We provide a host here so we can use nock 84 | // TODO run these test in browser? 85 | let endpoint = new Endpoint('http://example.com/endpoint'); 86 | let types = new ActionTypes('endpoint'); 87 | actionCreators = new ActionCreators('endpoint', endpoint, types); 88 | if (test.expectedHTTPMethod === 'post' || test.expectedHTTPMethod === 'put') { 89 | sinon.stub(endpoint, '_setCSRFHeader', request => request); 90 | } 91 | }); 92 | 93 | it(`should return a function that calls the ${test.actionType} API endpoint`, () => { 94 | let scope = nock('http://example.com') 95 | [test.expectedHTTPMethod](test.expectedURL, test.expectedRequestBody) 96 | .reply(200); 97 | let dispatch = sinon.stub(); 98 | 99 | actionCreators[test.actionType](...test.actionArgs)(dispatch); 100 | 101 | scope.done(); 102 | }); 103 | 104 | it(`should return a function that dispatches a pending ${test.actionType} action`, () => { 105 | let scope = nock('http://example.com') 106 | [test.expectedHTTPMethod](test.expectedURL, test.expectedRequestBody) 107 | .reply(200); 108 | let dispatch = sinon.stub(); 109 | 110 | actionCreators[test.actionType](...test.actionArgs)(dispatch); 111 | 112 | scope.done(); 113 | sinon.assert.calledWith( 114 | dispatch, 115 | sinon.match({type: `endpoint_${test.actionType}`}) 116 | ); 117 | }); 118 | 119 | it(`should return a function that dispatches a pending ${test.actionType} action with correct payload`, () => { 120 | let scope = nock('http://example.com') 121 | [test.expectedHTTPMethod](test.expectedURL, test.expectedRequestBody) 122 | .reply(200); 123 | let dispatch = sinon.stub(); 124 | 125 | actionCreators[test.actionType](...test.actionArgs)(dispatch); 126 | 127 | scope.done(); 128 | sinon.assert.calledWith( 129 | dispatch, 130 | sinon.match({payload: test.expectedPayload.pending}) 131 | ); 132 | }); 133 | 134 | it(`should return a function that dispatches a ${test.actionType}_success action on success`, (done) => { 135 | let scope = nock('http://example.com') 136 | [test.expectedHTTPMethod](test.expectedURL, test.expectedRequestBody) 137 | .reply(200, test.response); 138 | let dispatch = (action) => { 139 | if (action.type === `endpoint_${test.actionType}_success`) { 140 | done(); 141 | } 142 | }; 143 | actionCreators[test.actionType](...test.actionArgs)(dispatch); 144 | scope.done(); 145 | }); 146 | 147 | it(`should return a function that dispatches a ${test.actionType}_success action on success with correct payload`, (done) => { 148 | let scope = nock('http://example.com') 149 | [test.expectedHTTPMethod](test.expectedURL, test.expectedRequestBody) 150 | .reply(200, test.response); 151 | let dispatch = (action) => { 152 | if (action.type === `endpoint_${test.actionType}_success`) { 153 | expect(action.payload).toEqual(test.expectedPayload.success); 154 | done(); 155 | } 156 | }; 157 | actionCreators[test.actionType](...test.actionArgs)(dispatch); 158 | scope.done(); 159 | }); 160 | 161 | it(`should return a function that dispatches a ${test.actionType}_failure action on failure`, (done) => { 162 | let scope = nock('http://example.com') 163 | [test.expectedHTTPMethod](test.expectedURL, test.expectedRequestBody) 164 | .reply(400); 165 | let dispatch = (action) => { 166 | if (action.type === `endpoint_${test.actionType}_failure`) { 167 | done(); 168 | } 169 | }; 170 | actionCreators[test.actionType](...test.actionArgs)(dispatch); 171 | scope.done(); 172 | }); 173 | 174 | it(`should return a function that dispatches a ${test.actionType}_failure action on failure with correct payload`, (done) => { 175 | let scope = nock('http://example.com') 176 | [test.expectedHTTPMethod](test.expectedURL, test.expectedRequestBody) 177 | .reply(400); 178 | let dispatch = (action) => { 179 | if (action.type === `endpoint_${test.actionType}_failure`) { 180 | expect(action.payload).toEqual(test.expectedPayload.failure); 181 | done(); 182 | } 183 | }; 184 | actionCreators[test.actionType](...test.actionArgs)(dispatch); 185 | scope.done(); 186 | }); 187 | 188 | }); 189 | }); 190 | 191 | describe('_createAction()', () => { 192 | let actionCreators; 193 | 194 | beforeEach(() => { 195 | let endpoint = new Endpoint('endpoint'); 196 | let types = new ActionTypes('endpoint'); 197 | actionCreators = new ActionCreators('endpoint', endpoint, types); 198 | }); 199 | 200 | it('should return a function', () => { 201 | expect(actionCreators._createAction()).toBeA(Function); 202 | }); 203 | 204 | it('should return a function that calls API request', () => { 205 | let APIRequest = sinon.stub(); 206 | let promise = {end: function() {}}; 207 | APIRequest.returns(promise); 208 | 209 | let actionFunc = actionCreators._createAction('list', APIRequest, 'payload'); 210 | actionFunc(() => {}); 211 | 212 | assert(APIRequest.calledOnce); 213 | assert(APIRequest.calledWith('payload')); 214 | }); 215 | 216 | it('should return a function that dispatches the given action', () => { 217 | let APIRequest = sinon.stub(); 218 | let promise = {end: function() {}}; 219 | APIRequest.returns(promise); 220 | let dispatch = sinon.stub(); 221 | 222 | let actionFunc = actionCreators._createAction('list', APIRequest, 'payload'); 223 | actionFunc(dispatch); 224 | 225 | assert(dispatch.calledOnce); 226 | sinon.assert.calledWith(dispatch, { 227 | type: 'endpoint_list', 228 | payload: 'payload', 229 | pendingID: 1 230 | }); 231 | 232 | }); 233 | 234 | it('should dispatch a success action if the API request succeeds', () => { 235 | let APIRequest = sinon.stub(); 236 | let promise = {end: function(callback) { this.callback = callback; }}; 237 | APIRequest.returns(promise); 238 | let dispatch = sinon.stub(); 239 | 240 | let actionFunc = actionCreators._createAction('list', APIRequest, 'payload'); 241 | actionFunc(dispatch); 242 | promise.callback.bind(null)(null, {body: {id: 1}}); 243 | 244 | sinon.assert.calledWith(dispatch, { 245 | type: 'endpoint_list_success', 246 | payload: {id: 1}, 247 | pendingID: 1 248 | }); 249 | }); 250 | 251 | it('should dispatch a failure action if the API request fails', () => { 252 | let APIRequest = sinon.stub(); 253 | let promise = {end: function(callback) { this.callback = callback; }}; 254 | APIRequest.returns(promise); 255 | let dispatch = sinon.stub(); 256 | 257 | let actionFunc = actionCreators._createAction('list', APIRequest, 'payload'); 258 | actionFunc(dispatch); 259 | promise.callback.bind(null)('error'); 260 | 261 | sinon.assert.calledWith(dispatch, { 262 | type: 'endpoint_list_failure', 263 | payload: 'error', 264 | pendingID: 1 265 | }); 266 | }); 267 | 268 | }); 269 | 270 | describe('_getPendingID()', () => { 271 | it('should start at 1', () => { 272 | let endpoint = new Endpoint(''); 273 | let creators = new ActionCreators('endpoint', endpoint); 274 | assert.equal(creators._getPendingID(), 1); 275 | }); 276 | 277 | it('should increment the ID', () => { 278 | let endpoint = new Endpoint(''); 279 | let creators = new ActionCreators('endpoint', endpoint); 280 | creators._getPendingID(); 281 | assert.equal(creators._getPendingID(), 2); 282 | }); 283 | }); 284 | 285 | describe('_makeActionObject()', () => { 286 | it('should return an action', () => { 287 | let endpoint = new Endpoint('endpoint'); 288 | let types = new ActionTypes('endpoint'); 289 | let creators = new ActionCreators('endpoint', endpoint, types); 290 | assert.deepEqual( 291 | creators._makeActionObject('list', 'payload', 1), 292 | { 293 | type: 'endpoint_list', 294 | payload: 'payload', 295 | pendingID: 1 296 | } 297 | ); 298 | }); 299 | 300 | it('should return a result action when called with a result', () => { 301 | let endpoint = new Endpoint('endpoint'); 302 | let types = new ActionTypes('endpoint'); 303 | let creators = new ActionCreators('endpoint', endpoint, types); 304 | assert.deepEqual( 305 | creators._makeActionObject('list', 'payload', 1, 'success'), 306 | { 307 | type: 'endpoint_list_success', 308 | payload: 'payload', 309 | pendingID: 1 310 | } 311 | ); 312 | }); 313 | 314 | }); 315 | 316 | }); 317 | -------------------------------------------------------------------------------- /test/ActionTypes.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | import { ActionTypes } from '../src/reduxRest'; 4 | 5 | describe('ActionTypes', () => { 6 | describe('constructor', () => { 7 | ['list', 'retrieve', 'create', 'update'].forEach(action => { 8 | 9 | it(`adds a ${action} action type`, () => { 10 | let types = new ActionTypes('endpoint'); 11 | assert.doesNotThrow( 12 | () => types[action]); 13 | }); 14 | 15 | it(`sets the ${action} action type value to _${action}`, () => { 16 | let types = new ActionTypes('endpoint'); 17 | assert.equal(types[action], `endpoint_${action}`); 18 | }); 19 | 20 | ['success', 'failure'].forEach(result => { 21 | 22 | it(`adds a related ${result} action type for ${action}`, () => { 23 | let types = new ActionTypes('endpoint'); 24 | assert.doesNotThrow( 25 | () => types[`${action}_${result}`]); 26 | }); 27 | 28 | it(`sets the related ${result} action type for ${action} to _${action}_${result}`, 29 | () => { 30 | let types = new ActionTypes('endpoint'); 31 | assert.equal(types[`${action}_${result}`], `endpoint_${action}_${result}`); 32 | }); 33 | }); 34 | }); 35 | }); 36 | 37 | describe('getConstant', () => { 38 | 39 | it('returns _ when called with an action only', () => { 40 | let types = new ActionTypes('endpoint'); 41 | assert.equal('endpoint_list', types.getConstant('list')); 42 | }); 43 | 44 | it('returns __ when called with action and result', 45 | () => { 46 | let types = new ActionTypes('endpoint'); 47 | assert.equal('endpoint_list_success', types.getConstant('list', 'success')); 48 | }); 49 | 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/CollectionReducer.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | 3 | import { ActionTypes, CollectionReducer } from '../src/reduxRest'; 4 | 5 | describe('CollectionReducer', () => { 6 | describe('reducer', () => { 7 | 8 | let actionTypes = new ActionTypes('endpoint'); 9 | 10 | it('should not change the state if action is unhandled', () => { 11 | let reducer = new CollectionReducer(actionTypes).getReducer(); 12 | let action = { 13 | type: 'xxxxx' 14 | }; 15 | let newState = reducer([], action); 16 | expect(newState).toEqual([]); 17 | }); 18 | 19 | it('should create pending list state on list action', () => { 20 | let reducer = new CollectionReducer(actionTypes).getReducer(); 21 | let action = { 22 | type: actionTypes.list, 23 | pendingID: 1 24 | }; 25 | 26 | let newState = reducer(undefined, action); 27 | 28 | expect(newState.length).toEqual(1); 29 | expect(newState[0]).toEqual({ 30 | action: 'list', 31 | pendingID: 1, 32 | status: 'pending' 33 | }); 34 | }); 35 | 36 | it('should set list state to success on list action success', () => { 37 | let reducer = new CollectionReducer(actionTypes).getReducer(); 38 | let action = { 39 | type: actionTypes.list_success, 40 | pendingID: 1 41 | }; 42 | 43 | let newState = reducer([{ 44 | action: 'list', 45 | pendingID: 1, 46 | status: 'pending' 47 | }], action); 48 | 49 | expect(newState.length).toEqual(1); 50 | expect(newState[0]).toEqual({ 51 | action: 'list', 52 | status: 'saved' 53 | }); 54 | }); 55 | 56 | it('should set list state to failed on list action failure', () => { 57 | let reducer = new CollectionReducer(actionTypes).getReducer(); 58 | let action = { 59 | type: actionTypes.list_failure, 60 | pendingID: 1 61 | }; 62 | 63 | let newState = reducer([{ 64 | action: 'list', 65 | pendingID: 1, 66 | status: 'pending' 67 | }], action); 68 | 69 | expect(newState.length).toEqual(1); 70 | expect(newState[0]).toEqual({ 71 | action: 'list', 72 | status: 'failed' 73 | }); 74 | }); 75 | 76 | }); 77 | 78 | }); 79 | -------------------------------------------------------------------------------- /test/Endpoint.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import nock from 'nock'; 3 | import sinon from 'sinon'; 4 | import superagent from 'superagent'; 5 | 6 | import { Endpoint } from '../src/reduxRest'; 7 | 8 | describe('Endpoint', () => { 9 | describe('#_setCSRFHeader()', () => { 10 | // it('should call a custom setCSRF function', () => { 11 | // let csrf = sinon.stub(); 12 | // let endpoint = new Endpoint('', {setCSRF: csrf}); 13 | // let request = 'request'; 14 | // endpoint._setCSRFHeader(request); 15 | // sinon.assert.calledWith(csrf, request); 16 | // }); 17 | 18 | it('should be able to set headers from custom function', () => { 19 | const custom = (request) => { 20 | request.set('X-Custom', '1'); 21 | return request; 22 | }; 23 | let endpoint = new Endpoint('', {setHeaders: custom}); 24 | let request = superagent.post(''); 25 | let expectation = sinon.mock(request).expects('set').once(); 26 | expectation.withArgs('X-Custom', '1'); 27 | }); 28 | 29 | it('should set headers when withCSRF is true', () => { 30 | let endpoint = new Endpoint('', {withCSRF: true}); 31 | sinon.stub(endpoint, '_getCookie', () => 'cookie'); 32 | let request = superagent.post(''); 33 | let expectation = sinon.mock(request).expects('set').once(); 34 | expectation.withArgs('X-CSRFToken', 'cookie'); 35 | endpoint._setCSRFHeader(request); 36 | expectation.verify(); 37 | }); 38 | 39 | it('should not set headers when withCSRF is false', () => { 40 | let endpoint = new Endpoint(''); 41 | let request = superagent.post(''); 42 | let expectation = sinon.mock(request).expects('set').never(); 43 | endpoint._setCSRFHeader(request); 44 | expectation.verify(); 45 | }); 46 | 47 | it('should use custom CSRFHeaderName', () => { 48 | let endpoint = new Endpoint('', {withCSRF: true, CSRFHeaderName: 'X-Fred'}); 49 | sinon.stub(endpoint, '_getCookie'); 50 | let request = superagent.post(''); 51 | let expectation = sinon.mock(request).expects('set').once(); 52 | expectation.withArgs('X-Fred'); 53 | endpoint._setCSRFHeader(request); 54 | expectation.verify(); 55 | }); 56 | 57 | it('should use custom CSRFCookieName', () => { 58 | let endpoint = new Endpoint('', {withCSRF: true, CSRFCookieName: 'testcookie'}); 59 | let stub = sinon.stub(endpoint, '_getCookie'); 60 | let request = superagent.post(''); 61 | sinon.stub(request, 'set'); 62 | endpoint._setCSRFHeader(request); 63 | sinon.assert.calledWithExactly(stub, 'testcookie'); 64 | }); 65 | 66 | it('should set request headers', (done) => { 67 | let scope = nock('http://example.com') 68 | .matchHeader('x-csrftoken', 'token') 69 | .post('/endpoint') 70 | .reply(200); 71 | let endpoint = new Endpoint('http://example.com/endpoint', {withCSRF: true}); 72 | sinon.stub(endpoint, '_getCookie', () => 'token'); 73 | endpoint.create().end(() => { 74 | scope.done(); 75 | done(); 76 | }); 77 | }); 78 | }); 79 | 80 | describe('#_getObjectURL()', () => { 81 | it('should append the object id to the endpoint url', () => { 82 | let endpoint = new Endpoint(''); 83 | let objectURL = endpoint._getObjectURL('obj'); 84 | assert.equal(objectURL, '/obj'); 85 | }); 86 | 87 | it('should handle the endpoint url ending in a /', () => { 88 | let endpoint = new Endpoint('/'); 89 | let objectURL = endpoint._getObjectURL('obj'); 90 | assert.equal(objectURL, '/obj'); 91 | }); 92 | }); 93 | 94 | describe('list', () => { 95 | it('should make a GET request to the endpoint url', (done) => { 96 | // Note only giving full url to work with nock 97 | // TODO run these tests in browser? 98 | let endpoint = new Endpoint('http://example.com/endpoint'); 99 | // TODO if nock gets support for path regexps we don't need to specify the path here 100 | let scope = nock('http://example.com').get('/endpoint').reply(200); 101 | endpoint.list().end(() => { 102 | scope.done(); 103 | done(); 104 | }); 105 | }); 106 | 107 | it('should append params object as the query string', (done) => { 108 | let endpoint = new Endpoint('http://example.com/endpoint'); 109 | let scope = nock('http://example.com') 110 | .get('/endpoint') 111 | .query({key: 'value'}) 112 | .reply(200); 113 | endpoint.list({key: 'value'}).end(() => { 114 | scope.done(); 115 | done(); 116 | }); 117 | 118 | }); 119 | }); 120 | 121 | describe('retrieve', () => { 122 | it('should make a GET request to the object url', (done) => { 123 | let endpoint = new Endpoint('http://example.com/endpoint'); 124 | let scope = nock('http://example.com').get('/endpoint/obj').reply(200); 125 | endpoint.retrieve('obj').end(() => { 126 | scope.done(); 127 | done(); 128 | }); 129 | }); 130 | }); 131 | 132 | describe('create', () => { 133 | it('should make a POST request to the endpoint url', (done) => { 134 | let endpoint = new Endpoint('http://example.com/endpoint'); 135 | sinon.stub(endpoint, '_setCSRFHeader', request => request); 136 | let scope = nock('http://example.com').post('/endpoint').reply(200); 137 | endpoint.create().end(() => { 138 | scope.done(); 139 | done(); 140 | }); 141 | }); 142 | 143 | it('should send the object conf in the request body', (done) => { 144 | let endpoint = new Endpoint('http://example.com/endpoint'); 145 | sinon.stub(endpoint, '_setCSRFHeader', request => request); 146 | let scope = nock('http://example.com') 147 | .post('/endpoint', {id: 1}) 148 | .reply(200); 149 | endpoint.create({id: 1}).end(() => { 150 | scope.done(); 151 | done(); 152 | }); 153 | }); 154 | 155 | }); 156 | 157 | describe('update', () => { 158 | it('should make a PUT request to the object url', (done) => { 159 | let endpoint = new Endpoint('http://example.com/endpoint'); 160 | sinon.stub(endpoint, '_setCSRFHeader', request => request); 161 | let scope = nock('http://example.com').put('/endpoint/obj').reply(200); 162 | endpoint.update({}, 'obj').end(() => { 163 | scope.done(); 164 | done(); 165 | }); 166 | }); 167 | 168 | it('should send the object conf in the request body', (done) => { 169 | let endpoint = new Endpoint('http://example.com/endpoint'); 170 | sinon.stub(endpoint, '_setCSRFHeader', request => request); 171 | let scope = nock('http://example.com') 172 | .put('/endpoint/obj', {id: 1}) 173 | .reply(200); 174 | endpoint.update({id: 1}, 'obj').end(() => { 175 | scope.done(); 176 | done(); 177 | }); 178 | }); 179 | 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /test/Flux.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | 3 | import Flux, * as flux from '../src/reduxRest'; 4 | 5 | describe('Flux', () => { 6 | const APIConf = {endpoint: 'enpoint/'}; 7 | 8 | it('should create an API helper for each API endpoint', () => { 9 | let f = new Flux(APIConf); 10 | expect(f.API.endpoint).toBeAn(flux.Endpoint); 11 | }); 12 | 13 | it('should create ActionTypes for each API endpoint', () => { 14 | let f = new Flux(APIConf); 15 | expect(f.actionTypes.endpoint).toBeAn(flux.ActionTypes); 16 | }); 17 | 18 | it('should create ActionCreators for each API endpoint', () => { 19 | let f = new Flux(APIConf); 20 | expect(f.actionCreators.endpoint).toBeAn(flux.ActionCreators); 21 | }); 22 | 23 | it('should create an item reducer for each API endpoint', () => { 24 | let f = new Flux(APIConf); 25 | let newState = f.reducers.endpoint_items(undefined, {type: 'endpoint_create', payload: {key: 'val'}}); 26 | expect(newState[0].key).toEqual('val'); 27 | }); 28 | 29 | it('should create a collection reducer for each API endpoint', () => { 30 | let f = new Flux(APIConf); 31 | let newState = f.reducers.endpoint_collection(undefined, {type: 'endpoint_list'}); 32 | expect(newState[0].action).toEqual('list'); 33 | }); 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /test/ItemReducer.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | 3 | import { ActionTypes, ItemReducer } from '../src/reduxRest'; 4 | 5 | describe('ItemReducer', () => { 6 | describe('reducer', () => { 7 | 8 | let actionTypes = new ActionTypes('endpoint'); 9 | 10 | it('should not change the state if action is unhandled', () => { 11 | let reducer = new ItemReducer(actionTypes).getReducer(); 12 | let action = { 13 | type: 'xxxxx' 14 | }; 15 | let newState = reducer([], action); 16 | expect(newState).toEqual([]); 17 | }); 18 | 19 | it('should set state to items on list action success', () => { 20 | let reducer = new ItemReducer(actionTypes).getReducer(); 21 | let action = { 22 | type: actionTypes.list_success, 23 | payload: [{id: 1}] 24 | }; 25 | let newState = reducer(undefined, action); 26 | expect(newState.length).toEqual(1); 27 | expect(newState[0]).toEqual({id: 1}); 28 | }); 29 | 30 | it('should append item to state on create action', () => { 31 | let reducer = new ItemReducer(actionTypes).getReducer(); 32 | let action = { 33 | type: actionTypes.create, 34 | payload: {some: 'thing'}, 35 | pendingID: 1 36 | }; 37 | let newState = reducer(undefined, action); 38 | expect(newState.length).toEqual(1); 39 | expect(newState[0]).toEqual({ 40 | // TODO split these into separate tests 41 | some: 'thing', 42 | pendingID: 1, 43 | status: 'pending' 44 | }); 45 | }); 46 | 47 | it('should replace item on create action success', () => { 48 | let reducer = new ItemReducer(actionTypes).getReducer(); 49 | let action = { 50 | type: actionTypes.create_success, 51 | payload: {id: 1, some: 'thing'}, 52 | pendingID: 1 53 | }; 54 | let newState = reducer([{some: 'thing', pendingID: 1}], action); 55 | expect(newState.length).toEqual(1); 56 | expect(newState[0]).toEqual({ 57 | // TODO split these into separate tests 58 | id: 1, 59 | some: 'thing', 60 | status: 'saved' 61 | }); 62 | }); 63 | 64 | it('should set item status to failed on create action failure', () => { 65 | let reducer = new ItemReducer(actionTypes).getReducer(); 66 | let action = { 67 | type: actionTypes.create_failure, 68 | pendingID: 1 69 | }; 70 | let newState = reducer([{some: 'thing', pendingID: 1}], action); 71 | expect(newState.length).toEqual(1); 72 | expect(newState[0]).toEqual({ 73 | // TODO split these into separate tests 74 | pendingID: 1, 75 | some: 'thing', 76 | status: 'failed' 77 | }); 78 | }); 79 | 80 | it('should update item and mark pending on update action', () => { 81 | let reducer = new ItemReducer(actionTypes).getReducer(); 82 | let action = { 83 | type: actionTypes.update, 84 | payload: {id: 1, some: 'other'} 85 | }; 86 | let newState = reducer([{id: 1, some: 'thing'}], action); 87 | expect(newState.length).toEqual(1); 88 | expect(newState[0]).toEqual({ 89 | // TODO split these into separate tests 90 | id: 1, 91 | some: 'other', 92 | status: 'pending' 93 | }); 94 | }); 95 | 96 | it('should mark item saved on update action success', () => { 97 | let reducer = new ItemReducer(actionTypes).getReducer(); 98 | let action = { 99 | type: actionTypes.update_success, 100 | payload: {id: 1, some: 'other'} 101 | }; 102 | let newState = reducer([{id: 1, some: 'thing'}], action); 103 | expect(newState.length).toEqual(1); 104 | expect(newState[0]).toEqual({ 105 | // TODO split these into separate tests 106 | id: 1, 107 | some: 'other', 108 | status: 'saved' 109 | }); 110 | }); 111 | 112 | it('should mark item failed on update action failure', () => { 113 | let reducer = new ItemReducer(actionTypes).getReducer(); 114 | let action = { 115 | type: actionTypes.update_failure, 116 | payload: {id: 1, some: 'other'} 117 | }; 118 | let newState = reducer([{id: 1, some: 'thing'}], action); 119 | expect(newState.length).toEqual(1); 120 | expect(newState[0]).toEqual({ 121 | // TODO split these into separate tests 122 | id: 1, 123 | some: 'other', 124 | status: 'failed' 125 | }); 126 | }); 127 | 128 | it('should mark item pending while retrieving', () => { 129 | let reducer = new ItemReducer(actionTypes).getReducer(); 130 | let action = { 131 | type: actionTypes.retrieve, 132 | payload: {id: 1, some: 'other'} 133 | }; 134 | let newState = reducer([], action); 135 | expect(newState.length).toEqual(1); 136 | expect(newState[0]).toEqual({ 137 | // TODO split these into separate tests 138 | id: 1, 139 | some: 'other', 140 | status: 'pending' 141 | }); 142 | }); 143 | 144 | it('should mark item failed if not retrieved', () => { 145 | let reducer = new ItemReducer(actionTypes).getReducer(); 146 | let action = { 147 | type: actionTypes.retrieve_failure, 148 | payload: {id: 1, some: 'other'} 149 | }; 150 | let newState = reducer([], action); 151 | expect(newState.length).toEqual(1); 152 | expect(newState[0]).toEqual({ 153 | // TODO split these into separate tests 154 | id: 1, 155 | some: 'other', 156 | status: 'failed' 157 | }); 158 | }); 159 | 160 | it('should mark item saved if retrieved', () => { 161 | // TODO 'loaded' status instead of 'saved'? 162 | let reducer = new ItemReducer(actionTypes).getReducer(); 163 | let action = { 164 | type: actionTypes.retrieve_success, 165 | payload: {id: 1, some: 'other'} 166 | }; 167 | let newState = reducer([], action); 168 | expect(newState.length).toEqual(1); 169 | expect(newState[0]).toEqual({ 170 | // TODO split these into separate tests 171 | id: 1, 172 | some: 'other', 173 | status: 'saved' 174 | }); 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /test/ReduxIntegration.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 2 | import thunkMiddleware from 'redux-thunk'; 3 | import expect from 'expect'; 4 | import nock from 'nock'; 5 | 6 | import API from '../src/reduxRest'; 7 | 8 | describe('API', () => { 9 | it('should integrate with redux', () => { 10 | const myAPI = { 11 | users: 'http://example.com/api/users/' 12 | }; 13 | const api = new API(myAPI); 14 | const reducers = combineReducers(api.reducers); 15 | 16 | let createStoreWithMiddleware = applyMiddleware( 17 | thunkMiddleware 18 | )(createStore); 19 | const store = createStoreWithMiddleware(reducers); 20 | let scope = nock('http://example.com') 21 | .get('/api/users/') 22 | .reply(200, [{ 23 | username: 'mark' 24 | }]); 25 | store.dispatch( 26 | d => { 27 | api.actionCreators.users.list()(d).end(() => { 28 | expect(store.getState()).toBe([{username: 'mark'}]); 29 | scope.done(); 30 | }); 31 | }); 32 | }); 33 | }); 34 | --------------------------------------------------------------------------------