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    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Add an error to the collection.
29    pub fn add_error(&mut self, error: ValidationFieldError) {
30        self.errors.push(error);
31    }
32
33    /// Check if there are any errors.
34    pub fn is_empty(&self) -> bool {
35        self.errors.is_empty()
36    }
37
38    /// Get the number of errors.
39    pub fn len(&self) -> usize {
40        self.errors.len()
41    }
42
43    /// Convert to a FraiseQL error.
44    pub fn to_error(&self) -> FraiseQLError {
45        if self.errors.is_empty() {
46            FraiseQLError::validation("No validation errors")
47        } else if self.errors.len() == 1 {
48            let err = &self.errors[0];
49            FraiseQLError::Validation {
50                message: err.to_string(),
51                path:    Some(err.field.clone()),
52            }
53        } else {
54            let messages: Vec<String> = self.errors.iter().map(|e| e.to_string()).collect();
55            FraiseQLError::Validation {
56                message: format!("Multiple validation errors: {}", messages.join("; ")),
57                path:    None,
58            }
59        }
60    }
61}
62
63/// Validate a scalar value against a custom scalar type definition.
64///
65/// This function validates a JSON value against a custom scalar type registered
66/// in the schema, checking both validation rules and ELO expressions.
67///
68/// # Arguments
69///
70/// * `value` - The JSON value to validate
71/// * `scalar_type_name` - Name of the custom scalar type (e.g., "LibraryCode")
72/// * `schema` - The compiled schema containing custom scalar definitions
73///
74/// # Errors
75///
76/// Returns a validation error if the value doesn't match the custom scalar definition.
77pub fn validate_custom_scalar(
78    value: &Value,
79    scalar_type_name: &str,
80    schema: &CompiledSchema,
81) -> Result<()> {
82    // Check if this is a custom scalar type
83    if schema.custom_scalars.exists(scalar_type_name) {
84        schema.custom_scalars.validate(scalar_type_name, value)
85    } else {
86        // Not a custom scalar, pass through (built-in type)
87        Ok(())
88    }
89}
90
91/// Validate JSON input against validation rules.
92///
93/// This function recursively validates a JSON value against a set of
94/// validation rules, collecting all errors that occur.
95pub fn validate_input(value: &Value, field_path: &str, rules: &[ValidationRule]) -> Result<()> {
96    let mut errors = ValidationErrorCollection::new();
97
98    match value {
99        Value::String(s) => {
100            for rule in rules {
101                if let Err(FraiseQLError::Validation { message, .. }) =
102                    validate_string_field(s, field_path, rule)
103                {
104                    if let Some(field_err) = extract_field_error(&message) {
105                        errors.add_error(field_err);
106                    }
107                }
108            }
109        },
110        Value::Null => {
111            for rule in rules {
112                if rule.is_required() {
113                    errors.add_error(ValidationFieldError::new(
114                        field_path,
115                        "required",
116                        "Field is required",
117                    ));
118                }
119            }
120        },
121        _ => {
122            // Other types (number, bool, array, object) have different validation logic
123        },
124    }
125
126    if errors.is_empty() {
127        Ok(())
128    } else {
129        Err(errors.to_error())
130    }
131}
132
133/// Validate a string field against a validation rule.
134fn validate_string_field(value: &str, field_path: &str, rule: &ValidationRule) -> Result<()> {
135    match rule {
136        ValidationRule::Required => {
137            if value.is_empty() {
138                return Err(FraiseQLError::Validation {
139                    message: format!(
140                        "Field validation failed: {}",
141                        ValidationFieldError::new(field_path, "required", "Field is required")
142                    ),
143                    path:    Some(field_path.to_string()),
144                });
145            }
146            Ok(())
147        },
148        ValidationRule::Pattern { pattern, message } => {
149            let regex = regex::Regex::new(pattern)
150                .map_err(|e| FraiseQLError::validation(format!("Invalid regex pattern: {}", e)))?;
151            if regex.is_match(value) {
152                Ok(())
153            } else {
154                let msg = message.clone().unwrap_or_else(|| "Pattern mismatch".to_string());
155                Err(FraiseQLError::Validation {
156                    message: format!(
157                        "Field validation failed: {}",
158                        ValidationFieldError::new(field_path, "pattern", msg)
159                    ),
160                    path:    Some(field_path.to_string()),
161                })
162            }
163        },
164        ValidationRule::Length { min, max } => {
165            let len = value.len();
166            let valid = if let Some(m) = min { len >= *m } else { true }
167                && if let Some(m) = max { len <= *m } else { true };
168
169            if valid {
170                Ok(())
171            } else {
172                let msg = match (min, max) {
173                    (Some(m), Some(x)) => format!("Length must be between {} and {}", m, x),
174                    (Some(m), None) => format!("Length must be at least {}", m),
175                    (None, Some(x)) => format!("Length must be at most {}", x),
176                    (None, None) => "Length validation failed".to_string(),
177                };
178                Err(FraiseQLError::Validation {
179                    message: format!(
180                        "Field validation failed: {}",
181                        ValidationFieldError::new(field_path, "length", msg)
182                    ),
183                    path:    Some(field_path.to_string()),
184                })
185            }
186        },
187        ValidationRule::Enum { values } => {
188            if values.contains(&value.to_string()) {
189                Ok(())
190            } else {
191                Err(FraiseQLError::Validation {
192                    message: format!(
193                        "Field validation failed: {}",
194                        ValidationFieldError::new(
195                            field_path,
196                            "enum",
197                            format!("Must be one of: {}", values.join(", "))
198                        )
199                    ),
200                    path:    Some(field_path.to_string()),
201                })
202            }
203        },
204        _ => Ok(()), // Other rule types handled elsewhere
205    }
206}
207
208/// Extract field error information from an error message.
209fn extract_field_error(message: &str) -> Option<ValidationFieldError> {
210    // Format: "Field validation failed: field (rule): message"
211    if message.contains("Field validation failed:") {
212        if let Some(field_start) = message.find("Field validation failed: ") {
213            let rest = &message[field_start + "Field validation failed: ".len()..];
214            if let Some(paren_start) = rest.find('(') {
215                if let Some(paren_end) = rest.find(')') {
216                    let field = rest[..paren_start].trim().to_string();
217                    let rule_type = rest[paren_start + 1..paren_end].to_string();
218                    let msg_start = rest.find(": ").unwrap_or(0) + 2;
219                    let message_text = rest[msg_start..].to_string();
220                    return Some(ValidationFieldError::new(field, rule_type, message_text));
221                }
222            }
223        }
224    }
225    None
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_validation_error_collection() {
234        let mut errors = ValidationErrorCollection::new();
235        assert!(errors.is_empty());
236
237        errors.add_error(ValidationFieldError::new("email", "pattern", "Invalid email"));
238        assert!(!errors.is_empty());
239        assert_eq!(errors.len(), 1);
240    }
241
242    #[test]
243    fn test_validation_error_collection_to_error() {
244        let mut errors = ValidationErrorCollection::new();
245        errors.add_error(ValidationFieldError::new("email", "pattern", "Invalid email"));
246
247        let err = errors.to_error();
248        assert!(matches!(err, FraiseQLError::Validation { .. }));
249    }
250
251    #[test]
252    fn test_validate_required_field() {
253        let rule = ValidationRule::Required;
254        let result = validate_string_field("value", "field", &rule);
255        assert!(result.is_ok());
256
257        let result = validate_string_field("", "field", &rule);
258        assert!(result.is_err());
259    }
260
261    #[test]
262    fn test_validate_pattern() {
263        let rule = ValidationRule::Pattern {
264            pattern: "^[a-z]+$".to_string(),
265            message: None,
266        };
267
268        let result = validate_string_field("hello", "field", &rule);
269        assert!(result.is_ok());
270
271        let result = validate_string_field("Hello", "field", &rule);
272        assert!(result.is_err());
273    }
274
275    #[test]
276    fn test_validate_length() {
277        let rule = ValidationRule::Length {
278            min: Some(3),
279            max: Some(10),
280        };
281
282        let result = validate_string_field("hello", "field", &rule);
283        assert!(result.is_ok());
284
285        let result = validate_string_field("hi", "field", &rule);
286        assert!(result.is_err());
287
288        let result = validate_string_field("this is too long", "field", &rule);
289        assert!(result.is_err());
290    }
291
292    #[test]
293    fn test_validate_enum() {
294        let rule = ValidationRule::Enum {
295            values: vec!["active".to_string(), "inactive".to_string()],
296        };
297
298        let result = validate_string_field("active", "field", &rule);
299        assert!(result.is_ok());
300
301        let result = validate_string_field("unknown", "field", &rule);
302        assert!(result.is_err());
303    }
304
305    #[test]
306    fn test_validate_null_field() {
307        let rule = ValidationRule::Required;
308        let result = validate_input(&Value::Null, "field", &[rule]);
309        assert!(result.is_err());
310    }
311
312    #[test]
313    fn test_validate_custom_scalar_library_code_valid() {
314        use crate::{
315            schema::CompiledSchema,
316            validation::{CustomTypeDef, CustomTypeRegistry},
317        };
318
319        let schema = {
320            let mut s = CompiledSchema::new();
321            let registry = CustomTypeRegistry::new(Default::default());
322
323            let mut def = CustomTypeDef::new("LibraryCode".to_string());
324            def.validation_rules = vec![ValidationRule::Pattern {
325                pattern: r"^LIB-[0-9]{4}$".to_string(),
326                message: Some("Library code must be LIB-#### format".to_string()),
327            }];
328
329            registry.register("LibraryCode".to_string(), def).unwrap();
330
331            s.custom_scalars = registry;
332            s
333        };
334
335        let value = serde_json::json!("LIB-1234");
336        let result = validate_custom_scalar(&value, "LibraryCode", &schema);
337        assert!(result.is_ok());
338    }
339
340    #[test]
341    fn test_validate_custom_scalar_library_code_invalid() {
342        use crate::{
343            schema::CompiledSchema,
344            validation::{CustomTypeDef, CustomTypeRegistry},
345        };
346
347        let schema = {
348            let mut s = CompiledSchema::new();
349            let registry = CustomTypeRegistry::new(Default::default());
350
351            let mut def = CustomTypeDef::new("LibraryCode".to_string());
352            def.validation_rules = vec![ValidationRule::Pattern {
353                pattern: r"^LIB-[0-9]{4}$".to_string(),
354                message: Some("Library code must be LIB-#### format".to_string()),
355            }];
356
357            registry.register("LibraryCode".to_string(), def).unwrap();
358
359            s.custom_scalars = registry;
360            s
361        };
362
363        let value = serde_json::json!("INVALID");
364        let result = validate_custom_scalar(&value, "LibraryCode", &schema);
365        assert!(result.is_err());
366    }
367
368    #[test]
369    fn test_validate_custom_scalar_student_id_with_length() {
370        use crate::{
371            schema::CompiledSchema,
372            validation::{CustomTypeDef, CustomTypeRegistry},
373        };
374
375        let schema = {
376            let mut s = CompiledSchema::new();
377            let registry = CustomTypeRegistry::new(Default::default());
378
379            let mut def = CustomTypeDef::new("StudentID".to_string());
380            def.validation_rules = vec![
381                ValidationRule::Pattern {
382                    pattern: r"^STU-[0-9]{4}-[0-9]{3}$".to_string(),
383                    message: None,
384                },
385                ValidationRule::Length {
386                    min: Some(12),
387                    max: Some(12),
388                },
389            ];
390
391            registry.register("StudentID".to_string(), def).unwrap();
392
393            s.custom_scalars = registry;
394            s
395        };
396
397        // Valid: matches pattern and length
398        let value = serde_json::json!("STU-2024-001");
399        let result = validate_custom_scalar(&value, "StudentID", &schema);
400        assert!(result.is_ok());
401
402        // Invalid: wrong pattern
403        let value = serde_json::json!("STUDENT-2024");
404        let result = validate_custom_scalar(&value, "StudentID", &schema);
405        assert!(result.is_err());
406    }
407
408    #[test]
409    fn test_validate_unknown_scalar_type_passthrough() {
410        use crate::schema::CompiledSchema;
411
412        let schema = CompiledSchema::new();
413
414        // Unknown scalar types should pass through (they're built-in types)
415        let value = serde_json::json!("any value");
416        let result = validate_custom_scalar(&value, "UnknownType", &schema);
417        assert!(result.is_ok());
418    }
419
420    #[test]
421    fn test_validate_custom_scalar_patient_id_passthrough() {
422        use crate::schema::CompiledSchema;
423
424        // Schema without PatientID definition
425        let schema = CompiledSchema::new();
426
427        let value = serde_json::json!("PAT-123456");
428        let result = validate_custom_scalar(&value, "PatientID", &schema);
429        // Should pass through (not registered as custom scalar)
430        assert!(result.is_ok());
431    }
432
433    #[test]
434    fn test_validate_custom_scalar_with_elo_expression() {
435        use crate::{
436            schema::CompiledSchema,
437            validation::{CustomTypeDef, CustomTypeRegistry},
438        };
439
440        let schema = {
441            let mut s = CompiledSchema::new();
442            let registry = CustomTypeRegistry::new(Default::default());
443
444            let mut def = CustomTypeDef::new("StudentID".to_string());
445            def.elo_expression = Some("matches(value, \"^STU-[0-9]{4}-[0-9]{3}$\")".to_string());
446
447            registry.register("StudentID".to_string(), def).unwrap();
448
449            s.custom_scalars = registry;
450            s
451        };
452
453        // Valid: matches ELO expression
454        let value = serde_json::json!("STU-2024-001");
455        let result = validate_custom_scalar(&value, "StudentID", &schema);
456        assert!(result.is_ok());
457
458        // Invalid: doesn't match ELO expression
459        let value = serde_json::json!("INVALID");
460        let result = validate_custom_scalar(&value, "StudentID", &schema);
461        assert!(result.is_err());
462    }
463
464    #[test]
465    fn test_validate_custom_scalar_combined_rules_and_elo() {
466        use crate::{
467            schema::CompiledSchema,
468            validation::{CustomTypeDef, CustomTypeRegistry},
469        };
470
471        let schema = {
472            let mut s = CompiledSchema::new();
473            let registry = CustomTypeRegistry::new(Default::default());
474
475            let mut def = CustomTypeDef::new("PatientID".to_string());
476            def.validation_rules = vec![ValidationRule::Length {
477                min: Some(10),
478                max: Some(10),
479            }];
480            def.elo_expression = Some("matches(value, \"^PAT-[0-9]{6}$\")".to_string());
481
482            registry.register("PatientID".to_string(), def).unwrap();
483
484            s.custom_scalars = registry;
485            s
486        };
487
488        // Valid: passes both length rule and ELO expression
489        let value = serde_json::json!("PAT-123456");
490        let result = validate_custom_scalar(&value, "PatientID", &schema);
491        assert!(result.is_ok());
492
493        // Invalid: passes length but fails ELO expression
494        let value = serde_json::json!("NOTVALID!");
495        let result = validate_custom_scalar(&value, "PatientID", &schema);
496        assert!(result.is_err());
497
498        // Invalid: fails length rule
499        let value = serde_json::json!("PAT-12345");
500        let result = validate_custom_scalar(&value, "PatientID", &schema);
501        assert!(result.is_err());
502    }
503}