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::{LengthCheck, ValidationRule, check_length},
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                // The structured error is kept structured. Formatting it into a
111                // message and re-parsing that message with `find('(')` meant a
112                // field path containing a parenthesis — or any change to the
113                // message format — silently discarded the violation and let
114                // validation *pass* (#720).
115                if let Err(field_err) = check_string_field(s, field_path, rule) {
116                    errors.add_error(field_err);
117                }
118            }
119        },
120        Value::Null => {
121            for rule in rules {
122                if rule.is_required() {
123                    errors.add_error(ValidationFieldError::new(
124                        field_path,
125                        "required",
126                        "Field is required",
127                    ));
128                }
129            }
130        },
131        _ => {
132            // Other types (number, bool, array, object) have different validation logic
133        },
134    }
135
136    if errors.is_empty() {
137        Ok(())
138    } else {
139        Err(errors.to_error())
140    }
141}
142
143/// Check a string field against one validation rule.
144///
145/// Returns the violation as a structured [`ValidationFieldError`]. Callers that
146/// The structured form is what the aggregator consumes, so no error is
147/// laundered through its own display string on the way (#720).
148pub(crate) fn check_string_field(
149    value: &str,
150    field_path: &str,
151    rule: &ValidationRule,
152) -> std::result::Result<(), ValidationFieldError> {
153    match rule {
154        ValidationRule::Required if value.is_empty() => {
155            Err(ValidationFieldError::new(field_path, "required", "Field is required"))
156        },
157        ValidationRule::Pattern { pattern, message } if !pattern.is_match(value) => {
158            let msg = message.clone().unwrap_or_else(|| "Pattern mismatch".to_string());
159            Err(ValidationFieldError::new(field_path, "pattern", msg))
160        },
161        ValidationRule::Length { min, max } => match check_length(value, *min, *max) {
162            LengthCheck::Ok => Ok(()),
163            LengthCheck::TooShort { min, actual } => Err(ValidationFieldError::new(
164                field_path,
165                "length",
166                format!("Length must be at least {min} characters, got {actual}"),
167            )),
168            LengthCheck::TooLong { max, actual } => Err(ValidationFieldError::new(
169                field_path,
170                "length",
171                format!("Length must be at most {max} characters, got {actual}"),
172            )),
173        },
174        ValidationRule::Enum { values } if !values.iter().any(|v| v == value) => {
175            Err(ValidationFieldError::new(
176                field_path,
177                "enum",
178                format!("Must be one of: {}", values.join(", ")),
179            ))
180        },
181        // Every other rule either passed above or is evaluated elsewhere.
182        _ => Ok(()),
183    }
184}