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 const fn is_empty(&self) -> bool {
35        self.errors.is_empty()
36    }
37
38    /// Get the number of errors.
39    pub const 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_from_schema(
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.
95///
96/// # Errors
97///
98/// Returns [`FraiseQLError::Validation`] if any rule is violated (e.g., string
99/// too short, value out of range, or a required field is null).
100pub fn validate_input(value: &Value, field_path: &str, rules: &[ValidationRule]) -> Result<()> {
101    let mut errors = ValidationErrorCollection::new();
102
103    match value {
104        Value::String(s) => {
105            for rule in rules {
106                if let Err(FraiseQLError::Validation { message, .. }) =
107                    validate_string_field(s, field_path, rule)
108                {
109                    if let Some(field_err) = extract_field_error(&message) {
110                        errors.add_error(field_err);
111                    }
112                }
113            }
114        },
115        Value::Null => {
116            for rule in rules {
117                if rule.is_required() {
118                    errors.add_error(ValidationFieldError::new(
119                        field_path,
120                        "required",
121                        "Field is required",
122                    ));
123                }
124            }
125        },
126        _ => {
127            // Other types (number, bool, array, object) have different validation logic
128        },
129    }
130
131    if errors.is_empty() {
132        Ok(())
133    } else {
134        Err(errors.to_error())
135    }
136}
137
138/// Validate a string field against a validation rule.
139fn validate_string_field(value: &str, field_path: &str, rule: &ValidationRule) -> Result<()> {
140    match rule {
141        ValidationRule::Required => {
142            if value.is_empty() {
143                return Err(FraiseQLError::Validation {
144                    message: format!(
145                        "Field validation failed: {}",
146                        ValidationFieldError::new(field_path, "required", "Field is required")
147                    ),
148                    path:    Some(field_path.to_string()),
149                });
150            }
151            Ok(())
152        },
153        ValidationRule::Pattern { pattern, message } => {
154            let regex = regex::Regex::new(pattern)
155                .map_err(|e| FraiseQLError::validation(format!("Invalid regex pattern: {}", e)))?;
156            if regex.is_match(value) {
157                Ok(())
158            } else {
159                let msg = message.clone().unwrap_or_else(|| "Pattern mismatch".to_string());
160                Err(FraiseQLError::Validation {
161                    message: format!(
162                        "Field validation failed: {}",
163                        ValidationFieldError::new(field_path, "pattern", msg)
164                    ),
165                    path:    Some(field_path.to_string()),
166                })
167            }
168        },
169        ValidationRule::Length { min, max } => {
170            let len = value.len();
171            let valid = if let Some(m) = min { len >= *m } else { true }
172                && if let Some(m) = max { len <= *m } else { true };
173
174            if valid {
175                Ok(())
176            } else {
177                let msg = match (min, max) {
178                    (Some(m), Some(x)) => format!("Length must be between {} and {}", m, x),
179                    (Some(m), None) => format!("Length must be at least {}", m),
180                    (None, Some(x)) => format!("Length must be at most {}", x),
181                    (None, None) => "Length validation failed".to_string(),
182                };
183                Err(FraiseQLError::Validation {
184                    message: format!(
185                        "Field validation failed: {}",
186                        ValidationFieldError::new(field_path, "length", msg)
187                    ),
188                    path:    Some(field_path.to_string()),
189                })
190            }
191        },
192        ValidationRule::Enum { values } => {
193            if values.contains(&value.to_string()) {
194                Ok(())
195            } else {
196                Err(FraiseQLError::Validation {
197                    message: format!(
198                        "Field validation failed: {}",
199                        ValidationFieldError::new(
200                            field_path,
201                            "enum",
202                            format!("Must be one of: {}", values.join(", "))
203                        )
204                    ),
205                    path:    Some(field_path.to_string()),
206                })
207            }
208        },
209        _ => Ok(()), // Other rule types handled elsewhere
210    }
211}
212
213/// Extract field error information from an error message.
214fn extract_field_error(message: &str) -> Option<ValidationFieldError> {
215    // Format: "Field validation failed: field (rule): message"
216    if message.contains("Field validation failed:") {
217        if let Some(field_start) = message.find("Field validation failed: ") {
218            let rest = &message[field_start + "Field validation failed: ".len()..];
219            if let Some(paren_start) = rest.find('(') {
220                if let Some(paren_end) = rest.find(')') {
221                    let field = rest[..paren_start].trim().to_string();
222                    let rule_type = rest[paren_start + 1..paren_end].to_string();
223                    let msg_start = rest.find(": ").unwrap_or(0) + 2;
224                    let message_text = rest[msg_start..].to_string();
225                    return Some(ValidationFieldError::new(field, rule_type, message_text));
226                }
227            }
228        }
229    }
230    None
231}
232
233#[cfg(test)]
234mod tests {
235    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
236
237    use super::*;
238
239    #[test]
240    fn test_validation_error_collection() {
241        let mut errors = ValidationErrorCollection::new();
242        assert!(errors.is_empty());
243
244        errors.add_error(ValidationFieldError::new("email", "pattern", "Invalid email"));
245        assert!(!errors.is_empty());
246        assert_eq!(errors.len(), 1);
247    }
248
249    #[test]
250    fn test_validation_error_collection_to_error() {
251        let mut errors = ValidationErrorCollection::new();
252        errors.add_error(ValidationFieldError::new("email", "pattern", "Invalid email"));
253
254        let err = errors.to_error();
255        assert!(matches!(err, FraiseQLError::Validation { .. }));
256    }
257
258    #[test]
259    fn test_validate_required_field() {
260        let rule = ValidationRule::Required;
261        let result = validate_string_field("value", "field", &rule);
262        result.unwrap_or_else(|e| panic!("expected Ok for non-empty value: {e}"));
263
264        let result = validate_string_field("", "field", &rule);
265        assert!(
266            matches!(result, Err(FraiseQLError::Validation { .. })),
267            "expected Validation error for empty required field, got: {result:?}"
268        );
269    }
270
271    #[test]
272    fn test_validate_pattern() {
273        let rule = ValidationRule::Pattern {
274            pattern: "^[a-z]+$".to_string(),
275            message: None,
276        };
277
278        let result = validate_string_field("hello", "field", &rule);
279        result.unwrap_or_else(|e| panic!("expected Ok for matching pattern: {e}"));
280
281        let result = validate_string_field("Hello", "field", &rule);
282        assert!(
283            matches!(result, Err(FraiseQLError::Validation { .. })),
284            "expected Validation error for non-matching pattern, got: {result:?}"
285        );
286    }
287
288    #[test]
289    fn test_validate_length() {
290        let rule = ValidationRule::Length {
291            min: Some(3),
292            max: Some(10),
293        };
294
295        let result = validate_string_field("hello", "field", &rule);
296        result.unwrap_or_else(|e| panic!("expected Ok for in-range length: {e}"));
297
298        let result = validate_string_field("hi", "field", &rule);
299        assert!(
300            matches!(result, Err(FraiseQLError::Validation { .. })),
301            "expected Validation error for too-short string, got: {result:?}"
302        );
303
304        let result = validate_string_field("this is too long", "field", &rule);
305        assert!(
306            matches!(result, Err(FraiseQLError::Validation { .. })),
307            "expected Validation error for too-long string, got: {result:?}"
308        );
309    }
310
311    #[test]
312    fn test_validate_enum() {
313        let rule = ValidationRule::Enum {
314            values: vec!["active".to_string(), "inactive".to_string()],
315        };
316
317        let result = validate_string_field("active", "field", &rule);
318        result.unwrap_or_else(|e| panic!("expected Ok for valid enum value: {e}"));
319
320        let result = validate_string_field("unknown", "field", &rule);
321        assert!(
322            matches!(result, Err(FraiseQLError::Validation { .. })),
323            "expected Validation error for invalid enum value, got: {result:?}"
324        );
325    }
326
327    #[test]
328    fn test_validate_null_field() {
329        let rule = ValidationRule::Required;
330        let result = validate_input(&Value::Null, "field", &[rule]);
331        assert!(
332            matches!(result, Err(FraiseQLError::Validation { .. })),
333            "expected Validation error for null required field, got: {result:?}"
334        );
335    }
336
337    #[test]
338    fn test_validate_custom_scalar_library_code_valid() {
339        use crate::{
340            schema::CompiledSchema,
341            validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
342        };
343
344        let schema = {
345            let mut s = CompiledSchema::new();
346            let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
347
348            let mut def = CustomTypeDef::new("LibraryCode".to_string());
349            def.validation_rules = vec![ValidationRule::Pattern {
350                pattern: r"^LIB-[0-9]{4}$".to_string(),
351                message: Some("Library code must be LIB-#### format".to_string()),
352            }];
353
354            registry.register("LibraryCode".to_string(), def).unwrap();
355
356            s.custom_scalars = registry;
357            s
358        };
359
360        let value = serde_json::json!("LIB-1234");
361        let result = validate_custom_scalar_from_schema(&value, "LibraryCode", &schema);
362        result.unwrap_or_else(|e| panic!("expected Ok for valid LibraryCode: {e}"));
363    }
364
365    #[test]
366    fn test_validate_custom_scalar_library_code_invalid() {
367        use crate::{
368            schema::CompiledSchema,
369            validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
370        };
371
372        let schema = {
373            let mut s = CompiledSchema::new();
374            let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
375
376            let mut def = CustomTypeDef::new("LibraryCode".to_string());
377            def.validation_rules = vec![ValidationRule::Pattern {
378                pattern: r"^LIB-[0-9]{4}$".to_string(),
379                message: Some("Library code must be LIB-#### format".to_string()),
380            }];
381
382            registry.register("LibraryCode".to_string(), def).unwrap();
383
384            s.custom_scalars = registry;
385            s
386        };
387
388        let value = serde_json::json!("INVALID");
389        let result = validate_custom_scalar_from_schema(&value, "LibraryCode", &schema);
390        assert!(
391            matches!(result, Err(FraiseQLError::Validation { .. })),
392            "expected Validation error for invalid LibraryCode, got: {result:?}"
393        );
394    }
395
396    #[test]
397    fn test_validate_custom_scalar_student_id_with_length() {
398        use crate::{
399            schema::CompiledSchema,
400            validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
401        };
402
403        let schema = {
404            let mut s = CompiledSchema::new();
405            let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
406
407            let mut def = CustomTypeDef::new("StudentID".to_string());
408            def.validation_rules = vec![
409                ValidationRule::Pattern {
410                    pattern: r"^STU-[0-9]{4}-[0-9]{3}$".to_string(),
411                    message: None,
412                },
413                ValidationRule::Length {
414                    min: Some(12),
415                    max: Some(12),
416                },
417            ];
418
419            registry.register("StudentID".to_string(), def).unwrap();
420
421            s.custom_scalars = registry;
422            s
423        };
424
425        // Valid: matches pattern and length
426        let value = serde_json::json!("STU-2024-001");
427        let result = validate_custom_scalar_from_schema(&value, "StudentID", &schema);
428        result.unwrap_or_else(|e| panic!("expected Ok for valid StudentID: {e}"));
429
430        // Invalid: wrong pattern
431        let value = serde_json::json!("STUDENT-2024");
432        let result = validate_custom_scalar_from_schema(&value, "StudentID", &schema);
433        assert!(
434            matches!(result, Err(FraiseQLError::Validation { .. })),
435            "expected Validation error for invalid StudentID, got: {result:?}"
436        );
437    }
438
439    #[test]
440    fn test_validate_unknown_scalar_type_passthrough() {
441        use crate::schema::CompiledSchema;
442
443        let schema = CompiledSchema::new();
444
445        // Unknown scalar types should pass through (they're built-in types)
446        let value = serde_json::json!("any value");
447        let result = validate_custom_scalar_from_schema(&value, "UnknownType", &schema);
448        result.unwrap_or_else(|e| panic!("expected Ok for unknown scalar passthrough: {e}"));
449    }
450
451    #[test]
452    fn test_validate_custom_scalar_patient_id_passthrough() {
453        use crate::schema::CompiledSchema;
454
455        // Schema without PatientID definition
456        let schema = CompiledSchema::new();
457
458        let value = serde_json::json!("PAT-123456");
459        let result = validate_custom_scalar_from_schema(&value, "PatientID", &schema);
460        // Should pass through (not registered as custom scalar)
461        result
462            .unwrap_or_else(|e| panic!("expected Ok for unregistered PatientID passthrough: {e}"));
463    }
464
465    #[test]
466    fn test_validate_custom_scalar_with_elo_expression() {
467        use crate::{
468            schema::CompiledSchema,
469            validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
470        };
471
472        let schema = {
473            let mut s = CompiledSchema::new();
474            let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
475
476            let mut def = CustomTypeDef::new("StudentID".to_string());
477            def.elo_expression = Some("matches(value, \"^STU-[0-9]{4}-[0-9]{3}$\")".to_string());
478
479            registry.register("StudentID".to_string(), def).unwrap();
480
481            s.custom_scalars = registry;
482            s
483        };
484
485        // Valid: matches ELO expression
486        let value = serde_json::json!("STU-2024-001");
487        let result = validate_custom_scalar_from_schema(&value, "StudentID", &schema);
488        result.unwrap_or_else(|e| panic!("expected Ok for StudentID matching ELO expression: {e}"));
489
490        // Invalid: doesn't match ELO expression
491        let value = serde_json::json!("INVALID");
492        let result = validate_custom_scalar_from_schema(&value, "StudentID", &schema);
493        assert!(
494            matches!(result, Err(FraiseQLError::Validation { .. })),
495            "expected Validation error for StudentID not matching ELO expression, got: {result:?}"
496        );
497    }
498
499    #[test]
500    fn test_validate_custom_scalar_combined_rules_and_elo() {
501        use crate::{
502            schema::CompiledSchema,
503            validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
504        };
505
506        let schema = {
507            let mut s = CompiledSchema::new();
508            let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
509
510            let mut def = CustomTypeDef::new("PatientID".to_string());
511            def.validation_rules = vec![ValidationRule::Length {
512                min: Some(10),
513                max: Some(10),
514            }];
515            def.elo_expression = Some("matches(value, \"^PAT-[0-9]{6}$\")".to_string());
516
517            registry.register("PatientID".to_string(), def).unwrap();
518
519            s.custom_scalars = registry;
520            s
521        };
522
523        // Valid: passes both length rule and ELO expression
524        let value = serde_json::json!("PAT-123456");
525        let result = validate_custom_scalar_from_schema(&value, "PatientID", &schema);
526        result.unwrap_or_else(|e| panic!("expected Ok for valid PatientID: {e}"));
527
528        // Invalid: passes length but fails ELO expression
529        let value = serde_json::json!("NOTVALID!");
530        let result = validate_custom_scalar_from_schema(&value, "PatientID", &schema);
531        assert!(
532            matches!(result, Err(FraiseQLError::Validation { .. })),
533            "expected Validation error for PatientID failing ELO expression, got: {result:?}"
534        );
535
536        // Invalid: fails length rule
537        let value = serde_json::json!("PAT-12345");
538        let result = validate_custom_scalar_from_schema(&value, "PatientID", &schema);
539        assert!(
540            matches!(result, Err(FraiseQLError::Validation { .. })),
541            "expected Validation error for PatientID failing length rule, got: {result:?}"
542        );
543    }
544}