@webex/webex-core
Version:
Plugin handling for Cisco Webex
308 lines (271 loc) • 7.93 kB
JavaScript
/*!
* Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
*/
import {has} from 'lodash';
import {cappedDebounce, Defer, tap} from '@webex/common';
import WebexPlugin from './webex-plugin';
import WebexHttpError from './webex-http-error';
/**
* Base class for coalescing requests to batched APIs
* @class Batcher
*/
const Batcher = WebexPlugin.extend({
session: {
deferreds: {
type: 'object',
default() {
return new Map();
},
},
queue: {
type: 'array',
default() {
return [];
},
},
},
derived: {
bounce: {
fn() {
return cappedDebounce((...args) => this.executeQueue(...args), this.config.batcherWait, {
maxCalls: this.config.batcherMaxCalls,
maxWait: this.config.batcherMaxWait,
});
},
},
},
/**
* Requests an item from a batched API
* @param {Object} item
* @returns {Promise<mixed>}
*/
request(item) {
// So far, I can't find a way to avoid three layers of nesting here.
/* eslint max-nested-callbacks: [0] */
const defer = new Defer();
this.fingerprintRequest(item)
.then((idx) => {
if (this.deferreds.has(idx)) {
defer.resolve(this.deferreds.get(idx).promise);
return;
}
this.deferreds.set(idx, defer);
this.prepareItem(item)
.then((req) => {
defer.promise = defer.promise
.then(tap(() => this.deferreds.delete(idx)))
.catch((reason) => {
this.deferreds.delete(idx);
return Promise.reject(reason);
});
this.enqueue(req)
.then(() => this.bounce())
.catch((reason) => defer.reject(reason));
})
.catch((reason) => defer.reject(reason));
})
.catch((reason) => defer.reject(reason));
return defer.promise;
},
/**
* Adds an item to the queue.
* Intended to be overridden
* @param {mixed} req
* @returns {Promise<undefined>}
*/
enqueue(req) {
this.queue.push(req);
return Promise.resolve();
},
/**
* Transform the item before adding it to the queue
* Intended to be overridden
* @param {mixed} item
* @returns {Promise<mixed>}
*/
prepareItem(item) {
return Promise.resolve(item);
},
/**
* Detaches the current queue, does any appropriate transforms, and submits it
* to the API.
* @returns {Promise<undefined>}
*/
executeQueue() {
const queue = this.queue.splice(0, this.config.batcherMaxCalls);
return new Promise((resolve) => {
resolve(
this.prepareRequest(queue)
.then((payload) =>
this.submitHttpRequest(payload).then((res) => this.handleHttpSuccess(res))
)
.catch((reason) => {
if (reason instanceof WebexHttpError) {
return this.handleHttpError(reason);
}
return Promise.all(
queue.map((item) =>
this.getDeferredForRequest(item).then((defer) => {
defer.reject(reason);
})
)
);
})
);
}).catch((reason) => {
this.logger.error(process.env.NODE_ENV === 'production' ? reason : reason.stack);
return Promise.reject(reason);
});
},
/**
* Performs any final transforms on the queue before submitting it to the API
* Intended to be overridden
* @param {Object|Array} queue
* @returns {Promise<Object>}
*/
prepareRequest(queue) {
return Promise.resolve(queue);
},
/**
* Submits the prepared request body to the API.
* This method *must* be overridden
* @param {Object} payload
* @returns {Promise<HttpResponseObject>}
*/
// eslint-disable-next-line no-unused-vars
submitHttpRequest(payload) {
throw new Error('request() must be implemented');
},
/**
* Actions taken when the http request returns a success
* Intended to be overridden
* @param {Promise<HttpResponseObject>} res
* @returns {Promise<undefined>}
*/
handleHttpSuccess(res) {
return Promise.all(
((res.body && res.body.items) || res.body).map((item) => this.acceptItem(item))
);
},
/**
* Actions taken when the http request returns a failure. Typically, this
* means failing the entire queue, but could be overridden in some
* implementations to e.g. reenqueue.
* Intended to be overridden
* @param {WebexHttpError} reason
* @returns {Promise<undefined>}
*/
handleHttpError(reason) {
if (reason instanceof WebexHttpError) {
if (has(reason, 'options.body.map')) {
return Promise.all(
reason.options.body.map((item) =>
this.getDeferredForRequest(item).then((defer) => {
defer.reject(reason);
})
)
);
}
}
this.logger.error('http error handler called without a WebexHttpError object', reason);
return Promise.reject(reason);
},
/**
* Determines if the item succeeded or failed and delegates accordingly
* @param {Object} item
* @returns {Promise<undefined>}
*/
acceptItem(item) {
return this.didItemFail(item).then((didFail) => {
if (didFail) {
return this.handleItemFailure(item);
}
return this.handleItemSuccess(item);
});
},
/**
* Indicates if the specified response item implies a success or a failure
* Intended to be overridden
* @param {Object} item
* @returns {Promise<Boolean>}
*/
// eslint-disable-next-line no-unused-vars
didItemFail(item) {
return Promise.resolve(false);
},
/**
* Finds the Defer for the specified item and rejects its promise
* Intended to be overridden
* @param {Object} item
* @returns {Promise<undefined>}
*/
handleItemFailure(item) {
return this.getDeferredForResponse(item).then((defer) => {
defer.reject(item);
});
},
/**
* Finds the Defer for the specified item and resolves its promise
* Intended to be overridden
* @param {Object} item
* @returns {Promise<undefined>}
*/
handleItemSuccess(item) {
return this.getDeferredForResponse(item).then((defer) => {
defer.resolve(item);
});
},
/**
* Returns the Deferred for the specified request item
* @param {Object} item
* @returns {Promise<Defer>}
*/
getDeferredForRequest(item) {
return this.fingerprintRequest(item).then((idx) => {
const defer = this.deferreds.get(idx);
/* istanbul ignore if */
if (!defer) {
throw new Error('Could not find pending request for received response');
}
return defer;
});
},
/**
* Returns the Deferred for the specified response item
* @param {Object} item
* @returns {Promise<Defer>}
*/
getDeferredForResponse(item) {
return this.fingerprintResponse(item).then((idx) => {
const defer = this.deferreds.get(idx);
/* istanbul ignore if */
if (!defer) {
throw new Error('Could not find pending request for received response');
}
return defer;
});
},
/**
* Generates a unique identifier for the item in a request payload
* Intended to be overridden
* Note that overrides must return a primitive.
* @param {Object} item
* @returns {Promise<primitive>}
*/
// eslint-disable-next-line no-unused-vars
fingerprintRequest(item) {
throw new Error('fingerprintRequest() must be implemented');
},
/**
* Generates a unique identifier for the item in a response payload
* Intended to be overridden
* Note that overrides must return a primitive.
* @param {Object} item
* @returns {Promise<primitive>}
*/
// eslint-disable-next-line no-unused-vars
fingerprintResponse(item) {
throw new Error('fingerprintResponse() must be implemented');
},
});
export default Batcher;