Skip to main content

fraiseql_core/validation/
input_object.rs

1//! Input object-level validation.
2//!
3//! This module provides validation capabilities at the input object level,
4//! applying cross-field rules and aggregating errors from multiple validators.
5//!
6//! # Examples
7//!
8//! ```ignore
9//! // Validate entire input object
10//! let input = json!({
11//!     "name": "John",
12//!     "email": "john@example.com",
13//!     "phone": null
14//! });
15//!
16//! let validators = vec![
17//!     InputObjectRule::AnyOf { fields: vec!["email", "phone"] },
18//!     InputObjectRule::ConditionalRequired {
19//!         if_field: "name",
20//!         then_fields: vec!["email"]
21//!     }
22//! ];
23//!
24//! validate_input_object(&input, &validators)?;
25//! ```
26
27use serde_json::Value;
28
29use crate::error::{FraiseQLError, Result};
30
31/// Rules that apply at the input object level.
32#[derive(Debug, Clone)]
33pub enum InputObjectRule {
34    /// At least one field from the set must be provided
35    AnyOf { fields: Vec<String> },
36    /// Exactly one field from the set must be provided
37    OneOf { fields: Vec<String> },
38    /// If one field is present, others must be present
39    ConditionalRequired {
40        if_field:    String,
41        then_fields: Vec<String>,
42    },
43    /// If one field is absent, others must be present
44    RequiredIfAbsent {
45        absent_field: String,
46        then_fields:  Vec<String>,
47    },
48    /// Custom validator function name to invoke
49    Custom { name: String },
50}
51
52/// Result of validating an input object, aggregating multiple errors.
53#[derive(Debug, Clone, Default)]
54pub struct InputObjectValidationResult {
55    /// All validation errors
56    pub errors:      Vec<String>,
57    /// Count of errors
58    pub error_count: usize,
59}
60
61impl InputObjectValidationResult {
62    /// Create a new empty result.
63    pub fn new() -> Self {
64        Self {
65            errors:      Vec::new(),
66            error_count: 0,
67        }
68    }
69
70    /// Add an error to the result.
71    pub fn add_error(&mut self, error: String) {
72        self.errors.push(error);
73        self.error_count += 1;
74    }
75
76    /// Add multiple errors at once.
77    pub fn add_errors(&mut self, errors: Vec<String>) {
78        self.error_count += errors.len();
79        self.errors.extend(errors);
80    }
81
82    /// Check if there are any errors.
83    pub fn has_errors(&self) -> bool {
84        !self.errors.is_empty()
85    }
86
87    /// Convert to a Result, failing if there are errors.
88    pub fn into_result(self) -> Result<()> {
89        self.into_result_with_path("input")
90    }
91
92    /// Convert to a Result with a custom path, failing if there are errors.
93    pub fn into_result_with_path(self, path: &str) -> Result<()> {
94        if self.has_errors() {
95            Err(FraiseQLError::Validation {
96                message: format!("Input object validation failed: {}", self.errors.join("; ")),
97                path:    Some(path.to_string()),
98            })
99        } else {
100            Ok(())
101        }
102    }
103}
104
105/// Validate an input object against a set of rules.
106///
107/// Applies all rules to the input object and aggregates errors.
108///
109/// # Arguments
110/// * `input` - The input object to validate
111/// * `rules` - Rules to apply at the object level
112/// * `object_path` - Optional path to the object for error reporting
113///
114/// # Returns
115/// - `Ok(())` if all rules pass
116/// - `Err` containing all error messages if any rule fails
117pub fn validate_input_object(
118    input: &Value,
119    rules: &[InputObjectRule],
120    object_path: Option<&str>,
121) -> Result<()> {
122    let mut result = InputObjectValidationResult::new();
123    let path = object_path.unwrap_or("input");
124
125    if !matches!(input, Value::Object(_)) {
126        return Err(FraiseQLError::Validation {
127            message: "Input must be an object".to_string(),
128            path:    Some(path.to_string()),
129        });
130    }
131
132    for rule in rules {
133        if let Err(FraiseQLError::Validation { message, .. }) = validate_rule(input, rule, path) {
134            result.add_error(message);
135        }
136    }
137
138    result.into_result_with_path(path)
139}
140
141/// Validate a single input object rule.
142fn validate_rule(input: &Value, rule: &InputObjectRule, path: &str) -> Result<()> {
143    match rule {
144        InputObjectRule::AnyOf { fields } => validate_any_of(input, fields, path),
145        InputObjectRule::OneOf { fields } => validate_one_of(input, fields, path),
146        InputObjectRule::ConditionalRequired {
147            if_field,
148            then_fields,
149        } => validate_conditional_required(input, if_field, then_fields, path),
150        InputObjectRule::RequiredIfAbsent {
151            absent_field,
152            then_fields,
153        } => validate_required_if_absent(input, absent_field, then_fields, path),
154        InputObjectRule::Custom { name } => Err(FraiseQLError::Validation {
155            message: format!("Custom validator '{}' not implemented", name),
156            path:    Some(path.to_string()),
157        }),
158    }
159}
160
161/// Validate that at least one field from the set is present.
162fn validate_any_of(input: &Value, fields: &[String], path: &str) -> Result<()> {
163    if let Value::Object(obj) = input {
164        let has_any = fields
165            .iter()
166            .any(|name| obj.get(name).map(|v| !matches!(v, Value::Null)).unwrap_or(false));
167
168        if !has_any {
169            return Err(FraiseQLError::Validation {
170                message: format!("At least one of [{}] must be provided", fields.join(", ")),
171                path:    Some(path.to_string()),
172            });
173        }
174    }
175
176    Ok(())
177}
178
179/// Validate that exactly one field from the set is present.
180fn validate_one_of(input: &Value, fields: &[String], path: &str) -> Result<()> {
181    if let Value::Object(obj) = input {
182        let present_count = fields
183            .iter()
184            .filter(|name| obj.get(*name).map(|v| !matches!(v, Value::Null)).unwrap_or(false))
185            .count();
186
187        if present_count != 1 {
188            return Err(FraiseQLError::Validation {
189                message: format!(
190                    "Exactly one of [{}] must be provided, but {} {} provided",
191                    fields.join(", "),
192                    present_count,
193                    if present_count == 1 { "was" } else { "were" }
194                ),
195                path:    Some(path.to_string()),
196            });
197        }
198    }
199
200    Ok(())
201}
202
203/// Validate conditional requirement: if one field is present, others must be too.
204fn validate_conditional_required(
205    input: &Value,
206    if_field: &str,
207    then_fields: &[String],
208    path: &str,
209) -> Result<()> {
210    if let Value::Object(obj) = input {
211        let condition_met = obj.get(if_field).map(|v| !matches!(v, Value::Null)).unwrap_or(false);
212
213        if condition_met {
214            let missing_fields: Vec<&String> = then_fields
215                .iter()
216                .filter(|name| obj.get(*name).map(|v| matches!(v, Value::Null)).unwrap_or(true))
217                .collect();
218
219            if !missing_fields.is_empty() {
220                return Err(FraiseQLError::Validation {
221                    message: format!(
222                        "Since '{}' is provided, {} must also be provided",
223                        if_field,
224                        missing_fields
225                            .iter()
226                            .map(|s| format!("'{}'", s))
227                            .collect::<Vec<_>>()
228                            .join(", ")
229                    ),
230                    path:    Some(path.to_string()),
231                });
232            }
233        }
234    }
235
236    Ok(())
237}
238
239/// Validate requirement based on absence: if one field is missing, others must be provided.
240fn validate_required_if_absent(
241    input: &Value,
242    absent_field: &str,
243    then_fields: &[String],
244    path: &str,
245) -> Result<()> {
246    if let Value::Object(obj) = input {
247        let field_absent = obj.get(absent_field).map(|v| matches!(v, Value::Null)).unwrap_or(true);
248
249        if field_absent {
250            let missing_fields: Vec<&String> = then_fields
251                .iter()
252                .filter(|name| obj.get(*name).map(|v| matches!(v, Value::Null)).unwrap_or(true))
253                .collect();
254
255            if !missing_fields.is_empty() {
256                return Err(FraiseQLError::Validation {
257                    message: format!(
258                        "Since '{}' is not provided, {} must be provided",
259                        absent_field,
260                        missing_fields
261                            .iter()
262                            .map(|s| format!("'{}'", s))
263                            .collect::<Vec<_>>()
264                            .join(", ")
265                    ),
266                    path:    Some(path.to_string()),
267                });
268            }
269        }
270    }
271
272    Ok(())
273}
274
275#[cfg(test)]
276mod tests {
277    use serde_json::json;
278
279    use super::*;
280
281    #[test]
282    fn test_any_of_passes() {
283        let input = json!({
284            "email": "user@example.com",
285            "phone": null,
286            "address": null
287        });
288        let rules = vec![InputObjectRule::AnyOf {
289            fields: vec![
290                "email".to_string(),
291                "phone".to_string(),
292                "address".to_string(),
293            ],
294        }];
295        let result = validate_input_object(&input, &rules, None);
296        assert!(result.is_ok());
297    }
298
299    #[test]
300    fn test_any_of_fails() {
301        let input = json!({
302            "email": null,
303            "phone": null,
304            "address": null
305        });
306        let rules = vec![InputObjectRule::AnyOf {
307            fields: vec![
308                "email".to_string(),
309                "phone".to_string(),
310                "address".to_string(),
311            ],
312        }];
313        let result = validate_input_object(&input, &rules, None);
314        assert!(result.is_err());
315    }
316
317    #[test]
318    fn test_one_of_passes() {
319        let input = json!({
320            "entityId": "123",
321            "entityPayload": null
322        });
323        let rules = vec![InputObjectRule::OneOf {
324            fields: vec!["entityId".to_string(), "entityPayload".to_string()],
325        }];
326        let result = validate_input_object(&input, &rules, None);
327        assert!(result.is_ok());
328    }
329
330    #[test]
331    fn test_one_of_fails_both_present() {
332        let input = json!({
333            "entityId": "123",
334            "entityPayload": { "name": "test" }
335        });
336        let rules = vec![InputObjectRule::OneOf {
337            fields: vec!["entityId".to_string(), "entityPayload".to_string()],
338        }];
339        let result = validate_input_object(&input, &rules, None);
340        assert!(result.is_err());
341    }
342
343    #[test]
344    fn test_one_of_fails_neither_present() {
345        let input = json!({
346            "entityId": null,
347            "entityPayload": null
348        });
349        let rules = vec![InputObjectRule::OneOf {
350            fields: vec!["entityId".to_string(), "entityPayload".to_string()],
351        }];
352        let result = validate_input_object(&input, &rules, None);
353        assert!(result.is_err());
354    }
355
356    #[test]
357    fn test_conditional_required_passes() {
358        let input = json!({
359            "isPremium": true,
360            "paymentMethod": "credit_card"
361        });
362        let rules = vec![InputObjectRule::ConditionalRequired {
363            if_field:    "isPremium".to_string(),
364            then_fields: vec!["paymentMethod".to_string()],
365        }];
366        let result = validate_input_object(&input, &rules, None);
367        assert!(result.is_ok());
368    }
369
370    #[test]
371    fn test_conditional_required_fails() {
372        let input = json!({
373            "isPremium": true,
374            "paymentMethod": null
375        });
376        let rules = vec![InputObjectRule::ConditionalRequired {
377            if_field:    "isPremium".to_string(),
378            then_fields: vec!["paymentMethod".to_string()],
379        }];
380        let result = validate_input_object(&input, &rules, None);
381        assert!(result.is_err());
382    }
383
384    #[test]
385    fn test_conditional_required_skips_when_condition_false() {
386        let input = json!({
387            "isPremium": null,
388            "paymentMethod": null
389        });
390        let rules = vec![InputObjectRule::ConditionalRequired {
391            if_field:    "isPremium".to_string(),
392            then_fields: vec!["paymentMethod".to_string()],
393        }];
394        let result = validate_input_object(&input, &rules, None);
395        assert!(result.is_ok());
396    }
397
398    #[test]
399    fn test_required_if_absent_passes() {
400        let input = json!({
401            "addressId": null,
402            "street": "123 Main St",
403            "city": "Springfield",
404            "zip": "12345"
405        });
406        let rules = vec![InputObjectRule::RequiredIfAbsent {
407            absent_field: "addressId".to_string(),
408            then_fields:  vec!["street".to_string(), "city".to_string(), "zip".to_string()],
409        }];
410        let result = validate_input_object(&input, &rules, None);
411        assert!(result.is_ok());
412    }
413
414    #[test]
415    fn test_required_if_absent_fails() {
416        let input = json!({
417            "addressId": null,
418            "street": "123 Main St",
419            "city": null,
420            "zip": "12345"
421        });
422        let rules = vec![InputObjectRule::RequiredIfAbsent {
423            absent_field: "addressId".to_string(),
424            then_fields:  vec!["street".to_string(), "city".to_string(), "zip".to_string()],
425        }];
426        let result = validate_input_object(&input, &rules, None);
427        assert!(result.is_err());
428    }
429
430    #[test]
431    fn test_required_if_absent_skips_when_field_present() {
432        let input = json!({
433            "addressId": "addr_123",
434            "street": null,
435            "city": null,
436            "zip": null
437        });
438        let rules = vec![InputObjectRule::RequiredIfAbsent {
439            absent_field: "addressId".to_string(),
440            then_fields:  vec!["street".to_string(), "city".to_string(), "zip".to_string()],
441        }];
442        let result = validate_input_object(&input, &rules, None);
443        assert!(result.is_ok());
444    }
445
446    #[test]
447    fn test_multiple_rules_all_pass() {
448        let input = json!({
449            "entityId": "123",
450            "entityPayload": null,
451            "isPremium": true,
452            "paymentMethod": "credit_card"
453        });
454        let rules = vec![
455            InputObjectRule::OneOf {
456                fields: vec!["entityId".to_string(), "entityPayload".to_string()],
457            },
458            InputObjectRule::ConditionalRequired {
459                if_field:    "isPremium".to_string(),
460                then_fields: vec!["paymentMethod".to_string()],
461            },
462        ];
463        let result = validate_input_object(&input, &rules, None);
464        assert!(result.is_ok());
465    }
466
467    #[test]
468    fn test_multiple_rules_one_fails() {
469        let input = json!({
470            "entityId": "123",
471            "entityPayload": null,
472            "isPremium": true,
473            "paymentMethod": null
474        });
475        let rules = vec![
476            InputObjectRule::OneOf {
477                fields: vec!["entityId".to_string(), "entityPayload".to_string()],
478            },
479            InputObjectRule::ConditionalRequired {
480                if_field:    "isPremium".to_string(),
481                then_fields: vec!["paymentMethod".to_string()],
482            },
483        ];
484        let result = validate_input_object(&input, &rules, None);
485        assert!(result.is_err());
486    }
487
488    #[test]
489    fn test_multiple_rules_both_fail() {
490        let input = json!({
491            "entityId": "123",
492            "entityPayload": { "name": "test" },
493            "isPremium": true,
494            "paymentMethod": null
495        });
496        let rules = vec![
497            InputObjectRule::OneOf {
498                fields: vec!["entityId".to_string(), "entityPayload".to_string()],
499            },
500            InputObjectRule::ConditionalRequired {
501                if_field:    "isPremium".to_string(),
502                then_fields: vec!["paymentMethod".to_string()],
503            },
504        ];
505        let result = validate_input_object(&input, &rules, None);
506        assert!(result.is_err());
507        if let Err(FraiseQLError::Validation { message, .. }) = result {
508            // Should have both error messages aggregated
509            assert!(message.contains("Exactly one") || message.contains("must also be provided"));
510        }
511    }
512
513    #[test]
514    fn test_error_aggregation() {
515        let input = json!({
516            "entityId": "123",
517            "entityPayload": { "name": "test" },
518            "isPremium": true,
519            "paymentMethod": null
520        });
521        let rules = vec![
522            InputObjectRule::OneOf {
523                fields: vec!["entityId".to_string(), "entityPayload".to_string()],
524            },
525            InputObjectRule::ConditionalRequired {
526                if_field:    "isPremium".to_string(),
527                then_fields: vec!["paymentMethod".to_string()],
528            },
529        ];
530
531        let result = validate_input_object(&input, &rules, Some("createInput"));
532        assert!(result.is_err());
533        if let Err(FraiseQLError::Validation { message, path }) = result {
534            assert_eq!(path, Some("createInput".to_string()));
535            assert!(message.contains("failed"));
536        }
537    }
538
539    #[test]
540    fn test_conditional_required_multiple_fields() {
541        let input = json!({
542            "isInternational": true,
543            "customsCode": "ABC123",
544            "importDuties": "50.00"
545        });
546        let rules = vec![InputObjectRule::ConditionalRequired {
547            if_field:    "isInternational".to_string(),
548            then_fields: vec!["customsCode".to_string(), "importDuties".to_string()],
549        }];
550        let result = validate_input_object(&input, &rules, None);
551        assert!(result.is_ok());
552    }
553
554    #[test]
555    fn test_conditional_required_multiple_fields_one_missing() {
556        let input = json!({
557            "isInternational": true,
558            "customsCode": "ABC123",
559            "importDuties": null
560        });
561        let rules = vec![InputObjectRule::ConditionalRequired {
562            if_field:    "isInternational".to_string(),
563            then_fields: vec!["customsCode".to_string(), "importDuties".to_string()],
564        }];
565        let result = validate_input_object(&input, &rules, None);
566        assert!(result.is_err());
567    }
568
569    #[test]
570    fn test_validation_result_aggregation() {
571        let mut result = InputObjectValidationResult::new();
572        assert!(!result.has_errors());
573        assert_eq!(result.error_count, 0);
574
575        result.add_error("Error 1".to_string());
576        assert!(result.has_errors());
577        assert_eq!(result.error_count, 1);
578
579        result.add_errors(vec!["Error 2".to_string(), "Error 3".to_string()]);
580        assert_eq!(result.error_count, 3);
581    }
582
583    #[test]
584    fn test_validation_result_into_result_success() {
585        let result = InputObjectValidationResult::new();
586        assert!(result.into_result().is_ok());
587    }
588
589    #[test]
590    fn test_validation_result_into_result_failure() {
591        let mut result = InputObjectValidationResult::new();
592        result.add_error("Test error".to_string());
593        assert!(result.into_result().is_err());
594    }
595
596    #[test]
597    fn test_non_object_input() {
598        let input = json!([1, 2, 3]);
599        let rules = vec![InputObjectRule::AnyOf {
600            fields: vec!["field".to_string()],
601        }];
602        let result = validate_input_object(&input, &rules, None);
603        assert!(result.is_err());
604    }
605
606    #[test]
607    fn test_empty_rules() {
608        let input = json!({"field": "value"});
609        let rules: Vec<InputObjectRule> = vec![];
610        let result = validate_input_object(&input, &rules, None);
611        assert!(result.is_ok());
612    }
613
614    #[test]
615    fn test_custom_validator_not_implemented() {
616        let input = json!({"field": "value"});
617        let rules = vec![InputObjectRule::Custom {
618            name: "myValidator".to_string(),
619        }];
620        let result = validate_input_object(&input, &rules, None);
621        assert!(result.is_err());
622        if let Err(FraiseQLError::Validation { message, .. }) = result {
623            assert!(message.contains("not implemented"));
624        }
625    }
626
627    #[test]
628    fn test_complex_create_or_reference_pattern() {
629        // Either provide entityId OR provide (name + description), but not both
630        let input = json!({
631            "entityId": "123",
632            "name": null,
633            "description": null
634        });
635        let rules = vec![InputObjectRule::OneOf {
636            fields: vec!["entityId".to_string(), "name".to_string()],
637        }];
638        let result = validate_input_object(&input, &rules, None);
639        assert!(result.is_ok());
640    }
641
642    #[test]
643    fn test_complex_address_pattern() {
644        // Either provide addressId OR provide all of (street, city, zip)
645        let input = json!({
646            "addressId": null,
647            "street": "123 Main St",
648            "city": "Springfield",
649            "zip": "12345"
650        });
651        let rules = vec![InputObjectRule::RequiredIfAbsent {
652            absent_field: "addressId".to_string(),
653            then_fields:  vec!["street".to_string(), "city".to_string(), "zip".to_string()],
654        }];
655        let result = validate_input_object(&input, &rules, None);
656        assert!(result.is_ok());
657    }
658}