diff --git a/Cargo.toml b/Cargo.toml index 5a860865..e01982cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/actions/cargo-version-action/src/index.spec.ts b/actions/cargo-version-action/src/index.spec.ts index 2f78f9e5..9603ba31 100644 --- a/actions/cargo-version-action/src/index.spec.ts +++ b/actions/cargo-version-action/src/index.spec.ts @@ -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" } diff --git a/core/engine/Cargo.toml b/core/engine/Cargo.toml index fb91ed5d..d0e75f25 100644 --- a/core/engine/Cargo.toml +++ b/core/engine/Cargo.toml @@ -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" \ No newline at end of file diff --git a/core/engine/src/decision.rs b/core/engine/src/decision.rs index 4b8610bf..6a87a0a6 100644 --- a/core/engine/src/decision.rs +++ b/core/engine/src/decision.rs @@ -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, loader: Arc, adapter: Arc, + + validator_cache: ValidatorCache, } impl From for Decision { @@ -26,6 +28,8 @@ impl From for Decision { content: value.into(), loader: NoopLoader::default().into(), adapter: NoopCustomNode::default().into(), + + validator_cache: Default::default(), } } } @@ -36,6 +40,8 @@ impl From> for Decision { 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() diff --git a/core/engine/src/error.rs b/core/engine/src/error.rs index 412567c0..4cd79486 100644 --- a/core/engine/src/error.rs +++ b/core/engine/src/error.rs @@ -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), + + #[error("Validation failed")] + Validation(Box), } 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 for Box { Box::new(EvaluationError::InvalidGraph(error.into())) } } + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ValidationErrorJson { + path: String, + message: String, +} + +impl<'a> From> for ValidationErrorJson { + fn from(value: ValidationError<'a>) -> Self { + ValidationErrorJson { + path: value.instance_path.to_string(), + message: format!("{}", value), + } + } +} + +impl<'a> From> for Box { + fn from(error_iter: ErrorIterator<'a>) -> Self { + let errors: Vec = 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> for Box { + fn from(value: ValidationError<'a>) -> Self { + let iterator: ErrorIterator<'a> = Box::new(once(value)); + Box::::from(iterator) + } +} diff --git a/core/engine/src/handler/decision.rs b/core/engine/src/handler/decision.rs index 927d5976..89415a19 100644 --- a/core/engine/src/handler/decision.rs +++ b/core/engine/src/handler/decision.rs @@ -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, max_depth: u8, js_function: Option>, + validator_cache: ValidatorCache, } impl DecisionHandler { @@ -26,6 +28,7 @@ impl DecisionHandle loader: Arc, adapter: Arc, js_function: Option>, + validator_cache: ValidatorCache, ) -> Self { Self { trace, @@ -33,6 +36,7 @@ impl DecisionHandle adapter, max_depth, js_function, + validator_cache, } } @@ -58,6 +62,7 @@ impl 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()); diff --git a/core/engine/src/handler/function/module/http.rs b/core/engine/src/handler/function/module/http.rs index c4b683fd..b611cdce 100644 --- a/core/engine/src/handler/function/module/http.rs +++ b/core/engine/src/handler/function/module/http.rs @@ -33,7 +33,7 @@ async fn execute_http<'js>( url: String, data: Option, config: Option, -) -> rquickjs::Result { +) -> rquickjs::Result> { static HTTP_CLIENT: OnceLock = 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, -) -> rquickjs::Result { +) -> rquickjs::Result> { execute_http(ctx, Method::GET, url, None, config.0).await } @@ -172,7 +172,7 @@ async fn post<'js>( url: String, data: JsValue, config: Opt, -) -> rquickjs::Result { +) -> rquickjs::Result> { 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, -) -> rquickjs::Result { +) -> rquickjs::Result> { 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, -) -> rquickjs::Result { +) -> rquickjs::Result> { 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, -) -> rquickjs::Result { +) -> rquickjs::Result> { 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, -) -> rquickjs::Result { +) -> rquickjs::Result> { execute_http(ctx, Method::DELETE, url, None, config.0).await } diff --git a/core/engine/src/handler/function/module/zen.rs b/core/engine/src/handler/function/module/zen.rs index ad3541f5..65ab08f4 100644 --- a/core/engine/src/handler/function/module/zen.rs +++ b/core/engine/src/handler/function/module/zen.rs @@ -64,6 +64,7 @@ impl Run adapter, iteration: iteration + 1, trace, + validator_cache: None, }) .or_throw(&ctx)?; diff --git a/core/engine/src/handler/graph.rs b/core/engine/src/handler/graph.rs index beb73932..db735b7e 100644 --- a/core/engine/src/handler/graph.rs +++ b/core/engine/src/handler/graph.rs @@ -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>, + validator_cache: ValidatorCache, } pub struct DecisionGraphConfig { @@ -43,6 +45,7 @@ pub struct DecisionGraphConfig, } impl DecisionGraph { @@ -80,6 +83,7 @@ impl 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 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 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 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::(&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::>::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::(&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::>::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 DecisionGraph< self.loader.clone(), self.adapter.clone(), self.runtime.clone(), + self.validator_cache.clone(), ) .handle(node_request.clone()) .await diff --git a/core/engine/src/handler/node.rs b/core/engine/src/handler/node.rs index 38800c36..ebc5438c 100644 --- a/core/engine/src/handler/node.rs +++ b/core/engine/src/handler/node.rs @@ -23,9 +23,9 @@ pub struct NodeRequest { #[derive(Error, Debug)] pub struct NodeError { pub node_id: String, + pub trace: Option, #[source] pub source: anyhow::Error, - pub trace: Option, } impl Display for NodeError { diff --git a/core/engine/src/handler/traversal.rs b/core/engine/src/handler/traversal.rs index 74ecf10e..e3fe22d0 100644 --- a/core/engine/src/handler/traversal.rs +++ b/core/engine/src/handler/traversal.rs @@ -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: _ })) })); } diff --git a/core/engine/src/model/mod.rs b/core/engine/src/model/mod.rs index c19d283e..657b39fc 100644 --- a/core/engine/src/model/mod.rs +++ b/core/engine/src/model/mod.rs @@ -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>, @@ -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, +} + +#[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, } #[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>, @@ -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, @@ -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, } -#[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 { - 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 { - 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, D::Error> where D: Deserializer<'de>, diff --git a/core/engine/src/util/mod.rs b/core/engine/src/util/mod.rs index 59a47e0e..92f712a3 100644 --- a/core/engine/src/util/mod.rs +++ b/core/engine/src/util/mod.rs @@ -1 +1,2 @@ mod transform_attribute; +pub mod validator_cache; diff --git a/core/engine/src/util/validator_cache.rs b/core/engine/src/util/validator_cache.rs new file mode 100644 index 00000000..bfba7e84 --- /dev/null +++ b/core/engine/src/util/validator_cache.rs @@ -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>>>, +} + +impl ValidatorCache { + pub async fn get(&self, key: &str) -> Option> { + let read = self.inner.read().await; + read.get(key).cloned() + } + + pub async fn get_or_insert( + &self, + key: &str, + schema: &Value, + ) -> Result, Box> { + 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) + } +} diff --git a/core/engine/tests/engine.rs b/core/engine/tests/engine.rs index 961453a4..c0337dff 100644 --- a/core/engine/tests/engine.rs +++ b/core/engine/tests/engine.rs @@ -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()); +} diff --git a/core/engine/tests/model.rs b/core/engine/tests/model.rs index 4fe89de6..dd5fc32b 100644 --- a/core/engine/tests/model.rs +++ b/core/engine/tests/model.rs @@ -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::(&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() { diff --git a/core/engine/tests/schema/customer.schema.json b/core/engine/tests/schema/customer.schema.json new file mode 100644 index 00000000..42d8d5c6 --- /dev/null +++ b/core/engine/tests/schema/customer.schema.json @@ -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" + ] +} \ No newline at end of file diff --git a/test-data/customer-input-schema.json b/test-data/customer-input-schema.json new file mode 100644 index 00000000..ca43477f --- /dev/null +++ b/test-data/customer-input-schema.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/test-data/customer-output-schema.json b/test-data/customer-output-schema.json new file mode 100644 index 00000000..158960ad --- /dev/null +++ b/test-data/customer-output-schema.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/test-data/passthrough.json b/test-data/passthrough.json new file mode 100644 index 00000000..f64ee962 --- /dev/null +++ b/test-data/passthrough.json @@ -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" + } + ] +} \ No newline at end of file