-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #6 from port-labs/jq-template
Add support for jq template
- Loading branch information
Showing
9 changed files
with
291 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
declare module '@port-labs/jq-node-bindings' { | ||
export function exec(json: object, input: string): object | Array<any> | string | number | boolean | null; | ||
export function exec(json: object, input: string, options?: {enableEnv?: boolean}): object | Array<any> | string | number | boolean | null; | ||
export function renderRecursively(json: object, input: object | Array<any> | string | number | boolean | null): object | Array<any> | string | number | boolean | null; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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|")|(?<!\s|")'(\s|$)/g, '$1"$2'); | ||
} | ||
const jq = require('./jq'); | ||
const template = require('./template'); | ||
|
||
|
||
module.exports = { | ||
exec: (object, filter) => { | ||
try { | ||
const data = nativeJq.exec(JSON.stringify(object), escapeFilter(filter)) | ||
|
||
return data?.value; | ||
} catch (err) { | ||
return null | ||
} | ||
} | ||
exec: jq.exec, | ||
renderRecursively: template.renderRecursively | ||
}; | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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|")|(?<!\s|")'(\s|$)/g, '$1"$2'); | ||
// Conditionally enable access to env | ||
return options.enableEnv ? formattedFilter: `def env: {}; {} as $ENV | ${formattedFilter}`; | ||
} | ||
const exec = (object, filter, options = { enableEnv: false }) => { | ||
try { | ||
const data = nativeJq.exec(JSON.stringify(object), formatFilter(filter, options)) | ||
|
||
return data?.value; | ||
} catch (err) { | ||
return null | ||
} | ||
} | ||
|
||
module.exports = { | ||
exec | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
}; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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]}); | ||
}); | ||
}) | ||
|