Skip to main content

vld/primitives/string/
mod.rs

1use serde_json::Value;
2
3use crate::error::{ValidationIssue, VldError};
4use crate::schema::VldSchema;
5
6mod checks;
7mod formats;
8
9#[cfg(feature = "openapi")]
10use checks::regex_escape;
11use checks::{Kind, StringCheck};
12
13#[derive(Clone)]
14enum StringTransform {
15    Trim,
16    ToLowerCase,
17    ToUpperCase,
18}
19
20/// Schema for string validation. Created via [`vld::string()`](crate::string).
21///
22/// # Example
23/// ```
24/// use vld::prelude::*;
25///
26/// let schema = vld::string().min(3).max(20).email();
27/// ```
28#[derive(Clone)]
29pub struct ZString {
30    checks: Vec<StringCheck>,
31    transforms: Vec<StringTransform>,
32    coerce: bool,
33    custom_type_error: Option<String>,
34    messages: crate::primitives::Messages,
35}
36
37impl ZString {
38    pub fn new() -> Self {
39        Self {
40            checks: vec![],
41            transforms: vec![],
42            coerce: false,
43            custom_type_error: None,
44            messages: None,
45        }
46    }
47
48    fn push(mut self, kind: Kind, msg: Option<String>) -> Self {
49        self.checks.push(StringCheck { kind, msg });
50        self
51    }
52
53    /// Set a custom error message for type mismatch (when the input is not a string).
54    ///
55    /// # Example
56    /// ```
57    /// use vld::prelude::*;
58    /// let schema = vld::string().type_error("Must be text!");
59    /// let err = schema.parse("42").unwrap_err();
60    /// assert!(err.issues[0].message.contains("Must be text!"));
61    /// ```
62    pub fn type_error(mut self, msg: impl Into<String>) -> Self {
63        self.custom_type_error = Some(msg.into());
64        self
65    }
66
67    /// Override error messages in bulk by check key.
68    ///
69    /// The closure receives the check key (e.g. `"too_small"`, `"invalid_email"`)
70    /// and should return `Some(new_message)` to replace, or `None` to keep the original.
71    ///
72    /// Keys: `"too_small"`, `"too_big"`, `"invalid_length"`, `"non_empty"`,
73    /// `"invalid_regex"`, `"invalid_starts_with"`, `"invalid_ends_with"`,
74    /// `"invalid_contains"`, and `"invalid_<format>"` for every format check
75    /// (`"invalid_email"`, `"invalid_url"`, `"invalid_uuid"`, `"invalid_ip"`,
76    /// `"invalid_currency"`, `"invalid_phone_e164"`, …).
77    ///
78    /// The closure is consulted when an issue is built, so it covers checks
79    /// added before **and after** this call; a per-check message
80    /// (`*_msg(...)`) takes precedence over it. A later call replaces it.
81    ///
82    /// # Example
83    /// ```
84    /// use vld::prelude::*;
85    /// let schema = vld::string().min(3).email()
86    ///     .with_messages(|key| match key {
87    ///         "too_small" => Some("Too short!".into()),
88    ///         "invalid_email" => Some("Bad email!".into()),
89    ///         _ => None,
90    ///     });
91    /// ```
92    pub fn with_messages<F>(mut self, f: F) -> Self
93    where
94        F: Fn(&str) -> Option<String> + Send + Sync + 'static,
95    {
96        self.messages = Some(std::sync::Arc::new(f));
97        self
98    }
99
100    /// Minimum string length in characters (inclusive).
101    pub fn min(self, len: usize) -> Self {
102        self.push(Kind::Min(len), None)
103    }
104
105    /// Minimum string length with custom message.
106    pub fn min_msg(self, len: usize, msg: impl Into<String>) -> Self {
107        self.push(Kind::Min(len), Some(msg.into()))
108    }
109
110    /// Maximum string length in characters (inclusive).
111    pub fn max(self, len: usize) -> Self {
112        self.push(Kind::Max(len), None)
113    }
114
115    /// Maximum string length with custom message.
116    pub fn max_msg(self, len: usize, msg: impl Into<String>) -> Self {
117        self.push(Kind::Max(len), Some(msg.into()))
118    }
119
120    /// Exact string length in characters.
121    pub fn len(self, len: usize) -> Self {
122        self.push(Kind::Len(len), None)
123    }
124
125    /// Exact string length with custom message.
126    pub fn len_msg(self, len: usize, msg: impl Into<String>) -> Self {
127        self.push(Kind::Len(len), Some(msg.into()))
128    }
129
130    /// Must match the given regex (emitted as JSON Schema `pattern`).
131    ///
132    /// Requires the `regex` feature.
133    #[cfg(feature = "regex")]
134    #[cfg_attr(docsrs, doc(cfg(feature = "regex")))]
135    pub fn regex(self, re: regex_lite::Regex) -> Self {
136        self.push(Kind::Regex(re), None)
137    }
138
139    /// Must match the given regex, with custom message.
140    ///
141    /// Requires the `regex` feature.
142    #[cfg(feature = "regex")]
143    #[cfg_attr(docsrs, doc(cfg(feature = "regex")))]
144    pub fn regex_msg(self, re: regex_lite::Regex, msg: impl Into<String>) -> Self {
145        self.push(Kind::Regex(re), Some(msg.into()))
146    }
147
148    /// Must start with the given prefix.
149    pub fn starts_with(self, prefix: impl Into<String>) -> Self {
150        self.push(Kind::StartsWith(prefix.into()), None)
151    }
152
153    /// Must start with the given prefix, with custom message.
154    pub fn starts_with_msg(self, prefix: impl Into<String>, msg: impl Into<String>) -> Self {
155        self.push(Kind::StartsWith(prefix.into()), Some(msg.into()))
156    }
157
158    /// Must end with the given suffix.
159    pub fn ends_with(self, suffix: impl Into<String>) -> Self {
160        self.push(Kind::EndsWith(suffix.into()), None)
161    }
162
163    /// Must end with the given suffix, with custom message.
164    pub fn ends_with_msg(self, suffix: impl Into<String>, msg: impl Into<String>) -> Self {
165        self.push(Kind::EndsWith(suffix.into()), Some(msg.into()))
166    }
167
168    /// Must contain the given substring.
169    pub fn contains(self, sub: impl Into<String>) -> Self {
170        self.push(Kind::Contains(sub.into()), None)
171    }
172
173    /// Must contain the given substring, with custom message.
174    pub fn contains_msg(self, sub: impl Into<String>, msg: impl Into<String>) -> Self {
175        self.push(Kind::Contains(sub.into()), Some(msg.into()))
176    }
177
178    /// Must not be empty.
179    pub fn non_empty(self) -> Self {
180        self.push(Kind::NonEmpty, None)
181    }
182
183    /// Must not be empty, with custom message.
184    pub fn non_empty_msg(self, msg: impl Into<String>) -> Self {
185        self.push(Kind::NonEmpty, Some(msg.into()))
186    }
187
188    /// Trim whitespace before validation.
189    pub fn trim(mut self) -> Self {
190        self.transforms.push(StringTransform::Trim);
191        self
192    }
193
194    /// Convert to lowercase before validation.
195    pub fn to_lowercase(mut self) -> Self {
196        self.transforms.push(StringTransform::ToLowerCase);
197        self
198    }
199
200    /// Convert to uppercase before validation.
201    pub fn to_uppercase(mut self) -> Self {
202        self.transforms.push(StringTransform::ToUpperCase);
203        self
204    }
205
206    /// Coerce non-string values (numbers, booleans) to string.
207    pub fn coerce(mut self) -> Self {
208        self.coerce = true;
209        self
210    }
211}
212
213impl Default for ZString {
214    fn default() -> Self {
215        Self::new()
216    }
217}
218
219impl VldSchema for ZString {
220    type Output = String;
221
222    fn parse_value(&self, value: &Value) -> Result<String, VldError> {
223        let type_err = || crate::primitives::type_error(&self.custom_type_error, "string", value);
224
225        let mut s = match value {
226            Value::String(s) => s.clone(),
227            Value::Number(n) if self.coerce => n.to_string(),
228            Value::Bool(b) if self.coerce => b.to_string(),
229            _ => return Err(type_err()),
230        };
231
232        for t in &self.transforms {
233            s = match t {
234                StringTransform::Trim => s.trim().to_string(),
235                StringTransform::ToLowerCase => s.to_lowercase(),
236                StringTransform::ToUpperCase => s.to_uppercase(),
237            };
238        }
239
240        // The (transformed) string is reported as the received value; built
241        // only once something fails.
242        let mut received: Option<Value> = None;
243        let mut errors = VldError::new();
244        for check in &self.checks {
245            if let Some(code) = check.kind.failure(&s) {
246                let received = received.get_or_insert_with(|| Value::String(s.clone()));
247                let bulk = || self.messages.as_ref().and_then(|f| f(check.kind.key()));
248                let msg = check.msg.clone().or_else(bulk);
249                errors.issues.push(
250                    ValidationIssue::with_override(code, msg.as_ref()).with_received(received),
251                );
252            }
253        }
254
255        if errors.is_empty() {
256            Ok(s)
257        } else {
258            Err(errors)
259        }
260    }
261}
262
263/// Generate a JSON Schema representation of this string schema.
264///
265/// - Length bounds: the strictest `min`/`max`/`len`/`non_empty` wins.
266/// - Formats: the first goes into `format`, further ones into `allOf`.
267/// - `regex`/`starts_with`/`ends_with`/`contains` become `pattern`s
268///   (first in `pattern`, further ones in `allOf`).
269#[cfg(feature = "openapi")]
270impl crate::openapi::JsonSchema for ZString {
271    fn json_schema(&self) -> serde_json::Value {
272        let mut schema = serde_json::json!({"type": "string"});
273        let (mut mins, mut maxs) = (vec![], vec![]);
274        let mut formats: Vec<&str> = vec![];
275        let mut patterns: Vec<String> = vec![];
276        for check in &self.checks {
277            match &check.kind {
278                Kind::Min(n) => mins.push(*n),
279                Kind::Max(n) => maxs.push(*n),
280                Kind::Len(n) => {
281                    mins.push(*n);
282                    maxs.push(*n);
283                }
284                Kind::NonEmpty => mins.push(1),
285                Kind::StartsWith(p) => patterns.push(format!("^{}", regex_escape(p))),
286                Kind::EndsWith(p) => patterns.push(format!("{}$", regex_escape(p))),
287                Kind::Contains(p) => patterns.push(regex_escape(p)),
288                #[cfg(feature = "regex")]
289                Kind::Regex(re) => patterns.push(re.as_str().to_string()),
290                Kind::Format(f) => {
291                    if !formats.contains(&f.schema_format()) {
292                        formats.push(f.schema_format());
293                    }
294                }
295            }
296        }
297        crate::primitives::length_schema(&mut schema, ("minLength", "maxLength"), mins, maxs);
298        let mut all_of = vec![];
299        let mut formats = formats.into_iter();
300        if let Some(f) = formats.next() {
301            schema["format"] = serde_json::json!(f);
302        }
303        all_of.extend(formats.map(|f| serde_json::json!({ "format": f })));
304        let mut patterns = patterns.into_iter();
305        if let Some(p) = patterns.next() {
306            schema["pattern"] = serde_json::json!(p);
307        }
308        all_of.extend(patterns.map(|p| serde_json::json!({ "pattern": p })));
309        if !all_of.is_empty() {
310            schema["allOf"] = serde_json::Value::Array(all_of);
311        }
312        schema
313    }
314}
315
316#[cfg(feature = "openapi")]
317impl crate::openapi::CollectNestedSchemas for ZString {}