/** * @summary rotations * @version 3.0.0 * @since 2.0.0 * @author Arian Khosravi <arian.khosravi@aofl.com> */ import {CacheManager, cacheTypeEnumerate} from '@aofl/cache-manager'; /** * @memberof module:@aofl/rotations * @private * @type {Number} */ const EXPIRE_90_DAYS = 7776000000; /** * @memberof module:@aofl/rotations * @private * @type {RegExp} */ const TRAILING_SLASH_REGEX = /\/$/i; /** * @memberof module:@aofl/rotations * @private * @generator * @function arrayIterator * @yields {*} next next item in array */ const arrayIterator = function* arrayIterator(arr) { yield* arr; }; /** * Produces an updated route config based on a rotation config * * @memberof module:@aofl/rotations */ class Rotations { /** * @param {String} cacheNamespace * @param {Object} routesConfig * @param {Object} rotationConfig * @param {Object} rotationConditions * @param {Object} publicPath */ constructor(cacheNamespace, routesConfig, rotationConfig, rotationConditions, publicPath = '/', expires = EXPIRE_90_DAYS) { this.routesConfig = routesConfig; this.rotationConfig = rotationConfig; this.rotationConditions = rotationConditions; this.cache = new CacheManager(cacheNamespace, cacheTypeEnumerate.LOCAL, expires); this.qualification = {}; this.weightRanges = {}; this.qualifiedVersions = {}; this.PUBLIC_PATH = publicPath.replace(TRAILING_SLASH_REGEX, ''); // eslint-disable-line this.PUBLIC_PATH_REGEX = new RegExp(`^${this.PUBLIC_PATH}`); } /** * @private * @param {String} conditionId */ async qualifies(conditionId) { if (typeof this.qualification[conditionId] !== 'undefined') { return this.qualification[conditionId]; } const conditionName = this.rotationConfig.conditions[conditionId]; if (typeof this.rotationConditions[conditionName] === 'function') { const qualifies = Promise.resolve(await this.rotationConditions[conditionName]()); this.qualification[conditionId] = qualifies; return qualifies; } return false; } /** * * @private * @param {String} qualificationOrder * @return {Promise} */ getQualifyingId(qualificationOrder) { const qualificationIterator = arrayIterator(qualificationOrder); const qualify = async () => { const nextQ = qualificationIterator.next(); if (nextQ.done) { throw new Error('No matching conditions'); } const conditionId = nextQ.value; const qualifies = await this.qualifies(conditionId); if (qualifies) { return conditionId; } return qualify(); }; return qualify(); } /** * @param {String} qualificationId * @private * @return {Array} */ getWeightRange(qualificationId) { const weights = this.rotationConfig.weights[qualificationId]; if (typeof weights === 'undefined') { throw new Error('Invalid weights for qualifying rotation'); } const weightRanges = this.weightRanges[qualificationId]; if (typeof weightRanges !== 'undefined') { return weightRanges; } const range = []; for (const key in weights) { /* istanbul ignore next */ if (!Object.hasOwnProperty.call(weights, key)) continue; let i = 0; while (i++ < weights[key]) { range.push(key); } } this.weightRanges[qualificationId] = range; return range; } /** * @param {String} qualificationId * @private * @return {String} */ getVersion(qualificationId) { if (typeof this.qualifiedVersions[qualificationId] !== 'undefined') { return this.qualifiedVersions[qualificationId]; } const weights = this.getWeightRange(qualificationId); const version = weights[Math.floor(Math.random() * Math.floor(weights.length))]; this.qualifiedVersions[qualificationId] = version; return version; } /** * @param {String} rotation * @param {String} path * @private * @return {Object} */ findRotationRoute(rotation, path) { const routes = this.routesConfig[rotation]; if (typeof routes === 'undefined') { throw new Error('Rotation not found'); } for (let i = 0; i < routes.length; i++) { if (routes[i].path === path) { return routes[i]; } } throw new Error('Rotation route not found'); } /** * @param {String} path * @private * @return {Object} */ getCachedRotation(path) { const cachedResult = this.cache.getItem(path); if (cachedResult === null) { return null; } const qualifyingId = cachedResult.qualifyingId; const version = cachedResult.version; const weights = this.rotationConfig.weights[qualifyingId]; const rotation = this.rotationConfig.versions[version]; if (typeof weights !== 'undefined' && typeof rotation !== 'undefined' && typeof this.routesConfig[rotation] !== 'undefined') { try { this.findRotationRoute(rotation, path); return { qualifyingId, version }; } catch (e) {} } return null; } /** * * @param {String} mainRoutes * @return {Promise} resolves to a route configuration Array of route objects */ async getRoutes(mainRoutes = 'routes') { const routes = []; const routesIterator = arrayIterator(this.routesConfig[mainRoutes]); const qualifyRoutes = async () => { const next = routesIterator.next(); if (next.done) { return routes; } const route = next.value; const routePath = route.path.replace(this.PUBLIC_PATH_REGEX, '').replace(TRAILING_SLASH_REGEX, ''); const qualificationOrder = this.rotationConfig.qualification_order[routePath] || this.rotationConfig.qualification_order[routePath + '/']; try { /* istanbul ignore next */ if (typeof qualificationOrder === 'undefined') { throw new Error('No qualification order for giver route'); } let qualifyingId = 0; let version = ''; const cachedRotation = this.getCachedRotation(route.path); if (cachedRotation !== null) { qualifyingId = cachedRotation.qualifyingId; version = cachedRotation.version; } else { qualifyingId = await this.getQualifyingId(qualificationOrder); version = this.getVersion(qualifyingId); } const rotation = this.rotationConfig.versions[version]; if (typeof rotation === 'undefined') { throw new Error('Version does not exist'); } const rotationInfo = {qualifyingId, version}; const matchedRoute = this.findRotationRoute(rotation, route.path); matchedRoute.rotationInfo = rotationInfo; routes.push(matchedRoute); if (cachedRotation === null) { this.cache.setItem(route.path, {qualifyingId, version}); } } catch (e) { // no qualifying rotation route.rotationInfo = {version: this.rotationConfig.baseline_version}; routes.push(route); this.cache.removeItem(route.path); } await qualifyRoutes(); }; await qualifyRoutes(); return routes; } } export { Rotations };