feat: custom node (#138)

* feat: custom node

* add zen template, expose $nodes and $root

* add custom handler to go and nodejs

* add support for python, improve bindings, add error to zen template

* fix: correct binding exports for nodejs and python

* fix benchmark

* improve rust api, trim template in zen templates

* update cargo action

* update expression version

* compile action

* fix action format
This commit is contained in:
stefan-gorules
2024-04-03 16:09:51 +02:00
committed by GitHub
parent 685a0345f5
commit daecf901e6
65 changed files with 1729 additions and 235 deletions
+6 -3
View File
@@ -10,12 +10,15 @@ crate-type = ["cdylib"]
[dependencies]
async-trait = { workspace = true }
napi = { version = "2.14.4", features = ["serde-json", "error_anyhow", "tokio_rt"] }
napi-derive = "2.14.6"
napi = { version = "2.16", features = ["serde-json", "error_anyhow", "tokio_rt"] }
napi-derive = "2.16"
serde_json = { workspace = true }
futures = { workspace = true }
zen-engine = { path = "../../core/engine" }
zen-expression = { path = "../../core/expression" }
zen-template = { path = "../../core/template" }
serde = { workspace = true, features = ["derive"] }
json_dotpath = { workspace = true }
[build-dependencies]
napi-build = "2.1.0"
napi-build = "2.1.2"
+37 -2
View File
@@ -9,17 +9,52 @@ export interface ZenEvaluateOptions {
}
export interface ZenEngineOptions {
loader?: (key: string) => Promise<Buffer>
customHandler?: (request: ZenEngineHandlerRequest) => Promise<ZenEngineHandlerResponse>
}
export function evaluateExpressionSync(expression: string, context?: any | undefined | null): any
export function evaluateUnaryExpressionSync(expression: string, context: any): boolean
export function renderTemplateSync(template: string, context: any): any
export function evaluateExpression(expression: string, context?: any | undefined | null): Promise<any>
export function evaluateUnaryExpression(expression: string, context: any): Promise<boolean>
export function renderTemplate(template: string, context: any): Promise<any>
export interface ZenEngineTrace {
id: string
name: string
input: any
output: any
performance?: string
traceData?: any
}
export interface ZenEngineResponse {
performance: string
result: any
trace?: Record<string, ZenEngineTrace>
}
export interface ZenEngineHandlerResponse {
output: any
traceData?: any
}
export interface DecisionNode {
id: string
name: string
kind: string
config: any
}
export class ZenDecision {
constructor()
evaluate(context: any, opts?: ZenEvaluateOptions | undefined | null): Promise<any>
evaluate(context: any, opts?: ZenEvaluateOptions | undefined | null): Promise<ZenEngineResponse>
validate(): void
}
export class ZenEngine {
constructor(options?: ZenEngineOptions | undefined | null)
evaluate(key: string, context: any, opts?: ZenEvaluateOptions | undefined | null): Promise<any>
evaluate(key: string, context: any, opts?: ZenEvaluateOptions | undefined | null): Promise<ZenEngineResponse>
createDecision(content: Buffer): ZenDecision
getDecision(key: string): Promise<ZenDecision>
}
export class ZenEngineHandlerRequest {
input: any
node: DecisionNode
constructor()
getField(path: string): unknown
getFieldRaw(path: string): unknown
}
+6 -1
View File
@@ -281,9 +281,14 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { ZenDecision, ZenEngine, evaluateExpression, evaluateUnaryExpression } = nativeBinding
const { ZenDecision, ZenEngine, evaluateExpressionSync, evaluateUnaryExpressionSync, renderTemplateSync, evaluateExpression, evaluateUnaryExpression, renderTemplate, ZenEngineHandlerRequest } = nativeBinding
module.exports.ZenDecision = ZenDecision
module.exports.ZenEngine = ZenEngine
module.exports.evaluateExpressionSync = evaluateExpressionSync
module.exports.evaluateUnaryExpressionSync = evaluateUnaryExpressionSync
module.exports.renderTemplateSync = renderTemplateSync
module.exports.evaluateExpression = evaluateExpression
module.exports.evaluateUnaryExpression = evaluateUnaryExpression
module.exports.renderTemplate = renderTemplate
module.exports.ZenEngineHandlerRequest = ZenEngineHandlerRequest
+60
View File
@@ -0,0 +1,60 @@
use napi::anyhow::anyhow;
use napi::bindgen_prelude::Promise;
use napi::threadsafe_function::{ErrorStrategy, ThreadSafeCallContext, ThreadsafeFunction};
use napi::{Env, JsFunction};
use zen_engine::handler::custom_node_adapter::{CustomNodeAdapter, CustomNodeRequest};
use zen_engine::handler::node::{NodeResponse, NodeResult};
use crate::types::{ZenEngineHandlerRequest, ZenEngineHandlerResponse};
pub(crate) struct CustomNode {
function: Option<ThreadsafeFunction<ZenEngineHandlerRequest, ErrorStrategy::Fatal>>,
}
impl Default for CustomNode {
fn default() -> Self {
Self { function: None }
}
}
impl CustomNode {
pub fn try_new(env: &mut Env, function: JsFunction) -> napi::Result<Self> {
let mut tsf = function.create_threadsafe_function(
0,
|cx: ThreadSafeCallContext<ZenEngineHandlerRequest>| Ok(vec![cx.value]),
)?;
tsf.unref(env)?;
Ok(Self {
function: Some(tsf),
})
}
}
impl CustomNodeAdapter for CustomNode {
async fn handle(&self, request: CustomNodeRequest<'_>) -> NodeResult {
let Some(function) = &self.function else {
return Err(anyhow!("Custom function is undefined"));
};
let node_data = crate::types::DecisionNode::from(request.node);
let promise: Promise<ZenEngineHandlerResponse> = function
.clone()
.call_async(ZenEngineHandlerRequest {
input: request.input.clone(),
node: node_data,
})
.await
.map_err(|err| anyhow!(err.reason))?;
let result = promise.await.map_err(|err| anyhow!(err.reason))?;
Ok(NodeResponse {
output: result.output,
trace_data: result.trace_data,
})
}
}
+7 -5
View File
@@ -1,5 +1,7 @@
use crate::custom_node::CustomNode;
use crate::engine::ZenEvaluateOptions;
use crate::loader::DecisionLoader;
use crate::types::ZenEngineResponse;
use napi::anyhow::anyhow;
use napi::tokio;
use napi_derive::napi;
@@ -8,10 +10,10 @@ use std::sync::Arc;
use zen_engine::{Decision, EvaluationOptions};
#[napi]
pub struct ZenDecision(pub(crate) Arc<Decision<DecisionLoader>>);
pub struct ZenDecision(pub(crate) Arc<Decision<DecisionLoader, CustomNode>>);
impl From<Decision<DecisionLoader>> for ZenDecision {
fn from(value: Decision<DecisionLoader>) -> Self {
impl From<Decision<DecisionLoader, CustomNode>> for ZenDecision {
fn from(value: Decision<DecisionLoader, CustomNode>) -> Self {
Self(value.into())
}
}
@@ -28,7 +30,7 @@ impl ZenDecision {
&self,
context: Value,
opts: Option<ZenEvaluateOptions>,
) -> napi::Result<Value> {
) -> napi::Result<ZenEngineResponse> {
let decision = self.0.clone();
let result = tokio::spawn(async move {
let options = opts.unwrap_or_default();
@@ -46,7 +48,7 @@ impl ZenDecision {
anyhow!(serde_json::to_string(e.as_ref()).unwrap_or_else(|_| e.to_string()))
})?;
Ok(serde_json::to_value(&result)?)
Ok(ZenEngineResponse::from(result))
}
#[napi]
+22 -9
View File
@@ -1,5 +1,7 @@
use crate::custom_node::CustomNode;
use crate::decision::ZenDecision;
use crate::loader::DecisionLoader;
use crate::types::ZenEngineResponse;
use napi::anyhow::{anyhow, Context};
use napi::bindgen_prelude::Buffer;
use napi::{tokio, Env, JsFunction};
@@ -11,7 +13,7 @@ use zen_engine::{DecisionEngine, EvaluationOptions};
#[napi]
pub struct ZenEngine {
graph: Arc<DecisionEngine<DecisionLoader>>,
graph: Arc<DecisionEngine<DecisionLoader, CustomNode>>,
}
#[napi(object)]
@@ -33,6 +35,9 @@ impl Default for ZenEvaluateOptions {
pub struct ZenEngineOptions {
#[napi(ts_type = "(key: string) => Promise<Buffer>")]
pub loader: Option<JsFunction>,
#[napi(ts_type = "(request: ZenEngineHandlerRequest) => Promise<ZenEngineHandlerResponse>")]
pub custom_handler: Option<JsFunction>,
}
#[napi]
@@ -41,18 +46,26 @@ impl ZenEngine {
pub fn new(mut env: Env, options: Option<ZenEngineOptions>) -> napi::Result<Self> {
let Some(opts) = options else {
return Ok(Self {
graph: DecisionEngine::new(DecisionLoader::default()).into(),
graph: DecisionEngine::new(
DecisionLoader::default().into(),
CustomNode::default().into(),
)
.into(),
});
};
let Some(loader_fn) = opts.loader else {
return Ok(Self {
graph: DecisionEngine::new(DecisionLoader::default()).into(),
});
let loader = match opts.loader {
None => DecisionLoader::default(),
Some(loader_fn) => DecisionLoader::try_new(&mut env, loader_fn)?,
};
let custom_handler = match opts.custom_handler {
None => CustomNode::default(),
Some(custom_fn) => CustomNode::try_new(&mut env, custom_fn)?,
};
Ok(Self {
graph: DecisionEngine::new(DecisionLoader::try_new(&mut env, loader_fn)?).into(),
graph: DecisionEngine::new(loader.into(), custom_handler.into()).into(),
})
}
@@ -62,7 +75,7 @@ impl ZenEngine {
key: String,
context: Value,
opts: Option<ZenEvaluateOptions>,
) -> napi::Result<Value> {
) -> napi::Result<ZenEngineResponse> {
let graph = self.graph.clone();
let result = tokio::spawn(async move {
let options = opts.unwrap_or_default();
@@ -82,7 +95,7 @@ impl ZenEngine {
anyhow!(serde_json::to_string(e.as_ref()).unwrap_or_else(|_| e.to_string()))
})?;
Ok(serde_json::to_value(&result)?)
Ok(ZenEngineResponse::from(result))
}
#[napi]
+39 -17
View File
@@ -2,33 +2,55 @@ use napi::anyhow::anyhow;
use napi_derive::napi;
use serde_json::Value;
#[napi]
pub fn evaluate_expression_sync(expression: String, context: Option<Value>) -> napi::Result<Value> {
let ctx = context.unwrap_or(Value::Null);
Ok(
zen_expression::evaluate_expression(expression.as_str(), &ctx)
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?,
)
}
#[allow(dead_code)]
#[napi]
pub fn evaluate_unary_expression_sync(expression: String, context: Value) -> napi::Result<bool> {
Ok(
zen_expression::evaluate_unary_expression(expression.as_str(), &context)
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?,
)
}
#[allow(dead_code)]
#[napi]
pub fn render_template_sync(template: String, context: Value) -> napi::Result<Value> {
Ok(zen_template::render(template.as_str(), &context)
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?)
}
#[allow(dead_code)]
#[napi]
pub async fn evaluate_expression(
expression: String,
context: Option<Value>,
) -> napi::Result<Value> {
let ctx = context.unwrap_or(Value::Null);
let result: Value = napi::tokio::spawn(async move {
zen_expression::evaluate_expression(expression.as_str(), &ctx)
})
.await
.map_err(|_| anyhow!("Hook timed out"))?
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
Ok(result)
napi::tokio::spawn(async move { evaluate_expression_sync(expression, context) })
.await
.map_err(|_| anyhow!("Hook timed out"))?
}
#[allow(dead_code)]
#[napi]
pub async fn evaluate_unary_expression(expression: String, context: Value) -> napi::Result<bool> {
let result: bool = napi::tokio::spawn(async move {
zen_expression::evaluate_unary_expression(expression.as_str(), &context)
})
.await
.map_err(|_| anyhow!("Hook timed out"))?
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
napi::tokio::spawn(async move { evaluate_unary_expression_sync(expression, context) })
.await
.map_err(|_| anyhow!("Hook timed out"))?
}
Ok(result)
#[allow(dead_code)]
#[napi]
pub async fn render_template(template: String, context: Value) -> napi::Result<Value> {
napi::tokio::spawn(async move { render_template_sync(template, context) })
.await
.map_err(|_| anyhow!("Hook timed out"))?
}
+2
View File
@@ -1,4 +1,6 @@
mod custom_node;
mod decision;
mod engine;
mod expression;
mod loader;
mod types;
+2 -2
View File
@@ -48,12 +48,12 @@ impl DecisionLoader {
.await
.map_err(|e| LoaderError::Internal {
key: key.to_string(),
source: e.into(),
source: anyhow!(e.reason),
})?;
let result = promise.await.map_err(|e| LoaderError::Internal {
key: key.to_string(),
source: e.into(),
source: anyhow!(e.reason),
})?;
let Some(buffer) = result else {
+125
View File
@@ -0,0 +1,125 @@
use std::collections::HashMap;
use json_dotpath::DotPaths;
use napi::anyhow::{anyhow, Context};
use napi_derive::napi;
use serde_json::Value;
use zen_engine::handler::custom_node_adapter::CustomDecisionNode;
use zen_engine::{DecisionGraphResponse, DecisionGraphTrace};
#[napi(object)]
pub struct ZenEngineTrace {
pub id: String,
pub name: String,
pub input: Value,
pub output: Value,
pub performance: Option<String>,
pub trace_data: Option<Value>,
}
impl From<DecisionGraphTrace> for ZenEngineTrace {
fn from(value: DecisionGraphTrace) -> Self {
Self {
id: value.id,
name: value.name,
input: value.input,
output: value.output,
performance: value.performance,
trace_data: value.trace_data,
}
}
}
#[napi(object)]
pub struct ZenEngineResponse {
pub performance: String,
pub result: Value,
pub trace: Option<HashMap<String, ZenEngineTrace>>,
}
impl From<DecisionGraphResponse> for ZenEngineResponse {
fn from(value: DecisionGraphResponse) -> Self {
Self {
performance: value.performance,
result: value.result,
trace: value.trace.map(|opt| {
opt.into_iter()
.map(|(key, value)| (key, ZenEngineTrace::from(value)))
.collect()
}),
}
}
}
#[napi(object)]
pub struct ZenEngineHandlerResponse {
pub output: Value,
pub trace_data: Option<Value>,
}
#[derive(Clone)]
#[napi(object)]
pub struct DecisionNode {
pub id: String,
pub name: String,
pub kind: String,
pub config: Value,
}
impl From<CustomDecisionNode<'_>> for DecisionNode {
fn from(value: CustomDecisionNode<'_>) -> Self {
Self {
id: value.id.to_string(),
name: value.name.to_string(),
kind: value.kind.to_string(),
config: value.config.clone(),
}
}
}
#[napi]
pub struct ZenEngineHandlerRequest {
pub input: Value,
pub node: DecisionNode,
}
#[napi]
impl ZenEngineHandlerRequest {
#[napi(constructor)]
pub fn new() -> napi::Result<Self> {
Err(anyhow!("Private constructor").into())
}
#[napi(ts_return_type = "unknown")]
pub fn get_field(&self, path: String) -> napi::Result<Value> {
let node_config = &self.node.config;
let selected_value: Value = node_config
.dot_get(path.as_str())
.ok()
.flatten()
.context("Failed to find JSON path")?;
let Value::String(template) = selected_value else {
return Ok(selected_value);
};
let template_value = zen_template::render(template.as_str(), &self.input)
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
Ok(template_value)
}
#[napi(ts_return_type = "unknown")]
pub fn get_field_raw(&self, path: String) -> napi::Result<Value> {
let node_config = &self.node.config;
let selected_value: Value = node_config
.dot_get(path.as_str())
.ok()
.flatten()
.context("Failed to find JSON path")?;
Ok(selected_value.clone())
}
}
+49 -1
View File
@@ -1,4 +1,11 @@
import {ZenEngine, evaluateExpression, evaluateUnaryExpression} from "../index";
import {
ZenEngine,
evaluateExpression,
evaluateUnaryExpression,
renderTemplate,
evaluateExpressionSync,
evaluateUnaryExpressionSync, renderTemplateSync
} from "../index";
import fs from 'fs/promises';
import path from 'path';
import {describe, expect, it, jest} from "@jest/globals";
@@ -49,6 +56,31 @@ describe('ZenEngine', () => {
const r = await functionDecision.evaluate({input: 15});
expect(r.result.output).toEqual(30);
}, 10000)
it('Evaluate custom nodes with a handler', async () => {
const engine = new ZenEngine({
loader,
customHandler: async (request) => {
const prop1 = request.getField('prop1') as number;
const prop1Raw = request.getFieldRaw('prop1');
expect(prop1).toEqual(15);
expect(prop1Raw).toEqual('{{ a + 10 }}')
expect(request.node).toMatchObject({
id: '138b3b11-ff46-450f-9704-3f3c712067b2',
name: 'customNode1',
kind: 'sum',
config: {
prop1: '{{ a + 10 }}'
}
});
return {output: {data: prop1 + 10}}
}
});
const r = await engine.evaluate('custom.json', {a: 5});
expect(r.result.data).toEqual(25);
});
})
describe('Expressions', () => {
@@ -63,6 +95,7 @@ describe('Expressions', () => {
for (const {expression, result, context} of expressions) {
expect(await evaluateExpression(expression, context)).toEqual(result);
expect(evaluateExpressionSync(expression, context)).toEqual(result);
}
});
@@ -76,6 +109,21 @@ describe('Expressions', () => {
for (const {expression, result, context} of expressions) {
expect(await evaluateUnaryExpression(expression, context)).toEqual(result);
expect(evaluateUnaryExpressionSync(expression, context)).toEqual(result);
}
});
it('Renders templates', async () => {
const templateCases = [
{template: '{{ a + 10 }}', context: {a: 10}, result: 20},
{template: '{{ a + 10 }}', context: {a: 15}, result: 25},
{template: '{{ a + 10 }}', context: {a: 20}, result: 30},
{template: '{{ a + 10 }}', context: {a: 25}, result: 35},
];
for (const {template, context, result} of templateCases) {
expect(await renderTemplate(template, context)).toEqual(result);
expect(renderTemplateSync(template, context)).toEqual(result);
}
});
});