diff --git a/README.md b/README.md index 18b4cd21..85af2035 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,53 @@ The result of the promise returned by `mutateAsync` will be the following object Similarly to how mutations are triggered by dispatching `mutateAsync` actions, you can trigger requests by dispatching `requestAsync` actions with a request query config. +### Usage without superagent with `redux-query/advanced` + +By default, `redux-query` makes XHR requests using the [superagent](https://github.com/visionmedia/superagent) library. If you'd rather use a different library for making requests, you can use the `redux-query`'s "advanced" mode by importing from `redux-query/advanced` instead of `redux-query`. + +Note: The default [`queryMiddleware`](./src/middleware/query.js) exported from the main `redux-query` entry point is simply a [superagent adapter](./src/adapters/superagent.js) bound to `queryMiddlewareAdvanced`. + +Example `queryMiddlewareAdvanced` usage: + +```javascript +import { applyMiddleware, createStore, combineReducers } from 'redux'; +import { entitiesReducer, queriesReducer, queryMiddlewareAdvanced } from 'redux-query/advanced'; + +// A function that takes a url, method, and other options. This function should return an object +// with two required properties: execute and abort. +import myNetworkAdapter from './network-adapter'; + +export const getQueries = (state) => state.queries; +export const getEntities = (state) => state.entities; + +const reducer = combineReducers({ + entities: entitiesReducer, + queries: queriesReducer, +}); + +const store = createStore( + reducer, + applyMiddleware(queryMiddlewareAdvanced(myNetworkAdapter)(getQueries, getEntities)) +); +``` + +#### Network adapters + +You must provide a function to `queryMiddlewareAdvanced` that adheres to the following `NetworkAdapter` interface: + +```javascript +type NetworkAdapter = ( + url: string, + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + config?: { body?: string | Object, headers?: Object, credentials?: 'omit' | 'include' } = {}, +) => Adapter; + +type NetworkRequest = { + execute: (callback: (err: any, resStatus: number, resBody: ?Object, resText: string) => void) => void, + abort: () => void, +}; +``` + ## Example A fork of the `redux` [Async](https://github.com/reactjs/redux/tree/master/examples/async) example is included. To run, first build the package: diff --git a/advanced.js b/advanced.js new file mode 100644 index 00000000..7ce0f342 --- /dev/null +++ b/advanced.js @@ -0,0 +1 @@ +module.exports = require('./dist/commonjs/advanced'); diff --git a/src/adapters/superagent.js b/src/adapters/superagent.js new file mode 100644 index 00000000..025313f1 --- /dev/null +++ b/src/adapters/superagent.js @@ -0,0 +1,49 @@ +import superagent from 'superagent'; +import * as httpMethods from '../constants/http-methods'; + +export const createRequest = (url, method) => { + switch (method) { + case httpMethods.GET: + return superagent.get(url); + case httpMethods.POST: + return superagent.post(url); + case httpMethods.PUT: + return superagent.put(url); + case httpMethods.PATCH: + return superagent.patch(url); + case httpMethods.DELETE: + return superagent.del(url); + default: + throw new Error(`Unsupported HTTP method: ${method}`); + } +}; + +const superagentNetworkAdapter = (url, method, { body, headers, credentials } = {}) => { + const request = createRequest(url, method); + + if (body) { + request.send(body); + } + + if (headers) { + request.set(headers); + } + + if (credentials === 'include') { + request.withCredentials(); + } + + const execute = (cb) => request.end((err, response) => { + const resStatus = (response && response.status) || 0; + const resBody = (response && response.body) || undefined; + const resText = (response && response.text) || undefined; + + cb(err, resStatus, resBody, resText); + }); + + const abort = () => request.abort(); + + return { execute, abort }; +}; + +export default superagentNetworkAdapter; diff --git a/src/advanced.js b/src/advanced.js new file mode 100644 index 00000000..eaa244e2 --- /dev/null +++ b/src/advanced.js @@ -0,0 +1,12 @@ +import * as actions from './actions'; +import * as actionTypes from './constants/action-types'; +import * as httpMethods from './constants/http-methods'; +import * as querySelectors from './selectors/query'; + +export { default as connectRequest } from './components/connect-request'; +export { getQueryKey, reconcileQueryKey } from './lib/query-key'; +export { default as queriesReducer } from './reducers/queries'; +export { default as entitiesReducer } from './reducers/entities'; +export { default as queryMiddlewareAdvanced } from './middleware/query-advanced'; +export { cancelQuery, mutateAsync, requestAsync, removeEntities, removeEntity } from './actions'; +export { actions, actionTypes, httpMethods, querySelectors }; diff --git a/src/middleware/query-advanced.js b/src/middleware/query-advanced.js new file mode 100644 index 00000000..bcd5bd50 --- /dev/null +++ b/src/middleware/query-advanced.js @@ -0,0 +1,295 @@ +import Backoff from 'backo'; +import invariant from 'invariant'; +import get from 'lodash.get'; +import identity from 'lodash.identity'; +import includes from 'lodash.includes'; +import pickBy from 'lodash.pickby'; + +import { + requestStart, + requestFailure, + requestSuccess, + mutateStart, + mutateFailure, + mutateSuccess, +} from '../actions'; +import * as actionTypes from '../constants/action-types'; +import * as httpMethods from '../constants/http-methods'; +import * as statusCodes from '../constants/status-codes'; +import { reconcileQueryKey } from '../lib/query-key'; + +const updateEntities = (update, entities, transformed) => { + // If update, not supplied, then no change to entities should be made + + return Object.keys(update || {}).reduce((accum, key) => { + accum[key] = update[key]((entities || {})[key], (transformed || {})[key]); + + return accum; + }, {}); +}; + +const optimisticUpdateEntities = (optimisticUpdate, entities) => { + return Object.keys(optimisticUpdate).reduce((accum, key) => { + if (optimisticUpdate[key]) { + accum[key] = optimisticUpdate[key](entities[key]); + } else { + accum[key] = entities[key]; + } + + return accum; + }, {}); +}; + +const defaultConfig = { + backoff: { + maxAttempts: 5, + minDuration: 300, + maxDuration: 5000, + }, + retryableStatusCodes: [ + statusCodes.UNKNOWN, // normally means a failed connection + statusCodes.REQUEST_TIMEOUT, + statusCodes.TOO_MANY_REQUESTS, // hopefully backoff stops this getting worse + statusCodes.SERVICE_UNAVAILABLE, + statusCodes.GATEWAY_TIMEOUT, + ], +}; + +const getPendingQueries = (queries) => { + return pickBy(queries, (query) => query.isPending); +}; + +const resOk = (status) => Math.floor(status / 100) === 2; + +const queryMiddlewareAdvanced = (networkAdapter) => (queriesSelector, entitiesSelector, config = defaultConfig) => { + return ({ dispatch, getState }) => (next) => (action) => { + // TODO(ryan): add warnings when there are simultaneous requests and mutation queries for the same entities + let returnValue; + + switch (action.type) { + case actionTypes.REQUEST_ASYNC: { + const { + url, + body, + force, + retry, + transform = identity, + update, + options = {}, + meta, + } = action; + + invariant(!!url, 'Missing required `url` field in action handler'); + invariant(!!update, 'Missing required `update` field in action handler'); + + const queryKey = reconcileQueryKey(action); + + const state = getState(); + const queries = queriesSelector(state); + + const queriesState = queries[queryKey]; + const isPending = get(queriesState, ['isPending']); + const status = get(queriesState, ['status']); + const hasSucceeded = status >= 200 && status < 300; + + if (force || !queriesState || (retry && !isPending && !hasSucceeded)) { + returnValue = new Promise((resolve) => { + const start = new Date(); + const { method = httpMethods.GET } = options; + + const request = networkAdapter(url, method, { + body, + headers: options.headers, + credentials: options.credentials, + }); + + let attempts = 0; + const backoff = new Backoff({ + min: config.backoff.minDuration, + max: config.backoff.maxDuration, + }); + + const attemptRequest = () => { + dispatch(requestStart(url, body, request, meta, queryKey)); + + attempts += 1; + + request.execute((err, resStatus, resBody, resText) => { + if ( + includes(config.retryableStatusCodes, resStatus) && + attempts < config.backoff.maxAttempts + ) { + // TODO take into account Retry-After header if 503 + setTimeout(attemptRequest, backoff.duration()); + return; + } + + let transformed; + let newEntities; + + if (err || !resOk(resStatus)) { + dispatch(requestFailure( + url, + body, + resStatus, + resBody, + meta, + queryKey, + resText + )); + } else { + const callbackState = getState(); + const entities = entitiesSelector(callbackState); + transformed = transform(resBody, resText); + newEntities = updateEntities(update, entities, transformed); + dispatch(requestSuccess( + url, + body, + resStatus, + newEntities, + meta, + queryKey, + resBody, + resText + )); + } + + const end = new Date(); + const duration = end - start; + resolve({ + body: resBody, + duration, + status: resStatus, + text: resText, + transformed, + entities: newEntities, + }); + }); + }; + + attemptRequest(); + }); + } + + break; + } + case actionTypes.MUTATE_ASYNC: { + const { + url, + transform = identity, + update, + body, + optimisticUpdate, + options = {}, + } = action; + invariant(!!url, 'Missing required `url` field in action handler'); + + const state = getState(); + const entities = entitiesSelector(state); + let optimisticEntities; + if (optimisticUpdate) { + optimisticEntities = optimisticUpdateEntities(optimisticUpdate, entities); + } + + const queryKey = reconcileQueryKey(action); + + returnValue = new Promise((resolve) => { + const start = new Date(); + const { method = httpMethods.POST } = options; + + const request = networkAdapter(url, method, { + body, + headers: options.headers, + credentials: options.credentials, + }); + + // Note: only the entities that are included in `optimisticUpdate` will be passed along in the + // `mutateStart` action as `optimisticEntities` + dispatch(mutateStart(url, body, request, optimisticEntities, queryKey)); + + request.execute((err, resStatus, resBody, resText) => { + let transformed; + let newEntities; + + if (err || !resOk(resStatus)) { + dispatch(mutateFailure( + url, + body, + resStatus, + entities, + queryKey, + resBody, + resText + )); + } else { + transformed = transform(resBody, resText); + newEntities = updateEntities(update, entities, transformed); + dispatch(mutateSuccess( + url, + body, + resStatus, + newEntities, + queryKey, + resBody, + resText + )); + } + + const end = new Date(); + const duration = end - start; + resolve({ + body: resBody, + duration, + status: resStatus, + text: resText, + transformed, + entities: newEntities, + }); + }); + }); + + break; + } + case actionTypes.CANCEL_QUERY: { + const { queryKey } = action; + invariant(!!queryKey, 'Missing required `queryKey` field in action handler'); + + const state = getState(); + const queries = queriesSelector(state); + const pendingQueries = getPendingQueries(queries); + + if (queryKey in pendingQueries) { + pendingQueries[queryKey].request.abort(); + returnValue = next(action); + } else { + console.warn('Trying to cancel a request that is not in flight: ', queryKey); + returnValue = null; + } + + break; + } + case actionTypes.RESET: { + const state = getState(); + const queries = queriesSelector(state); + const pendingQueries = getPendingQueries(queries); + + for (const queryKey in pendingQueries) { + if (pendingQueries.hasOwnProperty(queryKey)) { + pendingQueries[queryKey].request.abort(); + } + } + + returnValue = next(action); + + break; + } + default: { + returnValue = next(action); + } + } + + return returnValue; + }; +}; + +export default queryMiddlewareAdvanced; diff --git a/src/middleware/query.js b/src/middleware/query.js index 0859eb37..937e4105 100644 --- a/src/middleware/query.js +++ b/src/middleware/query.js @@ -1,341 +1,6 @@ -import Backoff from 'backo'; -import invariant from 'invariant'; -import get from 'lodash.get'; -import identity from 'lodash.identity'; -import includes from 'lodash.includes'; -import pickBy from 'lodash.pickby'; -import superagent from 'superagent'; +import queryAdvanced from './query-advanced.js'; +import superagentAdapter from '../adapters/superagent'; -import { - requestStart, - requestFailure, - requestSuccess, - mutateStart, - mutateFailure, - mutateSuccess, -} from '../actions'; -import * as actionTypes from '../constants/action-types'; -import * as httpMethods from '../constants/http-methods'; -import * as statusCodes from '../constants/status-codes'; -import { reconcileQueryKey } from '../lib/query-key'; - -const createRequest = (url, method) => { - let request; - switch (method) { - case httpMethods.GET: - request = superagent.get(url); - break; - case httpMethods.POST: - request = superagent.post(url); - break; - case httpMethods.PUT: - request = superagent.put(url); - break; - case httpMethods.PATCH: - request = superagent.patch(url); - break; - case httpMethods.DELETE: - request = superagent.del(url); - break; - default: - throw new Error(`Unsupported HTTP method: ${method}`); - } - - return request; -}; - -const updateEntities = (update, entities, transformed) => { - // If update, not supplied, then no change to entities should be made - - return Object.keys(update || {}).reduce((accum, key) => { - accum[key] = update[key]((entities || {})[key], (transformed || {})[key]); - - return accum; - }, {}); -}; - -const optimisticUpdateEntities = (optimisticUpdate, entities) => { - return Object.keys(optimisticUpdate).reduce((accum, key) => { - if (optimisticUpdate[key]) { - accum[key] = optimisticUpdate[key](entities[key]); - } else { - accum[key] = entities[key]; - } - - return accum; - }, {}); -}; - -const defaultConfig = { - backoff: { - maxAttempts: 5, - minDuration: 300, - maxDuration: 5000, - }, - retryableStatusCodes: [ - statusCodes.UNKNOWN, // normally means a failed connection - statusCodes.REQUEST_TIMEOUT, - statusCodes.TOO_MANY_REQUESTS, // hopefully backoff stops this getting worse - statusCodes.SERVICE_UNAVAILABLE, - statusCodes.GATEWAY_TIMEOUT, - ], -}; - -const getPendingQueries = (queries) => { - return pickBy(queries, (query) => query.isPending); -}; - -const queryMiddleware = (queriesSelector, entitiesSelector, config = defaultConfig) => { - return ({ dispatch, getState }) => (next) => (action) => { - // TODO(ryan): add warnings when there are simultaneous requests and mutation queries for the same entities - let returnValue; - - switch (action.type) { - case actionTypes.REQUEST_ASYNC: { - const { - url, - body, - force, - retry, - transform = identity, - update, - options = {}, - meta, - } = action; - - invariant(!!url, 'Missing required `url` field in action handler'); - invariant(!!update, 'Missing required `update` field in action handler'); - - const queryKey = reconcileQueryKey(action); - - const state = getState(); - const queries = queriesSelector(state); - - const queriesState = queries[queryKey]; - const isPending = get(queriesState, ['isPending']); - const status = get(queriesState, ['status']); - const hasSucceeded = status >= 200 && status < 300; - - if (force || !queriesState || (retry && !isPending && !hasSucceeded)) { - returnValue = new Promise((resolve) => { - const start = new Date(); - const { method = httpMethods.GET } = options; - - const request = createRequest(url, method); - - if (body) { - request.send(body); - } - - if (options.headers) { - request.set(options.headers); - } - - if (options.credentials === 'include') { - request.withCredentials(); - } - - let attempts = 0; - const backoff = new Backoff({ - min: config.backoff.minDuration, - max: config.backoff.maxDuration, - }); - - const attemptRequest = () => { - dispatch(requestStart(url, body, request, meta, queryKey)); - - attempts += 1; - - request.end((err, response) => { - const resOk = !!(response && response.ok); - const resStatus = (response && response.status) || 0; - const resBody = (response && response.body) || undefined; - const resText = (response && response.text) || undefined; - - if ( - includes(config.retryableStatusCodes, resStatus) && - attempts < config.backoff.maxAttempts - ) { - // TODO take into account Retry-After header if 503 - setTimeout(attemptRequest, backoff.duration()); - return; - } - - let transformed; - let newEntities; - - if (err || !resOk) { - dispatch(requestFailure( - url, - body, - resStatus, - resBody, - meta, - queryKey, - resText - )); - } else { - const callbackState = getState(); - const entities = entitiesSelector(callbackState); - transformed = transform(resBody, resText); - newEntities = updateEntities(update, entities, transformed); - dispatch(requestSuccess( - url, - body, - resStatus, - newEntities, - meta, - queryKey, - resBody, - resText - )); - } - - const end = new Date(); - const duration = end - start; - resolve({ - body: resBody, - duration, - status: resStatus, - text: resText, - transformed, - entities: newEntities, - }); - }); - }; - - attemptRequest(); - }); - } - - break; - } - case actionTypes.MUTATE_ASYNC: { - const { - url, - transform = identity, - update, - body, - optimisticUpdate, - options = {}, - } = action; - invariant(!!url, 'Missing required `url` field in action handler'); - - const state = getState(); - const entities = entitiesSelector(state); - let optimisticEntities; - if (optimisticUpdate) { - optimisticEntities = optimisticUpdateEntities(optimisticUpdate, entities); - } - - const queryKey = reconcileQueryKey(action); - - returnValue = new Promise((resolve) => { - const start = new Date(); - const { method = httpMethods.POST } = options; - - const request = createRequest(url, method); - - if (options.headers) { - request.set(options.headers); - } - - if (options.credentials === 'include') { - request.withCredentials(); - } - - // Note: only the entities that are included in `optimisticUpdate` will be passed along in the - // `mutateStart` action as `optimisticEntities` - dispatch(mutateStart(url, body, request, optimisticEntities, queryKey)); - - request.send(body).end((err, response) => { - const resOk = !!(response && response.ok); - const resStatus = (response && response.status) || 0; - const resBody = (response && response.body) || undefined; - const resText = (response && response.text) || undefined; - - let transformed; - let newEntities; - - if (err || !resOk) { - dispatch(mutateFailure( - url, - body, - resStatus, - entities, - queryKey, - resBody, - resText - )); - } else { - transformed = transform(resBody, resText); - newEntities = updateEntities(update, entities, transformed); - dispatch(mutateSuccess( - url, - body, - resStatus, - newEntities, - queryKey, - resBody, - resText - )); - } - - const end = new Date(); - const duration = end - start; - resolve({ - body: resBody, - duration, - status: resStatus, - text: resText, - transformed, - entities: newEntities, - }); - }); - }); - - break; - } - case actionTypes.CANCEL_QUERY: { - const { queryKey } = action; - invariant(!!queryKey, 'Missing required `queryKey` field in action handler'); - - const state = getState(); - const queries = queriesSelector(state); - const pendingQueries = getPendingQueries(queries); - - if (queryKey in pendingQueries) { - pendingQueries[queryKey].request.abort(); - returnValue = next(action); - } else { - console.warn('Trying to cancel a request that is not in flight: ', queryKey); - returnValue = null; - } - - break; - } - case actionTypes.RESET: { - const state = getState(); - const queries = queriesSelector(state); - const pendingQueries = getPendingQueries(queries); - - for (const queryKey in pendingQueries) { - if (pendingQueries.hasOwnProperty(queryKey)) { - pendingQueries[queryKey].request.abort(); - } - } - - returnValue = next(action); - - break; - } - default: { - returnValue = next(action); - } - } - - return returnValue; - }; -}; +const queryMiddleware = queryAdvanced(superagentAdapter); export default queryMiddleware; diff --git a/test/adapters/superagent.test.js b/test/adapters/superagent.test.js new file mode 100644 index 00000000..fc597cd3 --- /dev/null +++ b/test/adapters/superagent.test.js @@ -0,0 +1,43 @@ +import { assert } from 'chai'; +import * as HTTPMethods from '../../src/constants/http-methods'; +import superagentAdapter, { createRequest } from '../../src/adapters/superagent'; + +describe('superagent adapter', () => { + it('must return an object with both execute and abort functions', () => { + const adapter = superagentAdapter('http://localhost', HTTPMethods.GET); + assert.isFunction(adapter.execute); + assert.isFunction(adapter.abort); + }); + + describe('createRequest', () => { + it('must return a DELETE request when supplied a DELETE method', () => { + const request = createRequest('http://localhost', HTTPMethods.DELETE); + assert.equal(request.method, HTTPMethods.DELETE); + }); + + it('must return a GET request when supplied a GET method', () => { + const request = createRequest('http://localhost', HTTPMethods.GET); + assert.equal(request.method, HTTPMethods.GET); + }); + + it('must return a PATCH request when supplied a PATCH method', () => { + const request = createRequest('http://localhost', HTTPMethods.PATCH); + assert.equal(request.method, HTTPMethods.PATCH); + }); + + it('must return a POST request when supplied a POST method', () => { + const request = createRequest('http://localhost', HTTPMethods.POST); + assert.equal(request.method, HTTPMethods.POST); + }); + + it('must return a PUT request when supplied a PUT method', () => { + const request = createRequest('http://localhost', HTTPMethods.PUT); + assert.equal(request.method, HTTPMethods.PUT); + }); + + it('must throw an error when supplied an invalid HTTP method', () => { + const request = () => createRequest('http://localhost', 'abc'); + assert.throws(request, /Unsupported HTTP method/); + }); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index f23a3cbf..b2dcaa67 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -25,7 +25,8 @@ module.exports = { 'redux': reduxExternal, }, entry: { - 'redux-query': './src/index.js' + 'redux-query': './src/index.js', + 'redux-query/advanced': './src/advanced.js', }, output: { path: 'dist/umd',