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    #[must_use]
51    pub fn name(&self) -> String {
52        match self {
53            Self::EmailFormatCheck => "email_format_check".to_string(),
54            Self::PhoneE164Check => "phone_e164_check".to_string(),
55            Self::ChecksumValidation => "checksum_validation".to_string(),
56            Self::Custom(name) => name.clone(),
57        }
58    }
59}
60
61impl std::fmt::Display for AsyncValidatorProvider {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(f, "{}", self.name())
64    }
65}
66
67/// Configuration for an async validator.
68#[derive(Debug, Clone)]
69pub struct AsyncValidatorConfig {
70    /// The provider to use
71    pub provider:       AsyncValidatorProvider,
72    /// Timeout duration for the validation operation
73    pub timeout:        Duration,
74    /// Cache TTL in seconds (0 = no caching)
75    pub cache_ttl_secs: u64,
76    /// Field pattern this validator applies to (e.g., "*.email")
77    pub field_pattern:  String,
78}
79
80impl AsyncValidatorConfig {
81    /// Create a new async validator configuration.
82    #[must_use]
83    pub const fn new(provider: AsyncValidatorProvider, timeout_ms: u64) -> Self {
84        Self {
85            provider,
86            timeout: Duration::from_millis(timeout_ms),
87            cache_ttl_secs: 0,
88            field_pattern: String::new(),
89        }
90    }
91
92    /// Set cache TTL for this validator.
93    #[must_use]
94    pub const fn with_cache_ttl(mut self, secs: u64) -> Self {
95        self.cache_ttl_secs = secs;
96        self
97    }
98
99    /// Set field pattern for this validator.
100    #[must_use]
101    pub fn with_field_pattern(mut self, pattern: impl Into<String>) -> Self {
102        self.field_pattern = pattern.into();
103        self
104    }
105}
106
107/// Trait for async validators.
108///
109/// Implementers should handle timeout and error cases gracefully.
110// Reason: used as dyn Trait (Arc<dyn AsyncValidator>); async_trait ensures Send bounds and
111// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
112#[async_trait]
113pub trait AsyncValidator: Send + Sync {
114    /// Validate a value asynchronously.
115    ///
116    /// # Arguments
117    /// * `value` - The value to validate
118    /// * `field` - The field name (for error reporting)
119    ///
120    /// # Returns
121    /// `Ok(())` if valid, `Err(FraiseQLError)` if invalid
122    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult;
123
124    /// Get the provider this validator uses
125    fn provider(&self) -> AsyncValidatorProvider;
126
127    /// Get the timeout for this validator
128    fn timeout(&self) -> Duration;
129}
130
131/// Type alias for arc-wrapped dynamic async validator.
132///
133/// Used for thread-safe, reference-counted storage of async validators.
134pub type ArcAsyncValidator = std::sync::Arc<dyn AsyncValidator>;
135
136/// Email format validator.
137///
138/// Validates that a string is a well-formed email address using the RFC 5321
139/// practical regex (`local-part@domain.tld`). No network I/O is performed.
140///
141/// # Example
142///
143/// ```
144/// use fraiseql_core::validation::async_validators::{AsyncValidator, EmailFormatValidator};
145///
146/// # #[tokio::main]
147/// # async fn main() {
148/// let v = EmailFormatValidator::new();
149/// v.validate_async("alice@example.com", "email").await
150///     .expect("valid email should pass validation");
151/// assert!(
152///     v.validate_async("not-an-email", "email").await.is_err(),
153///     "string without @ should fail email validation"
154/// );
155/// # }
156/// ```
157pub struct EmailFormatValidator {
158    config: AsyncValidatorConfig,
159}
160
161impl EmailFormatValidator {
162    /// Create a new email format validator.
163    #[must_use]
164    pub const fn new() -> Self {
165        // Duration::MAX signals "no timeout" — this validator is purely local (regex only).
166        let mut config = AsyncValidatorConfig::new(AsyncValidatorProvider::EmailFormatCheck, 0);
167        config.timeout = Duration::MAX;
168        Self { config }
169    }
170}
171
172impl Default for EmailFormatValidator {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178// Reason: AsyncValidator is defined with #[async_trait]; all implementations must match
179// its transformed method signatures to satisfy the trait contract
180// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
181#[async_trait]
182impl AsyncValidator for EmailFormatValidator {
183    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult {
184        if EMAIL_REGEX.is_match(value) {
185            Ok(())
186        } else {
187            Err(FraiseQLError::Validation {
188                message: format!("Invalid email format for field '{field}'"),
189                path:    Some(field.to_string()),
190            })
191        }
192    }
193
194    fn provider(&self) -> AsyncValidatorProvider {
195        self.config.provider.clone()
196    }
197
198    fn timeout(&self) -> Duration {
199        self.config.timeout
200    }
201}
202
203/// E.164 phone number validator.
204///
205/// Validates that a string is a valid E.164 international phone number:
206/// a `+` followed by a non-zero country code digit and 6–14 more digits
207/// (7–15 digits total after the `+`). No network I/O is performed.
208///
209/// # Example
210///
211/// ```
212/// use fraiseql_core::validation::async_validators::{AsyncValidator, PhoneE164Validator};
213///
214/// # #[tokio::main]
215/// # async fn main() {
216/// let v = PhoneE164Validator::new();
217/// v.validate_async("+14155552671", "phone").await
218///     .expect("E.164 number should pass phone validation");
219/// assert!(
220///     v.validate_async("0044207946000", "phone").await.is_err(),
221///     "number without leading + should fail E.164 validation"
222/// );
223/// assert!(
224///     v.validate_async("+123", "phone").await.is_err(),
225///     "too-short number should fail phone validation"
226/// );
227/// # }
228/// ```
229pub struct PhoneE164Validator {
230    config: AsyncValidatorConfig,
231}
232
233impl PhoneE164Validator {
234    /// Create a new E.164 phone number validator.
235    #[must_use]
236    pub const fn new() -> Self {
237        // Duration::MAX signals "no timeout" — this validator is purely local (regex only).
238        let mut config = AsyncValidatorConfig::new(AsyncValidatorProvider::PhoneE164Check, 0);
239        config.timeout = Duration::MAX;
240        Self { config }
241    }
242}
243
244impl Default for PhoneE164Validator {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250// Reason: AsyncValidator is defined with #[async_trait]; all implementations must match
251// its transformed method signatures to satisfy the trait contract
252// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
253#[async_trait]
254impl AsyncValidator for PhoneE164Validator {
255    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult {
256        if PHONE_E164_REGEX.is_match(value) {
257            Ok(())
258        } else {
259            Err(FraiseQLError::Validation {
260                message: format!(
261                    "Invalid E.164 phone number for field '{field}': \
262                     expected '+' followed by 7–15 digits (e.g. +14155552671)"
263                ),
264                path:    Some(field.to_string()),
265            })
266        }
267    }
268
269    fn provider(&self) -> AsyncValidatorProvider {
270        self.config.provider.clone()
271    }
272
273    fn timeout(&self) -> Duration {
274        self.config.timeout
275    }
276}
277
278/// Checksum validator supporting Luhn and Mod-97 algorithms.
279///
280/// Validates credit card numbers (Luhn) and IBANs (Mod-97) locally.
281/// Implements `AsyncValidator` for composition with other async validators,
282/// but performs no I/O.
283pub struct ChecksumAsyncValidator {
284    config:    AsyncValidatorConfig,
285    algorithm: String,
286}
287
288impl ChecksumAsyncValidator {
289    /// Create a new checksum validator.
290    ///
291    /// `algorithm` must be `"luhn"` or `"mod97"`.
292    #[must_use]
293    pub fn new(algorithm: impl Into<String>) -> Self {
294        let mut config = AsyncValidatorConfig::new(AsyncValidatorProvider::ChecksumValidation, 0);
295        config.timeout = Duration::MAX;
296        Self {
297            config,
298            algorithm: algorithm.into(),
299        }
300    }
301}
302
303// Reason: AsyncValidator is defined with #[async_trait]; all implementations must match
304// its transformed method signatures to satisfy the trait contract
305// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
306#[async_trait]
307impl AsyncValidator for ChecksumAsyncValidator {
308    async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult {
309        use crate::validation::checksum::{LuhnValidator, Mod97Validator};
310        let valid = match self.algorithm.as_str() {
311            "luhn" => LuhnValidator::validate(value),
312            "mod97" => Mod97Validator::validate(value),
313            other => {
314                return Err(crate::error::FraiseQLError::Validation {
315                    message: format!(
316                        "Unknown checksum algorithm '{}' for field '{}'",
317                        other, field
318                    ),
319                    path:    Some(field.to_string()),
320                });
321            },
322        };
323        if valid {
324            Ok(())
325        } else {
326            Err(crate::error::FraiseQLError::Validation {
327                message: format!(
328                    "Checksum validation ({}) failed for field '{}'",
329                    self.algorithm, field
330                ),
331                path:    Some(field.to_string()),
332            })
333        }
334    }
335
336    fn provider(&self) -> AsyncValidatorProvider {
337        self.config.provider.clone()
338    }
339
340    fn timeout(&self) -> Duration {
341        self.config.timeout
342    }
343}