redux-api-middleware
Version:
Redux middleware for calling an API.
632 lines (531 loc) • 17 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
/**
* String key that carries API call info interpreted by this Redux middleware.
*
* @constant {string}
* @access public
* @deprecated To be made private (implementation detail). Use `createAction` instead.
* @default
*/
const RSAA = '@@redux-api-middleware/RSAA';
/**
* Is the argument a plain object?
* Inspired by lodash.isplainobject
*
* @function isPlainObject
* @param {object} obj - The object to check
* @returns {boolean}
*/
function isPlainObject(obj) {
return obj && typeof obj == 'object' && Object.getPrototypeOf(obj) === Object.prototype;
}
/**
* Is the given action a plain JavaScript object with an [RSAA] property?
*
* @function isRSAA
* @access public
* @param {object} action - The action to check
* @returns {boolean}
*/
function isRSAA(action) {
return isPlainObject(action) && action.hasOwnProperty(RSAA);
}
/**
* Is the given object a valid type descriptor?
*
* @function isValidTypeDescriptor
* @access private
* @param {object} obj - The object to check agains the type descriptor definition
* @returns {boolean}
*/
function isValidTypeDescriptor(obj) {
const validKeys = ['type', 'payload', 'meta'];
if (!isPlainObject(obj)) {
return false;
}
for (let key in obj) {
if (!~validKeys.indexOf(key)) {
return false;
}
}
if (!('type' in obj)) {
return false;
} else if (typeof obj.type !== 'string' && typeof obj.type !== 'symbol') {
return false;
}
return true;
}
/**
* Checks an action against the RSAA definition, returning a (possibly empty)
* array of validation errors.
*
* @function validateRSAA
* @access public
* @param {object} action - The action to check against the RSAA definition
* @returns {array}
*/
function validateRSAA(action) {
var validationErrors = [];
const validCallAPIKeys = ['endpoint', 'options', 'method', 'body', 'headers', 'credentials', 'bailout', 'types', 'fetch', 'ok'];
const validMethods = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'];
const validCredentials = ['omit', 'same-origin', 'include'];
if (!isRSAA(action)) {
validationErrors.push('RSAAs must be plain JavaScript objects with an [RSAA] property');
return validationErrors;
}
const callAPI = action[RSAA];
if (!isPlainObject(callAPI)) {
validationErrors.push('[RSAA] property must be a plain JavaScript object');
}
for (let key in callAPI) {
if (!~validCallAPIKeys.indexOf(key)) {
validationErrors.push(`Invalid [RSAA] key: ${key}`);
}
}
const {
endpoint,
method,
headers,
options,
credentials,
types,
bailout,
fetch,
ok
} = callAPI;
if (typeof endpoint === 'undefined') {
validationErrors.push('[RSAA] must have an endpoint property');
} else if (typeof endpoint !== 'string' && typeof endpoint !== 'function') {
validationErrors.push('[RSAA].endpoint property must be a string or a function');
}
if (typeof method === 'undefined') {
validationErrors.push('[RSAA] must have a method property');
} else if (typeof method !== 'string') {
validationErrors.push('[RSAA].method property must be a string');
} else if (!~validMethods.indexOf(method.toUpperCase())) {
validationErrors.push(`Invalid [RSAA].method: ${method.toUpperCase()}`);
}
if (typeof headers !== 'undefined' && !isPlainObject(headers) && typeof headers !== 'function') {
validationErrors.push('[RSAA].headers property must be undefined, a plain JavaScript object, or a function');
}
if (typeof options !== 'undefined' && !isPlainObject(options) && typeof options !== 'function') {
validationErrors.push('[RSAA].options property must be undefined, a plain JavaScript object, or a function');
}
if (typeof credentials !== 'undefined') {
if (typeof credentials !== 'string') {
validationErrors.push('[RSAA].credentials property must be undefined, or a string');
} else if (!~validCredentials.indexOf(credentials)) {
validationErrors.push(`Invalid [RSAA].credentials: ${credentials}`);
}
}
if (typeof bailout !== 'undefined' && typeof bailout !== 'boolean' && typeof bailout !== 'function') {
validationErrors.push('[RSAA].bailout property must be undefined, a boolean, or a function');
}
if (typeof types === 'undefined') {
validationErrors.push('[RSAA] must have a types property');
} else if (!Array.isArray(types) || types.length !== 3) {
validationErrors.push('[RSAA].types property must be an array of length 3');
} else {
const [requestType, successType, failureType] = types;
if (typeof requestType !== 'string' && typeof requestType !== 'symbol' && !isValidTypeDescriptor(requestType)) {
validationErrors.push('Invalid request type');
}
if (typeof successType !== 'string' && typeof successType !== 'symbol' && !isValidTypeDescriptor(successType)) {
validationErrors.push('Invalid success type');
}
if (typeof failureType !== 'string' && typeof failureType !== 'symbol' && !isValidTypeDescriptor(failureType)) {
validationErrors.push('Invalid failure type');
}
}
if (typeof fetch !== 'undefined') {
if (typeof fetch !== 'function') {
validationErrors.push('[RSAA].fetch property must be a function');
}
}
if (typeof ok !== 'undefined') {
if (typeof ok !== 'function') {
validationErrors.push('[RSAA].ok property must be a function');
}
}
return validationErrors;
}
/**
* Is the given action a valid RSAA?
*
* @function isValidRSAA
* @access public
* @param {object} action - The action to check against the RSAA definition
* @returns {boolean}
*/
function isValidRSAA(action) {
return !validateRSAA(action).length;
}
/**
* Error class for an RSAA that does not conform to the RSAA definition
*
* @class InvalidRSAA
* @access public
* @param {array} validationErrors - an array of validation errors
*/
class InvalidRSAA extends Error {
constructor(validationErrors) {
super();
this.name = 'InvalidRSAA';
this.message = 'Invalid RSAA';
this.validationErrors = validationErrors;
}
}
/**
* Error class for a custom `payload` or `meta` function throwing
*
* @class InternalError
* @access public
* @param {string} message - the error message
*/
class InternalError extends Error {
constructor(message) {
super();
this.name = 'InternalError';
this.message = message;
}
}
/**
* Error class for an error raised trying to make an API call
*
* @class RequestError
* @access public
* @param {string} message - the error message
*/
class RequestError extends Error {
constructor(message) {
super();
this.name = 'RequestError';
this.message = message;
}
}
/**
* Error class for an API response outside the 200 range
*
* @class ApiError
* @access public
* @param {number} status - the status code of the API response
* @param {string} statusText - the status text of the API response
* @param {object} response - the parsed JSON response of the API server if the
* 'Content-Type' header signals a JSON response
*/
class ApiError extends Error {
constructor(status, statusText, response) {
super();
this.name = 'ApiError';
this.status = status;
this.statusText = statusText;
this.response = response;
this.message = `${status} - ${statusText}`;
}
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
function ownKeys(object, enumerableOnly) {
var keys = Object.keys(object);
if (Object.getOwnPropertySymbols) {
var symbols = Object.getOwnPropertySymbols(object);
if (enumerableOnly) symbols = symbols.filter(function (sym) {
return Object.getOwnPropertyDescriptor(object, sym).enumerable;
});
keys.push.apply(keys, symbols);
}
return keys;
}
function _objectSpread2(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i] != null ? arguments[i] : {};
if (i % 2) {
ownKeys(Object(source), true).forEach(function (key) {
_defineProperty(target, key, source[key]);
});
} else if (Object.getOwnPropertyDescriptors) {
Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
} else {
ownKeys(Object(source)).forEach(function (key) {
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
});
}
}
return target;
}
/**
* Extract JSON body from a server response
*
* @function getJSON
* @access public
* @param {object} res - A raw response object
* @returns {promise|undefined}
*/
async function getJSON(res) {
const contentType = res.headers.get('Content-Type');
const emptyCodes = [204, 205];
if (!~emptyCodes.indexOf(res.status) && contentType && ~contentType.indexOf('json')) {
return await res.json();
} else {
return await Promise.resolve();
}
}
/**
* Blow up string or symbol types into full-fledged type descriptors,
* and add defaults
*
* @function normalizeTypeDescriptors
* @access private
* @param {array} types - The [RSAA].types from a validated RSAA
* @returns {array}
*/
function normalizeTypeDescriptors(types) {
let [requestType, successType, failureType] = types;
if (typeof requestType === 'string' || typeof requestType === 'symbol') {
requestType = {
type: requestType
};
}
if (typeof successType === 'string' || typeof successType === 'symbol') {
successType = {
type: successType
};
}
successType = _objectSpread2({
payload: (action, state, res) => getJSON(res)
}, successType);
if (typeof failureType === 'string' || typeof failureType === 'symbol') {
failureType = {
type: failureType
};
}
failureType = _objectSpread2({
payload: (action, state, res) => getJSON(res).then(json => new ApiError(res.status, res.statusText, json))
}, failureType);
return [requestType, successType, failureType];
}
/**
* Evaluate a type descriptor to an FSA
*
* @function actionWith
* @access private
* @param {object} descriptor - A type descriptor
* @param {array} args - The array of arguments for `payload` and `meta` function properties
* @returns {object}
*/
async function actionWith(descriptor, args = []) {
try {
descriptor.payload = typeof descriptor.payload === 'function' ? await descriptor.payload(...args) : descriptor.payload;
} catch (e) {
descriptor.payload = new InternalError(e.message);
descriptor.error = true;
}
try {
descriptor.meta = typeof descriptor.meta === 'function' ? await descriptor.meta(...args) : descriptor.meta;
} catch (e) {
delete descriptor.meta;
descriptor.payload = new InternalError(e.message);
descriptor.error = true;
}
return descriptor;
}
/**
* Create RSAA action
*
* @function createAction
* @access public
* @param {object} clientCall - The options for the RSAA action
* @returns {object} RSAA Action
*/
function createAction(clientCall) {
return {
[RSAA]: clientCall
};
}
/**
* Default options for redux-api-middleware
* These can be customized by passing options into `createMiddleware`
* @type {Object}
*/
const defaults = {
ok: res => res.ok
};
/**
* A middleware creator used to create a ReduxApiMiddleware
* with custom defaults
*
* @type {function}
* @returns {ReduxMiddleware}
* @access public
*/
function createMiddleware(options = {}) {
const middlewareOptions = Object.assign({}, defaults, options);
return ({
getState
}) => next => action => {
// Do not process actions without an [RSAA] property
if (!isRSAA(action)) {
return next(action);
}
return (async () => {
// Try to dispatch an error request FSA for invalid RSAAs
const validationErrors = validateRSAA(action);
if (validationErrors.length) {
const callAPI = action[RSAA];
if (callAPI.types && Array.isArray(callAPI.types)) {
let requestType = callAPI.types[0];
if (requestType && requestType.type) {
requestType = requestType.type;
}
next({
type: requestType,
payload: new InvalidRSAA(validationErrors),
error: true
});
}
return;
} // Parse the validated RSAA action
const callAPI = action[RSAA];
var {
endpoint,
body,
headers,
options = {},
fetch: doFetch = middlewareOptions.fetch || fetch,
ok = middlewareOptions.ok
} = callAPI;
const {
method,
credentials,
bailout,
types
} = callAPI;
const [requestType, successType, failureType] = normalizeTypeDescriptors(types); // Should we bail out?
try {
if (typeof bailout === 'boolean' && bailout || typeof bailout === 'function' && bailout(getState())) {
return;
}
} catch (e) {
return next((await actionWith(_objectSpread2({}, failureType, {
payload: new RequestError('[RSAA].bailout function failed'),
error: true
}), [action, getState()])));
} // Process [RSAA].endpoint function
if (typeof endpoint === 'function') {
try {
endpoint = await endpoint(getState());
} catch (e) {
return next((await actionWith(_objectSpread2({}, failureType, {
payload: new RequestError('[RSAA].endpoint function failed'),
error: true
}), [action, getState()])));
}
} // Process [RSAA].body function
if (typeof body === 'function') {
try {
body = await body(getState());
} catch (e) {
return next((await actionWith(_objectSpread2({}, failureType, {
payload: new RequestError('[RSAA].body function failed'),
error: true
}), [action, getState()])));
}
} // Process [RSAA].headers function
if (typeof headers === 'function') {
try {
headers = await headers(getState());
} catch (e) {
return next((await actionWith(_objectSpread2({}, failureType, {
payload: new RequestError('[RSAA].headers function failed'),
error: true
}), [action, getState()])));
}
} // Process [RSAA].options function
if (typeof options === 'function') {
try {
options = await options(getState());
} catch (e) {
return next((await actionWith(_objectSpread2({}, failureType, {
payload: new RequestError('[RSAA].options function failed'),
error: true
}), [action, getState()])));
}
} // We can now dispatch the request FSA
if (typeof requestType.payload === 'function' || typeof requestType.meta === 'function') {
next((await actionWith(requestType, [action, getState()])));
} else {
next(requestType);
}
let res;
try {
// Make the API call
res = await doFetch(endpoint, _objectSpread2({}, options, {
method,
body: body || undefined,
credentials,
headers: headers || {}
}));
} catch (e) {
// The request was malformed, or there was a network error
return next((await actionWith(_objectSpread2({}, failureType, {
payload: new RequestError(e.message),
error: true
}), [action, getState()])));
}
let isOk;
try {
isOk = ok(res);
} catch (e) {
return next((await actionWith(_objectSpread2({}, failureType, {
payload: new InternalError('[RSAA].ok function failed'),
error: true
}), [action, getState(), res])));
} // Process the server response
if (isOk) {
return next((await actionWith(successType, [action, getState(), res])));
} else {
return next((await actionWith(_objectSpread2({}, failureType, {
error: true
}), [action, getState(), res])));
}
})();
};
}
/**
* A Redux middleware that processes RSAA actions.
*
* @type {ReduxMiddleware}
* @access public
* @deprecated since v3.2.0 use `createMiddleware`
*/
function apiMiddleware({
getState
}) {
return createMiddleware()({
getState
});
}
exports.ApiError = ApiError;
exports.InternalError = InternalError;
exports.InvalidRSAA = InvalidRSAA;
exports.RSAA = RSAA;
exports.RequestError = RequestError;
exports.apiMiddleware = apiMiddleware;
exports.createAction = createAction;
exports.createMiddleware = createMiddleware;
exports.getJSON = getJSON;
exports.isRSAA = isRSAA;
exports.isValidRSAA = isValidRSAA;
exports.validateRSAA = validateRSAA;