├── .babelrc ├── .gitignore ├── .npmignore ├── Makefile ├── Readme.md ├── package.json ├── src ├── fetchEncodeJSON.js └── index.js └── test ├── fetchEncodeJSONSpec.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: ["es2015", "stage-1"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | lib -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Vars 3 | # 4 | 5 | BIN = node_modules/.bin/ 6 | .DEFAULT_GOAL := all 7 | 8 | # 9 | # Tasks 10 | # 11 | 12 | validate: 13 | @${BIN}/standard 14 | 15 | test: 16 | ${BIN}/babel-tape-runner test/*.js 17 | 18 | all: validate test 19 | 20 | .PHONY: validate test -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # redux-effects-fetch 2 | 3 | Declarative data fetching for [redux](https://github.com/rackt/redux). Build on top of [isomorphic-fetch](https://github.com/matthew-andrews/isomorphic-fetch) so it works everywhere. 4 | 5 | ## Installation 6 | 7 | `npm install redux-effects-fetch` 8 | 9 | ## Usage 10 | 11 | This package is designed to be used in conjunction with [redux-effects](https://github.com/redux-effects/redux-effects). Install it like this: 12 | 13 | ```javascript 14 | import effects from 'redux-effects' 15 | import fetch, { fetchEncodeJSON } from 'redux-effects-fetch' 16 | 17 | // fetchEncodeJSON is optional 18 | applyMiddleware(effects, fetch, fetchEncodeJSON)(createStore) 19 | ``` 20 | 21 | This will enable your middleware to support fetch actions. 22 | 23 | ## Actions 24 | 25 | You can create your own action creators for this package, or you can use the one that comes bundled with it. The action format is simple: 26 | 27 | ```javascript 28 | { 29 | type: 'EFFECT_FETCH', 30 | payload: { 31 | url, 32 | params 33 | } 34 | } 35 | ``` 36 | 37 | Where `url` and `params` are what you would pass as the first and second arguments to the native `fetch` API. If you want your action creators to support some async flow control, you should use [redux-effects](https://github.com/redux-effects/redux-effects)' `bind` function. If you do, your fetch action will return you an object with the following properties: 38 | 39 | * `url` - The url of the endpoint you requested (as returned by the request) 40 | * `status` - The numerical status code of the response (e.g. 200) 41 | * `statusText` - The text version of the status (e.g. 'OK') 42 | * `headers` - A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers) 43 | * `value` - The deserialized value of the response. This may be an object or string, depending on the type of response (json or text). 44 | 45 | ## Examples 46 | 47 | ### Creating a user 48 | 49 | ```javascript 50 | import {bind} from 'redux-effects' 51 | import {fetch} from 'redux-effects-fetch' 52 | import {createAction} from 'redux-actions' 53 | 54 | function signupUser (user) { 55 | return bind(fetch(api + '/user', { 56 | method: 'POST', 57 | body: user 58 | }), ({value}) => userDidLogin(value), ({value}) => setError(value)) 59 | } 60 | 61 | const userDidLogin = createAction('USER_DID_LOGIN') 62 | const setError = createAction('SET_ERROR') 63 | ``` 64 | 65 | This works exactly as if you were working with the native `fetch` API, except your request is actually being executed by middleware. 66 | 67 | ### Handling loading states 68 | 69 | For this I recommend the use of [redux-multi](https://github.com/ashaffer/redux-multi), which allows you to dispatch more than one action at a time. 70 | 71 | ```javascript 72 | import {bind} from 'redux-effects' 73 | import {fetch} from 'redux-effects-fetch' 74 | import {createAction} from 'redux-actions' 75 | 76 | function signupUser (user) { 77 | return [ 78 | signupIsLoading(), 79 | bind(fetch(api + '/user', { 80 | method: 'POST', 81 | body: user 82 | }), ({value}) => userDidLogin(value), ({value}) => setError(value)) 83 | ] 84 | } 85 | 86 | const signupIsLoading = createAction('SIGNUP_IS_LOADING') 87 | const userDidLogin = createAction('USER_DID_LOGIN') 88 | const setError = createAction('SET_ERROR') 89 | ``` 90 | 91 | ## Local development 92 | 93 | If you want to develope your frontend application without any REST server running, 94 | you can use [redux-effects-fetch-fixture](https://github.com/team-boris/redux-effects-fetch-fixture) to define 95 | fixtures for your `fetch` requests. 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-effects-fetch", 3 | "version": "0.5.5", 4 | "description": "Declarative data-fetching for redux", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "babel-tape-runner test/*.js | tap-spec", 8 | "prepublish": "rm -rf lib && babel src --out-dir lib", 9 | "postpublish": "rm -rf lib" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/redux-effects/redux-effects-fetch" 14 | }, 15 | "keywords": [ 16 | "redux", 17 | "fetch" 18 | ], 19 | "author": "ashaffer (http://github.com/ashaffer)", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/redux-effects/redux-effects-fetch/issues" 23 | }, 24 | "homepage": "https://github.com/redux-effects/redux-effects-fetch", 25 | "dependencies": { 26 | "es6-promise": "^3.0.2", 27 | "isomorphic-fetch": "^2.1.1" 28 | }, 29 | "devDependencies": { 30 | "babel-cli": "^6.6.5", 31 | "babel-preset-es2015": "^6.3.13", 32 | "babel-preset-stage-1": "^6.5.0", 33 | "babel-tape-runner": "^2.0.0", 34 | "lodash": "^4.8.1", 35 | "tap-spec": "^4.1.1", 36 | "tape": "^4.2.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/fetchEncodeJSON.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Imports 3 | */ 4 | 5 | import {FETCH} from '.' 6 | 7 | /** 8 | * @see https://github.com/lodash/lodash/blob/4.8.0/lodash.js#L10705 9 | * @see https://lodash.com/docs#isObject 10 | */ 11 | 12 | function isObject(value) { 13 | const type = typeof value 14 | return !!value && (type == 'object' || type == 'function') 15 | } 16 | 17 | /** 18 | * A middleware which automatically converts object bodies to JSON for fetch effects. 19 | * 20 | * The middleware intercepts all fetch actions, and encodes their body to JSON if 21 | * 22 | * - the request has a `Content-Type: application/json` and 23 | * - the body is an object. 24 | * 25 | * In this case it also adds an `Accept: application/json` header but only if there's no other `Accept` header yet. 26 | * 27 | * Hook into **before** the regular fetch middleware. 28 | */ 29 | 30 | function fetchEncodeJSON () { 31 | return next => action => action.type === FETCH 32 | ? next(maybeConvertBodyToJSON(action)) 33 | : next(action) 34 | } 35 | 36 | /** 37 | * Whether we may convert a request with the given params to JSON. 38 | */ 39 | 40 | function shallConvertToJSON (params) { 41 | return isObject(params.body) 42 | } 43 | 44 | /** 45 | * Add an accept header if necessary. 46 | * 47 | * Add an `Accept: application/json` header to the given headers but only if they don't already contain an `Accept` 48 | * header. 49 | */ 50 | 51 | function maybeAddAcceptHeader (headers = {}) { 52 | return headers.hasOwnProperty('Accept') 53 | ? headers 54 | : {'Accept': 'application/json', 'Content-Type': 'application/json;charset=UTF-8', ...headers} 55 | } 56 | 57 | function maybeConvertBodyToJSON (action) { 58 | const {payload} = action 59 | 60 | if (shallConvertToJSON(payload.params)) { 61 | const body = JSON.stringify(payload.params.body) 62 | const headers = maybeAddAcceptHeader(payload.params.headers) 63 | const params = {...payload.params, body, headers} 64 | const result = {...action, payload: {...payload, params: params}} 65 | 66 | return result 67 | } else { 68 | return action 69 | } 70 | } 71 | 72 | /** 73 | * Exports 74 | */ 75 | 76 | export default fetchEncodeJSON 77 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Imports 3 | */ 4 | 5 | import 'isomorphic-fetch' 6 | import fetchEncodeJSON from './fetchEncodeJSON' 7 | 8 | /** 9 | * Action types 10 | */ 11 | 12 | const FETCH = 'EFFECT_FETCH' 13 | 14 | /** 15 | * Fetch middleware 16 | */ 17 | 18 | function fetchMiddleware ({dispatch, getState}) { 19 | return next => action => 20 | action.type === FETCH 21 | ? g().fetch(action.payload.url, action.payload.params) 22 | .then(checkStatus) 23 | .then(createResponse, createErrorResponse) 24 | : next(action) 25 | } 26 | 27 | /** 28 | * g - Return the global object (in the browser or node) 29 | */ 30 | 31 | function g () { 32 | return typeof window === 'undefined' 33 | ? global 34 | : window 35 | } 36 | 37 | /** 38 | * Create a plain JS response object. Note that 'headers' is still a Headers 39 | * object (https://developer.mozilla.org/en-US/docs/Web/API/Headers), and must be 40 | * read using that API. 41 | */ 42 | 43 | function createResponse (res) { 44 | return deserialize(res).then(value => ({ 45 | url: res.url, 46 | status: res.status, 47 | statusText: res.statusText, 48 | headers: res.headers, 49 | value: value 50 | }), err => { 51 | throw { 52 | value: err 53 | } 54 | }) 55 | } 56 | 57 | /** 58 | * Create the response, then return a new rejected 59 | * promise so the failure chain stays failed. 60 | */ 61 | 62 | function createErrorResponse (res) { 63 | const q = res.headers 64 | ? createResponse(res) 65 | : Promise.resolve(res) 66 | 67 | return q.then(function (res) { throw res }) 68 | } 69 | 70 | /** 71 | * Deserialize the request body 72 | */ 73 | 74 | function deserialize (res) { 75 | const header = res.headers.get('Content-Type') || '' 76 | if (header.indexOf('application/json') > -1) return res.json() 77 | if (header.indexOf('application/ld+json') > -1) return res.json() 78 | if (header.indexOf('application/octet-stream') > -1) return res.arrayBuffer() 79 | return res.text() 80 | } 81 | 82 | /** 83 | * Check the status and reject the promise if it's not in the 200 range 84 | */ 85 | 86 | function checkStatus (res) { 87 | if (res.status >= 200 && res.status < 300) { 88 | return res 89 | } else { 90 | throw res 91 | } 92 | } 93 | 94 | /** 95 | * Action creator 96 | */ 97 | 98 | function fetchActionCreator (url = '', params = {}) { 99 | return { 100 | type: FETCH, 101 | payload: { 102 | url, 103 | params 104 | } 105 | } 106 | } 107 | 108 | /** 109 | * Exports 110 | */ 111 | 112 | export default fetchMiddleware 113 | export { 114 | fetchActionCreator as fetch, 115 | FETCH, 116 | fetchEncodeJSON 117 | } 118 | -------------------------------------------------------------------------------- /test/fetchEncodeJSONSpec.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import {omit} from 'lodash/object' 3 | import {fetch, fetchEncodeJSON} from '../src' 4 | 5 | 6 | const run = (action, cb) => fetchEncodeJSON()(cb)(action) 7 | 8 | 9 | test('should ignore other actions', t => { 10 | const action = { 11 | type: 'NO_FETCH', 12 | payload: {} 13 | } 14 | 15 | run(action, newAction => { 16 | t.equal(newAction, action) 17 | t.end() 18 | }) 19 | }) 20 | 21 | test('non-JSON requests', t => { 22 | const action = fetch('/foo', {method: 'POST', headers: {'Content-Type': 'text/plain'}, body: 'foo'}) 23 | run(action, newAction => { 24 | t.equal(newAction, action) 25 | t.end() 26 | }) 27 | }) 28 | 29 | 30 | test('should ignore requests whose body is a string', t => { 31 | const action = fetch('/foo', { 32 | method: 'POST', 33 | headers: {'Content-Type': 'application/json'}, 34 | body: 'foo' 35 | }) 36 | run(action, newAction => { 37 | t.equal(newAction, action) 38 | t.end() 39 | }) 40 | }) 41 | 42 | test('should encode the body if it is an object', t => { 43 | const request = { 44 | method: 'POST', 45 | body: {message: 'Hello world!'} 46 | } 47 | const action = fetch('/foo', request) 48 | const payload = action.payload 49 | 50 | run(action, newAction => { 51 | t.equal(newAction.payload.params.body,JSON.stringify({message: 'Hello world!'})) 52 | t.same(omit(newAction.payload.params, ['body', 'headers']), omit(payload.params, ['body'])) 53 | t.same(omit(newAction.payload, ['params']), omit(payload, ['params'])) 54 | t.end() 55 | }) 56 | }) 57 | 58 | test('should add an accept header if it encoded the object', t => { 59 | const request = { 60 | method: 'POST', 61 | body: {message: 'Hello world!'}, 62 | headers: { 63 | 'Content-Type': 'application/json', 64 | 'X-Foo': 'bar' 65 | } 66 | } 67 | const action = fetch('/foo', request) 68 | const payload = action.payload 69 | run(action, newAction => { 70 | t.same(newAction.payload.params.headers, { 71 | 'X-Foo': 'bar', 72 | 'Content-Type': 'application/json', 73 | 'Accept': 'application/json' 74 | }) 75 | // Ensure that the payload is otherwise unmodified 76 | t.same(omit(newAction.payload.params, ['body', 'headers']), omit(payload.params, ['body', 'headers'])) 77 | t.same(omit(newAction.payload, ['params']), omit(payload, ['params'])) 78 | t.end() 79 | }) 80 | }) 81 | 82 | test('should leave an existing accept header untouched', t => { 83 | const request = { 84 | method: 'POST', 85 | body: {message: 'foo'}, 86 | headers: { 87 | 'Content-Type': 'application/json', 88 | 'Accept': 'text/plain' 89 | } 90 | } 91 | const action = fetch('/foo', request) 92 | 93 | run(action, newAction => { 94 | t.same(newAction.payload.params.body,JSON.stringify({message: 'foo'})) 95 | t.same(newAction.payload.params.headers, { 96 | 'Content-Type': 'application/json', 97 | 'Accept': 'text/plain' 98 | }) 99 | t.end() 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Imports 3 | */ 4 | 5 | import test from 'tape' 6 | import fetchMw, {fetch} from '../src' 7 | 8 | /** 9 | * Setup 10 | */ 11 | 12 | const api = { 13 | dispatch: () => {}, 14 | getState: () => ({}) 15 | } 16 | 17 | const run = fetchMw(api)(() => {}) 18 | 19 | /** 20 | * Tests 21 | */ 22 | 23 | test('should work', t => { 24 | run(fetch('https://www.google.com')).then(({url, headers, value, status, statusText}) => { 25 | t.equal(url, 'https://www.google.com') 26 | t.equal(status, 200) 27 | t.equal(statusText, 'OK') 28 | t.ok(headers.get('content-type').indexOf('text/html') !== -1) 29 | t.end() 30 | }) 31 | }) 32 | 33 | test('should reject on invalid response', t => { 34 | t.plan(1) 35 | run(fetch('https://www.google.com/notAValidUrl')).then(() => t.fail(), (res) => t.pass()) 36 | }) 37 | --------------------------------------------------------------------------------