diff --git a/lib/collection/property.js b/lib/collection/property.js index 18f8cf819..b7b52f916 100644 --- a/lib/collection/property.js +++ b/lib/collection/property.js @@ -286,6 +286,26 @@ _.assign(Property, /** @lends Property */ { return Substitutor.box(variables, Substitutor.DEFAULT_VARS).parse(str).toString(); }, + /** + * Similar to `replaceSubstitutions` but runs asynchronously + * and supports async value functions + * + * @param {String} str - + * @param {VariableList|Object|Array.} variables - + * @returns {String} + */ + // @todo: improve algorithm via variable replacement caching + replaceSubstitutionsAsync: async function (str, variables) { + // if there is nothing to replace, we move on + if (!(str && _.isString(str))) { return str; } + + // if variables object is not an instance of substitutor then ensure that it is an array so that it becomes + // compatible with the constructor arguments for a substitutor + !Substitutor.isInstance(variables) && !_.isArray(variables) && (variables = _.tail(arguments)); + + return (await Substitutor.box(variables, Substitutor.DEFAULT_VARS).parseAsync(str)).toString(); + }, + /** * This function accepts an object followed by a number of variable sources as arguments. One or more variable * sources can be provided and it will use the one that has the value in left-to-right order. @@ -319,6 +339,49 @@ _.assign(Property, /** @lends Property */ { return _.mergeWith({}, obj, customizer); }, + /** + * Similar to `replaceSubstitutionsIn` but runs asynchronously + * and supports async value functions + * + * @param {Object} obj - + * @param {Array.} variables - + * @returns {Object} + */ + replaceSubstitutionsInAsync: async function (obj, variables) { + // if there is nothing to replace, we move on + if (!(obj && _.isObject(obj))) { + return obj; + } + + // convert the variables to a substitutor object (will not reconvert if already substitutor) + variables = Substitutor.box(variables, Substitutor.DEFAULT_VARS); + + const promises = []; + var customizer = function (objectValue, sourceValue, key) { + objectValue = objectValue || {}; + if (!_.isString(sourceValue)) { + _.forOwn(sourceValue, function (value, key) { + sourceValue[key] = customizer(objectValue[key], value); + }); + + return sourceValue; + } + + const result = this.replaceSubstitutionsAsync(sourceValue, variables); + + promises.push({ key: key, promise: result }); + + return result; + }.bind(this), + res = _.mergeWith({}, obj, customizer); + + await Promise.all(promises.map(async ({ key, promise }) => { + res[key] = await promise; + })); + + return res; + }, + /** * This function recursively traverses a variable and detects all instances of variable replacements * within the string of the object diff --git a/lib/superstring/index.js b/lib/superstring/index.js index d8894b74e..42539696c 100644 --- a/lib/superstring/index.js +++ b/lib/superstring/index.js @@ -64,6 +64,46 @@ _.assign(SuperString.prototype, /** @lends SuperString.prototype */ { return this; }, + async replaceAsync (regex, fn) { + var replacements = 0; // maintain a count of tokens replaced + + // to ensure we do not perform needless operations in the replacement, we use multiple replacement functions + // after validating the parameters + const replacerFn = _.isFunction(fn) ? + function () { + replacements += 1; + + return fn.apply(this, arguments); + } : + // this case is returned when replacer is not a function (ensures we do not need to check it) + /* istanbul ignore next */ + function () { + replacements += 1; + + return fn; + }; + + let index = 0, + match; + + while ((match = regex.exec(this.value.slice(index)))) { + try { + // eslint-disable-next-line no-await-in-loop + let value = await replacerFn(...match); + + index += match.index; + this.value = this.value.slice(0, index) + value + this.value.slice(index + match[0].length); + index += match[0].length; + } + catch (_err) { /* empty */ } + } + + this.replacements = replacements; // store the last replacements + replacements && (this.substitutions += 1); // if any replacement is done, count that some substitution was made + + return this; + }, + /** * @returns {String} */ @@ -153,6 +193,34 @@ _.assign(Substitutor.prototype, /** @lends Substitutor.prototype */ { // } return value; + }, + + /** + * @param {SuperString} value - + * @returns {String} + */ + async parseAsync (value) { + // convert the value into a SuperString so that it can return tracking results during replacements + value = new SuperString(value); + + // get an instance of a replacer function that would be used to replace ejs like variable replacement + // tokens + var replacer = Substitutor.replacer(this); + + // replace the value once and keep on doing it until all tokens are replaced or we have reached a limit of + // replacements + do { + // eslint-disable-next-line no-await-in-loop + value = await value.replaceAsync(Substitutor.REGEX_EXTRACT_VARS, replacer); + } while (value.replacements && (value.substitutions < Substitutor.VARS_SUBREPLACE_LIMIT)); + + // @todo: uncomment this code, and try to raise a warning in some way. + // do a final check that if recursion limits are reached then replace with blank string + // if (value.substitutions >= Substitutor.VARS_SUBREPLACE_LIMIT) { + // value = value.replace(Substitutor.REGEX_EXTRACT_VARS, E); + // } + + return value.toString(); } }); @@ -225,6 +293,9 @@ _.assign(Substitutor, /** @lends Substitutor */ { var r = substitutor.find(token); r && _.isFunction(r) && (r = r()); + if (r && _.isFunction(r.value)) { + return r.get(); + } r && _.isFunction(r.toString) && (r = r.toString()); return Substitutor.NATIVETYPES[(typeof r)] ? r : match; diff --git a/test/unit/property.test.js b/test/unit/property.test.js index 45f4c0b88..e0113a074 100644 --- a/test/unit/property.test.js +++ b/test/unit/property.test.js @@ -426,6 +426,59 @@ describe('Property', function () { // resolves all independent unique variables as well as poly-chained {{0}} & {{1}} expect(Property.replaceSubstitutions(str, variables)).to.eql('{{xyz}}'); }); + + it('should correctly resolve variables with values as sync fn', function () { + const str = '{{world}}', + variables = new VariableList(null, [ + { + key: 'world', + value: () => { + return 'hello'; + } + } + ]); + + expect(Property.replaceSubstitutions(str, variables)).to.eql('hello'); + }); + }); + + describe('.replaceSubstitutionsAsync', function () { + it('should correctly resolve variables with values as async fn', async function () { + const str = '{{world}}', + variables = new VariableList(null, [ + { + key: 'world', + type: 'function', + value: async () => { + const x = await new Promise((resolve) => { + resolve('hello'); + }); + + return x; + } + } + ]); + + expect(await Property.replaceSubstitutionsAsync(str, variables)).to.eql('hello'); + }); + + it('should show variables as unresolved with values as async fn with error', async function () { + const str = '{{world}}', + variables = new VariableList(null, [ + { + key: 'world', + type: 'function', + value: async () => { + await new Promise((resolve) => { + resolve('hello'); + }); + throw new Error('fail'); + } + } + ]); + + expect(await Property.replaceSubstitutionsAsync(str, variables)).to.eql('{{world}}'); + }); }); describe('.replaceSubstitutionsIn', function () { @@ -442,6 +495,29 @@ describe('Property', function () { }); }); + describe('.replaceSubstitutionsInAsync', function () { + it('should replace with async variables', async function () { + const obj = { foo: '{{var}}' }, + variables = new VariableList(null, [ + { + key: 'var', + type: 'any', + value: async () => { + const res = await new Promise((resolve) => { + resolve('bar'); + }); + + return res; + } + } + ]), + res = await Property.replaceSubstitutionsInAsync(obj, [variables]); + + expect(res).to.eql({ foo: 'bar' }); + expect(obj).to.eql({ foo: '{{var}}' }); + }); + }); + describe('variable resolution', function () { it('must resolve variables accurately', function () { var unresolvedRequest = { diff --git a/test/unit/variable-scope.test.js b/test/unit/variable-scope.test.js index aee0f53f6..0eaeb5d9b 100644 --- a/test/unit/variable-scope.test.js +++ b/test/unit/variable-scope.test.js @@ -299,6 +299,15 @@ describe('VariableScope', function () { expect(scope.get('var-2')).to.equal('var-2-value'); }); + it('should get the specified variable with value as a fn', function () { + var scope = new VariableScope([ + { key: 'var-1', value: () => { return 'var-1-value'; } }, + { key: 'var-2', value: () => { return 'var-2-value'; } } + ]); + + expect(scope.get('var-2')).to.equal('var-2-value'); + }); + it('should get last enabled from multi value list', function () { var scope = new VariableScope([ { key: 'var-2', value: 'var-2-value' },