mirror of
https://github.com/gorules/zen.git
synced 2026-10-03 16:02:27 +00:00
feat: schema validation (#267)
* feat: schema validation * fix prettier * update implementation * add test cases * change validator to string * update tests
This commit is contained in:
@@ -7,7 +7,6 @@ members = [
|
||||
|
||||
[workspace.dependencies]
|
||||
ahash = "0.8.11"
|
||||
bincode = "2.0.0-rc.3"
|
||||
bumpalo = "3.16.0"
|
||||
chrono = "0.4.38"
|
||||
criterion = "0.5.1"
|
||||
|
||||
@@ -21,7 +21,6 @@ const makeToml = ({ version }): string => `
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
bincode = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, features = ["arbitrary_precision"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_v8 = { version = "0.88.0" }
|
||||
|
||||
@@ -14,7 +14,6 @@ doctest = false
|
||||
ahash = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
bincode = { workspace = true, optional = true }
|
||||
petgraph = { workspace = true }
|
||||
serde_json = { workspace = true, features = ["arbitrary_precision"] }
|
||||
serde = { workspace = true, features = ["derive", "rc"] }
|
||||
@@ -26,6 +25,7 @@ tokio = { workspace = true, features = ["sync", "time"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
rquickjs = { version = "0.8.1", features = ["macro", "loader", "rust-alloc", "futures", "either", "properties"] }
|
||||
itertools = { workspace = true }
|
||||
jsonschema = "0.26.2"
|
||||
zen-expression = { path = "../expression", version = "0.37.2" }
|
||||
zen-tmpl = { path = "../template", version = "0.37.2" }
|
||||
|
||||
@@ -33,9 +33,6 @@ zen-tmpl = { path = "../template", version = "0.37.2" }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
criterion = { workspace = true, features = ["async_tokio"] }
|
||||
|
||||
[features]
|
||||
bincode = ["dep:bincode"]
|
||||
|
||||
[[bench]]
|
||||
harness = false
|
||||
name = "engine"
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::engine::EvaluationOptions;
|
||||
use crate::handler::custom_node_adapter::{CustomNodeAdapter, NoopCustomNode};
|
||||
use crate::handler::graph::{DecisionGraph, DecisionGraphConfig, DecisionGraphResponse};
|
||||
use crate::loader::{CachedLoader, DecisionLoader, NoopLoader};
|
||||
use crate::model::DecisionContent;
|
||||
use crate::util::validator_cache::ValidatorCache;
|
||||
use crate::{DecisionGraphValidationError, EvaluationError};
|
||||
use std::sync::Arc;
|
||||
use zen_expression::variable::Variable;
|
||||
|
||||
/// Represents a JDM decision which can be evaluated
|
||||
@@ -18,6 +18,8 @@ where
|
||||
content: Arc<DecisionContent>,
|
||||
loader: Arc<Loader>,
|
||||
adapter: Arc<CustomNode>,
|
||||
|
||||
validator_cache: ValidatorCache,
|
||||
}
|
||||
|
||||
impl From<DecisionContent> for Decision<NoopLoader, NoopCustomNode> {
|
||||
@@ -26,6 +28,8 @@ impl From<DecisionContent> for Decision<NoopLoader, NoopCustomNode> {
|
||||
content: value.into(),
|
||||
loader: NoopLoader::default().into(),
|
||||
adapter: NoopCustomNode::default().into(),
|
||||
|
||||
validator_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +40,8 @@ impl From<Arc<DecisionContent>> for Decision<NoopLoader, NoopCustomNode> {
|
||||
content: value,
|
||||
loader: NoopLoader::default().into(),
|
||||
adapter: NoopCustomNode::default().into(),
|
||||
|
||||
validator_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +59,7 @@ where
|
||||
loader,
|
||||
adapter: self.adapter,
|
||||
content: self.content,
|
||||
validator_cache: self.validator_cache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +71,7 @@ where
|
||||
loader: self.loader,
|
||||
adapter,
|
||||
content: self.content,
|
||||
validator_cache: self.validator_cache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +96,12 @@ where
|
||||
loader: Arc::new(CachedLoader::from(self.loader.clone())),
|
||||
adapter: self.adapter.clone(),
|
||||
iteration: 0,
|
||||
validator_cache: Some(self.validator_cache.clone()),
|
||||
})?;
|
||||
|
||||
Ok(decision_graph.evaluate(context).await?)
|
||||
let response = decision_graph.evaluate(context).await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), DecisionGraphValidationError> {
|
||||
@@ -101,6 +112,7 @@ where
|
||||
loader: Arc::new(CachedLoader::from(self.loader.clone())),
|
||||
adapter: self.adapter.clone(),
|
||||
iteration: 0,
|
||||
validator_cache: Some(self.validator_cache.clone()),
|
||||
})?;
|
||||
|
||||
decision_graph.validate()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use crate::handler::graph::DecisionGraphValidationError;
|
||||
use crate::handler::node::NodeError;
|
||||
use crate::loader::LoaderError;
|
||||
use jsonschema::{ErrorIterator, ValidationError};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde_json::{Map, Value};
|
||||
use std::iter::once;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -18,6 +21,9 @@ pub enum EvaluationError {
|
||||
|
||||
#[error("Invalid graph")]
|
||||
InvalidGraph(Box<DecisionGraphValidationError>),
|
||||
|
||||
#[error("Validation failed")]
|
||||
Validation(Box<Value>),
|
||||
}
|
||||
|
||||
impl Serialize for EvaluationError {
|
||||
@@ -55,6 +61,10 @@ impl Serialize for EvaluationError {
|
||||
map.serialize_entry("type", "InvalidGraph")?;
|
||||
map.serialize_entry("source", err)?;
|
||||
}
|
||||
EvaluationError::Validation(err) => {
|
||||
map.serialize_entry("type", "Validation")?;
|
||||
map.serialize_entry("source", err)?;
|
||||
}
|
||||
}
|
||||
|
||||
map.end()
|
||||
@@ -90,3 +100,42 @@ impl From<DecisionGraphValidationError> for Box<EvaluationError> {
|
||||
Box::new(EvaluationError::InvalidGraph(error.into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ValidationErrorJson {
|
||||
path: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl<'a> From<ValidationError<'a>> for ValidationErrorJson {
|
||||
fn from(value: ValidationError<'a>) -> Self {
|
||||
ValidationErrorJson {
|
||||
path: value.instance_path.to_string(),
|
||||
message: format!("{}", value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<ErrorIterator<'a>> for Box<EvaluationError> {
|
||||
fn from(error_iter: ErrorIterator<'a>) -> Self {
|
||||
let errors: Vec<ValidationErrorJson> = error_iter.into_iter().map(From::from).collect();
|
||||
|
||||
let mut json_map = Map::new();
|
||||
json_map.insert(
|
||||
"errors".to_string(),
|
||||
serde_json::to_value(errors).unwrap_or_default(),
|
||||
);
|
||||
|
||||
Box::new(EvaluationError::Validation(Box::new(Value::Object(
|
||||
json_map,
|
||||
))))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<ValidationError<'a>> for Box<EvaluationError> {
|
||||
fn from(value: ValidationError<'a>) -> Self {
|
||||
let iterator: ErrorIterator<'a> = Box::new(once(value));
|
||||
Box::<EvaluationError>::from(iterator)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::handler::graph::{DecisionGraph, DecisionGraphConfig};
|
||||
use crate::handler::node::{NodeRequest, NodeResponse, NodeResult};
|
||||
use crate::loader::DecisionLoader;
|
||||
use crate::model::DecisionNodeKind;
|
||||
use crate::util::validator_cache::ValidatorCache;
|
||||
use anyhow::anyhow;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
@@ -17,6 +18,7 @@ pub struct DecisionHandler<L: DecisionLoader + 'static, A: CustomNodeAdapter + '
|
||||
adapter: Arc<A>,
|
||||
max_depth: u8,
|
||||
js_function: Option<Rc<Function>>,
|
||||
validator_cache: ValidatorCache,
|
||||
}
|
||||
|
||||
impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionHandler<L, A> {
|
||||
@@ -26,6 +28,7 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionHandle
|
||||
loader: Arc<L>,
|
||||
adapter: Arc<A>,
|
||||
js_function: Option<Rc<Function>>,
|
||||
validator_cache: ValidatorCache,
|
||||
) -> Self {
|
||||
Self {
|
||||
trace,
|
||||
@@ -33,6 +36,7 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionHandle
|
||||
adapter,
|
||||
max_depth,
|
||||
js_function,
|
||||
validator_cache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +62,7 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionHandle
|
||||
adapter: self.adapter.clone(),
|
||||
iteration: request.iteration + 1,
|
||||
trace: self.trace,
|
||||
validator_cache: Some(self.validator_cache.clone()),
|
||||
})?
|
||||
.with_function(self.js_function.clone());
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ async fn execute_http<'js>(
|
||||
url: String,
|
||||
data: Option<JsValue>,
|
||||
config: Option<HttpConfig>,
|
||||
) -> rquickjs::Result<HttpResponse> {
|
||||
) -> rquickjs::Result<HttpResponse<'js>> {
|
||||
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
|
||||
let client = HTTP_CLIENT.get_or_init(|| reqwest::Client::new()).clone();
|
||||
@@ -163,7 +163,7 @@ async fn get<'js>(
|
||||
ctx: Ctx<'js>,
|
||||
url: String,
|
||||
config: Opt<HttpConfig>,
|
||||
) -> rquickjs::Result<HttpResponse> {
|
||||
) -> rquickjs::Result<HttpResponse<'js>> {
|
||||
execute_http(ctx, Method::GET, url, None, config.0).await
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ async fn post<'js>(
|
||||
url: String,
|
||||
data: JsValue,
|
||||
config: Opt<HttpConfig>,
|
||||
) -> rquickjs::Result<HttpResponse> {
|
||||
) -> rquickjs::Result<HttpResponse<'js>> {
|
||||
execute_http(ctx, Method::POST, url, Some(data), config.0).await
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ async fn patch<'js>(
|
||||
url: String,
|
||||
data: JsValue,
|
||||
config: Opt<HttpConfig>,
|
||||
) -> rquickjs::Result<HttpResponse> {
|
||||
) -> rquickjs::Result<HttpResponse<'js>> {
|
||||
execute_http(ctx, Method::PATCH, url, Some(data), config.0).await
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ async fn put<'js>(
|
||||
url: String,
|
||||
data: JsValue,
|
||||
config: Opt<HttpConfig>,
|
||||
) -> rquickjs::Result<HttpResponse> {
|
||||
) -> rquickjs::Result<HttpResponse<'js>> {
|
||||
execute_http(ctx, Method::PUT, url, Some(data), config.0).await
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ async fn delete<'js>(
|
||||
ctx: Ctx<'js>,
|
||||
url: String,
|
||||
config: Opt<HttpConfig>,
|
||||
) -> rquickjs::Result<HttpResponse> {
|
||||
) -> rquickjs::Result<HttpResponse<'js>> {
|
||||
execute_http(ctx, Method::DELETE, url, None, config.0).await
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ async fn head<'js>(
|
||||
ctx: Ctx<'js>,
|
||||
url: String,
|
||||
config: Opt<HttpConfig>,
|
||||
) -> rquickjs::Result<HttpResponse> {
|
||||
) -> rquickjs::Result<HttpResponse<'js>> {
|
||||
execute_http(ctx, Method::DELETE, url, None, config.0).await
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ impl<Loader: DecisionLoader + 'static, Adapter: CustomNodeAdapter + 'static> Run
|
||||
adapter,
|
||||
iteration: iteration + 1,
|
||||
trace,
|
||||
validator_cache: None,
|
||||
})
|
||||
.or_throw(&ctx)?;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::handler::table::zen::DecisionTableHandler;
|
||||
use crate::handler::traversal::{GraphWalker, StableDiDecisionGraph};
|
||||
use crate::loader::DecisionLoader;
|
||||
use crate::model::{DecisionContent, DecisionNodeKind, FunctionNodeContent};
|
||||
use crate::util::validator_cache::ValidatorCache;
|
||||
use crate::{EvaluationError, NodeError};
|
||||
use ahash::{HashMap, HashMapExt};
|
||||
use anyhow::anyhow;
|
||||
@@ -34,6 +35,7 @@ pub struct DecisionGraph<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'st
|
||||
max_depth: u8,
|
||||
iteration: u8,
|
||||
runtime: Option<Rc<Function>>,
|
||||
validator_cache: ValidatorCache,
|
||||
}
|
||||
|
||||
pub struct DecisionGraphConfig<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> {
|
||||
@@ -43,6 +45,7 @@ pub struct DecisionGraphConfig<L: DecisionLoader + 'static, A: CustomNodeAdapter
|
||||
pub trace: bool,
|
||||
pub iteration: u8,
|
||||
pub max_depth: u8,
|
||||
pub validator_cache: Option<ValidatorCache>,
|
||||
}
|
||||
|
||||
impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGraph<L, A> {
|
||||
@@ -80,6 +83,7 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGraph<
|
||||
loader: config.loader,
|
||||
adapter: config.adapter,
|
||||
max_depth: config.max_depth,
|
||||
validator_cache: config.validator_cache.unwrap_or_default(),
|
||||
runtime: None,
|
||||
})
|
||||
}
|
||||
@@ -116,7 +120,7 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGraph<
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), DecisionGraphValidationError> {
|
||||
let input_count = self.node_kind_count(DecisionNodeKind::InputNode);
|
||||
let input_count = self.input_node_count();
|
||||
if input_count != 1 {
|
||||
return Err(DecisionGraphValidationError::InvalidInputCount(
|
||||
input_count as u32,
|
||||
@@ -130,10 +134,10 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGraph<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node_kind_count(&self, kind: DecisionNodeKind) -> usize {
|
||||
fn input_node_count(&self) -> usize {
|
||||
self.graph
|
||||
.node_weights()
|
||||
.filter(|weight| weight.kind == kind)
|
||||
.filter(|weight| matches!(weight.kind, DecisionNodeKind::InputNode { content: _ }))
|
||||
.count()
|
||||
}
|
||||
|
||||
@@ -194,23 +198,82 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGraph<
|
||||
}
|
||||
|
||||
match &node.kind {
|
||||
DecisionNodeKind::InputNode => {
|
||||
walker.set_node_data(nid, context.clone());
|
||||
trace!({
|
||||
input: Variable::Null,
|
||||
output: Variable::Null,
|
||||
trace_data: None,
|
||||
});
|
||||
}
|
||||
DecisionNodeKind::OutputNode => {
|
||||
DecisionNodeKind::InputNode { content } => {
|
||||
trace!({
|
||||
input: Variable::Null,
|
||||
output: Variable::Null,
|
||||
trace_data: None,
|
||||
});
|
||||
|
||||
if let Some(json_schema) = content
|
||||
.schema
|
||||
.as_ref()
|
||||
.map(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.flatten()
|
||||
{
|
||||
let validator = self
|
||||
.validator_cache
|
||||
.get_or_insert(node.id.as_str(), &json_schema)
|
||||
.await
|
||||
.map_err(|e| NodeError {
|
||||
source: e.into(),
|
||||
node_id: node.id.clone(),
|
||||
trace: error_trace(&node_traces),
|
||||
})?;
|
||||
|
||||
let context_json = context.to_value();
|
||||
validator.validate(&context_json).map_err(|e| NodeError {
|
||||
source: anyhow!(serde_json::to_value(
|
||||
Into::<Box<EvaluationError>>::into(e)
|
||||
)
|
||||
.unwrap_or_default()),
|
||||
node_id: node.id.clone(),
|
||||
trace: error_trace(&node_traces),
|
||||
})?;
|
||||
}
|
||||
|
||||
walker.set_node_data(nid, context.clone());
|
||||
}
|
||||
DecisionNodeKind::OutputNode { content } => {
|
||||
let incoming_data = walker.incoming_node_data(&self.graph, nid, false);
|
||||
|
||||
trace!({
|
||||
input: incoming_data.clone(),
|
||||
output: Variable::Null,
|
||||
trace_data: None,
|
||||
});
|
||||
|
||||
if let Some(json_schema) = content
|
||||
.schema
|
||||
.as_ref()
|
||||
.map(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.flatten()
|
||||
{
|
||||
let validator = self
|
||||
.validator_cache
|
||||
.get_or_insert(node.id.as_str(), &json_schema)
|
||||
.await
|
||||
.map_err(|e| NodeError {
|
||||
source: e.into(),
|
||||
node_id: node.id.clone(),
|
||||
trace: error_trace(&node_traces),
|
||||
})?;
|
||||
|
||||
let incoming_data_json = incoming_data.to_value();
|
||||
validator
|
||||
.validate(&incoming_data_json)
|
||||
.map_err(|e| NodeError {
|
||||
source: anyhow!(serde_json::to_value(
|
||||
Into::<Box<EvaluationError>>::into(e)
|
||||
)
|
||||
.unwrap_or_default()),
|
||||
node_id: node.id.clone(),
|
||||
trace: error_trace(&node_traces),
|
||||
})?;
|
||||
}
|
||||
|
||||
return Ok(DecisionGraphResponse {
|
||||
result: walker.incoming_node_data(&self.graph, nid, false),
|
||||
result: incoming_data,
|
||||
performance: format!("{:.1?}", root_start.elapsed()),
|
||||
trace: node_traces,
|
||||
});
|
||||
@@ -297,6 +360,7 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGraph<
|
||||
self.loader.clone(),
|
||||
self.adapter.clone(),
|
||||
self.runtime.clone(),
|
||||
self.validator_cache.clone(),
|
||||
)
|
||||
.handle(node_request.clone())
|
||||
.await
|
||||
|
||||
@@ -23,9 +23,9 @@ pub struct NodeRequest {
|
||||
#[derive(Error, Debug)]
|
||||
pub struct NodeError {
|
||||
pub node_id: String,
|
||||
pub trace: Option<Value>,
|
||||
#[source]
|
||||
pub source: anyhow::Error,
|
||||
pub trace: Option<Value>,
|
||||
}
|
||||
|
||||
impl Display for NodeError {
|
||||
|
||||
@@ -45,7 +45,7 @@ impl GraphWalker {
|
||||
self.to_visit
|
||||
.extend(g.node_identifiers().filter(move |&nid| {
|
||||
g.node_weight(nid)
|
||||
.is_some_and(|n| n.kind == DecisionNodeKind::InputNode)
|
||||
.is_some_and(|n| matches!(n.kind, DecisionNodeKind::InputNode { content: _ }))
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ use std::sync::Arc;
|
||||
|
||||
/// JDM Decision model
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DecisionContent {
|
||||
pub nodes: Vec<Arc<DecisionNode>>,
|
||||
@@ -13,7 +12,6 @@ pub struct DecisionContent {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DecisionEdge {
|
||||
pub id: String,
|
||||
@@ -23,7 +21,6 @@ pub struct DecisionEdge {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DecisionNode {
|
||||
pub id: String,
|
||||
@@ -40,22 +37,52 @@ impl PartialEq for DecisionNode {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DecisionNodeKind {
|
||||
InputNode,
|
||||
OutputNode,
|
||||
FunctionNode { content: FunctionNodeContent },
|
||||
DecisionNode { content: DecisionNodeContent },
|
||||
DecisionTableNode { content: DecisionTableContent },
|
||||
ExpressionNode { content: ExpressionNodeContent },
|
||||
SwitchNode { content: SwitchNodeContent },
|
||||
CustomNode { content: CustomNodeContent },
|
||||
InputNode {
|
||||
#[serde(default)]
|
||||
content: InputNodeContent,
|
||||
},
|
||||
OutputNode {
|
||||
#[serde(default)]
|
||||
content: OutputNodeContent,
|
||||
},
|
||||
FunctionNode {
|
||||
content: FunctionNodeContent,
|
||||
},
|
||||
DecisionNode {
|
||||
content: DecisionNodeContent,
|
||||
},
|
||||
DecisionTableNode {
|
||||
content: DecisionTableContent,
|
||||
},
|
||||
ExpressionNode {
|
||||
content: ExpressionNodeContent,
|
||||
},
|
||||
SwitchNode {
|
||||
content: SwitchNodeContent,
|
||||
},
|
||||
CustomNode {
|
||||
content: CustomNodeContent,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InputNodeContent {
|
||||
#[serde(default, deserialize_with = "empty_string_is_none")]
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OutputNodeContent {
|
||||
#[serde(default, deserialize_with = "empty_string_is_none")]
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(untagged)]
|
||||
pub enum FunctionNodeContent {
|
||||
@@ -64,14 +91,12 @@ pub enum FunctionNodeContent {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Default)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FunctionContent {
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DecisionNodeContent {
|
||||
pub key: String,
|
||||
@@ -80,7 +105,6 @@ pub struct DecisionNodeContent {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DecisionTableContent {
|
||||
pub rules: Vec<HashMap<String, String>>,
|
||||
@@ -92,7 +116,6 @@ pub struct DecisionTableContent {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DecisionTableHitPolicy {
|
||||
First,
|
||||
@@ -100,7 +123,6 @@ pub enum DecisionTableHitPolicy {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DecisionTableInputField {
|
||||
pub id: String,
|
||||
@@ -110,7 +132,6 @@ pub struct DecisionTableInputField {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DecisionTableOutputField {
|
||||
pub id: String,
|
||||
@@ -119,7 +140,6 @@ pub struct DecisionTableOutputField {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExpressionNodeContent {
|
||||
pub expressions: Vec<Expression>,
|
||||
@@ -128,7 +148,6 @@ pub struct ExpressionNodeContent {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Expression {
|
||||
pub id: String,
|
||||
@@ -137,7 +156,6 @@ pub struct Expression {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SwitchNodeContent {
|
||||
#[serde(default)]
|
||||
@@ -146,7 +164,6 @@ pub struct SwitchNodeContent {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SwitchStatement {
|
||||
pub id: String,
|
||||
@@ -154,7 +171,6 @@ pub struct SwitchStatement {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Default)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SwitchStatementHitPolicy {
|
||||
#[default]
|
||||
@@ -163,7 +179,6 @@ pub enum SwitchStatementHitPolicy {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Default)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransformAttributes {
|
||||
#[serde(default, deserialize_with = "empty_string_is_none")]
|
||||
@@ -177,7 +192,6 @@ pub struct TransformAttributes {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Default)]
|
||||
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TransformExecutionMode {
|
||||
#[default]
|
||||
@@ -192,50 +206,6 @@ pub struct CustomNodeContent {
|
||||
pub config: Arc<Value>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "bincode")]
|
||||
impl ::bincode::Encode for CustomNodeContent {
|
||||
fn encode<__E: ::bincode::enc::Encoder>(
|
||||
&self,
|
||||
encoder: &mut __E,
|
||||
) -> Result<(), ::bincode::error::EncodeError> {
|
||||
let config_string = self.config.to_string();
|
||||
|
||||
::bincode::Encode::encode(&self.kind, encoder)?;
|
||||
::bincode::Encode::encode(config_string.as_bytes(), encoder)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bincode")]
|
||||
impl ::bincode::Decode for CustomNodeContent {
|
||||
fn decode<__D: ::bincode::de::Decoder>(
|
||||
decoder: &mut __D,
|
||||
) -> Result<Self, ::bincode::error::DecodeError> {
|
||||
let kind: String = ::bincode::Decode::decode(decoder)?;
|
||||
let config_string: String = ::bincode::Decode::decode(decoder)?;
|
||||
|
||||
let config = serde_json::from_str(config_string.as_str())
|
||||
.map_err(|_| ::bincode::error::DecodeError::Other("failed to deserialize value"))?;
|
||||
|
||||
Ok(Self { kind, config })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bincode")]
|
||||
impl<'__de> ::bincode::BorrowDecode<'__de> for CustomNodeContent {
|
||||
fn borrow_decode<__D: ::bincode::de::BorrowDecoder<'__de>>(
|
||||
decoder: &mut __D,
|
||||
) -> Result<Self, ::bincode::error::DecodeError> {
|
||||
let kind: String = ::bincode::BorrowDecode::borrow_decode(decoder)?;
|
||||
let config_string: String = ::bincode::BorrowDecode::borrow_decode(decoder)?;
|
||||
|
||||
let config = serde_json::from_str(config_string.as_str())
|
||||
.map_err(|_| ::bincode::error::DecodeError::Other("failed to deserialize value"))?;
|
||||
|
||||
Ok(Self { kind, config })
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_string_is_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
mod transform_attribute;
|
||||
pub mod validator_cache;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use crate::EvaluationError;
|
||||
use ahash::HashMap;
|
||||
use jsonschema::Validator;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct ValidatorCache {
|
||||
inner: Arc<RwLock<HashMap<String, Arc<Validator>>>>,
|
||||
}
|
||||
|
||||
impl ValidatorCache {
|
||||
pub async fn get(&self, key: &str) -> Option<Arc<Validator>> {
|
||||
let read = self.inner.read().await;
|
||||
read.get(key).cloned()
|
||||
}
|
||||
|
||||
pub async fn get_or_insert(
|
||||
&self,
|
||||
key: &str,
|
||||
schema: &Value,
|
||||
) -> Result<Arc<Validator>, Box<EvaluationError>> {
|
||||
if let Some(v) = self.get(key).await {
|
||||
return Ok(v);
|
||||
}
|
||||
|
||||
let mut w_shared = self.inner.write().await;
|
||||
let validator = Arc::new(jsonschema::draft7::new(&schema)?);
|
||||
w_shared.insert(key.to_string(), validator.clone());
|
||||
|
||||
Ok(validator)
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ async fn engine_graph_tests() {
|
||||
let engine = DecisionEngine::default();
|
||||
|
||||
let graphs_path = Path::new(test_data_root().as_str()).join("graphs");
|
||||
let file_list = std::fs::read_dir(graphs_path).unwrap();
|
||||
let file_list = fs::read_dir(graphs_path).unwrap();
|
||||
for maybe_file in file_list {
|
||||
let Ok(file) = maybe_file else {
|
||||
panic!("Failed to read DirEntry {maybe_file:?}");
|
||||
@@ -293,3 +293,49 @@ async fn engine_function_v2() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validation() {
|
||||
let engine = DecisionEngine::default().with_loader(create_fs_loader().into());
|
||||
|
||||
let context_valid = json!({
|
||||
"color": "red",
|
||||
"customer": {
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"email": "john@doe.com",
|
||||
"age": 20
|
||||
}
|
||||
});
|
||||
|
||||
let context_invalid = json!({
|
||||
"color": "redd",
|
||||
"customer": {
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"email": "john@doe.com",
|
||||
"age": 20
|
||||
}
|
||||
});
|
||||
|
||||
assert!(engine
|
||||
.evaluate("customer-input-schema.json", context_valid.clone().into())
|
||||
.await
|
||||
.is_ok());
|
||||
assert!(engine
|
||||
.evaluate("customer-input-schema.json", context_invalid.clone().into())
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
assert!(engine
|
||||
.evaluate("customer-output-schema.json", context_valid.clone().into())
|
||||
.await
|
||||
.is_ok());
|
||||
assert!(engine
|
||||
.evaluate(
|
||||
"customer-output-schema.json",
|
||||
context_invalid.clone().into()
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
@@ -5,30 +5,6 @@ use zen_engine::model::DecisionContent;
|
||||
|
||||
mod support;
|
||||
|
||||
#[cfg(feature = "bincode")]
|
||||
mod bincode_tests {
|
||||
use crate::support::load_test_data;
|
||||
use bincode::config;
|
||||
use zen_engine::model::DecisionContent;
|
||||
|
||||
#[test]
|
||||
fn jdm_bincode() {
|
||||
let decision_content = load_test_data("table.json");
|
||||
let cache_slice_r = bincode::encode_to_vec(&decision_content, config::standard());
|
||||
|
||||
assert!(cache_slice_r.is_ok(), "Bincode serialisation failed");
|
||||
|
||||
let cache_slice = cache_slice_r.unwrap();
|
||||
let decode_res =
|
||||
bincode::decode_from_slice::<DecisionContent, _>(&cache_slice, config::standard());
|
||||
|
||||
assert!(decode_res.is_ok(), "Bincode deserialization failed");
|
||||
|
||||
let decoded_decision_content = decode_res.unwrap();
|
||||
assert_eq!(decoded_decision_content.0, decision_content);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(miri, ignore)]
|
||||
fn jdm_serde() {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Generated schema for Root",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"color": {
|
||||
"enum": [
|
||||
"red",
|
||||
"blue",
|
||||
"green"
|
||||
]
|
||||
},
|
||||
"customer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firstName": {
|
||||
"description": "Customer first name",
|
||||
"type": "string",
|
||||
"minimum": 1
|
||||
},
|
||||
"lastName": {
|
||||
"description": "Customer last name",
|
||||
"type": "string",
|
||||
"minimum": 1
|
||||
},
|
||||
"email": {
|
||||
"description": "Customer email",
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"age": {
|
||||
"description": "Customer age",
|
||||
"type": "number",
|
||||
"minimum": 18
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"age"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"color",
|
||||
"customer"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"contentType": "application/vnd.gorules.decision",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "inputNode",
|
||||
"id": "4354dede-b4ed-4a57-9b80-45c1e33e2326",
|
||||
"name": "request",
|
||||
"content": {
|
||||
"schema": "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Generated schema for Root\",\"type\":\"object\",\"properties\":{\"color\":{\"enum\":[\"red\",\"blue\",\"green\"]},\"customer\":{\"type\":\"object\",\"properties\":{\"firstName\":{\"description\":\"Customer first name\",\"type\":\"string\",\"minimum\":1},\"lastName\":{\"description\":\"Customer last name\",\"type\":\"string\",\"minimum\":1},\"email\":{\"description\":\"Customer email\",\"type\":\"string\",\"format\":\"email\"},\"age\":{\"description\":\"Customer age\",\"type\":\"number\",\"minimum\":18}},\"required\":[\"firstName\",\"lastName\",\"email\",\"age\"]}},\"required\":[\"color\",\"customer\"]}"
|
||||
},
|
||||
"position": {
|
||||
"x": 90,
|
||||
"y": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "outputNode",
|
||||
"id": "27e18970-f565-43eb-859e-568c9f53b7a8",
|
||||
"name": "response",
|
||||
"position": {
|
||||
"x": 510,
|
||||
"y": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "4c036317-bc39-4ad2-a825-adbfd4ca2df6",
|
||||
"sourceId": "4354dede-b4ed-4a57-9b80-45c1e33e2326",
|
||||
"type": "edge",
|
||||
"targetId": "27e18970-f565-43eb-859e-568c9f53b7a8"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"contentType": "application/vnd.gorules.decision",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "inputNode",
|
||||
"id": "4354dede-b4ed-4a57-9b80-45c1e33e2326",
|
||||
"name": "request",
|
||||
"position": {
|
||||
"x": 90,
|
||||
"y": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "outputNode",
|
||||
"id": "27e18970-f565-43eb-859e-568c9f53b7a8",
|
||||
"name": "response",
|
||||
"content": {
|
||||
"schema": "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Generated schema for Root\",\"type\":\"object\",\"properties\":{\"color\":{\"enum\":[\"red\",\"blue\",\"green\"]},\"customer\":{\"type\":\"object\",\"properties\":{\"firstName\":{\"description\":\"Customer first name\",\"type\":\"string\",\"minimum\":1},\"lastName\":{\"description\":\"Customer last name\",\"type\":\"string\",\"minimum\":1},\"email\":{\"description\":\"Customer email\",\"type\":\"string\",\"format\":\"email\"},\"age\":{\"description\":\"Customer age\",\"type\":\"number\",\"minimum\":18}},\"required\":[\"firstName\",\"lastName\",\"email\",\"age\"]}},\"required\":[\"color\",\"customer\"]}"
|
||||
},
|
||||
"position": {
|
||||
"x": 510,
|
||||
"y": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "4c036317-bc39-4ad2-a825-adbfd4ca2df6",
|
||||
"sourceId": "4354dede-b4ed-4a57-9b80-45c1e33e2326",
|
||||
"type": "edge",
|
||||
"targetId": "27e18970-f565-43eb-859e-568c9f53b7a8"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"contentType": "application/vnd.gorules.decision",
|
||||
"nodes": [
|
||||
{
|
||||
"type": "inputNode",
|
||||
"id": "4354dede-b4ed-4a57-9b80-45c1e33e2326",
|
||||
"name": "request",
|
||||
"position": {
|
||||
"x": 90,
|
||||
"y": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "outputNode",
|
||||
"id": "27e18970-f565-43eb-859e-568c9f53b7a8",
|
||||
"name": "response",
|
||||
"position": {
|
||||
"x": 510,
|
||||
"y": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "4c036317-bc39-4ad2-a825-adbfd4ca2df6",
|
||||
"sourceId": "4354dede-b4ed-4a57-9b80-45c1e33e2326",
|
||||
"type": "edge",
|
||||
"targetId": "27e18970-f565-43eb-859e-568c9f53b7a8"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user