rustrails-model 0.1.2

Model layer (ActiveModel equivalent)
Documentation
use std::fmt;

use serde_json::Value;

use super::{Validator, ValidatorOptions};
use crate::errors::Errors;

type CustomValidateFn = dyn Fn(&str, Option<&Value>, &mut Errors) + Send + Sync;

/// Validates an attribute through a caller-provided function.
pub struct CustomValidator {
    /// Function invoked during validation.
    pub validate_fn: Box<CustomValidateFn>,
    pub(crate) options: ValidatorOptions,
}

impl fmt::Debug for CustomValidator {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("CustomValidator")
            .field("options", &self.options)
            .finish()
    }
}

impl CustomValidator {
    /// Creates a new custom validator from `validate_fn`.
    #[must_use]
    pub fn new<F>(validate_fn: F) -> Self
    where
        F: Fn(&str, Option<&Value>, &mut Errors) + Send + Sync + 'static,
    {
        Self {
            validate_fn: Box::new(validate_fn),
            options: ValidatorOptions::default(),
        }
    }

    crate::validations::impl_common_validator_methods!();
}

impl Validator for CustomValidator {
    fn validate(&self, attribute: &str, value: Option<&Value>, errors: &mut Errors) {
        (self.validate_fn)(attribute, value, errors);
    }

    fn name(&self) -> &str {
        "custom"
    }

    fn options(&self) -> &ValidatorOptions {
        &self.options
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;
    use std::sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
    };

    use serde_json::json;

    use super::CustomValidator;
    use crate::{
        errors::{ErrorType, Errors},
        validations::{ValidationContext, ValidationSet, Validator},
    };

    #[test]
    fn invokes_user_closure() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let validator = CustomValidator::new(move |_attribute, _value, _errors| {
            called_clone.store(true, Ordering::Relaxed);
        });
        let mut errors = Errors::new();

        validator.validate("name", Some(&json!("Alice")), &mut errors);

