Skip to main content

dataflow_rs/engine/functions/
validation.rs

1//! # Validation Function Module
2//!
3//! This module provides rule-based validation capabilities using JSONLogic expressions.
4//! The validation function evaluates a set of rules against message data and collects
5//! any validation errors that occur.
6//!
7//! ## Features
8//!
9//! - Define validation rules using JSONLogic expressions
10//! - Custom error messages for each rule
11//! - Non-destructive: validation is read-only and doesn't modify message data
12//! - Errors are collected in the message's error list
13//!
14//! ## Example Usage
15//!
16//! ```json
17//! {
18//!     "name": "validation",
19//!     "input": {
20//!         "rules": [
21//!             {
22//!                 "logic": {"!!": [{"var": "data.email"}]},
23//!                 "message": "Email is required"
24//!             },
25//!             {
26//!                 "logic": {">": [{"var": "data.age"}, 0]},
27//!                 "message": "Age must be positive"
28//!             }
29//!         ]
30//!     }
31//! }
32//! ```
33
34use crate::engine::error::{DataflowError, ErrorInfo, Result};
35use crate::engine::executor::{ArenaContext, with_arena};
36use crate::engine::functions::path_template::ParamCtx;
37use crate::engine::functions::template::Template;
38use crate::engine::message::{Change, Message};
39use crate::engine::task_outcome::TaskOutcome;
40use datalogic_rs::{Engine, Logic};
41use datavalue::DataValue;
42use log::{debug, error};
43use serde::Deserialize;
44use serde_json::Value;
45use std::sync::Arc;
46
47/// Configuration for the validation function containing a list of rules.
48///
49/// Each rule specifies a JSONLogic condition that must evaluate to `true`
50/// for the validation to pass. If a rule evaluates to anything other than
51/// `true`, its error message is added to the message's error list.
52#[derive(Debug, Clone, Deserialize)]
53pub struct ValidationConfig {
54    /// List of validation rules to evaluate.
55    pub rules: Vec<ValidationRule>,
56}
57
58/// A single validation rule with a condition and error message.
59///
60/// The rule's logic is evaluated against the message context. If it does not
61/// return exactly `true`, the validation fails and the error message is recorded.
62#[derive(Debug, Clone, Deserialize)]
63pub struct ValidationRule {
64    /// JSONLogic expression that must evaluate to `true` for validation to pass.
65    /// Any other result (false, null, etc.) is considered a validation failure.
66    pub logic: Value,
67
68    /// Error message to display if validation fails.
69    ///
70    /// Required when a rule is deserialized as part of a workflow definition —
71    /// which is the path `Engine::build` takes, so a rule without it is
72    /// rejected at build time. [`ValidationConfig::from_json`], the standalone
73    /// parser, is the one path that substitutes `"Validation failed"`.
74    ///
75    /// JSONLogic, so a message can name the value that failed:
76    /// `{"cat": ["age must be positive, got ", {"var": "data.age"}]}`. A plain
77    /// string is a literal, so the static spelling is unchanged.
78    ///
79    /// Evaluated **only when the rule fails**, so a passing validation — the
80    /// common case — costs nothing for a computed message.
81    ///
82    /// This message is recorded in [`Message::errors`], which is serialized, so
83    /// it may not read a secret. `Engine::build` refuses one that does; see
84    /// [`crate::IssueCode::SecretInMessageWrite`].
85    pub message: Template,
86
87    /// Pre-compiled JSONLogic, populated by `LogicCompiler`. `None` is
88    /// recorded as a `COMPILATION_ERROR` at execute time.
89    #[serde(skip)]
90    pub compiled_logic: Option<Arc<Logic>>,
91}
92
93impl ValidationConfig {
94    /// Parses a `ValidationConfig` from a JSON value.
95    ///
96    /// # Arguments
97    /// * `input` - JSON object containing a "rules" array
98    ///
99    /// # Errors
100    /// Returns `DataflowError::Validation` if:
101    /// - The "rules" field is missing
102    /// - The "rules" field is not an array
103    /// - Any rule is missing the "logic" field
104    pub fn from_json(input: &Value) -> Result<Self> {
105        let rules = input.get("rules").ok_or_else(|| {
106            DataflowError::Validation("Missing 'rules' array in input".to_string())
107        })?;
108
109        let rules_arr = rules
110            .as_array()
111            .ok_or_else(|| DataflowError::Validation("'rules' must be an array".to_string()))?;
112
113        let mut parsed_rules = Vec::new();
114
115        for rule in rules_arr {
116            let logic = rule
117                .get("logic")
118                .ok_or_else(|| DataflowError::Validation("Missing 'logic' in rule".to_string()))?
119                .clone();
120
121            let message = Template::from(
122                rule.get("message")
123                    .cloned()
124                    .unwrap_or_else(|| Value::from("Validation failed")),
125            );
126
127            parsed_rules.push(ValidationRule {
128                logic,
129                message,
130                compiled_logic: None,
131            });
132        }
133
134        Ok(Self {
135            rules: parsed_rules,
136        })
137    }
138
139    /// Executes all validation rules using pre-compiled logic.
140    ///
141    /// Evaluates each rule sequentially against the message context.
142    /// This is a read-only operation that does not modify message data.
143    ///
144    /// # Arguments
145    /// * `message` - The message to validate (errors are added to its error list)
146    /// * `engine` - Datalogic v5 engine for evaluation
147    ///
148    /// # Returns
149    /// * `Ok((TaskOutcome::Success, []))` — all rules passed
150    /// * `Ok((TaskOutcome::Status(400), []))` — one or more rules failed,
151    ///   `ErrorInfo` entries pushed onto `message.errors`
152    pub fn execute(
153        &self,
154        message: &mut Message,
155        engine: &Arc<Engine>,
156    ) -> Result<(TaskOutcome, Vec<Change>)> {
157        // Default path: open the arena and convert context once for this
158        // task call. When called from the workflow-level sync-stretch
159        // executor (`execute_in_arena`), the conversion is reused across
160        // multiple tasks in the same stretch.
161        with_arena(|arena| {
162            let ctx_av: DataValue<'_> = message.context.to_arena(arena);
163            self.run_rules(message, ctx_av, arena, engine)
164        })
165    }
166
167    /// Run validation rules against an externally-provided `ArenaContext`.
168    /// Reuses the cached arena form built by an earlier task in the same
169    /// workflow sync stretch — the heavy `data.input` subtree stays cached
170    /// across the parse_json → map → validation pipeline.
171    pub(crate) fn execute_in_arena(
172        &self,
173        message: &mut Message,
174        arena_ctx: &mut ArenaContext<'_>,
175        engine: &Arc<Engine>,
176    ) -> Result<(TaskOutcome, Vec<Change>)> {
177        let arena = arena_ctx.arena();
178        let ctx_av = arena_ctx.as_data_value();
179        self.run_rules(message, ctx_av, arena, engine)
180    }
181
182    /// Shared inner loop: evaluate each rule against `ctx_av` and record
183    /// `ErrorInfo` entries for any failures.
184    fn run_rules(
185        &self,
186        message: &mut Message,
187        ctx_av: DataValue<'_>,
188        arena: &bumpalo::Bump,
189        engine: &Arc<Engine>,
190    ) -> Result<(TaskOutcome, Vec<Change>)> {
191        let changes = Vec::new();
192        let mut validation_errors = Vec::new();
193
194        for (idx, rule) in self.rules.iter().enumerate() {
195            debug!("Processing validation rule {idx}");
196
197            let compiled_logic = match &rule.compiled_logic {
198                Some(logic) => logic,
199                None => {
200                    error!("Validation: Logic not compiled for rule at index {}", idx);
201                    validation_errors.push(ErrorInfo::simple_ref(
202                        "COMPILATION_ERROR",
203                        &format!("Logic not compiled for rule at index: {}", idx),
204                        None,
205                    ));
206                    continue;
207                }
208            };
209
210            // Reuse the pre-converted `ctx_av` (DataValue is Copy). The
211            // result is `&DataValue<'_>` borrowed from the arena — we
212            // only need to peek at the discriminant so we skip the
213            // `to_owned()` deep-clone too.
214            match engine.evaluate(compiled_logic, ctx_av, arena) {
215                Ok(value) => {
216                    if !matches!(value, DataValue::Bool(true)) {
217                        // Resolved here, not up front: a computed message costs
218                        // nothing on the passing path. A message that fails to
219                        // evaluate still reports the validation failure — the
220                        // rule did fail, and losing that to a broken message
221                        // expression would be the worse outcome.
222                        let text = rule
223                            .message
224                            .resolve_string_in_arena(ParamCtx::new(engine, ctx_av, arena))
225                            .unwrap_or_else(|e| {
226                                error!("Validation: rule {idx} message failed to render: {e:?}");
227                                "Validation failed".to_string()
228                            });
229                        debug!("Validation failed for rule {idx}: {text}");
230                        validation_errors.push(ErrorInfo::simple_ref(
231                            "VALIDATION_ERROR",
232                            &text,
233                            None,
234                        ));
235                    } else {
236                        debug!("Validation passed for rule {}", idx);
237                    }
238                }
239                Err(e) => {
240                    error!("Validation: Error evaluating rule {}: {:?}", idx, e);
241                    validation_errors.push(ErrorInfo::simple_ref(
242                        "EVALUATION_ERROR",
243                        &format!("Failed to evaluate rule {}: {}", idx, e),
244                        None,
245                    ));
246                }
247            }
248        }
249
250        if !validation_errors.is_empty() {
251            message.errors.extend(validation_errors);
252            Ok((TaskOutcome::Status(400), changes))
253        } else {
254            Ok((TaskOutcome::Success, changes))
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use datavalue::OwnedDataValue;
263    use serde_json::json;
264
265    #[test]
266    fn test_validation_config_from_json() {
267        let input = json!({
268            "rules": [
269                {
270                    "logic": {"!!": [{"var": "data.required_field"}]},
271                    "path": "data",
272                    "message": "Required field is missing"
273                },
274                {
275                    "logic": {">": [{"var": "data.age"}, 18]},
276                    "message": "Must be over 18"
277                }
278            ]
279        });
280
281        let config = ValidationConfig::from_json(&input).unwrap();
282        assert_eq!(config.rules.len(), 2);
283        assert_eq!(
284            config.rules[0].message.as_json(),
285            &json!("Required field is missing")
286        );
287        assert_eq!(config.rules[1].message.as_json(), &json!("Must be over 18"));
288    }
289
290    #[test]
291    fn test_validation_config_missing_rules() {
292        let input = json!({});
293        let result = ValidationConfig::from_json(&input);
294        assert!(result.is_err());
295    }
296
297    #[test]
298    fn test_validation_config_invalid_rules() {
299        let input = json!({
300            "rules": "not_an_array"
301        });
302        let result = ValidationConfig::from_json(&input);
303        assert!(result.is_err());
304    }
305
306    #[test]
307    fn test_validation_config_missing_logic() {
308        let input = json!({
309            "rules": [
310                {
311                    "path": "data",
312                    "message": "Some error"
313                }
314            ]
315        });
316        let result = ValidationConfig::from_json(&input);
317        assert!(result.is_err());
318    }
319
320    #[test]
321    fn test_validation_config_defaults() {
322        let input = json!({
323            "rules": [
324                {
325                    "logic": {"var": "data.field"}
326                }
327            ]
328        });
329
330        let config = ValidationConfig::from_json(&input).unwrap();
331        assert_eq!(
332            config.rules[0].message.as_json(),
333            &json!("Validation failed")
334        );
335    }
336
337    fn dv(v: serde_json::Value) -> OwnedDataValue {
338        OwnedDataValue::from(&v)
339    }
340
341    fn message_with_data(initial: serde_json::Value) -> crate::engine::message::Message {
342        use crate::engine::message::Message;
343        Message::builder().data(dv(initial)).build()
344    }
345
346    /// Compile each rule's `logic` and stamp the resulting `Arc<Logic>` into
347    /// the `compiled_logic` slot — mirroring `LogicCompiler`.
348    fn compile_rules(engine: &Arc<Engine>, config: &mut ValidationConfig) {
349        for rule in &mut config.rules {
350            rule.compiled_logic = Some(engine.compile_arc(&rule.logic).unwrap());
351        }
352    }
353
354    #[test]
355    fn test_validation_execute_passes() {
356        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
357
358        let mut message = message_with_data(json!({
359            "email": "test@example.com",
360            "age": 25
361        }));
362
363        let mut config = ValidationConfig {
364            rules: vec![
365                ValidationRule {
366                    logic: json!({"!!": [{"var": "data.email"}]}),
367                    message: Template::from(json!("Email is required")),
368                    compiled_logic: None,
369                },
370                ValidationRule {
371                    logic: json!({">": [{"var": "data.age"}, 18]}),
372                    message: Template::from(json!("Must be over 18")),
373                    compiled_logic: None,
374                },
375            ],
376        };
377        compile_rules(&engine, &mut config);
378
379        let result = config.execute(&mut message, &engine);
380        assert!(result.is_ok());
381
382        let (outcome, changes) = result.unwrap();
383        assert_eq!(outcome, TaskOutcome::Success);
384        assert!(changes.is_empty());
385        assert!(message.errors.is_empty());
386    }
387
388    #[test]
389    fn test_validation_execute_fails() {
390        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
391
392        let mut message = message_with_data(json!({ "age": 15 }));
393
394        let mut config = ValidationConfig {
395            rules: vec![
396                ValidationRule {
397                    logic: json!({"!!": [{"var": "data.email"}]}),
398                    message: Template::from(json!("Email is required")),
399                    compiled_logic: None,
400                },
401                ValidationRule {
402                    logic: json!({">": [{"var": "data.age"}, 18]}),
403                    message: Template::from(json!("Must be over 18")),
404                    compiled_logic: None,
405                },
406            ],
407        };
408        compile_rules(&engine, &mut config);
409
410        let result = config.execute(&mut message, &engine);
411        assert!(result.is_ok());
412
413        let (outcome, _changes) = result.unwrap();
414        assert_eq!(outcome, TaskOutcome::Status(400));
415        assert_eq!(message.errors.len(), 2);
416
417        let error_messages: Vec<&str> = message.errors.iter().map(|e| e.message.as_str()).collect();
418        assert!(error_messages.contains(&"Email is required"));
419        assert!(error_messages.contains(&"Must be over 18"));
420    }
421
422    #[test]
423    fn test_validation_uncompiled_logic() {
424        use crate::engine::message::Message;
425
426        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
427
428        let mut message = Message::new(Arc::new(dv(json!({}))));
429
430        let config = ValidationConfig {
431            rules: vec![ValidationRule {
432                logic: json!(true),
433                message: Template::from(json!("Test")),
434                compiled_logic: None,
435            }],
436        };
437
438        let result = config.execute(&mut message, &engine);
439        assert!(result.is_ok());
440
441        let (outcome, _) = result.unwrap();
442        assert_eq!(outcome, TaskOutcome::Status(400));
443        assert!(!message.errors.is_empty());
444        assert!(message.errors[0].code == "COMPILATION_ERROR");
445    }
446}