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