Skip to main content

rskit_validation/
builder.rs

1//! Fluent validation builder.
2
3use std::fmt::Display;
4
5use rskit_errors::{AppError, AppResult, ErrorCode};
6
7use crate::{FieldError, validate_email, validate_url, validate_uuid};
8
9/// Fluent builder that collects field errors and converts to [`AppError`] via [`Validator::validate`].
10#[derive(Debug, Default)]
11pub struct Validator {
12    errors: Vec<FieldError>,
13}
14
15impl Validator {
16    /// Create a new empty validator.
17    #[must_use]
18    pub fn new() -> Self {
19        Self::default()
20    }
21
22    /// Fail if `value` is empty or whitespace-only.
23    #[must_use]
24    pub fn required(mut self, field: &str, value: &str) -> Self {
25        if value.trim().is_empty() {
26            self.add(field, "is required");
27        }
28        self
29    }
30
31    /// Fail if `value` has fewer than `min` characters.
32    #[must_use]
33    pub fn min_length(mut self, field: &str, value: &str, min: usize) -> Self {
34        if value.chars().count() < min {
35            self.add(field, format!("must be at least {min} characters"));
36        }
37        self
38    }
39
40    /// Fail if `value` exceeds `max` characters.
41    #[must_use]
42    pub fn max_length(mut self, field: &str, value: &str, max: usize) -> Self {
43        if value.chars().count() > max {
44            self.add(field, format!("must be at most {max} characters"));
45        }
46        self
47    }
48
49    /// Fail if `value` is not a valid e-mail address.
50    #[must_use]
51    pub fn email(mut self, field: &str, value: &str) -> Self {
52        if !validate_email(value) {
53            self.add(field, "must be a valid email address");
54        }
55        self
56    }
57
58    /// Fail if `value` is not a valid HTTP/HTTPS URL.
59    #[must_use]
60    pub fn url(mut self, field: &str, value: &str) -> Self {
61        if !validate_url(value) {
62            self.add(field, "must be a valid URL");
63        }
64        self
65    }
66
67    /// Fail if `value` does not match the regular expression `re`.
68    #[must_use]
69    pub fn pattern(mut self, field: &str, value: &str, re: &str) -> Self {
70        match regex::Regex::new(re) {
71            Ok(regex) => {
72                if !regex.is_match(value) {
73                    self.add(field, format!("must match pattern {re}"));
74                }
75            }
76            Err(err) => self.add(field, format!("invalid pattern: {err}")),
77        }
78        self
79    }
80
81    /// Fail if `value` is not a valid UUID string.
82    #[must_use]
83    pub fn required_uuid(mut self, field: &str, value: &str) -> Self {
84        if !validate_uuid(value) {
85            self.add(field, "must be a valid UUID");
86        }
87        self
88    }
89
90    /// Fail if `value` is `Some` but not a valid UUID string.
91    #[must_use]
92    pub fn optional_uuid(mut self, field: &str, value: Option<&str>) -> Self {
93        if let Some(v) = value
94            && !validate_uuid(v)
95        {
96            self.add(field, "must be a valid UUID");
97        }
98        self
99    }
100
101    /// Fail if `value` is outside the inclusive range `[min, max]`.
102    #[must_use]
103    pub fn in_range<T: PartialOrd + Display>(
104        mut self,
105        field: &str,
106        value: T,
107        min: T,
108        max: T,
109    ) -> Self {
110        if value < min || value > max {
111            self.add(field, format!("must be between {min} and {max}"));
112        }
113        self
114    }
115
116    /// Fail if `value` is below `min`.
117    #[must_use]
118    pub fn min_value<T: PartialOrd + Display>(mut self, field: &str, value: T, min: T) -> Self {
119        if value < min {
120            self.add(field, format!("must be at least {min}"));
121        }
122        self
123    }
124
125    /// Fail if `value` is above `max`.
126    #[must_use]
127    pub fn max_value<T: PartialOrd + Display>(mut self, field: &str, value: T, max: T) -> Self {
128        if value > max {
129            self.add(field, format!("must be {max} or less"));
130        }
131        self
132    }
133
134    /// Fail if `value` (ISO-8601 datetime string) is not before `deadline`.
135    #[must_use]
136    pub fn before(mut self, field: &str, value: &str, deadline: &str) -> Self {
137        match (
138            chrono::DateTime::parse_from_rfc3339(value),
139            chrono::DateTime::parse_from_rfc3339(deadline),
140        ) {
141            (Ok(v), Ok(d)) if v >= d => {
142                self.add(field, format!("must be before {deadline}"));
143            }
144            (Err(_), _) => self.add(field, "must be a valid datetime"),
145            _ => {}
146        }
147        self
148    }
149
150    /// Fail if `value` (ISO-8601 datetime string) is not after `floor`.
151    #[must_use]
152    pub fn after(mut self, field: &str, value: &str, floor: &str) -> Self {
153        match (
154            chrono::DateTime::parse_from_rfc3339(value),
155            chrono::DateTime::parse_from_rfc3339(floor),
156        ) {
157            (Ok(v), Ok(f)) if v <= f => {
158                self.add(field, format!("must be after {floor}"));
159            }
160            (Err(_), _) => self.add(field, "must be a valid datetime"),
161            _ => {}
162        }
163        self
164    }
165
166    /// Fail if `value` is not in `allowed`.
167    #[must_use]
168    pub fn one_of<T: PartialEq + Display>(mut self, field: &str, value: &T, allowed: &[T]) -> Self {
169        if !allowed.iter().any(|a| a == value) {
170            let list = allowed
171                .iter()
172                .map(|a| a.to_string())
173                .collect::<Vec<_>>()
174                .join(", ");
175            self.add(field, format!("must be one of: {list}"));
176        }
177        self
178    }
179
180    /// Add an error for `field` if `condition` is `false`.
181    #[must_use]
182    pub fn custom(mut self, condition: bool, field: &str, message: &str) -> Self {
183        if !condition {
184            self.add(field, message);
185        }
186        self
187    }
188
189    /// Returns `true` if any validation errors have been accumulated.
190    #[must_use]
191    pub fn has_errors(&self) -> bool {
192        !self.errors.is_empty()
193    }
194
195    /// Returns a slice of all accumulated field errors.
196    #[must_use]
197    pub fn errors(&self) -> &[FieldError] {
198        &self.errors
199    }
200
201    /// Consume the validator and return `Ok(())` if no errors,
202    /// or an [`AppError::invalid_input`] containing all field errors.
203    pub fn validate(self) -> AppResult<()> {
204        if self.errors.is_empty() {
205            return Ok(());
206        }
207        let detail = self
208            .errors
209            .iter()
210            .map(|e| format!("{}: {}", e.field, e.message))
211            .collect::<Vec<_>>()
212            .join("; ");
213        let fields_json =
214            serde_json::to_value(&self.errors).unwrap_or_else(|_| serde_json::Value::Array(vec![]));
215        Err(AppError::new(ErrorCode::InvalidInput, detail).with_detail("fields", fields_json))
216    }
217
218    fn add(&mut self, field: &str, message: impl Into<String>) {
219        self.errors.push(FieldError {
220            field: field.to_owned(),
221            message: message.into(),
222        });
223    }
224}