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//!
6//! The built-in implementations (`EmailFormatValidator`, `PhoneE164Validator`) perform
7//! local regex validation only — no network I/O. They implement `AsyncValidator` so they
8//! compose with the same dispatch infrastructure as future network-backed validators.
9
10use std::{sync::LazyLock, time::Duration};
11
12use async_trait::async_trait;
13use regex::Regex;
14
15use crate::{
16    error::{FraiseQLError, Result},
17    validation::patterns,
18};
19
20/// Async validator result type.
21pub type AsyncValidatorResult = Result<()>;
22
23/// Email format regex — canonical pattern from [`patterns::EMAIL`].
24static EMAIL_REGEX: LazyLock<Regex> =
25    LazyLock::new(|| Regex::new(patterns::EMAIL).expect("email format regex is valid"));
26
27/// E.164 phone number regex — canonical pattern from [`patterns::PHONE_E164`].
28///
29/// Accepts `+` followed by a non-zero leading digit and 6–14 more digits
30/// (7–15 total digits after the `+`), covering all valid ITU-T E.164 numbers.
31static PHONE_E164_REGEX: LazyLock<Regex> =
32    LazyLock::new(|| Regex::new(patterns::PHONE_E164).expect("E.164 phone regex is valid"));
33
34/// Provider types for async validators.
35#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
36#[non_exhaustive]
37pub enum AsyncValidatorProvider {
38    /// Email format validation (RFC 5321 regex)
39    EmailFormatCheck,
40    /// Phone number E.164 format validation
41    PhoneE164Check,
42    /// IBAN/VIN checksum validation
43    ChecksumValidation,
44    /// Custom provider
45    Custom(String),
46}
47
48impl AsyncValidatorProvider {
49    /// Get provider name for logging/debugging
50    pub fn name(&self) -> String {
51        match self {
52            Self::EmailFormatCheck => "email_format_check".to_string(),
53            Self::PhoneE164Check => "phone_e164_check".to_string(),
54            Self::ChecksumValidation => "checksum_validation".to_string(),
55            Self::Custom(name) => name.clone(),
56        }
57    }
58}
59
60impl std::fmt::Display for AsyncValidatorProvider {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        write!(f, "{}", self.name())
63    }
64}
65
66/// Configuration for an async validator.
67#[derive(Debug, Clone)]
68pub struct AsyncValidatorConfig {
69    /// The provider to use
70    pub provider:       AsyncValidatorProvider,
71    /// Timeout duration for the validation operation
72    pub timeout:        Duration,
73    /// Cache TTL in seconds (0 = no caching)
74    pub cache_ttl_secs: u64,
75    /// Field pattern this validator applies to (e.g., "*.email")
76    pub field_pattern:  String,
77}
78
79impl AsyncValidatorConfig {
80    /// Create a new async validator configuration.
81    pub const fn new(provider: AsyncValidatorProvider, timeout_ms: u64) -> Self {
82        Self {
83            provider,
84            timeout: Duration::from_millis(timeout_ms),
85            cache_ttl_secs: 0,
86            field_pattern: String::new(),
87        }
88    }
89
90    /// Set cache TTL for this validator.
91    pub const fn with_cache_ttl(mut self, secs: u64) -> Self {
92        self.cache_ttl_secs = secs;
93        self
94    }
95
96    /// Set field pattern for this validator.
97    #[must_use]
98    pub fn with_field_pattern(mut self, pattern: impl Into<String>) -> Self {
99        self.field_pattern = pattern.into();
100        self
101    }
102}
103
104/// Trait for async validators.
105///
106/// Implementers should handle timeout and error cases gracefully.
107// Reason: used as dyn Trait (Arc<dyn AsyncValidator>); async_trait ensures Send bounds and
108// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
109#[async_trait]
110pub trait AsyncValidator: Send + Sync {
111    /// Validate a value asynchronously.
112    ///
113    /// # Arguments
114    /// * `value` - The value to validate
115    /// * `field` - The field name (for error reporting)
116    ///
117    /// # Returns
118    /// `Ok(())` if valid, `Err(FraiseQLError)` if invalid
119    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult;
120
121    /// Get the provider this validator uses
122    fn provider(&self) -> AsyncValidatorProvider;
123
124    /// Get the timeout for this validator
125    fn timeout(&self) -> Duration;
126}
127
128/// Type alias for arc-wrapped dynamic async validator.
129///
130/// Used for thread-safe, reference-counted storage of async validators.
131pub type ArcAsyncValidator = std::sync::Arc<dyn AsyncValidator>;
132
133/// Email format validator.
134///
135/// Validates that a string is a well-formed email address using the RFC 5321
136/// practical regex (`local-part@domain.tld`). No network I/O is performed.
137///
138/// # Example
139///
140/// ```
141/// use fraiseql_core::validation::async_validators::{AsyncValidator, EmailFormatValidator};
142///
143/// # #[tokio::main]
144/// # async fn main() {
145/// let v = EmailFormatValidator::new();
146/// v.validate_async("alice@example.com", "email").await
147///     .expect("valid email should pass validation");
148/// assert!(
149///     v.validate_async("not-an-email", "email").await.is_err(),
150///     "string without @ should fail email validation"
151/// );
152/// # }
153/// ```
154pub struct EmailFormatValidator {
155    config: AsyncValidatorConfig,
156}
157
158impl EmailFormatValidator {
159    /// Create a new email format validator.
160    #[must_use]
161    pub const fn new() -> Self {
162        // Duration::MAX signals "no timeout" — this validator is purely local (regex only).
163        let mut config = AsyncValidatorConfig::new(AsyncValidatorProvider::EmailFormatCheck, 0);
164        config.timeout = Duration::MAX;
165        Self { config }
166    }
167}
168
169impl Default for EmailFormatValidator {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175// Reason: AsyncValidator is defined with #[async_trait]; all implementations must match
176// its transformed method signatures to satisfy the trait contract
177// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
178#[async_trait]
179impl AsyncValidator for EmailFormatValidator {
180    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult {
181        if EMAIL_REGEX.is_match(value) {
182            Ok(())
183        } else {
184            Err(FraiseQLError::Validation {
185                message: format!("Invalid email format for field '{field}'"),
186                path:    Some(field.to_string()),
187            })
188        }
189    }
190
191    fn provider(&self) -> AsyncValidatorProvider {
192        self.config.provider.clone()
193    }
194
195    fn timeout(&self) -> Duration {
196        self.config.timeout
197    }
198}
199
200/// E.164 phone number validator.
201///
202/// Validates that a string is a valid E.164 international phone number:
203/// a `+` followed by a non-zero country code digit and 6–14 more digits
204/// (7–15 digits total after the `+`). No network I/O is performed.
205///
206/// # Example
207///
208/// ```
209/// use fraiseql_core::validation::async_validators::{AsyncValidator, PhoneE164Validator};
210///
211/// # #[tokio::main]
212/// # async fn main() {
213/// let v = PhoneE164Validator::new();
214/// v.validate_async("+14155552671", "phone").await
215///     .expect("E.164 number should pass phone validation");
216/// assert!(
217///     v.validate_async("0044207946000", "phone").await.is_err(),
218///     "number without leading + should fail E.164 validation"
219/// );
220/// assert!(
221///     v.validate_async("+123", "phone").await.is_err(),
222///     "too-short number should fail phone validation"
223/// );
224/// # }
225/// ```
226pub struct PhoneE164Validator {
227    config: AsyncValidatorConfig,
228}
229
230impl PhoneE164Validator {
231    /// Create a new E.164 phone number validator.
232    #[must_use]
233    pub const fn new() -> Self {
234        // Duration::MAX signals "no timeout" — this validator is purely local (regex only).
235        let mut config = AsyncValidatorConfig::new(AsyncValidatorProvider::PhoneE164Check, 0);
236        config.timeout = Duration::MAX;
237        Self { config }
238    }
239}
240
241impl Default for PhoneE164Validator {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247// Reason: AsyncValidator is defined with #[async_trait]; all implementations must match
248// its transformed method signatures to satisfy the trait contract
249// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
250#[async_trait]
251impl AsyncValidator for PhoneE164Validator {
252    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult {
253        if PHONE_E164_REGEX.is_match(value) {
254            Ok(())
255        } else {
256            Err(FraiseQLError::Validation {
257                message: format!(
258                    "Invalid E.164 phone number for field '{field}': \
259                     expected '+' followed by 7–15 digits (e.g. +14155552671)"
260                ),
261                path:    Some(field.to_string()),
262            })
263        }
264    }
265
266    fn provider(&self) -> AsyncValidatorProvider {
267        self.config.provider.clone()
268    }
269
270    fn timeout(&self) -> Duration {
271        self.config.timeout
272    }
273}
274
275/// Checksum validator supporting Luhn and Mod-97 algorithms.
276///
277/// Validates credit card numbers (Luhn) and IBANs (Mod-97) locally.
278/// Implements `AsyncValidator` for composition with other async validators,
279/// but performs no I/O.
280pub struct ChecksumAsyncValidator {
281    config:    AsyncValidatorConfig,
282    algorithm: String,
283}
284
285impl ChecksumAsyncValidator {
286    /// Create a new checksum validator.
287    ///
288    /// `algorithm` must be `"luhn"` or `"mod97"`.
289    #[must_use]
290    pub fn new(algorithm: impl Into<String>) -> Self {
291        let mut config = AsyncValidatorConfig::new(AsyncValidatorProvider::ChecksumValidation, 0);
292        config.timeout = Duration::MAX;
293        Self {
294            config,
295            algorithm: algorithm.into(),
296        }
297    }
298}
299
300// Reason: AsyncValidator is defined with #[async_trait]; all implementations must match
301// its transformed method signatures to satisfy the trait contract
302// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
303#[async_trait]
304impl AsyncValidator for ChecksumAsyncValidator {
305    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult {
306        use crate::validation::checksum::{LuhnValidator, Mod97Validator};
307        let valid = match self.algorithm.as_str() {
308            "luhn" => LuhnValidator::validate(value),
309            "mod97" => Mod97Validator::validate(value),
310            other => {
311                return Err(crate::error::FraiseQLError::Validation {
312                    message: format!(
313                        "Unknown checksum algorithm '{}' for field '{}'",
314                        other, field
315                    ),
316                    path:    Some(field.to_string()),
317                });
318            },
319        };
320        if valid {
321            Ok(())
322        } else {
323            Err(crate::error::FraiseQLError::Validation {
324                message: format!(
325                    "Checksum validation ({}) failed for field '{}'",
326                    self.algorithm, field
327                ),
328                path:    Some(field.to_string()),
329            })
330        }
331    }
332
333    fn provider(&self) -> AsyncValidatorProvider {
334        self.config.provider.clone()
335    }
336
337    fn timeout(&self) -> Duration {
338        self.config.timeout
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
345
346    use super::*;
347
348    // ── EmailFormatValidator ──────────────────────────────────────────────────
349
350    #[tokio::test]
351    async fn test_email_valid_simple() {
352        let v = EmailFormatValidator::new();
353        v.validate_async("user@example.com", "email")
354            .await
355            .unwrap_or_else(|e| panic!("valid simple email should pass: {e}"));
356    }
357
358    #[tokio::test]
359    async fn test_email_valid_subdomain() {
360        let v = EmailFormatValidator::new();
361        v.validate_async("user@mail.example.co.uk", "email")
362            .await
363            .unwrap_or_else(|e| panic!("valid subdomain email should pass: {e}"));
364    }
365
366    #[tokio::test]
367    async fn test_email_valid_plus_addressing() {
368        let v = EmailFormatValidator::new();
369        v.validate_async("user+tag@example.com", "email")
370            .await
371            .unwrap_or_else(|e| panic!("valid plus-addressed email should pass: {e}"));
372    }
373
374    #[tokio::test]
375    async fn test_email_valid_corporate_domain() {
376        let v = EmailFormatValidator::new();
377        // Must accept any valid domain, not a hardcoded allowlist
378        v.validate_async("alice@my-company.io", "email")
379            .await
380            .unwrap_or_else(|e| panic!("valid corporate email should pass: {e}"));
381        v.validate_async("bob@university.edu", "email")
382            .await
383            .unwrap_or_else(|e| panic!("valid edu email should pass: {e}"));
384    }
385
386    #[tokio::test]
387    async fn test_email_invalid_no_at() {
388        let v = EmailFormatValidator::new();
389        let result = v.validate_async("notanemail", "email").await;
390        assert!(
391            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid email format")),
392            "expected Validation error about invalid email format, got: {result:?}"
393        );
394    }
395
396    #[tokio::test]
397    async fn test_email_invalid_no_tld() {
398        let v = EmailFormatValidator::new();
399        // Single label after @ has no dot — rejected
400        let result = v.validate_async("user@localhost", "email").await;
401        assert!(
402            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid email format")),
403            "expected Validation error about invalid email format, got: {result:?}"
404        );
405    }
406
407    #[tokio::test]
408    async fn test_email_invalid_empty() {
409        let v = EmailFormatValidator::new();
410        let result = v.validate_async("", "email").await;
411        assert!(
412            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid email format")),
413            "expected Validation error about invalid email format, got: {result:?}"
414        );
415    }
416
417    #[tokio::test]
418    async fn test_email_error_message_contains_field() {
419        let v = EmailFormatValidator::new();
420        let err = v.validate_async("bad", "contact_email").await.unwrap_err();
421        assert!(err.to_string().contains("contact_email"));
422    }
423
424    // ── PhoneE164Validator ────────────────────────────────────────────────────
425
426    #[tokio::test]
427    async fn test_phone_valid_us() {
428        let v = PhoneE164Validator::new();
429        v.validate_async("+14155552671", "phone")
430            .await
431            .unwrap_or_else(|e| panic!("valid US phone should pass: {e}"));
432    }
433
434    #[tokio::test]
435    async fn test_phone_valid_uk() {
436        let v = PhoneE164Validator::new();
437        v.validate_async("+447911123456", "phone")
438            .await
439            .unwrap_or_else(|e| panic!("valid UK phone should pass: {e}"));
440    }
441
442    #[tokio::test]
443    async fn test_phone_valid_any_country_code() {
444        let v = PhoneE164Validator::new();
445        // Must accept all country codes, not a hardcoded subset
446        v.validate_async("+819012345678", "phone")
447            .await
448            .unwrap_or_else(|e| panic!("valid Japan phone should pass: {e}"));
449        v.validate_async("+5511987654321", "phone")
450            .await
451            .unwrap_or_else(|e| panic!("valid Brazil phone should pass: {e}"));
452        v.validate_async("+27821234567", "phone")
453            .await
454            .unwrap_or_else(|e| panic!("valid South Africa phone should pass: {e}"));
455    }
456
457    #[tokio::test]
458    async fn test_phone_invalid_missing_plus() {
459        let v = PhoneE164Validator::new();
460        let result = v.validate_async("14155552671", "phone").await;
461        assert!(
462            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid E.164")),
463            "expected Validation error about invalid E.164 phone, got: {result:?}"
464        );
465    }
466
467    #[tokio::test]
468    async fn test_phone_invalid_too_short() {
469        let v = PhoneE164Validator::new();
470        // 5 digits after + — below E.164 minimum of 7
471        let result = v.validate_async("+12345", "phone").await;
472        assert!(
473            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid E.164")),
474            "expected Validation error about invalid E.164 phone, got: {result:?}"
475        );
476    }
477
478    #[tokio::test]
479    async fn test_phone_invalid_too_long() {
480        let v = PhoneE164Validator::new();
481        // 16 digits after + — above E.164 maximum of 15
482        let result = v.validate_async("+1234567890123456", "phone").await;
483        assert!(
484            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid E.164")),
485            "expected Validation error about invalid E.164 phone, got: {result:?}"
486        );
487    }
488
489    #[tokio::test]
490    async fn test_phone_invalid_leading_zero_country_code() {
491        let v = PhoneE164Validator::new();
492        let result = v.validate_async("+0441234567890", "phone").await;
493        assert!(
494            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid E.164")),
495            "expected Validation error about invalid E.164 phone, got: {result:?}"
496        );
497    }
498
499    #[tokio::test]
500    async fn test_phone_error_message_contains_field() {
501        let v = PhoneE164Validator::new();
502        let err = v.validate_async("bad", "mobile_number").await.unwrap_err();
503        assert!(err.to_string().contains("mobile_number"));
504    }
505
506    // ── AsyncValidatorConfig ──────────────────────────────────────────────────
507
508    #[test]
509    fn test_async_validator_config() {
510        let config = AsyncValidatorConfig::new(AsyncValidatorProvider::EmailFormatCheck, 5000)
511            .with_cache_ttl(3600)
512            .with_field_pattern("*.email");
513
514        assert_eq!(config.provider, AsyncValidatorProvider::EmailFormatCheck);
515        assert_eq!(config.timeout, Duration::from_secs(5));
516        assert_eq!(config.cache_ttl_secs, 3600);
517        assert_eq!(config.field_pattern, "*.email");
518    }
519
520    #[test]
521    fn test_provider_display() {
522        assert_eq!(AsyncValidatorProvider::EmailFormatCheck.to_string(), "email_format_check");
523        assert_eq!(AsyncValidatorProvider::PhoneE164Check.to_string(), "phone_e164_check");
524    }
525
526    #[test]
527    fn test_email_validator_timeout_is_max() {
528        // Duration::MAX signals no-timeout for local-only (regex) validators
529        let v = EmailFormatValidator::new();
530        assert_eq!(v.timeout(), Duration::MAX);
531    }
532
533    #[test]
534    fn test_phone_validator_timeout_is_max() {
535        // Duration::MAX signals no-timeout for local-only (regex) validators
536        let v = PhoneE164Validator::new();
537        assert_eq!(v.timeout(), Duration::MAX);
538    }
539}