mirror of
https://github.com/usebruno/bruno.git
synced 2024-11-07 16:44:27 +01:00
Merge pull request #90 from ajaishankar/feature/expression-eval
Compiled and cached expressions
This commit is contained in:
commit
b852d1cc52
@ -9,6 +9,9 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vm2": "^3.9.13"
|
"vm2": "^3.9.13"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest --testPathIgnorePatterns test.js"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"atob": "^2.1.2",
|
"atob": "^2.1.2",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
|
@ -1,15 +1,69 @@
|
|||||||
const jsonQuery = require('json-query');
|
const jsonQuery = require('json-query');
|
||||||
|
|
||||||
|
const JS_KEYWORDS = `
|
||||||
|
break case catch class const continue debugger default delete do
|
||||||
|
else export extends false finally for function if import in instanceof
|
||||||
|
new null return super switch this throw true try typeof var void while with
|
||||||
|
undefined let static yield arguments of
|
||||||
|
`.split(/\s+/).filter(word => word.length > 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a function from a Javascript expression
|
||||||
|
*
|
||||||
|
* When the function is called, the variables used in this expression are picked up from the context
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* res.data.pets.map(pet => pet.name.toUpperCase())
|
||||||
|
*
|
||||||
|
* function(context) {
|
||||||
|
* const { res, pet } = context;
|
||||||
|
* return res.data.pets.map(pet => pet.name.toUpperCase())
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const compileJsExpression = (expr) => {
|
||||||
|
// get all dotted identifiers (foo, bar.baz, .baz)
|
||||||
|
const matches = expr.match(/([\w\.$]+)/g) ?? [];
|
||||||
|
|
||||||
|
// get valid js identifiers (foo, bar)
|
||||||
|
const vars = new Set(
|
||||||
|
matches
|
||||||
|
.filter(match => /^[a-zA-Z$_]/.test(match)) // starts with valid js identifier (foo.bar)
|
||||||
|
.map(match => match.split('.')[0]) // top level identifier (foo)
|
||||||
|
.filter(name => !JS_KEYWORDS.includes(name)) // exclude js keywords
|
||||||
|
);
|
||||||
|
|
||||||
|
// globals such as Math
|
||||||
|
const globals = [...vars].filter(name => name in globalThis);
|
||||||
|
|
||||||
|
const code = {
|
||||||
|
vars: [...vars].join(", "),
|
||||||
|
// pick global from context or globalThis
|
||||||
|
globals: globals
|
||||||
|
.map(name => ` ${name} = ${name} ?? globalThis.${name};`)
|
||||||
|
.join('')
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = `let { ${code.vars} } = context; ${code.globals}; return ${expr}`;
|
||||||
|
|
||||||
|
return new Function("context", body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const internalExpressionCache = new Map();
|
||||||
|
|
||||||
const evaluateJsExpression = (expression, context) => {
|
const evaluateJsExpression = (expression, context) => {
|
||||||
const fn = new Function(...Object.keys(context), `return ${expression}`);
|
let fn = internalExpressionCache.get(expression);
|
||||||
return fn(...Object.values(context));
|
if (fn == null) {
|
||||||
|
internalExpressionCache.set(expression, fn = compileJsExpression(expression));
|
||||||
|
}
|
||||||
|
return fn(context);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createResponseParser = (response = {}) => {
|
const createResponseParser = (response = {}) => {
|
||||||
const res = (expr) => {
|
const res = (expr) => {
|
||||||
const output = jsonQuery(expr, { data: response.data });
|
const output = jsonQuery(expr, { data: response.data });
|
||||||
return output ? output.value : null;
|
return output ? output.value : null;
|
||||||
}
|
};
|
||||||
|
|
||||||
res.status = response.status;
|
res.status = response.status;
|
||||||
res.statusText = response.statusText;
|
res.statusText = response.statusText;
|
||||||
@ -21,5 +75,6 @@ const createResponseParser = (response = {}) => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
evaluateJsExpression,
|
evaluateJsExpression,
|
||||||
createResponseParser
|
createResponseParser,
|
||||||
|
internalExpressionCache
|
||||||
};
|
};
|
115
packages/bruno-js/tests/utils.spec.js
Normal file
115
packages/bruno-js/tests/utils.spec.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
const { evaluateJsExpression, internalExpressionCache: cache } = require("../src/utils");
|
||||||
|
|
||||||
|
describe("utils", () => {
|
||||||
|
describe("expression evaluation", () => {
|
||||||
|
const context = {
|
||||||
|
res: {
|
||||||
|
data: { pets: ["bruno", "max"] }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => cache.clear());
|
||||||
|
afterEach(() => cache.clear());
|
||||||
|
|
||||||
|
it("should evaluate expression", () => {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
result = evaluateJsExpression("res.data.pets", context);
|
||||||
|
expect(result).toEqual(["bruno", "max"]);
|
||||||
|
|
||||||
|
result = evaluateJsExpression("res.data.pets[0].toUpperCase()", context);
|
||||||
|
expect(result).toEqual("BRUNO");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should cache expression", () => {
|
||||||
|
expect(cache.size).toBe(0);
|
||||||
|
evaluateJsExpression("res.data.pets", context);
|
||||||
|
expect(cache.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use cached expression", () => {
|
||||||
|
const expr = "res.data.pets";
|
||||||
|
|
||||||
|
evaluateJsExpression(expr, context);
|
||||||
|
|
||||||
|
const fn = cache.get(expr);
|
||||||
|
expect(fn).toBeDefined();
|
||||||
|
|
||||||
|
evaluateJsExpression(expr, context);
|
||||||
|
|
||||||
|
// cache should not be overwritten
|
||||||
|
expect(cache.get(expr)).toBe(fn);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should identify top level variables", () => {
|
||||||
|
const expr = "res.data.pets[0].toUpperCase()";
|
||||||
|
evaluateJsExpression(expr, context);
|
||||||
|
expect(cache.get(expr).toString()).toContain("let { res } = context;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not duplicate variables", () => {
|
||||||
|
const expr = "res.data.pets[0] + res.data.pets[1]";
|
||||||
|
evaluateJsExpression(expr, context);
|
||||||
|
expect(cache.get(expr).toString()).toContain("let { res } = context;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should exclude js keywords like true false from vars", () => {
|
||||||
|
const expr = "res.data.pets.length > 0 ? true : false";
|
||||||
|
evaluateJsExpression(expr, context);
|
||||||
|
expect(cache.get(expr).toString()).toContain("let { res } = context;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should exclude numbers from vars", () => {
|
||||||
|
const expr = "res.data.pets.length + 10";
|
||||||
|
evaluateJsExpression(expr, context);
|
||||||
|
expect(cache.get(expr).toString()).toContain("let { res } = context;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pick variables from complex expressions", () => {
|
||||||
|
const expr = "res.data.pets.map(pet => pet.length)";
|
||||||
|
const result = evaluateJsExpression(expr, context);
|
||||||
|
expect(result).toEqual([5, 3]);
|
||||||
|
expect(cache.get(expr).toString()).toContain("let { res, pet } = context;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be ok picking extra vars from strings", () => {
|
||||||
|
const expr = "'hello' + ' ' + res.data.pets[0]";
|
||||||
|
const result = evaluateJsExpression(expr, context);
|
||||||
|
expect(result).toBe("hello bruno");
|
||||||
|
// extra var hello is harmless
|
||||||
|
expect(cache.get(expr).toString()).toContain("let { hello, res } = context;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should evaluate expressions referencing globals", () => {
|
||||||
|
const startTime = new Date("2022-02-01").getTime();
|
||||||
|
const currentTime = new Date("2022-02-02").getTime();
|
||||||
|
|
||||||
|
jest.useFakeTimers({ now: currentTime });
|
||||||
|
|
||||||
|
const expr = "Math.max(Date.now(), startTime)";
|
||||||
|
const result = evaluateJsExpression(expr, { startTime });
|
||||||
|
|
||||||
|
expect(result).toBe(currentTime);
|
||||||
|
|
||||||
|
expect(cache.get(expr).toString()).toContain("Math = Math ?? globalThis.Math;");
|
||||||
|
expect(cache.get(expr).toString()).toContain("Date = Date ?? globalThis.Date;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use global overridden in context", () => {
|
||||||
|
const startTime = new Date("2022-02-01").getTime();
|
||||||
|
const currentTime = new Date("2022-02-02").getTime();
|
||||||
|
|
||||||
|
jest.useFakeTimers({ now: currentTime });
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
Date: { now: () => new Date("2022-01-31").getTime() },
|
||||||
|
startTime
|
||||||
|
};
|
||||||
|
|
||||||
|
const expr = "Math.max(Date.now(), startTime)";
|
||||||
|
const result = evaluateJsExpression(expr, context);
|
||||||
|
|
||||||
|
expect(result).toBe(startTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user