├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── .yaspellerrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bower.json ├── dist ├── redux-api.js ├── redux-api.js.map ├── redux-api.min.js └── redux-api.min.js.map ├── docs ├── AuthorizationJWT.md ├── DOCS.md └── Scoping.md ├── examples └── isomorphic │ ├── .babelrc │ ├── .gitignore │ ├── README.md │ ├── app │ ├── client.jsx │ ├── pages │ │ ├── Application.jsx │ │ ├── Repo.jsx │ │ └── User.jsx │ ├── routes │ │ └── routes.js │ ├── server.jsx │ └── utils │ │ └── rest.js │ ├── dist │ ├── 404.jpg │ ├── favicon.ico │ └── styles.css │ ├── package.json │ ├── server.js │ ├── views │ ├── 404.ejs │ └── index.ejs │ ├── webpack.config.js │ └── yarn.lock ├── package.json ├── src ├── PubSub.js ├── actionFn.js ├── adapters │ └── fetch.js ├── async.js ├── createHolder.js ├── fetchResolver.js ├── helpers.js ├── index.js ├── reducerFn.js ├── transformers.js ├── urlTransform.js └── utils │ ├── cache.js │ ├── get.js │ ├── merge.js │ └── omit.js ├── test ├── PubSub_spec.js ├── actionFn_spec.js ├── adapters_fetch_spec.js ├── cache_spec.js ├── createHolder_spec.js ├── fetchResolver_spec.js ├── get_spec.js ├── index_spec.js ├── merge_spec.js ├── omit_spec.js ├── reducerFn_spec.js ├── redux_spec.js └── urlTransform_spec.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["add-module-exports"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | [*] 7 | # Change these settings to your own preference 8 | indent_style = space 9 | indent_size = 2 10 | 11 | # We recommend you to keep these unchanged 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb", 4 | "prettier" 5 | ], 6 | "parser": "babel-eslint", 7 | "env": { 8 | "browser": true, 9 | "node": true 10 | }, 11 | "rules": { 12 | "prefer-promise-reject-errors": "off", 13 | "prefer-destructuring": "off", 14 | "no-multi-spaces": 0, 15 | "space-infix-ops": 0, 16 | "quotes": [ 17 | 2, "double", "avoid-escape" // http://eslint.org/docs/rules/quotes 18 | ], 19 | "func-names": 0, 20 | "vars-on-top": 0, 21 | "strict": 0, 22 | "no-unused-expressions": 0, 23 | "consistent-return": 0, 24 | "one-var": 0, 25 | "new-cap": 0, 26 | "no-else-return": 0, 27 | "semi-spacing": 0, 28 | "no-nested-ternary": 0, 29 | "no-shadow": 0, 30 | "no-param-reassign": 0, 31 | "no-extend-native": 0, 32 | "no-empty": 0, 33 | "guard-for-in": 0, 34 | "comma-dangle": 0, 35 | "space-before-function-paren": 0, 36 | "arrow-spacing": [2, { "before": true, "after": true }], 37 | "arrow-parens": [2, "as-needed"], 38 | "prefer-template": 0, 39 | "prefer-arrow-callback": 0, 40 | "arrow-body-style": 0, 41 | "no-confusing-arrow": 0, 42 | "react/require-extension": 0, 43 | "import/no-unresolved": 0, 44 | "import/extentions": 0, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage/ 4 | bower_components 5 | /lib 6 | .DS_Store 7 | 8 | #Webstorm metadata 9 | .idea 10 | 11 | #VSCode metadata 12 | .vscode 13 | 14 | #IntelliJ metadata 15 | *.iml 16 | /.nyc_output 17 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /dist 3 | /examples 4 | /node_modules 5 | /src 6 | /test 7 | /.babelrc 8 | /.editorconfig 9 | /.eslintrc 10 | /.gitignore 11 | /.travis.yml 12 | /webpack.config.js 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | - '12' 5 | - '13' 6 | after_script: 7 | - npm run coveralls 8 | -------------------------------------------------------------------------------- /.yaspellerrc: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "checkYo": true, 4 | "lang": "en,ru", 5 | "format": "auto", 6 | "fileExtensions": [".md"], 7 | "excludeFiles":[ 8 | "coverage", 9 | "dist", 10 | "examples", 11 | "lib", 12 | "node_modules", 13 | "src", 14 | "test", 15 | "yaspeller" 16 | ], 17 | "dictionary": [ 18 | "JWT", 19 | "api", 20 | "AuthorizationJWT", 21 | "Changelog", 22 | "Redux", 23 | "browserify", 24 | "endpoint", 25 | "endpoints", 26 | "endpoint's", 27 | "actionFail - emits", 28 | "reduxApi", 29 | "github", 30 | "isomorphic", 31 | "js", 32 | "jsx", 33 | "md", 34 | "npm", 35 | "redux", 36 | "webpack", 37 | "actionFail", 38 | "actionFetch", 39 | "actionReset", 40 | "actionSuccess", 41 | "async", 42 | "autogenerate", 43 | "backend", 44 | "backends", 45 | "crud", 46 | "es7", 47 | "getUser", 48 | "isServer", 49 | "middlewareParser", 50 | "param", 51 | "params", 52 | "postfetch", 53 | "qs", 54 | "reducerName", 55 | "rootUrl", 56 | "stringify", 57 | "thunk", 58 | "updateUser", 59 | "urlOptions", 60 | "urlparams", 61 | "urls", 62 | "usefull", 63 | "v1", 64 | "xhr", 65 | "javascript", 66 | "responseHandler", 67 | "restApi", 68 | "Efremov", 69 | "NONINFRINGEMENT", 70 | "sublicense", 71 | "baseConfig", 72 | "It generates", 73 | "scoping", 74 | "uses", 75 | "reducers", 76 | "from success response", 77 | "initial store", 78 | "init", 79 | "size mode", 80 | "use", 81 | "sending events" 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [0.12.0](https://github.com/lexich/redux-api/compare/v0.11.2...v0.12.0) (2020-02-25) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * potential security vulnerabilities in dependencies ([5a37929](https://github.com/lexich/redux-api/commit/5a379290d78798ca39b4eb7792f9a0cd0d6ab0b8)) 11 | 12 | 13 | ## [0.11.2](https://github.com/lexich/redux-api/compare/v0.11.1...v0.11.2) (2017-12-29) 14 | 15 | 16 | 17 | 18 | ## [0.11.1](https://github.com/lexich/redux-api/compare/v0.11.0...v0.11.1) (2017-10-02) 19 | 20 | 21 | 22 | 23 | # [0.11.0](https://github.com/lexich/redux-api/compare/v0.10.8...v0.11.0) (2017-07-31) 24 | 25 | 26 | 27 | 28 | ## [0.10.8](https://github.com/lexich/redux-api/compare/v0.10.7...v0.10.8) (2017-07-14) 29 | 30 | 31 | 32 | 33 | ## [0.10.7](https://github.com/lexich/redux-api/compare/v0.10.6...v0.10.7) (2017-07-10) 34 | 35 | 36 | 37 | 38 | ## [0.10.6](https://github.com/lexich/redux-api/compare/v0.10.5...v0.10.6) (2017-06-12) 39 | 40 | 41 | 42 | 43 | ## [0.10.5](https://github.com/lexich/redux-api/compare/v0.10.4...v0.10.5) (2017-05-18) 44 | 45 | 46 | 47 | 48 | ## [0.10.4](https://github.com/lexich/redux-api/compare/v0.10.3...v0.10.4) (2017-04-28) 49 | 50 | 51 | 52 | 53 | ## [0.10.3](https://github.com/lexich/redux-api/compare/v0.10.2...v0.10.3) (2017-03-30) 54 | 55 | 56 | 57 | 58 | ## [0.10.2](https://github.com/lexich/redux-api/compare/v0.9.18...v0.10.2) (2017-03-29) 59 | Add support cache [option](https://github.com/lexich/redux-api/blob/master/docs/DOCS.md#cache) 60 | 61 | 62 | ## [0.9.18](https://github.com/lexich/redux-api/compare/v0.9.17...v0.9.18) (2017-03-06) 63 | 64 | 65 | 66 | 67 | ## [0.9.17](https://github.com/lexich/redux-api/compare/0.9.16...v0.9.17) (2017-01-19) 68 | 69 | 70 | 71 | 72 | ## [0.9.15](https://github.com/lexich/redux-api/compare/v0.9.13...v0.9.15) (2016-11-16) 73 | 74 | 75 | 76 | 77 | ## [0.9.13](https://github.com/lexich/redux-api/compare/v0.9.12...v0.9.13) (2016-10-24) 78 | 79 | 80 | 81 | 82 | ## [0.9.12](https://github.com/lexich/redux-api/compare/0.9.11...v0.9.12) (2016-10-24) 83 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Efremov Alex 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Redux-api 2 | Flux REST API for redux infrastructure 3 | 4 | [![Build Status](https://travis-ci.org/lexich/redux-api.svg)](https://travis-ci.org/lexich/redux-api) 5 | [![NPM version](https://badge.fury.io/js/redux-api.svg)](http://badge.fury.io/js/redux-api) 6 | [![Coverage Status](https://coveralls.io/repos/lexich/redux-api/badge.png?branch=master)](https://coveralls.io/r/lexich/redux-api?branch=master) 7 | 8 | ## Introduction 9 | `redux-api` solves the problem of writing clients to communicate with backends. It generates [actions](http://redux.js.org/docs/basics/Actions.html) and [reducers](http://redux.js.org/docs/basics/Reducers.html) for making AJAX calls to API endpoints. You don't need to write a lot of [boilerplate code](http://redux.js.org/docs/advanced/ExampleRedditAPI.html) if you use `redux` and want to exchange data with server. 10 | 11 | Inspired by [Redux-rest](https://github.com/Kvoti/redux-rest) and is intended to be used with [Redux](https://github.com/gaearon/redux). 12 | 13 | 14 | ## Documentation 15 | See [DOCS.md](docs/DOCS.md) for API documentation. 16 | ## Use cases 17 | * [AuthorizationJWT.md](docs/AuthorizationJWT.md) - example of JWT Authorization 18 | * [Scoping.md](docs/Scoping.md) - use scoping or using multiple redux-api instance without naming intersections. 19 | 20 | ## Install 21 | With npm: 22 | ```sh 23 | npm install redux-api --save 24 | ``` 25 | With bower: 26 | ```sh 27 | bower install redux-api --save 28 | ``` 29 | 30 | If you don't use tools like webpack, browserify, etc and you want to load redux-api manually, the best way to add redux-api to your project is: 31 | ```js 32 | 33 | 39 | ``` 40 | 41 | ======= 42 | ## Remote calls 43 | 44 | `redux-api` doesn't bind you to a technology to make AJAX calls. It uses configurable `adapters` - a pretty simple function which receives 2 arguments: `endpoint` and `options`, and returns a Promise as result. The default adapter uses [isomorphic-fetch](https://github.com/matthew-andrews/isomorphic-fetch), and has an implementation like this: 45 | ```js 46 | function adapterFetch(url, options) { 47 | return fetch(url, options); 48 | } 49 | ``` 50 | 51 | However, you are not tied to using isomorphic-fetch. For instance, if you prefer to use jQuery, you can use the following adapter: 52 | ```js 53 | function adapterJquery(url, options) { 54 | return new Promise((success, error)=> { 55 | $.ajax({ ...options, url, success, error }); 56 | }); 57 | } 58 | ``` 59 | This implementation allows you to make any request and process any response. 60 | 61 | And of course you have to set up adapter to your `redux-api` instance before using. 62 | ``` 63 | reduxApi(....).use("fetch", adapterFetch) 64 | ``` 65 | 66 | ======= 67 | ## Examples 68 | [examples/isomorphic](https://github.com/lexich/redux-api/tree/master/examples/isomorphic) - React + Redux + React-Router + Redux-api with webpack and express + github API 69 | 70 | ### Example 71 | rest.js 72 | ```js 73 | import "isomorphic-fetch"; 74 | import reduxApi, {transformers} from "redux-api"; 75 | import adapterFetch from "redux-api/lib/adapters/fetch"; 76 | export default reduxApi({ 77 | // simple endpoint description 78 | entry: `/api/v1/entry/:id`, 79 | // complex endpoint description 80 | regions: { 81 | url: `/api/v1/regions`, 82 | // reimplement default `transformers.object` 83 | transformer: transformers.array, 84 | // base endpoint options `fetch(url, options)` 85 | options: { 86 | headers: { 87 | "Accept": "application/json" 88 | } 89 | } 90 | } 91 | }).use("fetch", adapterFetch(fetch)); 92 | ``` 93 | 94 | index.jsx 95 | ```js 96 | import React, {PropTypes} from "react"; 97 | import { createStore, applyMiddleware, combineReducers } from "redux"; 98 | import thunk from "redux-thunk"; 99 | import { Provider, connect } from "react-redux"; 100 | import rest from "./rest"; //our redux-rest object 101 | 102 | const createStoreWithMiddleware = applyMiddleware(thunk)(createStore); 103 | const reducer = combineReducers(rest.reducers); 104 | const store = createStoreWithMiddleware(reducer); 105 | 106 | function select(state) { 107 | return { entry: state.entry, regions: state.regions }; 108 | } 109 | 110 | class Application { 111 | static propTypes = { 112 | entry: PropTypes.shape({ 113 | loading: PropTypes.bool.isRequired, 114 | data: PropTypes.shape({ 115 | text: PropTypes.string 116 | }).isRequired 117 | }).isRequired, 118 | regions: PropTypes.shape({ 119 | loading: PropTypes.bool.isRequired, 120 | data: PropTypes.array.isRequired 121 | }).isRequired, 122 | dispatch: PropTypes.func.isRequired 123 | }; 124 | componentDidMount() { 125 | const {dispatch} = this.props; 126 | // fetch `/api/v1/regions 127 | dispatch(rest.actions.regions.sync()); 128 | //specify id for GET: /api/v1/entry/1 129 | dispatch(rest.actions.entry({id: 1})); 130 | } 131 | render() { 132 | const {entry, regions} = this.props; 133 | const Regions = regions.data.map((item)=>

{ item.name }

) 134 | return ( 135 |
136 | Loading regions: { regions.loading } 137 | 138 | Loading entry: {entry.loading} 139 |
{{ entry.data.text }}
140 |
141 | ); 142 | } 143 | } 144 | 145 | const SmartComponent = connect(select)(Application); 146 | 147 | React.render( 148 | 149 | 150 | , 151 | document.getElementById("content") 152 | ); 153 | ``` 154 | 155 | ### [Releases Changelog](https://github.com/lexich/redux-api/releases) 156 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-api", 3 | "version": "0.12.0", 4 | "main": "dist/redux-api.min.js", 5 | "dependencies": {} 6 | } 7 | -------------------------------------------------------------------------------- /dist/redux-api.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("redux-api",[],e):"object"==typeof exports?exports["redux-api"]=e():t["redux-api"]=e()}(window,(function(){return function(t){var e={};function r(n){if(e[n])return e[n].exports;var o=e[n]={i:n,l:!1,exports:{}};return t[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}return r.m=t,r.c=e,r.d=function(t,e,n){r.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},r.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},r.t=function(t,e){if(1&e&&(t=r(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var o in t)r.d(n,o,function(e){return t[e]}.bind(null,o));return n},r.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return r.d(e,"a",e),e},r.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},r.p="",r(r.s=13)}([function(t,e,r){"use strict";var n=r(4),o=r(7);function a(){this.protocol=null,this.slashes=null,this.auth=null,this.host=null,this.port=null,this.hostname=null,this.hash=null,this.search=null,this.query=null,this.pathname=null,this.path=null,this.href=null}e.parse=m,e.resolve=function(t,e){return m(t,!1,!0).resolve(e)},e.resolveObject=function(t,e){return t?m(t,!1,!0).resolveObject(e):e},e.format=function(t){o.isString(t)&&(t=m(t));return t instanceof a?t.format():a.prototype.format.call(t)},e.Url=a;var i=/^([a-z0-9.+-]+:)/i,c=/:[0-9]*$/,s=/^(\/\/?(?!\/)[^\?\s]*)(\?[^\s]*)?$/,u=["{","}","|","\\","^","`"].concat(["<",">",'"',"`"," ","\r","\n","\t"]),l=["'"].concat(u),f=["%","/","?",";","#"].concat(l),h=["/","?","#"],p=/^[+a-z0-9A-Z_-]{0,63}$/,y=/^([+a-z0-9A-Z_-]{0,63})(.*)$/,d={javascript:!0,"javascript:":!0},b={javascript:!0,"javascript:":!0},v={http:!0,https:!0,ftp:!0,gopher:!0,file:!0,"http:":!0,"https:":!0,"ftp:":!0,"gopher:":!0,"file:":!0},g=r(8);function m(t,e,r){if(t&&o.isObject(t)&&t instanceof a)return t;var n=new a;return n.parse(t,e,r),n}a.prototype.parse=function(t,e,r){if(!o.isString(t))throw new TypeError("Parameter 'url' must be a string, not "+typeof t);var a=t.indexOf("?"),c=-1!==a&&a127?F+="x":F+=N[I];if(!F.match(p)){var U=C.slice(0,D),R=C.slice(D+1),L=N.match(y);L&&(U.push(L[1]),R.unshift(L[2])),R.length&&(m="/"+R.join(".")+m),this.hostname=U.join(".");break}}}this.hostname.length>255?this.hostname="":this.hostname=this.hostname.toLowerCase(),k||(this.hostname=n.toASCII(this.hostname));var H=this.port?":"+this.port:"",_=this.hostname||"";this.host=_+H,this.href+=this.host,k&&(this.hostname=this.hostname.substr(1,this.hostname.length-2),"/"!==m[0]&&(m="/"+m))}if(!d[w])for(D=0,E=l.length;D0)&&r.host.split("@"))&&(r.auth=k.shift(),r.host=r.hostname=k.shift());return r.search=t.search,r.query=t.query,o.isNull(r.pathname)&&o.isNull(r.search)||(r.path=(r.pathname?r.pathname:"")+(r.search?r.search:"")),r.href=r.format(),r}if(!P.length)return r.pathname=null,r.search?r.path="/"+r.search:r.path=null,r.href=r.format(),r;for(var A=P.slice(-1)[0],S=(r.host||t.host||P.length>1)&&("."===A||".."===A)||""===A,D=0,q=P.length;q>=0;q--)"."===(A=P[q])?P.splice(q,1):".."===A?(P.splice(q,1),D++):D&&(P.splice(q,1),D--);if(!j&&!w)for(;D--;D)P.unshift("..");!j||""===P[0]||P[0]&&"/"===P[0].charAt(0)||P.unshift(""),S&&"/"!==P.join("/").substr(-1)&&P.push("");var k,C=""===P[0]||P[0]&&"/"===P[0].charAt(0);x&&(r.hostname=r.host=C?"":P.length?P.shift():"",(k=!!(r.host&&r.host.indexOf("@")>0)&&r.host.split("@"))&&(r.auth=k.shift(),r.host=r.hostname=k.shift()));return(j=j||r.host&&P.length)&&!C&&P.unshift(""),P.length?r.pathname=P.join("/"):(r.pathname=null,r.path=null),o.isNull(r.pathname)&&o.isNull(r.search)||(r.path=(r.pathname?r.pathname:"")+(r.search?r.search:"")),r.auth=t.auth||r.auth,r.slashes=r.slashes||t.slashes,r.href=r.format(),r},a.prototype.parseHost=function(){var t=this.host,e=c.exec(t);e&&(":"!==(e=e[0])&&(this.port=e.substr(1)),t=t.substr(0,t.length-e.length)),t&&(this.hostname=t)}},function(t,e){t.exports=function(t,e,r){switch(r?r.length:0){case 0:return e?t.call(e):t();case 1:return e?t.call(e,r[0]):t(r[0]);case 2:return e?t.call(e,r[0],r[1]):t(r[0],r[1]);case 3:return e?t.call(e,r[0],r[1],r[2]):t(r[0],r[1],r[2]);case 4:return e?t.call(e,r[0],r[1],r[2],r[3]):t(r[0],r[1],r[2],r[3]);case 5:return e?t.call(e,r[0],r[1],r[2],r[3],r[4]):t(r[0],r[1],r[2],r[3],r[4]);default:return t.apply(e,r)}}},function(t,e,r){var n=r(11),o=r(12);t.exports={stringify:n,parse:o}},function(t,e){var r={};r.hexTable=new Array(256);for(var n=0;n<256;++n)r.hexTable[n]="%"+((n<16?"0":"")+n.toString(16)).toUpperCase();e.arrayToObject=function(t,e){for(var r=e.plainObjects?Object.create(null):{},n=0,o=t.length;n=48&&a<=57||a>=65&&a<=90||a>=97&&a<=122?e+=t[n]:a<128?e+=r.hexTable[a]:a<2048?e+=r.hexTable[192|a>>6]+r.hexTable[128|63&a]:a<55296||a>=57344?e+=r.hexTable[224|a>>12]+r.hexTable[128|a>>6&63]+r.hexTable[128|63&a]:(++n,a=65536+((1023&a)<<10|1023&t.charCodeAt(n)),e+=r.hexTable[240|a>>18]+r.hexTable[128|a>>12&63]+r.hexTable[128|a>>6&63]+r.hexTable[128|63&a])}return e},e.compact=function(t,r){if("object"!=typeof t||null===t)return t;var n=(r=r||[]).indexOf(t);if(-1!==n)return r[n];if(r.push(t),Array.isArray(t)){for(var o=[],a=0,i=t.length;a= 0x80 (not a basic code point)","invalid-input":"Invalid input"},p=Math.floor,y=String.fromCharCode;function d(t){throw new RangeError(h[t])}function b(t,e){for(var r=t.length,n=[];r--;)n[r]=e(t[r]);return n}function v(t,e){var r=t.split("@"),n="";return r.length>1&&(n=r[0]+"@",t=r[1]),n+b((t=t.replace(f,".")).split("."),e).join(".")}function g(t){for(var e,r,n=[],o=0,a=t.length;o=55296&&e<=56319&&o65535&&(e+=y((t-=65536)>>>10&1023|55296),t=56320|1023&t),e+=y(t)})).join("")}function O(t,e){return t+22+75*(t<26)-((0!=e)<<5)}function j(t,e,r){var n=0;for(t=r?p(t/700):t>>1,t+=p(t/e);t>455;n+=36)t=p(t/35);return p(n+36*t/(t+38))}function w(t){var e,r,n,o,a,i,c,u,l,f,h,y=[],b=t.length,v=0,g=128,O=72;for((r=t.lastIndexOf("-"))<0&&(r=0),n=0;n=128&&d("not-basic"),y.push(t.charCodeAt(n));for(o=r>0?r+1:0;o=b&&d("invalid-input"),((u=(h=t.charCodeAt(o++))-48<10?h-22:h-65<26?h-65:h-97<26?h-97:36)>=36||u>p((s-v)/i))&&d("overflow"),v+=u*i,!(u<(l=c<=O?1:c>=O+26?26:c-O));c+=36)i>p(s/(f=36-l))&&d("overflow"),i*=f;O=j(v-a,e=y.length+1,0==a),p(v/e)>s-g&&d("overflow"),g+=p(v/e),v%=e,y.splice(v++,0,g)}return m(y)}function P(t){var e,r,n,o,a,i,c,u,l,f,h,b,v,m,w,P=[];for(b=(t=g(t)).length,e=128,r=0,a=72,i=0;i=e&&hp((s-r)/(v=n+1))&&d("overflow"),r+=(c-e)*v,e=c,i=0;is&&d("overflow"),h==e){for(u=r,l=36;!(u<(f=l<=a?1:l>=a+26?26:l-a));l+=36)w=u-f,m=36-f,P.push(y(O(f+w%m,0))),u=p(w/m);P.push(y(O(u,0))),a=j(r,v,n==o),r=0,++n}++r,++e}return P.join("")}c={version:"1.4.1",ucs2:{decode:g,encode:m},decode:w,encode:P,toASCII:function(t){return v(t,(function(t){return l.test(t)?"xn--"+P(t):t}))},toUnicode:function(t){return v(t,(function(t){return u.test(t)?w(t.slice(4).toLowerCase()):t}))}},void 0===(o=function(){return c}.call(e,r,e,t))||(t.exports=o)}()}).call(this,r(5)(t),r(6))},function(t,e){t.exports=function(t){return t.webpackPolyfill||(t.deprecate=function(){},t.paths=[],t.children||(t.children=[]),Object.defineProperty(t,"loaded",{enumerable:!0,get:function(){return t.l}}),Object.defineProperty(t,"id",{enumerable:!0,get:function(){return t.i}}),t.webpackPolyfill=1),t}},function(t,e){var r;r=function(){return this}();try{r=r||new Function("return this")()}catch(t){"object"==typeof window&&(r=window)}t.exports=r},function(t,e,r){"use strict";t.exports={isString:function(t){return"string"==typeof t},isObject:function(t){return"object"==typeof t&&null!==t},isNull:function(t){return null===t},isNullOrUndefined:function(t){return null==t}}},function(t,e,r){"use strict";e.decode=e.parse=r(9),e.encode=e.stringify=r(10)},function(t,e,r){"use strict";function n(t,e){return Object.prototype.hasOwnProperty.call(t,e)}t.exports=function(t,e,r,a){e=e||"&",r=r||"=";var i={};if("string"!=typeof t||0===t.length)return i;var c=/\+/g;t=t.split(e);var s=1e3;a&&"number"==typeof a.maxKeys&&(s=a.maxKeys);var u=t.length;s>0&&u>s&&(u=s);for(var l=0;l=0?(f=d.substr(0,b),h=d.substr(b+1)):(f=d,h=""),p=decodeURIComponent(f),y=decodeURIComponent(h),n(i,p)?o(i[p])?i[p].push(y):i[p]=[i[p],y]:i[p]=y}return i};var o=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)}},function(t,e,r){"use strict";var n=function(t){switch(typeof t){case"string":return t;case"boolean":return t?"true":"false";case"number":return isFinite(t)?t:"";default:return""}};t.exports=function(t,e,r,c){return e=e||"&",r=r||"=",null===t&&(t=void 0),"object"==typeof t?a(i(t),(function(i){var c=encodeURIComponent(n(i))+r;return o(t[i])?a(t[i],(function(t){return c+encodeURIComponent(n(t))})).join(e):c+encodeURIComponent(n(t[i]))})).join(e):c?encodeURIComponent(n(c))+r+encodeURIComponent(n(t)):""};var o=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function a(t,e){if(t.map)return t.map(e);for(var r=[],n=0;n=0&&r.parseArrays&&c<=r.arrayLimit?(n=[])[c]=o.parseObject(t,e,r):n[i]=o.parseObject(t,e,r)}return n},parseKeys:function(t,e,r){if(t){r.allowDots&&(t=t.replace(/\.([^\.\[]+)/g,"[$1]"));var n=/(\[[^\[\]]*\])/g,a=/^([^\[\]]*)/.exec(t),i=[];if(a[1]){if(!r.plainObjects&&Object.prototype.hasOwnProperty(a[1])&&!r.allowPrototypes)return;i.push(a[1])}for(var c=0;null!==(a=n.exec(t))&&ci.pop().valueOf()?r:void 0}},id:function(t){return t?Object.keys(t).reduce((function(e,r){return e+"".concat(r,"=").concat(t[r],";")}),""):""}};function s(t,e){var r=t;if("number"==typeof r||r instanceof Number){var n=i.pop();n.setSeconds(n.getSeconds()+r),r=n}return e instanceof Date&&r instanceof Date&&r.valueOf()1?r-1:0),o=1;o0&&void 0!==arguments[0]?arguments[0]:0,e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:F;!e.prefetch||t>=e.prefetch.length?r():e.prefetch[t](e,(function(n){return n?r(n):I(t+1,e,r)}))}function T(t,e){for(var r=0;r1&&void 0!==arguments[1]&&arguments[1],r=arguments.length>2?arguments[2]:void 0;return console.warn("Deprecated method, use `use` method"),this.use("fetch",t),this.use("server",e),this.use("rootUrl",r),this},actions:{},reducers:{},events:{}};function a(t,n,a){var i="object"===et(n)?Y({},rt,{reducerName:a},n):Y({},rt,{reducerName:a,url:n});void 0!==i.broadcast&&console.warn("Deprecated `broadcast` option. you shoud use `events`to catch redux-api events (see https://github.com/lexich/redux-api/blob/master/DOCS.md#Events)");var l,p=i.url,d=i.urlOptions,v=i.options,g=i.transformer,m=i.broadcast,O=i.crud,j=i.reducerName,w=i.prefetch,P=i.postfetch,A=i.validation,S=i.helpers,D=e&&e.prefix||"",q={actionFetch:"".concat(nt,"@").concat(D).concat(j),actionSuccess:"".concat(nt,"@").concat(D).concat(j,"_success"),actionFail:"".concat(nt,"@").concat(D).concat(j,"_fail"),actionReset:"".concat(nt,"@").concat(D).concat(j,"_delete"),actionCache:"".concat(nt,"@").concat(D).concat(j,"_cache"),actionAbort:"".concat(nt,"@").concat(D).concat(j,"_abort")},C=i.fetch?i.fetch:function(){for(var t=arguments.length,e=new Array(t),n=0;n3&&void 0!==arguments[3]?arguments[3]:{},o=arguments.length>4&&void 0!==arguments[4]?arguments[4]:{},a=n.actionFetch,i=n.actionSuccess,c=n.actionFail,s=n.actionReset,l=n.actionCache,f=n.actionAbort,h=new U,p=R();function d(t,e,n){var a=o.holder?o.holder.options instanceof Function?o.holder.options(t,e,n):o.holder.options:{},i=r instanceof Function?r(t,e,n):r;return x({},a,i,e)}function v(e,r,n){var a=k(t,e,o.urlOptions),i=N(o,"holder","rootUrl");if(i=i instanceof Function?i(a,r,n):i){var c=b.a.parse(i),s=b.a.parse(a);if(!s.host){var u=(c.path?c.path.replace(/\/$/,""):"")+"/"+(s.path?s.path.replace(/^\//,""):"");a="".concat(c.protocol,"//").concat(c.host).concat(u)}}return a}function g(t,e){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:M,a=arguments.length>4&&void 0!==arguments[4]?arguments[4]:M,i=v(t,e,n),c=d(i,e,n),s=o.reducerName||"",f=u(r.expire,o.cache);if(f&&n!==M){var h=n(),p=N(h,o.prefix,o.reducerName,"cache");s+="_"+f.id(t,e);var y=f.getData(p&&s&&void 0!==p[s]&&p[s]);if(void 0!==y)return Promise.resolve(y)}var b=o.fetch(i,c);return f&&a!==M&&s&&b.then((function(t){a({type:l,id:s,data:t,expire:f.expire})})),b}function m(){var t=p.pop(),e=new Error("Application abort request");return t&&t.reject(e),e}function O(t,e,r){var n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:M,a=arguments.length>4&&void 0!==arguments[4]?arguments[4]:M,i=g(t,e,r,n,a),c=o.validation?i.then((function(t){return new Promise((function(e,r){return o.validation(t,(function(n){return n?r(n):e(t)}))}))})):i,s=c,u=N(o,"holder","responseHandler");return u&&(s=c&&c.then?c.then((function(t){var e=u(null,t);return void 0===e?t:e}),(function(t){return u(t)})):u(c)),s&&s.catch&&s.catch(M),s}function j(){for(var t=arguments.length,e=new Array(t),r=0;r1&&void 0!==arguments[1]?arguments[1]:[];e?o(e):y()(i?j.sync:j,null,n.concat(o))(t,r)}));else{var c=J(s,2),u=c[0],l=c[1];y()(i?j.sync:j,null,[u,l,o])(t,r)}}));return u.catch(M),u}},t};return Object.keys(w).reduce((function(t,e){return P(t,w[e],e,w)}),j)}(p,a,v,q,E),!E.virtual&&!t.reducers[j]){var F=g(),T=i.cache?{sync:!1,syncing:!1,loading:!1,data:F,cache:{},request:null}:{sync:!1,syncing:!1,loading:!1,data:F,request:null},L=i.reducer?i.reducer.bind(t):null;t.reducers[j]=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=arguments.length>2?arguments[2]:void 0,n=e.actionFetch,o=e.actionSuccess,a=e.actionFail,i=e.actionReset,c=e.actionCache,u=e.actionAbort;return function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:t,l=arguments.length>1?arguments[1]:void 0,p=l.request||{};switch(l.type){case n:return f({},e,{request:p,loading:!0,error:null,syncing:!!l.syncing});case o:return f({},e,{loading:!1,sync:!0,syncing:!1,error:null,data:l.data});case a:return f({},e,{loading:!1,error:l.error,syncing:!1});case i:var y=l.mutation;return"sync"===y?f({},e,{request:null,sync:!1}):f({},t);case u:return f({},e,{request:null,loading:!1,syncing:!1,error:l.error});case c:var d=l.id,b=l.data,v=e.cache[d]?e.cache[d].expire:null,g=s(l.expire,v);return f({},e,{cache:f({},e.cache,h({},d,{expire:g,data:b}))});default:return r?r(e,l):e}}}(T,q,L)}return t.events[j]=q,t}return Object.keys(t).reduce((function(e,r){return a(e,t[r],r)}),n)}ot.transformers=W,ot.async=function t(e){for(var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,n=arguments.length,o=new Array(n>2?n-2:0),a=2;a { 35 | const { user: { data: { token }}} = getState(); 36 | // Add token to header request 37 | const headers = { 38 | Accept: "application/json", 39 | "Content-Type": "application/json", 40 | }; 41 | if (token) { 42 | return { headers: { ...headers, Authorization: `Bearer ${token}` } }; 43 | } 44 | return { headers }; 45 | }); 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/DOCS.md: -------------------------------------------------------------------------------- 1 | ## Documentation 2 | ### Initialization redux-api endpoint 3 | 4 | ```js 5 | import reduxApi, {transformers} from "redux-api"; 6 | ``` 7 | 8 | #### reduxApi(options, baseConfig) 9 | - **Description**: create endpoint 10 | - **Param** **options** - configuration of rest-api endpoints 11 | - **Type**: Object 12 | - **Default**: {} 13 | - **Example**: 14 | Simple endpoint definition `GET /api/v1/entry` where response is Object 15 | ```js 16 | { 17 | entry: "/api/v1/entry", 18 | } 19 | // equivalent 20 | { 21 | entry: { 22 | url: "/api/v1/entry" 23 | } 24 | } 25 | // equivalent 26 | { 27 | entry: { 28 | url: "/api/v1/entry", 29 | transformer: transformers.object, //it's default value 30 | options: {} //it's default value 31 | } 32 | } 33 | // equivalent 34 | { 35 | entry: { 36 | url: "/api/v1/entry", 37 | transformer: transformers.object, //it's default value 38 | options: function(url, params, getState) { //it's default value 39 | return {}; 40 | } 41 | } 42 | } 43 | ``` 44 | **Param** **baseConfig** - additional base configuration 45 | **Param** baseConfig.prefix - custom prefix for ACTIONS if you use more then 1 restApi instance 46 | **Type**: String 47 | **Default**: "" 48 | 49 | 50 | ### Configuration options 51 | #### url 52 | - **Description**: url endpoint 53 | - **Type**: String 54 | - **Example**: 55 | ```js 56 | { 57 | entry: { 58 | url: "/api/v1/entry" 59 | } 60 | } 61 | ``` 62 | 63 | #### urlOptions 64 | - **Description**: options for transforming urls 65 | - **Type**: Object 66 | - **Example**: Keys `delimiter` and `arrayFormat` are passed on to 67 | [qs#parse](https://github.com/ljharb/qs#parsing-objects) and 68 | [qs#stringify](https://github.com/ljharb/qs#stringifying): 69 | ```js 70 | { 71 | entry: { 72 | url: "/api/v1/entry", 73 | urlOptions: { 74 | delimiter: ";", 75 | arrayFormat: "brackets" 76 | } 77 | } 78 | } 79 | ``` 80 | To pass different options to `#parse` and `#stringify`, use the `qsParseOptions` and `qsStringifyOptions` keys: 81 | ```js 82 | { 83 | entry: { 84 | url: "/api/v1/entry?a[]=5,a[]=6", 85 | urlOptions: { 86 | arrayFormat: "brackets", 87 | qsParseOptions: { 88 | delimiter: /[,;]/ 89 | }, 90 | qsStringifyOptions: { 91 | delimiter: ";" 92 | } 93 | } 94 | } 95 | } 96 | ``` 97 | This would re-encode the url to `/api/v1/entry?a[]=5;a[]=6`. 98 | 99 | #### transformer 100 | - **Description**: function for rest response transformation 101 | - **Type**: Function 102 | - **Default**: transformers.object 103 | - **Example**: It's a good idea to write custom transformer 104 | for example you have response 105 | ```json 106 | { "title": "Hello", "message": "World" } 107 | ``` 108 | Custom transformer 109 | ```js 110 | function customTransformer(data, prevData, action) { 111 | data || (data = {}); 112 | return { title: (data.title || ""), message: (data.message || "")}; 113 | } 114 | ``` 115 | 116 | #### options 117 | - **Description**: options for rest-api backend. `function(url, options)` 118 | - **Type**: Object | Functions 119 | - **Default**: null 120 | - **Example**: if you use [isomorphic-fetch](https://www.npmjs.com/package/isomorphic-fetch) backend 121 | ```js 122 | options: { 123 | method: "post", 124 | headers: { 125 | "Accept": "application/json", 126 | "Content-Type": "application/json" 127 | } 128 | } 129 | // equivalent 130 | options: function() { 131 | return { 132 | method: "post", 133 | headers: { 134 | "Accept": "application/json", 135 | "Content-Type": "application/json" 136 | } 137 | }; 138 | } 139 | ``` 140 | 141 | #### cache 142 | - **Description**: cache response. By default cache is turn off. If cache = true - this means that cache is permanent. Also cache can be object. see example 143 | - **Type** Boolean, Object, null 144 | - **Default**: null 145 | - **Example**: 146 | ```js 147 | { 148 | permanent: { 149 | url: "/api/v1/permanent", 150 | cache: true 151 | }, 152 | expires1: { 153 | url: "/api/v1/expires/1", 154 | cache: { expire: 360 }, // 360 seconds 155 | }, 156 | expires2: { 157 | url: "/api/v1/expires/2", 158 | cache: { 159 | expire: new Date("...."), // use concrete Date 160 | id(params, params) { 161 | // here you can overwrite cache id for request 162 | return `you custom id for request`; 163 | } 164 | } 165 | } 166 | } 167 | ``` 168 | 169 | #### broadcast 170 | - @deprecated 171 | - **Description**: list of actions which would emit after data fetching. 172 | - **Type**: Array 173 | - **Default**: null 174 | - **Example**: 175 | ```js 176 | import {ACTION_ENTRY_UPDATE} from "./constants"; 177 | .... 178 | entry: { 179 | url: "/api/v1/entry", 180 | broadcast: [ ACTION_ENTRY_UPDATE ] 181 | } 182 | // in your redux reducer 183 | function (state, action) { 184 | switch (action.type) { 185 | case ACTION_ENTRY_UPDATE: 186 | return { 187 | ...state, 188 | data: action.data // fetching data 189 | }; 190 | default: 191 | return state; 192 | } 193 | } 194 | ``` 195 | 196 | #### reducer 197 | - **Description**: Define your custom reducer to catch other events and modify state of current entry 198 | ATTENTION: custom reducer can't catch default events for current entry. 199 | - **Type**: Function 200 | - **Default**: null 201 | - **Example**: 202 | ```js 203 | const rest = reduxApi({ 204 | hello: "/api/hello", 205 | item: { 206 | url: "/api/item", 207 | reducer(state, action) { 208 | /* 209 | ATTENTION: this.events.item.actionSuccess and other default redux-api events never catch there 210 | */ 211 | // context has instance 212 | if (action.type === "MY_CUSTOM_EVENT") { 213 | return { ...state, value: action.value }; 214 | } else if (action.type === this.events.hello.actionSuccess) { 215 | return { ...state, value: action.value }; 216 | } else { 217 | return state; 218 | } 219 | } 220 | } 221 | }); 222 | ``` 223 | 224 | #### virtual 225 | - **Description**: if virtual is `true` this endpoint doesn't create reducer and doesn't emit redux-api actions. All data broadcasting by actions from `broadcast` list. 226 | - **Type**: Array 227 | - **Default**: false 228 | - **Example**: 229 | It usefull, for example, when you need to manipulate list of items. But you don't want to persist information about each manipulation, you want to save it in list. 230 | ```js 231 | const rest = reduxApi({ 232 | items: "/api/items", 233 | item: { 234 | url: "/api/item/:id", 235 | virtual: true, //reducer in this case doesn't generate 236 | postfetch: [ 237 | function({ dispatch, actions }) { 238 | dispatch(actions.items()); // update list of items after modify any item 239 | } 240 | ] 241 | } 242 | }); 243 | ``` 244 | In this case you global state is look like this: 245 | ```js 246 | { items: [ ... ] } 247 | ``` 248 | 249 | #### prefetch 250 | - **Description**: you can organize chain of calling events before the current endpoint will be executed 251 | - **Type**: Array 252 | - **Default**: null 253 | - **Example**: 254 | 255 | ```js 256 | { 257 | user: "/user/info", 258 | profile: "/user/:name", 259 | changeName: { 260 | url: "/user/changename", 261 | prefetch: [ 262 | function({actions, dispatch, getState}, cb) { 263 | const {user: {data: {name}}} = getState(); 264 | name ? cb() : dispatch(actions.user(cb)); 265 | }, 266 | function({actions, dispatch, getState}, cb) { 267 | const {user: {data: {name}}, profile: {data: {uuid}}} = getState(); 268 | uuid ? cb() : dispatch(actions.profile({name}, cb)); 269 | } 270 | ], 271 | options: function(url, params, getState) { 272 | const {profile: {data: {uuid}}} = getState(); 273 | return { ...params, body: { ...params.body, uuid }}; 274 | } 275 | }, 276 | friends: { 277 | url: "/user/:name/friends", 278 | prefetch: [ 279 | function({actions, dispatch, getState, requestOptions}, cb) { 280 | const {profile: {data: {uuid}}} = getState(); 281 | const {pathvars: {name}} = requestOptions; 282 | uuid ? cb() : dispatch(actions.profile({name}, cb)); 283 | } 284 | ], 285 | options: function(url, params, getState) { 286 | const {profile: {data: {uuid}}} = getState(); 287 | return { ...params, body: { ...params.body, uuid }}; 288 | } 289 | } 290 | } 291 | ``` 292 | 293 | #### postfetch 294 | - **Description**: you can organize chain of calling events after the current endpoint will be successful executed 295 | - **Type**: Array 296 | - **Default**: null 297 | - **Example**: 298 | ```js 299 | { 300 | user: "/user/info", 301 | logout: { 302 | url: "/user/logout", 303 | postfetch: [ 304 | function({data, actions, dispatch, getState, request}) { 305 | dispatch(actions.user.reset()); 306 | } 307 | ] 308 | } 309 | } 310 | ``` 311 | 312 | #### validation (data, callback) 313 | - **Param** **data** - response data 314 | > type: Object 315 | 316 | - **Param** **callback** - you need to execute this callback function to finish data validation 317 | > type: Function 318 | 319 | - **Example**: 320 | ```js 321 | { 322 | test: { 323 | url: "/api/test", 324 | validation: (data, cb) { 325 | // check data format 326 | let error; 327 | if (data instanceOf Array) { 328 | error = "Data must be array"; 329 | } 330 | cb(error); 331 | } 332 | } 333 | } 334 | ``` 335 | 336 | #### reducerName 337 | - **Description**: Sometimes though, you might want named actions that go back to the same reducer. For example: 338 | - **Type**: String 339 | - **Example**: 340 | ```js 341 | import reduxApi, {transformers} from "redux-api"; 342 | const rest = reduxApi({ 343 | getUser: { 344 | reducerName: "user" 345 | url: "/user/1", // return a user object 346 | } 347 | updateUser: { 348 | reducerName: "user" 349 | url: "/user/1/update", 350 | options: { 351 | method: "post" 352 | } 353 | } 354 | }); 355 | const {actions} = rest; 356 | 357 | // In component with redux support (see example section) 358 | const {dispatch} = this.props; 359 | dispatch(rest.actions.getUser()); // GET "/api/v1/entry" 360 | dispatch(rest.actions.updateUser({}, { 361 | body: JSON.stringify({ name: "Hubot", login: "hubot"}) 362 | })); // POST "/api/v1/entry/1" with body 363 | 364 | ``` 365 | In the above example, both getUser, and updateUser update the same user reducer as they share the same reducerName 366 | For example used es7 javascript 367 | 368 | #### helpers 369 | - **Description**: you can create custom helper function which work with this rest endpoint but with different parameters. 370 | - **Type**: Object 371 | - **Example**: 372 | ```js 373 | { 374 | logger: "/api/logger", 375 | test: { 376 | url: "/api/test/:name/:id", 377 | helpers: { 378 | get(id, name) { 379 | return [{id, name}], {}] 380 | }, 381 | post(id, name, data) { 382 | const {uuid} = this.getState().test; 383 | const urlparams = {id, name}; 384 | const params = {body: {uuid, data}}; 385 | return [urlparams, params]; 386 | }, 387 | // complicated async logic 388 | async() { 389 | const {dispatch} = this; 390 | return (cb)=> { 391 | dispatch(rest.actions.logger((err)=> { 392 | const args = [{id: 1, name: "admin"}]; 393 | cb(err, args); 394 | })); 395 | }; 396 | } 397 | } 398 | } 399 | } 400 | // using helpers 401 | rest.actions.test.get(1, "admin"); 402 | // with callback 403 | rest.actions.test.post(1, "admin", {msg: "Hello"}, (err)=> { 404 | // end of action 405 | }); 406 | rest.actions.test.async(); 407 | ``` 408 | 409 | #### crud 410 | - **Description**: autogenerate `helpers` ("get", "post", "put", "delete", "patch") for selected endpoint. Also you can overwrite autogenerate action with `helpers` definitions. 411 | - **Type**: Boolean 412 | - **Default**: false 413 | - **Example**: 414 | ```js 415 | { 416 | test: { 417 | url: "/test/:id", 418 | crud: true 419 | } 420 | } 421 | 422 | //using 423 | rest.actions.test.get({ id: 1}) 424 | rest.actions.test.post({ id: 1}, { body: "data" }, (err, data)=> { 425 | //code 426 | }); 427 | rest.actions.test.put({ id: 1}, { body: "data" }) 428 | rest.actions.test.delete({ id: 1 }); 429 | ``` 430 | 431 | ### reduxApi object 432 | 433 | #### use(key, value) 434 | - **Description**: initialize `reduxApi` with custom properties 435 | - **Param** **key** - name of property 436 | - **Param** **value** - value of property 437 | 438 | #### list of properties 439 | #### fetch 440 | - **Description**: backend adapter. In current example we use `adaptersFetch` adapter for rest backend using `fetch` API for rest [isomorphic-fetch](https://www.npmjs.com/package/isomorphic-fetch) 441 | - **Example**: 442 | ```js 443 | import adapterFetch from "redux-api/lib/adapters/fetch"; 444 | const rest = reduxApi({...}); 445 | rest.use("fetch", adapterFetch(fetch)); 446 | ``` 447 | 448 | #### server 449 | - **Description**: redux api is isomorphic compatible see [examples/isomorphic](https://github.com/lexich/redux-api/tree/master/examples/isomorphic) By default `server===false` for client-size mode. If `server===true` redux-api works in server-size mode. 450 | - **Default** false 451 | ```js 452 | const rest = reduxApi({...}); 453 | rest.use("server", true); 454 | ``` 455 | 456 | #### rootUrl 457 | - **Description**: root url for every endpoint. very usefull for isomorphic(universal) app. For client-side use default rootUrl, and for backend use http://localhost:80 for example. For client-side for request `/api/get` will be `/api/get` and for backend will be `http://localhost:80/api/get` 458 | - **Type**: String | Functions 459 | - **Example**: 460 | ```js 461 | const rest = reduxApi({...}); 462 | rest.use("rootUrl", "http://localhost:3000"); 463 | ``` 464 | 465 | Or a function 466 | ```js 467 | const rest = reduxApi({...}); 468 | rest.use("rootUrl", function(url, params, getState) { 469 | return getState().config.rootUrl; 470 | }); 471 | ``` 472 | 473 | #### options 474 | - **Description**: Apply add options for each rest call. 475 | - **Type**: String | Functions 476 | - **Example**: 477 | ```js 478 | const rest = reduxApi({...}); 479 | rest.use("options", function() { 480 | const headers = { 481 | 'User-Agent': 'foodsoft-shop', // @todo add version 482 | 'Accept': 'application/json' 483 | }; 484 | return { headers: headers }; 485 | }); 486 | ``` 487 | 488 | Or a function 489 | ```js 490 | const rest = reduxApi({...}); 491 | rest.use("options", function(url, params getState) { 492 | return { 493 | headers: { 494 | 'X-Token': getState().user.accessToken 495 | } 496 | }; 497 | }); 498 | ``` 499 | 500 | #### middlewareParser 501 | - **Description**: if you use middleware different from [redux-thunk](https://github.com/gaearon/redux-thunk) you can realize custom behaviour for argument parser. 502 | - **Example**: 503 | ```js 504 | // Custom middleware 505 | const cutsomThunkMiddleware = ({ dispatch, getState }) => next => action => { 506 | if (typeof action === 'function') { 507 | return action({ dispatch, getState }); 508 | } 509 | return next(action); 510 | }; 511 | 512 | // middlewareParser 513 | reduxApi({ ... }).use("middlewareParser", 514 | ({ dispatch, getState })=> { 515 | return { getState, dispatch }; 516 | }); 517 | ``` 518 | 519 | #### responseHandler 520 | - **Description**: catch all http response from each redux-api endpoint. First argument is `Error` is response fail, second argument data from success response. It can be used for logging, error handling or data transformation. 521 | - **Example**: 522 | ```js 523 | reduxApi({ ... }).use("responseHandler", 524 | (err, data)=> 525 | err ? console.log("ERROR", err) : console.log("SUCCESS", data)); 526 | ``` 527 | ```js 528 | reduxApi({ ... }).use("responseHandler", 529 | (err, data)=> { 530 | if (err.message === 'Not allowed') { 531 | throw new NotAllowedError(); 532 | } else { 533 | return data; 534 | } 535 | }); 536 | ``` 537 | 538 | #### init(adapter, isServer, rootUrl) 539 | - @deprecated 540 | - **Description**: `reduxApi` initializer returns not initialized object. You need to call `init` for initialize it. 541 | - **Type**: Function 542 | - **Param** **adapter** - backend adapter. In current example we use `adaptersFetch` adapter for rest backend using `fetch` API for rest [isomorphic-fetch](https://www.npmjs.com/package/isomorphic-fetch) 543 | - **Param** **isServer** - redux api is isomorphic compatible see [examples/isomorphic](https://github.com/lexich/redux-api/tree/master/examples/isomorphic) By default `isServer===false` for client-size mode. If `isServer===true` redux-api works in server-size mode. 544 | - **Param** **rootUrl** - root url for every endpoint. very usefull for isomorphic(universal) app. For client-side use default rootUrl, and for backend use http://localhost:80 for example. For client-side for request `/api/get` will be `/api/get` and for backend will be `http://localhost:80/api/get`. 545 | - **Example**: 546 | 547 | ```js 548 | import "isomorphic-fetch"; 549 | import reduxApi from "redux-api"; 550 | import adapterFetch from "redux-api/lib/adapters/fetch"; 551 | const rest = reduxApi({ 552 | ... //config 553 | }); 554 | rest.init(adapterFetch(fetch), false, "http://localhost:3000"); 555 | ``` 556 | 557 | #### actions 558 | - **Description**: list of redux actions for rest manipulations 559 | - **Type**: Object 560 | - **Example**: 561 | ```js 562 | const rest = reduxApi({ 563 | entries: "/api/v1/entry", 564 | entry: { 565 | url: "/api/v1/entry/:id", 566 | options: { 567 | method: "post" 568 | } 569 | } 570 | }); 571 | // .... 572 | const {actions} = rest; 573 | /* 574 | initialState for store 575 | store = { 576 | entries: { 577 | loading: false, // request finish flag 578 | sync: false, // data has loaded minimum once 579 | data: {} // data 580 | }, 581 | entry: { loading: false, sync: false, data: {} }, 582 | } 583 | */ 584 | 585 | // In component with redux support (see example section) 586 | const {dispatch} = this.props; 587 | dispatch(rest.actions.entries()); // GET "/api/v1/entry" 588 | dispatch(rest.actions.entry({id: 1}, { 589 | body: JSON.stringify({ name: "Hubot", login: "hubot" 590 | }})); // POST "/api/v1/entry/1" with body 591 | dispatch(rest.actions.entries.reset()); 592 | dispatch(rest.actions.entries.sync()); 593 | ``` 594 | 595 | ### Actions sub methods 596 | 597 | #### sync(urlparams, params, callback) 598 | - **Description**: this method save you from twice requests flag `sync`. if `sync === true` request wouldn't execute. In server-side mode calls twice 599 | - **Param** **urlparams** - update url according Url schema 600 | - **Param** **params** - add additional params to rest request 601 | - **Param** **callback** - callback function when action ends 602 | - **Type**: Function 603 | - **Example**: 604 | 605 | ```js 606 | import {actions} from "./rest"; 607 | function onEnter(state, replaceState, callback) { 608 | dispatch(rest.actions.entries.sync(callback)); 609 | } 610 | 611 | ``` 612 | 613 | ### abort() 614 | - **Description**: abort loading request 615 | - **Type**: null 616 | - **Example**: 617 | ```js 618 | import {actions} from "./rest"; 619 | dispatch(actions.entries({ id: 1 })) 620 | actions.entries.abort() // abort previous request 621 | dispatch(actions.entries({ id: 2 })) 622 | ``` 623 | 624 | ### force(urlparams, params, callback) 625 | - **Description**: abort previous request if it performs and after that perform new request. This method combines `abort` and direct call action methods. 626 | - **Type**: Function 627 | - **Example**: 628 | ``` 629 | import {actions} from "./rest"; 630 | dispatch(actions.entries({ id: 1 })) 631 | dispatch(actions.entries.force({ id: 2 })) 632 | ``` 633 | 634 | #### reset(mutation) 635 | - **Description**: Reset state of current reducer and application abort request if it processed. 636 | - **Type**: Function 637 | - **Param** mutation: if `mutation` equal `sync`, it reset only `sync` flag in store. 638 | - **Example**: 639 | ```js 640 | import {actions} from "./rest"; 641 | function onLeave(state, replaceState, cb) { 642 | dispatch(actions.entries.reset(cb)); 643 | } 644 | 645 | ``` 646 | 647 | #### request() 648 | - **Description**: Pure xhr request is without sending events or catching reducers. 649 | - **Type**: Function 650 | - **Example**: 651 | ```js 652 | import {actions} from "./rest"; 653 | actions.entries.request().then((data)=> { 654 | .... 655 | }); 656 | ``` 657 | 658 | ### Url schema 659 | /api/v1/user/:id 660 | ```js 661 | rest.actions.user({id: 1}) // /api/v1/user/1 662 | ``` 663 | 664 | /api/v1/user/(:id) 665 | ```js 666 | rest.actions.user({id: 1}) // /api/v1/user/1 667 | ``` 668 | 669 | /api/v1/user/(:id) 670 | ```js 671 | rest.actions.user({id: 1, test: 2}) // /api/v1/user/1?test=2 672 | ``` 673 | 674 | ### Events 675 | Each endpoint in redux-api infrastructure has own collection of methods. 676 | - actionFetch - emits when endpoint's call is started 677 | - actionSuccess - emits when endpoint's call finishes with success result 678 | - actionFail - emits when endpoint's call finishes with error result 679 | - actionReset - emits when reset action was called 680 | 681 | you can get access for anyone using next accessible properties 682 | ```js 683 | rest.events.user.actionFetch // actionSuccess actionFail actionReset 684 | .... 685 | ``` 686 | It's very useful when you need to update external reducer using information from redux-api. 687 | 688 | ```js 689 | const initialState = { access: false }; 690 | function accessReducer(state=initialState, action) { 691 | switch (action.type) { 692 | case UPDATE_ACCESS: // manual update 693 | return { ...state, access: action.access }; 694 | case rest.events.user.actionSuccess: // user has own information about access 695 | return { ...state, access: action.data.access }; 696 | default: 697 | state; 698 | } 699 | } 700 | ``` 701 | 702 | ### Tools 703 | #### async 704 | - **Description**: helps to organize chain call of actions 705 | - **Example**: 706 | ```js 707 | import reduxApi, { async } from "redux-api"; 708 | const rest = reduxApi({ 709 | test: "/api/test", 710 | test2: "/api/test2", 711 | test3: "/api/test3" 712 | }); 713 | async(dispatch, 714 | (cb)=> rest.actions.test(cb), 715 | rest.actions.test2 716 | ).then((data)=> async(rest.actions.test3)); 717 | ``` 718 | 719 | ### Store state schema 720 | ```js 721 | import reduxApi from "redux-api"; 722 | 723 | const rest = reduxApi({ 724 | user: "/user/1" 725 | }); 726 | ``` 727 | In the above example, an endpoint for a user object is created. The corresponding initial store state for this object is the following: 728 | 729 | ```js 730 | // initialState 731 | { 732 | user: { 733 | sync: false, // State was update once 734 | syncing: false, // State syncing is in progress 735 | loading: false, // State updating is in progress 736 | error: null, // response error 737 | data: [] // response data 738 | } 739 | } 740 | ``` 741 | - The `sync` flag will be set to `true` after the first dispatched request is handled. 742 | - The `syncing` flag is set to `true` while a dispatched sync request is being handled, but only when the `sync` flag is `false`. This can only happen once when not manually resetting the `sync` flag during execution, since the `sync` flag is set to `true` after the first dispatched request is handled. 743 | - The `loading` flag is set to `true` while a dispatched request is being handled. After the dispatched request is handled, its value will be reset to `false`. 744 | - The `error` property contains the response error of a dispatched request after it is handled. 745 | - The `data` property contains the response data of a dispatched request after it is handled. 746 | -------------------------------------------------------------------------------- /docs/Scoping.md: -------------------------------------------------------------------------------- 1 | If you want use multipoint redux-api or separate your reducers by scope. 2 | ```js 3 | const rest1 = reduxApi({ 4 | test: "/test1", 5 | }, { prefix: "r1" }); // <-- important to scoping 6 | 7 | const rest2 = reduxApi({ 8 | test: "/test2" 9 | }, { prefix: "r2" }); // <-- important to scoping 10 | 11 | const reducer = combineReducers({ 12 | r1: combineReducers(rest1.reducers), // <-- the same scope (r1) 13 | r2: combineReducers(rest2.reducers), // <-- as in prefix (r2) 14 | r3: combineReducers(myReducers) 15 | }); 16 | 17 | const createStoreWithMiddleware = applyMiddleware(thunk)(createStore); 18 | const store = createStoreWithMiddleware(reducer); 19 | // etc 20 | 21 | // You should remember that scoping modify structure of your state 22 | // state of rest1 is 23 | const dataOfRest1Test = store.getState().r1.test.data; 24 | const dataOfRest2Test = store.getState().r2.test.data; 25 | const other = store.getState().r3.other; 26 | ``` 27 | -------------------------------------------------------------------------------- /examples/isomorphic/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-3", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/isomorphic/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist/main.js 3 | -------------------------------------------------------------------------------- /examples/isomorphic/README.md: -------------------------------------------------------------------------------- 1 | # Isomorphic example 2 | React + Redux + React-Router + Redux-api with webpack and express + github api 3 | 4 | Run server 5 | ```sh 6 | npm install 7 | npm start 8 | ``` 9 | 10 | Point your browser to http://localhost:4444/ 11 | 12 | -------------------------------------------------------------------------------- /examples/isomorphic/app/client.jsx: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | import React from "react"; 3 | import { render } from "react-dom"; 4 | 5 | // React-Router 6 | import Router from "react-router"; 7 | import { createHistory } from "history"; 8 | import routes from "./routes/routes"; 9 | 10 | // Redux 11 | import { createStore, applyMiddleware, combineReducers, compose } from "redux"; 12 | import thunk from "redux-thunk"; 13 | import { Provider } from "react-redux"; 14 | 15 | // Redux-api 16 | import reduxApi from "./utils/rest"; 17 | import adapterFetch from "redux-api/lib/adapters/fetch"; 18 | import "isomorphic-fetch"; 19 | 20 | // Initialize react-api 21 | reduxApi.use("fetch", adapterFetch(fetch)); 22 | 23 | // Prepare store 24 | const reducer = combineReducers(reduxApi.reducers); 25 | const finalCreateStore = applyMiddleware(thunk)(createStore); 26 | const initialState = window.$REDUX_STATE; 27 | const store = initialState 28 | ? finalCreateStore(reducer, initialState) 29 | : finalCreateStore(reducer); 30 | delete window.$REDUX_STATE; 31 | 32 | const childRoutes = routes(store); 33 | const history = createHistory(); 34 | const el = document.getElementById("react-main-mount"); 35 | 36 | render( 37 | 38 | 39 | , 40 | el 41 | ); 42 | -------------------------------------------------------------------------------- /examples/isomorphic/app/pages/Application.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default class Application extends React.Component { 4 | render() { 5 | return ( 6 |
7 |
8 |
9 |

