Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for jq template #6

Merged
merged 5 commits into from
Dec 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]});
});
})