Skip to main content

fraiseql_core/validation/
input_object.rs

1//! Input object-level validation.
2//!
3//! This module provides validation capabilities at the input object level,
4//! applying cross-field rules and aggregating errors from multiple validators.
5//!
6//! # Examples
7//!
8//! ```
9//! use fraiseql_core::validation::{InputObjectRule, validate_input_object};
10//! use serde_json::json;
11//!
12//! // Validate entire input object
13//! let input = json!({
14//!     "name": "John",
15//!     "email": "john@example.com",
16//!     "phone": null
17//! });
18//!
19//! let validators = vec![
20//!     InputObjectRule::AnyOf { fields: vec!["email".to_string(), "phone".to_string()] },
21//!     InputObjectRule::ConditionalRequired {
22//!         if_field: "name".to_string(),
23//!         then_fields: vec!["email".to_string()],
24//!     },
25//! ];
26//!
27//! validate_input_object(&input, &validators, None).unwrap();
28//! ```
29
30use serde_json::Value;
31
32use crate::error::{FraiseQLError, Result};
33
34/// Rules that apply at the input object level.
35#[derive(Debug, Clone)]
36#[non_exhaustive]
37pub enum InputObjectRule {
38    /// At least one field from the set must be provided
39    AnyOf {
40        /// Field names of which at least one must be present.
41        fields: Vec<String>,
42    },
43    /// Exactly one field from the set must be provided
44    OneOf {
45        /// Field names of which exactly one must be present.
46        fields: Vec<String>,
47    },
48    /// If one field is present, others must be present
49    ConditionalRequired {
50        /// The trigger field whose presence activates the requirement.
51        if_field:    String,
52        /// Fields that must be present when `if_field` is provided.
53        then_fields: Vec<String>,
54    },
55    /// If one field is absent, others must be present
56    RequiredIfAbsent {
57        /// The field whose absence activates the requirement.
58        absent_field: String,
59        /// Fields that must be present when `absent_field` is missing.
60        then_fields:  Vec<String>,
61    },
62    /// Custom validator function name to invoke
63    Custom {
64        /// Name of the registered custom validator function.
65        name: String,
66    },
67}
68
69/// Result of validating an input object, aggregating multiple errors.
70#[derive(Debug, Clone, Default)]
71pub struct InputObjectValidationResult {
72    /// All validation errors
73    pub errors:      Vec<String>,
74    /// Count of errors
75    pub error_count: usize,
76}
77
78impl InputObjectValidationResult {
79    /// Create a new empty result.
80    #[must_use]
81    pub const fn new() -> Self {
82        Self {
83            errors:      Vec::new(),
84            error_count: 0,
85        }
86    }
87
88    /// Add an error to the result.
89    pub fn add_error(&mut self, error: String) {
90        self.errors.push(error);
91        self.error_count += 1;
92    }
93
94    /// Add multiple errors at once.
95    pub fn add_errors(&mut self, errors: Vec<String>) {
96        self.error_count += errors.len();
97        self.errors.extend(errors);
98    }
99
100    /// Check if there are any errors.
101    #[must_use]
102    pub const fn has_errors(&self) -> bool {
103        !self.errors.is_empty()
104    }
105
106    /// Convert to a Result, failing if there are errors.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`FraiseQLError::Validation`] if any validation errors have been
111    /// accumulated via [`add_error`][Self::add_error].
112    pub fn into_result(self) -> Result<()> {
113        self.into_result_with_path("input")
114    }
115
116    /// Convert to a Result with a custom path, failing if there are errors.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`FraiseQLError::Validation`] with the given `path` if any
121    /// validation errors have been accumulated.
122    pub fn into_result_with_path(self, path: &str) -> Result<()> {
123        if self.has_errors() {
124            Err(FraiseQLError::Validation {
125                message: format!("Input object validation failed: {}", self.errors.join("; ")),
126                path:    Some(path.to_string()),
127            })
128        } else {
129            Ok(())
130        }
131    }
132}
133
134/// Validate an input object against a set of rules.
135///
136/// Applies all rules to the input object and aggregates errors.
137///
138/// # Arguments
139/// * `input` - The input object to validate
140/// * `rules` - Rules to apply at the object level
141/// * `object_path` - Optional path to the object for error reporting
142///
143/// # Errors
144///
145/// Returns `FraiseQLError::Validation` if any input object rule fails.
146pub fn validate_input_object(
147    input: &Value,
148    rules: &[InputObjectRule],
149    object_path: Option<&str>,
150) -> Result<()> {
151    let mut result = InputObjectValidationResult::new();
152    let path = object_path.unwrap_or("input");
153
154    if !matches!(input, Value::Object(_)) {
155        return Err(FraiseQLError::Validation {
156            message: "Input must be an object".to_string(),
157            path:    Some(path.to_string()),
158        });
159    }
160
161    for rule in rules {
162        if let Err(FraiseQLError::Validation { message, .. }) = validate_rule(input, rule, path) {
163            result.add_error(message);
164        }
165    }
166
167    result.into_result_with_path(path)
168}
169
170/// Validate a single input object rule.
171fn validate_rule(input: &Value, rule: &InputObjectRule, path: &str) -> Result<()> {
172    match rule {
173        InputObjectRule::AnyOf { fields } => validate_any_of(input, fields, path),
174        InputObjectRule::OneOf { fields } => validate_one_of(input, fields, path),
175        InputObjectRule::ConditionalRequired {
176            if_field,
177            then_fields,
178        } => validate_conditional_required(input, if_field, then_fields, path),
179        InputObjectRule::RequiredIfAbsent {
180            absent_field,
181            then_fields,
182        } => validate_required_if_absent(input, absent_field, then_fields, path),
183        InputObjectRule::Custom { name } => Err(FraiseQLError::Validation {
184            message: format!(
185                "Custom validator '{name}' is not registered. \
186                 Register validators via InputValidatorRegistry before executing queries."
187            ),
188            path:    Some(path.to_string()),
189        }),
190    }
191}
192
193/// Validate that at least one field from the set is present.
194fn validate_any_of(input: &Value, fields: &[String], path: &str) -> Result<()> {
195    if let Value::Object(obj) = input {
196        let has_any = fields
197            .iter()
198            .any(|name| obj.get(name).is_some_and(|v| !matches!(v, Value::Null)));
199
200        if !has_any {
201            return Err(FraiseQLError::Validation {
202                message: format!("At least one of [{}] must be provided", fields.join(", ")),
203                path:    Some(path.to_string()),
204            });
205        }
206    }
207
208    Ok(())
209}
210
211/// Validate that exactly one field from the set is present.
212fn validate_one_of(input: &Value, fields: &[String], path: &str) -> Result<()> {
213    if let Value::Object(obj) = input {
214        let present_count = fields
215            .iter()
216            .filter(|name| obj.get(*name).is_some_and(|v| !matches!(v, Value::Null)))
217            .count();
218
219        if present_count != 1 {
220            return Err(FraiseQLError::Validation {
221                message: format!(
222                    "Exactly one of [{}] must be provided, but {} {} provided",
223                    fields.join(", "),
224                    present_count,
225                    if present_count == 1 { "was" } else { "were" }
226                ),
227                path:    Some(path.to_string()),
228            });
229        }
230    }
231
232    Ok(())
233}
234
235/// Validate conditional requirement: if one field is present, others must be too.
236fn validate_conditional_required(
237    input: &Value,
238    if_field: &str,
239    then_fields: &[String],
240    path: &str,
241) -> Result<()> {
242    if let Value::Object(obj) = input {
243        let condition_met = obj.get(if_field).is_some_and(|v| !matches!(v, Value::Null));
244
245        if condition_met {
246            let missing_fields: Vec<&String> = then_fields
247                .iter()
248                .filter(|name| obj.get(*name).is_none_or(|v| matches!(v, Value::Null)))
249                .collect();
250
251            if !missing_fields.is_empty() {
252                return Err(FraiseQLError::Validation {
253                    message: format!(
254                        "Since '{}' is provided, {} must also be provided",
255                        if_field,
256                        missing_fields
257                            .iter()
258                            .map(|s| format!("'{}'", s))
259                            .collect::<Vec<_>>()
260                            .join(", ")
261                    ),
262                    path:    Some(path.to_string()),
263                });
264            }
265        }
266    }
267
268    Ok(())
269}
270
271/// Validate requirement based on absence: if one field is missing, others must be provided.
272fn validate_required_if_absent(
273    input: &Value,
274    absent_field: &str,
275    then_fields: &[String],
276    path: &str,
277) -> Result<()> {
278    if let Value::Object(obj) = input {
279        let field_absent = obj.get(absent_field).is_none_or(|v| matches!(v, Value::Null));
280
281        if field_absent {
282            let missing_fields: Vec<&String> = then_fields
283                .iter()
284                .filter(|name| obj.get(*name).is_none_or(|v| matches!(v, Value::Null)))
285                .collect();
286
287            if !missing_fields.is_empty() {
288                return Err(FraiseQLError::Validation {
289                    message: format!(
290                        "Since '{}' is not provided, {} must be provided",
291                        absent_field,
292                        missing_fields
293                            .iter()
294                            .map(|s| format!("'{}'", s))
295                            .collect::<Vec<_>>()
296                            .join(", ")
297                    ),
298                    path:    Some(path.to_string()),
299                });
300            }
301        }
302    }
303
304    Ok(())
305}