├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── README.md ├── package.json ├── readme-src ├── dev-tools-1v.jpg ├── dev-tools.png └── readme-1v.md ├── src ├── entity-api.js ├── index.js ├── reducers-builder.js └── utils.js └── tests ├── config └── global-mocks.js ├── integration ├── api-actions.test.js └── demo.test.js └── unit ├── api-reducers.test.js ├── entitity-api-instance.test.js └── utils.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"], 3 | "plugins": [ 4 | "transform-object-assign" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | # Developer files 12 | [*.{js, css, html, rb, package.json, .travis.yml}] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | # Markdown 17 | [*.md] 18 | indent_style = space 19 | indent_size = 4 -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "rambler", 4 | "rules": { 5 | "react/jsx-uses-vars": 1, 6 | "react/react-in-jsx-scope": 1 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /deploy 2 | /nbproject/ 3 | vendor/ 4 | shared/ 5 | node_modules/ 6 | lib/ 7 | static/ 8 | web/static/ 9 | web/index.html 10 | .idea/* 11 | *.DS_Store 12 | *.js.map 13 | *.iml 14 | /sftp-config.json 15 | .old_hooks/ 16 | npm-debug.log 17 | _tests/_output/* 18 | app/runtime 19 | runtime/ 20 | _tests/_output/* 21 | .user.ini 22 | ### JetBrains template 23 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 24 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 25 | 26 | # User-specific stuff: 27 | .idea/workspace.xml 28 | .idea/tasks.xml 29 | .idea/dictionaries 30 | .idea/vcs.xml 31 | .idea/jsLibraryMappings.xml 32 | 33 | # Sensitive or high-churn files: 34 | .idea/dataSources.ids 35 | .idea/dataSources.xml 36 | .idea/dataSources.local.xml 37 | .idea/sqlDataSources.xml 38 | .idea/dynamic.xml 39 | .idea/uiDesigner.xml 40 | 41 | # Gradle: 42 | .idea/gradle.xml 43 | .idea/libraries 44 | 45 | # Mongo Explorer plugin: 46 | .idea/mongoSettings.xml 47 | 48 | ## File-based project format: 49 | *.iws 50 | 51 | ## Plugin-specific files: 52 | 53 | # IntelliJ 54 | /out/ 55 | 56 | # mpeltonen/sbt-idea plugin 57 | .idea_modules/ 58 | 59 | # JIRA plugin 60 | atlassian-ide-plugin.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | ### Node template 68 | # Logs 69 | logs 70 | *.log 71 | npm-debug.log* 72 | 73 | # Runtime data 74 | pids 75 | *.pid 76 | *.seed 77 | 78 | # Directory for instrumented libs generated by jscoverage/JSCover 79 | lib-cov 80 | 81 | # Coverage directory used by tools like istanbul 82 | coverage 83 | 84 | # nyc test coverage 85 | .nyc_output 86 | 87 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 88 | .grunt 89 | 90 | # node-waf configuration 91 | .lock-wscript 92 | 93 | # Compiled binary addons (http://nodejs.org/api/addons.html) 94 | build/Release 95 | 96 | # Dependency directories 97 | jspm_packages 98 | 99 | # Optional npm cache directory 100 | .npm 101 | 102 | # Optional REPL history 103 | .node_repl_history 104 | /.idea/ 105 | /coverage/ 106 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/lib 3 | !/src 4 | !/redux-api-middleware 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-rest-adapter 2 | 3 | [![NPM](https://nodei.co/npm/redux-rest-adapter.png)](https://npmjs.org/package/redux-rest-adapter) 4 | [![Code Climate](https://codeclimate.com/github/maksim-chekrishov/redux-rest-adapter/badges/gpa.svg)](https://codeclimate.com/github/maksim-chekrishov/redux-rest-adapter) 5 | [![Test Coverage](https://codeclimate.com/github/maksim-chekrishov/redux-rest-adapter/badges/coverage.svg)](https://codeclimate.com/github/maksim-chekrishov/redux-rest-adapter/coverage) 6 | 7 | redux-rest-adapter is a tool for easy connection your REST api with redux store. 8 | 9 | Compatible with [json.api specification](http://jsonapi.org/) 10 | 11 | ## Main points 12 | - Write **code** instead of reducers and actions for trivial data operations. 13 | 14 | ## Changelog 15 | 16 | Starts from v2.0.0 redux-rest-adapter based on 17 | [axios](https://www.npmjs.com/package/axios) and 18 | [promise-middleware](https://www.npmjs.com/package/promise-middleware) 19 | for easy access to promises and better experience with isomorphic app. 20 | 21 | [Versions 1.x.x](https://raw.githubusercontent.com/maksim-chekrishov/redux-rest-adapter/master/readme-src/readme-1v.md) 22 | 23 | ### short-example.js 24 | 25 | ```js 26 | import EntityApi, {promiseMiddleware} from 'redux-rest-adapter'; 27 | import {createStore, applyMiddleware, combineReducers} from 'redux'; 28 | 29 | const tagsApi = new EntityApi({ 30 | entityName: 'TAG', 31 | endpointUrl: 'api/v2/tags' 32 | }); 33 | 34 | const apiReducer = combineReducers({ 35 | TAG: tagsApi.configureReducer() 36 | }); 37 | 38 | const store = createStore( 39 | combineReducers({ 40 | api: apiReducer 41 | }), 42 | {}, 43 | applyMiddleware(promiseMiddleware()) 44 | ); 45 | 46 | store.dispatch(tagsApi.actions.load()).then(()=> { 47 | console.log(store.getState().api.TAG.data); // [{id:1, name:'tag1'}, {id:2, name:'tag2'}]; 48 | }) 49 | ``` 50 | 51 | ## Setup 52 | 53 | ### your/known-entities-api.js 54 | 55 | ```js 56 | import EntityApi from 'redux-rest-adapter'; 57 | 58 | export const KnownEntitiesUrls = { 59 | NEWS_TAGS: 'news-tags', 60 | NEWS_TAG_FOR_EDIT: 'news-tags', 61 | //.. 62 | }; 63 | export default _.mapValues(KnownEntitiesUrls, (url, name) => new EntityApi({ 64 | entityName: name, 65 | endpointUrl: 'api/v2/' + url 66 | })); 67 | ``` 68 | 69 | ### your/api-reducer.js 70 | 71 | ```js 72 | import knownEntitiesApi from 'your/known-entities-api'; 73 | 74 | // Ability to extend default api reducers 75 | const apiReducersExtensions = { 76 | NEWS_TAGS: tagsReducer 77 | } 78 | 79 | const apiReducers = _.mapValues(knownEntitiesApi, (api, key) => api.configureReducer(apiReducersExtensions[key])); 80 | 81 | export default combineReducers(apiReducers); 82 | ``` 83 | 84 | ### your/index-reducer.js 85 | 86 | ```js 87 | import apiReducer from 'your/api-reducer'; 88 | 89 | export default combineReducers({ 90 | api: apiReducer 91 | //.. 92 | }); 93 | ``` 94 | 95 | ### your/configure-store.js 96 | 97 | ```js 98 | import indexReducer from 'your/index-reducer'; 99 | import {promiseMiddleware} from 'redux-rest-adapter'; 100 | //.. 101 | export default function configureStore(initialState) { 102 | return createStore( 103 | indexReducer, 104 | initialState, 105 | applyMiddleware(promiseMiddleware()) 106 | ); 107 | } 108 | ``` 109 | 110 | ### your/entities-actions.js 111 | 112 | ```js 113 | import knownEntitiesApi from 'your/known-entities-api'; 114 | 115 | export default _.mapValues(knownEntitiesApi, entityApi => entityApi.actions); 116 | ``` 117 | 118 | 119 | ## Adapter is ready 120 | 121 | ![Image devTools](https://raw.githubusercontent.com/maksim-chekrishov/redux-rest-adapter/master/readme-src/dev-tools.png) 122 | 123 | ## Usage 124 | 125 | ### Actions 126 | 127 | ```js 128 | import entitesActions from 'your/entities-actions'; 129 | 130 | dispatch(entitesActions.NEWS_TAG.load()); // GET: api/v2/news-tags 131 | dispatch(entitesActions.NEWS_TAG.load(1)); // GET: api/v2/news-tags/1 132 | 133 | // --- NOTE: HTTP methods for create and update operations can be configured 134 | dispatch(entitesActions.NEWS_TAG.update(1, {name: 'new tag'})); // PATCH: api/v2/news-tags/1 135 | dispatch(entitesActions.NEWS_TAG.create({name: 'new tag'})); // POST: api/v2/news-tags 136 | dispatch(entitesActions.NEWS_TAG.remove(1)); // DELETE: api/v2/news-tags/1 137 | 138 | // --- Silent methods for changing store without sync with backend 139 | dispatch(entitesActions.NEWS_TAG.set({name: 'new tag'})); // set new data 140 | dispatch(entitesActions.NEWS_TAG.reset()); // reset to initial state 141 | 142 | ``` 143 | 144 | ### React component example 145 | ```js 146 | import entitesActions from 'your/entities-actions'; 147 | 148 | class TagsComponent extends Component { 149 | componentWillMount() { 150 | this.props.loadList(); 151 | } 152 | 153 | //.. 154 | 155 | componentWillUnmount() { 156 | this.props.resetEntryForEdit(); 157 | } 158 | 159 | onTagFormSubmit = ()=> { 160 | const data = this.props.tagForEdit; 161 | if (data.id) { 162 | this.props.updateTag(data.id, data); 163 | } else { 164 | this.props.createTag(data); 165 | } 166 | } 167 | 168 | render() { 169 | return ( 170 | this.props.pending ? 171 | : 172 |
173 | {/*...*/} 174 |
175 | ); 176 | } 177 | } 178 | 179 | const mapStateToProps = (state) => ({ 180 | list: state.api.NEWS_TAGS.data || [], 181 | pending: state.api.NEWS_TAGS._pending, 182 | tagForEdit: state.api.NEWS_TAG_FOR_EDIT.data || {} 183 | }); 184 | 185 | const mapDispatchToProps = { 186 | createTag: entitiesActions.NEWS_TAG_FOR_EDIT.create, 187 | updateTag: entitiesActions.NEWS_TAG_FOR_EDIT.update, 188 | resetEntryForEdit: entitiesActions.NEWS_TAG_FOR_EDIT.reset, 189 | loadList: entitiesActions.NEWS_TAGS.load 190 | }; 191 | 192 | const TagsContainer = connect(mapStateToProps, mapDispatchToProps)(TagsComponent); 193 | 194 | export {TagsComponent, TagsContainer}; 195 | ``` 196 | 197 | ## Configuration 198 | 199 | ### EntityApi constructor options 200 | 201 | Name | Type | Default | Description 202 | --- | --- | --- | --- 203 | `entityName` | `String` | *Required.* will be used for naming state and actionTypes. 204 | `endpointUrl` | `String | *Required.* endpointUrl 205 | `reducersBuilderCustom` | `Object`| `reducersBuilderDefault` | Customer can redefine interface of reducers-builder.js 206 | `axiosConfig` | `Object`| `{}` | [axios config](https://github.com/mzabriskie/axios#request-config) 207 | `resourceKey` | `String`| `'data'` | Name of data property key at response object 208 | `idKey` | `String`| `'id'` | Name of id property key at response data object. Required for CRUD reducer extensions 209 | `restHttpMethods` | `Object`| `{create:'post', update:'patch'}` | Customer can change HTTP methods used for create and update actions 210 | 211 | 212 | ## TODO 213 | 214 | Example of generated list reducer (basic CRUD operations) 215 | 216 | ### See also 217 | 218 | [redux-localstorage-adapter](https://www.npmjs.com/package/redux-localstorage-adapter) 219 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-rest-adapter", 3 | "version": "2.0.7", 4 | "description": "REST adapter for redux", 5 | "main": "lib/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/maksim-chekrishov/redux-rest-adapter" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/maksim-chekrishov/redux-rest-adapter/issues" 12 | }, 13 | "keywords": [ 14 | "redux", 15 | "rest-api", 16 | "rest", 17 | "rest-client", 18 | "rest-adapter", 19 | "redux-rest" 20 | ], 21 | "scripts": { 22 | "build": "export NODE_ENV=development; yarn && $(npm bin)/babel src -d lib", 23 | "test": "npm run build && jest", 24 | "send-cover": "CODECLIMATE_REPO_TOKEN=b2b59ab7c1c4c064063ea46e2462af844a9c6b000529811379d7b2a666e23285 codeclimate-test-reporter < ./coverage/lcov.info", 25 | "prepub": "rm -rf node_modules && npm t && npm run send-cover" 26 | }, 27 | "author": "Maksim Chekrishov (https://github.com/maksim-chekrishov)", 28 | "license": "ISC", 29 | "dependencies": { 30 | "axios": "0.15.2", 31 | "redux-promise-middleware": "4.2.0" 32 | }, 33 | "devDependencies": { 34 | "axios-mock-adapter": "^1.7.1", 35 | "babel-cli": "^6.0.0", 36 | "babel-core": "latest", 37 | "babel-eslint": "latest", 38 | "babel-jest": "^17.0.2", 39 | "babel-plugin-transform-object-assign": "^6.22.0", 40 | "babel-polyfill": "^6.20.0", 41 | "babel-preset-es2015": "~6.0.15", 42 | "babel-preset-stage-0": "~6.0.15", 43 | "eslint": "~1.10.3", 44 | "eslint-config-rambler": "0.0.3", 45 | "jest-cli": "17.0.2", 46 | "lodash": "latest", 47 | "redux": "^3.6.0", 48 | "redux-mock-store": "^1.2.1" 49 | }, 50 | "jest": { 51 | "collectCoverage": true, 52 | "collectCoverageFrom": [ 53 | "lib/*.js" 54 | ], 55 | "setupFiles": [ 56 | "tests/config/global-mocks.js" 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /readme-src/dev-tools-1v.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maksim-chekrishov/redux-rest-adapter/4396f55c7164519345885018112caf63b73bf3b6/readme-src/dev-tools-1v.jpg -------------------------------------------------------------------------------- /readme-src/dev-tools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maksim-chekrishov/redux-rest-adapter/4396f55c7164519345885018112caf63b73bf3b6/readme-src/dev-tools.png -------------------------------------------------------------------------------- /readme-src/readme-1v.md: -------------------------------------------------------------------------------- 1 | # redux-rest-adapter 2 | 3 | redux-rest-adapter is REST adapter for redux based on [redux-api-middleware](https://www.npmjs.com/package/redux-api-middleware) 4 | 5 | [![npm version](https://badge.fury.io/js/redux-rest-adapter.svg)](https://badge.fury.io/js/redux-rest-adapter) 6 | 7 | ##Setup 8 | 9 | ###known-entities-api.js 10 | 11 | ```js 12 | import EntityApi from 'redux-rest-adapter'; 13 | 14 | export const KnownEntitiesUrls = { 15 | NEWS_TAGS: 'news-tags', 16 | NEWS_TAG_FOR_EDIT: 'news-tags', 17 | //.. 18 | }; 19 | export default _.mapValues(KnownEntitiesUrls, (url, name)=> new EntityApi({ 20 | entityName: name, 21 | endpointUrl: config.endpointRoot + url, 22 | resourceKey: 'result' // data key at payload, default - 'data' 23 | })); 24 | ``` 25 | 26 | ###entities-reducer.js 27 | 28 | ```js 29 | // Ability to extend default api reducers 30 | const entityReducersExtensions = { 31 | NEWS_TAGS: tagsReducer 32 | } 33 | 34 | const entitiesReducers = _.mapValues(knownEntitiesApi, (api, key) => api.configureReducer(entityReducersExtensions[key])); 35 | 36 | export default combineReducers(entitiesReducers); 37 | ``` 38 | 39 | ###your-index-reducer.js 40 | 41 | ```js 42 | export default combineReducers({ 43 | entities: entitiesReducers 44 | //.. 45 | }); 46 | ``` 47 | 48 | ###configure-store.js 49 | 50 | ```js 51 | import {apiMiddleware} from 'redux-rest-adapter/redux-api-middleware'; 52 | //.. 53 | 54 | 55 | export default function configureStore(initialState) { 56 | return createStore( 57 | yourIndexReducer, 58 | initialState, 59 | applyMiddleware(apiMiddleware) 60 | ); 61 | } 62 | ``` 63 | 64 | ###entities-actions.js 65 | 66 | ```js 67 | export default _.mapValues(knownEntitiesApi, entityApi => entityApi.actions); 68 | ``` 69 | 70 | 71 | ##Adapter is ready 72 | 73 | ![Image devTools](https://raw.githubusercontent.com/maksim-chekrishov/redux-rest-adapter/master/readme-src/dev-tools-1v.jpg) 74 | 75 | ##Usage 76 | 77 | ###tags-container.js 78 | ```js 79 | class TagsComponent extends Component { 80 | componentWillMount() { 81 | this.props.loadList(); 82 | } 83 | 84 | //.. 85 | 86 | componentWillUnmount() { 87 | this.props.resetEntryForEdit(); 88 | } 89 | 90 | onTagFormSubmit = ()=> { 91 | const data = this.props.tagForEdit; 92 | if (data.id) { 93 | this.props.updateTag(data.id, data); 94 | } else { 95 | this.props.createTag(data); 96 | } 97 | } 98 | 99 | render() { 100 | return ( 101 | this.props.pending ? 102 | : 103 |
104 | {/*...*/} 105 |
106 | ); 107 | } 108 | } 109 | 110 | const mapStateToProps = (state) => ({ 111 | list: state.entities.NEWS_TAGS.data || [], 112 | pending: state.entities.NEWS_TAGS.pending, 113 | tagForEdit: state.entities.NEWS_TAG_FOR_EDIT.data || {} 114 | }); 115 | 116 | const mapDispatchToProps = { 117 | createTag: entitiesActions.NEWS_TAG_FOR_EDIT.create, 118 | updateTag: entitiesActions.NEWS_TAG_FOR_EDIT.update, 119 | removeTag: entitiesActions.NEWS_TAG_FOR_EDIT.remove, 120 | setTagForEdit: entitiesActions.NEWS_TAG_FOR_EDIT.set, 121 | resetEntryForEdit: entitiesActions.NEWS_TAG_FOR_EDIT.reset, 122 | loadList: entitiesActions.NEWS_TAGS.load 123 | }; 124 | 125 | const TagsContainer = connect(mapStateToProps, mapDispatchToProps)(TagsComponent); 126 | 127 | export {TagsComponent, TagsContainer}; 128 | ``` 129 | 130 | ##TODO 131 | 132 | Example of generated list reducer (basic CRUD operations) 133 | 134 | ##Changelog 135 | 136 | - v1.1.6 - Add source code to package. Workaround for issue with [babel and redux-api-middleware@1.1.2](https://github.com/agraboso/redux-api-middleware/issues/83) 137 | 138 | 139 | 140 | ###See also 141 | 142 | [redux-localstorage-adapter](https://www.npmjs.com/package/redux-localstorage-adapter) 143 | -------------------------------------------------------------------------------- /src/entity-api.js: -------------------------------------------------------------------------------- 1 | import reducersBuilderDefault from './reducers-builder'; 2 | import axios from 'axios'; 3 | 4 | export const RestMethods = { 5 | LOAD: 'load', 6 | CREATE: 'create', 7 | UPDATE: 'update', 8 | REMOVE: 'remove' 9 | }; 10 | 11 | export const RestHttpMethodsDefault = { 12 | 'create': 'post', 13 | 'update': 'patch' 14 | }; 15 | 16 | export const SilentMethods = { 17 | SET: 'set', 18 | RESET: 'reset' 19 | }; 20 | 21 | export const RequestStatusesDefault = { 22 | REQUEST: 'REQUEST', 23 | SUCCESS: 'SUCCESS', 24 | FAIL: 'FAIL' 25 | }; 26 | 27 | 28 | export default class EntityApi { 29 | 30 | _resourceKey = 'data' 31 | 32 | _requestStatuses = [RequestStatusesDefault.REQUEST, RequestStatusesDefault.SUCCESS, RequestStatusesDefault.FAIL]; 33 | 34 | _reducersBuilder = reducersBuilderDefault; 35 | 36 | _restHttpMethods = RestHttpMethodsDefault; 37 | 38 | _axiosConfig = {}; 39 | 40 | _idKey = 'id' 41 | 42 | /** 43 | * Constructor 44 | * 45 | * @param {Object} options 46 | * @param {string} options.entityName - will be used for naming actionTypes 47 | * @param {string} options.endpointUrl 48 | * @param {Object} [options.reducersBuilderCustom = reducersBuilderDefault] 49 | * @param {Object} [options.axiosConfig = _axiosConfig] - options for redux-api-middleware 50 | * @param {string} [options.resourceKey = _resourceKey] - payload resource key (entity data key) 51 | * @param {Object} [options.restHttpMethods = RestHttpMethodsDefault] - Customer can change http methods for create and update actions 52 | * @param {string} [options.idKey = _idKey] - payload id key (entity id key) 53 | */ 54 | constructor(options) { 55 | if (!options || !options.entityName || !options.endpointUrl) { 56 | throw new Error('entityName and endpointUrl are required'); 57 | } 58 | 59 | this._entityName = options.entityName; 60 | this._endpointUrl = options.endpointUrl; 61 | 62 | // Options with default values 63 | this._reducersBuilder = options.reducersBuilderCustom || reducersBuilderDefault; 64 | this._resourceKey = options.resourceKey || this._resourceKey; 65 | this._restHttpMethods = options.restHttpMethods || this._restHttpMethods; 66 | this._idKey = options.idKey || this._idKey; 67 | this._axiosConfig = options.axiosConfig || this._axiosConfig; 68 | } 69 | 70 | /** 71 | * Provide actions for api instance 72 | * 73 | * @returns {Object} 74 | */ 75 | get actions() { 76 | const _this = this; 77 | const actions = {}; 78 | 79 | const allMethods = {...RestMethods, ...SilentMethods}; 80 | 81 | for (let key in allMethods) { 82 | if (allMethods.hasOwnProperty(key)) { 83 | const methodName = allMethods[key]; 84 | actions[methodName] = _this[methodName].bind(_this); 85 | } 86 | } 87 | 88 | return actions; 89 | } 90 | 91 | get actionsTypes() { 92 | const _this = this; 93 | 94 | // Private property for lazy getter 95 | if (!this._actionsTypes) { 96 | this._actionsTypes = {}; 97 | 98 | for (let key in RestMethods) { 99 | if (RestMethods.hasOwnProperty(key)) { 100 | const methodName = RestMethods[key]; 101 | const requestStatusActionsOptions = _this.generateRequestActionsOptions(methodName); 102 | const res = {}; 103 | 104 | this._requestStatuses.map((status, i)=> { 105 | res[status] = requestStatusActionsOptions[i].type; 106 | }); 107 | 108 | _this._actionsTypes[key] = res; 109 | } 110 | } 111 | 112 | for (let key in SilentMethods) { 113 | if (SilentMethods.hasOwnProperty(key)) { 114 | _this.actionsTypes[key] = _this._getActionTypeForMethod(SilentMethods[key]); 115 | } 116 | } 117 | } 118 | 119 | return this._actionsTypes; 120 | } 121 | 122 | _getActionTypeForMethod(apiMethodName) { 123 | return `${this._entityName}_${apiMethodName.toUpperCase()}`; 124 | } 125 | 126 | generateRequestActionsOptions(methodName) { 127 | return this._requestStatuses.map(eventName => ({ 128 | type: `${this._entityName}_${methodName.toUpperCase()}_${eventName}` 129 | })); 130 | } 131 | 132 | /** 133 | * Generate reducer for current api instance 134 | * @param {Function} [reducerExtension] 135 | * @param {Object} [initialState = {}] 136 | */ 137 | configureReducer(reducerExtension, initialState = {}) { 138 | return this._reducersBuilder.build(this.actionsTypes, reducerExtension, this._resourceKey, initialState); 139 | } 140 | 141 | /** 142 | * Parse options for load method 143 | * 144 | * @param options 145 | * @return {{queryString: string, params: {}}} 146 | * @private 147 | */ 148 | _parseLoadOptions(options) { 149 | let queryString = ''; 150 | let params = {}; 151 | 152 | const paramsType = typeof options; 153 | 154 | switch (paramsType) { 155 | case 'string': 156 | case 'number': 157 | if (options + '') { 158 | queryString = `/${options}`; 159 | } 160 | break; 161 | 162 | case 'object': 163 | const hasPath = options.hasOwnProperty('path'); 164 | const hasParams = options.hasOwnProperty('params'); 165 | 166 | if (!hasPath && !hasParams) { 167 | params = options; 168 | break; 169 | } 170 | if (hasPath) { 171 | queryString = `/${options.path}`; 172 | } 173 | 174 | if (hasParams) { 175 | params = options.params; 176 | } 177 | break; 178 | 179 | default: 180 | break; 181 | } 182 | 183 | return {queryString, params}; 184 | } 185 | 186 | 187 | /** 188 | * Load entity 189 | * 190 | * @param {Object | Number | string} options 191 | * @returns {Object} 192 | * 193 | * @example 194 | * 195 | * entityName.load(1); // get: /entity-name/1 196 | * entityName.load('sub/path'); // get: /entity-name/sub/path 197 | * entityName.load({mode:'short', 'page[limit]':10}}); // get: /entity-name?mode=short&page[limit]=10 198 | * entityName.load({path:'11', params:{mode:'short'}}); // get: /entity-name/11?mode=short 199 | */ 200 | [RestMethods.LOAD](options) { 201 | const {queryString, params} = this._parseLoadOptions(options); 202 | 203 | const config = Object.assign({}, this._axiosConfig, {params: params}); 204 | 205 | return { 206 | type: this._getActionTypeForMethod(RestMethods.LOAD), 207 | payload: axios.get(`${this._endpointUrl}${queryString}`, config) 208 | .then(res => res.data) 209 | }; 210 | } 211 | 212 | [RestMethods.CREATE](entity) { 213 | const createMethodName = this._restHttpMethods.create; 214 | const data = {[this._resourceKey]: entity}; 215 | 216 | return { 217 | type: this._getActionTypeForMethod(RestMethods.CREATE), 218 | payload: axios[createMethodName](this._endpointUrl, data, this._axiosConfig) 219 | .then(res => res.data) 220 | }; 221 | } 222 | 223 | [RestMethods.UPDATE](id, entity) { 224 | const updateMethodName = this._restHttpMethods.update; 225 | const data = {[this._resourceKey]: entity}; 226 | 227 | return { 228 | type: this._getActionTypeForMethod(RestMethods.UPDATE), 229 | payload: axios[updateMethodName](`${this._endpointUrl}/${id}`, data, this._axiosConfig) 230 | .then(res => res.data) 231 | }; 232 | } 233 | 234 | /** 235 | * Remove entity by id or entity object 236 | * 237 | * @param {string} [id=''] 238 | * @returns {Object} 239 | */ 240 | [RestMethods.REMOVE](id = '') { 241 | return { 242 | type: this._getActionTypeForMethod(RestMethods.REMOVE), 243 | payload: axios.delete(`${this._endpointUrl}/${id}`, this._axiosConfig).then(res => res.data), 244 | meta: {[this._idKey]: id} 245 | }; 246 | } 247 | 248 | /** 249 | * Provide ability to set data into entity storage 250 | * without server synchronization 251 | * 252 | * @param data 253 | * @returns {{type: string, payload: {result: *}}} 254 | * @constructor 255 | */ 256 | [SilentMethods.SET](resource) { 257 | return { 258 | type: this._getActionTypeForMethod(SilentMethods.SET), 259 | payload: { 260 | [this._resourceKey]: resource 261 | } 262 | }; 263 | } 264 | 265 | /** 266 | * Provide ability to reset resource in entity storage 267 | * without server synchronization 268 | * 269 | * @returns {{type: string, payload: {result: *}}} 270 | * @constructor 271 | */ 272 | [SilentMethods.RESET]() { 273 | return { 274 | type: this._getActionTypeForMethod(SilentMethods.RESET) 275 | }; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by m.chekryshov on 12.12.16. 3 | */ 4 | import EntityApi, {RequestStatusesDefault} from './entity-api'; 5 | import ReducersBuilder from './reducers-builder'; 6 | import reduxPromiseMiddleware from 'redux-promise-middleware'; 7 | 8 | export {promiseMiddleware, ReducersBuilder, EntityApi}; 9 | 10 | export default EntityApi; 11 | 12 | /** 13 | * 14 | * @param {Object} options - reduxPromiseMiddleware options 15 | */ 16 | function promiseMiddleware(options) { 17 | const optionsDefault = { 18 | promiseTypeSuffixes: [RequestStatusesDefault.REQUEST, RequestStatusesDefault.SUCCESS, RequestStatusesDefault.FAIL] 19 | }; 20 | 21 | return reduxPromiseMiddleware({...optionsDefault, ...options}) 22 | } 23 | -------------------------------------------------------------------------------- /src/reducers-builder.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by m.chekryshov on 18.12.16. 3 | */ 4 | 5 | import {containsString, hasCyclicReferences} from './utils'; 6 | 7 | class ReducersBuilder { 8 | /** 9 | * Configure default list reducer extension with basic reactions on CRUD actions 10 | * 11 | * @param {Object} actionsTypes - at least one of actions types is required 12 | * @param {string || Array.} [actionsTypes.createSuccess] 13 | * @param {string || Array.} [actionsTypes.updateSuccess] 14 | * @param {string || Array.} [actionsTypes.deleteSuccess] 15 | * @param {string} [resourceKey= 'data'] - response resource prop name 16 | * @param {string} [idKey='id'] - resource id prop name 17 | * @returns {Function} reducerExtension 18 | */ 19 | static buildCRUDExtensionsForList({createSuccess, updateSuccess, deleteSuccess}, resourceKey, idKey) { 20 | resourceKey = resourceKey || 'data'; 21 | idKey = idKey || 'id'; 22 | 23 | const opt = arguments[0]; 24 | 25 | for (let key in opt) { 26 | if (opt.hasOwnProperty(key)) { 27 | // convert to array 28 | opt[key] = [].concat(opt[key]); 29 | } 30 | } 31 | 32 | return function(state = {}, action = {}) { 33 | let id; 34 | let clone; 35 | let result; 36 | const actionType = action.type; 37 | 38 | if (deleteSuccess && deleteSuccess.indexOf(actionType) !== -1) { 39 | /** 40 | * after success we need remove item from list 41 | */ 42 | id = action.meta[idKey]; 43 | clone = Object.assign({}, state); 44 | clone[resourceKey] = clone[resourceKey] ? clone[resourceKey].filter(item=> item[idKey] !== id) : []; 45 | return clone; 46 | } else if (updateSuccess && updateSuccess.indexOf(actionType) !== -1) { 47 | /** 48 | * after successfully update we need update item at the list 49 | */ 50 | id = action.payload[resourceKey][idKey]; 51 | result = action.payload[resourceKey]; 52 | clone = Object.assign({}, state); 53 | clone[resourceKey] = clone[resourceKey] ? clone[resourceKey].map((item)=>(item[idKey] !== id ? item : result)) : []; 54 | return clone; 55 | } else if (createSuccess && createSuccess.indexOf(actionType) !== -1) { 56 | /** 57 | * after creation new item we need add it to the list 58 | */ 59 | result = action.payload[resourceKey]; 60 | clone = Object.assign({}, state); 61 | clone[resourceKey] = clone[resourceKey] ? clone[resourceKey].concat([result]) : []; 62 | return clone; 63 | } 64 | return state; 65 | }; 66 | } 67 | 68 | /** 69 | * Generate reducers for supplied entity api 70 | * 71 | * @param {Object} actionsTypesTree 72 | * @param {Function | Array.} [reducerExtensions] 73 | * @param {string} [resourceKey= 'data'] - response resource prop name 74 | * @param {Object} [initialState= {}] 75 | * @param {String} [operationsFlags="CRUD"] 76 | * @returns {Function} reducer 77 | */ 78 | static build(actionsTypesTree, reducerExtensions, resourceKey = 'data', initialState = {}, operationsFlags = 'CRUDS') { 79 | const normalizedFlags = operationsFlags.toLowerCase(); 80 | 81 | const reducerParts = []; 82 | 83 | 84 | // Extension has top level priority to provide ability override default behaviour 85 | if (reducerExtensions) { 86 | Array.isArray(reducerExtensions) 87 | ? reducerParts.push(...reducerExtensions) 88 | : reducerParts.push(reducerExtensions); 89 | } 90 | 91 | containsString(normalizedFlags, 'c') && reducerParts.push(this._buildReducerForOperation(actionsTypesTree.CREATE)); 92 | containsString(normalizedFlags, 'r') && reducerParts.push(this._buildReducerForOperation(actionsTypesTree.LOAD)); 93 | containsString(normalizedFlags, 'u') && reducerParts.push(this._buildReducerForOperation(actionsTypesTree.UPDATE)); 94 | containsString(normalizedFlags, 'd') && reducerParts.push(this._buildReducerForOperation(actionsTypesTree.REMOVE)); 95 | 96 | // Silent actions 97 | containsString(normalizedFlags, 's') && reducerParts.push(this._buildSilentActionsReducer(actionsTypesTree, resourceKey, initialState)); 98 | 99 | return function(state = initialState, action = {}) { 100 | let _state = state; 101 | 102 | reducerParts.forEach((reduce)=> { 103 | _state = reduce(_state, action); 104 | }); 105 | 106 | return _state; 107 | }; 108 | } 109 | 110 | static _buildSilentActionsReducer(actionsTypesTree, resourceKey, initialState) { 111 | return (state, action) => { 112 | switch (action.type) { 113 | 114 | case actionsTypesTree.RESET: 115 | return initialState; 116 | 117 | 118 | case actionsTypesTree.SET: 119 | return Object.assign({}, state, { 120 | [resourceKey]: Array.isArray(state[resourceKey]) 121 | ? [].concat(action.payload[resourceKey]) 122 | : Object.assign({}, state[resourceKey], action.payload[resourceKey]) 123 | }); 124 | 125 | default: 126 | return state; 127 | } 128 | } 129 | } 130 | 131 | static _buildReducerForOperation(operationActionsTypes) { 132 | return (state, action) => { 133 | const actionType = action.type; 134 | 135 | if (operationActionsTypes.REQUEST === actionType) { 136 | return this._reduceRequest(state, action); 137 | } else if (operationActionsTypes.SUCCESS === actionType) { 138 | return this._reduceSuccess(state, action); 139 | } else if (operationActionsTypes.FAIL === actionType) { 140 | return this._reduceFail(state, action); 141 | } 142 | return state; 143 | }; 144 | } 145 | 146 | 147 | // 148 | // Common reducer functions 149 | // 150 | 151 | static _reduceRequest(state, action) { 152 | return Object.assign({}, state, { 153 | _pending: true, 154 | _actionMeta: action.meta, 155 | _error: !!action.error, 156 | ...action.payload 157 | }); 158 | } 159 | 160 | static _reduceSuccess(state, action) { 161 | return Object.assign({}, state, { 162 | _pending: false, 163 | _actionMeta: action.meta, 164 | _error: false, 165 | ...action.payload 166 | }); 167 | } 168 | 169 | static _reduceFail(state, action) { 170 | // console.log('cicle', action.payload.response.request) 171 | let payload = action.payload; 172 | 173 | if (hasCyclicReferences(payload)) { 174 | if (payload.response) { 175 | // The request was made, but the server responded with a status code 176 | // that falls out of the range of 2xx 177 | const {data, status, headers} = payload.response; 178 | payload = {data, status, headers}; 179 | } else { 180 | // Something happened in setting up the request that triggered an Error 181 | payload = {message: payload.message}; 182 | } 183 | } 184 | 185 | return Object.assign({}, state, { 186 | _pending: false, 187 | _actionMeta: action.meta, 188 | _error: true, 189 | ...payload 190 | }); 191 | } 192 | } 193 | 194 | export default ReducersBuilder; 195 | 196 | 197 | 198 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by m.chekryshov on 20.12.16. 3 | */ 4 | 5 | export function containsString(str, value) { 6 | return str.indexOf(value) !== -1; 7 | } 8 | 9 | export function hasCyclicReferences(obj) { 10 | var seenObjects = []; 11 | 12 | function detect(obj) { 13 | if (obj && typeof obj === 'object') { 14 | if (seenObjects.indexOf(obj) !== -1) { 15 | return true; 16 | } 17 | seenObjects.push(obj); 18 | for (var key in obj) { 19 | if (obj.hasOwnProperty && obj.hasOwnProperty(key) && detect(obj[key])) { 20 | return true; 21 | } 22 | } 23 | } 24 | return false; 25 | } 26 | 27 | return detect(obj); 28 | } 29 | -------------------------------------------------------------------------------- /tests/config/global-mocks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by m.chekryshov on 21.12.16. 3 | */ 4 | import axios from 'axios'; 5 | import AxiosMockAdapter from 'axios-mock-adapter'; 6 | import configureMockStore from 'redux-mock-store'; 7 | import {promiseMiddleware} from '../../lib' 8 | 9 | global.mockAxios = new AxiosMockAdapter(axios); 10 | 11 | global.mockStore = configureMockStore([promiseMiddleware()]); 12 | -------------------------------------------------------------------------------- /tests/integration/api-actions.test.js: -------------------------------------------------------------------------------- 1 | import EntityApi from '../../lib'; 2 | 3 | describe('EntityApi', () => { 4 | const resourceKey = 'testResKey'; 5 | let store, 6 | apiInstance, 7 | ActionsTypes, 8 | expectedResource; 9 | 10 | beforeEach(() => { 11 | store = mockStore({entities: {TEST: {}}}); 12 | //store.dispatch = jest.fn(store.dispatch); 13 | 14 | apiInstance = new EntityApi({ 15 | entityName: 'TEST', 16 | endpointUrl: '/test' 17 | }); 18 | 19 | expectedResource = {id: 1, title: 'TITLE'}; 20 | ActionsTypes = apiInstance.actionsTypes; 21 | }) 22 | 23 | afterEach(()=> { 24 | mockAxios.reset(); 25 | }) 26 | 27 | // --------------------- LOAD -------------------------- 28 | 29 | it('should be able to load resource', () => { 30 | mockAxios.onGet('/test').reply(200, {[resourceKey]: expectedResource}); 31 | 32 | const expectedActions = [ 33 | {type: ActionsTypes.LOAD.REQUEST}, 34 | {type: ActionsTypes.LOAD.SUCCESS, payload: {[resourceKey]: expectedResource}} 35 | ]; 36 | 37 | return store.dispatch(apiInstance.actions.load()).then(res => { 38 | return expect(store.getActions()).toEqual(expectedActions); 39 | }); 40 | }) 41 | 42 | it('should be able to load resource by id', () => { 43 | const expectedActions = [ 44 | {type: ActionsTypes.LOAD.REQUEST}, 45 | {type: ActionsTypes.LOAD.SUCCESS, payload: {[resourceKey]: expectedResource}} 46 | ]; 47 | 48 | mockAxios.onGet('/test/1').reply(200, {[resourceKey]: expectedResource}); 49 | 50 | return store.dispatch(apiInstance.actions.load(1)).then(res => { 51 | return expect(store.getActions()).toEqual(expectedActions); 52 | }); 53 | }) 54 | 55 | // todo: need research axions mock doesn't work with params 56 | xit('should be able to load resource by id with params', () => { 57 | const expectedActions = [ 58 | {type: ActionsTypes.LOAD.REQUEST}, 59 | {type: ActionsTypes.LOAD.SUCCESS, payload: {[resourceKey]: expectedResource}} 60 | ]; 61 | 62 | mockAxios.onGet('/test/1?param1=1').reply(200, {[resourceKey]: expectedResource}); 63 | // mockAxios.onGet().reply(config=> { 64 | // console.log(config); 65 | // }); 66 | 67 | return store.dispatch(apiInstance.actions.load({id: 1, param1: 1})).then(() => { 68 | return expect(store.getActions()).toEqual(expectedActions); 69 | }); 70 | }) 71 | 72 | // todo: need research axions mock doesn't work with params 73 | xit('should be able to load resource with complex param', () => { 74 | const expectedActions = [ 75 | {type: ActionsTypes.LOAD.REQUEST}, 76 | {type: ActionsTypes.LOAD.SUCCESS, payload: {[resourceKey]: expectedResource}} 77 | ]; 78 | 79 | mockAxios.onGet('/test?filter[order]=1').reply(200, {[resourceKey]: expectedResource}); 80 | 81 | return store.dispatch(apiInstance.actions.load({'filter[order]': '1'})).then(() => { 82 | return expect(store.getActions()).toEqual(expectedActions); 83 | }); 84 | }) 85 | 86 | it('should be able to process load fail', () => { 87 | mockAxios.onGet('/test/2').reply(500, {}); 88 | 89 | return store.dispatch(apiInstance.actions.load(2)).catch(res => { 90 | let lastAction = store.getActions(); 91 | lastAction = lastAction[lastAction.length - 1]; 92 | 93 | const hasError = !!lastAction.error; 94 | const isFailed = lastAction.type === ActionsTypes.LOAD.FAIL; 95 | 96 | return expect(isFailed && hasError && res.response.status == 500).toEqual(true); 97 | }); 98 | }) 99 | 100 | 101 | // --------------------- CREATE -------------------------- 102 | 103 | it('should be able to create resource', () => { 104 | const expectedActions = [ 105 | {type: ActionsTypes.CREATE.REQUEST}, 106 | {type: ActionsTypes.CREATE.SUCCESS, payload: {[resourceKey]: expectedResource}} 107 | ]; 108 | 109 | mockAxios.onPost('/test').reply(200, {[resourceKey]: expectedResource}); 110 | 111 | return store.dispatch(apiInstance.actions.create(expectedResource)).catch(res => { 112 | const actions = store.getActions(); 113 | 114 | return expect(actions).toEqual(expectedActions); 115 | }); 116 | }) 117 | 118 | it('should be able to process create fail', () => { 119 | mockAxios.onPost('/test').reply(500, {}); 120 | 121 | return store.dispatch(apiInstance.actions.create()).catch(res => { 122 | let lastAction = store.getActions(); 123 | lastAction = lastAction[lastAction.length - 1]; 124 | 125 | return expect(!!lastAction.error && 126 | lastAction.type === ActionsTypes.CREATE.FAIL && 127 | res.response.status == 500 128 | ).toEqual(true); 129 | }); 130 | }) 131 | 132 | 133 | // --------------------- UPDATE -------------------------- 134 | 135 | it('should be able to update resource', () => { 136 | mockAxios.onPatch('/test/1').reply(200, {[resourceKey]: expectedResource}); 137 | 138 | return store.dispatch(apiInstance.actions.update(1)).then(res => { 139 | const actions = store.getActions(); 140 | const lastAction = actions[actions.length - 1]; 141 | 142 | return expect(lastAction.type === ActionsTypes.UPDATE.SUCCESS).toEqual(true); 143 | }); 144 | }) 145 | 146 | it('should be able to process create fail', () => { 147 | mockAxios.onPatch('/test/44').reply(500, {}); 148 | 149 | return store.dispatch(apiInstance.actions.update(44)).catch(res => { 150 | let lastAction = store.getActions(); 151 | lastAction = lastAction[lastAction.length - 1]; 152 | 153 | return expect(!!lastAction.error && 154 | lastAction.type === ActionsTypes.UPDATE.FAIL && 155 | res.response.status == 500 156 | ).toEqual(true); 157 | }); 158 | }) 159 | 160 | // --------------------- DELETE -------------------------- 161 | 162 | it('should be able remove source', () => { 163 | mockAxios.onAny('/test/1').reply(200, {}); 164 | 165 | return store.dispatch(apiInstance.actions.remove(1)).then(res => { 166 | const actions = store.getActions(); 167 | const lastAction = actions[actions.length - 1]; 168 | 169 | return expect(lastAction.type).toEqual(ActionsTypes.REMOVE.SUCCESS); 170 | }); 171 | }) 172 | 173 | it('should be able to process remove fail', () => { 174 | mockAxios.onAny('/test/44').reply(500, {}); 175 | 176 | return store.dispatch(apiInstance.actions.remove(44)).catch(res => { 177 | let lastAction = store.getActions(); 178 | lastAction = lastAction[lastAction.length - 1]; 179 | 180 | return expect(!!lastAction.error && 181 | lastAction.type === ActionsTypes.REMOVE.FAIL && 182 | res.response.status == 500 183 | ).toEqual(true); 184 | }); 185 | }) 186 | 187 | 188 | describe('should provide ability to override http method for ', ()=> { 189 | 190 | beforeEach(()=> { 191 | apiInstance = new EntityApi({ 192 | entityName: 'TEST', 193 | endpointUrl: '/test', 194 | restHttpMethods: {create: 'put', update: 'post'} 195 | }); 196 | }) 197 | 198 | it('update', () => { 199 | mockAxios.onPost('/test/1').reply(200, {[resourceKey]: expectedResource}); 200 | 201 | return store.dispatch(apiInstance.actions.update(1)).then(res => { 202 | const actions = store.getActions(); 203 | const lastAction = actions[actions.length - 1]; 204 | 205 | return expect(lastAction.type).toEqual(ActionsTypes.UPDATE.SUCCESS); 206 | }); 207 | }) 208 | 209 | it('create', () => { 210 | const expectedActions = [ 211 | {type: ActionsTypes.CREATE.REQUEST}, 212 | {type: ActionsTypes.CREATE.SUCCESS, payload: {[resourceKey]: expectedResource}} 213 | ]; 214 | 215 | mockAxios.onPut('/test').reply(200, {[resourceKey]: expectedResource}); 216 | 217 | return store.dispatch(apiInstance.actions.create(expectedResource)).catch(res => { 218 | const actions = store.getActions(); 219 | 220 | return expect(actions).toEqual(expectedActions); 221 | }); 222 | }) 223 | 224 | }); 225 | 226 | describe('should provide ability to use axios config for', ()=> { 227 | const axiosConfig = {timeout: 99}; 228 | 229 | beforeEach(()=> { 230 | apiInstance = new EntityApi({ 231 | entityName: 'TEST', 232 | endpointUrl: '/test', 233 | axiosConfig 234 | }); 235 | 236 | mockAxios.onAny().reply(config=>[200, config]); 237 | }) 238 | 239 | it('load', () => { 240 | return store.dispatch(apiInstance.actions.load(1)).then(config => { 241 | return expect(config.value.timeout).toEqual(axiosConfig.timeout); 242 | }); 243 | }); 244 | 245 | it('update', () => { 246 | return store.dispatch(apiInstance.actions.update(1)).then(config => { 247 | return expect(config.value.timeout).toEqual(axiosConfig.timeout); 248 | }); 249 | }); 250 | 251 | it('remove', () => { 252 | return store.dispatch(apiInstance.actions.remove(1)).then(config => { 253 | return expect(config.value.timeout).toEqual(axiosConfig.timeout); 254 | }); 255 | }); 256 | 257 | it('create', () => { 258 | return store.dispatch(apiInstance.actions.create(1)).then(config => { 259 | return expect(config.value.timeout).toEqual(axiosConfig.timeout); 260 | }); 261 | }); 262 | }); 263 | }) 264 | -------------------------------------------------------------------------------- /tests/integration/demo.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by m.chekryshov on 10.02.17. 3 | */ 4 | import EntityApi, {promiseMiddleware} from '../../lib'; 5 | import {createStore, applyMiddleware, combineReducers} from 'redux'; 6 | 7 | describe('Demo', () => { 8 | 9 | afterEach(()=> { 10 | mockAxios.reset(); 11 | }) 12 | 13 | it('should work', () => { 14 | const expectedResource = {id: 1, title: 'TITLE'}; 15 | 16 | mockAxios.onGet('api/v2/tags/1').reply(200, {data: expectedResource}); 17 | 18 | const tagsApi = new EntityApi({ 19 | entityName: 'TAGS', 20 | endpointUrl: 'api/v2/tags' 21 | }); 22 | 23 | const apiReducers = combineReducers({ 24 | TAGS: tagsApi.configureReducer() 25 | }); 26 | 27 | const store = createStore( 28 | combineReducers({ 29 | api: apiReducers 30 | }), 31 | {}, 32 | applyMiddleware(promiseMiddleware()) 33 | ); 34 | 35 | return store.dispatch(tagsApi.actions.load(1)).then(()=> { 36 | return expect(store.getState().api.TAGS.data).toEqual(expectedResource); 37 | }); 38 | }) 39 | }); 40 | -------------------------------------------------------------------------------- /tests/unit/api-reducers.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by m.chekryshov on 03.10.16. 3 | */ 4 | import {EntityApi, ReducersBuilder} from '../../lib'; 5 | import _ from 'lodash'; 6 | 7 | describe('EntityApi', ()=> { 8 | let entityApiInstance = new EntityApi({ 9 | entityName: 'TEST', 10 | endpointUrl: 'test' 11 | }); 12 | 13 | const resourceKey = 'resourceKey'; 14 | const idKey = 'idKey' 15 | 16 | it('should be able to reset item', ()=> { 17 | const initialState = {1: 1}; 18 | const reducer = entityApiInstance.configureReducer(null, initialState); 19 | const stateBeforeAction = {2: 2}; 20 | const stateAfterAction = reducer(stateBeforeAction, entityApiInstance.reset()); 21 | 22 | expect(stateAfterAction).toEqual(initialState); 23 | }); 24 | 25 | it('should be able to set item', ()=> { 26 | const resourceToSet = {1: 1}; 27 | const reducer = entityApiInstance.configureReducer(null, resourceToSet); 28 | const stateBeforeAction = {data: 1}; 29 | const stateAfterAction = reducer(stateBeforeAction, entityApiInstance.set(resourceToSet)); 30 | 31 | expect(stateAfterAction.data).toEqual(resourceToSet); 32 | }); 33 | 34 | describe('CRUD list extended reducer', ()=> { 35 | const {CreatedActionType, UpdatedActionType, DeletedActionType} = { 36 | CreatedActionType: 'CREATE_SUCCESS', 37 | UpdatedActionType: 'UPDATE_SUCCESS', 38 | DeletedActionType: ['DELETE_SUCCESS', 'ANOTHER_DELETE_SUCCESS'] 39 | }; 40 | 41 | const crudReducerExtension = ReducersBuilder.buildCRUDExtensionsForList({ 42 | createSuccess: CreatedActionType, 43 | updateSuccess: UpdatedActionType, 44 | deleteSuccess: DeletedActionType 45 | }, resourceKey, idKey); 46 | 47 | const crudReducer = entityApiInstance.configureReducer(crudReducerExtension); 48 | 49 | let itemToRemove; 50 | let existedItem; 51 | let itemToCreate; 52 | let stateBeforeAction; 53 | 54 | beforeEach(()=> { 55 | itemToRemove = {[idKey]: 1}; 56 | existedItem = {[idKey]: 2}; 57 | itemToCreate = {[idKey]: 3}; 58 | 59 | stateBeforeAction = {[resourceKey]: [itemToRemove, existedItem]}; 60 | }); 61 | 62 | it('should be able to remove item from list', ()=> { 63 | const action = { 64 | type: DeletedActionType[0], 65 | meta: itemToRemove 66 | }; 67 | 68 | const stateAfterAction = crudReducer(stateBeforeAction, action); 69 | 70 | expect(stateAfterAction[resourceKey].indexOf(itemToRemove)).toEqual(-1); 71 | expect(stateAfterAction[resourceKey].length).toEqual(stateBeforeAction[resourceKey].length - 1); 72 | }); 73 | 74 | it('should be able reduce several action types to one state', ()=> { 75 | const action = { 76 | type: DeletedActionType[1], 77 | meta: itemToRemove 78 | }; 79 | 80 | const stateAfterAction = crudReducer(stateBeforeAction, action); 81 | 82 | expect(stateAfterAction[resourceKey].indexOf(itemToRemove)).toEqual(-1); 83 | //expect(stateAfterAction[resourceKey].length).toEqual(stateBeforeAction[resourceKey].length - 1); 84 | }); 85 | 86 | it('should be able to update item at the list', ()=> { 87 | const updatedTag = {[idKey]: itemToRemove[idKey], name: 'updatedName'}; 88 | 89 | const action = { 90 | type: UpdatedActionType, 91 | payload: {[resourceKey]: updatedTag} 92 | }; 93 | 94 | const stateAfterAction = crudReducer(stateBeforeAction, action); 95 | const tagAfterAction = _.find(stateAfterAction[resourceKey], item=>item[idKey] === updatedTag[idKey]); 96 | 97 | expect(tagAfterAction.name).toEqual(updatedTag.name); 98 | expect(itemToRemove).not.toEqual(updatedTag.name); 99 | expect(stateAfterAction[resourceKey].length).toEqual(stateBeforeAction[resourceKey].length); 100 | }); 101 | 102 | it('should be able to add new item to the list', ()=> { 103 | const action = { 104 | type: CreatedActionType, 105 | payload: {[resourceKey]: itemToCreate} 106 | }; 107 | 108 | expect(stateBeforeAction[resourceKey].some(item=> item[idKey] === itemToCreate[idKey])).toBeFalsy(); 109 | 110 | const stateAfterAction = crudReducer(stateBeforeAction, action); 111 | const tagAfterAction = stateAfterAction[resourceKey].filter(item=>item[idKey] === itemToCreate[idKey]); 112 | 113 | expect(tagAfterAction).toBeDefined(); 114 | expect(stateAfterAction[resourceKey].length).toEqual(stateBeforeAction[resourceKey].length + 1); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /tests/unit/entitity-api-instance.test.js: -------------------------------------------------------------------------------- 1 | import EntityApi from '../../lib'; 2 | 3 | describe('EntityApi', ()=> { 4 | let apiInstance; 5 | 6 | beforeEach(()=> { 7 | apiInstance = new EntityApi({ 8 | entityName: 'TEST', 9 | endpointUrl: 'test' 10 | }); 11 | }); 12 | 13 | it('should be able to create instance', ()=> { 14 | expect(apiInstance).toBeDefined(); 15 | }); 16 | 17 | it('constructor should trow error without required params', ()=> { 18 | expect(()=> { 19 | new EntityApi(); 20 | }).toThrow('entityName and endpointUrl are required'); 21 | }); 22 | 23 | it('should be able to configure reducer', ()=> { 24 | expect(typeof apiInstance.configureReducer() === 'function').toBeTruthy(); 25 | }); 26 | 27 | describe('should be able to parse load options when consumer use', ()=> { 28 | 29 | it('empty string', ()=> { 30 | const parsed = apiInstance._parseLoadOptions(''); 31 | expect(parsed.queryString).toEqual(''); 32 | expect(parsed.params).toEqual({}); 33 | }); 34 | 35 | it('not empty string', ()=> { 36 | const parsed = apiInstance._parseLoadOptions('1'); 37 | expect(parsed.queryString).toEqual('/1'); 38 | expect(parsed.params).toEqual({}); 39 | }); 40 | 41 | it('number', ()=> { 42 | const parsed = apiInstance._parseLoadOptions(1); 43 | expect(parsed.queryString).toEqual('/1'); 44 | expect(parsed.params).toEqual({}); 45 | }); 46 | 47 | it('object without params and path', ()=> { 48 | const parsed = apiInstance._parseLoadOptions({id: 1}); 49 | expect(parsed.queryString).toEqual(''); 50 | expect(parsed.params).toEqual({id: 1}); 51 | }); 52 | 53 | it('object with params only', ()=> { 54 | const parsed = apiInstance._parseLoadOptions({params: {id: 1}}); 55 | expect(parsed.queryString).toEqual(''); 56 | expect(parsed.params).toEqual({id: 1}); 57 | }); 58 | 59 | it('object with path only', ()=> { 60 | const parsed = apiInstance._parseLoadOptions({path: '1'}); 61 | expect(parsed.queryString).toEqual('/1'); 62 | expect(parsed.params).toEqual({}); 63 | }); 64 | 65 | it('object with path and params', ()=> { 66 | const options = {path: 'path/1', params: {id: 1, mode: 'short'}}; 67 | const parsed = apiInstance._parseLoadOptions(options); 68 | expect(parsed.queryString).toEqual(`/${options.path}`); 69 | expect(parsed.params).toEqual(options.params); 70 | }); 71 | 72 | 73 | }); 74 | 75 | 76 | }); 77 | -------------------------------------------------------------------------------- /tests/unit/utils.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by m.chekryshov on 03.03.17. 3 | */ 4 | import {hasCyclicReferences, containsString} from '../../lib/utils'; 5 | 6 | describe('hasCyclicReferences method', ()=> { 7 | it('should be able find cycle', ()=> { 8 | const c = {a: 1}; 9 | c.c = c; 10 | 11 | expect(hasCyclicReferences(c)).toEqual(true); 12 | }); 13 | 14 | it('should find return tru for cycles only', ()=> { 15 | const c = {a: 1}; 16 | 17 | expect(hasCyclicReferences(c)).toEqual(false); 18 | }); 19 | }); 20 | 21 | describe('containsString method', ()=> { 22 | it('should work', ()=> { 23 | const sub = '11'; 24 | const c = 'asasassasas' 25 | 26 | expect(containsString(c + sub, sub)).toEqual(true); 27 | expect(containsString(c, sub)).toEqual(false); 28 | }); 29 | }); 30 | --------------------------------------------------------------------------------