diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3da362b..4e55377 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,9 @@ jobs: else brew install autoconf automake libtool fi + python --version + python -m pip install packaging setuptools + - uses: actions/checkout@v3 with: submodules: recursive diff --git a/index.d.ts b/index.d.ts index 21ecda3..57dbe9e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1,4 @@ declare module '@port-labs/jq-node-bindings' { - export function exec(json: object, input: string): object | Array | string | number | boolean | null; + export function exec(json: object, input: string, options?: {enableEnv?: boolean}): object | Array | string | number | boolean | null; + export function renderRecursively(json: object, input: object | Array | string | number | boolean | null): object | Array | string | number | boolean | null; } diff --git a/lib/index.js b/lib/index.js index 9ef9e22..9080512 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,20 +1,8 @@ -const nativeJq = require('bindings')('jq-node-bindings') - -const escapeFilter = (filter) => { - // Escape single quotes only if they are opening or closing a string - return filter.replace(/(^|\s)'(?!\s|")|(? { - try { - const data = nativeJq.exec(JSON.stringify(object), escapeFilter(filter)) - - return data?.value; - } catch (err) { - return null - } - } + exec: jq.exec, + renderRecursively: template.renderRecursively }; - diff --git a/lib/jq.js b/lib/jq.js new file mode 100644 index 0000000..3cffb33 --- /dev/null +++ b/lib/jq.js @@ -0,0 +1,21 @@ +const nativeJq = require('bindings')('jq-node-bindings') + +const formatFilter = (filter, options) => { + // Escape single quotes only if they are opening or closing a string + let formattedFilter = filter.replace(/(^|\s)'(?!\s|")|(? { + try { + const data = nativeJq.exec(JSON.stringify(object), formatFilter(filter, options)) + + return data?.value; + } catch (err) { + return null + } +} + +module.exports = { + exec +}; diff --git a/lib/template.js b/lib/template.js new file mode 100644 index 0000000..04219c5 --- /dev/null +++ b/lib/template.js @@ -0,0 +1,108 @@ +const jq = require('./jq'); + +const findInsideDoubleBracesIndices = (input) => { + let wrappingQuote = null; + let insideDoubleBracesStart = null; + const indices = []; + + for (let i = 0; i < input.length; i += 1) { + const char = input[i]; + + if (char === '"' || char === "'") { + // If inside quotes, ignore braces + if (!wrappingQuote) { + wrappingQuote = char; + } else if (wrappingQuote === char) { + wrappingQuote = null; + } + } else if (!wrappingQuote && char === '{' && i > 0 && input[i - 1] === '{') { + // if opening double braces that not wrapped with quotes + if (insideDoubleBracesStart) { + throw new Error(`Found double braces in index ${i - 1} inside other one in index ${insideDoubleBracesStart - '{{'.length}`); + } + insideDoubleBracesStart = i + 1; + if (input[i + 1] === '{') { + // To overcome three "{" in a row considered as two different opening double braces + i += 1; + } + } else if (!wrappingQuote && char === '}' && i > 0 && input[i - 1] === '}') { + // if closing double braces that not wrapped with quotes + if (insideDoubleBracesStart) { + indices.push({start: insideDoubleBracesStart, end: i - 1}); + insideDoubleBracesStart = null; + if (input[i + 1] === '}') { + // To overcome three "}" in a row considered as two different closing double braces + i += 1; + } + } else { + throw new Error(`Found closing double braces in index ${i - 1} without opening double braces`); + } + } + } + + if (insideDoubleBracesStart) { + throw new Error(`Found opening double braces in index ${insideDoubleBracesStart - '{{'.length} without closing double braces`); + } + + return indices; +} + +const render = (inputJson, template) => { + if (typeof template !== 'string') { + return null; + } + const indices = findInsideDoubleBracesIndices(template); + if (!indices.length) { + // If no jq templates in string, return it + return template; + } + + const firstIndex = indices[0]; + if (indices.length === 1 && template.trim().startsWith('{{') && template.trim().endsWith('}}')) { + // If entire string is a template, evaluate and return the result with the original type + return jq.exec(inputJson, template.slice(firstIndex.start, firstIndex.end)); + } + + let result = template.slice(0, firstIndex.start - '{{'.length); // Initiate result with string until first template start index + indices.forEach((index, i) => { + const jqResult = jq.exec(inputJson, template.slice(index.start, index.end)); + result += + // Add to the result the stringified evaluated jq of the current template + (typeof jqResult === 'string' ? jqResult : JSON.stringify(jqResult)) + + // Add to the result from template end index. if last template index - until the end of string, else until next start index + template.slice( + index.end + '}}'.length, + i + 1 === indices.length ? template.length : indices[i + 1].start - '{{'.length, + ); + }); + + return result; +} + +const renderRecursively = (inputJson, template) => { + if (typeof template === 'string') { + return render(inputJson, template); + } + if (Array.isArray(template)) { + return template.map((value) => renderRecursively(inputJson, value)); + } + if (typeof template === 'object' && template !== null) { + return Object.fromEntries( + Object.entries(template).flatMap(([key, value]) => { + const evaluatedKey = renderRecursively(inputJson, key); + if (!['undefined', 'string'].includes(typeof evaluatedKey) && evaluatedKey !== null) { + throw new Error( + `Evaluated object key should be undefined, null or string. Original key: ${key}, evaluated to: ${JSON.stringify(evaluatedKey)}`, + ); + } + return evaluatedKey ? [[evaluatedKey, renderRecursively(inputJson, value)]] : []; + }), + ); + } + + return template; +} + +module.exports = { + renderRecursively +}; diff --git a/package-lock.json b/package-lock.json index 2046d35..9343484 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@port-labs/jq-node-bindings", - "version": "v0.0.7", + "version": "v0.0.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@port-labs/jq-node-bindings", - "version": "v0.0.7", + "version": "v0.0.9", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -9218,4 +9218,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index c43631e..d0ee1d9 100755 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@port-labs/jq-node-bindings", - "version": "v0.0.8", + "version": "v0.0.9", "description": "Node.js bindings for JQ", - "jq-node-bindings": "0.0.8", + "jq-node-bindings": "0.0.9", "main": "lib/index.js", "scripts": { "configure": "node-gyp configure", @@ -45,4 +45,4 @@ "engines": { "node": ">=6.0.0" } -} \ No newline at end of file +} diff --git a/test/santiy.test.js b/test/santiy.test.js index be507a3..1f0679e 100644 --- a/test/santiy.test.js +++ b/test/santiy.test.js @@ -150,5 +150,12 @@ describe('jq', () => { expect(result).toBe('https://some.random.urlbar-1.bar.longggggbar)test(bartestadsftets'); }) + + it('test disable env', () => { + expect(jq.exec({}, 'env', {enableEnv: false})).toEqual({}); + expect(jq.exec({}, 'env', {enableEnv: true})).not.toEqual({}); + expect(jq.exec({}, 'env', {})).toEqual({}); + expect(jq.exec({}, 'env')).toEqual({}); + }) }) diff --git a/test/template.test.js b/test/template.test.js new file mode 100644 index 0000000..1817557 --- /dev/null +++ b/test/template.test.js @@ -0,0 +1,140 @@ +const jq = require('../lib'); + +describe('template', () => { + it('should break', () => { + const json = { foo2: 'bar' }; + const input = '{{.foo}}'; + const result = jq.renderRecursively(json, input); + + expect(result).toBe(null); + }); + it('non template should work', () => { + const json = { foo2: 'bar' }; + const render = (input) => jq.renderRecursively(json, input); + + expect(render(123)).toBe(123); + expect(render(undefined)).toBe(undefined); + expect(render(null)).toBe(null); + expect(render(true)).toBe(true); + expect(render(false)).toBe(false); + }); + it('different types should work', () => { + const input = '{{.foo}}'; + const render = (json) => jq.renderRecursively(json, input); + + expect(render({ foo: 'bar' })).toBe('bar'); + expect(render({ foo: 1 })).toBe(1); + expect(render({ foo: true })).toBe(true); + expect(render({ foo: null })).toBe(null); + expect(render({ foo: undefined })).toBe(null); + expect(render({ foo: ['bar'] })).toEqual(['bar']); + expect(render({ foo: [{ bar: 'bar' }] })).toEqual([{ bar: 'bar' }]); + expect(render({ foo: {prop1: "1"} })).toEqual({prop1: "1"}); + expect(render({ foo: {obj: { obj2: { num: 1, string: "str"} }} })).toEqual({obj: { obj2: { num: 1, string: "str"} }}); + expect(render({ foo: { obj: { obj2: { num: 1, string: "str", bool: true} }} })).toEqual({ obj: { obj2: { num: 1, string: "str", bool: true} }}); + }); + it ('should return undefined', () => { + const json = { foo: 'bar' }; + const input = '{{empty}}'; + const result = jq.renderRecursively(json, input); + + expect(result).toBe(undefined); + }); + it ('should return null on invalid json', () => { + const json = "foo"; + const input = '{{.foo}}'; + const result = jq.renderRecursively(json, input); + + expect(result).toBe(undefined); + }); + it('should excape \'\' to ""', () => { + const json = { foo: 'com' }; + const input = "{{'https://url.' + .foo}}"; + const result = jq.renderRecursively(json, input); + + expect(result).toBe('https://url.com'); + }); + it('should not escape \' in the middle of the string', () => { + const json = { foo: 'com' }; + const input = "{{\"https://'url.\" + 'test.' + .foo}}"; + const result = jq.renderRecursively(json, input); + + expect(result).toBe("https://'url.test.com"); + }); + it ('should run a jq function succesfully', () => { + const json = { foo: 'bar' }; + const input = '{{.foo | gsub("bar";"foo")}}'; + const result = jq.renderRecursively(json, input); + + expect(result).toBe('foo'); + }); + it ('Testing multiple the \'\' in the same expression', () => { + const json = { foo: 'bar' }; + const input = "{{'https://some.random.url' + .foo + '-1' + '.' + .foo + '.' + 'longgggg' + .foo + ')test(' + .foo + 'testadsftets'}}"; + const result = jq.renderRecursively(json, input); + + expect(result).toBe('https://some.random.urlbar-1.bar.longggggbar)test(bartestadsftets'); + }); + it ('Testing multiple the \'\' in the same expression', () => { + const json = { foo: 'bar' }; + const input = "{{'https://some.random.url' + .foo + '-1' + '.' + .foo + '.' + 'longgggg' + .foo + ')test(' + .foo + 'testadsftets'}}"; + const result = jq.renderRecursively(json, input); + + expect(result).toBe('https://some.random.urlbar-1.bar.longggggbar)test(bartestadsftets'); + }); + it('should break for invalid template', () => { + const json = { foo: 'bar' }; + const render = (input) => () => jq.renderRecursively(json, input); + + expect(render('prefix{{.foo}postfix')).toThrow('Found opening double braces in index 6 without closing double braces'); + expect(render('prefix{.foo}}postfix')).toThrow('Found closing double braces in index 11 without opening double braces'); + expect(render('prefix{{ .foo {{ }}postfix')).toThrow('Found double braces in index 14 inside other one in index 6'); + expect(render('prefix{{ .foo }} }}postfix')).toThrow('Found closing double braces in index 17 without opening double braces'); + expect(render('prefix{{ .foo }} }}postfix')).toThrow('Found closing double braces in index 17 without opening double braces'); + expect(render('prefix{{ "{{" + .foo }} }}postfix')).toThrow('Found closing double braces in index 24 without opening double braces'); + expect(render('prefix{{ \'{{\' + .foo }} }}postfix')).toThrow('Found closing double braces in index 24 without opening double braces'); + expect(render({'{{1}}': 'bar'})).toThrow('Evaluated object key should be undefined, null or string. Original key: {{1}}, evaluated to: 1'); + expect(render({'{{true}}': 'bar'})).toThrow('Evaluated object key should be undefined, null or string. Original key: {{true}}, evaluated to: true'); + expect(render({'{{ {} }}': 'bar'})).toThrow('Evaluated object key should be undefined, null or string. Original key: {{ {} }}, evaluated to: {}'); + }); + it('should concat string and other types', () => { + const input = 'https://some.random.url?q={{.foo}}'; + const render = (json) => jq.renderRecursively(json, input); + + expect(render({ foo: 'bar' })).toBe('https://some.random.url?q=bar'); + expect(render({ foo: 1 })).toBe('https://some.random.url?q=1'); + expect(render({ foo: false })).toBe('https://some.random.url?q=false'); + expect(render({ foo: null })).toBe('https://some.random.url?q=null'); + expect(render({ foo: undefined })).toBe('https://some.random.url?q=null'); + expect(render({ foo: [1] })).toBe('https://some.random.url?q=[1]'); + expect(render({ foo: {bar: 'bar'} })).toBe('https://some.random.url?q={\"bar\":\"bar\"}'); + }); + it('testing multiple template blocks', () => { + const json = {str: 'bar', num: 1, bool: true, 'null': null, arr: ['foo'], obj: {bar: 'bar'}}; + const input = 'https://some.random.url?str={{.str}}&num={{.num}}&bool={{.bool}}&null={{.null}}&arr={{.arr}}&obj={{.obj}}'; + const result = jq.renderRecursively(json, input); + + expect(result).toBe("https://some.random.url?str=bar&num=1&bool=true&null=null&arr=[\"foo\"]&obj={\"bar\":\"bar\"}"); + }); + it('testing conditional key', () => { + const json = {}; + const render = (input) => jq.renderRecursively(json, input); + + expect(render({'{{empty}}': 'bar'})).toEqual({}); + expect(render({'{{null}}': 'bar'})).toEqual({}); + expect(render({'{{""}}': 'bar'})).toEqual({}); + expect(render({'{{\'\'}}': 'bar'})).toEqual({}); + }); + it('recursive templates should work', () => { + const json = { foo: 'bar', bar: 'foo' }; + const render = (input) => jq.renderRecursively(json, input); + + expect(render({'{{.foo}}': '{{.bar}}{{.foo}}'})).toEqual({bar: 'foobar'}); + expect(render({'{{.foo}}': {foo: '{{.foo}}'}})).toEqual({bar: {foo: 'bar'}}); + expect(render([1, true, null, undefined, '{{.foo}}', 'https://{{.bar}}.com'])).toEqual([1, true, null, undefined, 'bar', 'https://foo.com']); + expect(render([['{{.bar}}{{.foo}}'], 1, '{{.bar | ascii_upcase}}'])).toEqual([['foobar'], 1, 'FOO']); + expect(render([{'{{.bar}}': [false, '/foo/{{.foo + .bar}}']}])).toEqual([{foo: [false, '/foo/barfoo']}]); + expect(render({foo: [{bar: '{{1}}'}, '{{empty}}']})).toEqual({foo: [{bar: 1}, undefined]}); + }); +}) +