Skip to main content

fraiseql_core/runtime/
input_validator.rs

1//! Input validation for GraphQL mutations and queries.
2//!
3//! This module provides the validation pipeline that processes GraphQL input
4//! variables and validates them against defined validation rules before
5//! execution.
6
7use serde_json::Value;
8
9use crate::{
10    error::{FraiseQLError, Result, ValidationFieldError},
11    schema::CompiledSchema,
12    validation::ValidationRule,
13};
14
15/// Validation error aggregator - collects multiple validation errors.
16#[derive(Debug, Clone, Default)]
17pub struct ValidationErrorCollection {
18    /// All collected validation errors.
19    pub errors: Vec<ValidationFieldError>,
20}
21
22impl ValidationErrorCollection {
23    /// Create a new empty error collection.
24    #[must_use]
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Add an error to the collection.
30    pub fn add_error(&mut self, error: ValidationFieldError) {
31        self.errors.push(error);
32    }
33
34    /// Check if there are any errors.
35    #[must_use]
36    pub const fn is_empty(&self) -> bool {
37        self.errors.is_empty()
38    }
39
40    /// Get the number of errors.
41    #[must_use]
42    pub const fn len(&self) -> usize {
43        self.errors.len()
44    }
45
46    /// Convert to a FraiseQL error.
47    #[must_use]
48    pub fn to_error(&self) -> FraiseQLError {
49        if self.errors.is_empty() {
50            FraiseQLError::validation("No validation errors")
51        } else if self.errors.len() == 1 {
52            let err = &self.errors[0];
53            FraiseQLError::Validation {
54                message: err.to_string(),
55                path:    Some(err.field.clone()),
56            }
57        } else {
58            let messages: Vec<String> = self.errors.iter().map(|e| e.to_string()).collect();
59            FraiseQLError::Validation {
60                message: format!("Multiple validation errors: {}", messages.join("; ")),
61                path:    None,
62            }
63        }
64    }
65}
66
67/// Validate a scalar value against a custom scalar type definition.
68///
69/// This function validates a JSON value against a custom scalar type registered
70/// in the schema, checking both validation rules and ELO expressions.
71///
72/// # Arguments
73///
74/// * `value` - The JSON value to validate
75/// * `scalar_type_name` - Name of the custom scalar type (e.g., "`LibraryCode`")
76/// * `schema` - The compiled schema containing custom scalar definitions
77///
78/// # Errors
79///
80/// Returns a validation error if the value doesn't match the custom scalar definition.
81pub fn validate_custom_scalar_from_schema(
82    value: &Value,
83    scalar_type_name: &str,
84    schema: &CompiledSchema,
85) -> Result<()> {
86    // Check if this is a custom scalar type
87    if schema.custom_scalars.exists(scalar_type_name) {
88        schema.custom_scalars.validate(scalar_type_name, value)
89    } else {
90        // Not a custom scalar, pass through (built-in type)
91        Ok(())
92    }
93}
94
95/// Validate JSON input against validation rules.
96///
97/// This function recursively validates a JSON value against a set of
98/// validation rules, collecting all errors that occur.
99///
100/// # Errors
101///
102/// Returns [`FraiseQLError::Validation`] if any rule is violated (e.g., string
103/// too short, value out of range, or a required field is null).
104pub fn validate_input(value: &Value, field_path: &str, rules: &[ValidationRule]) -> Result<()> {
105    let mut errors = ValidationErrorCollection::new();
106
107    match value {
108        Value::String(s) => {
109            for rule in rules {
110                if let Err(FraiseQLError::Validation { message, .. }) =
111                    validate_string_field(s, field_path, rule)
112                {
113                    if let Some(field_err) = extract_field_error(&message) {
114                        errors.add_error(field_err);
115                    }
116                }
117            }
118        },
119        Value::Null => {
120            for rule in rules {
121                if rule.is_required() {
122                    errors.add_error(ValidationFieldError::new(
123                        field_path,
124                        "required",
125                        "Field is required",
126                    ));
127                }
128            }
129        },
130        _ => {
131            // Other types (number, bool, array, object) have different validation logic
132        },
133    }
134
135    if errors.is_empty() {
136        Ok(())
137    } else {
138        Err(errors.to_error())
139    }
140}
141
142/// Validate a string field against a validation rule.
143pub(crate) fn validate_string_field(
144    value: &str,
145    field_path: &str,
146    rule: &ValidationRule,
147) -> Result<()> {
148    match rule {
149        ValidationRule::Required => {
150            if value.is_empty() {
151                return Err(FraiseQLError::Validation {
152                    message: format!(
153                        "Field validation failed: {}",
154                        ValidationFieldError::new(field_path, "required", "Field is required")
155                    ),
156                    path:    Some(field_path.to_string()),
157                });
158            }
159            Ok(())
160        },
161        ValidationRule::Pattern { pattern, message } => {
162            if pattern.is_match(value) {
163                Ok(())
164            } else {
165                let msg = message.clone().unwrap_or_else(|| "Pattern mismatch".to_string());
166                Err(FraiseQLError::Validation {
167                    message: format!(
168                        "Field validation failed: {}",
169                        ValidationFieldError::new(field_path, "pattern", msg)
170                    ),
171                    path:    Some(field_path.to_string()),
172                })
173            }
174        },
175        ValidationRule::Length { min, max } => {
176            let len = value.len();
177            let valid = if let Some(m) = min { len >= *m } else { true }
178                && if let Some(m) = max { len <= *m } else { true };
179
180            if valid {
181                Ok(())
182            } else {
183                let msg = match (min, max) {
184                    (Some(m), Some(x)) => format!("Length must be between {} and {}", m, x),
185                    (Some(m), None) => format!("Length must be at least {}", m),
186                    (None, Some(x)) => format!("Length must be at most {}", x),
187                    (None, None) => "Length validation failed".to_string(),
188                };
189                Err(FraiseQLError::Validation {
190                    message: format!(
191                        "Field validation failed: {}",
192                        ValidationFieldError::new(field_path, "length", msg)
193                    ),
194                    path:    Some(field_path.to_string()),
195                })
196            }
197        },
198        ValidationRule::Enum { values } => {
199            if values.contains(&value.to_string()) {
200                Ok(())
201            } else {
202                Err(FraiseQLError::Validation {
203                    message: format!(
204                        "Field validation failed: {}",
205                        ValidationFieldError::new(
206                            field_path,
207                            "enum",
208                            format!("Must be one of: {}", values.join(", "))
209                        )
210                    ),
211                    path:    Some(field_path.to_string()),
212                })
213            }
214        },
215        _ => Ok(()), // Other rule types handled elsewhere
216    }
217}
218
219/// Extract field error information from an error message.
220fn extract_field_error(message: &str) -> Option<ValidationFieldError> {
221    // Format: "Field validation failed: field (rule): message"
222    if message.contains("Field validation failed:") {
223        if let Some(field_start) = message.find("Field validation failed: ") {
224            let rest = &message[field_start + "Field validation failed: ".len()..];
225            if let Some(paren_start) = rest.find('(') {
226                if let Some(paren_end) = rest.find(')') {
227                    let field = rest[..paren_start].trim().to_string();
228                    let rule_type = rest[paren_start + 1..paren_end].to_string();
229                    let msg_start = rest.find(": ").unwrap_or(0) + 2;
230                    let message_text = rest[msg_start..].to_string();
231                    return Some(ValidationFieldError::new(field, rule_type, message_text));
232                }
233            }
234        }
235    }
236    None
237}