use serde_json::Value;
use crate::error::{FraiseQLError, Result};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum InputObjectRule {
AnyOf {
fields: Vec<String>,
},
OneOf {
fields: Vec<String>,
},
ConditionalRequired {
if_field: String,
then_fields: Vec<String>,
},
RequiredIfAbsent {
absent_field: String,
then_fields: Vec<String>,
},
Custom {
name: String,
},
}
#[derive(Debug, Clone, Default)]
pub struct InputObjectValidationResult {
pub errors: Vec<String>,
pub error_count: usize,
}
impl InputObjectValidationResult {
pub const fn new() -> Self {
Self {
errors: Vec::new(),
error_count: 0,
}
}
pub fn add_error(&mut self, error: String) {
self.errors.push(error);
self.error_count += 1;
}
pub fn add_errors(&mut self, errors: Vec<String>) {
self.error_count += errors.len();
self.errors.extend(errors);
}
pub const fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
pub fn into_result(self) -> Result<()> {
self.into_result_with_path("input")
}
pub fn into_result_with_path(self, path: &str) -> Result<()> {
if self.has_errors() {
Err(FraiseQLError::Validation {
message: format!("Input object validation failed: {}", self.errors.join("; ")),
path: Some(path.to_string()),
})
} else {
Ok(())
}
}
}
pub fn validate_input_object(
input: &Value,
rules: &[InputObjectRule],
object_path: Option<&str>,
) -> Result<()> {
let mut result = InputObjectValidationResult::new();
let path = object_path.unwrap_or("input");
if !matches!(input, Value::Object(_)) {
return Err(FraiseQLError::Validation {
message: "Input must be an object".to_string(),
path: Some(path.to_string()),
});
}
for rule in rules {
if let Err(FraiseQLError::Validation { message, .. }) = validate_rule(input, rule, path) {
result.add_error(message);
}
}
result.into_result_with_path(path)
}
fn validate_rule(input: &Value, rule: &InputObjectRule, path: &str) -> Result<()> {
match rule {
InputObjectRule::AnyOf { fields } => validate_any_of(input, fields, path),
InputObjectRule::OneOf { fields } => validate_one_of(input, fields, path),
InputObjectRule::ConditionalRequired {
if_field,
then_fields,
} => validate_conditional_required(input, if_field, then_fields, path),
InputObjectRule::RequiredIfAbsent {
absent_field,
then_fields,
} => validate_required_if_absent(input, absent_field, then_fields, path),
InputObjectRule::Custom { name } => Err(FraiseQLError::Validation {
message: format!(
"Custom validator '{name}' is not registered. \
Register validators via InputValidatorRegistry before executing queries."
),
path: Some(path.to_string()),
}),
}
}
fn validate_any_of(input: &Value, fields: &[String], path: &str) -> Result<()> {
if let Value::Object(obj) = input {
let has_any = fields
.iter()
.any(|name| obj.get(name).is_some_and(|v| !matches!(v, Value::Null)));
if !has_any {
return Err(FraiseQLError::Validation {
message: format!("At least one of [{}] must be provided", fields.join(", ")),
path: Some(path.to_string()),
});
}
}
Ok(())
}
fn validate_one_of(input: &Value, fields: &[String], path: &str) -> Result<()> {
if let Value::Object(obj) = input {
let present_count = fields
.iter()
.filter(|name| obj.get(*name).is_some_and(|v| !matches!(v, Value::Null)))
.count();
if present_count != 1 {
return Err(FraiseQLError::Validation {
message: format!(
"Exactly one of [{}] must be provided, but {} {} provided",
fields.join(", "),
present_count,
if present_count == 1 { "was" } else { "were" }
),
path: Some(path.to_string()),
});
}
}
Ok(())
}
fn validate_conditional_required(
input: &Value,
if_field: &str,
then_fields: &[String],
path: &str,
) -> Result<()> {
if let Value::Object(obj) = input {
let condition_met = obj.get(if_field).is_some_and(|v| !matches!(v, Value::Null));
if condition_met {
let missing_fields: Vec<&String> = then_fields
.iter()
.filter(|name| obj.get(*name).is_none_or(|v| matches!(v, Value::Null)))
.collect();
if !missing_fields.is_empty() {
return Err(FraiseQLError::Validation {
message: format!(
"Since '{}' is provided, {} must also be provided",
if_field,
missing_fields
.iter()
.map(|s| format!("'{}'", s))
.collect::<Vec<_>>()
.join(", ")
),
path: Some(path.to_string()),
});
}
}
}
Ok(())
}
fn validate_required_if_absent(
input: &Value,
absent_field: &str,
then_fields: &[String],
path: &str,
) -> Result<()> {
if let Value::Object(obj) = input {
let field_absent = obj.get(absent_field).is_none_or(|v| matches!(v, Value::Null));
if field_absent {
let missing_fields: Vec<&String> = then_fields
.iter()
.filter(|name| obj.get(*name).is_none_or(|v| matches!(v, Value::Null)))
.collect();
if !missing_fields.is_empty() {
return Err(FraiseQLError::Validation {
message: format!(
"Since '{}' is not provided, {} must be provided",
absent_field,
missing_fields
.iter()
.map(|s| format!("'{}'", s))
.collect::<Vec<_>>()
.join(", ")
),
path: Some(path.to_string()),
});
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use serde_json::json;
use super::*;
#[test]
fn test_any_of_passes() {
let input = json!({
"email": "user@example.com",
"phone": null,
"address": null
});
let rules = vec![InputObjectRule::AnyOf {
fields: vec![
"email".to_string(),
"phone".to_string(),
"address".to_string(),
],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| panic!("any_of should pass when email is present: {e}"));
}
#[test]
fn test_any_of_fails() {
let input = json!({
"email": null,
"phone": null,
"address": null
});
let rules = vec![InputObjectRule::AnyOf {
fields: vec![
"email".to_string(),
"phone".to_string(),
"address".to_string(),
],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("At least one of")),
"expected Validation error about missing fields, got: {result:?}"
);
}
#[test]
fn test_one_of_passes() {
let input = json!({
"entityId": "123",
"entityPayload": null
});
let rules = vec![InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("one_of should pass when exactly one field is present: {e}")
});
}
#[test]
fn test_one_of_fails_both_present() {
let input = json!({
"entityId": "123",
"entityPayload": { "name": "test" }
});
let rules = vec![InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Exactly one of")),
"expected Validation error about exactly one field, got: {result:?}"
);
}
#[test]
fn test_one_of_fails_neither_present() {
let input = json!({
"entityId": null,
"entityPayload": null
});
let rules = vec![InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Exactly one of")),
"expected Validation error about exactly one field, got: {result:?}"
);
}
#[test]
fn test_conditional_required_passes() {
let input = json!({
"isPremium": true,
"paymentMethod": "credit_card"
});
let rules = vec![InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("conditional_required should pass when condition is met: {e}")
});
}
#[test]
fn test_conditional_required_fails() {
let input = json!({
"isPremium": true,
"paymentMethod": null
});
let rules = vec![InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must also be provided")),
"expected Validation error about missing conditional fields, got: {result:?}"
);
}
#[test]
fn test_conditional_required_skips_when_condition_false() {
let input = json!({
"isPremium": null,
"paymentMethod": null
});
let rules = vec![InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("conditional_required should skip when condition field is null: {e}")
});
}
#[test]
fn test_required_if_absent_passes() {
let input = json!({
"addressId": null,
"street": "123 Main St",
"city": "Springfield",
"zip": "12345"
});
let rules = vec![InputObjectRule::RequiredIfAbsent {
absent_field: "addressId".to_string(),
then_fields: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("required_if_absent should pass when all then_fields are provided: {e}")
});
}
#[test]
fn test_required_if_absent_fails() {
let input = json!({
"addressId": null,
"street": "123 Main St",
"city": null,
"zip": "12345"
});
let rules = vec![InputObjectRule::RequiredIfAbsent {
absent_field: "addressId".to_string(),
then_fields: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be provided")),
"expected Validation error about missing required fields, got: {result:?}"
);
}
#[test]
fn test_required_if_absent_skips_when_field_present() {
let input = json!({
"addressId": "addr_123",
"street": null,
"city": null,
"zip": null
});
let rules = vec![InputObjectRule::RequiredIfAbsent {
absent_field: "addressId".to_string(),
then_fields: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("required_if_absent should skip when absent_field is present: {e}")
});
}
#[test]
fn test_multiple_rules_all_pass() {
let input = json!({
"entityId": "123",
"entityPayload": null,
"isPremium": true,
"paymentMethod": "credit_card"
});
let rules = vec![
InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
},
InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
},
];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| panic!("multiple rules should all pass: {e}"));
}
#[test]
fn test_multiple_rules_one_fails() {
let input = json!({
"entityId": "123",
"entityPayload": null,
"isPremium": true,
"paymentMethod": null
});
let rules = vec![
InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
},
InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
},
];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error when one rule fails, got: {result:?}"
);
}
#[test]
fn test_multiple_rules_both_fail() {
let input = json!({
"entityId": "123",
"entityPayload": { "name": "test" },
"isPremium": true,
"paymentMethod": null
});
let rules = vec![
InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
},
InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
},
];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. })
if message.contains("Exactly one") || message.contains("must also be provided")),
"expected aggregated Validation error with both failures, got: {result:?}"
);
}
#[test]
fn test_error_aggregation() {
let input = json!({
"entityId": "123",
"entityPayload": { "name": "test" },
"isPremium": true,
"paymentMethod": null
});
let rules = vec![
InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
},
InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
},
];
let result = validate_input_object(&input, &rules, Some("createInput"));
match result {
Err(FraiseQLError::Validation {
ref message,
ref path,
}) => {
assert_eq!(*path, Some("createInput".to_string()));
assert!(message.contains("failed"), "expected 'failed' in message, got: {message}");
},
other => panic!("expected Validation error with custom path, got: {other:?}"),
}
}
#[test]
fn test_conditional_required_multiple_fields() {
let input = json!({
"isInternational": true,
"customsCode": "ABC123",
"importDuties": "50.00"
});
let rules = vec![InputObjectRule::ConditionalRequired {
if_field: "isInternational".to_string(),
then_fields: vec!["customsCode".to_string(), "importDuties".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("conditional_required with multiple fields should pass: {e}")
});
}
#[test]
fn test_conditional_required_multiple_fields_one_missing() {
let input = json!({
"isInternational": true,
"customsCode": "ABC123",
"importDuties": null
});
let rules = vec![InputObjectRule::ConditionalRequired {
if_field: "isInternational".to_string(),
then_fields: vec!["customsCode".to_string(), "importDuties".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must also be provided")),
"expected Validation error about missing conditional field, got: {result:?}"
);
}
#[test]
fn test_validation_result_aggregation() {
let mut result = InputObjectValidationResult::new();
assert!(!result.has_errors());
assert_eq!(result.error_count, 0);
result.add_error("Error 1".to_string());
assert!(result.has_errors());
assert_eq!(result.error_count, 1);
result.add_errors(vec!["Error 2".to_string(), "Error 3".to_string()]);
assert_eq!(result.error_count, 3);
}
#[test]
fn test_validation_result_into_result_success() {
let result = InputObjectValidationResult::new();
result
.into_result()
.unwrap_or_else(|e| panic!("empty result should be Ok: {e}"));
}
#[test]
fn test_validation_result_into_result_failure() {
let mut result = InputObjectValidationResult::new();
result.add_error("Test error".to_string());
let outcome = result.into_result();
assert!(
matches!(outcome, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Test error")),
"expected Validation error containing 'Test error', got: {outcome:?}"
);
}
#[test]
fn test_non_object_input() {
let input = json!([1, 2, 3]);
let rules = vec![InputObjectRule::AnyOf {
fields: vec!["field".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be an object")),
"expected Validation error about non-object input, got: {result:?}"
);
}
#[test]
fn test_empty_rules() {
let input = json!({"field": "value"});
let rules: Vec<InputObjectRule> = vec![];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| panic!("empty rules should always pass: {e}"));
}
#[test]
fn test_custom_validator_not_implemented() {
let input = json!({"field": "value"});
let rules = vec![InputObjectRule::Custom {
name: "myValidator".to_string(),
}];
let result = validate_input_object(&input, &rules, None);
match result {
Err(FraiseQLError::Validation { ref message, .. }) => {
assert!(
message.contains("myValidator"),
"expected 'myValidator' in message, got: {message}"
);
assert!(
message.contains("InputValidatorRegistry"),
"expected 'InputValidatorRegistry' in message, got: {message}"
);
},
other => {
panic!("expected Validation error about unregistered validator, got: {other:?}")
},
}
}
#[test]
fn test_complex_create_or_reference_pattern() {
let input = json!({
"entityId": "123",
"name": null,
"description": null
});
let rules = vec![InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "name".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("create_or_reference pattern should pass with entityId: {e}")
});
}
#[test]
fn test_complex_address_pattern() {
let input = json!({
"addressId": null,
"street": "123 Main St",
"city": "Springfield",
"zip": "12345"
});
let rules = vec![InputObjectRule::RequiredIfAbsent {
absent_field: "addressId".to_string(),
then_fields: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("address pattern should pass with all fields provided: {e}")
});
}
}