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 view the context once for this
158        // task call. When called from the workflow-level sync-stretch
159        // executor (`execute_in_arena`), the view is reused across multiple
160        // tasks in the same stretch.
161        //
162        // The fields are split so the view can borrow `context` (strings and
163        // keys point into the owned tree) while the rules still push onto
164        // `errors`.
165        with_arena(|arena| {
166            let Message {
167                context, errors, ..
168            } = message;
169            let ctx_av: DataValue<'_> = context.view_in(arena);
170            self.run_rules(errors, ctx_av, arena, engine)
171        })
172    }
173
174    /// Run validation rules against an externally-provided `ArenaContext`.
175    /// Reuses the cached arena form built by an earlier task in the same
176    /// workflow sync stretch — the heavy `data.input` subtree stays cached
177    /// across the parse_json → map → validation pipeline.
178    pub(crate) fn execute_in_arena(
179        &self,
180        message: &mut Message,
181        arena_ctx: &mut ArenaContext<'_>,
182        engine: &Arc<Engine>,
183    ) -> Result<(TaskOutcome, Vec<Change>)> {
184        let arena = arena_ctx.arena();
185        let ctx_av = arena_ctx.as_data_value();
186        self.run_rules(&mut message.errors, ctx_av, arena, engine)
187    }
188
189    /// Shared inner loop: evaluate each rule against `ctx_av` and record
190    /// `ErrorInfo` entries for any failures.
191    fn run_rules(
192        &self,
193        errors: &mut Vec<ErrorInfo>,
194        ctx_av: DataValue<'_>,
195        arena: &bumpalo::Bump,
196        engine: &Arc<Engine>,
197    ) -> Result<(TaskOutcome, Vec<Change>)> {
198        let changes = Vec::new();
199        let mut validation_errors = Vec::new();
200
201        for (idx, rule) in self.rules.iter().enumerate() {
202            debug!("Processing validation rule {idx}");
203
204            let compiled_logic = match &rule.compiled_logic {
205                Some(logic) => logic,
206                None => {
207                    error!("Validation: Logic not compiled for rule at index {}", idx);
208                    validation_errors.push(ErrorInfo::simple_ref(
209                        "COMPILATION_ERROR",
210                        &format!("Logic not compiled for rule at index: {}", idx),
211                        None,
212                    ));
213                    continue;
214                }
215            };
216
217            // Reuse the pre-converted `ctx_av` (DataValue is Copy). The
218            // result is `&DataValue<'_>` borrowed from the arena — we
219            // only need to peek at the discriminant so we skip the
220            // `to_owned()` deep-clone too.
221            match engine.evaluate(compiled_logic, ctx_av, arena) {
222                Ok(value) => {
223                    if !matches!(value, DataValue::Bool(true)) {
224                        // Resolved here, not up front: a computed message costs
225                        // nothing on the passing path. A message that fails to
226                        // evaluate still reports the validation failure — the
227                        // rule did fail, and losing that to a broken message
228                        // expression would be the worse outcome.
229                        let text = rule
230                            .message
231                            .resolve_string_in_arena(ParamCtx::new(engine, ctx_av, arena))
232                            .unwrap_or_else(|e| {
233                                error!("Validation: rule {idx} message failed to render: {e:?}");
234                                "Validation failed".to_string()
235                            });
236                        debug!("Validation failed for rule {idx}: {text}");
237                        validation_errors.push(ErrorInfo::simple_ref(
238                            "VALIDATION_ERROR",
239                            &text,
240                            None,
241                        ));
242                    } else {
243                        debug!("Validation passed for rule {}", idx);
244                    }
245                }
246                Err(e) => {
247                    error!("Validation: Error evaluating rule {}: {:?}", idx, e);
248                    validation_errors.push(ErrorInfo::simple_ref(
249                        "EVALUATION_ERROR",
250                        &format!("Failed to evaluate rule {}: {}", idx, e),
251                        None,
252                    ));
253                }
254            }
255        }
256
257        if !validation_errors.is_empty() {
258            errors.extend(validation_errors);
259            Ok((TaskOutcome::Status(400), changes))
260        } else {
261            Ok((TaskOutcome::Success, changes))
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use datavalue::OwnedDataValue;
270    use serde_json::json;
271
272    #[test]
273    fn test_validation_config_from_json() {
274        let input = json!({
275            "rules": [
276                {
277                    "logic": {"!!": [{"var": "data.required_field"}]},
278                    "path": "data",
279                    "message": "Required field is missing"
280                },
281                {
282                    "logic": {">": [{"var": "data.age"}, 18]},
283                    "message": "Must be over 18"
284                }
285            ]
286        });
287
288        let config = ValidationConfig::from_json(&input).unwrap();
289        assert_eq!(config.rules.len(), 2);
290        assert_eq!(
291            config.rules[0].message.as_json(),
292            &json!("Required field is missing")
293        );
294        assert_eq!(config.rules[1].message.as_json(), &json!("Must be over 18"));
295    }
296
297    #[test]
298    fn test_validation_config_missing_rules() {
299        let input = json!({});
300        let result = ValidationConfig::from_json(&input);
301        assert!(result.is_err());
302    }
303
304    #[test]
305    fn test_validation_config_invalid_rules() {
306        let input = json!({
307            "rules": "not_an_array"
308        });
309        let result = ValidationConfig::from_json(&input);
310        assert!(result.is_err());
311    }
312
313    #[test]
314    fn test_validation_config_missing_logic() {
315        let input = json!({
316            "rules": [
317                {
318                    "path": "data",
319                    "message": "Some error"
320                }
321            ]
322        });
323        let result = ValidationConfig::from_json(&input);
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn test_validation_config_defaults() {
329        let input = json!({
330            "rules": [
331                {
332                    "logic": {"var": "data.field"}
333                }
334            ]
335        });
336
337        let config = ValidationConfig::from_json(&input).unwrap();
338        assert_eq!(
339            config.rules[0].message.as_json(),
340            &json!("Validation failed")
341        );
342    }
343
344    fn dv(v: serde_json::Value) -> OwnedDataValue {
345        OwnedDataValue::from(&v)
346    }
347
348    fn message_with_data(initial: serde_json::Value) -> crate::engine::message::Message {
349        use crate::engine::message::Message;
350        Message::builder().data(dv(initial)).build()
351    }
352
353    /// Compile each rule's `logic` and stamp the resulting `Arc<Logic>` into
354    /// the `compiled_logic` slot — mirroring `LogicCompiler`.
355    fn compile_rules(engine: &Arc<Engine>, config: &mut ValidationConfig) {
356        for rule in &mut config.rules {
357            rule.compiled_logic = Some(engine.compile_arc(&rule.logic).unwrap());
358        }
359    }
360
361    #[test]
362    fn test_validation_execute_passes() {
363        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
364
365        let mut message = message_with_data(json!({
366            "email": "test@example.com",
367            "age": 25
368        }));
369
370        let mut config = ValidationConfig {
371            rules: vec![
372                ValidationRule {
373                    logic: json!({"!!": [{"var": "data.email"}]}),
374                    message: Template::from(json!("Email is required")),
375                    compiled_logic: None,
376                },
377                ValidationRule {
378                    logic: json!({">": [{"var": "data.age"}, 18]}),
379                    message: Template::from(json!("Must be over 18")),
380                    compiled_logic: None,
381                },
382            ],
383        };
384        compile_rules(&engine, &mut config);
385
386        let result = config.execute(&mut message, &engine);
387        assert!(result.is_ok());
388
389        let (outcome, changes) = result.unwrap();
390        assert_eq!(outcome, TaskOutcome::Success);
391        assert!(changes.is_empty());
392        assert!(message.errors.is_empty());
393    }
394
395    #[test]
396    fn test_validation_execute_fails() {
397        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
398
399        let mut message = message_with_data(json!({ "age": 15 }));
400
401        let mut config = ValidationConfig {
402            rules: vec![
403                ValidationRule {
404                    logic: json!({"!!": [{"var": "data.email"}]}),
405                    message: Template::from(json!("Email is required")),
406                    compiled_logic: None,
407                },
408                ValidationRule {
409                    logic: json!({">": [{"var": "data.age"}, 18]}),
410                    message: Template::from(json!("Must be over 18")),
411                    compiled_logic: None,
412                },
413            ],
414        };
415        compile_rules(&engine, &mut config);
416
417        let result = config.execute(&mut message, &engine);
418        assert!(result.is_ok());
419
420        let (outcome, _changes) = result.unwrap();
421        assert_eq!(outcome, TaskOutcome::Status(400));
422        assert_eq!(message.errors.len(), 2);
423
424        let error_messages: Vec<&str> = message.errors.iter().map(|e| e.message.as_str()).collect();
425        assert!(error_messages.contains(&"Email is required"));
426        assert!(error_messages.contains(&"Must be over 18"));
427    }
428
429    #[test]
430    fn test_validation_uncompiled_logic() {
431        use crate::engine::message::Message;
432
433        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
434
435        let mut message = Message::new(Arc::new(dv(json!({}))));
436
437        let config = ValidationConfig {
438            rules: vec![ValidationRule {
439                logic: json!(true),
440                message: Template::from(json!("Test")),
441                compiled_logic: None,
442            }],
443        };
444
445        let result = config.execute(&mut message, &engine);
446        assert!(result.is_ok());
447
448        let (outcome, _) = result.unwrap();
449        assert_eq!(outcome, TaskOutcome::Status(400));
450        assert!(!message.errors.is_empty());
451        assert!(message.errors[0].code == "COMPILATION_ERROR");
452    }
453}