Skip to main content

vld/
rules.rs

1//! Reusable validation rules.
2//!
3//! A [`Rule`] is a named check you write once and attach anywhere: on a
4//! string schema with [`ZString::rule`](crate::primitives::ZString::rule), or on
5//! any schema with [`VldSchema::rule`](crate::schema::VldSchema::rule). A
6//! failing rule reports `IssueCode::Custom { code: <key> }`, so its message can
7//! be translated by key (see [`i18n`](crate::i18n)) and set per schema with
8//! `with_messages` / `rule_msg`.
9//!
10//! ```
11//! use vld::prelude::*;
12//! use vld::rules::Rule;
13//!
14//! /// Russian mobile number: `+7` and ten digits.
15//! struct RuPhone;
16//!
17//! impl Rule<str> for RuPhone {
18//!     fn key(&self) -> &str {
19//!         "ru_phone"
20//!     }
21//!     fn check(&self, s: &str) -> bool {
22//!         s.strip_prefix("+7")
23//!             .is_some_and(|d| d.len() == 10 && d.bytes().all(|b| b.is_ascii_digit()))
24//!     }
25//!     fn message(&self) -> Option<String> {
26//!         Some("Must be a Russian phone number (+7XXXXXXXXXX)".into())
27//!     }
28//! }
29//!
30//! let phone = vld::string().trim().rule(RuPhone);
31//! assert!(phone.parse(r#""+79991234567""#).is_ok());
32//! let err = phone.parse(r#""8999""#).unwrap_err();
33//! assert_eq!(err.issues[0].code.key(), "ru_phone");
34//!
35//! // A closure rule, on any schema.
36//! let even = vld::number().int().rule(vld::rules::from_fn("even", |n: &i64| n % 2 == 0));
37//! assert!(even.parse("4").is_ok());
38//! assert!(even.parse("5").is_err());
39//! ```
40
41use serde_json::Value;
42use std::sync::Arc;
43
44use crate::error::{IssueCode, ValidationIssue, VldError};
45
46/// A named, reusable check on values of type `T`.
47///
48/// Implement it on a type (`struct RuPhone;`) and attach it with `.rule(...)`.
49/// Rules are shared by schemas that are built once and reused, so they must be
50/// `Send + Sync + 'static`.
51pub trait Rule<T: ?Sized>: Send + Sync + 'static {
52    /// Stable key: the issue code (`IssueCode::Custom { code: key }`), the
53    /// `with_messages` key and the translation key.
54    fn key(&self) -> &str;
55
56    /// `true` when `value` passes.
57    fn check(&self, value: &T) -> bool;
58
59    /// Default message. `None` uses the generic `custom` message, which
60    /// translators can replace through the rule's key; `Some` is kept as is.
61    fn message(&self) -> Option<String> {
62        None
63    }
64
65    /// JSON Schema keywords this rule adds (`{"format": "ru-phone"}`,
66    /// `{"pattern": "..."}`); merged into the schema it is attached to.
67    #[cfg(feature = "openapi")]
68    fn json_schema(&self) -> Option<Value> {
69        None
70    }
71}
72
73/// The issue a failing `rule` reports; `msg` (from `rule_msg` /
74/// `with_messages`) wins over the rule's own message.
75pub(crate) fn failure<T: ?Sized, R: Rule<T> + ?Sized>(
76    rule: &R,
77    msg: Option<String>,
78) -> ValidationIssue {
79    let code = IssueCode::Custom {
80        code: rule.key().to_string(),
81    };
82    let msg = msg.or_else(|| rule.message());
83    ValidationIssue::with_override(code, msg.as_ref())
84}
85
86/// A rule from a closure: `vld::rules::from_fn("even", |n: &i64| n % 2 == 0)`.
87pub fn from_fn<T: ?Sized, F>(key: impl Into<String>, check: F) -> FnRule<F>
88where
89    F: Fn(&T) -> bool + Send + Sync + 'static,
90{
91    FnRule {
92        key: key.into(),
93        check,
94        message: None,
95        #[cfg(feature = "openapi")]
96        schema: None,
97    }
98}
99
100/// Rule built by [`from_fn`].
101pub struct FnRule<F> {
102    key: String,
103    check: F,
104    message: Option<String>,
105    #[cfg(feature = "openapi")]
106    schema: Option<Value>,
107}
108
109impl<F> FnRule<F> {
110    /// Default message of the rule.
111    pub fn message(mut self, msg: impl Into<String>) -> Self {
112        self.message = Some(msg.into());
113        self
114    }
115
116    /// JSON Schema keywords the rule adds (see [`Rule::json_schema`]).
117    #[cfg(feature = "openapi")]
118    pub fn json_schema(mut self, keywords: Value) -> Self {
119        self.schema = Some(keywords);
120        self
121    }
122}
123
124impl<T: ?Sized, F> Rule<T> for FnRule<F>
125where
126    F: Fn(&T) -> bool + Send + Sync + 'static,
127{
128    fn key(&self) -> &str {
129        &self.key
130    }
131    fn check(&self, value: &T) -> bool {
132        (self.check)(value)
133    }
134    fn message(&self) -> Option<String> {
135        self.message.clone()
136    }
137    #[cfg(feature = "openapi")]
138    fn json_schema(&self) -> Option<Value> {
139        self.schema.clone()
140    }
141}
142
143impl<T: ?Sized, R: Rule<T> + ?Sized> Rule<T> for Arc<R> {
144    fn key(&self) -> &str {
145        (**self).key()
146    }
147    fn check(&self, value: &T) -> bool {
148        (**self).check(value)
149    }
150    fn message(&self) -> Option<String> {
151        (**self).message()
152    }
153    #[cfg(feature = "openapi")]
154    fn json_schema(&self) -> Option<Value> {
155        (**self).json_schema()
156    }
157}
158
159/// A regular-expression rule for strings (feature `regex`).
160///
161/// Compiled once when built; matching follows the Rust `regex` syntax (no
162/// look-around, no backreferences). Attach it with
163/// [`ZString::pattern`](crate::primitives::ZString::pattern).
164/// Its issues are `invalid_string` / `StringValidation::Regex`
165/// (`with_messages` key `invalid_regex`), and the JSON Schema gets `pattern`.
166#[cfg(feature = "regex")]
167#[cfg_attr(docsrs, doc(cfg(feature = "regex")))]
168#[derive(Clone)]
169pub struct Pattern(regex_lite::Regex);
170
171#[cfg(feature = "regex")]
172impl Pattern {
173    /// Compile `pattern`.
174    pub fn new(pattern: &str) -> Result<Self, PatternError> {
175        regex_lite::Regex::new(pattern)
176            .map(Self)
177            .map_err(|e| PatternError(e.to_string()))
178    }
179
180    /// The pattern source.
181    pub fn as_str(&self) -> &str {
182        self.0.as_str()
183    }
184
185    /// `true` when `s` matches.
186    pub fn is_match(&self, s: &str) -> bool {
187        self.0.is_match(s)
188    }
189}
190
191#[cfg(feature = "regex")]
192impl std::fmt::Debug for Pattern {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.debug_tuple("Pattern").field(&self.as_str()).finish()
195    }
196}
197
198/// What [`ZString::pattern`](crate::primitives::ZString::pattern) accepts: a
199/// source string (compiled, panics when invalid) or a [`Pattern`].
200#[cfg(feature = "regex")]
201#[cfg_attr(docsrs, doc(cfg(feature = "regex")))]
202pub trait IntoPattern {
203    /// The compiled pattern.
204    fn into_pattern(self) -> Pattern;
205}
206
207#[cfg(feature = "regex")]
208impl IntoPattern for Pattern {
209    fn into_pattern(self) -> Pattern {
210        self
211    }
212}
213
214#[cfg(feature = "regex")]
215impl IntoPattern for &str {
216    #[track_caller]
217    fn into_pattern(self) -> Pattern {
218        Pattern::new(self).unwrap_or_else(|e| panic!("vld: {e} (`{self}`)"))
219    }
220}
221
222#[cfg(feature = "regex")]
223impl IntoPattern for String {
224    #[track_caller]
225    fn into_pattern(self) -> Pattern {
226        self.as_str().into_pattern()
227    }
228}
229
230/// An invalid regular expression given to [`Pattern::new`].
231#[cfg(feature = "regex")]
232#[cfg_attr(docsrs, doc(cfg(feature = "regex")))]
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct PatternError(String);
235
236#[cfg(feature = "regex")]
237impl std::fmt::Display for PatternError {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        write!(f, "invalid pattern: {}", self.0)
240    }
241}
242
243#[cfg(feature = "regex")]
244impl std::error::Error for PatternError {}
245
246/// Wrapper returned by [`VldSchema::rule`](crate::schema::VldSchema::rule):
247/// runs `rule` on the inner schema's output.
248pub struct ZRule<S, R> {
249    inner: S,
250    rule: R,
251    msg: Option<String>,
252}
253
254impl<S, R> ZRule<S, R> {
255    pub(crate) fn new(inner: S, rule: R, msg: Option<String>) -> Self {
256        Self { inner, rule, msg }
257    }
258}
259
260impl<S: crate::schema::VldSchema, R: Rule<S::Output>> ZRule<S, R> {
261    fn finish(&self, out: S::Output) -> Result<S::Output, VldError> {
262        if self.rule.check(&out) {
263            Ok(out)
264        } else {
265            Err(failure::<S::Output, R>(&self.rule, self.msg.clone()).into())
266        }
267    }
268}
269
270impl<S, R> crate::schema::VldSchema for ZRule<S, R>
271where
272    S: crate::schema::VldSchema,
273    R: Rule<S::Output>,
274{
275    type Output = S::Output;
276
277    fn parse_value(&self, value: &Value) -> Result<S::Output, VldError> {
278        self.finish(self.inner.parse_value(value)?)
279    }
280
281    fn parse_missing(&self) -> Result<S::Output, VldError> {
282        self.finish(self.inner.parse_missing()?)
283    }
284
285    fn output_json(&self, o: &Self::Output) -> Option<Value> {
286        self.inner.output_json(o)
287    }
288}
289
290#[cfg(feature = "openapi")]
291impl<S, R> crate::openapi::JsonSchema for ZRule<S, R>
292where
293    S: crate::openapi::JsonSchema,
294    R: Rule<<S as crate::schema::VldSchema>::Output>,
295    S: crate::schema::VldSchema,
296{
297    fn json_schema(&self) -> Value {
298        let mut schema = self.inner.json_schema();
299        if let Some(extra) = self.rule.json_schema() {
300            merge_keywords(&mut schema, extra);
301        }
302        schema
303    }
304}
305
306#[cfg(feature = "openapi")]
307impl<S: crate::openapi::CollectNestedSchemas, R> crate::openapi::CollectNestedSchemas
308    for ZRule<S, R>
309{
310    fn collect_nested_schemas(&self, out: &mut Vec<crate::openapi::NestedSchemaEntry>) {
311        self.inner.collect_nested_schemas(out);
312    }
313}
314
315/// Merge `extra` keywords into `schema`; a keyword the schema already has
316/// goes into `allOf` instead of overwriting it.
317#[cfg(feature = "openapi")]
318pub(crate) fn merge_keywords(schema: &mut Value, extra: Value) {
319    let (Some(base), Value::Object(extra)) = (schema.as_object_mut(), extra) else {
320        return;
321    };
322    let mut clash = serde_json::Map::new();
323    for (k, v) in extra {
324        if base.contains_key(&k) {
325            clash.insert(k, v);
326        } else {
327            base.insert(k, v);
328        }
329    }
330    if !clash.is_empty() {
331        let all_of = base
332            .entry("allOf")
333            .or_insert_with(|| Value::Array(Vec::new()));
334        if let Value::Array(items) = all_of {
335            items.push(Value::Object(clash));
336        }
337    }
338}