diff --git a/src/tools/cleaner.js b/src/tools/cleaner.js index 9594212..31f1140 100644 --- a/src/tools/cleaner.js +++ b/src/tools/cleaner.js @@ -3,9 +3,9 @@ const _ = require('lodash'); const { defaults, pick, pipe } = require('lodash/fp'); -const isPlainObj = require('./data').isPlainObj; -const recurseReplace = require('./data').recurseReplace; -const flattenPaths = require('./data').flattenPaths; +const { isPlainObj, getObjectType } = require('./data'); +const { recurseReplace } = require('./data'); +const { flattenPaths } = require('./data'); const DEFAULT_BUNDLE = { authData: {}, @@ -43,11 +43,34 @@ const recurseReplaceBank = (obj, bank = {}) => { if (typeof out !== 'string') { return out; } + Object.keys(bank).forEach(key => { // Escape characters (ex. {{foo}} => \\{\\{foo\\}\\} ) - const s = String(key).replace(/[-[\]/{}()\\*+?.^$|]/g, '\\$&'); - const re = new RegExp(s, 'g'); - out = out.replace(re, bank[key]); + const escapedKey = String(key).replace(/[-[\]/{}()\\*+?.^$|]/g, '\\$&'); + const matchesKey = new RegExp(escapedKey, 'g'); + + if (!matchesKey.test(out)) { + return; + } + + const valueParts = out.split(/({{.*?}})/).filter(Boolean); + const replacementValue = bank[key]; + + if (valueParts.length > 1) { + if (_.isArray(replacementValue) || _.isPlainObject(replacementValue)) { + throw new TypeError( + [ + 'Cannot interpolate objects or arrays into a string.', + `You've sent an ${getObjectType(replacementValue)},`, + `which will get coerced to ${replacementValue}` + ].join(' ') + ); + } + + out = valueParts.join('').replace(matchesKey, replacementValue); + } else { + out = replacementValue; + } }); return out; diff --git a/src/tools/data.js b/src/tools/data.js index 87013a0..9e276a2 100644 --- a/src/tools/data.js +++ b/src/tools/data.js @@ -14,6 +14,18 @@ const isPlainObj = o => { const comparison = (obj, needle) => obj === needle; +const getObjectType = obj => { + if (_.isPlainObject(obj)) { + return 'Object'; + } + + if (_.isArray(obj)) { + return 'Array'; + } + + return _.capitalize(typeof obj); +}; + // Returns a path for the deeply nested haystack where // you could find the needle. If the needle is a plain // object we try _.isEqual (which could be slow!). @@ -130,8 +142,7 @@ const recurseExtract = (obj, matcher) => { const _IGNORE = {}; // Flatten a nested object. -const flattenPaths = (data, sep) => { - sep = sep || '.'; +const flattenPaths = (data, sep = '.') => { const out = {}; const recurse = (obj, prop) => { prop = prop || ''; @@ -179,6 +190,7 @@ module.exports = { findMapDeep, flattenPaths, genId, + getObjectType, isPlainObj, jsonCopy, memoizedFindMapDeep, diff --git a/test/create-request-client.js b/test/create-request-client.js index 4e8fe46..db4a85a 100644 --- a/test/create-request-client.js +++ b/test/create-request-client.js @@ -534,4 +534,117 @@ describe('request client', () => { }); }); }); + + describe('resolves body and header curlies', () => { + it('should keep valid data types', () => { + const event = { + bundle: { + inputData: { + number: 123, + bool: true, + float: 123.456, + arr: [1, 2, 3] + } + } + }; + const bodyInput = createInput({}, event, testLogger); + const request = createAppRequestClient(bodyInput); + return request({ + url: 'http://zapier-httpbin.herokuapp.com/post', + method: 'POST', + body: { + number: '{{bundle.inputData.number}}', + bool: '{{bundle.inputData.bool}}', + float: '{{bundle.inputData.float}}', + arr: '{{bundle.inputData.arr}}' + } + }).then(response => { + const { json } = response.json; + + json.number.should.eql(123); + json.bool.should.eql(true); + json.float.should.eql(123.456); + json.arr.should.eql([1, 2, 3]); + }); + }); + + it('should interpolate strings', () => { + const event = { + bundle: { + inputData: { + resourceId: 123 + }, + authData: { + access_token: 'Let me in' + } + } + }; + const bodyInput = createInput({}, event, testLogger); + const request = createAppRequestClient(bodyInput); + return request({ + url: 'http://zapier-httpbin.herokuapp.com/post', + method: 'POST', + body: { + message: 'We just got #{{bundle.inputData.resourceId}}' + }, + headers: { + Authorization: 'Bearer {{bundle.authData.access_token}}' + } + }).then(response => { + const { json, headers } = response.json; + + json.message.should.eql('We just got #123'); + headers.Authorization.should.eql('Bearer Let me in'); + }); + }); + + it('should throw when interpolating a string with an array', () => { + const event = { + bundle: { + inputData: { + badData: [1, 2, 3] + } + } + }; + const bodyInput = createInput({}, event, testLogger); + const request = createAppRequestClient(bodyInput); + return request({ + url: 'http://zapier-httpbin.herokuapp.com/post', + method: 'POST', + body: { + message: 'No arrays, thank you: {{bundle.inputData.badData}}' + } + }).should.be.rejectedWith( + "Cannot interpolate objects or arrays into a string. You've sent an Array, which will get coerced to 1,2,3" + ); + }); + + it('should send flatten objects', () => { + const event = { + bundle: { + inputData: { + address: { + street: '123 Zapier Way', + city: 'El Mundo' + } + } + } + }; + const bodyInput = createInput({}, event, testLogger); + const request = createAppRequestClient(bodyInput); + return request({ + url: 'http://zapier-httpbin.herokuapp.com/post', + method: 'POST', + body: { + streetAddress: '{{bundle.inputData.address.street}}', + city: '{{bundle.inputData.address.city}}' + } + }).then(response => { + const { json } = response.json; + + json.streetAddress.should.eql('123 Zapier Way'); + json.city.should.eql('El Mundo'); + }); + }); + }); });