Skip to main content

fraiseql_core/validation/
async_validators.rs

1//! Async validation framework for validators requiring runtime operations.
2//!
3//! This module provides traits and helpers for validators that need to perform
4//! asynchronous operations like network requests or database lookups.
5
6use std::time::Duration;
7
8use crate::error::{FraiseQLError, Result};
9
10/// Async validator result type.
11pub type AsyncValidatorResult = Result<()>;
12
13/// Provider types for async validators.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15pub enum AsyncValidatorProvider {
16    /// Email domain MX record verification
17    EmailDomainCheck,
18    /// Phone number validation via provider (e.g., Twilio)
19    PhoneNumberValidation,
20    /// IBAN/VIN checksum validation
21    ChecksumValidation,
22    /// Custom provider
23    Custom(String),
24}
25
26impl AsyncValidatorProvider {
27    /// Get provider name for logging/debugging
28    pub fn name(&self) -> String {
29        match self {
30            Self::EmailDomainCheck => "email_domain_check".to_string(),
31            Self::PhoneNumberValidation => "phone_validation".to_string(),
32            Self::ChecksumValidation => "checksum_validation".to_string(),
33            Self::Custom(name) => name.clone(),
34        }
35    }
36}
37
38impl std::fmt::Display for AsyncValidatorProvider {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}", self.name())
41    }
42}
43
44/// Configuration for an async validator.
45#[derive(Debug, Clone)]
46pub struct AsyncValidatorConfig {
47    /// The provider to use
48    pub provider:       AsyncValidatorProvider,
49    /// Timeout duration for the validation operation
50    pub timeout:        Duration,
51    /// Cache TTL in seconds (0 = no caching)
52    pub cache_ttl_secs: u64,
53    /// Field pattern this validator applies to (e.g., "*.email")
54    pub field_pattern:  String,
55}
56
57impl AsyncValidatorConfig {
58    /// Create a new async validator configuration.
59    pub fn new(provider: AsyncValidatorProvider, timeout_ms: u64) -> Self {
60        Self {
61            provider,
62            timeout: Duration::from_millis(timeout_ms),
63            cache_ttl_secs: 0,
64            field_pattern: String::new(),
65        }
66    }
67
68    /// Set cache TTL for this validator.
69    pub fn with_cache_ttl(mut self, secs: u64) -> Self {
70        self.cache_ttl_secs = secs;
71        self
72    }
73
74    /// Set field pattern for this validator.
75    pub fn with_field_pattern(mut self, pattern: impl Into<String>) -> Self {
76        self.field_pattern = pattern.into();
77        self
78    }
79}
80
81/// Trait for async validators.
82///
83/// Implementers should handle timeout and error cases gracefully.
84#[async_trait::async_trait]
85pub trait AsyncValidator: Send + Sync {
86    /// Validate a value asynchronously.
87    ///
88    /// # Arguments
89    /// * `value` - The value to validate
90    /// * `field` - The field name (for error reporting)
91    ///
92    /// # Returns
93    /// Ok(()) if valid, Err(FraiseQLError) if invalid
94    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult;
95
96    /// Get the provider this validator uses
97    fn provider(&self) -> AsyncValidatorProvider;
98
99    /// Get the timeout for this validator
100    fn timeout(&self) -> Duration;
101}
102
103/// Mock email domain validator for testing.
104///
105/// In production, this would perform actual MX record lookups.
106pub struct MockEmailDomainValidator {
107    config:        AsyncValidatorConfig,
108    /// List of valid domains (for testing)
109    valid_domains: Vec<String>,
110}
111
112impl MockEmailDomainValidator {
113    /// Create a new mock email domain validator.
114    pub fn new(timeout_ms: u64) -> Self {
115        let config =
116            AsyncValidatorConfig::new(AsyncValidatorProvider::EmailDomainCheck, timeout_ms);
117        Self {
118            config,
119            valid_domains: vec![
120                "gmail.com".to_string(),
121                "yahoo.com".to_string(),
122                "outlook.com".to_string(),
123                "example.com".to_string(),
124            ],
125        }
126    }
127
128    /// Add a valid domain for testing.
129    pub fn add_valid_domain(&mut self, domain: impl Into<String>) {
130        self.valid_domains.push(domain.into());
131    }
132}
133
134#[async_trait::async_trait]
135impl AsyncValidator for MockEmailDomainValidator {
136    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult {
137        // Extract domain from email
138        if let Some(at_index) = value.find('@') {
139            let domain = &value[at_index + 1..];
140
141            // Simulate async operation with small delay
142            tokio::time::sleep(Duration::from_millis(10)).await;
143
144            if self.valid_domains.iter().any(|d| d.eq_ignore_ascii_case(domain)) {
145                Ok(())
146            } else {
147                Err(FraiseQLError::Validation {
148                    message: format!(
149                        "Email domain validation failed: {} (domain {} not found)",
150                        field, domain
151                    ),
152                    path:    Some(field.to_string()),
153                })
154            }
155        } else {
156            Err(FraiseQLError::Validation {
157                message: format!(
158                    "Email domain validation failed: {} (invalid email format)",
159                    field
160                ),
161                path:    Some(field.to_string()),
162            })
163        }
164    }
165
166    fn provider(&self) -> AsyncValidatorProvider {
167        self.config.provider.clone()
168    }
169
170    fn timeout(&self) -> Duration {
171        self.config.timeout
172    }
173}
174
175/// Mock phone number validator for testing.
176///
177/// In production, this would integrate with Twilio or similar service.
178pub struct MockPhoneNumberValidator {
179    config:          AsyncValidatorConfig,
180    /// List of valid country codes (for testing)
181    valid_countries: Vec<String>,
182}
183
184impl MockPhoneNumberValidator {
185    /// Create a new mock phone number validator.
186    pub fn new(timeout_ms: u64) -> Self {
187        let config =
188            AsyncValidatorConfig::new(AsyncValidatorProvider::PhoneNumberValidation, timeout_ms);
189        Self {
190            config,
191            valid_countries: vec![
192                "1".to_string(),  // US
193                "44".to_string(), // UK
194                "33".to_string(), // France
195                "49".to_string(), // Germany
196            ],
197        }
198    }
199
200    /// Add a valid country code for testing.
201    pub fn add_valid_country(&mut self, code: impl Into<String>) {
202        self.valid_countries.push(code.into());
203    }
204}
205
206#[async_trait::async_trait]
207impl AsyncValidator for MockPhoneNumberValidator {
208    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult {
209        let phone_clean = value.trim_start_matches('+');
210
211        // Simulate async operation
212        tokio::time::sleep(Duration::from_millis(10)).await;
213
214        // Check if starts with valid country code
215        let is_valid = self.valid_countries.iter().any(|cc| phone_clean.starts_with(cc));
216
217        if is_valid && phone_clean.len() >= 10 {
218            Ok(())
219        } else {
220            Err(FraiseQLError::Validation {
221                message: format!(
222                    "Phone number validation failed: {} (invalid phone number)",
223                    field
224                ),
225                path:    Some(field.to_string()),
226            })
227        }
228    }
229
230    fn provider(&self) -> AsyncValidatorProvider {
231        self.config.provider.clone()
232    }
233
234    fn timeout(&self) -> Duration {
235        self.config.timeout
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[tokio::test]
244    async fn test_mock_email_domain_valid() {
245        let validator = MockEmailDomainValidator::new(5000);
246        let result = validator.validate_async("user@gmail.com", "email").await;
247        assert!(result.is_ok());
248    }
249
250    #[tokio::test]
251    async fn test_mock_email_domain_invalid() {
252        let validator = MockEmailDomainValidator::new(5000);
253        let result = validator.validate_async("user@invalid-domain.com", "email").await;
254        assert!(result.is_err());
255    }
256
257    #[tokio::test]
258    async fn test_mock_email_domain_no_at() {
259        let validator = MockEmailDomainValidator::new(5000);
260        let result = validator.validate_async("invalid-email", "email").await;
261        assert!(result.is_err());
262    }
263
264    #[tokio::test]
265    async fn test_mock_phone_valid() {
266        let validator = MockPhoneNumberValidator::new(5000);
267        let result = validator.validate_async("+14155552671", "phone").await;
268        assert!(result.is_ok());
269    }
270
271    #[tokio::test]
272    async fn test_mock_phone_invalid_country() {
273        let validator = MockPhoneNumberValidator::new(5000);
274        let result = validator.validate_async("+999999999999", "phone").await;
275        assert!(result.is_err());
276    }
277
278    #[tokio::test]
279    async fn test_mock_phone_too_short() {
280        let validator = MockPhoneNumberValidator::new(5000);
281        let result = validator.validate_async("+123", "phone").await;
282        assert!(result.is_err());
283    }
284
285    #[test]
286    fn test_async_validator_config() {
287        let config = AsyncValidatorConfig::new(AsyncValidatorProvider::EmailDomainCheck, 5000)
288            .with_cache_ttl(3600)
289            .with_field_pattern("*.email");
290
291        assert_eq!(config.provider, AsyncValidatorProvider::EmailDomainCheck);
292        assert_eq!(config.timeout, Duration::from_secs(5));
293        assert_eq!(config.cache_ttl_secs, 3600);
294        assert_eq!(config.field_pattern, "*.email");
295    }
296
297    #[test]
298    fn test_provider_display() {
299        assert_eq!(AsyncValidatorProvider::EmailDomainCheck.to_string(), "email_domain_check");
300        assert_eq!(AsyncValidatorProvider::PhoneNumberValidation.to_string(), "phone_validation");
301    }
302
303    #[tokio::test]
304    async fn test_timeout_duration() {
305        let validator = MockEmailDomainValidator::new(2000);
306        assert_eq!(validator.timeout(), Duration::from_secs(2));
307    }
308}