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
+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())
}
}