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}