        assert!(called.load(Ordering::Relaxed));
    }

    #[test]
    fn closure_can_add_errors() {
        let validator = CustomValidator::new(|attribute, _value, errors| {
            errors.add(
                attribute,
                ErrorType::Custom(String::from("custom")),
                "failed",
            );
        });
        let mut errors = Errors::new();

        validator.validate("name", Some(&json!("Alice")), &mut errors);

        assert_eq!(errors.on("name")[0].message, "failed");
    }

    #[test]
    fn validation_set_can_skip_custom_validator_via_options() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let validator = CustomValidator::new(move |_attribute, _value, _errors| {
            called_clone.store(true, Ordering::Relaxed);
        })
        .allow_nil();
        let mut set = ValidationSet::new();
        set.add("nickname", validator);
        let mut errors = Errors::new();

        let _ = set.validate(&|_| None, &mut errors);

        assert!(errors.is_empty());
        assert!(!called.load(Ordering::Relaxed));
    }

    #[test]
    fn closure_receives_attribute_name() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let validator = CustomValidator::new(move |attribute, _value, _errors| {
            called_clone.store(attribute == "email", Ordering::Relaxed);
        });
        let mut errors = Errors::new();

        validator.validate("email", Some(&json!("alice@example.com")), &mut errors);

        assert!(called.load(Ordering::Relaxed));
    }

    #[test]
    fn closure_receives_missing_values() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let validator = CustomValidator::new(move |_attribute, value, _errors| {
            called_clone.store(value.is_none(), Ordering::Relaxed);
        });
        let mut errors = Errors::new();

        validator.validate("email", None, &mut errors);

        assert!(called.load(Ordering::Relaxed));
    }

    #[test]
    fn allow_blank_skips_blank_values_in_validation_set() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let mut set = ValidationSet::new();
        set.add(
            "nickname",
            CustomValidator::new(move |_attribute, _value, _errors| {
                called_clone.store(true, Ordering::Relaxed);
            })
            .allow_blank(),
        );
        let attrs = HashMap::from([("nickname".to_string(), json!("   "))]);
        let mut errors = Errors::new();

        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);

        assert!(errors.is_empty());
        assert!(!called.load(Ordering::Relaxed));
    }

    #[test]
    fn on_context_runs_only_when_matching() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let mut set = ValidationSet::new();
        set.add(
            "name",
            CustomValidator::new(move |_attribute, _value, _errors| {
                called_clone.store(true, Ordering::Relaxed);
            })
            .on(vec![ValidationContext::Update]),
        );
        let attrs = HashMap::from([("name".to_string(), json!("Alice"))]);
        let mut errors = Errors::new();

        let _ = set.validate_with_context(
            &|name| attrs.get(name).cloned(),
            &mut errors,
            &ValidationContext::Create,
        );
        assert!(!called.load(Ordering::Relaxed));

        let _ = set.validate_with_context(
            &|name| attrs.get(name).cloned(),
            &mut errors,
            &ValidationContext::Update,
        );
        assert!(called.load(Ordering::Relaxed));
    }

    #[test]
    fn if_condition_false_skips_custom_validator() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let mut set = ValidationSet::new();
        set.add(
            "age",
            CustomValidator::new(move |_attribute, _value, _errors| {
                called_clone.store(true, Ordering::Relaxed);
            })
            .if_cond(|value| value == &json!(21)),
        );
        let attrs = HashMap::from([("age".to_string(), json!(18))]);
        let mut errors = Errors::new();

        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);

        assert!(!called.load(Ordering::Relaxed));
    }

    #[test]
    fn unless_condition_true_skips_custom_validator() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let mut set = ValidationSet::new();
        set.add(
            "age",
            CustomValidator::new(move |_attribute, _value, _errors| {
                called_clone.store(true, Ordering::Relaxed);
            })
            .unless_cond(|value| value == &json!(18)),
        );
        let attrs = HashMap::from([("age".to_string(), json!(18))]);
        let mut errors = Errors::new();

        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);

        assert!(!called.load(Ordering::Relaxed));
    }

    #[test]
    fn allow_nil_does_not_skip_present_values() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let mut set = ValidationSet::new();
        set.add(
            "name",
            CustomValidator::new(move |_attribute, _value, _errors| {
                called_clone.store(true, Ordering::Relaxed);
            })
            .allow_nil(),
        );
        let attrs = HashMap::from([("name".to_string(), json!("Alice"))]);
        let mut errors = Errors::new();

        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);

        assert!(called.load(Ordering::Relaxed));
    }

    #[test]
    fn custom_errors_full_message_humanizes_attribute_name() {
        let validator = CustomValidator::new(|attribute, _value, errors| {
            errors.add(
                attribute,
                ErrorType::Custom(String::from("state")),
                "is unavailable",
            );
        });
        let mut errors = Errors::new();

        validator.validate("display_name", Some(&json!("Alice")), &mut errors);

        assert_eq!(
            errors.full_messages(),
            vec!["Display name is unavailable".to_string()]
        );
    }

    #[test]
    fn debug_output_hides_closure_details() {
        let debug = format!("{:?}", CustomValidator::new(|_, _, _| {}));

        assert!(debug.contains("CustomValidator"));
        assert!(!debug.contains("validate_fn"));
    }

    #[test]
    fn validation_set_passes_value_through_to_closure() {
        let called = Arc::new(AtomicBool::new(false));
        let called_clone = Arc::clone(&called);
        let mut set = ValidationSet::new();
        set.add(
            "name",
            CustomValidator::new(move |_attribute, value, _errors| {
                called_clone.store(value == Some(&json!("Alice")), Ordering::Relaxed);
            }),
        );
        let attrs = HashMap::from([("name".to_string(), json!("Alice"))]);
        let mut errors = Errors::new();

        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);

        assert!(called.load(Ordering::Relaxed));
    }
}