Skip to main content

dpp_plugin_sdk/
validate.rs

1//! Reusable field validation for sector plugins.
2//!
3//! [`Validator`] is a fluent collector: each `require_*` / `optional_*` method
4//! records a [`PluginFieldError`] when a check fails and is chainable, so a
5//! plugin's `validate_input` reads as a declarative list of field constraints.
6//! [`Validator::finish`] reports *all* failures at once rather than stopping at
7//! the first — better for surfacing form errors to a manufacturer.
8//!
9//! The free functions [`num`] and [`str_of`] are convenience readers for
10//! `calculate_metrics` bodies.
11
12use dpp_plugin_traits::{PluginError, PluginFieldError, PluginInput};
13use serde_json::Value;
14
15/// A present, non-null value for `key`, or `None` if absent/null.
16fn present<'a>(input: &'a Value, key: &str) -> Option<&'a Value> {
17    match input.get(key) {
18        Some(Value::Null) | None => None,
19        other => other,
20    }
21}
22
23/// Read a finite number field (ignores absent/non-numeric/NaN/inf).
24#[must_use]
25pub fn num(input: &PluginInput, key: &str) -> Option<f64> {
26    input
27        .get(key)
28        .and_then(Value::as_f64)
29        .filter(|n| n.is_finite())
30}
31
32/// Read a string field.
33#[must_use]
34pub fn str_of<'a>(input: &'a PluginInput, key: &str) -> Option<&'a str> {
35    input.get(key).and_then(Value::as_str)
36}
37
38/// Fluent per-field validator. See module docs.
39pub struct Validator<'a> {
40    input: &'a Value,
41    errors: Vec<PluginFieldError>,
42}
43
44impl<'a> Validator<'a> {
45    #[must_use]
46    pub fn new(input: &'a PluginInput) -> Self {
47        Self {
48            input,
49            errors: Vec::new(),
50        }
51    }
52
53    fn push_opt(&mut self, key: &str, err: Option<(&str, String)>) {
54        if let Some((code, message)) = err {
55            self.errors.push(PluginFieldError {
56                field: format!("/{key}"),
57                code: code.to_owned(),
58                message,
59            });
60        }
61    }
62
63    /// Require a present, non-empty string.
64    pub fn require_str(&mut self, key: &str) -> &mut Self {
65        let err = match present(self.input, key) {
66            None => Some(("missing", format!("{key} is required"))),
67            Some(v) => match v.as_str() {
68                Some(s) if !s.trim().is_empty() => None,
69                Some(_) => Some(("empty", format!("{key} must not be empty"))),
70                None => Some(("type", format!("{key} must be a string"))),
71            },
72        };
73        self.push_opt(key, err);
74        self
75    }
76
77    /// Require a string field whose value is one of `allowed`.
78    pub fn require_enum(&mut self, key: &str, allowed: &[&str]) -> &mut Self {
79        let err = match present(self.input, key).and_then(Value::as_str) {
80            None => Some(("missing", format!("{key} is required"))),
81            Some(s) if allowed.contains(&s) => None,
82            Some(_) => Some(("out_of_range", format!("{key} must be one of {allowed:?}"))),
83        };
84        self.push_opt(key, err);
85        self
86    }
87
88    /// Require a 14-digit GS1 GTIN string with a valid check digit.
89    pub fn require_gtin(&mut self, key: &str) -> &mut Self {
90        let err = match present(self.input, key).and_then(Value::as_str) {
91            None => Some(("missing", format!("{key} is required"))),
92            Some(g) if g.len() == 14 && g.bytes().all(|b| b.is_ascii_digit()) => {
93                if gs1_check_digit_valid(g) {
94                    None
95                } else {
96                    Some(("checksum", format!("{key} has an invalid GS1 check digit")))
97                }
98            }
99            Some(_) => Some(("format", format!("{key} must be 14 digits"))),
100        };
101        self.push_opt(key, err);
102        self
103    }
104
105    /// Require a recognized ISO 3166-1 alpha-2 country code.
106    pub fn require_country(&mut self, key: &str) -> &mut Self {
107        let err = match present(self.input, key).and_then(Value::as_str) {
108            None => Some(("missing", format!("{key} is required"))),
109            Some(c) if c.len() == 2 && c.bytes().all(|b| b.is_ascii_uppercase()) => {
110                if ISO_3166_1_A2.binary_search(&c).is_ok() {
111                    None
112                } else {
113                    Some((
114                        "invalid",
115                        format!("{key} is not a recognized ISO 3166-1 alpha-2 code"),
116                    ))
117                }
118            }
119            Some(_) => Some((
120                "format",
121                format!("{key} must be a 2-letter uppercase country code"),
122            )),
123        };
124        self.push_opt(key, err);
125        self
126    }
127
128    /// Require a present, finite number greater than 0.
129    pub fn require_positive(&mut self, key: &str) -> &mut Self {
130        let err = match num(self.input, key) {
131            None => Some((
132                "missing",
133                format!("{key} is required and must be a finite number"),
134            )),
135            Some(v) if v <= 0.0 => Some(("out_of_range", format!("{key} must be greater than 0"))),
136            Some(_) => None,
137        };
138        self.push_opt(key, err);
139        self
140    }
141
142    /// Require a present, finite number greater than or equal to 0.
143    pub fn require_non_negative(&mut self, key: &str) -> &mut Self {
144        let err = match num(self.input, key) {
145            None => Some((
146                "missing",
147                format!("{key} is required and must be a finite number"),
148            )),
149            Some(v) if v < 0.0 => Some(("out_of_range", format!("{key} must be 0 or greater"))),
150            Some(_) => None,
151        };
152        self.push_opt(key, err);
153        self
154    }
155
156    /// Require a present, finite number in `[0, 100]`.
157    pub fn require_pct(&mut self, key: &str) -> &mut Self {
158        let err = match num(self.input, key) {
159            None => Some((
160                "missing",
161                format!("{key} is required and must be a number in 0..=100"),
162            )),
163            Some(v) if !(0.0..=100.0).contains(&v) => {
164                Some(("out_of_range", format!("{key} must be in 0..=100")))
165            }
166            Some(_) => None,
167        };
168        self.push_opt(key, err);
169        self
170    }
171
172    /// Require a present non-negative integer that is at least 1.
173    pub fn require_positive_int(&mut self, key: &str) -> &mut Self {
174        let err = match present(self.input, key).and_then(Value::as_u64) {
175            None => Some((
176                "missing",
177                format!("{key} is required and must be a non-negative integer"),
178            )),
179            Some(0) => Some(("out_of_range", format!("{key} must be at least 1"))),
180            Some(_) => None,
181        };
182        self.push_opt(key, err);
183        self
184    }
185
186    /// Require a present boolean.
187    pub fn require_bool(&mut self, key: &str) -> &mut Self {
188        let err = match present(self.input, key) {
189            None => Some(("missing", format!("{key} is required"))),
190            Some(v) if v.is_boolean() => None,
191            Some(_) => Some(("type", format!("{key} must be a boolean"))),
192        };
193        self.push_opt(key, err);
194        self
195    }
196
197    /// Require a present, non-empty array.
198    pub fn require_non_empty_array(&mut self, key: &str) -> &mut Self {
199        let err = match present(self.input, key).and_then(Value::as_array) {
200            None => Some(("missing", format!("{key} is required and must be an array"))),
201            Some(a) if a.is_empty() => Some(("empty", format!("{key} must not be empty"))),
202            Some(_) => None,
203        };
204        self.push_opt(key, err);
205        self
206    }
207
208    /// If present (and non-null), the value must be a finite number in `[0, 100]`.
209    pub fn optional_pct(&mut self, key: &str) -> &mut Self {
210        let err = match present(self.input, key) {
211            None => None,
212            Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
213                Some(n) if (0.0..=100.0).contains(&n) => None,
214                _ => Some(("out_of_range", format!("{key} must be a number in 0..=100"))),
215            },
216        };
217        self.push_opt(key, err);
218        self
219    }
220
221    /// Finish validation, returning every collected error at once.
222    pub fn finish(&mut self) -> Result<(), PluginError> {
223        if self.errors.is_empty() {
224            Ok(())
225        } else {
226            Err(PluginError::ValidationErrors(std::mem::take(
227                &mut self.errors,
228            )))
229        }
230    }
231}
232
233/// GS1 check-digit validation for GTIN-14.
234///
235/// Even-indexed positions (0, 2, 4, … 12) are weighted ×3; odd-indexed ×1.
236/// The check digit at position 13 must equal `(10 − sum mod 10) mod 10`.
237fn gs1_check_digit_valid(gtin: &str) -> bool {
238    let bytes = gtin.as_bytes();
239    debug_assert_eq!(bytes.len(), 14, "caller must check length == 14 first");
240    let sum: u32 = bytes[..13]
241        .iter()
242        .enumerate()
243        .map(|(i, &b)| {
244            let d = (b - b'0') as u32;
245            if i % 2 == 0 { d * 3 } else { d }
246        })
247        .sum();
248    let expected = (10 - sum % 10) % 10;
249    expected == (bytes[13] - b'0') as u32
250}
251
252/// All 249 ISO 3166-1 alpha-2 country codes, sorted for binary search.
253const ISO_3166_1_A2: &[&str] = &[
254    "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ",
255    "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS",
256    "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN",
257    "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE",
258    "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF",
259    "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM",
260    "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM",
261    "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC",
262    "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK",
263    "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA",
264    "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG",
265    "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW",
266    "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS",
267    "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO",
268    "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI",
269    "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW",
270];
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use serde_json::json;
276
277    #[test]
278    fn collects_all_failures() {
279        let input = json!({ "gtin": "12-34", "voltage": -1.0 });
280        let err = Validator::new(&input)
281            .require_gtin("gtin")
282            .require_positive("voltage")
283            .require_str("name")
284            .finish()
285            .unwrap_err();
286        match err {
287            PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
288            other => panic!("expected ValidationErrors, got {other:?}"),
289        }
290    }
291
292    #[test]
293    fn valid_input_passes() {
294        let input = json!({
295            "gtin": "12345678901231",
296            "country": "DE",
297            "pct": 42.0,
298            "count": 3,
299            "flag": true,
300            "items": [1]
301        });
302        assert!(
303            Validator::new(&input)
304                .require_gtin("gtin")
305                .require_country("country")
306                .require_pct("pct")
307                .require_positive_int("count")
308                .require_bool("flag")
309                .require_non_empty_array("items")
310                .finish()
311                .is_ok()
312        );
313    }
314
315    #[test]
316    fn enum_and_country_and_pct_bounds() {
317        let input = json!({ "cls": "Z", "country": "de", "pct": 150.0 });
318        let err = Validator::new(&input)
319            .require_enum("cls", &["A", "B"])
320            .require_country("country")
321            .require_pct("pct")
322            .finish()
323            .unwrap_err();
324        match err {
325            PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
326            other => panic!("expected ValidationErrors, got {other:?}"),
327        }
328    }
329
330    #[test]
331    fn gtin_invalid_check_digit_is_rejected() {
332        // "12345678901234" — correct digits, correct length, but check digit should be 1, not 4.
333        let input = json!({ "gtin": "12345678901234" });
334        let err = Validator::new(&input)
335            .require_gtin("gtin")
336            .finish()
337            .unwrap_err();
338        match err {
339            PluginError::ValidationErrors(errs) => {
340                assert_eq!(errs.len(), 1);
341                assert_eq!(errs[0].code, "checksum");
342            }
343            other => panic!("expected ValidationErrors, got {other:?}"),
344        }
345    }
346
347    #[test]
348    fn gtin_valid_check_digit_passes() {
349        // "12345678901231" — check digit = 1, matches GS1 calculation.
350        let input = json!({ "gtin": "12345678901231" });
351        assert!(Validator::new(&input).require_gtin("gtin").finish().is_ok());
352    }
353
354    #[test]
355    fn country_not_in_iso_list_is_rejected() {
356        // "XX" has the right format (2 uppercase letters) but is not an assigned code.
357        let input = json!({ "country": "XX" });
358        let err = Validator::new(&input)
359            .require_country("country")
360            .finish()
361            .unwrap_err();
362        match err {
363            PluginError::ValidationErrors(errs) => {
364                assert_eq!(errs.len(), 1);
365                assert_eq!(errs[0].code, "invalid");
366            }
367            other => panic!("expected ValidationErrors, got {other:?}"),
368        }
369    }
370
371    #[test]
372    fn country_valid_iso_code_passes() {
373        for code in ["DE", "NO", "FR", "US", "JP"] {
374            let input = json!({ "country": code });
375            assert!(
376                Validator::new(&input)
377                    .require_country("country")
378                    .finish()
379                    .is_ok(),
380                "{code} should be a valid ISO 3166-1 alpha-2 code"
381            );
382        }
383    }
384
385    #[test]
386    fn optional_pct_absent_is_ok_present_out_of_range_fails() {
387        let ok = json!({});
388        assert!(Validator::new(&ok).optional_pct("x").finish().is_ok());
389        let bad = json!({ "x": 101.0 });
390        assert!(Validator::new(&bad).optional_pct("x").finish().is_err());
391    }
392
393    #[test]
394    fn readers_extract_values() {
395        let input = json!({ "n": 3.5, "s": "hi", "bad": "x" });
396        assert_eq!(num(&input, "n"), Some(3.5));
397        assert_eq!(num(&input, "bad"), None);
398        assert_eq!(str_of(&input, "s"), Some("hi"));
399    }
400}