Skip to content

Commit

Permalink
Merge branch 'main' of github.com:usebruno/bruno
Browse files Browse the repository at this point in the history
  • Loading branch information
helloanoop committed Feb 12, 2023
2 parents 943e74c + b852d1c commit 9d395de
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 5 deletions.
3 changes: 3 additions & 0 deletions packages/bruno-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"peerDependencies": {
"vm2": "^3.9.13"
},
"scripts": {
"test": "jest --testPathIgnorePatterns test.js"
},
"dependencies": {
"atob": "^2.1.2",
"ajv": "^8.12.0",
Expand Down
65 changes: 60 additions & 5 deletions packages/bruno-js/src/utils.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,69 @@
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 fn = new Function(...Object.keys(context), `return ${expression}`);
return fn(...Object.values(context));
let fn = internalExpressionCache.get(expression);
if (fn == null) {
internalExpressionCache.set(expression, fn = compileJsExpression(expression));
}
return fn(context);
};

const createResponseParser = (response = {}) => {
const res = (expr) => {
const res = (expr) => {
const output = jsonQuery(expr, { data: response.data });
return output ? output.value : null;
}
};

res.status = response.status;
res.statusText = response.statusText;
Expand All @@ -21,5 +75,6 @@ const createResponseParser = (response = {}) => {

module.exports = {
evaluateJsExpression,
createResponseParser
createResponseParser,
internalExpressionCache
};
115 changes: 115 additions & 0 deletions packages/bruno-js/tests/utils.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

0 comments on commit 9d395de

Please sign in to comment.