10 | Isomorphic Redux-api example with react-router 11 |

12 |
13 |
{this.props.children}
14 |
15 |
16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/isomorphic/app/pages/Repo.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from "react"; 2 | import { connect } from "react-redux"; 3 | import { Link } from "react-router"; 4 | 5 | class Repo extends React.Component { 6 | render() { 7 | const { repo } = this.props; 8 | const data = repo.data || {}; 9 | const owner = data.owner || {}; 10 | return !repo.loading ? ( 11 |
12 |
13 | 14 | 15 | 16 |
17 |
18 |

{data.name}

19 | @{owner.login}  Stars{" "} 20 | {data.stargazers_count} Forks {data.forks} 21 |
22 |
23 | ) : ( 24 |
Loading
25 | ); 26 | } 27 | } 28 | 29 | Repo.propTypes = { 30 | repo: PropTypes.object.isRequired, 31 | dispatch: PropTypes.func.isRequired 32 | }; 33 | 34 | function select(state) { 35 | return { repo: state.repo }; 36 | } 37 | 38 | export default connect(select)(Repo); 39 | -------------------------------------------------------------------------------- /examples/isomorphic/app/pages/User.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from "react"; 2 | import { connect } from "react-redux"; 3 | import { Link } from "react-router"; 4 | 5 | class User extends React.Component { 6 | render() { 7 | const { userRepos } = this.props; 8 | const Repos = userRepos.data.map(item => ( 9 | 14 | {item.name} 15 | 16 | )); 17 | return
{Repos}
; 18 | } 19 | } 20 | 21 | User.propTypes = { 22 | userRepos: PropTypes.object.isRequired, 23 | dispatch: PropTypes.func.isRequired 24 | }; 25 | 26 | function select(state) { 27 | return { userRepos: state.userRepos }; 28 | } 29 | 30 | export default connect(select)(User); 31 | -------------------------------------------------------------------------------- /examples/isomorphic/app/routes/routes.js: -------------------------------------------------------------------------------- 1 | import Application from "../pages/Application"; 2 | import User from "../pages/User"; 3 | import Repo from "../pages/Repo"; 4 | import rest from "../utils/rest"; 5 | 6 | const { actions } = rest; 7 | 8 | export default function routes({ dispatch }) { 9 | return { 10 | path: "/", 11 | component: Application, 12 | indexRoute: { 13 | path: "/", 14 | onEnter(state, replaceState) { 15 | replaceState(state, "/lexich"); 16 | } 17 | }, 18 | childRoutes: [ 19 | { 20 | path: "/:user", 21 | component: User, 22 | onEnter(state, replaceState, callback) { 23 | const { user } = state.params; 24 | dispatch(actions.userRepos({ user }, null, callback)); 25 | } 26 | }, 27 | { 28 | path: "/:user/:repo", 29 | component: Repo, 30 | onEnter(state, replaceState, callback) { 31 | const { user, repo } = state.params; 32 | dispatch(actions.repo({ user, repo }, null, callback)); 33 | } 34 | } 35 | ] 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /examples/isomorphic/app/server.jsx: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | import React from "react"; 3 | import ReactDom from "react-dom/server"; 4 | import express from "express"; 5 | import path from "path"; 6 | 7 | // Redux 8 | import { applyMiddleware, createStore, combineReducers } from "redux"; 9 | import { Provider } from "react-redux"; 10 | import thunk from "redux-thunk"; 11 | 12 | // React-router 13 | import { RoutingContext, match } from "react-router"; 14 | import { createMemoryHistory as createHistory } from "history"; 15 | 16 | import routes from "./routes/routes"; 17 | 18 | // Redux-api 19 | import "isomorphic-fetch"; 20 | import reduxApi from "./utils/rest"; 21 | import adapterFetch from "redux-api/lib/adapters/fetch"; 22 | 23 | reduxApi.use("fetch", adapterFetch(fetch)).use("server", true); 24 | 25 | const history = createHistory(); 26 | 27 | // Init express app 28 | const app = express(); 29 | 30 | // Include static assets. Not advised for production 31 | app.use(express.static(path.join(__dirname, "..", "dist"))); 32 | 33 | // Set view path 34 | app.set("views", path.join(__dirname, "..", "views")); 35 | 36 | // set up ejs for templating. You can use whatever 37 | app.set("view engine", path.join(__dirname, "..", "ejs")); 38 | 39 | app.use(function(req, res, next) { 40 | const location = history.createLocation(req.url); 41 | const createStoreWithMiddleware = applyMiddleware(thunk)(createStore); 42 | const reducer = combineReducers(reduxApi.reducers); 43 | const store = createStoreWithMiddleware(reducer); 44 | const childRoutes = routes(store); 45 | match( 46 | { routes: childRoutes, location }, 47 | (error, redirectLocation, renderProps) => { 48 | if (redirectLocation) { 49 | res 50 | .status(301) 51 | .redirect(redirectLocation.pathname + redirectLocation.search); 52 | } else if (error) { 53 | res.status(500).send(error.message); 54 | } else if (renderProps === null) { 55 | res.status(404).render("404.ejs"); 56 | } else { 57 | const html = ReactDom.renderToString( 58 | 59 | 60 | 61 | ); 62 | res.render("index.ejs", { 63 | html, 64 | json: JSON.stringify(store.getState()) 65 | }); 66 | } 67 | } 68 | ); 69 | }); 70 | 71 | const server = app.listen(4444, function() { 72 | const { port } = server.address(); 73 | console.log("Server started at http://localhost:%s", port); 74 | }); 75 | -------------------------------------------------------------------------------- /examples/isomorphic/app/utils/rest.js: -------------------------------------------------------------------------------- 1 | /* eslint import/no-unresolved: 0 */ 2 | /* eslint import/extensions: 0 */ 3 | import reduxApi from "redux-api"; 4 | import map from "lodash/map"; 5 | 6 | const headers = { 7 | "User-Agent": "redux-api" 8 | }; 9 | const URL = "https://api.github.com"; 10 | 11 | export default reduxApi({ 12 | userRepos: { 13 | url: `${URL}/users/:user/repos`, 14 | options: { headers }, 15 | cache: { expire: 5000 }, 16 | transformer(data) { 17 | return map(data, item => { 18 | return { 19 | name: item.name, 20 | fullName: item.full_name, 21 | user: item.owner.login 22 | }; 23 | }); 24 | } 25 | }, 26 | repo: { 27 | url: `${URL}/repos/:user/:repo`, 28 | options: { headers }, 29 | cache: { 30 | expire: 5000 31 | } 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /examples/isomorphic/dist/404.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lexich/redux-api/e92b171dda6b1586777a0caacb0308b9f7d5039a/examples/isomorphic/dist/404.jpg -------------------------------------------------------------------------------- /examples/isomorphic/dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lexich/redux-api/e92b171dda6b1586777a0caacb0308b9f7d5039a/examples/isomorphic/dist/favicon.ico -------------------------------------------------------------------------------- /examples/isomorphic/dist/styles.css: -------------------------------------------------------------------------------- 1 | .Repo__media{ 2 | max-width: 100px; 3 | } 4 | -------------------------------------------------------------------------------- /examples/isomorphic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node_modules/.bin/webpack --process --watch & node_modules/.bin/pm2-dev start server.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": { 11 | "name": "Efremov Alex", 12 | "email": "lexich121@gmail.com", 13 | "url": "https://github.com/lexich" 14 | }, 15 | "license": "MIT", 16 | "dependencies": { 17 | "ejs": "^2.3.4", 18 | "express": "^4.13.3", 19 | "history": "^1.17.0", 20 | "isomorphic-fetch": "^2.2.0", 21 | "lodash": "^4.11.1", 22 | "react": "^0.14.3", 23 | "react-dom": "^0.14.3", 24 | "react-redux": "^4.0.2", 25 | "react-router": "^1.0.2", 26 | "redux": "^3.0.5", 27 | "redux-api": "^0.9.3", 28 | "redux-thunk": "^1.0.2", 29 | "babel-core": "^6.3.21" 30 | }, 31 | "devDependencies": { 32 | "babel-loader": "^6.2.0", 33 | "babel-plugin-add-module-exports": "^0.1.2", 34 | "babel-preset-es2015": "^6.3.13", 35 | "babel-preset-react": "^6.3.13", 36 | "babel-preset-stage-3": "^6.3.13", 37 | "pm2": "^0.15.10", 38 | "webpack": "^1.12.9" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/isomorphic/server.js: -------------------------------------------------------------------------------- 1 | require("babel-core/register")({ 2 | ignore: /node_modules/ 3 | }); 4 | 5 | require("./app/server"); 6 | -------------------------------------------------------------------------------- /examples/isomorphic/views/404.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Isomorphic Server Side Rendering Example 5 | 6 | 7 | 8 | 9 | 10 |

11 |
12 |

404 Not found

13 | Not found 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/isomorphic/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Isomorphic Server Side Rendering Example 5 | 6 | 7 | 8 | 9 | 10 |
<%- html %>
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/isomorphic/webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var webpack = require("webpack"); 4 | var path = require("path"); 5 | 6 | var plugins = [ 7 | new webpack.DefinePlugin({ 8 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV) 9 | }), 10 | new webpack.optimize.OccurenceOrderPlugin() 11 | ]; 12 | 13 | if (process.env.NODE_ENV === "production") { 14 | plugins.push( 15 | new webpack.optimize.UglifyJsPlugin({ 16 | compressor: { 17 | screw_ie8: true, 18 | warnings: false 19 | } 20 | }) 21 | ); 22 | } 23 | 24 | module.exports = { 25 | module: { 26 | loaders: [ 27 | { test: /\.(js|jsx)$/, loaders: ["babel-loader"], exclude: /node_modules/ } 28 | ] 29 | }, 30 | entry: { 31 | main: "./app/client.jsx" 32 | }, 33 | output: { 34 | path: "dist", 35 | filename: "main.js" 36 | }, 37 | debug: true, 38 | devtool: "eval-source-map", 39 | plugins: plugins, 40 | resolve: { 41 | // Alias redux-api for using version from source instead of npm 42 | alias: { 43 | "redux-api/lib": path.resolve( 44 | path.join(__dirname, "..", "..", "src") 45 | ), 46 | "redux-api": path.resolve( 47 | path.join(__dirname, "..", "..", "src") 48 | ) 49 | }, 50 | extensions: ["", ".js", ".jsx"] 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-api", 3 | "version": "0.12.0", 4 | "author": { 5 | "name": "Efremov Alex", 6 | "email": "lexich121@gmail.com", 7 | "url": "https://github.com/lexich" 8 | }, 9 | "main": "lib/index.js", 10 | "license": "MIT", 11 | "description": "Flux REST API for redux infrastructure", 12 | "repository": "http://github.com/lexich/redux-api", 13 | "scripts": { 14 | "test": "npm run eslint && npm run mocha && npm run yaspeller", 15 | "yaspeller": "node_modules/.bin/yaspeller .", 16 | "mocha": "node_modules/.bin/mocha --require @babel/register test/*_spec.js", 17 | "build": "rm -rf dist lib && npm run browser-dev && npm run browser-min && npm run compile", 18 | "cover": "./node_modules/.bin/nyc npm run mocha", 19 | "coveralls": "nyc report --reporter=text-lcov | coveralls", 20 | "eslint": "node_modules/.bin/eslint src test examples/isomorphic/app examples/isomorphic/server.js", 21 | "compile": "node_modules/.bin/babel src --out-dir lib --source-maps true", 22 | "prettier": "prettier --write \"{src,test,examples/isomorphic/app}/**/*.{js,jsx}\"", 23 | "browser-dev": "cross-env NODE_ENV=development node_modules/.bin/webpack", 24 | "browser-min": "cross-env NODE_ENV=production node_modules/.bin/webpack", 25 | "release": "node_modules/.bin/standard-version --no-verify && git push --follow-tags origin master; npm publish", 26 | "precommit": "npm run prettier && npm test", 27 | "prepush": "npm test && npm run build" 28 | }, 29 | "dependencies": { 30 | "fast-apply": "0.0.3", 31 | "qs": "^6.9.1" 32 | }, 33 | "devDependencies": { 34 | "@babel/cli": "^7.8.4", 35 | "@babel/core": "^7.8.4", 36 | "@babel/preset-env": "^7.8.4", 37 | "@babel/preset-react": "^7.8.3", 38 | "@babel/register": "^7.8.3", 39 | "babel-eslint": "10.0.3", 40 | "babel-loader": "8.0.6", 41 | "babel-plugin-add-module-exports": "1.0.2", 42 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 43 | "chai": "4.2.0", 44 | "coveralls": "3.0.9", 45 | "cross-env": "7.0.0", 46 | "eslint": "6.8.0", 47 | "eslint-config-airbnb": "18.0.1", 48 | "eslint-config-prettier": "6.10.0", 49 | "eslint-plugin-import": "2.20.1", 50 | "eslint-plugin-jsx-a11y": "6.2.3", 51 | "eslint-plugin-react": "7.18.3", 52 | "husky": "4.2.3", 53 | "lodash": "4.17.15", 54 | "mocha": "7.0.1", 55 | "nyc": "^15.0.0", 56 | "prettier": "1.19.1", 57 | "redux": "4.0.5", 58 | "redux-thunk": "2.3.0", 59 | "standard-version": "8.0.1", 60 | "webpack": "4.41.6", 61 | "webpack-cli": "^3.3.11", 62 | "yaspeller": "6.0.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/PubSub.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export default class PubSub { 4 | constructor() { 5 | this.container = []; 6 | } 7 | 8 | push(cb) { 9 | cb instanceof Function && this.container.push(cb); 10 | } 11 | 12 | resolve(data) { 13 | const container = this.container; 14 | this.container = []; 15 | container.forEach(cb => cb(null, data)); 16 | } 17 | 18 | reject(err) { 19 | const container = this.container; 20 | this.container = []; 21 | container.forEach(cb => cb(err)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/actionFn.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import fastApply from "fast-apply"; 4 | import libUrl from "url"; 5 | import urlTransform from "./urlTransform"; 6 | import merge from "./utils/merge"; 7 | import get from "./utils/get"; 8 | import fetchResolver from "./fetchResolver"; 9 | import PubSub from "./PubSub"; 10 | import createHolder from "./createHolder"; 11 | import { 12 | none, 13 | extractArgs, 14 | defaultMiddlewareArgsParser, 15 | CRUD 16 | } from "./helpers"; 17 | import { getCacheManager } from "./utils/cache"; 18 | 19 | /** 20 | * Constructor for create action 21 | * @param {String} url endpoint's url 22 | * @param {String} name action name 23 | * @param {Object} options action configuration 24 | * @param {Object} ACTIONS map of actions 25 | * @param {[type]} fetchAdapter adapter for fetching data 26 | * @return {Function+Object} action function object 27 | */ 28 | export default function actionFn(url, name, options, ACTIONS = {}, meta = {}) { 29 | const { 30 | actionFetch, 31 | actionSuccess, 32 | actionFail, 33 | actionReset, 34 | actionCache, 35 | actionAbort 36 | } = ACTIONS; 37 | const pubsub = new PubSub(); 38 | const requestHolder = createHolder(); 39 | 40 | function getOptions(urlT, params, getState) { 41 | const globalOptions = !meta.holder 42 | ? {} 43 | : meta.holder.options instanceof Function 44 | ? meta.holder.options(urlT, params, getState) 45 | : meta.holder.options; 46 | const baseOptions = !(options instanceof Function) 47 | ? options 48 | : options(urlT, params, getState); 49 | return merge({}, globalOptions, baseOptions, params); 50 | } 51 | 52 | function getUrl(pathvars, params, getState) { 53 | const resultUrlT = urlTransform(url, pathvars, meta.urlOptions); 54 | let urlT = resultUrlT; 55 | let rootUrl = get(meta, "holder", "rootUrl"); 56 | rootUrl = !(rootUrl instanceof Function) 57 | ? rootUrl 58 | : rootUrl(urlT, params, getState); 59 | if (rootUrl) { 60 | const rootUrlObject = libUrl.parse(rootUrl); 61 | const urlObject = libUrl.parse(urlT); 62 | if (!urlObject.host) { 63 | const urlPath = 64 | (rootUrlObject.path ? rootUrlObject.path.replace(/\/$/, "") : "") + 65 | "/" + 66 | (urlObject.path ? urlObject.path.replace(/^\//, "") : ""); 67 | urlT = `${rootUrlObject.protocol}//${rootUrlObject.host}${urlPath}`; 68 | } 69 | } 70 | return urlT; 71 | } 72 | 73 | function fetch( 74 | pathvars, 75 | params, 76 | options = {}, 77 | getState = none, 78 | dispatch = none 79 | ) { 80 | const urlT = getUrl(pathvars, params, getState); 81 | const opts = getOptions(urlT, params, getState); 82 | let id = meta.reducerName || ""; 83 | const cacheManager = getCacheManager(options.expire, meta.cache); 84 | 85 | if (cacheManager && getState !== none) { 86 | const state = getState(); 87 | const cache = get(state, meta.prefix, meta.reducerName, "cache"); 88 | id += "_" + cacheManager.id(pathvars, params); 89 | const data = cacheManager.getData( 90 | cache && id && cache[id] !== undefined && cache[id] 91 | ); 92 | if (data !== undefined) { 93 | return Promise.resolve(data); 94 | } 95 | } 96 | const response = meta.fetch(urlT, opts); 97 | if (cacheManager && dispatch !== none && id) { 98 | response.then(data => { 99 | dispatch({ type: actionCache, id, data, expire: cacheManager.expire }); 100 | }); 101 | } 102 | return response; 103 | } 104 | 105 | function abort() { 106 | const defer = requestHolder.pop(); 107 | const err = new Error("Application abort request"); 108 | defer && defer.reject(err); 109 | return err; 110 | } 111 | 112 | /** 113 | * Fetch data from server 114 | * @param {Object} pathvars path vars for url 115 | * @param {Object} params fetch params 116 | * @param {Function} getState helper meta function 117 | */ 118 | function request( 119 | pathvars, 120 | params, 121 | options, 122 | getState = none, 123 | dispatch = none 124 | ) { 125 | const response = fetch(pathvars, params, options, getState, dispatch); 126 | const result = !meta.validation 127 | ? response 128 | : response.then( 129 | data => 130 | new Promise((resolve, reject) => 131 | meta.validation(data, err => (err ? reject(err) : resolve(data))) 132 | ) 133 | ); 134 | let ret = result; 135 | const responseHandler = get(meta, "holder", "responseHandler"); 136 | if (responseHandler) { 137 | if (result && result.then) { 138 | ret = result.then( 139 | data => { 140 | const res = responseHandler(null, data); 141 | if (res === undefined) { 142 | return data; 143 | } else { 144 | return res; 145 | } 146 | }, 147 | err => responseHandler(err) 148 | ); 149 | } else { 150 | ret = responseHandler(result); 151 | } 152 | } 153 | ret && ret.catch && ret.catch(none); 154 | return ret; 155 | } 156 | 157 | /** 158 | * Fetch data from server 159 | * @param {Object} pathvars path vars for url 160 | * @param {Object} params fetch params 161 | * @param {Function} callback) callback execute after end request 162 | */ 163 | function fn(...args) { 164 | const [pathvars, params, callback] = extractArgs(args); 165 | const syncing = params ? !!params.syncing : false; 166 | params && delete params.syncing; 167 | pubsub.push(callback); 168 | return (...middlewareArgs) => { 169 | const middlewareParser = 170 | get(meta, "holder", "middlewareParser") || defaultMiddlewareArgsParser; 171 | const { dispatch, getState } = middlewareParser(...middlewareArgs); 172 | const state = getState(); 173 | const isLoading = get(state, meta.prefix, meta.reducerName, "loading"); 174 | if (isLoading) { 175 | return Promise.reject("isLoading"); 176 | } 177 | const requestOptions = { pathvars, params }; 178 | const prevData = get(state, meta.prefix, meta.reducerName, "data"); 179 | dispatch({ type: actionFetch, syncing, request: requestOptions }); 180 | const fetchResolverOpts = { 181 | dispatch, 182 | getState, 183 | request: requestOptions, 184 | actions: meta.actions, 185 | prefetch: meta.prefetch 186 | }; 187 | if (Object.defineProperty) { 188 | Object.defineProperty(fetchResolverOpts, "requestOptions", { 189 | get() { 190 | /* eslint no-console: 0 */ 191 | console.warn("Deprecated option, use `request` option"); 192 | return requestOptions; 193 | } 194 | }); 195 | } else { 196 | fetchResolverOpts.requestOptions = requestOptions; 197 | } 198 | 199 | const result = new Promise((done, fail) => { 200 | fetchResolver(0, fetchResolverOpts, err => { 201 | if (err) { 202 | pubsub.reject(err); 203 | return fail(err); 204 | } 205 | new Promise((resolve, reject) => { 206 | requestHolder.set({ 207 | resolve, 208 | reject, 209 | promise: request(pathvars, params, {}, getState, dispatch).then( 210 | resolve, 211 | reject 212 | ) 213 | }); 214 | }).then( 215 | d => { 216 | requestHolder.pop(); 217 | const data = meta.transformer(d, prevData, { 218 | type: actionSuccess, 219 | request: requestOptions 220 | }); 221 | dispatch({ 222 | data, 223 | origData: d, 224 | type: actionSuccess, 225 | syncing: false, 226 | request: requestOptions 227 | }); 228 | if (meta.broadcast) { 229 | meta.broadcast.forEach(type => { 230 | dispatch({ 231 | type, 232 | data, 233 | origData: d, 234 | request: requestOptions 235 | }); 236 | }); 237 | } 238 | if (meta.postfetch) { 239 | meta.postfetch.forEach(postfetch => { 240 | postfetch instanceof Function && 241 | postfetch({ 242 | data, 243 | getState, 244 | dispatch, 245 | actions: meta.actions, 246 | request: requestOptions 247 | }); 248 | }); 249 | } 250 | pubsub.resolve(data); 251 | done(data); 252 | }, 253 | error => { 254 | dispatch({ 255 | error, 256 | type: actionFail, 257 | loading: false, 258 | syncing: false, 259 | request: requestOptions 260 | }); 261 | pubsub.reject(error); 262 | fail(error); 263 | } 264 | ); 265 | }); 266 | }); 267 | result.catch(none); 268 | return result; 269 | }; 270 | } 271 | 272 | /* 273 | Pure rest request 274 | */ 275 | fn.request = function(pathvars, params, options) { 276 | return request(pathvars, params, options || {}); 277 | }; 278 | 279 | /** 280 | * Reset store to initial state 281 | */ 282 | fn.reset = mutation => { 283 | abort(); 284 | return mutation === "sync" 285 | ? { type: actionReset, mutation } 286 | : { type: actionReset }; 287 | }; 288 | 289 | /* 290 | Abort request 291 | */ 292 | fn.abort = function() { 293 | const error = abort(); 294 | return { type: actionAbort, error }; 295 | }; 296 | 297 | fn.force = function(...args) { 298 | return (dispatch, getState) => { 299 | const state = getState(); 300 | const isLoading = get(state, meta.prefix, meta.reducerName, "loading"); 301 | if (isLoading) { 302 | dispatch(fn.abort()); 303 | } 304 | return fn(...args)(dispatch, getState); 305 | }; 306 | }; 307 | 308 | /** 309 | * Sync store with server. In server mode works as usual method. 310 | * If data have already synced, data would not fetch after call this method. 311 | * @param {Object} pathvars path vars for url 312 | * @param {Object} params fetch params 313 | * @param {Function} callback) callback execute after end request 314 | */ 315 | fn.sync = (...args) => { 316 | const [pathvars, params, callback] = extractArgs(args); 317 | const isServer = meta.holder ? meta.holder.server : false; 318 | return (dispatch, getState) => { 319 | const state = getState(); 320 | const store = get(state, meta.prefix, name); 321 | if (!isServer && store && store.sync) { 322 | callback(null, store.data); 323 | return; 324 | } 325 | const modifyParams = { ...params, syncing: true }; 326 | return fn(pathvars, modifyParams, callback)(dispatch, getState); 327 | }; 328 | }; 329 | 330 | let helpers = meta.helpers || {}; 331 | if (meta.crud) { 332 | helpers = { ...CRUD, ...helpers }; 333 | } 334 | const fnHelperCallback = (memo, func, helpername) => { 335 | if (memo[helpername]) { 336 | throw new Error( 337 | `Helper name: "${helpername}" for endpoint "${name}" has been already reserved` 338 | ); 339 | } 340 | const { sync, call } = func instanceof Function ? { call: func } : func; 341 | memo[helpername] = (...args) => (dispatch, getState) => { 342 | const index = args.length - 1; 343 | const callbackFn = args[index] instanceof Function ? args[index] : none; 344 | const helpersResult = fastApply( 345 | call, 346 | { getState, dispatch, actions: meta.actions }, 347 | args 348 | ); 349 | const result = new Promise((resolve, reject) => { 350 | const callback = (err, data) => { 351 | err ? reject(err) : resolve(data); 352 | callbackFn(err, data); 353 | }; 354 | // If helper alias using async functionality 355 | if (helpersResult instanceof Function) { 356 | helpersResult((error, newArgs = []) => { 357 | if (error) { 358 | callback(error); 359 | } else { 360 | fastApply( 361 | sync ? fn.sync : fn, 362 | null, 363 | newArgs.concat(callback) 364 | )(dispatch, getState); 365 | } 366 | }); 367 | } else { 368 | // if helper alias is synchronous 369 | const [pathvars, params] = helpersResult; 370 | fastApply(sync ? fn.sync : fn, null, [pathvars, params, callback])( 371 | dispatch, 372 | getState 373 | ); 374 | } 375 | }); 376 | result.catch(none); 377 | return result; 378 | }; 379 | return memo; 380 | }; 381 | 382 | return Object.keys(helpers).reduce( 383 | (memo, key) => fnHelperCallback(memo, helpers[key], key, helpers), 384 | fn 385 | ); 386 | } 387 | -------------------------------------------------------------------------------- /src/adapters/fetch.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function processData(data) { 4 | try { 5 | return JSON.parse(data); 6 | } catch (err) { 7 | return data; 8 | } 9 | } 10 | 11 | function toJSON(resp) { 12 | if (resp.text) { 13 | return resp.text().then(processData); 14 | } else if (resp instanceof Promise) { 15 | return resp.then(processData); 16 | } else { 17 | return Promise.resolve(resp).then(processData); 18 | } 19 | } 20 | 21 | export default function(fetch) { 22 | return (url, opts) => 23 | fetch(url, opts).then(resp => { 24 | // Normalize IE9's response to HTTP 204 when Win error 1223. 25 | const status = resp.status === 1223 ? 204 : resp.status; 26 | const statusText = resp.status === 1223 ? "No Content" : resp.statusText; 27 | 28 | if (status >= 400) { 29 | return Promise.reject({ status, statusText }); 30 | } else { 31 | return toJSON(resp).then(data => { 32 | if (status >= 200 && status < 300) { 33 | return data; 34 | } else { 35 | return Promise.reject(data); 36 | } 37 | }); 38 | } 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/async.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {[type]} dispatch [description] 4 | * @param {...[type]} args [description] 5 | * @return {[type]} [description] 6 | * @example 7 | * async(dispatch, 8 | * cb=> actions.test(1, cb), 9 | * actions.test2 10 | * ).then(()=> async(dispatch, actions.test3)) 11 | */ 12 | export default function async( 13 | dispatch, 14 | currentFunction = null, 15 | ...restFunctions 16 | ) { 17 | return new Promise((resolve, reject) => { 18 | if (!currentFunction) { 19 | reject("no chain function"); 20 | } else { 21 | dispatch( 22 | currentFunction((err, data) => { 23 | err ? reject(err) : resolve(data); 24 | }) || {} 25 | ); 26 | } 27 | }).then(data => { 28 | if (restFunctions.length) { 29 | return async(dispatch, ...restFunctions); 30 | } else { 31 | return data; 32 | } 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/createHolder.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export default function() { 4 | let data; 5 | let hasData = false; 6 | return { 7 | set(val) { 8 | if (!hasData) { 9 | data = val; 10 | hasData = true; 11 | return true; 12 | } 13 | return false; 14 | }, 15 | empty() { 16 | return !hasData; 17 | }, 18 | pop() { 19 | if (hasData) { 20 | hasData = false; 21 | const result = data; 22 | data = null; 23 | return result; 24 | } 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/fetchResolver.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function none() {} 4 | 5 | export default function fetchResolver(index = 0, opts = {}, cb = none) { 6 | if (!opts.prefetch || index >= opts.prefetch.length) { 7 | cb(); 8 | } else { 9 | opts.prefetch[index](opts, err => 10 | err ? cb(err) : fetchResolver(index + 1, opts, cb) 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | export function none() {} 2 | 3 | export function extractArgs(args) { 4 | let pathvars; 5 | let params = {}; 6 | let callback; 7 | if (args[0] instanceof Function) { 8 | callback = args[0]; 9 | } else if (args[1] instanceof Function) { 10 | pathvars = args[0]; 11 | callback = args[1]; 12 | } else { 13 | pathvars = args[0]; 14 | params = args[1]; 15 | callback = args[2] || none; 16 | } 17 | return [pathvars, params, callback]; 18 | } 19 | 20 | export function helperCrudFunction(name) { 21 | return (...args) => { 22 | const [pathvars, params, cb] = extractArgs(args); 23 | return [pathvars, { ...params, method: name.toUpperCase() }, cb]; 24 | }; 25 | } 26 | 27 | export function defaultMiddlewareArgsParser(dispatch, getState) { 28 | return { dispatch, getState }; 29 | } 30 | 31 | export const CRUD = ["get", "post", "put", "delete", "patch"].reduce( 32 | (memo, name) => { 33 | memo[name] = helperCrudFunction(name); 34 | return memo; 35 | }, 36 | {} 37 | ); 38 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* eslint no-void: 0 */ 4 | 5 | import reducerFn from "./reducerFn"; 6 | import actionFn from "./actionFn"; 7 | import transformers from "./transformers"; 8 | import async from "./async"; 9 | import cacheManager from "./utils/cache"; 10 | // export { transformers, async }; 11 | 12 | /** 13 | * Default configuration for each endpoint 14 | * @type {Object} 15 | */ 16 | const defaultEndpointConfig = { 17 | transformer: transformers.object 18 | }; 19 | 20 | const PREFIX = "@@redux-api"; 21 | /** 22 | * Entry api point 23 | * @param {Object} config Rest api configuration 24 | * @param {Object} baseConfig baseConfig settings for Rest api 25 | * @param {Function} fetch Adapter for rest requests 26 | * @param {Boolean} isServer false by default (fif you want to use it for isomorphic apps) 27 | * @return {actions, reducers} { actions, reducers} 28 | * @example ```js 29 | * const api = reduxApi({ 30 | * test: "/plain/url", 31 | * testItem: "/plain/url/:id", 32 | * testModify: { 33 | * url: "/plain/url/:endpoint", 34 | 35 | * transformer: (data)=> !data ? 36 | * { title: "", message: "" } : 37 | * { title: data.title, message: data.message }, 38 | * options: { 39 | * method: "post" 40 | * headers: { 41 | * "Accept": "application/json", 42 | * "Content-Type": "application/json" 43 | * } 44 | * } 45 | * } 46 | * }); 47 | * // register reducers 48 | * 49 | * // call actions 50 | * dispatch(api.actions.test()); 51 | * dispatch(api.actions.testItem({id: 1})); 52 | * dispatch(api.actions.testModify({endpoint: "upload-1"}, { 53 | * body: JSON.stringify({title: "Hello", message: "World"}) 54 | * })); 55 | * ``` 56 | */ 57 | 58 | export default function reduxApi(config, baseConfig) { 59 | config || (config = {}); 60 | 61 | const fetchHolder = { 62 | fetch: null, 63 | server: false, 64 | rootUrl: null, 65 | middlewareParser: null, 66 | options: {}, 67 | responseHandler: null 68 | }; 69 | 70 | const cfg = { 71 | use(key, value) { 72 | fetchHolder[key] = value; 73 | 74 | return this; 75 | }, 76 | init(fetch, isServer = false, rootUrl) { 77 | /* eslint no-console: 0 */ 78 | console.warn("Deprecated method, use `use` method"); 79 | this.use("fetch", fetch); 80 | this.use("server", isServer); 81 | this.use("rootUrl", rootUrl); 82 | return this; 83 | }, 84 | actions: {}, 85 | reducers: {}, 86 | events: {} 87 | }; 88 | function fnConfigCallback(memo, value, key) { 89 | const opts = 90 | typeof value === "object" 91 | ? { ...defaultEndpointConfig, reducerName: key, ...value } 92 | : { ...defaultEndpointConfig, reducerName: key, url: value }; 93 | 94 | if (opts.broadcast !== void 0) { 95 | /* eslint no-console: 0 */ 96 | console.warn( 97 | "Deprecated `broadcast` option. you shoud use `events`" + 98 | "to catch redux-api events (see https://github.com/lexich/redux-api/blob/master/DOCS.md#Events)" 99 | ); 100 | } 101 | 102 | const { 103 | url, 104 | urlOptions, 105 | options, 106 | transformer, 107 | broadcast, 108 | crud, 109 | reducerName, 110 | prefetch, 111 | postfetch, 112 | validation, 113 | helpers 114 | } = opts; 115 | 116 | const prefix = (baseConfig && baseConfig.prefix) || ""; 117 | 118 | const ACTIONS = { 119 | actionFetch: `${PREFIX}@${prefix}${reducerName}`, 120 | actionSuccess: `${PREFIX}@${prefix}${reducerName}_success`, 121 | actionFail: `${PREFIX}@${prefix}${reducerName}_fail`, 122 | actionReset: `${PREFIX}@${prefix}${reducerName}_delete`, 123 | actionCache: `${PREFIX}@${prefix}${reducerName}_cache`, 124 | actionAbort: `${PREFIX}@${prefix}${reducerName}_abort` 125 | }; 126 | 127 | const fetch = opts.fetch 128 | ? opts.fetch 129 | : function(...args) { 130 | return fetchHolder.fetch.apply(this, args); 131 | }; 132 | 133 | const meta = { 134 | holder: fetchHolder, 135 | virtual: !!opts.virtual, 136 | actions: memo.actions, 137 | cache: cacheManager(opts.cache), 138 | urlOptions, 139 | fetch, 140 | broadcast, 141 | reducerName, 142 | prefetch, 143 | postfetch, 144 | validation, 145 | helpers, 146 | transformer, 147 | prefix, 148 | crud 149 | }; 150 | 151 | memo.actions[key] = actionFn(url, key, options, ACTIONS, meta); 152 | 153 | if (!meta.virtual && !memo.reducers[reducerName]) { 154 | const data = transformer(); 155 | const sync = false; 156 | const syncing = false; 157 | const loading = false; 158 | const initialState = opts.cache 159 | ? { sync, syncing, loading, data, cache: {}, request: null } 160 | : { sync, syncing, loading, data, request: null }; 161 | 162 | const reducer = opts.reducer ? opts.reducer.bind(memo) : null; 163 | memo.reducers[reducerName] = reducerFn(initialState, ACTIONS, reducer); 164 | } 165 | memo.events[reducerName] = ACTIONS; 166 | return memo; 167 | } 168 | 169 | return Object.keys(config).reduce( 170 | (memo, key) => fnConfigCallback(memo, config[key], key, config), 171 | cfg 172 | ); 173 | } 174 | 175 | reduxApi.transformers = transformers; 176 | reduxApi.async = async; 177 | -------------------------------------------------------------------------------- /src/reducerFn.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* eslint no-case-declarations: 0 */ 4 | import { setExpire } from "./utils/cache"; 5 | 6 | /** 7 | * Reducer contructor 8 | * @param {Object} initialState default initial state 9 | * @param {Object} actions actions map 10 | * @param {Function} reducer custom reducer function 11 | * @return {Function} reducer function 12 | */ 13 | export default function reducerFn(initialState, actions = {}, reducer) { 14 | const { 15 | actionFetch, 16 | actionSuccess, 17 | actionFail, 18 | actionReset, 19 | actionCache, 20 | actionAbort 21 | } = actions; 22 | return (state = initialState, action) => { 23 | const request = action.request || {}; 24 | switch (action.type) { 25 | case actionFetch: 26 | return { 27 | ...state, 28 | request, 29 | loading: true, 30 | error: null, 31 | syncing: !!action.syncing 32 | }; 33 | case actionSuccess: 34 | return { 35 | ...state, 36 | loading: false, 37 | sync: true, 38 | syncing: false, 39 | error: null, 40 | data: action.data 41 | }; 42 | case actionFail: 43 | return { 44 | ...state, 45 | loading: false, 46 | error: action.error, 47 | syncing: false 48 | }; 49 | case actionReset: 50 | const { mutation } = action; 51 | return mutation === "sync" 52 | ? { 53 | ...state, 54 | request: null, 55 | sync: false 56 | } 57 | : { ...initialState }; 58 | case actionAbort: 59 | return { 60 | ...state, 61 | request: null, 62 | loading: false, 63 | syncing: false, 64 | error: action.error 65 | }; 66 | case actionCache: 67 | const { id, data } = action; 68 | const cacheExpire = state.cache[id] ? state.cache[id].expire : null; 69 | const expire = setExpire(action.expire, cacheExpire); 70 | return { 71 | ...state, 72 | cache: { ...state.cache, [id]: { expire, data } } 73 | }; 74 | default: 75 | return reducer ? reducer(state, action) : state; 76 | } 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/transformers.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const toString = Object.prototype.toString; 4 | const OBJECT = "[object Object]"; 5 | 6 | /** 7 | * Default responce transformens 8 | */ 9 | export default { 10 | array(data) { 11 | return !data ? [] : Array.isArray(data) ? data : [data]; 12 | }, 13 | object(data) { 14 | if (!data) { 15 | return {}; 16 | } 17 | return toString.call(data) === OBJECT ? data : { data }; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/urlTransform.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import qs from "qs"; 4 | import { parse } from "url"; 5 | import omit from "./utils/omit"; 6 | import merge from "./utils/merge"; 7 | 8 | /* eslint no-useless-escape: 0 */ 9 | const rxClean = /(\(:[^\)]+\)|:[^\/]+\/?)/g; 10 | 11 | /** 12 | * Url modification 13 | * @param {String} url url template 14 | * @param {Object} params params for url template 15 | * @param {Object} options transformation options, accepts +delimiter+, +arrayFormat+, 16 | * +qsStringifyOptions+ and +qsParseOptions+ 17 | * @return {String} result url 18 | */ 19 | export default function urlTransform(url, params, options) { 20 | if (!url) { 21 | return ""; 22 | } 23 | params || (params = {}); 24 | const usedKeys = {}; 25 | const urlWithParams = Object.keys(params).reduce((url, key) => { 26 | const value = params[key]; 27 | const rx = new RegExp(`(\\(:${key}\\)|:${key})(\/?)`, "g"); 28 | return url.replace(rx, (_, _1, slash) => { 29 | usedKeys[key] = value; 30 | return value ? value + slash : value; 31 | }); 32 | }, url); 33 | 34 | if (!urlWithParams) { 35 | return urlWithParams; 36 | } 37 | const { protocol, host, path } = parse(urlWithParams); 38 | const cleanURL = host 39 | ? `${protocol}//${host}${path.replace(rxClean, "")}` 40 | : path.replace(rxClean, ""); 41 | const usedKeysArray = Object.keys(usedKeys); 42 | if (usedKeysArray.length !== Object.keys(params).length) { 43 | const urlObject = cleanURL.split("?"); 44 | options || (options = {}); 45 | const { arrayFormat, delimiter } = options; 46 | const qsParseOptions = { 47 | arrayFormat, 48 | delimiter, 49 | ...options.qsParseOptions 50 | }; 51 | const mergeParams = merge( 52 | urlObject[1] && qs.parse(urlObject[1], qsParseOptions), 53 | omit(params, usedKeysArray) 54 | ); 55 | const qsStringifyOptions = { 56 | arrayFormat, 57 | delimiter, 58 | ...options.qsStringifyOptions 59 | }; 60 | const urlStringParams = qs.stringify(mergeParams, qsStringifyOptions); 61 | return `${urlObject[0]}?${urlStringParams}`; 62 | } 63 | return cleanURL; 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/cache.js: -------------------------------------------------------------------------------- 1 | export const MockNowDate = { 2 | date: undefined, 3 | push(date) { 4 | this.date = date; 5 | }, 6 | pop() { 7 | if (this.date) { 8 | const d = this.date; 9 | this.date = undefined; 10 | return new Date(d); 11 | } else { 12 | return new Date(); 13 | } 14 | } 15 | }; 16 | 17 | export const Manager = { 18 | expire: false, 19 | getData(cache) { 20 | if (!cache) { 21 | return; 22 | } 23 | const { expire, data } = cache; 24 | if (expire === false || expire === undefined || expire === null) { 25 | return data; 26 | } 27 | if (expire instanceof Date) { 28 | if (expire.valueOf() > MockNowDate.pop().valueOf()) { 29 | return data; 30 | } 31 | } 32 | }, 33 | id(params) { 34 | if (!params) { 35 | return ""; 36 | } 37 | return Object.keys(params).reduce( 38 | (memo, key) => memo + `${key}=${params[key]};`, 39 | "" 40 | ); 41 | } 42 | }; 43 | 44 | export function setExpire(value, oldDate) { 45 | let expire = value; 46 | if (typeof expire === "number" || expire instanceof Number) { 47 | const d = MockNowDate.pop(); 48 | d.setSeconds(d.getSeconds() + expire); 49 | expire = d; 50 | } 51 | if (oldDate instanceof Date && expire instanceof Date) { 52 | if (expire.valueOf() < oldDate.valueOf()) { 53 | expire = oldDate; 54 | } 55 | } 56 | return expire; 57 | } 58 | 59 | export function getCacheManager(expire, cache) { 60 | if (expire !== undefined) { 61 | const ret = { ...Manager, ...cache }; 62 | if (ret.expire !== false) { 63 | ret.expire = setExpire(expire, ret.expire); 64 | } 65 | return ret; 66 | } else if (cache) { 67 | return { ...Manager, ...cache }; 68 | } else { 69 | return null; 70 | } 71 | } 72 | 73 | export default function(cache) { 74 | if (!cache) { 75 | return null; 76 | } 77 | if (cache === true) { 78 | return Manager; 79 | } else { 80 | return { ...Manager, ...cache }; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/utils/get.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* eslint no-void: 0 */ 4 | function isEmpty(name) { 5 | return name === "" || name === null || name === void 0; 6 | } 7 | 8 | function get(obj, ...path) { 9 | return path.reduce( 10 | (memo, name) => 11 | Array.isArray(name) 12 | ? get(memo, ...name) 13 | : isEmpty(name) 14 | ? memo 15 | : memo && memo[name], 16 | obj 17 | ); 18 | } 19 | 20 | export default get; 21 | -------------------------------------------------------------------------------- /src/utils/merge.js: -------------------------------------------------------------------------------- 1 | /* eslint no-void: 0 */ 2 | const toString = Object.prototype.toString; 3 | const OBJECT = "[object Object]"; 4 | const ARRAY = "[object Array]"; 5 | 6 | export function mergePair(a, b) { 7 | if (a === void 0) { 8 | return b; 9 | } 10 | if (b === void 0) { 11 | return a; 12 | } 13 | 14 | const aType = toString.call(a); 15 | const bType = toString.call(b); 16 | if (aType === ARRAY) { 17 | return a.concat(b); 18 | } 19 | if (bType === ARRAY) { 20 | return [a].concat(b); 21 | } 22 | if (aType !== OBJECT || bType !== OBJECT) { 23 | return b; 24 | } 25 | return Object.keys(b).reduce((memo, key) => { 26 | memo[key] = mergePair(a[key], b[key]); 27 | return memo; 28 | }, a); 29 | } 30 | 31 | export default function(...args) { 32 | return args.length ? args.reduce(mergePair) : null; 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/omit.js: -------------------------------------------------------------------------------- 1 | export default function(object, props) { 2 | if (!Array.isArray(props)) { 3 | return { ...object }; 4 | } 5 | 6 | return Object.keys(object || {}).reduce((memo, key) => { 7 | if (props.indexOf(key) === -1) { 8 | memo[key] = object[key]; 9 | } 10 | return memo; 11 | }, {}); 12 | } 13 | -------------------------------------------------------------------------------- /test/PubSub_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ 5 | import { expect } from "chai"; 6 | import PubSub from "../src/PubSub"; 7 | 8 | describe("PubSub", function() { 9 | it("constructor", function() { 10 | const pubsub = new PubSub(); 11 | expect(pubsub.container).to.be.instanceOf(Array); 12 | expect(pubsub.container).to.have.length(0); 13 | }); 14 | it("push", function() { 15 | const pubsub = new PubSub(); 16 | pubsub.push(); 17 | expect(pubsub.container).to.have.length(0); 18 | pubsub.push(function() {}); 19 | expect(pubsub.container).to.have.length(1); 20 | }); 21 | it("reject", function() { 22 | const expectArr = []; 23 | function ok1(err) { 24 | expectArr.push({ t: "ok1", err }); 25 | } 26 | function ok2(err) { 27 | expectArr.push({ t: "ok2", err }); 28 | } 29 | const pubsub = new PubSub(); 30 | pubsub.push(ok1); 31 | pubsub.push(ok2); 32 | expect(pubsub.container).to.have.length(2); 33 | pubsub.reject("err"); 34 | expect(pubsub.container).to.have.length(0); 35 | expect(expectArr).to.eql([ 36 | { t: "ok1", err: "err" }, 37 | { t: "ok2", err: "err" } 38 | ]); 39 | }); 40 | it("resolve", function() { 41 | const expectArr = []; 42 | function ok1(err, data) { 43 | expectArr.push({ t: "ok1", err, data }); 44 | } 45 | function ok2(err, data) { 46 | expectArr.push({ t: "ok2", err, data }); 47 | } 48 | const pubsub = new PubSub(); 49 | pubsub.push(ok1); 50 | pubsub.push(ok2); 51 | expect(pubsub.container).to.have.length(2); 52 | pubsub.resolve("ok"); 53 | expect(pubsub.container).to.have.length(0); 54 | expect(expectArr).to.eql([ 55 | { t: "ok1", err: null, data: "ok" }, 56 | { t: "ok2", err: null, data: "ok" } 57 | ]); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/actionFn_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}], no-void: 0 */ 5 | import { expect } from "chai"; 6 | import isFunction from "lodash/isFunction"; 7 | import actionFn from "../src/actionFn"; 8 | 9 | function fetchSuccess() { 10 | return new Promise(function(resolve) { 11 | resolve({ msg: "hello" }); 12 | }); 13 | } 14 | 15 | function getState() { 16 | return { 17 | test: { loading: false, syncing: false, sync: false, data: {} } 18 | }; 19 | } 20 | 21 | const ERROR = new Error("Error"); 22 | 23 | function fetchFail() { 24 | return new Promise(function(resolve, reject) { 25 | reject(ERROR); 26 | }); 27 | } 28 | 29 | function transformer(data) { 30 | return data; 31 | } 32 | 33 | const ACTIONS = { 34 | actionFetch: "actionFetch", 35 | actionSuccess: "actionSuccess", 36 | actionFail: "actionFail", 37 | actionReset: "actionReset", 38 | actionCache: "actionCache" 39 | }; 40 | 41 | describe("actionFn", function() { 42 | it("check null params", function() { 43 | const api = actionFn(); 44 | expect(isFunction(api)).to.be.true; 45 | }); 46 | 47 | it("check sync method", function() { 48 | let executeCounter = 0; 49 | const api = actionFn("/test", "test", null, ACTIONS, { 50 | transformer, 51 | fetch: () => { 52 | executeCounter += 1; 53 | return fetchSuccess(); 54 | } 55 | }); 56 | 57 | const async1 = new Promise(resolve => { 58 | const initialState = getState(); 59 | initialState.test.sync = true; 60 | 61 | api.sync(resolve)( 62 | function() {}, 63 | () => initialState 64 | ); 65 | expect(executeCounter).to.be.eql(0); 66 | }); 67 | 68 | const expectedEvent = [ 69 | { 70 | type: ACTIONS.actionFetch, 71 | syncing: true, 72 | request: { pathvars: undefined, params: {} } 73 | }, 74 | { 75 | type: ACTIONS.actionSuccess, 76 | data: { msg: "hello" }, 77 | origData: { msg: "hello" }, 78 | syncing: false, 79 | request: { pathvars: undefined, params: {} } 80 | } 81 | ]; 82 | const async2 = new Promise(resolve => { 83 | api.sync(resolve)(msg => { 84 | expect(expectedEvent).to.have.length.above(0); 85 | const exp = expectedEvent.shift(); 86 | expect(msg).to.eql(exp); 87 | }, getState); 88 | }).then(() => { 89 | expect(executeCounter).to.be.eql(1); 90 | expect(expectedEvent).to.have.length(0); 91 | }); 92 | 93 | return Promise.all([async1, async2]); 94 | }); 95 | 96 | it("check request method", function() { 97 | let urlFetch; 98 | let paramsFetch; 99 | const api = actionFn("/test/:id", "test", null, ACTIONS, { 100 | transformer, 101 | fetch: (url, params) => { 102 | urlFetch = url; 103 | paramsFetch = params; 104 | return fetchSuccess(); 105 | } 106 | }); 107 | const async = api.request({ id: 2 }, { hello: "world" }); 108 | expect(async).to.be.an.instanceof(Promise); 109 | return async.then(data => { 110 | expect(data).to.eql({ msg: "hello" }); 111 | expect(urlFetch).to.eql("/test/2"); 112 | expect(paramsFetch).to.eql({ hello: "world" }); 113 | }); 114 | }); 115 | 116 | it("check normal usage", function() { 117 | const api = actionFn("/test", "test", null, ACTIONS, { 118 | transformer, 119 | fetch: fetchSuccess 120 | }); 121 | expect(api.reset()).to.eql({ type: ACTIONS.actionReset }); 122 | const expectedEvent = [ 123 | { 124 | type: ACTIONS.actionFetch, 125 | syncing: false, 126 | request: { pathvars: undefined, params: {} } 127 | }, 128 | { 129 | type: ACTIONS.actionSuccess, 130 | data: { msg: "hello" }, 131 | origData: { msg: "hello" }, 132 | syncing: false, 133 | request: { pathvars: undefined, params: {} } 134 | } 135 | ]; 136 | return new Promise(resolve => { 137 | const action = api(resolve); 138 | expect(isFunction(action)).to.be.true; 139 | function dispatch(msg) { 140 | expect(expectedEvent).to.have.length.above(0); 141 | const exp = expectedEvent.shift(); 142 | expect(msg).to.eql(exp); 143 | } 144 | action(dispatch, getState); 145 | }).then(() => { 146 | expect(expectedEvent).to.have.length(0); 147 | }); 148 | }); 149 | 150 | it("check reset helper with mutation", function() { 151 | const api = actionFn("/test", "test", null, ACTIONS, { 152 | transformer, 153 | fetch: fetchSuccess 154 | }); 155 | expect(api.reset()).to.eql({ type: ACTIONS.actionReset }); 156 | expect(api.reset("sync")).to.eql({ 157 | type: ACTIONS.actionReset, 158 | mutation: "sync" 159 | }); 160 | expect(api.reset("other")).to.eql({ type: ACTIONS.actionReset }); 161 | }); 162 | 163 | it("check fail fetch", function() { 164 | const api = actionFn("/test", "test", null, ACTIONS, { 165 | transformer, 166 | fetch: fetchFail 167 | }); 168 | const expectedEvent = [ 169 | { 170 | type: ACTIONS.actionFetch, 171 | syncing: false, 172 | request: { pathvars: undefined, params: {} } 173 | }, 174 | { 175 | type: ACTIONS.actionFail, 176 | error: ERROR, 177 | syncing: false, 178 | request: { pathvars: undefined, params: {} } 179 | } 180 | ]; 181 | function dispatch(msg) { 182 | expect(expectedEvent).to.have.length.above(0); 183 | const exp = expectedEvent.shift(); 184 | expect(msg.type).to.eql(exp.type); 185 | expect(msg.syncing).to.eql(exp.syncing); 186 | expect(msg.request).to.eql(exp.request); 187 | expect(msg.error).to.eql(exp.error); 188 | } 189 | return new Promise(resolve => { 190 | api(resolve)(dispatch, getState); 191 | }).then( 192 | () => { 193 | expect(expectedEvent).to.have.length(0); 194 | }, 195 | err => expect(null).to.eql(err) 196 | ); 197 | }); 198 | 199 | it("check options param", function() { 200 | let callOptions = 0; 201 | let checkOptions = null; 202 | const api = actionFn( 203 | "/test/:id", 204 | "test", 205 | function(url, params, _getState) { 206 | expect(_getState).to.exist; 207 | expect(getState === _getState).to.be.true; 208 | callOptions += 1; 209 | return { ...params, test: 1 }; 210 | }, 211 | ACTIONS, 212 | { 213 | transformer, 214 | fetch(url, opts) { 215 | checkOptions = opts; 216 | return fetchSuccess(); 217 | } 218 | } 219 | ); 220 | function dispatch() {} 221 | return new Promise(resolve => { 222 | api("", { params: 1 }, resolve)(dispatch, getState); 223 | expect(callOptions).to.eql(1); 224 | expect(checkOptions).to.eql({ params: 1, test: 1 }); 225 | }); 226 | }); 227 | 228 | it("check server mode", function() { 229 | function getServerState() { 230 | return { 231 | test: { loading: false, syncing: false, sync: true, data: {} } 232 | }; 233 | } 234 | const api = actionFn("/test/:id", "test", null, ACTIONS, { 235 | transformer, 236 | fetch: fetchSuccess, 237 | holder: { 238 | server: true 239 | } 240 | }); 241 | 242 | const expectedEvent = [ 243 | { 244 | type: ACTIONS.actionFetch, 245 | syncing: true, 246 | request: { pathvars: undefined, params: {} } 247 | }, 248 | { 249 | type: ACTIONS.actionSuccess, 250 | syncing: false, 251 | data: { msg: "hello" }, 252 | origData: { msg: "hello" }, 253 | request: { pathvars: undefined, params: {} } 254 | } 255 | ]; 256 | return new Promise(resolve => { 257 | api.sync(resolve)(function(msg) { 258 | expect(expectedEvent).to.have.length.above(0); 259 | const exp = expectedEvent.shift(); 260 | expect(msg).to.eql(exp); 261 | }, getServerState); 262 | }).then(() => { 263 | expect(expectedEvent).to.have.length(0); 264 | }); 265 | }); 266 | 267 | it("check broadcast option", function() { 268 | const BROADCAST_ACTION = "BROADCAST_ACTION"; 269 | const expectedEvent = [ 270 | { 271 | type: ACTIONS.actionFetch, 272 | syncing: false, 273 | request: { pathvars: undefined, params: {} } 274 | }, 275 | { 276 | type: ACTIONS.actionSuccess, 277 | data: { msg: "hello" }, 278 | origData: { msg: "hello" }, 279 | syncing: false, 280 | request: { pathvars: undefined, params: {} } 281 | }, 282 | { 283 | type: BROADCAST_ACTION, 284 | data: { msg: "hello" }, 285 | origData: { msg: "hello" }, 286 | request: { pathvars: undefined, params: {} } 287 | } 288 | ]; 289 | const meta = { 290 | transformer, 291 | fetch: fetchSuccess, 292 | broadcast: [BROADCAST_ACTION] 293 | }; 294 | const api = actionFn("/test/:id", "test", null, ACTIONS, meta); 295 | 296 | return new Promise(resolve => { 297 | api(resolve)(function(msg) { 298 | expect(expectedEvent).to.have.length.above(0); 299 | const exp = expectedEvent.shift(); 300 | expect(msg).to.eql(exp); 301 | }, getState); 302 | }).then(() => { 303 | expect(expectedEvent).to.have.length(0); 304 | }); 305 | }); 306 | it("check validation with request method", function() { 307 | let expData; 308 | let counter = 0; 309 | const meta = { 310 | transformer, 311 | fetch: fetchSuccess, 312 | validation(data, cb) { 313 | counter += 1; 314 | expData = data; 315 | cb(); 316 | } 317 | }; 318 | const api = actionFn("/test/:id", "test", null, ACTIONS, meta); 319 | return api.request({ id: 1 }).then(data => { 320 | expect(data).to.eql({ msg: "hello" }); 321 | expect(counter).to.eql(1); 322 | expect(expData).to.eql({ msg: "hello" }); 323 | }); 324 | }); 325 | it("check success validation", function() { 326 | let expData; 327 | let counter = 0; 328 | const meta = { 329 | transformer, 330 | fetch: fetchSuccess, 331 | validation(data, cb) { 332 | counter += 1; 333 | expData = data; 334 | cb(); 335 | } 336 | }; 337 | const expectedEvent = [ 338 | { 339 | type: ACTIONS.actionFetch, 340 | syncing: false, 341 | request: { pathvars: undefined, params: {} } 342 | }, 343 | { 344 | type: ACTIONS.actionSuccess, 345 | data: { msg: "hello" }, 346 | origData: { msg: "hello" }, 347 | syncing: false, 348 | request: { pathvars: undefined, params: {} } 349 | } 350 | ]; 351 | 352 | const api = actionFn("/test/:id", "test", null, ACTIONS, meta); 353 | return new Promise(resolve => { 354 | api(resolve)(function(msg) { 355 | expect(expectedEvent).to.have.length.above(0); 356 | const exp = expectedEvent.shift(); 357 | expect(msg).to.eql(exp); 358 | }, getState); 359 | }).then(() => { 360 | expect(expectedEvent).to.have.length(0); 361 | expect(counter).to.eql(1); 362 | expect(expData).to.eql({ msg: "hello" }); 363 | }); 364 | }); 365 | it("check unsuccess validation", function() { 366 | let expData; 367 | let counter = 0; 368 | const meta = { 369 | transformer, 370 | fetch: fetchSuccess, 371 | validation(data, cb) { 372 | counter += 1; 373 | expData = data; 374 | cb("invalid"); 375 | } 376 | }; 377 | const expectedEvent = [ 378 | { 379 | type: ACTIONS.actionFetch, 380 | syncing: false, 381 | request: { pathvars: undefined, params: {} } 382 | }, 383 | { 384 | type: ACTIONS.actionFail, 385 | error: "invalid", 386 | syncing: false, 387 | request: { pathvars: undefined, params: {} } 388 | } 389 | ]; 390 | const api = actionFn("/test/:id", "test", null, ACTIONS, meta); 391 | return new Promise(resolve => { 392 | api(resolve)(function(msg) { 393 | expect(expectedEvent).to.have.length.above(0); 394 | const exp = expectedEvent.shift(); 395 | expect(msg.type).to.eql(exp.type); 396 | expect(msg.syncing).to.eql(exp.syncing); 397 | expect(msg.request).to.eql(exp.request); 398 | expect(msg.error).to.eql(exp.error); 399 | }, getState); 400 | }).then( 401 | () => { 402 | expect(expectedEvent).to.have.length(0); 403 | expect(counter).to.eql(1); 404 | expect(expData).to.eql({ msg: "hello" }); 405 | }, 406 | err => expect(null).to.eql(err) 407 | ); 408 | }); 409 | it("check postfetch option", function() { 410 | let expectedOpts; 411 | const meta = { 412 | transformer, 413 | fetch: fetchSuccess, 414 | postfetch: [ 415 | function(opts) { 416 | expectedOpts = opts; 417 | opts.dispatch({ type: "One", data: opts.data }); 418 | }, 419 | function(opts) { 420 | opts.dispatch({ type: "Two", data: opts.data }); 421 | } 422 | ], 423 | actions: { hello: "a" } 424 | }; 425 | const api = actionFn("/test/:id", "test", null, ACTIONS, meta); 426 | const expectedEvent = [ 427 | { 428 | type: ACTIONS.actionFetch, 429 | syncing: false, 430 | request: { pathvars: undefined, params: {} } 431 | }, 432 | { 433 | type: ACTIONS.actionSuccess, 434 | data: { msg: "hello" }, 435 | origData: { msg: "hello" }, 436 | syncing: false, 437 | request: { pathvars: undefined, params: {} } 438 | }, 439 | { 440 | type: "One", 441 | data: { msg: "hello" } 442 | }, 443 | { 444 | type: "Two", 445 | data: { msg: "hello" } 446 | } 447 | ]; 448 | function dispatch(msg) { 449 | expect(expectedEvent).to.have.length.above(0); 450 | const exp = expectedEvent.shift(); 451 | expect(msg).to.eql(exp); 452 | } 453 | return new Promise(resolve => { 454 | api(resolve)(dispatch, getState); 455 | }).then(() => { 456 | expect(expectedOpts).to.exist; 457 | expect(expectedOpts).to.include.keys( 458 | "data", 459 | "getState", 460 | "dispatch", 461 | "actions", 462 | "request" 463 | ); 464 | expect(expectedOpts.getState).to.eql(getState); 465 | expect(expectedOpts.dispatch).to.eql(dispatch); 466 | expect(expectedOpts.actions).to.eql({ hello: "a" }); 467 | expect(expectedOpts.request).to.eql({ params: {}, pathvars: void 0 }); 468 | }); 469 | }); 470 | it("check prefetch option", function() { 471 | const checkPrefetch = []; 472 | const meta = { 473 | transformer, 474 | fetch: fetchSuccess, 475 | prefetch: [ 476 | function(opts, cb) { 477 | checkPrefetch.push(["one", opts]); 478 | cb(); 479 | }, 480 | function(opts, cb) { 481 | checkPrefetch.push(["two", opts]); 482 | cb(); 483 | } 484 | ] 485 | }; 486 | const requestOptions = { pathvars: undefined, params: {} }; 487 | const api = actionFn("/test/:id", "test", null, ACTIONS, meta); 488 | const expectedEvent = [ 489 | { 490 | type: ACTIONS.actionFetch, 491 | syncing: false, 492 | request: requestOptions 493 | }, 494 | { 495 | type: ACTIONS.actionSuccess, 496 | data: { msg: "hello" }, 497 | origData: { msg: "hello" }, 498 | syncing: false, 499 | request: requestOptions 500 | } 501 | ]; 502 | function dispatch(msg) { 503 | expect(expectedEvent).to.have.length.above(0); 504 | const exp = expectedEvent.shift(); 505 | expect(msg).to.eql(exp); 506 | } 507 | const expOpts = { 508 | dispatch, 509 | getState, 510 | request: requestOptions, 511 | actions: undefined, 512 | prefetch: meta.prefetch 513 | }; 514 | return new Promise(resolve => { 515 | api(resolve)(dispatch, getState); 516 | }).then( 517 | () => { 518 | expect(expectedEvent).to.have.length(0); 519 | expect(checkPrefetch).to.eql([ 520 | ["one", expOpts], 521 | ["two", expOpts] 522 | ]); 523 | }, 524 | err => expect(null).to.eql(err) 525 | ); 526 | }); 527 | it("check incorrect helpers name", function() { 528 | expect(() => 529 | actionFn("/test/:id", "test", null, ACTIONS, { 530 | helpers: { 531 | reset() {} 532 | } 533 | }) 534 | ).to.throw( 535 | Error, 536 | 'Helper name: "reset" for endpoint "test" has been already reserved' 537 | ); 538 | expect(() => 539 | actionFn("/test/:id", "test", null, ACTIONS, { 540 | helpers: { 541 | sync() {} 542 | } 543 | }) 544 | ).to.throw( 545 | Error, 546 | 'Helper name: "sync" for endpoint "test" has been already reserved' 547 | ); 548 | }); 549 | it("check that helpers returns Promise", function() { 550 | const api = actionFn("/test/:id", "test", null, ACTIONS, { 551 | transformer, 552 | fetch: fetchSuccess, 553 | helpers: { 554 | test: () => cb => cb(null, [{ id: 1 }, { async: true }]) 555 | } 556 | }); 557 | const result = api.test()(() => {}, getState); 558 | expect(result).to.be.an.instanceof(Promise); 559 | }); 560 | it("check helpers with async functionality", function() { 561 | const meta = { 562 | transformer, 563 | fetch(url, opts) { 564 | return new Promise(resolve => resolve({ url, opts })); 565 | }, 566 | helpers: { 567 | asyncSuccess: () => cb => cb(null, [{ id: 1 }, { async: true }]), 568 | asyncFail: () => cb => cb("Error") 569 | } 570 | }; 571 | const api = actionFn("/test/:id", "test", null, ACTIONS, meta); 572 | const expectedEvent1 = [ 573 | { 574 | type: ACTIONS.actionFetch, 575 | syncing: false, 576 | request: { pathvars: { id: 1 }, params: { async: true } } 577 | }, 578 | { 579 | type: ACTIONS.actionSuccess, 580 | syncing: false, 581 | data: { url: "/test/1", opts: { async: true } }, 582 | origData: { url: "/test/1", opts: { async: true } }, 583 | request: { pathvars: { id: 1 }, params: { async: true } } 584 | } 585 | ]; 586 | const wait1 = new Promise(resolve => { 587 | api.asyncSuccess(resolve)(function(msg) { 588 | expect(expectedEvent1).to.have.length.above(0); 589 | const exp = expectedEvent1.shift(); 590 | expect(msg).to.eql(exp); 591 | }, getState); 592 | }); 593 | let errorMsg; 594 | const wait2 = new Promise(resolve => { 595 | api.asyncFail(function(err) { 596 | errorMsg = err; 597 | resolve(); 598 | })(function() {}, getState); 599 | }); 600 | return Promise.all([wait1, wait2]).then(() => { 601 | expect(expectedEvent1).to.have.length(0); 602 | expect(errorMsg).to.eql("Error"); 603 | }); 604 | }); 605 | 606 | it("check crud option", function() { 607 | const meta = { 608 | transformer, 609 | crud: true, 610 | fetch(url, opts) { 611 | return new Promise(resolve => resolve({ url, opts })); 612 | } 613 | }; 614 | const api = actionFn("/test/:id", "test", null, ACTIONS, meta); 615 | const expectedEvent = [ 616 | { 617 | type: ACTIONS.actionFetch, 618 | syncing: false, 619 | request: { 620 | pathvars: { id: 1 }, 621 | params: { method: "GET" } 622 | } 623 | }, 624 | { 625 | type: ACTIONS.actionFetch, 626 | syncing: false, 627 | request: { 628 | pathvars: { id: 2 }, 629 | params: { body: "Hello", method: "POST" } 630 | } 631 | }, 632 | { 633 | type: ACTIONS.actionFetch, 634 | syncing: false, 635 | request: { 636 | pathvars: { id: 3 }, 637 | params: { body: "World", method: "PUT" } 638 | } 639 | }, 640 | { 641 | type: ACTIONS.actionFetch, 642 | syncing: false, 643 | request: { 644 | pathvars: { id: 4 }, 645 | params: { method: "DELETE" } 646 | } 647 | }, 648 | { 649 | type: ACTIONS.actionFetch, 650 | syncing: false, 651 | request: { 652 | pathvars: { id: 5 }, 653 | params: { body: "World", method: "PATCH" } 654 | } 655 | }, 656 | { 657 | type: ACTIONS.actionSuccess, 658 | syncing: false, 659 | data: { url: "/test/1", opts: { method: "GET" } }, 660 | origData: { url: "/test/1", opts: { method: "GET" } }, 661 | request: { 662 | pathvars: { id: 1 }, 663 | params: { method: "GET" } 664 | } 665 | }, 666 | { 667 | type: ACTIONS.actionSuccess, 668 | syncing: false, 669 | data: { 670 | url: "/test/2", 671 | opts: { body: "Hello", method: "POST" } 672 | }, 673 | origData: { 674 | url: "/test/2", 675 | opts: { body: "Hello", method: "POST" } 676 | }, 677 | request: { 678 | pathvars: { id: 2 }, 679 | params: { body: "Hello", method: "POST" } 680 | } 681 | }, 682 | { 683 | type: ACTIONS.actionSuccess, 684 | syncing: false, 685 | data: { 686 | url: "/test/3", 687 | opts: { body: "World", method: "PUT" } 688 | }, 689 | origData: { 690 | url: "/test/3", 691 | opts: { body: "World", method: "PUT" } 692 | }, 693 | request: { 694 | pathvars: { id: 3 }, 695 | params: { body: "World", method: "PUT" } 696 | } 697 | }, 698 | { 699 | type: ACTIONS.actionSuccess, 700 | syncing: false, 701 | data: { 702 | url: "/test/4", 703 | opts: { method: "DELETE" } 704 | }, 705 | origData: { 706 | url: "/test/4", 707 | opts: { method: "DELETE" } 708 | }, 709 | request: { 710 | pathvars: { id: 4 }, 711 | params: { method: "DELETE" } 712 | } 713 | }, 714 | { 715 | type: ACTIONS.actionSuccess, 716 | syncing: false, 717 | data: { 718 | url: "/test/5", 719 | opts: { body: "World", method: "PATCH" } 720 | }, 721 | origData: { 722 | url: "/test/5", 723 | opts: { body: "World", method: "PATCH" } 724 | }, 725 | request: { 726 | pathvars: { id: 5 }, 727 | params: { body: "World", method: "PATCH" } 728 | } 729 | } 730 | ]; 731 | 732 | const getQuery = new Promise(resolve => { 733 | api.get({ id: 1 }, resolve)(function(msg) { 734 | expect(expectedEvent).to.have.length.above(0); 735 | const exp = expectedEvent.shift(); 736 | expect(msg).to.eql(exp); 737 | }, getState); 738 | }); 739 | 740 | const postQuery = new Promise(resolve => { 741 | api.post( 742 | { id: 2 }, 743 | { body: "Hello" }, 744 | resolve 745 | )(function(msg) { 746 | expect(expectedEvent).to.have.length.above(0); 747 | const exp = expectedEvent.shift(); 748 | expect(msg).to.eql(exp); 749 | }, getState); 750 | }); 751 | const putQuery = new Promise(resolve => { 752 | api.put( 753 | { id: 3 }, 754 | { body: "World" }, 755 | resolve 756 | )(function(msg) { 757 | expect(expectedEvent).to.have.length.above(0); 758 | const exp = expectedEvent.shift(); 759 | expect(msg).to.eql(exp); 760 | }, getState); 761 | }); 762 | const deleteQuery = new Promise(resolve => { 763 | api.delete({ id: 4 }, resolve)(function(msg) { 764 | expect(expectedEvent).to.have.length.above(0); 765 | const exp = expectedEvent.shift(); 766 | expect(msg).to.eql(exp); 767 | }, getState); 768 | }); 769 | const patchQuery = new Promise(resolve => { 770 | api.patch( 771 | { id: 5 }, 772 | { body: "World" }, 773 | resolve 774 | )(function(msg) { 775 | expect(expectedEvent).to.have.length.above(0); 776 | const exp = expectedEvent.shift(); 777 | expect(msg).to.eql(exp); 778 | }, getState); 779 | }); 780 | 781 | return Promise.all([ 782 | getQuery, 783 | postQuery, 784 | putQuery, 785 | deleteQuery, 786 | patchQuery 787 | ]).then(() => expect(expectedEvent).to.have.length(0)); 788 | }); 789 | 790 | it("check crud option with overwrite", function() { 791 | const meta = { 792 | transformer, 793 | crud: true, 794 | fetch(url, opts) { 795 | return new Promise(resolve => resolve({ url, opts })); 796 | }, 797 | helpers: { 798 | get() { 799 | return [{ id: "overwrite" }]; 800 | } 801 | } 802 | }; 803 | const api = actionFn("/test/:id", "test", null, ACTIONS, meta); 804 | const expectedEvent = [ 805 | { 806 | type: ACTIONS.actionFetch, 807 | syncing: false, 808 | request: { pathvars: { id: "overwrite" }, params: undefined } 809 | }, 810 | { 811 | type: ACTIONS.actionSuccess, 812 | syncing: false, 813 | data: { url: "/test/overwrite", opts: null }, 814 | origData: { url: "/test/overwrite", opts: null }, 815 | request: { pathvars: { id: "overwrite" }, params: undefined } 816 | } 817 | ]; 818 | 819 | return new Promise(resolve => { 820 | api.get({ id: 1 }, resolve)(function(msg) { 821 | expect(expectedEvent).to.have.length.above(0); 822 | const exp = expectedEvent.shift(); 823 | expect(msg).to.eql(exp); 824 | }, getState); 825 | }).then(() => expect(expectedEvent).to.have.length(0)); 826 | }); 827 | 828 | it("check crud option with overwrite 2", function() { 829 | const meta = { 830 | transformer, 831 | crud: true, 832 | fetch(url, opts) { 833 | return new Promise(resolve => resolve({ url, opts })); 834 | }, 835 | helpers: { 836 | get(param) { 837 | return [{ id: param.id }, null]; 838 | } 839 | } 840 | }; 841 | const api = actionFn("/test/", "test", null, ACTIONS, meta); 842 | const expectedEvent = [ 843 | { 844 | type: ACTIONS.actionFetch, 845 | syncing: false, 846 | request: { 847 | pathvars: { id: 1 }, 848 | params: null 849 | } 850 | }, 851 | { 852 | type: ACTIONS.actionSuccess, 853 | syncing: false, 854 | data: { url: "/test/?id=1", opts: null }, 855 | origData: { url: "/test/?id=1", opts: null }, 856 | request: { pathvars: { id: 1 }, params: null } 857 | } 858 | ]; 859 | 860 | return new Promise(resolve => { 861 | api.get({ id: 1 }, resolve)(function(msg) { 862 | expect(expectedEvent).to.have.length.above(0); 863 | const exp = expectedEvent.shift(); 864 | expect(msg).to.eql(exp); 865 | }, getState); 866 | }).then(() => expect(expectedEvent).to.have.length(0)); 867 | }); 868 | 869 | it("check merge params", function() { 870 | let params; 871 | const meta = { 872 | transformer, 873 | fetch: (urlparams, _params) => { 874 | params = _params; 875 | return fetchSuccess(); 876 | } 877 | }; 878 | const opts = { headers: { One: 1 } }; 879 | const api = actionFn("/test", "test", opts, ACTIONS, meta); 880 | return api.request(null, { headers: { Two: 2 } }).then(() => { 881 | expect(params).to.eql({ 882 | headers: { 883 | One: 1, 884 | Two: 2 885 | } 886 | }); 887 | }); 888 | }); 889 | 890 | it("check urlOptions", function() { 891 | let urlFetch; 892 | const api = actionFn("/test", "test", null, ACTIONS, { 893 | transformer, 894 | fetch: url => { 895 | urlFetch = url; 896 | return fetchSuccess(); 897 | }, 898 | urlOptions: { 899 | delimiter: ",", 900 | arrayFormat: "repeat" 901 | } 902 | }); 903 | const async = api.request({ id: [1, 2] }); 904 | expect(async).to.be.an.instanceof(Promise); 905 | return async.then(() => { 906 | expect(urlFetch).to.eql("/test?id=1,id=2"); 907 | }); 908 | }); 909 | 910 | it("check responseHandler success", function() { 911 | const resp = []; 912 | const api = actionFn("/test", "test", null, ACTIONS, { 913 | transformer, 914 | fetch() { 915 | return fetchSuccess(); 916 | }, 917 | holder: { 918 | responseHandler(err, data) { 919 | resp.push({ err, data }); 920 | } 921 | } 922 | }); 923 | return api.request().then(() => { 924 | expect(resp).to.eql([{ err: null, data: { msg: "hello" } }]); 925 | }); 926 | }); 927 | 928 | it("check responseHandler error", function() { 929 | const resp = []; 930 | const api = actionFn("/test", "test", null, ACTIONS, { 931 | transformer, 932 | fetch() { 933 | return fetchFail(); 934 | }, 935 | holder: { 936 | responseHandler(err, data) { 937 | resp.push({ err, data }); 938 | } 939 | } 940 | }); 941 | return api.request().then(null, () => { 942 | expect(resp).to.have.length(1); 943 | expect(resp[0].data).to.not.exist; 944 | expect(resp[0].err).to.be.an.instanceof(Error); 945 | }); 946 | }); 947 | 948 | it("chained callbacks all resolve", function() { 949 | const meta = { 950 | transformer, 951 | crud: true, 952 | fetch(url, opts) { 953 | return new Promise(resolve => resolve({ url, opts })); 954 | } 955 | }; 956 | const api = actionFn("/test/:id", "test", null, ACTIONS, meta); 957 | 958 | let callCount = 0; 959 | 960 | function spy(resolve) { 961 | callCount += 1; 962 | resolve(); 963 | } 964 | function none() {} 965 | 966 | function chainedAction(resolve) { 967 | api.get({ id: 1 }, () => spy(resolve))(none, getState); 968 | } 969 | 970 | return new Promise(resolve => 971 | api.get({ id: 1 }, () => chainedAction(resolve))(none, getState) 972 | ).then(() => expect(callCount).to.have.length.equal(1)); 973 | }); 974 | }); 975 | -------------------------------------------------------------------------------- /test/adapters_fetch_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ 5 | import { expect, assert } from "chai"; 6 | import fetch from "../src/adapters/fetch"; 7 | 8 | describe("fetch adapters", function() { 9 | it("check", function() { 10 | let jsonCall = 0; 11 | const fetchApi = (url, opts) => 12 | new Promise(resolve => { 13 | expect(url).to.eql("url"); 14 | expect(opts).to.eql("opts"); 15 | resolve({ 16 | status: 200, 17 | text() { 18 | jsonCall += 1; 19 | return Promise.resolve("{}"); 20 | } 21 | }); 22 | }); 23 | return fetch(fetchApi)("url", "opts").then(() => { 24 | expect(jsonCall).to.eql(1); 25 | }); 26 | }); 27 | it("should return the error response as content", function() { 28 | const fetchApi = (url, opts) => 29 | new Promise(resolve => { 30 | expect(url).to.eql("url"); 31 | expect(opts).to.eql("opts"); 32 | resolve({ 33 | status: 404, 34 | statusText: "Not Found" 35 | }); 36 | }); 37 | return fetch(fetchApi)("url", "opts").catch(error => { 38 | expect(error).to.eql({ status: 404, statusText: "Not Found" }); 39 | }); 40 | }); 41 | // Sometimes IE9 translates HTTP 204 (No Content) 42 | // into its own internal Status Code 1223. 43 | // redux-api rightly treats this an error unless 44 | // the status is coerced into 204 No Content 45 | it("should normalise IE9 1223 response into 204 No Content ", () => { 46 | const fetchApi = (url, opts) => 47 | new Promise(resolve => { 48 | expect(url).to.eql("url"); 49 | expect(opts).to.eql("opts"); 50 | resolve({ 51 | status: 1223, 52 | text() { 53 | return Promise.resolve(); 54 | } 55 | }); 56 | }); 57 | 58 | return fetch(fetchApi)("url", "opts") 59 | .then(() => { 60 | assert.isOk(true, "response status 1223 normalised"); 61 | }) 62 | .catch(() => { 63 | assert.isNotOk("response status 1223 not normalized"); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/cache_spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}], no-void: 0 */ 3 | 4 | import { expect } from "chai"; 5 | import cache, { 6 | Manager, 7 | setExpire, 8 | getCacheManager, 9 | MockNowDate 10 | } from "../src/utils/cache"; 11 | 12 | describe("cache-manager", () => { 13 | it("check empty call", () => { 14 | expect(cache()).to.not.exist; 15 | expect(cache(null)).to.not.exist; 16 | expect(cache(false)).to.not.exist; 17 | }); 18 | 19 | it("check cache as true", () => { 20 | const manager = cache(true); 21 | expect(manager === Manager).to.be.true; 22 | expect(manager.id).to.exist; 23 | expect(manager.id({ a: 1, b: 2 })).to.eql("a=1;b=2;"); 24 | }); 25 | 26 | it("check cache rewrite id", () => { 27 | const manager = cache({ 28 | id(params, opts) { 29 | return Manager.id(params) + opts; 30 | } 31 | }); 32 | expect(manager.id({ a: 1, b: 2 }, "extra")).to.eql("a=1;b=2;extra"); 33 | }); 34 | 35 | it("check setExpire", () => { 36 | const date = new Date(); 37 | const SECOND = 1000; 38 | MockNowDate.push(date); 39 | const res1 = +setExpire(1, date) - +date; 40 | expect(res1).to.eql(1 * SECOND); 41 | 42 | MockNowDate.push(date); 43 | const res2 = setExpire(false, date); 44 | expect(res2).to.eql(false); 45 | 46 | const date1000 = new Date(date); 47 | date1000.setSeconds(1000 + date1000.getSeconds()); 48 | const res3 = setExpire(date1000, date, date); 49 | expect(res3).to.eql(date1000); 50 | 51 | MockNowDate.push(date); 52 | const res4 = setExpire(1); 53 | expect(res4).to.be.instanceof(Date); 54 | expect(+res4 - +date).to.eql(1 * SECOND); 55 | }); 56 | 57 | it("check getCacheManager null check", () => { 58 | const ret1 = getCacheManager(); 59 | expect(ret1).to.be.null; 60 | }); 61 | 62 | it("check getCacheManager only expire without cache", () => { 63 | const ret2 = getCacheManager(1); 64 | expect(ret2.expire).to.be.false; // can't rewrite false expire 65 | expect(ret2.getData).to.be.instanceof(Function); 66 | expect(ret2.id).to.be.instanceof(Function); 67 | }); 68 | 69 | it("check getCacheManager full check", () => { 70 | const date = new Date(); 71 | const ret3 = getCacheManager(1, { expire: date }); 72 | 73 | expect(ret3.expire).to.be.instanceof(Date); 74 | expect(ret3.getData).to.be.instanceof(Function); 75 | expect(ret3.id).to.be.instanceof(Function); 76 | }); 77 | 78 | it("check getCacheManager check only cache", () => { 79 | const date = new Date(); 80 | const cache = { expire: date }; 81 | const ret3 = getCacheManager(undefined, cache); 82 | 83 | expect(ret3.expire).to.be.eql(cache.expire); 84 | expect(ret3.getData).to.be.instanceof(Function); 85 | expect(ret3.id).to.be.instanceof(Function); 86 | }); 87 | 88 | it("check Manager.getData empty args", () => { 89 | expect(Manager.getData()).to.not.exist; 90 | }); 91 | 92 | it("check Manager.getData with only data cache", () => { 93 | expect(Manager.getData({ data: "Test" })).to.eql("Test"); 94 | expect(Manager.getData({ data: "Test", expire: false })).to.eql("Test"); 95 | expect(Manager.getData({ data: "Test", expire: null })).to.eql("Test"); 96 | }); 97 | 98 | it("check Manager.getData with only data cache", () => { 99 | const now = new Date(); 100 | const before = new Date(now); 101 | before.setSeconds(before.getSeconds() - 1); 102 | const after = new Date(now); 103 | after.setSeconds(after.getSeconds() + 1); 104 | 105 | MockNowDate.push(now); 106 | expect(Manager.getData({ data: "Test", expire: after })).to.eql("Test"); 107 | 108 | MockNowDate.push(now); 109 | expect(Manager.getData({ data: "Test", expire: before })).to.not.exist; 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /test/createHolder_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it, beforeEach */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ 5 | import { expect } from "chai"; 6 | import createHolder from "../src/createHolder"; 7 | 8 | describe("Holder", function() { 9 | beforeEach(function() { 10 | this.holder = createHolder(); 11 | }); 12 | it("default state", function() { 13 | expect(this.holder.empty()).to.be.true; 14 | expect(this.holder.pop()).to.be.undefined; 15 | }); 16 | it("normal usage", function() { 17 | const ptr = {}; 18 | expect(this.holder.empty()).to.be.true; 19 | expect(this.holder.set(ptr)).to.be.true; 20 | expect(this.holder.empty()).to.be.false; 21 | 22 | expect(this.holder.set(1)).to.be.false; 23 | expect(this.holder.set(null)).to.be.false; 24 | expect(this.holder.pop() === ptr).to.be.true; 25 | expect(this.holder.empty()).to.be.true; 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/fetchResolver_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ 5 | import { expect } from "chai"; 6 | import isFunction from "lodash/isFunction"; 7 | import fetchResolver from "../src/fetchResolver"; 8 | 9 | describe("fetchResolver", function() { 10 | it("check import", function() { 11 | expect(isFunction(fetchResolver)).to.be.true; 12 | }); 13 | it("check null params", function() { 14 | expect(fetchResolver()).to.be.undefined; 15 | }); 16 | it("check with incorrect index§", function() { 17 | expect(fetchResolver(999)).to.be.undefined; 18 | }); 19 | it("call without callback", function() { 20 | expect( 21 | fetchResolver(0, { 22 | prefetch: [(opts, cb) => cb()] 23 | }) 24 | ).to.be.undefined; 25 | }); 26 | it("check normal usage", function() { 27 | const result = []; 28 | const opts = { 29 | prefetch: [ 30 | function(opts, cb) { 31 | result.push(["one", opts]); 32 | cb(); 33 | }, 34 | function(opts, cb) { 35 | result.push(["two", opts]); 36 | cb(); 37 | } 38 | ] 39 | }; 40 | fetchResolver(0, opts, () => result.push("ok")); 41 | expect(result).to.eql([["one", opts], ["two", opts], "ok"]); 42 | }); 43 | it("check usage without prefetch options", function() { 44 | let counter = 0; 45 | fetchResolver(0, {}, () => { 46 | counter += 1; 47 | }); 48 | expect(counter).to.eql(1); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/get_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}], no-void: 0 */ 5 | import { expect } from "chai"; 6 | import get from "../src/utils/get"; 7 | 8 | describe("get", function() { 9 | it("check `get` full path", function() { 10 | const obj = { 11 | a: { b: { c: 2 } } 12 | }; 13 | const c = get(obj, "a", "b", "c"); 14 | expect(c).to.eql(2); 15 | }); 16 | 17 | it("check `get` with empty path", function() { 18 | const obj = { 19 | a: { b: { c: { 0: 2 } } } 20 | }; 21 | const c = get(obj, "", "a", null, "b", void 0, "c", 0); 22 | expect(c).to.eql(2); 23 | }); 24 | 25 | it("check `get` incorrect path", function() { 26 | const obj = { 27 | a: { b: { c: 2 } } 28 | }; 29 | const c = get(obj, "c", "b", "a"); 30 | expect(c).to.not.exist; 31 | }); 32 | 33 | it("check `get` array path", function() { 34 | const obj = { 35 | a: { b: { c: 2 } } 36 | }; 37 | const c = get(obj, ["a", "b"], "c"); 38 | expect(c).to.eql(2); 39 | }); 40 | 41 | it("check `get` array incorrect path", function() { 42 | const obj = { 43 | a: { b: { c: 2 } } 44 | }; 45 | const c = get(obj, ["c", "b"], "a"); 46 | expect(c).to.not.exist; 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/index_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it, xit */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ 5 | import { expect } from "chai"; 6 | import isFunction from "lodash/isFunction"; 7 | import size from "lodash/size"; 8 | import reduxApi from "../src/index"; 9 | import transformers from "../src/transformers"; 10 | 11 | function getState() { 12 | return { test: { loading: false, data: {} } }; 13 | } 14 | 15 | describe("index", function() { 16 | it("check transformers", function() { 17 | expect(transformers.array()).to.eql([]); 18 | expect(transformers.array({ id: 1 })).to.eql([{ id: 1 }]); 19 | expect(transformers.array([1])).to.eql([1]); 20 | 21 | expect(transformers.object()).to.eql({}); 22 | expect(transformers.object({ id: 1 })).to.eql({ id: 1 }); 23 | expect(transformers.object([1])).to.eql({ data: [1] }); 24 | expect(transformers.object("test")).to.eql({ data: "test" }); 25 | expect(transformers.object(1)).to.eql({ data: 1 }); 26 | expect(transformers.object(true)).to.eql({ data: true }); 27 | }); 28 | it("check null params", function() { 29 | expect(isFunction(reduxApi)).to.be.true; 30 | const api = reduxApi(); 31 | expect(api.actions).to.eql({}); 32 | expect(api.reducers).to.eql({}); 33 | }); 34 | it("check rootUrl", function() { 35 | const urls = []; 36 | function fetchUrl(url) { 37 | urls.push(url); 38 | return new Promise(resolve => resolve({ msg: "hello" })); 39 | } 40 | const res = reduxApi({ 41 | test1: "/url1/", 42 | test2: "url2", 43 | test3: "", 44 | test4: "/(:id)" 45 | }) 46 | .use("fetch", fetchUrl) 47 | .use("server", false) 48 | .use("rootUrl", "http://api.com/root"); 49 | 50 | const res2 = reduxApi({ 51 | test1: "/url1/", 52 | test2: "url2", 53 | test3: "", 54 | test4: "/(:id)" 55 | }) 56 | .use("fetch", fetchUrl) 57 | .use("server", false) 58 | .use("rootUrl", "http://api.ru/"); 59 | 60 | const res3 = reduxApi({ 61 | test1: "/url1/" 62 | }) 63 | .use("fetch", fetchUrl) 64 | .use("server", false) 65 | .use("rootUrl", (url, params /* , getState */) => { 66 | expect(url).to.eql("/url1/"); 67 | expect(params).to.eql({ a: "b" }); 68 | return "http://api.net/"; 69 | }); 70 | 71 | const act = res.actions; 72 | const act2 = res2.actions; 73 | const act3 = res3.actions; 74 | return Promise.all([ 75 | act.test1.request(), 76 | act.test2.request(), 77 | act.test3.request(), 78 | act.test4.request({ id: 1 }), 79 | act2.test1.request(), 80 | act2.test2.request(), 81 | act2.test3.request(), 82 | act2.test4.request({ id: 2 }), 83 | act3.test1.request({}, { a: "b" }) 84 | ]).then(() => { 85 | expect([ 86 | "http://api.com/root/url1/", 87 | "http://api.com/root/url2", 88 | "http://api.com/root/", 89 | "http://api.com/root/1", 90 | "http://api.ru/url1/", 91 | "http://api.ru/url2", 92 | "http://api.ru/", 93 | "http://api.ru/2", 94 | "http://api.net/url1/" 95 | ]).to.eql(urls); 96 | }); 97 | }); 98 | it("check string url", function() { 99 | function fetchSuccess(url, data) { 100 | expect(url).to.eql("/plain/url"); 101 | expect(data).to.eql({}); 102 | return new Promise(function(resolve) { 103 | resolve({ msg: "hello" }); 104 | }); 105 | } 106 | const res = reduxApi({ 107 | test: "/plain/url" 108 | }).use("fetch", fetchSuccess); 109 | expect(size(res.actions)).to.eql(1); 110 | expect(size(res.events)).to.eql(1); 111 | 112 | expect(size(res.reducers)).to.eql(1); 113 | expect(res.actions.test).to.exist; 114 | expect(res.events.test).to.have.keys( 115 | "actionFetch", 116 | "actionSuccess", 117 | "actionFail", 118 | "actionReset", 119 | "actionCache", 120 | "actionAbort" 121 | ); 122 | expect(res.reducers.test).to.exist; 123 | const expectedEvent = [ 124 | { 125 | type: "@@redux-api@test", 126 | syncing: false, 127 | request: { pathvars: undefined, params: {} } 128 | }, 129 | { 130 | type: "@@redux-api@test_success", 131 | data: { msg: "hello" }, 132 | origData: { msg: "hello" }, 133 | syncing: false, 134 | request: { pathvars: undefined, params: {} } 135 | } 136 | ]; 137 | return new Promise(resolve => { 138 | const action = res.actions.test(resolve); 139 | function dispatch(msg) { 140 | expect(expectedEvent).to.have.length.above(0); 141 | const exp = expectedEvent.shift(); 142 | expect(msg).to.eql(exp); 143 | } 144 | action(dispatch, getState); 145 | }).then(() => { 146 | expect(expectedEvent).to.have.length(0); 147 | }); 148 | }); 149 | it("check object url", function() { 150 | function fetchSuccess(url, options) { 151 | expect(url).to.eql("/plain/url/1"); 152 | expect(options).to.eql({ 153 | headers: { 154 | Accept: "application/json" 155 | } 156 | }); 157 | return new Promise(function(resolve) { 158 | resolve({ msg: "hello" }); 159 | }); 160 | } 161 | const res = reduxApi({ 162 | test: { 163 | url: "/plain/url/:id", 164 | options: { 165 | headers: { 166 | Accept: "application/json" 167 | } 168 | } 169 | } 170 | }).use("fetch", fetchSuccess); 171 | expect(res.actions.test).to.exist; 172 | expect(res.reducers.test).to.exist; 173 | 174 | const expectedEvent = [ 175 | { 176 | type: "@@redux-api@test", 177 | syncing: false, 178 | request: { pathvars: { id: 1 }, params: {} } 179 | }, 180 | { 181 | type: "@@redux-api@test_success", 182 | data: { msg: "hello" }, 183 | origData: { msg: "hello" }, 184 | syncing: false, 185 | request: { pathvars: { id: 1 }, params: {} } 186 | } 187 | ]; 188 | return new Promise(resolve => { 189 | const action = res.actions.test({ id: 1 }, resolve); 190 | function dispatch(msg) { 191 | expect(expectedEvent).to.have.length.above(0); 192 | const exp = expectedEvent.shift(); 193 | expect(msg).to.eql(exp); 194 | } 195 | action(dispatch, getState); 196 | }).then(() => { 197 | expect(expectedEvent).to.have.length(0); 198 | }); 199 | }); 200 | it("use provided reducerName when avaliable", function() { 201 | const res = reduxApi({ 202 | test: { 203 | reducerName: "foo", 204 | url: "/plain/url/:id", 205 | options: { 206 | headers: { 207 | Accept: "application/json" 208 | } 209 | } 210 | } 211 | }).use("fetch", function fetchSuccess() {}); 212 | expect(res.actions.test).to.exist; 213 | expect(res.reducers.test).to.not.exist; 214 | expect(res.reducers.foo).to.exist; 215 | }); 216 | 217 | xit("check virtual option with broadcast", function() { 218 | const BROADCAST_ACTION = "BROADCAST_ACTION"; 219 | const res = reduxApi({ 220 | test: { 221 | url: "/api", 222 | broadcast: [BROADCAST_ACTION], 223 | virtual: true 224 | } 225 | }).use("fetch", function fetchSuccess() {}); 226 | expect(res.actions.test).to.exist; 227 | expect(res.reducers.test).to.not.exist; 228 | }); 229 | 230 | it("check prefetch options", function() { 231 | const expectUrls = []; 232 | function fetchSuccess(url) { 233 | expectUrls.push(url); 234 | return new Promise(resolve => resolve({ url })); 235 | } 236 | const res = reduxApi({ 237 | test: "/test", 238 | test1: { 239 | url: "/test1", 240 | prefetch: [ 241 | function(opts, cb) { 242 | opts.actions.test(cb)(opts.dispatch, opts.getState); 243 | } 244 | ] 245 | } 246 | }).use("fetch", fetchSuccess); 247 | return new Promise(resolve => { 248 | const action = res.actions.test1(resolve); 249 | action(function() {}, getState); 250 | }).then(() => { 251 | expect(expectUrls).to.eql(["/test", "/test1"]); 252 | }); 253 | }); 254 | 255 | it("check helpers", function() { 256 | const result = []; 257 | function getState() { 258 | return { 259 | params: { id: 9, name: "kitty" }, 260 | hello: { loading: false, data: {} } 261 | }; 262 | } 263 | function dispatch() {} 264 | const res = reduxApi({ 265 | hello: { 266 | url: "/test/:name/:id", 267 | helpers: { 268 | test1(id, name) { 269 | return [{ id, name }]; 270 | }, 271 | test2() { 272 | const { id, name } = this.getState().params; 273 | return [{ id, name }]; 274 | }, 275 | testSync: { 276 | sync: true, 277 | call(id) { 278 | return [{ id, name: "admin" }, { method: "post" }]; 279 | } 280 | } 281 | } 282 | } 283 | }).use("fetch", function(url, opts) { 284 | result.push({ url, opts }); 285 | return new Promise(resolve => resolve({ hello: "world" })); 286 | }); 287 | const a1 = new Promise(resolve => { 288 | res.actions.hello.test1(2, "lexich", resolve)(dispatch, getState); 289 | }); 290 | const a2 = new Promise(resolve => { 291 | res.actions.hello.test2(resolve)(dispatch, getState); 292 | }); 293 | const a3 = new Promise(resolve => { 294 | const mockSync = res.actions.hello.sync; 295 | let counter = 0; 296 | res.actions.hello.sync = function(...args) { 297 | counter += 1; 298 | return mockSync.apply(this, args); 299 | }; 300 | res.actions.hello.testSync(1, resolve)(dispatch, getState); 301 | expect(counter).to.eql(1); 302 | }); 303 | return Promise.all([a1, a2, a3]).then(() => { 304 | expect(result).to.eql([ 305 | { url: "/test/lexich/2", opts: {} }, 306 | { url: "/test/kitty/9", opts: {} }, 307 | { url: "/test/admin/1", opts: { method: "post" } } 308 | ]); 309 | }); 310 | }); 311 | it("check global options", () => { 312 | let expOpts; 313 | const rest = reduxApi({ 314 | test: { 315 | options: { 316 | headers: { 317 | "X-Header": 1 318 | } 319 | }, 320 | url: "/api/test" 321 | } 322 | }) 323 | .use("options", { 324 | headers: { 325 | Accept: "application/json" 326 | } 327 | }) 328 | .use("fetch", (url, options) => { 329 | expOpts = options; 330 | }); 331 | rest.actions.test.request(); 332 | expect(expOpts).to.eql({ 333 | headers: { 334 | Accept: "application/json", 335 | "X-Header": 1 336 | } 337 | }); 338 | }); 339 | it("check global options as function", () => { 340 | let expOpts; 341 | const rest = reduxApi({ 342 | test: { 343 | options: { 344 | headers: { 345 | "X-Header": 1 346 | } 347 | }, 348 | url: "/api/test/(:id)" 349 | } 350 | }) 351 | .use("options", (url, params /* , getState */) => { 352 | expect(url).to.eql("/api/test/1"); 353 | expect(params).to.eql({ a: "b" }); 354 | return { 355 | headers: { 356 | Accept: "application/json" 357 | } 358 | }; 359 | }) 360 | .use("fetch", (url, options) => { 361 | expOpts = options; 362 | }); 363 | rest.actions.test.request({ id: 1 }, { a: "b" }); 364 | expect(expOpts).to.eql({ 365 | a: "b", 366 | headers: { 367 | Accept: "application/json", 368 | "X-Header": 1 369 | } 370 | }); 371 | }); 372 | 373 | it("check crud option", () => { 374 | const rest = reduxApi({ 375 | test: { url: "/test", crud: true } 376 | }); 377 | expect(rest.actions.test).to.include.keys( 378 | "get", 379 | "post", 380 | "delete", 381 | "put", 382 | "patch" 383 | ); 384 | expect(rest.actions.test).to.include.keys("request", "reset", "sync"); 385 | }); 386 | 387 | it("check responseHandler option", () => { 388 | const error1 = new Error("bar"); 389 | const error2 = new Error("baz"); 390 | function fetchSuccess() { 391 | return new Promise(resolve => resolve({ msg: "hello" })); 392 | } 393 | function fetchError() { 394 | return new Promise((resolve, reject) => reject(error2)); 395 | } 396 | 397 | let calledSucess = false; 398 | const resSuccess = reduxApi({ 399 | hello: "/test/" 400 | }) 401 | .use("fetch", fetchSuccess) 402 | .use("responseHandler", (err, data) => { 403 | expect(err).to.equal(null); 404 | expect(data).to.deep.equal({ msg: "hello" }); 405 | 406 | calledSucess = true; 407 | 408 | return { modified: true }; 409 | }); 410 | 411 | let calledError = false; 412 | const resError = reduxApi({ 413 | hello: "/test/" 414 | }) 415 | .use("fetch", fetchError) 416 | .use("responseHandler", (err, data) => { 417 | expect(err).to.equal(error2); 418 | expect(data).to.equal(undefined); 419 | 420 | calledError = true; 421 | 422 | throw error1; 423 | }); 424 | 425 | let calledWithoutReturn = false; 426 | const resWithoutReturn = reduxApi({ 427 | hello: "/test/" 428 | }) 429 | .use("fetch", fetchSuccess) 430 | .use("responseHandler", (/* err, data */) => { 431 | calledWithoutReturn = true; 432 | }); 433 | 434 | return Promise.all([ 435 | resSuccess.actions.hello.request().then(res => { 436 | expect(calledSucess).to.true; 437 | expect(res).to.deep.equal({ modified: true }); 438 | }), 439 | 440 | resError.actions.hello 441 | .request() 442 | .catch(err => { 443 | return { inCatch: true, err }; 444 | }) 445 | .then(res => { 446 | expect(calledError).to.true; 447 | expect(res.inCatch).to.true; 448 | expect(res.err).to.equal(error1); 449 | }), 450 | 451 | resWithoutReturn.actions.hello.request().then(res => { 452 | expect(calledWithoutReturn).to.true; 453 | expect(res).to.deep.equal({ msg: "hello" }); 454 | }) 455 | ]); 456 | }); 457 | }); 458 | -------------------------------------------------------------------------------- /test/merge_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}], no-void: 0 */ 5 | import { expect } from "chai"; 6 | import merge from "../src/utils/merge"; 7 | 8 | describe("merge", function() { 9 | it("check null args", function() { 10 | expect(merge()).to.be.null; 11 | expect(merge(void 0)).to.be.undefined; 12 | expect(merge(null)).to.be.null; 13 | expect(merge(null, null)).to.be.null; 14 | expect(merge(null, null, null)).to.be.null; 15 | }); 16 | 17 | it("check number", function() { 18 | expect(merge(1)).to.eql(1); 19 | expect(merge(0)).to.eql(0); 20 | expect(merge(1, 0)).to.eql(0); 21 | expect(merge(1, 2)).to.eql(2); 22 | expect(merge(1, 2, 3)).to.eql(3); 23 | }); 24 | 25 | it("check string", function() { 26 | expect(merge("Hello")).to.eql("Hello"); 27 | expect(merge("Hello", "World")).to.eql("World"); 28 | expect(merge("Hello", "World", "Kitty")).to.eql("Kitty"); 29 | }); 30 | 31 | it("check boolean", function() { 32 | expect(merge(true)).to.eql(true); 33 | expect(merge(true, false)).to.eql(false); 34 | }); 35 | 36 | it("merge plain object", function() { 37 | expect(merge({ a: 1 }, { b: 2 })).to.eql({ a: 1, b: 2 }); 38 | expect(merge({ a: 1 }, { b: 2 }, { c: 3 })).to.eql({ a: 1, b: 2, c: 3 }); 39 | expect(merge({ a: { c: 2 } }, { b: 2 })).to.eql({ a: { c: 2 }, b: 2 }); 40 | }); 41 | 42 | it("deep merge object", function() { 43 | expect(merge({ a: { b: 1 } }, { a: { c: 2 } })).to.eql({ 44 | a: { b: 1, c: 2 } 45 | }); 46 | }); 47 | 48 | it("merge null with object", function() { 49 | expect(merge(void 0, { a: 1 })).to.eql({ a: 1 }); 50 | 51 | expect(merge({ a: 1 }, void 0)).to.eql({ a: 1 }); 52 | }); 53 | 54 | it("merge array with item", function() { 55 | expect(merge({ id: [1, 2] }, { id: 3 })).to.eql({ id: [1, 2, 3] }); 56 | 57 | expect(merge({ id: 3 }, { id: [1, 2] })).to.eql({ id: [3, 1, 2] }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/omit_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ 5 | import { expect } from "chai"; 6 | import omit from "../src/utils/omit"; 7 | 8 | describe("omit", function() { 9 | it("check without params", function() { 10 | const object = { a: 1, b: 2, c: 3 }; 11 | const result = omit(object); 12 | expect(result).to.eql(object); 13 | expect(result !== object).to.be.true; 14 | }); 15 | it("check without params", function() { 16 | const object = { a: 1, b: 2, c: 3 }; 17 | const result = omit(object, []); 18 | expect(result).to.eql(object); 19 | expect(result !== object).to.be.true; 20 | }); 21 | it("check omit", function() { 22 | const object = { a: 1, b: 2, c: 3 }; 23 | const result = omit(object, ["a", "b"]); 24 | expect(result).to.eql({ c: 3 }); 25 | }); 26 | it("check omit", function() { 27 | const object = { a: 1, b: 2, c: 3 }; 28 | const result = omit(object, ["a", "b", "d"]); 29 | expect(result).to.eql({ c: 3 }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/reducerFn_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ 5 | import { expect } from "chai"; 6 | import isFunction from "lodash/isFunction"; 7 | import reducerFn from "../src/reducerFn"; 8 | 9 | describe("reducerFn", function() { 10 | it("check null params", function() { 11 | expect(isFunction(reducerFn)).to.be.true; 12 | const fn = reducerFn(); 13 | expect(isFunction(fn)).to.be.true; 14 | }); 15 | it("check", function() { 16 | const initialState = { loading: false, data: { msg: "Hello" } }; 17 | const actions = { 18 | actionFetch: "actionFetch", 19 | actionSuccess: "actionSuccess", 20 | actionFail: "actionFail", 21 | actionReset: "actionReset" 22 | }; 23 | const fn = reducerFn(initialState, actions); 24 | const res1 = fn(initialState, { type: actions.actionFetch }); 25 | expect({ 26 | loading: true, 27 | error: null, 28 | data: { msg: "Hello" }, 29 | syncing: false, 30 | request: {} 31 | }).to.eql(res1); 32 | 33 | const res2 = fn(initialState, { type: actions.actionSuccess, data: true }); 34 | expect({ 35 | loading: false, 36 | error: null, 37 | data: true, 38 | sync: true, 39 | syncing: false 40 | }).to.eql(res2); 41 | 42 | const res3 = fn(initialState, { type: actions.actionFail, error: "Error" }); 43 | expect({ 44 | loading: false, 45 | error: "Error", 46 | data: { msg: "Hello" }, 47 | syncing: false 48 | }).to.eql(res3); 49 | 50 | const res4 = fn(initialState, { type: actions.actionReset }); 51 | expect(res4).to.deep.eq(initialState); 52 | 53 | const res5 = fn(undefined, { type: "fake" }); 54 | expect(res5).to.deep.eq(initialState); 55 | }); 56 | 57 | it("check with path variables", function() { 58 | const initialState = { loading: false, data: { msg: "Hello" } }; 59 | const actions = { 60 | actionFetch: "actionFetch", 61 | actionSuccess: "actionSuccess", 62 | actionFail: "actionFail", 63 | actionReset: "actionReset" 64 | }; 65 | const fn = reducerFn(initialState, actions); 66 | 67 | const res1 = fn(initialState, { 68 | type: actions.actionFetch, 69 | request: { pathvars: { id: 42 } } 70 | }); 71 | expect({ 72 | loading: true, 73 | error: null, 74 | data: { msg: "Hello" }, 75 | syncing: false, 76 | request: { 77 | pathvars: { id: 42 } 78 | } 79 | }).to.eql(res1); 80 | 81 | const res2 = fn(res1, { type: actions.actionSuccess, data: true }); 82 | expect({ 83 | loading: false, 84 | error: null, 85 | data: true, 86 | sync: true, 87 | syncing: false, 88 | request: { 89 | pathvars: { id: 42 } 90 | } 91 | }).to.eql(res2); 92 | 93 | const res3 = fn(res1, { type: actions.actionFail, error: "Error" }); 94 | expect({ 95 | loading: false, 96 | error: "Error", 97 | data: { msg: "Hello" }, 98 | syncing: false, 99 | request: { 100 | pathvars: { id: 42 } 101 | } 102 | }).to.eql(res3); 103 | 104 | const res4 = fn(res2, { type: actions.actionReset }); 105 | expect(res4).to.deep.eq(initialState); 106 | 107 | const res5 = fn(undefined, { type: "fake" }); 108 | expect(res5).to.deep.eq(initialState); 109 | }); 110 | 111 | it("check with body", function() { 112 | const initialState = { 113 | loading: false, 114 | request: null, 115 | data: { msg: "Hello" } 116 | }; 117 | const actions = { 118 | actionFetch: "actionFetch", 119 | actionSuccess: "actionSuccess", 120 | actionFail: "actionFail", 121 | actionReset: "actionReset" 122 | }; 123 | const fn = reducerFn(initialState, actions); 124 | 125 | const res1 = fn(initialState, { 126 | type: actions.actionFetch, 127 | request: { 128 | pathvars: { other: "var" }, 129 | params: { 130 | method: "post", 131 | body: { hello: "world", it: { should: { store: " the body" } } } 132 | } 133 | } 134 | }); 135 | expect({ 136 | loading: true, 137 | error: null, 138 | data: { msg: "Hello" }, 139 | syncing: false, 140 | request: { 141 | pathvars: { other: "var" }, 142 | params: { 143 | method: "post", 144 | body: { hello: "world", it: { should: { store: " the body" } } } 145 | } 146 | } 147 | }).to.eql(res1); 148 | 149 | const res2 = fn(res1, { type: actions.actionSuccess, data: true }); 150 | expect({ 151 | loading: false, 152 | error: null, 153 | data: true, 154 | sync: true, 155 | syncing: false, 156 | request: { 157 | pathvars: { other: "var" }, 158 | params: { 159 | method: "post", 160 | body: { hello: "world", it: { should: { store: " the body" } } } 161 | } 162 | } 163 | }).to.eql(res2); 164 | 165 | const res3 = fn(res1, { type: actions.actionFail, error: "Error" }); 166 | expect({ 167 | loading: false, 168 | error: "Error", 169 | data: { msg: "Hello" }, 170 | syncing: false, 171 | request: { 172 | pathvars: { other: "var" }, 173 | params: { 174 | method: "post", 175 | body: { 176 | hello: "world", 177 | it: { should: { store: " the body" } } 178 | } 179 | } 180 | } 181 | }).to.eql(res3); 182 | 183 | const res4 = fn(res2, { type: actions.actionReset }); 184 | expect(res4).to.deep.eq(initialState); 185 | 186 | const res5 = fn(undefined, { type: "fake" }); 187 | expect(res5).to.deep.eq(initialState); 188 | }); 189 | 190 | it("check injected reducer", function() { 191 | const initialState = { loading: false, data: { msg: "Hello" } }; 192 | const actions = { 193 | actionFetch: "actionFetch", 194 | actionSuccess: "actionSuccess", 195 | actionFail: "actionFail", 196 | actionReset: "actionReset" 197 | }; 198 | const fn = reducerFn(initialState, actions, (state, action) => { 199 | if (action.type === "CUSTOM") { 200 | return { ...state, data: "custom" }; 201 | } else { 202 | return state; 203 | } 204 | }); 205 | const res0 = fn(initialState, { type: "NO_WAY" }); 206 | expect(res0 === initialState).to.be.true; 207 | 208 | const res1 = fn(initialState, { type: "CUSTOM" }); 209 | expect(res1).to.eql({ loading: false, data: "custom" }); 210 | }); 211 | }); 212 | -------------------------------------------------------------------------------- /test/redux_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}], no-void: 0 */ 5 | import { 6 | expect 7 | } from "chai"; 8 | import { 9 | createStore, 10 | combineReducers, 11 | applyMiddleware 12 | } from "redux"; 13 | import thunk from "redux-thunk"; 14 | import after from "lodash/after"; 15 | import reduxApi from "../src"; 16 | import async from "../src/async" 17 | import { 18 | Manager 19 | } from "../src/utils/cache"; 20 | 21 | function storeHelper(rest) { 22 | const reducer = combineReducers(rest.reducers); 23 | const createStoreWithMiddleware = applyMiddleware(thunk)(createStore); 24 | return createStoreWithMiddleware(reducer); 25 | } 26 | 27 | function none() {} 28 | 29 | describe("redux", () => { 30 | it("check redux", () => { 31 | const rest = reduxApi({ 32 | test: "/api/url" 33 | }).use("fetch", url => { 34 | return new Promise(resolve => resolve(url)); 35 | }); 36 | const store = storeHelper(rest); 37 | return new Promise(resolve => { 38 | store.dispatch(rest.actions.test(resolve)); 39 | }).then(() => { 40 | expect(store.getState().test.data).to.eql({ 41 | data: "/api/url" 42 | }); 43 | }); 44 | }); 45 | it("check async function with redux", () => { 46 | const rest = reduxApi({ 47 | test: "/api/url", 48 | test2: "/api/url2" 49 | }).use("fetch", url => { 50 | return new Promise(resolve => resolve(url)); 51 | }); 52 | const store = storeHelper(rest); 53 | return async ( 54 | store.dispatch, 55 | cb => rest.actions.test(cb), 56 | rest.actions.test2 57 | ).then(d => { 58 | expect(d.data).to.eql("/api/url2"); 59 | expect(store.getState().test.data).to.eql({ 60 | data: "/api/url" 61 | }); 62 | expect(store.getState().test2.data).to.eql({ 63 | data: "/api/url2" 64 | }); 65 | }); 66 | }); 67 | it("check async 2", done => { 68 | const rest = reduxApi({ 69 | test: "/api/url" 70 | }).use("fetch", url => new Promise(resolve => resolve(url))); 71 | const store = storeHelper(rest); 72 | 73 | function testAction() { 74 | return (dispatch, getState) => { 75 | async (dispatch, rest.actions.test) 76 | .then(data => { 77 | expect(getState().test.data).to.eql(data); 78 | done(); 79 | }) 80 | .catch(done); 81 | }; 82 | } 83 | store.dispatch(testAction()); 84 | }); 85 | 86 | it("check custom middlewareParser", () => { 87 | const rest = reduxApi({ 88 | test: "/api/url" 89 | }) 90 | .use("fetch", url => new Promise(resolve => resolve(url))) 91 | .use("middlewareParser", ({ 92 | getState, 93 | dispatch 94 | }) => ({ 95 | getState, 96 | dispatch 97 | })); 98 | const reducer = combineReducers(rest.reducers); 99 | 100 | const cutsomThunkMiddleware = ({ 101 | dispatch, 102 | getState 103 | }) => next => action => { 104 | if (typeof action === "function") { 105 | return action({ 106 | dispatch, 107 | getState 108 | }); 109 | } 110 | return next(action); 111 | }; 112 | const createStoreWithMiddleware = applyMiddleware(cutsomThunkMiddleware)( 113 | createStore 114 | ); 115 | const store = createStoreWithMiddleware(reducer); 116 | return new Promise(resolve => { 117 | store.dispatch(rest.actions.test(resolve)); 118 | }).then( 119 | () => { 120 | expect(store.getState().test.data).to.eql({ 121 | data: "/api/url" 122 | }); 123 | }, 124 | err => expect(null).to.eql(err) 125 | ); 126 | }); 127 | 128 | it("check double call", done => { 129 | const rest = reduxApi({ 130 | test: "/test" 131 | }).use( 132 | "fetch", 133 | url => 134 | new Promise(resolve => { 135 | setTimeout(() => resolve({ 136 | url 137 | }), 100); 138 | }) 139 | ); 140 | 141 | const expectedAction = [{ 142 | type: "@@redux-api@test", 143 | syncing: true, 144 | request: { 145 | pathvars: undefined, 146 | params: {} 147 | } 148 | }, 149 | { 150 | data: { 151 | url: "/test" 152 | }, 153 | origData: { 154 | url: "/test" 155 | }, 156 | type: "@@redux-api@test_success", 157 | syncing: false, 158 | request: { 159 | pathvars: undefined, 160 | params: {} 161 | } 162 | } 163 | ]; 164 | const reducer = combineReducers({ 165 | ...rest.reducers, 166 | debug(state = {}, action) { 167 | if (!/^@@redux\//.test(action.type)) { 168 | const exp = expectedAction.shift(); 169 | expect(action).to.eql(exp); 170 | } 171 | return state; 172 | } 173 | }); 174 | const createStoreWithMiddleware = applyMiddleware(thunk)(createStore); 175 | const store = createStoreWithMiddleware(reducer); 176 | 177 | const next = after(2, function () { 178 | store.dispatch( 179 | rest.actions.test.sync((err, data) => { 180 | expect(data).to.eql({ 181 | url: "/test" 182 | }); 183 | expect(expectedAction).to.have.length(0); 184 | done(); 185 | }) 186 | ); 187 | }); 188 | 189 | store 190 | .dispatch( 191 | rest.actions.test.sync((err, data) => { 192 | expect(data).to.eql({ 193 | url: "/test" 194 | }); 195 | next(); 196 | }) 197 | ) 198 | .catch(none); 199 | store 200 | .dispatch( 201 | rest.actions.test.sync((err, data) => { 202 | expect(data).to.eql({ 203 | url: "/test" 204 | }); 205 | next(); 206 | }) 207 | ) 208 | .catch(none); 209 | }); 210 | 211 | it("check abort request", () => { 212 | const timeoutPromise = (url, timeout) => 213 | new Promise(resolve => setTimeout(() => resolve(url), timeout)); 214 | 215 | const rest = reduxApi({ 216 | test: "/test" 217 | }).use("fetch", url => timeoutPromise(url, 100)); 218 | 219 | const reducer = combineReducers(rest.reducers); 220 | const createStoreWithMiddleware = applyMiddleware(thunk)(createStore); 221 | const store = createStoreWithMiddleware(reducer); 222 | 223 | expect({ 224 | sync: false, 225 | syncing: false, 226 | loading: false, 227 | data: {}, 228 | request: null 229 | }).to.eql(store.getState().test, "Initial state"); 230 | const retAborting = store.dispatch(rest.actions.test()).then( 231 | () => expect(false).to.eql(true, "Should be error"), 232 | err => { 233 | expect(err.message).to.eql("Application abort request"); 234 | const { error, ...props } = store.getState().test; 235 | expect({ 236 | syncing: false, 237 | loading: false, 238 | data: {}, 239 | request: null, 240 | sync: false, 241 | }).to.eql(props); 242 | return true; 243 | } 244 | ); 245 | expect({ 246 | sync: false, 247 | syncing: false, 248 | loading: true, 249 | data: {}, 250 | error: null, 251 | request: { 252 | params: undefined, 253 | pathvars: undefined 254 | } 255 | }).to.eql( 256 | store.getState().test, 257 | "State doesn't change, request in process" 258 | ); 259 | store.dispatch(rest.actions.test.reset()); 260 | expect({ 261 | sync: false, 262 | syncing: false, 263 | loading: false, 264 | data: {}, 265 | request: null 266 | }).to.eql(store.getState().test, "State after reset"); 267 | return retAborting; 268 | }); 269 | 270 | it("check reducer option", () => { 271 | let context; 272 | const rest = reduxApi({ 273 | external: "/external", 274 | test: { 275 | url: "/test", 276 | reducer(state, action) { 277 | context = this; 278 | if (action.type === this.events.external.actionSuccess) { 279 | return { 280 | ...state, 281 | data: action.data 282 | }; 283 | } else { 284 | return state; 285 | } 286 | } 287 | } 288 | }).use("fetch", url => { 289 | return new Promise(resolve => { 290 | resolve({ 291 | url 292 | }); 293 | }); 294 | }); 295 | 296 | const store = storeHelper(rest); 297 | expect(store.getState()).to.eql({ 298 | external: { 299 | sync: false, 300 | syncing: false, 301 | loading: false, 302 | data: {}, 303 | request: null 304 | }, 305 | test: { 306 | sync: false, 307 | syncing: false, 308 | loading: false, 309 | data: {}, 310 | request: null 311 | } 312 | }); 313 | 314 | return new Promise(done => { 315 | store.dispatch(rest.actions.external(done)); 316 | }).then(err => { 317 | expect(err).to.not.exist; 318 | expect(context).to.include.keys("actions", "reducers", "events"); 319 | expect(store.getState()).to.eql({ 320 | external: { 321 | sync: true, 322 | syncing: false, 323 | loading: false, 324 | request: { 325 | params: {}, 326 | pathvars: undefined 327 | }, 328 | data: { 329 | url: "/external" 330 | }, 331 | error: null 332 | }, 333 | test: { 334 | sync: false, 335 | syncing: false, 336 | loading: false, 337 | request: null, 338 | data: { 339 | url: "/external" 340 | } 341 | } 342 | }); 343 | }); 344 | }); 345 | it('check reset "sync"', () => { 346 | const rest = reduxApi({ 347 | test: "/api/url" 348 | }).use("fetch", url => { 349 | return new Promise(resolve => resolve(url)); 350 | }); 351 | const store = storeHelper(rest); 352 | return new Promise(resolve => { 353 | store.dispatch(rest.actions.test(resolve)); 354 | }).then(() => { 355 | expect(store.getState().test).to.eql({ 356 | sync: true, 357 | syncing: false, 358 | loading: false, 359 | data: { 360 | data: "/api/url" 361 | }, 362 | request: { 363 | params: {}, 364 | pathvars: undefined 365 | }, 366 | error: null 367 | }); 368 | store.dispatch(rest.actions.test.reset("sync")); 369 | expect(store.getState().test).to.eql({ 370 | request: null, 371 | sync: false, 372 | syncing: false, 373 | loading: false, 374 | data: { 375 | data: "/api/url" 376 | }, 377 | error: null 378 | }); 379 | }); 380 | }); 381 | it("check result of dispatch", function () { 382 | const rest = reduxApi({ 383 | test: "/api/url" 384 | }).use("fetch", url => { 385 | return new Promise(resolve => resolve(url)); 386 | }); 387 | const store = storeHelper(rest); 388 | const result = store.dispatch(rest.actions.test()); 389 | expect(result instanceof Promise).to.be.true; 390 | return result.then(data => { 391 | expect(data).to.eql({ 392 | data: "/api/url" 393 | }); 394 | }); 395 | }); 396 | it("check all arguments for transformer", function () { 397 | const expectedArgs = [ 398 | [void 0, void 0, void 0], 399 | [ 400 | "/api/test1", 401 | void 0, 402 | { 403 | type: "@@redux-api@test1_success", 404 | request: { 405 | pathvars: void 0, 406 | params: void 0 407 | } 408 | } 409 | ], 410 | [ 411 | "/api/test2", 412 | "/api/test1", 413 | { 414 | type: "@@redux-api@test1_success", 415 | request: { 416 | pathvars: void 0, 417 | params: void 0 418 | } 419 | } 420 | ], 421 | "none" 422 | ]; 423 | const rest = reduxApi({ 424 | test1: { 425 | url: "/api/test1", 426 | transformer(data, prevData, opts) { 427 | expect([data, prevData, opts]).to.eql(expectedArgs.shift()); 428 | return data; 429 | } 430 | }, 431 | test2: { 432 | url: "/api/test2", 433 | reducerName: "test1", 434 | transformer(data, prevData, opts) { 435 | expect([data, prevData, opts]).to.eql(expectedArgs.shift()); 436 | return data; 437 | } 438 | } 439 | }).use("fetch", url => new Promise(resolve => resolve(url))); 440 | 441 | const store = storeHelper(rest); 442 | return store 443 | .dispatch(rest.actions.test1()) 444 | .then(() => store.dispatch(rest.actions.test2())) 445 | .then(() => expect(expectedArgs).to.eql(["none"])); 446 | }); 447 | 448 | it("multiple endpoints", function () { 449 | const fetch = url => Promise.resolve(url); 450 | 451 | const expectedData = [ 452 | [void 0, void 0], 453 | ["/test1", {}] 454 | ]; 455 | const actualData = []; 456 | 457 | const rest1 = reduxApi({ 458 | test: { 459 | url: "/test1", 460 | transformer(data, prevData) { 461 | actualData.push([data, prevData]); 462 | return data ? { 463 | data 464 | } : {}; 465 | } 466 | } 467 | }, { 468 | prefix: "r1" 469 | }).use("fetch", fetch); 470 | 471 | const rest2 = reduxApi({ 472 | test: "/test2" 473 | }, { 474 | prefix: "r2" 475 | }).use("fetch", fetch); 476 | 477 | const reducer = combineReducers({ 478 | r1: combineReducers(rest1.reducers), 479 | r2: combineReducers(rest2.reducers) 480 | }); 481 | 482 | const expectedArgs = [ 483 | [ 484 | "@@redux-api@r1test", 485 | { 486 | r1: { 487 | test: { 488 | sync: false, 489 | syncing: false, 490 | loading: true, 491 | data: {}, 492 | error: null, 493 | request: { 494 | params: undefined, 495 | pathvars: undefined 496 | } 497 | } 498 | }, 499 | r2: { 500 | test: { 501 | sync: false, 502 | syncing: false, 503 | loading: false, 504 | data: {}, 505 | request: null 506 | } 507 | } 508 | } 509 | ], 510 | [ 511 | "@@redux-api@r1test_success", 512 | { 513 | r1: { 514 | test: { 515 | sync: true, 516 | syncing: false, 517 | loading: false, 518 | data: { 519 | data: "/test1" 520 | }, 521 | error: null, 522 | request: { 523 | params: undefined, 524 | pathvars: undefined 525 | } 526 | } 527 | }, 528 | r2: { 529 | test: { 530 | sync: false, 531 | syncing: false, 532 | loading: false, 533 | request: null, 534 | data: {} 535 | } 536 | } 537 | } 538 | ], 539 | [ 540 | "@@redux-api@r2test", 541 | { 542 | r1: { 543 | test: { 544 | sync: true, 545 | syncing: false, 546 | loading: false, 547 | data: { 548 | data: "/test1" 549 | }, 550 | error: null, 551 | request: { 552 | params: undefined, 553 | pathvars: undefined 554 | } 555 | } 556 | }, 557 | r2: { 558 | test: { 559 | sync: false, 560 | syncing: false, 561 | loading: true, 562 | data: {}, 563 | error: null, 564 | request: { 565 | params: undefined, 566 | pathvars: undefined 567 | } 568 | } 569 | } 570 | } 571 | ], 572 | [ 573 | "@@redux-api@r2test_success", 574 | { 575 | r1: { 576 | test: { 577 | sync: true, 578 | syncing: false, 579 | loading: false, 580 | data: { 581 | data: "/test1" 582 | }, 583 | error: null, 584 | request: { 585 | params: undefined, 586 | pathvars: undefined 587 | } 588 | } 589 | }, 590 | r2: { 591 | test: { 592 | sync: true, 593 | syncing: false, 594 | loading: false, 595 | data: { 596 | data: "/test2" 597 | }, 598 | error: null, 599 | request: { 600 | params: undefined, 601 | pathvars: undefined 602 | } 603 | } 604 | } 605 | } 606 | ] 607 | ]; 608 | const receiveArgs = []; 609 | 610 | function midleware({ 611 | getState 612 | }) { 613 | return next => action => { 614 | const result = next(action); 615 | if (typeof action !== "function") { 616 | receiveArgs.push([action.type, getState()]); 617 | } 618 | return result; 619 | }; 620 | } 621 | 622 | const createStoreWithMiddleware = applyMiddleware( 623 | midleware, 624 | thunk 625 | )(createStore); 626 | const store = createStoreWithMiddleware(reducer); 627 | 628 | return store 629 | .dispatch(rest1.actions.test()) 630 | .then(() => store.dispatch(rest2.actions.test())) 631 | .then(() => { 632 | expect(receiveArgs).to.have.length(4); 633 | expect(expectedArgs[0]).to.eql(receiveArgs[0]); 634 | expect(expectedArgs[1]).to.eql(receiveArgs[1]); 635 | expect(expectedArgs[2]).to.eql(receiveArgs[2]); 636 | expect(expectedArgs[3]).to.eql(receiveArgs[3]); 637 | 638 | expect(actualData).to.have.length(2); 639 | expect(actualData[0]).to.eql(expectedData[0]); 640 | expect(actualData[1]).to.eql(expectedData[1]); 641 | }); 642 | }); 643 | 644 | it("check default cache options", function () { 645 | let requestCount = 0; 646 | const rest = reduxApi({ 647 | test: { 648 | url: "/api/:id1/:id2", 649 | cache: true 650 | } 651 | }).use("fetch", url => { 652 | requestCount += 1; 653 | return new Promise(resolve => resolve(url)); 654 | }); 655 | const store = storeHelper(rest); 656 | return store 657 | .dispatch(rest.actions.test({ 658 | id1: 1, 659 | id2: 2 660 | })) 661 | .then(() => { 662 | const state = store.getState(); 663 | expect(state.test.cache).to.eql({ 664 | "test_id1=1;id2=2;": { 665 | data: "/api/1/2", 666 | expire: false 667 | } 668 | }); 669 | return store.dispatch(rest.actions.test({ 670 | id1: 1, 671 | id2: 2 672 | })); 673 | }) 674 | .then(() => { 675 | expect(requestCount).to.eql(1); 676 | }); 677 | }); 678 | 679 | it("check cache options with rewrite id", function () { 680 | let requestCount = 0; 681 | const rest = reduxApi({ 682 | test: { 683 | url: "/api/:id1/:id2", 684 | cache: { 685 | id(urlparams) { 686 | return Manager.id(urlparams) + "test"; 687 | } 688 | } 689 | } 690 | }).use("fetch", url => { 691 | requestCount += 1; 692 | return new Promise(resolve => resolve(url)); 693 | }); 694 | const store = storeHelper(rest); 695 | return store 696 | .dispatch(rest.actions.test({ 697 | id1: 1, 698 | id2: 2 699 | })) 700 | .then(() => { 701 | const state = store.getState(); 702 | expect(state.test.cache).to.eql({ 703 | "test_id1=1;id2=2;test": { 704 | data: "/api/1/2", 705 | expire: false 706 | } 707 | }); 708 | return store.dispatch(rest.actions.test({ 709 | id1: 1, 710 | id2: 2 711 | })); 712 | }) 713 | .then(() => { 714 | expect(requestCount).to.eql(1); 715 | }); 716 | }); 717 | 718 | it("check cache options with expire=0 request", function () { 719 | let requestCount = 0; 720 | const rest = reduxApi({ 721 | test: { 722 | url: "/api/:id1/:id2", 723 | cache: { 724 | expire: 0 725 | } 726 | } 727 | }).use("fetch", url => { 728 | requestCount += 1; 729 | return new Promise(resolve => resolve(url)); 730 | }); 731 | const store = storeHelper(rest); 732 | return store 733 | .dispatch(rest.actions.test({ 734 | id1: 1, 735 | id2: 2 736 | })) 737 | .then(() => { 738 | const state = store.getState(); 739 | const d = state.test.cache["test_id1=1;id2=2;"]; 740 | expect(d).to.exist; 741 | expect(d.data).to.eql("/api/1/2"); 742 | return store.dispatch(rest.actions.test({ 743 | id1: 1, 744 | id2: 2 745 | })); 746 | }) 747 | .then(() => { 748 | expect(requestCount).to.eql(2); 749 | }); 750 | }); 751 | 752 | it("check double call crud alias", function () { 753 | let fetchCounter = 0; 754 | const rest = reduxApi({ 755 | test: { 756 | url: "/api/test", 757 | crud: true 758 | } 759 | }).use("fetch", url => { 760 | fetchCounter += 1; 761 | return new Promise(resolve => resolve(url)); 762 | }); 763 | 764 | const store = storeHelper(rest); 765 | let counter = 0; 766 | 767 | function callback() { 768 | counter += 1; 769 | } 770 | 771 | return store 772 | .dispatch(rest.actions.test.get(callback)) 773 | .then(() => store.dispatch(rest.actions.test.put(callback))) 774 | .then(() => { 775 | expect(fetchCounter).to.eql(2, "fetch should be perform twice"); 776 | expect(counter).to.eql(2, "call should be perform twice"); 777 | }); 778 | }); 779 | 780 | it("check abort", () => { 781 | const rest = reduxApi({ 782 | test: "/api/url" 783 | }).use("fetch", url => { 784 | return new Promise(resolve => setTimeout(() => resolve(url), 10)); 785 | }); 786 | const store = storeHelper(rest); 787 | 788 | const ret1 = new Promise((resolve, reject) => 789 | store.dispatch(rest.actions.test()).then( 790 | () => reject("Abort should generate error"), 791 | err => { 792 | try { 793 | expect("Application abort request").to.eql(err.message); 794 | expect({ 795 | sync: false, 796 | syncing: false, 797 | loading: false, 798 | data: {}, 799 | request: { 800 | params: undefined, 801 | pathvars: undefined 802 | }, 803 | error: err 804 | }).to.eql(store.getState().test); 805 | resolve(); 806 | } catch (e) { 807 | reject(e); 808 | } 809 | } 810 | ) 811 | ); 812 | 813 | store.dispatch(rest.actions.test.abort()); 814 | const ret2 = store.dispatch(rest.actions.test()); 815 | 816 | return Promise.all([ret1, ret2]).then(() => { 817 | expect(store.getState().test.data).to.eql({ 818 | data: "/api/url" 819 | }); 820 | }); 821 | }); 822 | 823 | it("check force", () => { 824 | const rest = reduxApi({ 825 | test: "/api/url" 826 | }).use("fetch", url => { 827 | return new Promise(resolve => setTimeout(() => resolve(url), 10)); 828 | }); 829 | const store = storeHelper(rest); 830 | const ret1 = store.dispatch(rest.actions.test()).then( 831 | () => Promise.reject("Abort shout generate error"), 832 | err => { 833 | try { 834 | expect("Application abort request").to.eql(err.message); 835 | expect({ 836 | sync: false, 837 | syncing: false, 838 | loading: false, 839 | data: {}, 840 | request: { 841 | params: undefined, 842 | pathvars: undefined 843 | }, 844 | error: err 845 | }).to.eql(store.getState().test); 846 | } catch (err) { 847 | return Promise.reject(err); 848 | } 849 | } 850 | ); 851 | const ret2 = store.dispatch(rest.actions.test.force()); 852 | return Promise.all([ret1, ret2]).then(() => { 853 | expect(store.getState().test.data).to.eql({ 854 | data: "/api/url" 855 | }); 856 | }); 857 | }); 858 | 859 | it("check pathvars", () => { 860 | const rest = reduxApi({ 861 | test: "/api/url/:id" 862 | }).use("fetch", url => { 863 | return new Promise(resolve => resolve(url)); 864 | }); 865 | const store = storeHelper(rest); 866 | const INIT_STATE = { 867 | test: { 868 | request: null, 869 | sync: false, 870 | syncing: false, 871 | loading: false, 872 | data: {} 873 | } 874 | }; 875 | expect(INIT_STATE).to.eql(store.getState()); 876 | 877 | const STATE_1 = { 878 | test: { 879 | sync: true, 880 | syncing: false, 881 | loading: false, 882 | data: { 883 | data: "/api/url/1" 884 | }, 885 | request: { 886 | pathvars: { 887 | id: 1 888 | }, 889 | params: { 890 | body: "Test", 891 | headers: ["JSON"] 892 | } 893 | }, 894 | error: null 895 | } 896 | }; 897 | const STATE_2 = { 898 | test: { 899 | sync: true, 900 | syncing: false, 901 | loading: false, 902 | data: { 903 | data: "/api/url/2" 904 | }, 905 | request: { 906 | pathvars: { 907 | id: 2 908 | }, 909 | params: { 910 | body: "Test2", 911 | headers: ["XML"] 912 | } 913 | }, 914 | error: null 915 | } 916 | }; 917 | 918 | return store 919 | .dispatch( 920 | rest.actions.test({ 921 | id: 1 922 | }, { 923 | body: "Test", 924 | headers: ["JSON"] 925 | }) 926 | ) 927 | .then(() => { 928 | expect(STATE_1).to.eql(store.getState()); 929 | return store.dispatch( 930 | rest.actions.test({ 931 | id: 2 932 | }, { 933 | body: "Test2", 934 | headers: ["XML"] 935 | }) 936 | ); 937 | }) 938 | .then(() => { 939 | expect(STATE_2).to.eql(store.getState()); 940 | }); 941 | }); 942 | }); 943 | -------------------------------------------------------------------------------- /test/urlTransform_spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* global describe, it */ 4 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ 5 | import { expect } from "chai"; 6 | import urlTransform from "../src/urlTransform"; 7 | 8 | describe("urlTransform", function() { 9 | it("check null params", function() { 10 | expect(urlTransform()).to.eql(""); 11 | expect(urlTransform(null)).to.eql(""); 12 | expect(urlTransform("")).to.eql(""); 13 | expect(urlTransform("/test")).to.eql("/test"); 14 | }); 15 | 16 | it("check replace path", function() { 17 | expect(urlTransform("/test/:id", { id: 1 })).to.eql("/test/1"); 18 | expect(urlTransform("/test/:id/hey/:id", { id: 1 })).to.eql( 19 | "/test/1/hey/1" 20 | ); 21 | }); 22 | 23 | it("check replace path with hostname", function() { 24 | expect(urlTransform("http://localhost:1234/test/:id", { id: 1 })).to.eql( 25 | "http://localhost:1234/test/1" 26 | ); 27 | expect( 28 | urlTransform("http://localhost:1234/test/:id/hey/:id", { id: 1 }) 29 | ).to.eql("http://localhost:1234/test/1/hey/1"); 30 | expect( 31 | urlTransform("http://localhost:1234/test/:id/hey/:id?hello=1", { id: 1 }) 32 | ).to.eql("http://localhost:1234/test/1/hey/1?hello=1"); 33 | expect( 34 | urlTransform("http://localhost:1234/test/:id/hey/:id?hello=1&world=2", { 35 | id: 1 36 | }) 37 | ).to.eql("http://localhost:1234/test/1/hey/1?hello=1&world=2"); 38 | }); 39 | 40 | it("check optional params path", function() { 41 | expect(urlTransform("/test/:id", { id: 1 })).to.eql("/test/1"); 42 | expect(urlTransform("/test/(:id)", { id: 1 })).to.eql("/test/1"); 43 | expect(urlTransform("/test/(:id)")).to.eql("/test/"); 44 | }); 45 | 46 | it("check non-pretty params in path", function() { 47 | expect(urlTransform("/test/(:id)", { id1: 1 })).to.eql("/test/?id1=1"); 48 | expect(urlTransform("/test/?hello=1&(:id)", { id1: 1 })).to.eql( 49 | "/test/?hello=1&id1=1" 50 | ); 51 | expect(urlTransform("/test/?hello=2(:id)", { id1: 1 })).to.eql( 52 | "/test/?hello=2&id1=1" 53 | ); 54 | }); 55 | 56 | it("check clean params", function() { 57 | expect(urlTransform("/test/:id")).to.eql("/test/"); 58 | expect(urlTransform("/test/:id/")).to.eql("/test/"); 59 | expect(urlTransform("/test/(:id)")).to.eql("/test/"); 60 | }); 61 | 62 | it("accepts url transform options", function() { 63 | const urlOptions = { arrayFormat: "repeat", delimiter: ";" }; 64 | expect(urlTransform("/test", { id: [1, 2] }, urlOptions)).to.eql( 65 | "/test?id=1;id=2" 66 | ); 67 | expect(urlTransform("/test?id=1;id=2", null, urlOptions)).to.eql( 68 | "/test?id=1;id=2" 69 | ); 70 | expect(urlTransform("/test?id=1", { id: [2, 3] }, urlOptions)).to.eql( 71 | "/test?id=1;id=2;id=3" 72 | ); 73 | expect(urlTransform("/test?id=1;id=2", { id: [2, 3] }, urlOptions)).to.eql( 74 | "/test?id=1;id=2;id=2;id=3" 75 | ); 76 | }); 77 | 78 | it("accepts qsParseOptions", function() { 79 | const urlOptions = { 80 | arrayFormat: "repeat", 81 | qsParseOptions: { arrayFormat: "indices" } 82 | }; 83 | expect(urlTransform("/t?id[0]=1&id[1]=2", { a: 0 }, urlOptions)).to.eql( 84 | "/t?id=1&id=2&a=0" 85 | ); 86 | expect( 87 | urlTransform("/t?id[0]=1&id[1]=2", { a: [1, 2] }, urlOptions) 88 | ).to.eql("/t?id=1&id=2&a=1&a=2"); 89 | }); 90 | 91 | it("accepts qsStringifyOptions", function() { 92 | const urlOptions = { 93 | arrayFormat: "brackets", 94 | qsStringifyOptions: { arrayFormat: "repeat" } 95 | }; 96 | expect(urlTransform("/test", { id: [1, 2] }, {})).to.eql( 97 | "/test?id%5B0%5D=1&id%5B1%5D=2" 98 | ); 99 | expect(urlTransform("/test", { id: [1, 2] }, urlOptions)).to.eql( 100 | "/test?id=1&id=2" 101 | ); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const webpack = require("webpack"); 4 | const path = require("path"); 5 | 6 | const plugins = [ 7 | new webpack.DefinePlugin({ 8 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV) 9 | }) 10 | ]; 11 | 12 | if (process.env.NODE_ENV === "production") { 13 | plugins.push( 14 | new webpack.LoaderOptionsPlugin({ 15 | minimize: true 16 | }) 17 | ); 18 | } 19 | 20 | module.exports = { 21 | mode: process.env.NODE_ENV, 22 | entry: "./src/index", 23 | output: { 24 | library: "redux-api", 25 | libraryTarget: "umd", 26 | umdNamedDefine: true, 27 | filename: process.env.NODE_ENV === "production" ? "redux-api.min.js" : "redux-api.js", 28 | path: path.resolve(__dirname, "dist") 29 | }, 30 | optimization: { 31 | minimize: true 32 | }, 33 | devtool: "hidden-source-map", 34 | plugins, 35 | module: { 36 | rules: [{ 37 | test: /\.js$/, 38 | exclude: /node_modules/, 39 | use: { 40 | loader: "babel-loader", 41 | } 42 | }] 43 | }, 44 | resolve: { 45 | extensions: [".js"] 46 | } 47 | }; 48 | --------------------------------------------------------------------------------