Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions bindings/c/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
strum = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = ["rt"] }
tokio-util = { workspace = true, features = ["rt"] }
zen-engine = { path = "../../core/engine", features = ["arbitrary_precision"] }
zen-expression = { path = "../../core/expression", features = ["arbitrary_precision"] }
zen-tmpl = { path = "../../core/template" }
Expand Down
11 changes: 10 additions & 1 deletion bindings/c/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@ use std::env;
fn main() {
let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();

let config = cbindgen::Config {
language: cbindgen::Language::C,
enumeration: cbindgen::EnumConfig {
prefix_with_name: true,
..Default::default()
},
..Default::default()
};

cbindgen::Builder::new()
.with_language(cbindgen::Language::C)
.with_config(config)
.with_crate(crate_dir)
.generate()
.expect("Unable to generate bindings")
Expand Down
270 changes: 268 additions & 2 deletions bindings/c/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ use std::sync::Arc;
use zen_engine::{DecisionEngine, EvaluationOptions};

use crate::custom_node::DynamicCustomNode;
use crate::custom_node::ZenCustomNodeResult;
use crate::decision::{ZenDecision, ZenDecisionStruct};
use crate::error::ZenError;
use crate::helper::safe_str_from_ptr;
use crate::loader::DynamicDecisionLoader;
use crate::mt::tokio_runtime;
use crate::languages::native::NativeCustomNode;
use crate::loader::{DynamicDecisionLoader, ZenEngineLoaderConfig};
use crate::mt::{tokio_runtime, worker_pool};
use crate::result::ZenResult;
use serde_json::json;

pub(crate) struct ZenEngine(DecisionEngine);

Expand Down Expand Up @@ -72,6 +75,29 @@ pub extern "C" fn zen_engine_new() -> *mut ZenEngineStruct {
Box::into_raw(Box::new(ZenEngine::default())) as *mut ZenEngineStruct
}

/// Creates a new ZenEngine instance from a loader configuration, caller is responsible for
/// freeing the returned reference by calling zen_engine_free.
#[no_mangle]
pub extern "C" fn zen_engine_new_with_loader_config(
config: ZenEngineLoaderConfig,
maybe_custom_node: Option<extern "C" fn(request: *const c_char) -> ZenCustomNodeResult>,
) -> ZenResult<ZenEngineStruct> {
let loader = match config.to_dynamic_loader() {
Ok(loader) => loader,
Err(error) => return ZenResult::error(error),
};

let custom_node = match maybe_custom_node {
Some(callback) => DynamicCustomNode::Native(NativeCustomNode::new(callback)),
None => DynamicCustomNode::default(),
};

let engine = ZenEngine::new(DynamicDecisionLoader::Config(loader), custom_node);
engine.compile();

ZenResult::ok(Box::into_raw(Box::new(engine)) as *mut ZenEngineStruct)
}

/// Frees the ZenEngine instance reference from the memory
#[no_mangle]
pub extern "C" fn zen_engine_free(engine: *mut ZenEngineStruct) {
Expand Down Expand Up @@ -148,6 +174,102 @@ pub extern "C" fn zen_engine_evaluate(
ZenResult::ok(cstring_result.into_raw())
}

#[repr(C)]
pub struct ZenEngineEvaluateBatchRequest {
key: *const c_char,
context: *const c_char,
}

enum BatchTask {
Failed(serde_json::Value),
Pending(tokio::task::JoinHandle<Result<serde_json::Value, serde_json::Value>>),
}

/// Evaluates a batch of requests in parallel using a DecisionEngine reference via loader.
/// Returns a JSON array of { success, data?, error? } envelopes in request order.
/// Caller is responsible for freeing: requests and ZenResult.
#[no_mangle]
pub extern "C" fn zen_engine_evaluate_batch(
engine: *const ZenEngineStruct,
requests: *const ZenEngineEvaluateBatchRequest,
requests_len: usize,
options: ZenEngineEvaluationOptions,
) -> ZenResult<c_char> {
if engine.is_null() || (requests.is_null() && requests_len > 0) {
return ZenResult::error(ZenError::InvalidArgument);
}

let request_slice = match requests_len {
0 => &[],
_ => unsafe { std::slice::from_raw_parts(requests, requests_len) },
};

let mut parsed: Vec<(String, Result<Value, String>)> = Vec::with_capacity(requests_len);
for request in request_slice {
let Some(key) = safe_str_from_ptr(request.key) else {
return ZenResult::error(ZenError::InvalidArgument);
};

if request.context.is_null() {
return ZenResult::error(ZenError::InvalidArgument);
}

let cstr_context = unsafe { CStr::from_ptr(request.context) };
let context =
serde_json::from_slice::<Value>(cstr_context.to_bytes()).map_err(|e| e.to_string());
parsed.push((key.to_string(), context));
}

let zen_engine = unsafe { &*(engine as *const ZenEngine) };
let decision_engine: DecisionEngine = DecisionEngine::clone(zen_engine);
let eval_options: EvaluationOptions = options.into();

let pool = worker_pool();
let tasks: Vec<BatchTask> = parsed
.into_iter()
.map(|(key, context)| match context {
Err(message) => BatchTask::Failed(json!(format!("invalid context: {message}"))),
Ok(value) => {
let engine = decision_engine.clone();
BatchTask::Pending(pool.spawn_pinned(move || async move {
engine
.evaluate_with_opts(key, value.into(), eval_options)
.await
.map(|response| serde_json::to_value(&response).unwrap_or(Value::Null))
.map_err(|e| {
serde_json::to_value(&e).unwrap_or_else(|_| json!(e.to_string()))
})
}))
}
})
.collect();

let results = tokio_runtime().block_on(async move {
let mut items = Vec::with_capacity(tasks.len());
for task in tasks {
let item = match task {
BatchTask::Failed(error) => json!({ "success": false, "error": error }),
BatchTask::Pending(handle) => match handle.await {
Ok(Ok(data)) => json!({ "success": true, "data": data }),
Ok(Err(error)) => json!({ "success": false, "error": error }),
Err(_) => {
json!({ "success": false, "error": "evaluation worker panicked" })
}
},
};
items.push(item);
}
Value::Array(items)
});

let Ok(serialized_results) = serde_json::to_string(&results) else {
return ZenResult::error(ZenError::JsonSerializationFailed);
};

let cstring_result = unsafe { CString::from_vec_unchecked(serialized_results.into_bytes()) };
ZenResult::ok(cstring_result.into_raw())
}

/// Loads a Decision through DecisionEngine
/// Caller is responsible for freeing: key and ZenResult.
#[no_mangle]
Expand All @@ -174,3 +296,147 @@ pub extern "C" fn zen_engine_get_decision(
let zen_decision = ZenDecision::from(decision);
ZenResult::ok(Box::into_raw(Box::new(zen_decision)) as *mut ZenDecisionStruct)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::error::ZenErrorDiscriminants;
use crate::loader::ZenLoaderConfigKind;
use std::ffi::CString;
use std::ptr::null;

fn evaluate_table(engine: *mut ZenEngineStruct) -> Value {
let key = CString::new("table.json").unwrap();
let context = CString::new(r#"{"input":12}"#).unwrap();
let result = zen_engine_evaluate(
engine,
key.as_ptr(),
context.as_ptr(),
ZenEngineEvaluationOptions {
trace: false,
max_depth: 5,
},
);

assert_eq!(result.error_code(), 0);
let response = unsafe { CString::from_raw(result.result_ptr()) };
serde_json::from_slice(response.to_bytes()).unwrap()
}

#[test]
fn engine_from_static_loader_config() {
let content = CString::new(format!(
r#"{{"table.json": {}}}"#,
include_str!("../../../test-data/table.json")
))
.unwrap();

let config = ZenEngineLoaderConfig {
kind: ZenLoaderConfigKind::Static,
content: content.as_ptr(),
bytes: null(),
bytes_len: 0,
};

let result = zen_engine_new_with_loader_config(config, None);
assert_eq!(result.error_code(), 0);

let engine = result.result_ptr();
let response = evaluate_table(engine);
assert_eq!(response["result"]["output"], serde_json::json!(10));

zen_engine_free(engine);
}

#[test]
fn engine_from_fs_loader_config() {
let path = CString::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-data")).unwrap();

let config = ZenEngineLoaderConfig {
kind: ZenLoaderConfigKind::Filesystem,
content: path.as_ptr(),
bytes: null(),
bytes_len: 0,
};

let result = zen_engine_new_with_loader_config(config, None);
assert_eq!(result.error_code(), 0);

let engine = result.result_ptr();
let response = evaluate_table(engine);
assert_eq!(response["result"]["output"], serde_json::json!(10));

zen_engine_free(engine);
}

#[test]
fn engine_evaluate_batch_mixed_results() {
let path = CString::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-data")).unwrap();
let config = ZenEngineLoaderConfig {
kind: ZenLoaderConfigKind::Filesystem,
content: path.as_ptr(),
bytes: null(),
bytes_len: 0,
};
let engine = zen_engine_new_with_loader_config(config, None).result_ptr();

let keys = [
CString::new("table.json").unwrap(),
CString::new("missing.json").unwrap(),
CString::new("table.json").unwrap(),
];
let contexts = [
CString::new(r#"{"input":12}"#).unwrap(),
CString::new(r#"{}"#).unwrap(),
CString::new(r#"{"input":5}"#).unwrap(),
];
let requests: Vec<ZenEngineEvaluateBatchRequest> = keys
.iter()
.zip(contexts.iter())
.map(|(key, context)| ZenEngineEvaluateBatchRequest {
key: key.as_ptr(),
context: context.as_ptr(),
})
.collect();

let result = zen_engine_evaluate_batch(
engine,
requests.as_ptr(),
requests.len(),
ZenEngineEvaluationOptions {
trace: false,
max_depth: 5,
},
);

assert_eq!(result.error_code(), 0);
let response = unsafe { CString::from_raw(result.result_ptr()) };
let items: Value = serde_json::from_slice(response.to_bytes()).unwrap();

assert_eq!(items[0]["success"], serde_json::json!(true));
assert_eq!(items[0]["data"]["result"]["output"], serde_json::json!(10));
assert_eq!(items[1]["success"], serde_json::json!(false));
assert_eq!(items[2]["success"], serde_json::json!(true));
assert_eq!(items[2]["data"]["result"]["output"], serde_json::json!(0));

zen_engine_free(engine);
}

#[test]
fn engine_from_invalid_zip_loader_config() {
let bytes = [0u8; 4];
let config = ZenEngineLoaderConfig {
kind: ZenLoaderConfigKind::Zip,
content: null(),
bytes: bytes.as_ptr(),
bytes_len: bytes.len(),
};

let result = zen_engine_new_with_loader_config(config, None);
assert_eq!(
result.error_code(),
ZenErrorDiscriminants::LoaderConfigError as u8
);
assert!(result.result_ptr().is_null());
}
}
5 changes: 5 additions & 0 deletions bindings/c/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ pub enum ZenError {
LoaderInternalError { key: String, message: String },

TemplateEngineError { template: String, message: String },

LoaderConfigError { message: String },
}

impl ZenError {
Expand All @@ -35,6 +37,9 @@ impl ZenError {
ZenError::TemplateEngineError { template, message } => {
Some(json!({ "template": template, "message": message }).to_string())
}
ZenError::LoaderConfigError { message } => {
Some(json!({ "message": message }).to_string())
}
_ => None,
}
}
Expand Down
24 changes: 23 additions & 1 deletion bindings/c/src/languages/go.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::custom_node::{DynamicCustomNode, ZenCustomNodeResult};
use crate::engine::{ZenEngine, ZenEngineStruct};
use crate::loader::{DynamicDecisionLoader, ZenDecisionLoaderResult};
use crate::loader::{DynamicDecisionLoader, ZenDecisionLoaderResult, ZenEngineLoaderConfig};
use crate::result::ZenResult;
use std::ffi::{c_char, CString};
use std::future::Future;
use std::pin::Pin;
Expand Down Expand Up @@ -103,6 +104,27 @@ pub extern "C" fn zen_engine_new_golang(
Box::into_raw(Box::new(engine)) as *mut ZenEngineStruct
}

/// Creates a DecisionEngine from a loader configuration using GoLang handler (optional). Caller is responsible for freeing DecisionEngine.
#[no_mangle]
pub extern "C" fn zen_engine_new_golang_with_loader_config(
config: ZenEngineLoaderConfig,
maybe_custom_node: Option<&usize>,
) -> ZenResult<ZenEngineStruct> {
let loader = match config.to_dynamic_loader() {
Ok(loader) => loader,
Err(error) => return ZenResult::error(error),
};

let custom_node = GoCustomNode::new(map_handler(maybe_custom_node.cloned()));
let engine = ZenEngine::new(
DynamicDecisionLoader::Config(loader),
DynamicCustomNode::Go(custom_node),
);
engine.compile();

ZenResult::ok(Box::into_raw(Box::new(engine)) as *mut ZenEngineStruct)
}

#[allow(unused_doc_comments)]
/// cbindgen:ignore
extern "C" {
Expand Down
Loading
Loading