Skip to content

Commit

Permalink
Merge pull request #6 from port-labs/jq-template
Browse files Browse the repository at this point in the history
Add support for jq template
  • Loading branch information
talsabagport authored Dec 25, 2023
2 parents 9cb3666 + b1b8bf0 commit 4dcf294
Show file tree
Hide file tree
Showing 9 changed files with 291 additions and 23 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion index.d.ts
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;
}
20 changes: 4 additions & 16 deletions lib/index.js
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
};

21 changes: 21 additions & 0 deletions lib/jq.js
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
};
108 changes: 108 additions & 0 deletions lib/template.js
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
};
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -45,4 +45,4 @@
"engines": {
"node": ">=6.0.0"
}
}
}
7 changes: 7 additions & 0 deletions test/santiy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
})
})

140 changes: 140 additions & 0 deletions test/template.test.js
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]});
});
})

0 comments on commit 4dcf294

Please sign in to comment.