mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 16:02:18 +00:00
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"))?
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
mod custom_node;
|
||||
mod decision;
|
||||
mod engine;
|
||||
mod expression;
|
||||
mod loader;
|
||||
mod types;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user