fraiseql_core/validation/
async_validators.rs1use 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
20pub type AsyncValidatorResult = Result<()>;
22
23static EMAIL_REGEX: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(patterns::EMAIL).expect("email format regex is valid"));
26
27static PHONE_E164_REGEX: LazyLock<Regex> =
32 LazyLock::new(|| Regex::new(patterns::PHONE_E164).expect("E.164 phone regex is valid"));
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
36#[non_exhaustive]
37pub enum AsyncValidatorProvider {
38 EmailFormatCheck,
40 PhoneE164Check,
42 ChecksumValidation,
44 Custom(String),
46}
47
48impl AsyncValidatorProvider {
49 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#[derive(Debug, Clone)]
68pub struct AsyncValidatorConfig {
69 pub provider: AsyncValidatorProvider,
71 pub timeout: Duration,
73 pub cache_ttl_secs: u64,
75 pub field_pattern: String,
77}
78
79impl AsyncValidatorConfig {
80 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 pub const fn with_cache_ttl(mut self, secs: u64) -> Self {
92 self.cache_ttl_secs = secs;
93 self
94 }
95
96 #[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#[async_trait]
110pub trait AsyncValidator: Send + Sync {
111 async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult;
120
121 fn provider(&self) -> AsyncValidatorProvider;
123
124 fn timeout(&self) -> Duration;
126}
127
128pub type ArcAsyncValidator = std::sync::Arc<dyn AsyncValidator>;
132
133pub struct EmailFormatValidator {
155 config: AsyncValidatorConfig,
156}
157
158impl EmailFormatValidator {
159 #[must_use]
161 pub const fn new() -> Self {
162 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#[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
200pub struct PhoneE164Validator {
227 config: AsyncValidatorConfig,
228}
229
230impl PhoneE164Validator {
231 #[must_use]
233 pub const fn new() -> Self {
234 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#[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
275pub struct ChecksumAsyncValidator {
281 config: AsyncValidatorConfig,
282 algorithm: String,
283}
284
285impl ChecksumAsyncValidator {
286 #[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#[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)] use super::*;
347
348 #[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 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 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 #[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 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 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 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 #[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 let v = EmailFormatValidator::new();
530 assert_eq!(v.timeout(), Duration::MAX);
531 }
532
533 #[test]
534 fn test_phone_validator_timeout_is_max() {
535 let v = PhoneE164Validator::new();
537 assert_eq!(v.timeout(), Duration::MAX);
538 }
539}