├── .babelrc ├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── circle.yml ├── example ├── public │ └── index.html ├── src │ ├── actions.js │ ├── client.js │ ├── components │ │ ├── Content.js │ │ ├── Input.js │ │ └── User.js │ ├── containers │ │ └── App.js │ ├── reducer.js │ ├── resources.js │ ├── server.js │ └── store │ │ └── configureStore.js └── webpack.config.js ├── package.json ├── src ├── createResourceAction.js ├── helpers │ ├── assign.js │ ├── assignPolyfill.js │ ├── fetchResource │ │ ├── index.js │ │ ├── normalizeHeaders.js │ │ ├── serializeDataForContentType.js │ │ ├── serializeFormData.js │ │ └── setHeaders.js │ ├── includes.js │ ├── mapKeys.js │ ├── omit.js │ ├── parseUrl.js │ ├── splitParams.js │ └── tryResult.js ├── index.js └── resourceMiddleware.js └── test ├── support ├── httpRequestTest.js └── jsdom.js └── tests ├── createResourceAction.test.js └── helpers ├── assignPolyfill.test.js ├── fetchResource ├── fetchResource.test.js ├── normalizeHeaders.test.js ├── serializeDataForContentType.test.js ├── serializeFormData.test.js └── setHeaders.test.js ├── includes.test.js ├── mapKeys.test.js ├── omit.test.js ├── parseUrl.test.js ├── splitParams.test.js └── tryResult.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "plugins": [ 4 | "syntax-async-functions", 5 | "transform-export-extensions", 6 | "transform-object-rest-spread", 7 | "transform-regenerator" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "rules": { 4 | "indent": [ 5 | 2, 6 | 2 7 | ], 8 | "quotes": [ 9 | 2, 10 | "single" 11 | ], 12 | "linebreak-style": [ 13 | 2, 14 | "unix" 15 | ], 16 | "no-console": 0, 17 | "no-unused-vars": [2, {"vars": "all", "varsIgnorePattern": "React"}], 18 | "semi": [ 19 | 2, 20 | "always" 21 | ] 22 | }, 23 | "env": { 24 | "es6": true, 25 | "node": true, 26 | "browser": true 27 | }, 28 | "extends": "eslint:recommended", 29 | "plugins": [ 30 | "react" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | lib/ 29 | example/public/bundle.js 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ### v1.1.0 - 2015-12-30 3 | 4 | **New Features** 5 | 6 | * Headers can be supplied to the object literal definition for a resource 7 | * Form data can be sent with POST requests when using 8 | `application/x-www-form-urlencoded` for the `Content-Type` header 9 | 10 | ### v1.0.0 - 2015-12-16 11 | 12 | **Initial Release** 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jeremy Fairbank 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-resource 2 | 3 | [![Circle CI](https://circleci.com/gh/jfairbank/redux-resource.svg?style=svg)](https://circleci.com/gh/jfairbank/redux-resource) 4 | 5 | Easily create actions for managing server resources like fetching, creating, or 6 | updating. Provide action types used in your reducer function for updating your 7 | redux store based on results from the server. 8 | 9 | ### \*\* DEPRECATED \*\* 10 | 11 | This package is no longer maintained. The redux-resource package name will be 12 | given to a different team managing a similar type of package for managing remote 13 | resources in Redux. More info to come soon. 14 | 15 | ## Install 16 | 17 | $ npm install redux-resource 18 | 19 | ## Usage 20 | 21 | Use `createResourceAction` to create an action for fetching a resource. Supply a 22 | URL (with optional path parameter placeholders) along with three action types to 23 | represent the act of sending the request, receiving a successful response from 24 | the server, and receiving an error from the server. Make sure to use these 25 | action types in your reducer function! 26 | 27 | ```js 28 | import { createResourceAction } from 'redux-resource'; 29 | 30 | const fetchTodo = createResourceAction( 31 | '/todos/:id', 'FETCH_TODO', 'RCV_TODO', 'ERR_RCV_TODO' 32 | ); 33 | ``` 34 | 35 | When calling the action, pass in any parameters to fill in the placeholders or 36 | add additional search query parameters. 37 | 38 | ```js 39 | // make request to '/todos/42' 40 | store.dispatch(fetchTodo({ id: 42 })); 41 | 42 | // make request to '/todos/42?extraParam=hi' 43 | store.dispatch(fetchTodo({ 44 | id: 1, 45 | extraParam: 'hi' 46 | })); 47 | ``` 48 | 49 | `createResourceAction` defaults to `GET` requests. To use other verbs swap out 50 | the URL string with an object literal that defines the URL and HTTP verb. 51 | 52 | ```js 53 | const addTodo = createResourceAction( 54 | { url: '/todos/add', method: 'POST' }, 55 | 'ADD_TODO', 'ADDED_TODO', 'ERR_ADDING_TODO' 56 | ); 57 | ``` 58 | 59 | You can also supply headers inside the object literal. This is useful if your 60 | request content type needs to be something other than `application/json`. 61 | 62 | ```js 63 | const resource = { 64 | url: '/todos/add', 65 | method: 'POST', 66 | headers: { 67 | 'Content-Type': 'application/x-www-form-urlencoded' 68 | } 69 | }; 70 | 71 | const addTodoFromForm = createResourceAction( 72 | resource, 'ADD_TODO', 'ADDED_TODO', 'ERR_ADDING_TODO' 73 | ); 74 | ``` 75 | 76 | In addition to taking parameters, the action can take request data. You can 77 | pass in `null` for params if you don't have any. 78 | 79 | ```js 80 | // make request to '/todos/add' with JSON '{"title":"Finish tests"}' 81 | store.dispatch( 82 | addTodo(null, { title: 'Finish tests' }) 83 | ); 84 | 85 | // make request to '/todos/add' with form data 'title="Add%20more%20features"' 86 | store.dispatch( 87 | addTodoFromForm(null, { title: 'Add more features' }) 88 | ); 89 | ``` 90 | 91 | Actions return a thunk (an anonymous function). To use the actions with your 92 | store you **must** include the middleware. (redux-resource uses 93 | [redux-thunk](https://github.com/gaearon/redux-thunk) under the hood.) 94 | 95 | ```js 96 | // configureStore.js 97 | 98 | import { applyMiddleware, createStore } from 'redux'; 99 | import { resourceMiddleware } from 'redux-resource'; 100 | import myReducer from './myReducer'; 101 | 102 | const createStoreWithMiddleWare = applyMiddleware(resourceMiddleware)(createStore); 103 | const store = createStoreWithMiddleWare(myReducer); 104 | ``` 105 | 106 | Make sure to add the action types to your reducer function! 107 | 108 | ```js 109 | function reducer(state = INITIAL_STATE, action) { 110 | switch(action.type) { 111 | case 'FETCH_TODO': 112 | // ... 113 | 114 | case 'RCV_TODO': 115 | return { ...state, todo: action.payload }; 116 | 117 | case 'ADD_TODO': 118 | // ... 119 | 120 | case 'ERR_ADDING_TODO': 121 | return { ...state, error: action.payload }; 122 | 123 | // etc. 124 | 125 | default: 126 | return state; 127 | } 128 | } 129 | ``` 130 | 131 | Dispatching the actions will return a promise that resolves when a request 132 | _succeeds_ or _fails_. Therefore, `catch` won't work on failed requests. Your 133 | reducer should manage how your state reacts to failed requests. This allows you 134 | to gracefully handle state changes from errors for React applications for 135 | example. 136 | 137 | ```js 138 | // Successful request 139 | store.dispatch(fetchTodo({ id: 42 })).then(() => { 140 | const todo = store.getState().todo; 141 | }); 142 | 143 | // Failing request 144 | store.dispatch(addTodo(null, { title: 'Finish tests' })).then(() => { 145 | const error = store.getState().error; 146 | }); 147 | ``` 148 | 149 | ## API 150 | 151 | ### `createResourceAction` 152 | 153 | ```js 154 | createResourceAction( 155 | url: string | { 156 | url: string, 157 | [method: string], 158 | [headers: Object] 159 | }, 160 | sendType: string, 161 | successType: string, 162 | errorType: string 163 | ) 164 | ``` 165 | 166 | `url` - URL for resource. Allows path parameter placeholders like `/users/:id`. 167 | 168 | `sendType` - The action type when dispatching the request. 169 | 170 | `successType` - The action type when successfully receiving back the resource 171 | from the server. 172 | 173 | `errorType` - The action type when a server request fails. 174 | 175 | ## Example 176 | 177 | ```js 178 | import { applyMiddleware, createStore } from 'redux'; 179 | import { resourceMiddleware, createResourceAction } from 'redux-resource'; 180 | 181 | const createStoreWithMiddleWare = applyMiddleware(resourceMiddleware)(createStore); 182 | 183 | const INITIAL_STATE = { 184 | fetching: false, 185 | user: null, 186 | error: null 187 | }; 188 | 189 | function reducer(state = INITIAL_STATE, action) { 190 | switch(action.type) { 191 | case 'FETCH_USER': 192 | return { ...state, fetching: true }; 193 | 194 | case 'RECEIVE_USER': 195 | return { ...state, fetching: false, user: action.payload }; 196 | 197 | case 'ERR_RECEIVE_USER': 198 | return { ...state, fetching: false, error: action.payload }; 199 | 200 | default: 201 | return state; 202 | } 203 | } 204 | 205 | const store = createStoreWithMiddleWare(reducer); 206 | 207 | const fetchUser = createResourceAction( 208 | '/users/:id', 'FETCH_USER', 'RECEIVE_USER', 'ERR_RECEIVE_USER' 209 | ); 210 | 211 | store.dispatch(fetchUser({ id: 1 })) 212 | .then(() => console.log(store.getState())); 213 | 214 | // { 215 | // fetching: false, 216 | // user: { id: 1, name: 'Jeremy' }, 217 | // error: null 218 | // } 219 | ``` 220 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 5.1.0 4 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /example/src/actions.js: -------------------------------------------------------------------------------- 1 | import * as users from './resources'; 2 | import { createResourceAction } from '../../src'; 3 | 4 | export const updateFetchId = (id) => ({ 5 | type: 'UPDATE_FETCH_ID', 6 | payload: id 7 | }); 8 | 9 | export const updateCreateName = (name) => ({ 10 | type: 'UPDATE_CREATE_NAME', 11 | payload: name 12 | }); 13 | 14 | export const fetchUser = createResourceAction( 15 | users.user, 'FETCH_USER', 'RECEIVE_USER', 'ERR_RECEIVE_USER' 16 | ); 17 | 18 | export const createUser = createResourceAction( 19 | users.create, 'ADD_USER', 'ADDED_USER', 'ERR_ADDING_USER' 20 | ); 21 | 22 | export const createUserFromForm = createResourceAction( 23 | users.createFromForm, 'ADD_USER', 'ADDED_USER', 'ERR_ADDING_USER' 24 | ); 25 | -------------------------------------------------------------------------------- /example/src/client.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import { render } from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import App from './containers/App'; 6 | import reducer from './reducer'; 7 | import configureStore from './store/configureStore'; 8 | 9 | const store = configureStore(reducer); 10 | 11 | render( 12 | 13 | 14 | , 15 | document.getElementById('main') 16 | ); 17 | -------------------------------------------------------------------------------- /example/src/components/Content.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import User from '../components/User'; 3 | 4 | const Content = ({ fetching, user, error }) => { 5 | if (fetching) { 6 | return

Sending request...

; 7 | } 8 | 9 | if (error) { 10 | return

Error: {error.message}

; 11 | } 12 | 13 | if (user) { 14 | return ; 15 | } 16 | 17 | return
; 18 | }; 19 | 20 | export default Content; 21 | -------------------------------------------------------------------------------- /example/src/components/Input.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Input = ({ placeholder, children, onSubmit }) => { 4 | let input; 5 | 6 | return ( 7 |

8 | input = i} 10 | placeholder={placeholder}/> 11 | {' '} 12 | 16 |

17 | ); 18 | }; 19 | 20 | export default Input; 21 | -------------------------------------------------------------------------------- /example/src/components/User.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const User = ({ id, name, email }) => ( 4 |
5 |

{id}: {name}

6 | {email && 7 |

{email}

8 | } 9 |
10 | ); 11 | 12 | export default User; 13 | -------------------------------------------------------------------------------- /example/src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { bindActionCreators } from 'redux'; 3 | import { connect } from 'react-redux'; 4 | import Content from '../components/Content'; 5 | import Input from '../components/Input'; 6 | import * as actions from '../actions'; 7 | 8 | export const App = ({ actions, fetching, error, user }) => ( 9 |
10 | 11 | 12 | actions.fetchUser({ id })} 15 | > 16 | Fetch User 17 | 18 | 19 | actions.createUser(null, { name })} 22 | > 23 | Create User 24 | 25 | 26 | actions.createUserFromForm(null, { name })} 29 | > 30 | Create User From Form 31 | 32 |
33 | ); 34 | 35 | export default connect( 36 | state => state, 37 | dispatch => ({ actions: bindActionCreators(actions, dispatch) }) 38 | )(App); 39 | -------------------------------------------------------------------------------- /example/src/reducer.js: -------------------------------------------------------------------------------- 1 | const match = (expression, matchers, defaultState) => { 2 | for (const key of Object.keys(matchers)) { 3 | if (expression === key) { 4 | return matchers[key](); 5 | } 6 | } 7 | 8 | return defaultState; 9 | }; 10 | 11 | export const INITIAL_STATE = { 12 | fetching: false, 13 | user: null, 14 | error: null 15 | }; 16 | 17 | export default (state = INITIAL_STATE, action) => match(action.type, { 18 | FETCH_USER: () => ({ ...state, fetching: true, error: null }), 19 | RECEIVE_USER: () => ({ ...state, fetching: false, user: action.payload }), 20 | ERR_RECEIVE_USER: () => ({ ...state, fetching: false, error: action.payload }), 21 | ADD_USER: () => ({ ...state, adding: true, error: null }), 22 | ADDED_USER: () => ({ ...state, adding: false, user: action.payload }), 23 | ERR_ADDING_USER: () => ({ ...state, adding: false, error: action.payload }) 24 | }, state); 25 | -------------------------------------------------------------------------------- /example/src/resources.js: -------------------------------------------------------------------------------- 1 | export const user = '/users/:id'; 2 | 3 | export const create = { 4 | url: '/users/create', 5 | method: 'POST' 6 | }; 7 | 8 | export const createFromForm = { 9 | url: '/users/create', 10 | method: 'POST', 11 | headers: { 12 | 'Content-Type': 'application/x-www-form-urlencoded' 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /example/src/server.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import express from 'express'; 3 | import bodyParser from 'body-parser'; 4 | 5 | const HOST = '127.0.0.1'; 6 | const PORT = 3000; 7 | const URL = `http://${HOST}:${PORT}`; 8 | 9 | const createUser = (() => { 10 | let id = 1; 11 | 12 | return (name, email) => ({ name, email, id: id++ }); 13 | })(); 14 | 15 | const users = _([ 16 | createUser('Bob', 'bob@example.com'), 17 | createUser('Alice', 'alice@example.com'), 18 | createUser('Jeremy', 'jeremy@example.com') 19 | ]) 20 | .map(user => [user.id, user]) 21 | .zipObject() 22 | .value(); 23 | 24 | const app = express(); 25 | 26 | app.use(express.static('public')); 27 | app.use(bodyParser.urlencoded({ extended: false })); 28 | app.use(bodyParser.json()); 29 | 30 | app.get('/users', (req, res) => { 31 | res.send(_.values(users)); 32 | }); 33 | 34 | app.post('/users/create', (req, res) => { 35 | res.send({ 36 | ...req.body, 37 | id: 4 38 | }); 39 | }); 40 | 41 | app.get('/users/:id', (req, res) => { 42 | const user = users[req.params.id]; 43 | 44 | if (!user) { 45 | res 46 | .status(404) 47 | .type('application/json') 48 | .send({ message: 'User not found' }); 49 | } else { 50 | res 51 | .type('application/json') 52 | .send(user); 53 | } 54 | }); 55 | 56 | app.listen(PORT, HOST, (err) => { 57 | if (err) { 58 | console.error('Error starting server'); 59 | } else { 60 | console.log(`Server listening at ${URL}`); 61 | } 62 | }); 63 | -------------------------------------------------------------------------------- /example/src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import { applyMiddleware, createStore } from 'redux'; 3 | import { resourceMiddleware } from '../../../src'; 4 | 5 | const createStoreWithMiddleWare = applyMiddleware(resourceMiddleware)(createStore); 6 | 7 | export default function configureStore(reducer, initialState) { 8 | return createStoreWithMiddleWare(reducer, initialState); 9 | } 10 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | devtool: '#inline-source-map', 5 | 6 | entry: path.resolve(__dirname, 'src/client'), 7 | 8 | output: { 9 | path: path.resolve(__dirname, 'public'), 10 | filename: 'bundle.js' 11 | }, 12 | 13 | module: { 14 | loaders: [ 15 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel' } 16 | ] 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-resource", 3 | "version": "1.1.0", 4 | "description": "Easily create redux actions for managing server resources", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "src", 8 | "lib" 9 | ], 10 | "scripts": { 11 | "build": "babel src --out-dir lib", 12 | "clean": "rm -rf lib", 13 | "prepublish": "npm run clean && npm run build", 14 | "test": "NODE_PATH=NODE_PATH:. babel-tape-runner 'test/tests/**/*.test.js'", 15 | "dev:test": "NODE_PATH=NODE_PATH:. babel-tape-runner 'test/tests/**/*.test.js' | tnyan", 16 | "dev:test:watch": "nodemon -w src -w test --exec 'npm run dev:test'" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/jfairbank/redux-resource.git" 21 | }, 22 | "keywords": [ 23 | "redux", 24 | "rest", 25 | "resource" 26 | ], 27 | "author": "Jeremy Fairbank (http://jeremyfairbank.com)", 28 | "license": "MIT", 29 | "dependencies": { 30 | "pinkie-promise": "^2.0.0", 31 | "redux-thunk": "^1.0.0" 32 | }, 33 | "devDependencies": { 34 | "babel-core": "^6.3.15", 35 | "babel-loader": "^6.2.0", 36 | "babel-plugin-syntax-async-functions": "^6.3.13", 37 | "babel-plugin-transform-export-extensions": "^6.3.13", 38 | "babel-plugin-transform-object-rest-spread": "^6.3.13", 39 | "babel-plugin-transform-regenerator": "^6.3.18", 40 | "babel-plugin-transform-runtime": "^6.3.13", 41 | "babel-polyfill": "^6.3.14", 42 | "babel-preset-es2015": "^6.3.13", 43 | "babel-preset-react": "^6.3.13", 44 | "babel-runtime": "^6.3.13", 45 | "babel-tape-runner": "^2.0.0", 46 | "body-parser": "^1.14.1", 47 | "express": "^4.13.3", 48 | "jsdom": "^7.2.1", 49 | "lodash": "^3.10.1", 50 | "nodemon": "^1.8.1", 51 | "react": "^0.14.3", 52 | "react-dom": "^0.14.3", 53 | "react-redux": "^4.0.6", 54 | "redux": "^3.0.4", 55 | "sinon": "^1.17.2", 56 | "tap-nyan": "0.0.2", 57 | "tape": "^4.2.2", 58 | "webpack": "^1.12.9", 59 | "webpack-dev-server": "^1.14.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/createResourceAction.js: -------------------------------------------------------------------------------- 1 | import fetchResource from './helpers/fetchResource'; 2 | import omit from './helpers/omit'; 3 | import parseUrl from './helpers/parseUrl'; 4 | import assign from './helpers/assign'; 5 | 6 | export default function createResourceAction( 7 | options, sendType, successType, errorType 8 | ) { 9 | let rawUrl, urlCompiler; 10 | 11 | if (typeof options === 'string') { 12 | rawUrl = options; 13 | options = {}; 14 | } else { 15 | rawUrl = options.url; 16 | options = omit(options, ['url']); 17 | } 18 | 19 | if (!rawUrl) { 20 | throw new Error('Please provide a url for the resource'); 21 | } 22 | 23 | urlCompiler = parseUrl(rawUrl); 24 | 25 | const resourceSendAction = () => ({ type: sendType }); 26 | 27 | const resourceSuccessAction = (resource) => ({ 28 | type: successType, 29 | payload: resource 30 | }); 31 | 32 | const resourceErrorAction = (error) => ({ 33 | type: errorType, 34 | payload: error 35 | }); 36 | 37 | return (params, data) => (dispatch) => { 38 | const url = urlCompiler(params); 39 | 40 | dispatch(resourceSendAction()); 41 | 42 | return fetchResource(url, assign({}, options, { data })).then( 43 | resource => dispatch(resourceSuccessAction(resource)), 44 | error => dispatch(resourceErrorAction(error)) 45 | ); 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/helpers/assign.js: -------------------------------------------------------------------------------- 1 | if (Object.assign) { 2 | module.exports = Object.assign; 3 | } else { 4 | module.exports = require('./assignPolyfill'); 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/assignPolyfill.js: -------------------------------------------------------------------------------- 1 | export default function assign(dest, ...sources) { 2 | sources.forEach((source) => { 3 | Object.keys(source).forEach((key) => { 4 | dest[key] = source[key]; 5 | }); 6 | }); 7 | 8 | return dest; 9 | } 10 | -------------------------------------------------------------------------------- /src/helpers/fetchResource/index.js: -------------------------------------------------------------------------------- 1 | import Promise from 'pinkie-promise'; 2 | import tryResult from '../tryResult'; 3 | import assign from '../assign'; 4 | import setHeaders from './setHeaders'; 5 | import normalizeHeaders from './normalizeHeaders'; 6 | import serializeDataForContentType from './serializeDataForContentType'; 7 | 8 | const DEFAULT_HEADERS = { 9 | 'content-type': 'application/json' 10 | }; 11 | 12 | const DEFAULT_OPTIONS = { 13 | method: 'GET' 14 | }; 15 | 16 | export default function fetchResource(url, options = {}) { 17 | return new Promise((resolve, reject) => { 18 | options = assign({}, DEFAULT_OPTIONS, options); 19 | 20 | options.headers = assign( 21 | {}, 22 | DEFAULT_HEADERS, 23 | normalizeHeaders(options.headers) 24 | ); 25 | 26 | const data = serializeDataForContentType( 27 | options.data, 28 | options.headers['content-type'] 29 | ); 30 | 31 | const xhr = new XMLHttpRequest(); 32 | 33 | xhr.open(options.method, url); 34 | 35 | setHeaders(xhr, options.headers); 36 | 37 | xhr.onload = () => { 38 | if (xhr.statusText !== 'OK') { 39 | reject(tryResult( 40 | () => JSON.parse(xhr.responseText), 41 | () => xhr.responseText 42 | )); 43 | } else { 44 | resolve(JSON.parse(xhr.responseText)); 45 | } 46 | }; 47 | 48 | xhr.send(data); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/helpers/fetchResource/normalizeHeaders.js: -------------------------------------------------------------------------------- 1 | import mapKeys from '../mapKeys'; 2 | 3 | export default function normalizeHeaders(headers = {}) { 4 | return mapKeys(headers, key => key.toLowerCase().trim()); 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/fetchResource/serializeDataForContentType.js: -------------------------------------------------------------------------------- 1 | import serializeFormData from './serializeFormData'; 2 | 3 | export default function serializeDataForContentType(data, contentType) { 4 | if (!data) { 5 | return; 6 | } 7 | 8 | if (contentType === 'application/x-www-form-urlencoded') { 9 | return serializeFormData(data); 10 | } 11 | 12 | return JSON.stringify(data); 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/fetchResource/serializeFormData.js: -------------------------------------------------------------------------------- 1 | export default function serializeFormData(data) { 2 | return Object.keys(data).map((key) => ( 3 | `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}` 4 | )).join('&'); 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/fetchResource/setHeaders.js: -------------------------------------------------------------------------------- 1 | export default function setHeaders(xhr, headers) { 2 | Object.keys(headers).forEach((key) => { 3 | xhr.setRequestHeader(key, headers[key]); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/includes.js: -------------------------------------------------------------------------------- 1 | export default function includes(array, value) { 2 | return array.indexOf(value) > -1; 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/mapKeys.js: -------------------------------------------------------------------------------- 1 | export default function mapKeys(object, fn) { 2 | return Object.keys(object).reduce((memo, key) => { 3 | memo[fn(key)] = object[key]; 4 | return memo; 5 | }, {}); 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers/omit.js: -------------------------------------------------------------------------------- 1 | import includes from './includes'; 2 | 3 | export default function omit(object, omittedKeys) { 4 | return Object.keys(object).reduce((memo, key) => { 5 | if (!includes(omittedKeys, key)) { 6 | memo[key] = object[key]; 7 | } 8 | 9 | return memo; 10 | }, {}); 11 | } 12 | -------------------------------------------------------------------------------- /src/helpers/parseUrl.js: -------------------------------------------------------------------------------- 1 | import splitParams from './splitParams'; 2 | 3 | export default function parseUrl(url) { 4 | const regex = /:\w+/g; 5 | const statics = url.split(regex); 6 | const paramKeys = (url.match(regex) || []) 7 | .map((paramId) => paramId.replace(':', '')); 8 | 9 | return (params) => { 10 | const split = splitParams(params || {}, paramKeys); 11 | const pathParams = split[0]; 12 | const queryParams = split[1]; 13 | 14 | const finalUrl = statics.reduce((memo, piece, i) => { 15 | const paramKey = paramKeys[i] || ''; 16 | const paramId = paramKey ? ':' + paramKey : ''; 17 | 18 | return memo + piece + (pathParams[paramKey] || paramId); 19 | }, ''); 20 | 21 | if (queryParams) { 22 | return finalUrl + '?' + queryParams; 23 | } 24 | 25 | return finalUrl; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/helpers/splitParams.js: -------------------------------------------------------------------------------- 1 | import includes from './includes'; 2 | 3 | export default function splitParams(params, pathParamKeys) { 4 | const pathParams = {}; 5 | const queryParams = []; 6 | 7 | Object.keys(params).forEach((paramKey) => { 8 | if (includes(pathParamKeys, paramKey)) { 9 | pathParams[paramKey] = params[paramKey]; 10 | } else { 11 | queryParams.push(`${paramKey}=${encodeURIComponent(params[paramKey])}`); 12 | } 13 | }); 14 | 15 | return [pathParams, queryParams.join('&')]; 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/tryResult.js: -------------------------------------------------------------------------------- 1 | export default function tryResult(expressionFn, catchExpression) { 2 | try { 3 | return expressionFn(); 4 | } catch (e) { 5 | return catchExpression(e); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createResourceAction from './createResourceAction'; 2 | import resourceMiddleware from './resourceMiddleware'; 3 | 4 | export { 5 | createResourceAction, 6 | resourceMiddleware 7 | }; 8 | -------------------------------------------------------------------------------- /src/resourceMiddleware.js: -------------------------------------------------------------------------------- 1 | export default from 'redux-thunk'; 2 | -------------------------------------------------------------------------------- /test/support/httpRequestTest.js: -------------------------------------------------------------------------------- 1 | import '../support/jsdom'; 2 | import test from 'tape'; 3 | import sinon from 'sinon'; 4 | 5 | export default function httpRequestTest(name, fn) { 6 | test(name, t => { 7 | const dispatch = sinon.spy(); 8 | const xhr = sinon.useFakeXMLHttpRequest(); 9 | const requests = []; 10 | 11 | xhr.onCreate = (xhr) => requests.push(xhr); 12 | 13 | fn(t, () => requests.slice(0), dispatch); 14 | 15 | xhr.restore(); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /test/support/jsdom.js: -------------------------------------------------------------------------------- 1 | import { jsdom } from 'jsdom'; 2 | 3 | global.document = jsdom(''); 4 | global.window = document.defaultView; 5 | global.navigator = global.window.navigator; 6 | global.XMLHttpRequest = global.window.XMLHttpRequest; 7 | -------------------------------------------------------------------------------- /test/tests/createResourceAction.test.js: -------------------------------------------------------------------------------- 1 | import test from '../support/httpRequestTest'; 2 | import createResourceAction from '../../src/createResourceAction'; 3 | 4 | const users = { 5 | user: '/users/:id', 6 | create: { 7 | url: '/users/create', 8 | method: 'POST' 9 | } 10 | }; 11 | 12 | users.createFromForm = { 13 | ...users.create, 14 | headers: { 15 | 'Content-Type': 'application/x-www-form-urlencoded', 16 | 'X-Custom': 'hello world' 17 | } 18 | }; 19 | 20 | const fetchUser = createResourceAction( 21 | users.user, 'FETCH_USER', 'RECEIVE_USER', 'ERR_RECEIVE_USER' 22 | ); 23 | 24 | const createUser = createResourceAction( 25 | users.create, 'CREATE_USER', 'CREATED_USER', 'ERR_CREATING_USER' 26 | ); 27 | 28 | const createUserFromForm = createResourceAction( 29 | users.createFromForm, 'CREATE_USER', 'CREATED_USER', 'ERR_CREATING_USER' 30 | ); 31 | 32 | const params = { id: 1 }; 33 | 34 | test('making a request with the default verb', (t, getRequests, dispatch) => { 35 | fetchUser(params)(dispatch); 36 | 37 | const requests = getRequests(); 38 | const [req] = requests; 39 | 40 | t.equal(requests.length, 1, 'made the request'); 41 | t.equal(req.url, `/users/${params.id}`, 'hit correct url'); 42 | t.equal(req.method, 'GET', 'made a GET request'); 43 | 44 | t.end(); 45 | }); 46 | 47 | test('making a request with an explicit verb', (t, getRequests, dispatch) => { 48 | createUser()(dispatch); 49 | 50 | const requests = getRequests(); 51 | const [req] = requests; 52 | 53 | t.equal(requests.length, 1, 'made the request'); 54 | t.equal(req.url, '/users/create', 'hit correct url'); 55 | t.equal(req.method, 'POST', 'made a POST request'); 56 | 57 | t.end(); 58 | }); 59 | 60 | test('making a request with an explicit verb and headers', 61 | (t, getRequests, dispatch) => { 62 | createUserFromForm()(dispatch); 63 | 64 | const requests = getRequests(); 65 | const [req] = requests; 66 | 67 | t.equal(requests.length, 1, 'made the request'); 68 | t.equal(req.url, '/users/create', 'hit correct url'); 69 | t.equal(req.method, 'POST', 'made a POST request'); 70 | 71 | t.deepEqual( 72 | Object.keys(req.requestHeaders), 73 | ['content-type', 'x-custom'], 74 | 'uses the supplied headers' 75 | ); 76 | 77 | t.ok( 78 | /application\/x-www-form-urlencoded/.test( 79 | req.requestHeaders['content-type'] 80 | ), 81 | 'uses the form content type header' 82 | ); 83 | 84 | t.equal( 85 | req.requestHeaders['x-custom'], 86 | 'hello world', 87 | 'uses the custom header' 88 | ); 89 | 90 | t.end(); 91 | } 92 | ); 93 | 94 | test('dispatching the send action', (t, getRequests, dispatch) => { 95 | fetchUser(params)(dispatch); 96 | 97 | t.ok(dispatch.calledOnce, 'called once'); 98 | 99 | t.ok( 100 | dispatch.calledWith({ type: 'FETCH_USER' }), 101 | 'dispatches send action' 102 | ); 103 | 104 | t.end(); 105 | }); 106 | 107 | test('dispatching the resource upon success', async (t, getRequests, dispatch) => { 108 | const promise = fetchUser(params)(dispatch); 109 | const [req] = getRequests(); 110 | const user = { id: params.id, name: 'Jeremy' }; 111 | 112 | req.respond( 113 | 200, 114 | { 'Content-Type': 'application/json' }, 115 | JSON.stringify(user) 116 | ); 117 | 118 | await promise; 119 | 120 | t.ok(dispatch.calledTwice, 'called twice'); 121 | 122 | t.ok( 123 | dispatch.calledWith({ type: 'RECEIVE_USER', payload: user }), 124 | 'receives resource' 125 | ); 126 | 127 | t.end(); 128 | }); 129 | 130 | test('dispatching an error upon failure', async (t, getRequests, dispatch) => { 131 | const promise = fetchUser(params)(dispatch); 132 | const [req] = getRequests(); 133 | const error = { message: 'Internal Server Error' }; 134 | 135 | req.respond( 136 | 500, 137 | { 'Content-Type': 'application/json' }, 138 | JSON.stringify(error) 139 | ); 140 | 141 | await promise; 142 | 143 | t.ok(dispatch.calledTwice, 'called twice'); 144 | 145 | t.ok( 146 | dispatch.calledWith({ type: 'ERR_RECEIVE_USER', payload: error }), 147 | 'receives error' 148 | ); 149 | 150 | t.end(); 151 | }); 152 | 153 | test('sending request body data', (t, getRequests, dispatch) => { 154 | const data = { name: 'Jeremy' }; 155 | 156 | createUser(null, data)(dispatch); 157 | 158 | const [req] = getRequests(); 159 | 160 | t.deepEqual(JSON.parse(req.requestBody), data, 'called with request body'); 161 | 162 | t.end(); 163 | }); 164 | 165 | test('sending request body form data', (t, getRequests, dispatch) => { 166 | const data = { name: 'Jeremy' }; 167 | 168 | createUserFromForm(null, data)(dispatch); 169 | 170 | const [req] = getRequests(); 171 | 172 | t.equal( 173 | req.requestBody, 174 | 'name=Jeremy', 175 | 'called with request body' 176 | ); 177 | 178 | t.end(); 179 | }); 180 | -------------------------------------------------------------------------------- /test/tests/helpers/assignPolyfill.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import assign from '../../../src/helpers/assignPolyfill'; 3 | 4 | test('assigning from one object', t => { 5 | const source = { hello: 'world' }; 6 | 7 | t.deepEqual(assign({}, source), source); 8 | 9 | t.end(); 10 | }); 11 | 12 | test('assigning from two objects', t => { 13 | const source1 = { hello: 'world' }; 14 | const source2 = { hola: 'mundo' }; 15 | 16 | t.deepEqual(assign({}, source1, source2), { 17 | hello: 'world', 18 | hola: 'mundo' 19 | }); 20 | 21 | t.end(); 22 | }); 23 | 24 | test('mutation', t => { 25 | const object = {}; 26 | const source = { hello: 'world' }; 27 | 28 | assign(object, source); 29 | 30 | t.deepEqual(object, source); 31 | 32 | t.end(); 33 | }); 34 | -------------------------------------------------------------------------------- /test/tests/helpers/fetchResource/fetchResource.test.js: -------------------------------------------------------------------------------- 1 | import test from '../../../support/httpRequestTest.js'; 2 | import fetchResource from 'src/helpers/fetchResource'; 3 | 4 | const URL = '/user'; 5 | 6 | test('making a request with the default options', (t, getRequests) => { 7 | fetchResource(URL); 8 | 9 | const requests = getRequests(); 10 | const [req] = requests; 11 | 12 | t.equal(requests.length, 1, 'make the request'); 13 | t.equal(req.url, URL, 'make the request to correct url'); 14 | t.equal(req.method, 'GET', 'make a GET request'); 15 | 16 | t.deepEqual( 17 | Object.keys(req.requestHeaders), 18 | ['content-type'], 19 | 'uses the defaults headers' 20 | ); 21 | 22 | t.ok( 23 | /application\/json/.test( 24 | req.requestHeaders['content-type'] 25 | ), 26 | 'defaults to json content type' 27 | ); 28 | 29 | t.end(); 30 | }); 31 | 32 | test('making a request with an explicit verb', (t, getRequests) => { 33 | fetchResource(URL, { method: 'POST' }); 34 | 35 | const requests = getRequests(); 36 | const [req] = requests; 37 | 38 | t.equal(requests.length, 1, 'make the request'); 39 | t.equal(req.url, URL, 'make the request to correct url'); 40 | t.equal(req.method, 'POST', 'make a POST request'); 41 | 42 | t.deepEqual( 43 | Object.keys(req.requestHeaders), 44 | ['content-type'], 45 | 'uses the defaults headers' 46 | ); 47 | 48 | t.ok( 49 | /application\/json/.test( 50 | req.requestHeaders['content-type'] 51 | ), 52 | 'defaults to json content type' 53 | ); 54 | 55 | t.end(); 56 | }); 57 | 58 | test('making request with headers', (t, getRequests) => { 59 | fetchResource(URL, { 60 | headers: { 61 | 'Content-Type': 'text/plain', 62 | 'X-Custom': 'hello' 63 | } 64 | }); 65 | 66 | const [req] = getRequests(); 67 | 68 | t.deepEqual( 69 | req.requestHeaders, 70 | { 'content-type': 'text/plain', 'x-custom': 'hello' }, 71 | 'uses the supplied headers' 72 | ); 73 | 74 | t.end(); 75 | }); 76 | 77 | test('sending request body data', (t, getRequests) => { 78 | const data = { name: 'Jeremy' }; 79 | 80 | fetchResource(URL, { method: 'POST', data }); 81 | 82 | const [req] = getRequests(); 83 | 84 | t.deepEqual(JSON.parse(req.requestBody), data, 'call with request body'); 85 | 86 | t.end(); 87 | }); 88 | 89 | test('sending request body form data', (t, getRequests) => { 90 | const data = { name: 'Jeremy' }; 91 | 92 | fetchResource(URL, { 93 | data, 94 | method: 'POST', 95 | headers: { 96 | 'Content-Type': 'application/x-www-form-urlencoded' 97 | } 98 | }); 99 | 100 | const [req] = getRequests(); 101 | 102 | t.equal( 103 | req.requestBody, 104 | 'name=Jeremy', 105 | 'called with request body' 106 | ); 107 | 108 | t.end(); 109 | }); 110 | -------------------------------------------------------------------------------- /test/tests/helpers/fetchResource/normalizeHeaders.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import normalizeHeaders from 'src/helpers/fetchResource/normalizeHeaders'; 3 | 4 | test('keys become lower case', t => { 5 | const headers = { 6 | 'Content-Type': 'application/json', 7 | 'ACCEPT': '*/*', 8 | 'host': 'localhost' 9 | }; 10 | 11 | t.deepEqual( 12 | Object.keys(normalizeHeaders(headers)), 13 | ['content-type', 'accept', 'host'] 14 | ); 15 | 16 | t.end(); 17 | }); 18 | -------------------------------------------------------------------------------- /test/tests/helpers/fetchResource/serializeDataForContentType.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | 3 | import serializeDataForContentType 4 | from 'src/helpers/fetchResource/serializeDataForContentType'; 5 | 6 | test('serializing form data', t => { 7 | const contentType = 'application/x-www-form-urlencoded'; 8 | const data = { hello: 'world' }; 9 | 10 | t.equal( 11 | serializeDataForContentType(data, contentType), 12 | 'hello=world' 13 | ); 14 | 15 | t.end(); 16 | }); 17 | 18 | test('serializing json data', t => { 19 | const contentType = 'application/json'; 20 | const data = { hello: 'world' }; 21 | 22 | t.equal( 23 | serializeDataForContentType(data, contentType), 24 | JSON.stringify(data) 25 | ); 26 | 27 | t.end(); 28 | }); 29 | -------------------------------------------------------------------------------- /test/tests/helpers/fetchResource/serializeFormData.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import serializeFormData from 'src/helpers/fetchResource/serializeFormData'; 3 | 4 | test('serializing form data', t => { 5 | const data = { hello: 'world', hola: 'mundo' }; 6 | 7 | t.equal( 8 | serializeFormData(data), 9 | 'hello=world&hola=mundo' 10 | ); 11 | 12 | t.end(); 13 | }); 14 | 15 | test('encoding special chars', t => { 16 | const data = { '@hello': '#world' }; 17 | 18 | t.equal( 19 | serializeFormData(data), 20 | '%40hello=%23world' 21 | ); 22 | 23 | t.end(); 24 | }); 25 | -------------------------------------------------------------------------------- /test/tests/helpers/fetchResource/setHeaders.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import sinon from 'sinon'; 3 | import setHeaders from 'src/helpers/fetchResource/setHeaders'; 4 | 5 | test('setting headers', t => { 6 | const xhr = { 7 | setRequestHeader() {} 8 | }; 9 | 10 | const spy = sinon.spy(xhr, 'setRequestHeader'); 11 | 12 | const headers = { 13 | hello: 'world', 14 | hola: 'mundo' 15 | }; 16 | 17 | setHeaders(xhr, headers); 18 | 19 | t.deepEqual( 20 | spy.args, 21 | [ 22 | ['hello', 'world'], 23 | ['hola', 'mundo'] 24 | ] 25 | ); 26 | 27 | t.end(); 28 | }); 29 | -------------------------------------------------------------------------------- /test/tests/helpers/includes.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import includes from '../../../src/helpers/includes'; 3 | 4 | test('returns true if the value exists in the array', t => { 5 | t.ok(includes([1, 2, 3], 1)); 6 | t.end(); 7 | }); 8 | 9 | test('checks based on strict equality', t => { 10 | t.notOk(includes([1, 2, 3], '1')); 11 | t.end(); 12 | }); 13 | 14 | test('returns false if the value is not in the array', t => { 15 | t.notOk(includes([1, 2, 3], 4)); 16 | t.end(); 17 | }); 18 | -------------------------------------------------------------------------------- /test/tests/helpers/mapKeys.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import mapKeys from 'src/helpers/mapKeys'; 3 | 4 | test('transforming object with keys', t => { 5 | const before = { FOO: 'BAR', BAZ: 42 }; 6 | const after = { foo: 'BAR', baz: 42 }; 7 | 8 | t.deepEqual( 9 | mapKeys(before, key => key.toLowerCase()), 10 | after 11 | ); 12 | 13 | t.end(); 14 | }); 15 | 16 | test('transforming empty object', t => { 17 | t.deepEqual( 18 | mapKeys({}, key => key.toUpperCase()), 19 | {} 20 | ); 21 | 22 | t.end(); 23 | }); 24 | -------------------------------------------------------------------------------- /test/tests/helpers/omit.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import omit from '../../../src/helpers/omit'; 3 | 4 | test('removing excluded keys', t => { 5 | t.deepEqual( 6 | omit({ hello: 'world', hola: 'mundo' }, ['hello']), 7 | { hola: 'mundo' } 8 | ); 9 | 10 | t.end(); 11 | }); 12 | 13 | test('removing nothing for no keys', t => { 14 | const object = { hello: 'world', hola: 'mundo' }; 15 | 16 | t.deepEqual(omit(object, []), object); 17 | 18 | t.end(); 19 | }); 20 | -------------------------------------------------------------------------------- /test/tests/helpers/parseUrl.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import parseUrl from '../../../src/helpers/parseUrl'; 3 | 4 | test('returns a compiled function for generating a url', t => { 5 | t.ok(parseUrl('/users') instanceof Function); 6 | t.end(); 7 | }); 8 | 9 | test('parsing without placeholders', t => { 10 | const urlCompiler = parseUrl('/users'); 11 | 12 | t.equal(urlCompiler(), '/users'); 13 | 14 | t.end(); 15 | }); 16 | 17 | test('parsing with placeholder', t => { 18 | const urlCompiler = parseUrl('/users/:id'); 19 | 20 | t.equal(urlCompiler({ id: 1 }), '/users/1', 'Uses id'); 21 | 22 | t.end(); 23 | }); 24 | 25 | 26 | test('parsing with multiple placeholders', t => { 27 | const urlCompiler = parseUrl('/users/:userId/tasks/:taskId'); 28 | const params = { userId: 1, taskId: 2 }; 29 | 30 | t.equal(urlCompiler(params), '/users/1/tasks/2', 'Uses userId and taskId'); 31 | 32 | t.end(); 33 | }); 34 | 35 | test('passing non-named params', t => { 36 | t.equal( 37 | parseUrl('/users')({ id: 1 }), '/users?id=1', 'Without named parameters' 38 | ); 39 | 40 | t.equal( 41 | parseUrl('/users/:id')({ id: 1, name: 'Jeremy' }), 42 | '/users/1?name=Jeremy', 43 | 'With named parameter' 44 | ); 45 | 46 | t.end(); 47 | }); 48 | -------------------------------------------------------------------------------- /test/tests/helpers/splitParams.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import splitParams from '../../../src/helpers/splitParams'; 3 | 4 | test('splitting into named and query parameters', t => { 5 | const params = { id: 1, firstName: 'Jeremy', lastName: 'Fairbank' }; 6 | const namedParamKeys = ['id']; 7 | const [namedParams, queryParams] = splitParams(params, namedParamKeys); 8 | 9 | t.deepEqual(namedParams, { id: 1 }); 10 | t.equal(queryParams, 'firstName=Jeremy&lastName=Fairbank'); 11 | 12 | t.end(); 13 | }); 14 | 15 | test('with no named parameters', t => { 16 | const params = { id: 1, name: 'Jeremy' }; 17 | const [namedParams, queryParams] = splitParams(params, []); 18 | 19 | t.deepEqual(namedParams, {}); 20 | t.equal(queryParams, 'id=1&name=Jeremy'); 21 | 22 | t.end(); 23 | }); 24 | 25 | test('with no query params', t => { 26 | const params = { id: 1, name: 'Jeremy' }; 27 | const namedParamKeys = ['id', 'name']; 28 | const [namedParams, queryParams] = splitParams(params, namedParamKeys); 29 | 30 | t.deepEqual(namedParams, { ...params }); 31 | t.equal(queryParams, ''); 32 | 33 | t.end(); 34 | }); 35 | -------------------------------------------------------------------------------- /test/tests/helpers/tryResult.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import tryResult from '../../../src/helpers/tryResult'; 3 | 4 | test('returning the value when no error is thrown', t => { 5 | t.equal(tryResult(() => 42), 42); 6 | t.end(); 7 | }); 8 | 9 | test('returning the catch value when an error is thrown', t => { 10 | const expressionFn = () => { throw new Error('error'); }; 11 | const catchFn = (e) => e.message; 12 | 13 | t.equal(tryResult(expressionFn, catchFn), 'error'); 14 | 15 | t.end(); 16 | }); 17 | --------------------------------------------------------------------------------