From 1b0ff9f6f1031d4e8dbced7ba7ebf661e6d91c09 Mon Sep 17 00:00:00 2001 From: stefan-gorules <127550877+stefan-gorules@users.noreply.github.com> Date: Thu, 14 Sep 2023 16:07:55 +0200 Subject: [PATCH] feat: expose expressions through bindings; (#86) * feat: expose expressions through bindings; * fix fmt --- Cargo.toml | 2 + bindings/nodejs/Cargo.toml | 1 + bindings/nodejs/index.d.ts | 2 + bindings/nodejs/index.js | 4 +- bindings/nodejs/src/expression.rs | 50 ++++++++++++++ bindings/nodejs/src/lib.rs | 1 + bindings/nodejs/test/decision.spec.ts | 93 ++++++++++++++++++--------- bindings/python/Cargo.toml | 7 +- bindings/python/index.py | 9 ++- bindings/python/src/expression.rs | 58 +++++++++++++++++ bindings/python/src/lib.rs | 7 +- core/engine/Cargo.toml | 12 ++-- core/expression/Cargo.toml | 1 + core/expression/src/isolate.rs | 38 +++++++++++ 14 files changed, 241 insertions(+), 44 deletions(-) create mode 100644 bindings/nodejs/src/expression.rs create mode 100644 bindings/python/src/expression.rs diff --git a/Cargo.toml b/Cargo.toml index e40ec581..14b2eadb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +resolver = "2" members = [ "core/*", "bindings/*" @@ -18,6 +19,7 @@ petgraph = "0.6.4" phf = "0.11.1" regex = "1.9.1" regex-lite = "0.1.0" +serde = "1.0.188" serde_json = "1.0.96" hashbrown = "0.13.2" rust_decimal = "1.29.1" diff --git a/bindings/nodejs/Cargo.toml b/bindings/nodejs/Cargo.toml index 9b50449e..661ab65f 100644 --- a/bindings/nodejs/Cargo.toml +++ b/bindings/nodejs/Cargo.toml @@ -15,6 +15,7 @@ napi-derive = "2.13.0" serde_json = "1.0.96" futures = "0.3.28" zen-engine = { path = "../../core/engine" } +zen-expression = { path = "../../core/expression" } [build-dependencies] napi-build = "2.0.1" \ No newline at end of file diff --git a/bindings/nodejs/index.d.ts b/bindings/nodejs/index.d.ts index 7392a834..bd30c05d 100644 --- a/bindings/nodejs/index.d.ts +++ b/bindings/nodejs/index.d.ts @@ -10,6 +10,8 @@ export interface ZenEvaluateOptions { export interface ZenEngineOptions { loader?: (key: string) => Promise } +export function evaluateExpression(expression: string, context?: any | undefined | null): Promise +export function evaluateUnaryExpression(expression: string, context: any): Promise export class ZenDecision { constructor() evaluate(context: any, opts?: ZenEvaluateOptions | undefined | null): Promise diff --git a/bindings/nodejs/index.js b/bindings/nodejs/index.js index 60688363..7200507d 100644 --- a/bindings/nodejs/index.js +++ b/bindings/nodejs/index.js @@ -246,7 +246,9 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { ZenDecision, ZenEngine } = nativeBinding +const { ZenDecision, ZenEngine, evaluateExpression, evaluateUnaryExpression } = nativeBinding module.exports.ZenDecision = ZenDecision module.exports.ZenEngine = ZenEngine +module.exports.evaluateExpression = evaluateExpression +module.exports.evaluateUnaryExpression = evaluateUnaryExpression diff --git a/bindings/nodejs/src/expression.rs b/bindings/nodejs/src/expression.rs new file mode 100644 index 00000000..582bfe32 --- /dev/null +++ b/bindings/nodejs/src/expression.rs @@ -0,0 +1,50 @@ +use napi::anyhow::anyhow; +use napi_derive::napi; +use serde_json::Value; + +use zen_expression::isolate::Isolate; + +#[allow(dead_code)] +#[napi] +pub async fn evaluate_expression( + expression: String, + context: Option, +) -> napi::Result { + let result: Value = napi::tokio::spawn(async move { + let isolate = Isolate::default(); + if let Some(ctx) = context { + isolate.inject_env(&ctx); + } + + isolate.run_standard(expression.as_str()) + }) + .await + .map_err(|_| anyhow!("Hook timed out"))? + .map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?; + + Ok(result) +} + +#[allow(dead_code)] +#[napi] +pub async fn evaluate_unary_expression(expression: String, context: Value) -> napi::Result { + let Some(context_object) = context.as_object() else { + return Err(anyhow!("Context must be an object").into()); + }; + + if !context_object.contains_key("$") { + return Err(anyhow!("Context must contain '$' reference.").into()); + } + + let result: Value = napi::tokio::spawn(async move { + let isolate = Isolate::default(); + isolate.inject_env(&context); + + isolate.run_unary(expression.as_str()) + }) + .await + .map_err(|_| anyhow!("Hook timed out"))? + .map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?; + + Ok(result) +} diff --git a/bindings/nodejs/src/lib.rs b/bindings/nodejs/src/lib.rs index 610bdf9f..9068031c 100644 --- a/bindings/nodejs/src/lib.rs +++ b/bindings/nodejs/src/lib.rs @@ -1,3 +1,4 @@ mod decision; mod engine; +mod expression; mod loader; diff --git a/bindings/nodejs/test/decision.spec.ts b/bindings/nodejs/test/decision.spec.ts index 431f07bb..0e1a2752 100644 --- a/bindings/nodejs/test/decision.spec.ts +++ b/bindings/nodejs/test/decision.spec.ts @@ -1,4 +1,4 @@ -import {ZenEngine} from "../index"; +import {ZenEngine, evaluateExpression, evaluateUnaryExpression} from "../index"; import fs from 'fs/promises'; import path from 'path'; import {describe, expect, it, jest} from "@jest/globals"; @@ -10,43 +10,72 @@ const loader = async (key: string) => fs.readFile(path.join(testDataRoot, key)) jest.useRealTimers(); describe('ZenEngine', () => { - it('Evaluates decisions using loader', async () => { - const engine = new ZenEngine({ - loader - }); + it('Evaluates decisions using loader', async () => { + const engine = new ZenEngine({ + loader + }); - const r1 = await engine.evaluate('function.json', {input: 5}); - const r2 = await engine.evaluate('table.json', {input: 2}); - const r3 = await engine.evaluate('table.json', {input: 12}); + const r1 = await engine.evaluate('function.json', {input: 5}); + const r2 = await engine.evaluate('table.json', {input: 2}); + const r3 = await engine.evaluate('table.json', {input: 12}); - expect(r1.result.output).toEqual(10); - expect(r2.result.output).toEqual(0); - expect(r3.result.output).toEqual(10); - }, 10000); + expect(r1.result.output).toEqual(10); + expect(r2.result.output).toEqual(0); + expect(r3.result.output).toEqual(10); + }, 10000); - it('Evaluates decisions using getDecision', async () => { - const engine = new ZenEngine({ - loader, - }); + it('Evaluates decisions using getDecision', async () => { + const engine = new ZenEngine({ + loader, + }); - const functionDecision = await engine.getDecision('function.json'); - const tableDecision = await engine.getDecision('table.json'); + const functionDecision = await engine.getDecision('function.json'); + const tableDecision = await engine.getDecision('table.json'); - const r1 = await functionDecision.evaluate({input: 10}); - const r2 = await tableDecision.evaluate({input: 5}); - const r3 = await tableDecision.evaluate({input: 12}); + const r1 = await functionDecision.evaluate({input: 10}); + const r2 = await tableDecision.evaluate({input: 5}); + const r3 = await tableDecision.evaluate({input: 12}); - expect(r1.result.output).toEqual(20); - expect(r2.result.output).toEqual(0); - expect(r3.result.output).toEqual(10); - }, 10000); + expect(r1.result.output).toEqual(20); + expect(r2.result.output).toEqual(0); + expect(r3.result.output).toEqual(10); + }, 10000); - it('Creates a decision from contents', async () => { - const engine = new ZenEngine(); - const functionContent = await fs.readFile(path.join(testDataRoot, 'function.json')); - const functionDecision = engine.createDecision(functionContent); + it('Creates a decision from contents', async () => { + const engine = new ZenEngine(); + const functionContent = await fs.readFile(path.join(testDataRoot, 'function.json')); + const functionDecision = engine.createDecision(functionContent); - const r = await functionDecision.evaluate({input: 15}); - expect(r.result.output).toEqual(30); - }, 10000) + const r = await functionDecision.evaluate({input: 15}); + expect(r.result.output).toEqual(30); + }, 10000) }) + +describe('Expressions', () => { + it('Evaluates standard expressions', async () => { + const expressions = [ + {expression: '1 + 1', result: 2}, + {expression: 'a > b', context: {a: 5, b: 3}, result: true}, + {expression: 'sum(a)', context: {a: [1, 2, 3, 4]}, result: 10}, + {expression: 'contains("some", "none")', result: false}, + {expression: 'matches("test@email.com", "\\w+@\\w+\\.com")', result: true}, + ]; + + for (const {expression, result, context} of expressions) { + expect(await evaluateExpression(expression, context)).toEqual(result); + } + }); + + it('Evaluates unary expressions', async () => { + const expressions = [ + {expression: '>= 5', context: {$: 5}, result: true}, + {expression: '< 5', context: {$: 5}, result: false}, + {expression: '"FR", "ES"', context: {$: 'GB'}, result: false}, + {expression: 'contains($, "some")', context: {$: 'some-string'}, result: true}, + ]; + + for (const {expression, result, context} of expressions) { + expect(await evaluateUnaryExpression(expression, context)).toEqual(result); + } + }); +}); \ No newline at end of file diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index 4181eee5..29b78f27 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -12,9 +12,10 @@ crate-type = ["cdylib"] [dependencies] async-trait = "0.1.68" anyhow = "1.0.71" -pyo3 = { version = "0.18.3", features = ["anyhow", "serde"] } -pythonize = "0.18.0" +pyo3 = { version = "0.19.2", features = ["anyhow", "serde"] } +pythonize = "0.19.0" serde = "1.0.163" serde_json = "1.0.96" futures = "0.3.28" -zen-engine = { path = "../../core/engine" } \ No newline at end of file +zen-engine = { path = "../../core/engine" } +zen-expression = { path = "../../core/expression" } \ No newline at end of file diff --git a/bindings/python/index.py b/bindings/python/index.py index 19c8717b..8cc45c51 100644 --- a/bindings/python/index.py +++ b/bindings/python/index.py @@ -28,7 +28,6 @@ class ZenEngine(unittest.TestCase): r2 = tableDecision.evaluate({"input": 5}) r3 = tableDecision.evaluate({"input": 12}) - print(r1) self.assertEqual(r1["result"]["output"], 20) self.assertEqual(r2["result"]["output"], 0) self.assertEqual(r3["result"]["output"], 10) @@ -42,5 +41,13 @@ class ZenEngine(unittest.TestCase): r = functionDecision.evaluate({"input": 15}) self.assertEqual(r["result"]["output"], 30) + def test_evaluate_expression(self): + result = zen.evaluate_expression("sum(a)", { "a": [1, 2, 3, 4] }) + self.assertEqual(result, 10) + + def test_evaluate_unary_expression(self): + result = zen.evaluate_unary_expression("'FR', 'ES', 'GB'", { "$": "GB" }) + self.assertEqual(result, True) + # run the test unittest.main() diff --git a/bindings/python/src/expression.rs b/bindings/python/src/expression.rs new file mode 100644 index 00000000..d455d7b1 --- /dev/null +++ b/bindings/python/src/expression.rs @@ -0,0 +1,58 @@ +use anyhow::{anyhow, Context}; +use pyo3::types::PyDict; +use pyo3::{pyfunction, PyObject, PyResult, Python, ToPyObject}; +use pythonize::depythonize; +use serde_json::Value; + +use zen_expression::isolate::Isolate; + +use crate::value::PyValue; + +#[pyfunction] +pub fn evaluate_expression( + py: Python, + expression: String, + ctx: Option<&PyDict>, +) -> PyResult { + let isolate = Isolate::default(); + let ctx = ctx + .map(|ctx| depythonize(ctx)) + .transpose() + .context("Failed to convert context")?; + + if let Some(env_value) = ctx { + isolate.inject_env(&env_value); + } + + let result = isolate + .run_standard(expression.as_str()) + .map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?; + + Ok(PyValue(result).to_object(py)) +} + +#[pyfunction] +pub fn evaluate_unary_expression( + py: Python, + expression: String, + ctx: &PyDict, +) -> PyResult { + let env_value: Value = depythonize(ctx).context("Failed to convert context")?; + + let Some(env_object) = env_value.as_object() else { + return Err(anyhow!("Context must be an object").into()); + }; + + if !env_object.contains_key("$") { + return Err(anyhow!("Context must contain '$' reference.").into()); + } + + let isolate = Isolate::default(); + isolate.inject_env(&env_value); + + let result = isolate + .run_unary(expression.as_str()) + .map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?; + + Ok(PyValue(result).to_object(py)) +} diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 494ba45e..20798e47 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1,10 +1,12 @@ use crate::decision::PyZenDecision; use crate::engine::PyZenEngine; +use crate::expression::{evaluate_expression, evaluate_unary_expression}; use pyo3::types::PyModule; -use pyo3::{pymodule, PyResult, Python}; +use pyo3::{pymodule, wrap_pyfunction, PyResult, Python}; mod decision; mod engine; +mod expression; mod loader; mod value; @@ -12,5 +14,8 @@ mod value; fn zen(_py: Python, m: &PyModule) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + m.add_function(wrap_pyfunction!(evaluate_expression, m)?)?; + m.add_function(wrap_pyfunction!(evaluate_unary_expression, m)?)?; + Ok(()) } diff --git a/core/engine/Cargo.toml b/core/engine/Cargo.toml index 9261b26f..f815251a 100644 --- a/core/engine/Cargo.toml +++ b/core/engine/Cargo.toml @@ -11,22 +11,22 @@ repository = "https://github.com/gorules/zen.git" doctest = false [dependencies] -async-recursion = "1.0.4" +async-recursion = "1.0.5" anyhow = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } bincode = { workspace = true, optional = true } petgraph = { workspace = true } serde_json = { workspace = true, features = ["arbitrary_precision"] } -serde = { version = "1.0.163", features = ["derive"] } -serde_v8 = { version = "0.103.0" } -once_cell = { version = "1.17.2" } +serde = { workspace = true, features = ["derive"] } +serde_v8 = { version = "0.121.0" } +once_cell = { version = "1.18.0" } futures = "0.3.28" -v8 = { version = "0.74.0" } +v8 = { version = "0.76.0" } zen-expression = { path = "../expression", version = "0.10.0" } [dev-dependencies] -tokio = { version = "1.28.2", features = ["rt", "macros"] } +tokio = { version = "1.32.0", features = ["rt", "macros"] } criterion = { workspace = true, features = ["async_futures"] } [features] diff --git a/core/expression/Cargo.toml b/core/expression/Cargo.toml index d3da6f06..61712a4c 100644 --- a/core/expression/Cargo.toml +++ b/core/expression/Cargo.toml @@ -18,6 +18,7 @@ once_cell = { workspace = true } phf = { workspace = true, features = ["macros"] } regex = { workspace = true, optional = true } regex-lite = { workspace = true, optional = true } +serde = { workspace = true } serde_json = { workspace = true, features = ["arbitrary_precision"] } thiserror = { workspace = true } rust_decimal = { workspace = true, features = ["maths-nopanic"] } diff --git a/core/expression/src/isolate.rs b/core/expression/src/isolate.rs index 10b854f5..f49f18aa 100644 --- a/core/expression/src/isolate.rs +++ b/core/expression/src/isolate.rs @@ -7,6 +7,8 @@ use std::rc::Rc; use ahash::AHasher; use bumpalo::Bump; +use serde::ser::SerializeMap; +use serde::{Serialize, Serializer}; use serde_json::Value; use thiserror::Error; @@ -43,6 +45,42 @@ pub enum IsolateError { ReferenceError, } +impl Serialize for IsolateError { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(None)?; + + match &self { + IsolateError::ReferenceError => { + map.serialize_entry("type", "referenceError")?; + } + IsolateError::ValueCastError => { + map.serialize_entry("type", "valueCastError")?; + } + IsolateError::LexerError { source } => { + map.serialize_entry("type", "lexerError")?; + map.serialize_entry("source", source.to_string().as_str())?; + } + IsolateError::ParserError { source } => { + map.serialize_entry("type", "parserError")?; + map.serialize_entry("source", source.to_string().as_str())?; + } + IsolateError::CompilerError { source } => { + map.serialize_entry("type", "compilerError")?; + map.serialize_entry("source", source.to_string().as_str())?; + } + IsolateError::VMError { source } => { + map.serialize_entry("type", "vmError")?; + map.serialize_entry("source", source.to_string().as_str())?; + } + } + + map.end() + } +} + #[derive(Debug)] pub struct Isolate<'a> { lexer: Lexer<'a>,