Skip to main content

fraiseql_core/validation/
input_processor.rs

1//! Input processor for GraphQL variables with ID policy validation
2//!
3//! This module provides utilities to validate GraphQL input variables,
4//! particularly ID fields, according to the configured ID policy.
5//!
6//! **SECURITY CRITICAL**: Input validation is a critical security layer that
7//! prevents invalid data from propagating through the GraphQL pipeline.
8
9use std::collections::HashSet;
10
11use serde_json::{Map, Value};
12
13use super::id_policy::{IDPolicy, validate_id};
14
15/// Configuration for input processing
16#[derive(Debug, Clone)]
17pub struct InputProcessingConfig {
18    /// ID policy to enforce for ID fields
19    pub id_policy: IDPolicy,
20
21    /// Enable ID validation on all inputs (recommended)
22    pub validate_ids: bool,
23
24    /// List of field names known to be ID types
25    /// (in a real implementation, this would come from the schema)
26    pub id_field_names: HashSet<String>,
27}
28
29impl Default for InputProcessingConfig {
30    fn default() -> Self {
31        Self {
32            id_policy:      IDPolicy::default(),
33            validate_ids:   true,
34            id_field_names: Self::default_id_field_names(),
35        }
36    }
37}
38
39impl InputProcessingConfig {
40    /// Get default set of common ID field names
41    fn default_id_field_names() -> HashSet<String> {
42        [
43            "id",
44            "userId",
45            "user_id",
46            "postId",
47            "post_id",
48            "commentId",
49            "comment_id",
50            "authorId",
51            "author_id",
52            "ownerId",
53            "owner_id",
54            "creatorId",
55            "creator_id",
56            "tenantId",
57            "tenant_id",
58        ]
59        .iter()
60        .map(|s| (*s).to_string())
61        .collect()
62    }
63
64    /// Add a custom ID field name to validation
65    pub fn add_id_field(&mut self, field_name: String) {
66        self.id_field_names.insert(field_name);
67    }
68
69    /// Create a configuration for strict UUID validation
70    #[must_use]
71    pub fn strict_uuid() -> Self {
72        Self {
73            id_policy:      IDPolicy::UUID,
74            validate_ids:   true,
75            id_field_names: Self::default_id_field_names(),
76        }
77    }
78
79    /// Create a configuration for opaque IDs (GraphQL spec compliant)
80    #[must_use]
81    pub fn opaque() -> Self {
82        Self {
83            id_policy:      IDPolicy::OPAQUE,
84            validate_ids:   false, // No validation needed for opaque
85            id_field_names: Self::default_id_field_names(),
86        }
87    }
88}
89
90/// Process and validate GraphQL input variables
91///
92/// **SECURITY CRITICAL**: This validates all ID fields in input variables
93/// according to the configured ID policy.
94///
95/// # Arguments
96///
97/// * `variables` - GraphQL operation variables (JSON object)
98/// * `config` - Input processing configuration
99///
100/// # Returns
101///
102/// `Ok(processed_variables)` with validated data, or
103/// `Err(ProcessingError)` if validation fails
104///
105/// # Errors
106///
107/// Returns `ProcessingError` if any ID field fails validation according to the configured policy.
108///
109/// # Examples
110///
111/// ```
112/// use fraiseql_core::validation::{InputProcessingConfig, process_variables};
113/// use serde_json::json;
114///
115/// let config = InputProcessingConfig::strict_uuid();
116/// let variables = json!({"userId": "550e8400-e29b-41d4-a716-446655440000"});
117///
118/// match process_variables(&variables, &config) {
119///     Ok(processed) => { /* Use processed variables */ },
120///     Err(_e) => { /* Handle validation error */ },
121/// }
122/// ```
123pub fn process_variables(
124    variables: &Value,
125    config: &InputProcessingConfig,
126) -> Result<Value, ProcessingError> {
127    if !config.validate_ids {
128        return Ok(variables.clone());
129    }
130
131    match variables {
132        Value::Object(obj) => {
133            let mut result = Map::new();
134
135            for (key, value) in obj {
136                let processed_value = process_value(value, config, key)?;
137                result.insert(key.clone(), processed_value);
138            }
139
140            Ok(Value::Object(result))
141        },
142        Value::Null => Ok(Value::Null),
143        other => Ok(other.clone()),
144    }
145}
146
147/// Process a single JSON value, validating ID fields
148fn process_value(
149    value: &Value,
150    config: &InputProcessingConfig,
151    field_name: &str,
152) -> Result<Value, ProcessingError> {
153    match value {
154        // Validate ID string fields
155        // Extract base field name (before array indices like [0])
156        Value::String(s)
157            if {
158                let base_field = field_name.split('[').next().unwrap_or(field_name);
159                config.id_field_names.contains(base_field)
160            } =>
161        {
162            validate_id(s, config.id_policy).map_err(|e| ProcessingError {
163                field_path: field_name.to_string(),
164                reason:     format!("Invalid ID value: {e}"),
165            })?;
166            Ok(Value::String(s.clone()))
167        },
168
169        // Recursively process nested objects
170        Value::Object(obj) => {
171            let mut result = Map::new();
172
173            for (key, nested_value) in obj {
174                let processed = process_value(nested_value, config, key)?;
175                result.insert(key.clone(), processed);
176            }
177
178            Ok(Value::Object(result))
179        },
180
181        // Process array items
182        Value::Array(arr) => {
183            let processed_items: Result<Vec<_>, _> = arr
184                .iter()
185                .enumerate()
186                .map(|(idx, item)| {
187                    let array_field = format!("{field_name}[{idx}]");
188                    process_value(item, config, &array_field)
189                })
190                .collect();
191
192            Ok(Value::Array(processed_items?))
193        },
194
195        // Pass through other values unchanged
196        other => Ok(other.clone()),
197    }
198}
199
200/// Error type for input processing failures
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ProcessingError {
203    /// The field path where the error occurred
204    pub field_path: String,
205    /// The reason for the error
206    pub reason:     String,
207}
208
209impl std::fmt::Display for ProcessingError {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        write!(f, "Error in field '{}': {}", self.field_path, self.reason)
212    }
213}
214
215impl std::error::Error for ProcessingError {}
216
217#[cfg(test)]
218mod tests {
219    use serde_json::json;
220
221    use super::*;
222
223    #[test]
224    fn test_process_valid_uuid_id() {
225        let config = InputProcessingConfig::strict_uuid();
226        let variables = json!({
227            "userId": "550e8400-e29b-41d4-a716-446655440000"
228        });
229
230        let result = process_variables(&variables, &config);
231        assert!(result.is_ok());
232    }
233
234    #[test]
235    fn test_process_invalid_uuid_id() {
236        let config = InputProcessingConfig::strict_uuid();
237        let variables = json!({
238            "userId": "invalid-id"
239        });
240
241        let result = process_variables(&variables, &config);
242        assert!(result.is_err());
243        let err = result.unwrap_err();
244        assert!(err.field_path.contains("userId"));
245    }
246
247    #[test]
248    fn test_process_multiple_ids() {
249        let config = InputProcessingConfig::strict_uuid();
250        let variables = json!({
251            "userId": "550e8400-e29b-41d4-a716-446655440000",
252            "postId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
253            "name": "John"
254        });
255
256        let result = process_variables(&variables, &config);
257        assert!(result.is_ok());
258    }
259
260    #[test]
261    fn test_process_nested_ids() {
262        let config = InputProcessingConfig::strict_uuid();
263        let variables = json!({
264            "input": {
265                "userId": "550e8400-e29b-41d4-a716-446655440000",
266                "profile": {
267                    "authorId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
268                }
269            }
270        });
271
272        let result = process_variables(&variables, &config);
273        assert!(result.is_ok());
274    }
275
276    #[test]
277    fn test_process_nested_invalid_id() {
278        let config = InputProcessingConfig::strict_uuid();
279        let variables = json!({
280            "input": {
281                "userId": "550e8400-e29b-41d4-a716-446655440000",
282                "profile": {
283                    "authorId": "invalid"
284                }
285            }
286        });
287
288        let result = process_variables(&variables, &config);
289        assert!(result.is_err());
290    }
291
292    #[test]
293    fn test_process_array_of_ids() {
294        let config = InputProcessingConfig::strict_uuid();
295        let variables = json!({
296            "userIds": [
297                "550e8400-e29b-41d4-a716-446655440000",
298                "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
299            ]
300        });
301
302        let result = process_variables(&variables, &config);
303        assert!(result.is_ok());
304    }
305
306    #[test]
307    fn test_process_array_with_invalid_id() {
308        let mut config = InputProcessingConfig::strict_uuid();
309        // Add "userIds" as a recognized ID field
310        config.add_id_field("userIds".to_string());
311        let variables = json!({
312            "userIds": [
313                "550e8400-e29b-41d4-a716-446655440000",
314                "invalid-id"
315            ]
316        });
317
318        let result = process_variables(&variables, &config);
319        assert!(result.is_err());
320    }
321
322    #[test]
323    fn test_opaque_policy_accepts_any_id() {
324        let config = InputProcessingConfig::opaque();
325        let variables = json!({
326            "userId": "any-string-here"
327        });
328
329        let result = process_variables(&variables, &config);
330        assert!(result.is_ok());
331    }
332
333    #[test]
334    fn test_disabled_validation_skips_checks() {
335        let mut config = InputProcessingConfig::strict_uuid();
336        config.validate_ids = false;
337
338        let variables = json!({
339            "userId": "invalid-id"
340        });
341
342        let result = process_variables(&variables, &config);
343        assert!(result.is_ok());
344    }
345
346    #[test]
347    fn test_custom_id_field_names() {
348        let mut config = InputProcessingConfig::strict_uuid();
349        config.add_id_field("customId".to_string());
350
351        let variables = json!({
352            "customId": "550e8400-e29b-41d4-a716-446655440000"
353        });
354
355        let result = process_variables(&variables, &config);
356        assert!(result.is_ok());
357    }
358
359    #[test]
360    fn test_process_null_variables() {
361        let config = InputProcessingConfig::strict_uuid();
362        let result = process_variables(&Value::Null, &config);
363        assert!(result.is_ok());
364        assert!(result.unwrap().is_null());
365    }
366
367    #[test]
368    fn test_non_id_fields_pass_through() {
369        let config = InputProcessingConfig::strict_uuid();
370        let variables = json!({
371            "name": "not-a-uuid",
372            "email": "invalid-format@email",
373            "age": 25
374        });
375
376        let result = process_variables(&variables, &config);
377        assert!(result.is_ok());
378    }
379}