diff --git a/Cargo.toml b/Cargo.toml index e2e872c5..060570e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ criterion = "0.5.1" fastrand = "1.9.0" humantime = "2.1.0" once_cell = "1.17.2" +petgraph = "0.6.4" phf = "0.11.1" regex = "1.8.4" serde_json = "1.0.96" diff --git a/bindings/nodejs/src/decision.rs b/bindings/nodejs/src/decision.rs index 043d271a..e6d7f638 100644 --- a/bindings/nodejs/src/decision.rs +++ b/bindings/nodejs/src/decision.rs @@ -48,4 +48,14 @@ impl ZenDecision { Ok(serde_json::to_value(&result)?) } + + #[napi] + pub fn validate(&self) -> napi::Result<()> { + let decision = self.0.clone(); + let result = decision + .validate() + .map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?; + + Ok(result) + } } diff --git a/bindings/python/src/decision.rs b/bindings/python/src/decision.rs index b38397c0..0687d5d6 100644 --- a/bindings/python/src/decision.rs +++ b/bindings/python/src/decision.rs @@ -43,4 +43,13 @@ impl PyZenDecision { let value = serde_json::to_value(&result).context("Fail")?; Ok(PyValue(value).to_object(py)) } + + pub fn validate(&self) -> PyResult<()> { + let decision = self.0.clone(); + decision + .validate() + .map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?; + + Ok(()) + } } diff --git a/core/engine/Cargo.toml b/core/engine/Cargo.toml index bbdab556..ad295198 100644 --- a/core/engine/Cargo.toml +++ b/core/engine/Cargo.toml @@ -16,6 +16,7 @@ anyhow = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } bincode = { workspace = true, optional = true } +petgraph = { workspace = true } serde_json = { workspace = true, features = ["arbitrary_precision"] } serde = { version = "1.0.163", features = ["derive"] } serde_v8 = { version = "0.103.0" } diff --git a/core/engine/src/decision.rs b/core/engine/src/decision.rs index a149848c..5bad1355 100644 --- a/core/engine/src/decision.rs +++ b/core/engine/src/decision.rs @@ -2,7 +2,7 @@ use crate::engine::EvaluationOptions; use crate::handler::graph::{DecisionGraph, DecisionGraphConfig, DecisionGraphResponse}; use crate::loader::{DecisionLoader, NoopLoader}; use crate::model::DecisionContent; -use crate::EvaluationError; +use crate::{DecisionGraphValidationError, EvaluationError}; use serde_json::Value; use std::sync::Arc; @@ -62,14 +62,26 @@ where context: &Value, options: EvaluationOptions, ) -> Result> { - let tree = DecisionGraph::new(DecisionGraphConfig { + let decision_graph = DecisionGraph::try_new(DecisionGraphConfig { max_depth: options.max_depth.unwrap_or(5), trace: options.trace.unwrap_or_default(), loader: self.loader.clone(), iteration: 0, content: &self.content, - }); + })?; - Ok(tree.evaluate(context).await?) + Ok(decision_graph.evaluate(context).await?) + } + + pub fn validate(&self) -> Result<(), DecisionGraphValidationError> { + let decision_graph = DecisionGraph::try_new(DecisionGraphConfig { + max_depth: 1, + trace: false, + loader: self.loader.clone(), + iteration: 0, + content: &self.content, + })?; + + decision_graph.validate() } } diff --git a/core/engine/src/error.rs b/core/engine/src/error.rs index 3f93b42e..aae8eb9a 100644 --- a/core/engine/src/error.rs +++ b/core/engine/src/error.rs @@ -1,3 +1,4 @@ +use crate::handler::graph::DecisionGraphValidationError; use crate::handler::node::NodeError; use crate::loader::LoaderError; use serde::ser::SerializeMap; @@ -14,6 +15,9 @@ pub enum EvaluationError { #[error("Depth limit exceeded")] DepthLimitExceeded, + + #[error("Invalid graph")] + InvalidGraph(Box), } impl Serialize for EvaluationError { @@ -43,6 +47,10 @@ impl Serialize for EvaluationError { } } } + EvaluationError::InvalidGraph(err) => { + map.serialize_entry("type", "InvalidGraph")?; + map.serialize_entry("source", err)?; + } } map.end() @@ -72,3 +80,9 @@ impl From> for Box { Box::new(EvaluationError::NodeError(error)) } } + +impl From for Box { + fn from(error: DecisionGraphValidationError) -> Self { + Box::new(EvaluationError::InvalidGraph(error.into())) + } +} diff --git a/core/engine/src/handler/decision.rs b/core/engine/src/handler/decision.rs index 32e67cd0..f0b7fd04 100644 --- a/core/engine/src/handler/decision.rs +++ b/core/engine/src/handler/decision.rs @@ -30,13 +30,13 @@ impl DecisionHandler { }?; let sub_decision = self.loader.load(&content.key).await?; - let sub_tree = DecisionGraph::new(DecisionGraphConfig { + let sub_tree = DecisionGraph::try_new(DecisionGraphConfig { content: sub_decision.deref(), max_depth: self.max_depth, loader: self.loader.clone(), iteration: request.iteration + 1, trace: self.trace, - }); + })?; let result = sub_tree .evaluate(&request.input) diff --git a/core/engine/src/handler/graph.rs b/core/engine/src/handler/graph.rs index 04ec7f59..08bcafcf 100644 --- a/core/engine/src/handler/graph.rs +++ b/core/engine/src/handler/graph.rs @@ -1,48 +1,35 @@ -use crate::loader::DecisionLoader; -use crate::model::{DecisionContent, DecisionNode, DecisionNodeKind}; use std::collections::HashMap; - -use crate::handler::decision::DecisionHandler; -use crate::handler::function::FunctionHandler; -use crate::handler::node::NodeRequest; -use crate::handler::table::zen::DecisionTableHandler; - -use crate::handler::expression::ExpressionHandler; -use crate::{EvaluationError, NodeError}; -use anyhow::anyhow; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; use std::sync::Arc; use std::time::Instant; -pub struct DecisionGraph<'a, T: DecisionLoader> { - nodes: Vec>, - loader: Arc, +use anyhow::anyhow; +use petgraph::algo::is_cyclic_directed; +use petgraph::graph::NodeIndex; +use petgraph::prelude::DiGraph; +use petgraph::visit::Topo; +use petgraph::Direction; +use serde::ser::SerializeMap; +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::{Map, Value}; +use thiserror::Error; + +use crate::handler::decision::DecisionHandler; +use crate::handler::expression::ExpressionHandler; +use crate::handler::function::FunctionHandler; +use crate::handler::node::NodeRequest; +use crate::handler::table::zen::DecisionTableHandler; +use crate::loader::DecisionLoader; +use crate::model::{DecisionContent, DecisionNode, DecisionNodeKind}; +use crate::{EvaluationError, NodeError}; + +pub struct DecisionGraph<'a, L: DecisionLoader> { + graph: DiGraph<&'a DecisionNode, usize>, + loader: Arc, trace: bool, max_depth: u8, iteration: u8, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DecisionGraphResponse { - pub performance: String, - pub result: Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub trace: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DecisionGraphTrace { - input: Value, - output: Value, - name: String, - id: String, - performance: Option, - trace_data: Option, -} - pub struct DecisionGraphConfig<'a, T: DecisionLoader> { pub loader: Arc, pub content: &'a DecisionContent, @@ -51,35 +38,85 @@ pub struct DecisionGraphConfig<'a, T: DecisionLoader> { pub max_depth: u8, } -impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { - pub fn new(config: DecisionGraphConfig<'a, T>) -> Self { - let nodes = config - .content - .nodes - .iter() - .map(|node| { - let parents: Vec<&'a str> = config - .content - .edges - .iter() - .filter(|edge| edge.target_id == node.id) - .map(|edge| edge.source_id.as_str()) - .collect(); +impl<'a, L: DecisionLoader> DecisionGraph<'a, L> { + pub fn try_new( + config: DecisionGraphConfig<'a, L>, + ) -> Result { + let content = config.content; + let mut graph = DiGraph::new(); + let mut index_map = HashMap::new(); - DecisionGraphNode { parents, node } - }) - .collect(); + for node in &content.nodes { + let node_id = node.id.clone(); + let node_index = graph.add_node(node); - Self { - nodes, - max_depth: config.max_depth, + index_map.insert(node_id, node_index); + } + + for (weight, edge) in content.edges.iter().enumerate() { + let source_index = index_map.get(&edge.source_id).ok_or_else(|| { + DecisionGraphValidationError::MissingNode(edge.source_id.to_string()) + })?; + + let target_index = index_map.get(&edge.target_id).ok_or_else(|| { + DecisionGraphValidationError::MissingNode(edge.target_id.to_string()) + })?; + + graph.add_edge(source_index.clone(), target_index.clone(), weight); + } + + Ok(Self { + graph, iteration: config.iteration, trace: config.trace, - loader: config.loader, - } + loader: config.loader.clone(), + max_depth: config.max_depth, + }) } - pub async fn evaluate(&self, state: &Value) -> Result { + pub fn validate(&self) -> Result<(), DecisionGraphValidationError> { + let input_count = self.node_kind_count(DecisionNodeKind::InputNode); + if input_count != 1 { + return Err(DecisionGraphValidationError::InvalidInputCount( + input_count as u32, + )); + } + + let output_count = self.node_kind_count(DecisionNodeKind::OutputNode); + if output_count < 1 { + return Err(DecisionGraphValidationError::InvalidOutputCount( + output_count as u32, + )); + } + + if is_cyclic_directed(&self.graph) { + return Err(DecisionGraphValidationError::CyclicGraph); + } + + Ok(()) + } + + fn node_kind_count(&self, kind: DecisionNodeKind) -> usize { + self.graph + .raw_nodes() + .iter() + .filter(|node| node.weight.kind == kind) + .count() + } + + fn incoming_nodes(&self, node_id: NodeIndex) -> Vec<&DecisionNode> { + let neighbors = self.graph.neighbors_directed(node_id, Direction::Incoming); + neighbors.map(|neighbor| self.graph[neighbor]).collect() + } + + pub async fn evaluate(&self, context: &Value) -> Result { + let root_start = Instant::now(); + + self.validate().map_err(|e| NodeError { + node_id: "".to_string(), + source: anyhow!(e), + })?; + if self.iteration >= self.max_depth { return Err(NodeError { node_id: "".to_string(), @@ -87,12 +124,14 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { }); } - let root_start = Instant::now(); + let mut dfs = Topo::new(&self.graph); let mut node_data = HashMap::<&str, Value>::default(); let mut node_traces = self.trace.then(|| HashMap::default()); - for graph_node in &self.nodes { - let node = graph_node.node; + let default_patch = Value::Object(Map::new()); + + while let Some(nid) = dfs.next(&self.graph) { + let node = self.graph[nid]; let start = Instant::now(); macro_rules! trace { @@ -103,9 +142,28 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { }; } + let incoming_nodes = self.incoming_nodes(nid); + let incoming_data = + incoming_nodes + .iter() + .fold(Value::Object(Map::new()), |mut prev, &curr| { + let data = node_data + .get(curr.id.as_str()) + .unwrap_or_else(|| &default_patch); + + merge_json(&mut prev, data, true); + prev + }); + + let node_request = NodeRequest { + node, + iteration: self.iteration, + input: incoming_data, + }; + match node.kind { DecisionNodeKind::InputNode => { - node_data.insert(&node.id, state.clone()); + node_data.insert(&node.id, context.clone()); trace!({ input: Value::Null, output: Value::Null, @@ -126,21 +184,14 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { }); return Ok(DecisionGraphResponse { - result: graph_node.parent_data(&node_data)?, + result: node_request.input, performance: format!("{:?}", root_start.elapsed()), trace: node_traces, }); } DecisionNodeKind::FunctionNode { .. } => { - let input = graph_node.parent_data(&node_data)?; - let req = NodeRequest { - node, - iteration: self.iteration, - input, - }; - let res = FunctionHandler::new(self.trace) - .handle(&req) + .handle(&node_request) .await .map_err(|e| NodeError { source: e.into(), @@ -149,7 +200,7 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { node_data.insert(&node.id, res.output.clone()); trace!({ - input: req.input, + input: node_request.input, output: res.output, name: node.name.clone(), id: node.id.clone(), @@ -158,16 +209,8 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { }); } DecisionNodeKind::DecisionNode { .. } => { - let input = graph_node.parent_data(&node_data)?; - - let req = NodeRequest { - node, - iteration: self.iteration, - input, - }; - let res = DecisionHandler::new(self.trace, self.max_depth, self.loader.clone()) - .handle(&req) + .handle(&node_request) .await .map_err(|e| NodeError { source: e.into(), @@ -176,7 +219,7 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { node_data.insert(&node.id, res.output.clone()); trace!({ - input: req.input, + input: node_request.input, output: res.output, name: node.name.clone(), id: node.id.clone(), @@ -185,16 +228,8 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { }); } DecisionNodeKind::DecisionTableNode { .. } => { - let input = graph_node.parent_data(&node_data)?; - - let req = NodeRequest { - node, - iteration: self.iteration, - input, - }; - let res = DecisionTableHandler::new(self.trace) - .handle(&req) + .handle(&node_request) .await .map_err(|e| NodeError { node_id: node.id.clone(), @@ -203,7 +238,7 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { node_data.insert(&node.id, res.output.clone()); trace!({ - input: req.input, + input: node_request.input, output: res.output, name: node.name.clone(), id: node.id.clone(), @@ -212,16 +247,8 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { }); } DecisionNodeKind::ExpressionNode { .. } => { - let input = graph_node.parent_data(&node_data)?; - - let req = NodeRequest { - node, - iteration: self.iteration, - input, - }; - let res = ExpressionHandler::new(self.trace) - .handle(&req) + .handle(&node_request) .await .map_err(|e| NodeError { node_id: node.id.clone(), @@ -230,7 +257,7 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { node_data.insert(&node.id, res.output.clone()); trace!({ - input: req.input, + input: node_request.input, output: res.output, name: node.name.clone(), id: node.id.clone(), @@ -248,28 +275,6 @@ impl<'a, T: DecisionLoader> DecisionGraph<'a, T> { } } -struct DecisionGraphNode<'a> { - parents: Vec<&'a str>, - node: &'a DecisionNode, -} - -impl<'a> DecisionGraphNode<'a> { - pub fn parent_data(&self, node_data: &HashMap<&str, Value>) -> Result { - let mut object = Value::Object(Map::new()); - - for pid in &self.parents { - let data = node_data.get(pid).ok_or_else(|| NodeError { - node_id: self.node.id.clone(), - source: anyhow!("Failed to parse node data"), - })?; - - merge_json(&mut object, data, true); - } - - Ok(object) - } -} - fn merge_json(doc: &mut Value, patch: &Value, top_level: bool) { if !patch.is_object() && !patch.is_array() && top_level { return; @@ -292,45 +297,66 @@ fn merge_json(doc: &mut Value, patch: &Value, top_level: bool) { } } -#[cfg(test)] -mod tests { - use crate::handler::graph::{DecisionGraph, DecisionGraphConfig}; - use crate::loader::MemoryLoader; - use serde_json::json; - use std::sync::Arc; +#[derive(Debug, Error)] +pub enum DecisionGraphValidationError { + #[error("Invalid input node count: {0}")] + InvalidInputCount(u32), - #[tokio::test] - async fn decision_table() { - let content = - &serde_json::from_str(include_str!("../../../../test-data/table.json")).unwrap(); - let tree = DecisionGraph::new(DecisionGraphConfig { - max_depth: 5, - trace: false, - iteration: 0, - content, - loader: Arc::new(MemoryLoader::default()), - }); + #[error("Invalid output node count: {0}")] + InvalidOutputCount(u32), - let result = tree.evaluate(&json!({ "input": 15 })).await.unwrap(); + #[error("Cyclic graph detected")] + CyclicGraph, - assert_eq!(result.result, json!({ "output": 10 })); - } + #[error("Missing node")] + MissingNode(String), +} - #[tokio::test] - #[cfg_attr(miri, ignore)] - async fn function() { - let content = - &serde_json::from_str(include_str!("../../../../test-data/function.json")).unwrap(); - let tree = DecisionGraph::new(DecisionGraphConfig { - max_depth: 5, - trace: false, - iteration: 0, - content, - loader: Arc::new(MemoryLoader::default()), - }); +impl Serialize for DecisionGraphValidationError { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(None)?; - let result = tree.evaluate(&json!({ "input": 15 })).await.unwrap(); + match &self { + DecisionGraphValidationError::InvalidInputCount(count) => { + map.serialize_entry("type", "invalidInputCount")?; + map.serialize_entry("nodeCount", count)?; + } + DecisionGraphValidationError::InvalidOutputCount(count) => { + map.serialize_entry("type", "invalidOutputCount")?; + map.serialize_entry("nodeCount", count)?; + } + DecisionGraphValidationError::MissingNode(node_id) => { + map.serialize_entry("type", "missingNode")?; + map.serialize_entry("nodeId", node_id)?; + } + DecisionGraphValidationError::CyclicGraph => { + map.serialize_entry("type", "cyclicGraph")?; + } + } - assert_eq!(result.result, json!({ "output": 30 })); + map.end() } } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DecisionGraphResponse { + pub performance: String, + pub result: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub trace: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DecisionGraphTrace { + input: Value, + output: Value, + name: String, + id: String, + performance: Option, + trace_data: Option, +} diff --git a/core/engine/src/lib.rs b/core/engine/src/lib.rs index 2e9c9332..6c6ba3e6 100644 --- a/core/engine/src/lib.rs +++ b/core/engine/src/lib.rs @@ -135,4 +135,5 @@ pub mod model; pub use decision::Decision; pub use engine::{DecisionEngine, EvaluationOptions}; pub use error::EvaluationError; +pub use handler::graph::DecisionGraphValidationError; pub use handler::node::NodeError; diff --git a/core/engine/tests/decision.rs b/core/engine/tests/decision.rs index a13a4d39..1862b4d7 100644 --- a/core/engine/tests/decision.rs +++ b/core/engine/tests/decision.rs @@ -2,7 +2,7 @@ use crate::support::{create_fs_loader, load_test_data}; use serde_json::json; use std::ops::Deref; use std::sync::Arc; -use zen_engine::{Decision, EvaluationError}; +use zen_engine::{Decision, DecisionGraphValidationError, EvaluationError}; mod support; @@ -68,3 +68,27 @@ async fn decision_expression_node() { }) ) } + +#[tokio::test] +async fn decision_validation() { + let cyclic_decision = Decision::from(load_test_data("error-cyclic.json")); + let cyclic_error = cyclic_decision.validate().unwrap_err(); + assert!(matches!( + cyclic_error, + DecisionGraphValidationError::CyclicGraph + )); + + let missing_input_decision = Decision::from(load_test_data("error-missing-input.json")); + let missing_input_error = missing_input_decision.validate().unwrap_err(); + assert!(matches!( + missing_input_error, + DecisionGraphValidationError::InvalidInputCount(_) + )); + + let missing_output_decision = Decision::from(load_test_data("error-missing-output.json")); + let missing_output_error = missing_output_decision.validate().unwrap_err(); + assert!(matches!( + missing_output_error, + DecisionGraphValidationError::InvalidOutputCount(_) + )); +} diff --git a/core/engine/tests/engine.rs b/core/engine/tests/engine.rs index b4225ca1..f0b64c4e 100644 --- a/core/engine/tests/engine.rs +++ b/core/engine/tests/engine.rs @@ -1,15 +1,17 @@ -use crate::support::{create_fs_loader, load_raw_test_data, load_test_data}; -use serde::Deserialize; -use serde_json::json; use std::io::Read; use std::ops::Deref; use std::path::Path; use std::sync::Arc; +use serde::Deserialize; +use serde_json::json; + use zen_engine::loader::{LoaderError, MemoryLoader}; use zen_engine::model::DecisionNodeKind; use zen_engine::{DecisionEngine, EvaluationError, EvaluationOptions}; +use crate::support::{create_fs_loader, load_raw_test_data, load_test_data}; + mod support; #[tokio::test] diff --git a/test-data/error-cyclic.json b/test-data/error-cyclic.json new file mode 100644 index 00000000..b836b563 --- /dev/null +++ b/test-data/error-cyclic.json @@ -0,0 +1,114 @@ +{ + "nodes": [ + { + "id": "115975ef-2f43-4e22-b553-0da6f4cc7f68", + "type": "inputNode", + "position": { + "x": 180, + "y": 240 + }, + "name": "Request" + }, + { + "id": "138b3b11-ff46-450f-9704-3f3c712067b2", + "type": "expressionNode", + "position": { + "x": 470, + "y": 240 + }, + "name": "expressionNode 1", + "content": { + "expressions": [ + { + "id": "xWauegxfG7", + "key": "deep.nested.sum", + "value": "sum(numbers)" + }, + { + "id": "qGAHmak0xj", + "key": "fullName", + "value": "firstName + ' ' + lastName" + }, + { + "id": "5ZnYGPFT-N", + "key": "largeNumbers", + "value": "filter(numbers, # >= 10)" + }, + { + "id": "pSg-vIQR5Q", + "key": "smallNumbers", + "value": "filter(numbers, # < 10)" + } + ] + } + }, + { + "id": "138b3b11-ff46-450f-9704-3f3c712067b3", + "type": "expressionNode", + "position": { + "x": 470, + "y": 240 + }, + "name": "expressionNode 2", + "content": { + "expressions": [ + { + "id": "xWauegxfG7", + "key": "deep.nested.sum", + "value": "sum(numbers)" + }, + { + "id": "qGAHmak0xj", + "key": "fullName", + "value": "firstName + ' ' + lastName" + }, + { + "id": "5ZnYGPFT-N", + "key": "largeNumbers", + "value": "filter(numbers, # >= 10)" + }, + { + "id": "pSg-vIQR5Q", + "key": "smallNumbers", + "value": "filter(numbers, # < 10)" + } + ] + } + }, + { + "id": "db8797b1-bcc1-4fbf-a5d8-e7d43a181d5e", + "type": "outputNode", + "position": { + "x": 780, + "y": 240 + }, + "name": "Response" + } + ], + "edges": [ + { + "type": "edge", + "id": "05740fa7-3755-4756-b85e-bc1af2f6773b", + "sourceId": "115975ef-2f43-4e22-b553-0da6f4cc7f68", + "targetId": "138b3b11-ff46-450f-9704-3f3c712067b2" + }, + { + "type": "edge", + "id": "05740fa7-3755-4756-b85e-bc1af2f6773c", + "sourceId": "138b3b11-ff46-450f-9704-3f3c712067b2", + "targetId": "138b3b11-ff46-450f-9704-3f3c712067b3" + }, + { + "type": "edge", + "id": "05740fa7-3755-4756-b85e-bc1af2f6773d", + "sourceId": "138b3b11-ff46-450f-9704-3f3c712067b3", + "targetId": "138b3b11-ff46-450f-9704-3f3c712067b2" + }, + { + "type": "edge", + "id": "5d89c1d6-e894-4e8a-bd13-22368c2a6bc7", + "sourceId": "138b3b11-ff46-450f-9704-3f3c712067b3", + "targetId": "db8797b1-bcc1-4fbf-a5d8-e7d43a181d5e" + } + ] +} \ No newline at end of file diff --git a/test-data/error-missing-input.json b/test-data/error-missing-input.json new file mode 100644 index 00000000..29aaef25 --- /dev/null +++ b/test-data/error-missing-input.json @@ -0,0 +1,54 @@ +{ + "nodes": [ + { + "id": "138b3b11-ff46-450f-9704-3f3c712067b2", + "type": "expressionNode", + "position": { + "x": 470, + "y": 240 + }, + "name": "expressionNode 1", + "content": { + "expressions": [ + { + "id": "xWauegxfG7", + "key": "deep.nested.sum", + "value": "sum(numbers)" + }, + { + "id": "qGAHmak0xj", + "key": "fullName", + "value": "firstName + ' ' + lastName" + }, + { + "id": "5ZnYGPFT-N", + "key": "largeNumbers", + "value": "filter(numbers, # >= 10)" + }, + { + "id": "pSg-vIQR5Q", + "key": "smallNumbers", + "value": "filter(numbers, # < 10)" + } + ] + } + }, + { + "id": "db8797b1-bcc1-4fbf-a5d8-e7d43a181d5e", + "type": "outputNode", + "position": { + "x": 780, + "y": 240 + }, + "name": "Response" + } + ], + "edges": [ + { + "id": "5d89c1d6-e894-4e8a-bd13-22368c2a6bc7", + "sourceId": "138b3b11-ff46-450f-9704-3f3c712067b2", + "type": "edge", + "targetId": "db8797b1-bcc1-4fbf-a5d8-e7d43a181d5e" + } + ] +} \ No newline at end of file diff --git a/test-data/error-missing-output.json b/test-data/error-missing-output.json new file mode 100644 index 00000000..f192f9c5 --- /dev/null +++ b/test-data/error-missing-output.json @@ -0,0 +1,54 @@ +{ + "nodes": [ + { + "id": "115975ef-2f43-4e22-b553-0da6f4cc7f68", + "type": "inputNode", + "position": { + "x": 180, + "y": 240 + }, + "name": "Request" + }, + { + "id": "138b3b11-ff46-450f-9704-3f3c712067b2", + "type": "expressionNode", + "position": { + "x": 470, + "y": 240 + }, + "name": "expressionNode 1", + "content": { + "expressions": [ + { + "id": "xWauegxfG7", + "key": "deep.nested.sum", + "value": "sum(numbers)" + }, + { + "id": "qGAHmak0xj", + "key": "fullName", + "value": "firstName + ' ' + lastName" + }, + { + "id": "5ZnYGPFT-N", + "key": "largeNumbers", + "value": "filter(numbers, # >= 10)" + }, + { + "id": "pSg-vIQR5Q", + "key": "smallNumbers", + "value": "filter(numbers, # < 10)" + } + ] + } + } + ], + "edges": [ + { + "id": "05740fa7-3755-4756-b85e-bc1af2f6773b", + "sourceId": "115975ef-2f43-4e22-b553-0da6f4cc7f68", + "type": "edge", + "targetId": "138b3b11-ff46-450f-9704-3f3c712067b2" + } + ] +} \ No newline at end of file