Skip to main content

backbone_core/
validation.rs

1//! Generic entity validation — composable field rules with typed errors.
2//!
3//! Generated services get a type alias:
4//!
5//! ```rust,ignore
6//! // Generated:
7//! pub type StoredFileValidator = EntityValidator<StoredFile>;
8//! ```
9//!
10//! Custom decorators then add field rules without touching generated code:
11//!
12//! ```rust,ignore
13//! // Custom:
14//! impl StoredFileValidator {
15//!     pub fn with_business_rules() -> Self {
16//!         EntityValidator::new()
17//!             .rule(RequiredString::new("name", |e: &StoredFile| &e.name))
18//!             .rule(MaxLength::new("name", |e: &StoredFile| &e.name, 255))
19//!             .rule(NonNegative::new("size_bytes", |e: &StoredFile| e.size_bytes))
20//!     }
21//! }
22//! ```
23
24use std::marker::PhantomData;
25use std::sync::Arc;
26
27// ─── Validation error ────────────────────────────────────────────────────────
28
29/// A single field-level validation failure.
30#[derive(Debug, Clone, PartialEq)]
31pub struct ValidationError {
32    /// The field name (dot-separated for nested: `"address.street"`).
33    pub field: String,
34    /// Human-readable message.
35    pub message: String,
36    /// Optional machine-readable code for API clients.
37    pub code: Option<String>,
38}
39
40impl ValidationError {
41    pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
42        Self {
43            field: field.into(),
44            message: message.into(),
45            code: None,
46        }
47    }
48
49    pub fn with_code(mut self, code: impl Into<String>) -> Self {
50        self.code = Some(code.into());
51        self
52    }
53}
54
55impl std::fmt::Display for ValidationError {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        write!(f, "{}: {}", self.field, self.message)
58    }
59}
60
61/// All validation errors collected from a single `validate()` call.
62#[derive(Debug, Clone, Default)]
63pub struct ValidationErrors(Vec<ValidationError>);
64
65impl ValidationErrors {
66    pub fn new() -> Self {
67        Self(Vec::new())
68    }
69
70    pub fn push(&mut self, error: ValidationError) {
71        self.0.push(error);
72    }
73
74    pub fn is_empty(&self) -> bool {
75        self.0.is_empty()
76    }
77
78    pub fn errors(&self) -> &[ValidationError] {
79        &self.0
80    }
81
82    pub fn into_errors(self) -> Vec<ValidationError> {
83        self.0
84    }
85
86    /// Returns `Ok(())` if no errors, `Err(self)` otherwise.
87    pub fn into_result(self) -> Result<(), ValidationErrors> {
88        if self.is_empty() {
89            Ok(())
90        } else {
91            Err(self)
92        }
93    }
94}
95
96impl std::fmt::Display for ValidationErrors {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        for error in &self.0 {
99            writeln!(f, "{error}")?;
100        }
101        Ok(())
102    }
103}
104
105// ─── Field rule trait ────────────────────────────────────────────────────────
106
107/// A single validation rule applied to an entity.
108///
109/// Implement this to create custom rules beyond the built-ins.
110pub trait FieldRule<E>: Send + Sync {
111    fn validate(&self, entity: &E) -> Vec<ValidationError>;
112}
113
114// ─── EntityValidator ─────────────────────────────────────────────────────────
115
116/// Composable validator for any entity type `E`.
117///
118/// Rules are evaluated in registration order and all failures are collected
119/// before returning — no fail-fast behaviour.
120pub struct EntityValidator<E> {
121    rules: Vec<Arc<dyn FieldRule<E>>>,
122    _phantom: PhantomData<E>,
123}
124
125impl<E: Send + Sync + 'static> EntityValidator<E> {
126    pub fn new() -> Self {
127        Self {
128            rules: Vec::new(),
129            _phantom: PhantomData,
130        }
131    }
132
133    /// Add a rule to this validator (builder pattern).
134    pub fn rule(mut self, rule: impl FieldRule<E> + 'static) -> Self {
135        self.rules.push(Arc::new(rule));
136        self
137    }
138
139    /// Run all rules and collect errors.
140    pub fn validate(&self, entity: &E) -> ValidationErrors {
141        let mut errors = ValidationErrors::new();
142        for rule in &self.rules {
143            for error in rule.validate(entity) {
144                errors.push(error);
145            }
146        }
147        errors
148    }
149
150    /// Convenience — returns `Ok(())` or `Err(ValidationErrors)`.
151    pub fn validate_result(&self, entity: &E) -> Result<(), ValidationErrors> {
152        self.validate(entity).into_result()
153    }
154}
155
156impl<E: Send + Sync + 'static> Default for EntityValidator<E> {
157    fn default() -> Self {
158        Self::new()
159    }
160}
161
162// ─── Built-in rules ──────────────────────────────────────────────────────────
163
164/// Fails if a string field is empty or whitespace-only.
165pub struct RequiredString<E, F> {
166    field_name: &'static str,
167    accessor: F,
168    _phantom: PhantomData<E>,
169}
170
171impl<E, F: Fn(&E) -> &str + Send + Sync> RequiredString<E, F> {
172    pub fn new(field_name: &'static str, accessor: F) -> Self {
173        Self {
174            field_name,
175            accessor,
176            _phantom: PhantomData,
177        }
178    }
179}
180
181impl<E: Send + Sync, F: Fn(&E) -> &str + Send + Sync> FieldRule<E> for RequiredString<E, F> {
182    fn validate(&self, entity: &E) -> Vec<ValidationError> {
183        let value = (self.accessor)(entity);
184        if value.trim().is_empty() {
185            vec![ValidationError::new(
186                self.field_name,
187                format!("{} is required", self.field_name),
188            )
189            .with_code("required")]
190        } else {
191            vec![]
192        }
193    }
194}
195
196/// Fails if a string field exceeds `max_len` Unicode scalar values.
197pub struct MaxLength<E, F> {
198    field_name: &'static str,
199    accessor: F,
200    max_len: usize,
201    _phantom: PhantomData<E>,
202}
203
204impl<E, F: Fn(&E) -> &str + Send + Sync> MaxLength<E, F> {
205    pub fn new(field_name: &'static str, accessor: F, max_len: usize) -> Self {
206        Self {
207            field_name,
208            accessor,
209            max_len,
210            _phantom: PhantomData,
211        }
212    }
213}
214
215impl<E: Send + Sync, F: Fn(&E) -> &str + Send + Sync> FieldRule<E> for MaxLength<E, F> {
216    fn validate(&self, entity: &E) -> Vec<ValidationError> {
217        let value = (self.accessor)(entity);
218        if value.chars().count() > self.max_len {
219            vec![ValidationError::new(
220                self.field_name,
221                format!(
222                    "{} must be at most {} characters",
223                    self.field_name, self.max_len
224                ),
225            )
226            .with_code("max_length")]
227        } else {
228            vec![]
229        }
230    }
231}
232
233/// Fails if a numeric field is negative.
234pub struct NonNegative<E, F> {
235    field_name: &'static str,
236    accessor: F,
237    _phantom: PhantomData<E>,
238}
239
240impl<E, F: Fn(&E) -> i64 + Send + Sync> NonNegative<E, F> {
241    pub fn new(field_name: &'static str, accessor: F) -> Self {
242        Self {
243            field_name,
244            accessor,
245            _phantom: PhantomData,
246        }
247    }
248}
249
250impl<E: Send + Sync, F: Fn(&E) -> i64 + Send + Sync> FieldRule<E> for NonNegative<E, F> {
251    fn validate(&self, entity: &E) -> Vec<ValidationError> {
252        let value = (self.accessor)(entity);
253        if value < 0 {
254            vec![ValidationError::new(
255                self.field_name,
256                format!("{} must be 0 or greater", self.field_name),
257            )
258            .with_code("non_negative")]
259        } else {
260            vec![]
261        }
262    }
263}
264
265/// Fails if an `Option<String>` field is `Some("")` or `Some("   ")`.
266pub struct OptionalNotBlank<E, F> {
267    field_name: &'static str,
268    accessor: F,
269    _phantom: PhantomData<E>,
270}
271
272impl<E, F: Fn(&E) -> Option<&str> + Send + Sync> OptionalNotBlank<E, F> {
273    pub fn new(field_name: &'static str, accessor: F) -> Self {
274        Self {
275            field_name,
276            accessor,
277            _phantom: PhantomData,
278        }
279    }
280}
281
282impl<E: Send + Sync, F: Fn(&E) -> Option<&str> + Send + Sync> FieldRule<E>
283    for OptionalNotBlank<E, F>
284{
285    fn validate(&self, entity: &E) -> Vec<ValidationError> {
286        if let Some(value) = (self.accessor)(entity) {
287            if value.trim().is_empty() {
288                return vec![ValidationError::new(
289                    self.field_name,
290                    format!("{} must not be blank when provided", self.field_name),
291                )
292                .with_code("not_blank")];
293            }
294        }
295        vec![]
296    }
297}
298
299/// Fails if a UUID string field is empty or not a valid v4 UUID.
300///
301/// Checks that the field is a non-empty string that can be parsed as a UUID.
302pub struct RequiredUuid<E, F> {
303    field_name: &'static str,
304    accessor: F,
305    _phantom: PhantomData<E>,
306}
307
308impl<E, F: Fn(&E) -> &str + Send + Sync> RequiredUuid<E, F> {
309    pub fn new(field_name: &'static str, accessor: F) -> Self {
310        Self {
311            field_name,
312            accessor,
313            _phantom: PhantomData,
314        }
315    }
316}
317
318impl<E: Send + Sync, F: Fn(&E) -> &str + Send + Sync> FieldRule<E> for RequiredUuid<E, F> {
319    fn validate(&self, entity: &E) -> Vec<ValidationError> {
320        let value = (self.accessor)(entity);
321        if value.trim().is_empty() {
322            return vec![ValidationError::new(
323                self.field_name,
324                format!("{} is required", self.field_name),
325            )
326            .with_code("required")];
327        }
328        if uuid::Uuid::parse_str(value).is_err() {
329            return vec![ValidationError::new(
330                self.field_name,
331                format!("{} must be a valid UUID", self.field_name),
332            )
333            .with_code("invalid_uuid")];
334        }
335        vec![]
336    }
337}
338
339/// Fails if a string field doesn't match the given regex pattern.
340pub struct Regex<E, F> {
341    field_name: &'static str,
342    accessor: F,
343    pattern: &'static str,
344    message: &'static str,
345    _phantom: PhantomData<E>,
346}
347
348impl<E, F: Fn(&E) -> &str + Send + Sync> Regex<E, F> {
349    pub fn new(
350        field_name: &'static str,
351        accessor: F,
352        pattern: &'static str,
353        message: &'static str,
354    ) -> Self {
355        Self {
356            field_name,
357            accessor,
358            pattern,
359            message,
360            _phantom: PhantomData,
361        }
362    }
363}
364
365impl<E: Send + Sync, F: Fn(&E) -> &str + Send + Sync> FieldRule<E> for Regex<E, F> {
366    fn validate(&self, entity: &E) -> Vec<ValidationError> {
367        let value = (self.accessor)(entity);
368        match regex::Regex::new(self.pattern) {
369            Ok(re) if re.is_match(value) => vec![],
370            Ok(_) => vec![ValidationError::new(self.field_name, self.message).with_code("pattern")],
371            Err(_) => vec![ValidationError::new(
372                self.field_name,
373                format!("invalid regex pattern: {}", self.pattern),
374            )
375            .with_code("invalid_pattern")],
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[derive(Debug)]
385    struct User {
386        name: String,
387        age: i64,
388        bio: Option<String>,
389    }
390
391    #[test]
392    fn required_string_rejects_blank() {
393        let rule = RequiredString::new("name", |u: &User| u.name.as_str());
394        let user = User {
395            name: "  ".into(),
396            age: 30,
397            bio: None,
398        };
399        let errors = rule.validate(&user);
400        assert_eq!(errors.len(), 1);
401        assert_eq!(errors[0].code.as_deref(), Some("required"));
402    }
403
404    #[test]
405    fn max_length_allows_exact_length() {
406        let rule = MaxLength::new("name", |u: &User| u.name.as_str(), 3);
407        let user = User {
408            name: "abc".into(),
409            age: 30,
410            bio: None,
411        };
412        assert!(rule.validate(&user).is_empty());
413    }
414
415    #[test]
416    fn max_length_rejects_over_limit() {
417        let rule = MaxLength::new("name", |u: &User| u.name.as_str(), 3);
418        let user = User {
419            name: "abcd".into(),
420            age: 30,
421            bio: None,
422        };
423        assert!(!rule.validate(&user).is_empty());
424    }
425
426    #[test]
427    fn regex_rule_validates_pattern() {
428        let rule = Regex::new("phone", |u: &User| u.name.as_str(), r"^\+\d{7,15}$", "must be E.164");
429
430        let valid = User { name: "+628123456789".into(), age: 0, bio: None };
431        assert!(rule.validate(&valid).is_empty(), "valid E.164 should pass");
432
433        let invalid = User { name: "no-plus".into(), age: 0, bio: None };
434        let errors = rule.validate(&invalid);
435        assert_eq!(errors.len(), 1);
436        assert_eq!(errors[0].code.as_deref(), Some("pattern"));
437    }
438
439    #[test]
440    fn entity_validator_collects_all_errors() {
441        let validator = EntityValidator::new()
442            .rule(RequiredString::new("name", |u: &User| u.name.as_str()))
443            .rule(NonNegative::new("age", |u: &User| u.age));
444
445        let user = User {
446            name: "".into(),
447            age: -1,
448            bio: None,
449        };
450
451        let errors = validator.validate(&user);
452        assert_eq!(errors.errors().len(), 2);
453    }
454}