fraiseql_core/validation/
async_validators.rs1use std::time::Duration;
7
8use crate::error::{FraiseQLError, Result};
9
10pub type AsyncValidatorResult = Result<()>;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15pub enum AsyncValidatorProvider {
16 EmailDomainCheck,
18 PhoneNumberValidation,
20 ChecksumValidation,
22 Custom(String),
24}
25
26impl AsyncValidatorProvider {
27 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#[derive(Debug, Clone)]
46pub struct AsyncValidatorConfig {
47 pub provider: AsyncValidatorProvider,
49 pub timeout: Duration,
51 pub cache_ttl_secs: u64,
53 pub field_pattern: String,
55}
56
57impl AsyncValidatorConfig {
58 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 pub fn with_cache_ttl(mut self, secs: u64) -> Self {
70 self.cache_ttl_secs = secs;
71 self
72 }
73
74 pub fn with_field_pattern(mut self, pattern: impl Into<String>) -> Self {
76 self.field_pattern = pattern.into();
77 self
78 }
79}
80
81#[async_trait::async_trait]
85pub trait AsyncValidator: Send + Sync {
86 async fn validate_async(&self, value: &str, field: &str) -> AsyncValidatorResult;
95
96 fn provider(&self) -> AsyncValidatorProvider;
98
99 fn timeout(&self) -> Duration;
101}
102
103pub struct MockEmailDomainValidator {
107 config: AsyncValidatorConfig,
108 valid_domains: Vec<String>,
110}
111
112impl MockEmailDomainValidator {
113 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 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 if let Some(at_index) = value.find('@') {
139 let domain = &value[at_index + 1..];
140
141 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
175pub struct MockPhoneNumberValidator {
179 config: AsyncValidatorConfig,
180 valid_countries: Vec<String>,
182}
183
184impl MockPhoneNumberValidator {
185 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(), "44".to_string(), "33".to_string(), "49".to_string(), ],
197 }
198 }
199
200 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 tokio::time::sleep(Duration::from_millis(10)).await;
213
214 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}