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    /// If present (and non-null), the value must be a finite number in `[min, max]`.
222    ///
223    /// For a field that is optional (its absence is meaningful) but must be
224    /// bounded when supplied — e.g. a 0–10 repairability score that gates a
225    /// verdict only when present.
226    pub fn optional_range(&mut self, key: &str, min: f64, max: f64) -> &mut Self {
227        let err = match present(self.input, key) {
228            None => None,
229            Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
230                Some(n) if (min..=max).contains(&n) => None,
231                _ => Some((
232                    "out_of_range",
233                    format!("{key} must be a number in {min}..={max}"),
234                )),
235            },
236        };
237        self.push_opt(key, err);
238        self
239    }
240
241    /// If present (and non-null), the value must be a finite number ≥ 0.
242    pub fn optional_non_negative(&mut self, key: &str) -> &mut Self {
243        let err = match present(self.input, key) {
244            None => None,
245            Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
246                Some(n) if n >= 0.0 => None,
247                _ => Some(("out_of_range", format!("{key} must be a finite number ≥ 0"))),
248            },
249        };
250        self.push_opt(key, err);
251        self
252    }
253
254    /// Finish validation, returning every collected error at once.
255    pub fn finish(&mut self) -> Result<(), PluginError> {
256        if self.errors.is_empty() {
257            Ok(())
258        } else {
259            Err(PluginError::ValidationErrors(std::mem::take(
260                &mut self.errors,
261            )))
262        }
263    }
264}
265
266/// GS1 check-digit validation for GTIN-14.
267///
268/// Even-indexed positions (0, 2, 4, … 12) are weighted ×3; odd-indexed ×1.
269/// The check digit at position 13 must equal `(10 − sum mod 10) mod 10`.
270fn gs1_check_digit_valid(gtin: &str) -> bool {
271    let bytes = gtin.as_bytes();
272    debug_assert_eq!(bytes.len(), 14, "caller must check length == 14 first");
273    let sum: u32 = bytes[..13]
274        .iter()
275        .enumerate()
276        .map(|(i, &b)| {
277            let d = (b - b'0') as u32;
278            if i % 2 == 0 { d * 3 } else { d }
279        })
280        .sum();
281    let expected = (10 - sum % 10) % 10;
282    expected == (bytes[13] - b'0') as u32
283}
284
285/// All 249 ISO 3166-1 alpha-2 country codes, sorted for binary search.
286const ISO_3166_1_A2: &[&str] = &[
287    "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ",
288    "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS",
289    "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN",
290    "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE",
291    "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF",
292    "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM",
293    "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM",
294    "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC",
295    "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK",
296    "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA",
297    "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG",
298    "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW",
299    "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS",
300    "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO",
301    "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI",
302    "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW",
303];
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use serde_json::json;
309
310    #[test]
311    fn collects_all_failures() {
312        let input = json!({ "gtin": "12-34", "voltage": -1.0 });
313        let err = Validator::new(&input)
314            .require_gtin("gtin")
315            .require_positive("voltage")
316            .require_str("name")
317            .finish()
318            .unwrap_err();
319        match err {
320            PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
321            other => panic!("expected ValidationErrors, got {other:?}"),
322        }
323    }
324
325    #[test]
326    fn valid_input_passes() {
327        let input = json!({
328            "gtin": "12345678901231",
329            "country": "DE",
330            "pct": 42.0,
331            "count": 3,
332            "flag": true,
333            "items": [1]
334        });
335        assert!(
336            Validator::new(&input)
337                .require_gtin("gtin")
338                .require_country("country")
339                .require_pct("pct")
340                .require_positive_int("count")
341                .require_bool("flag")
342                .require_non_empty_array("items")
343                .finish()
344                .is_ok()
345        );
346    }
347
348    #[test]
349    fn enum_and_country_and_pct_bounds() {
350        let input = json!({ "cls": "Z", "country": "de", "pct": 150.0 });
351        let err = Validator::new(&input)
352            .require_enum("cls", &["A", "B"])
353            .require_country("country")
354            .require_pct("pct")
355            .finish()
356            .unwrap_err();
357        match err {
358            PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
359            other => panic!("expected ValidationErrors, got {other:?}"),
360        }
361    }
362
363    #[test]
364    fn gtin_invalid_check_digit_is_rejected() {
365        // "12345678901234" — correct digits, correct length, but check digit should be 1, not 4.
366        let input = json!({ "gtin": "12345678901234" });
367        let err = Validator::new(&input)
368            .require_gtin("gtin")
369            .finish()
370            .unwrap_err();
371        match err {
372            PluginError::ValidationErrors(errs) => {
373                assert_eq!(errs.len(), 1);
374                assert_eq!(errs[0].code, "checksum");
375            }
376            other => panic!("expected ValidationErrors, got {other:?}"),
377        }
378    }
379
380    #[test]
381    fn gtin_valid_check_digit_passes() {
382        // "12345678901231" — check digit = 1, matches GS1 calculation.
383        let input = json!({ "gtin": "12345678901231" });
384        assert!(Validator::new(&input).require_gtin("gtin").finish().is_ok());
385    }
386
387    #[test]
388    fn country_not_in_iso_list_is_rejected() {
389        // "XX" has the right format (2 uppercase letters) but is not an assigned code.
390        let input = json!({ "country": "XX" });
391        let err = Validator::new(&input)
392            .require_country("country")
393            .finish()
394            .unwrap_err();
395        match err {
396            PluginError::ValidationErrors(errs) => {
397                assert_eq!(errs.len(), 1);
398                assert_eq!(errs[0].code, "invalid");
399            }
400            other => panic!("expected ValidationErrors, got {other:?}"),
401        }
402    }
403
404    #[test]
405    fn country_valid_iso_code_passes() {
406        for code in ["DE", "NO", "FR", "US", "JP"] {
407            let input = json!({ "country": code });
408            assert!(
409                Validator::new(&input)
410                    .require_country("country")
411                    .finish()
412                    .is_ok(),
413                "{code} should be a valid ISO 3166-1 alpha-2 code"
414            );
415        }
416    }
417
418    #[test]
419    fn optional_pct_absent_is_ok_present_out_of_range_fails() {
420        let ok = json!({});
421        assert!(Validator::new(&ok).optional_pct("x").finish().is_ok());
422        let bad = json!({ "x": 101.0 });
423        assert!(Validator::new(&bad).optional_pct("x").finish().is_err());
424    }
425
426    #[test]
427    fn optional_range_absent_ok_present_bounded() {
428        // Absent → ok (the field's absence is meaningful).
429        assert!(
430            Validator::new(&json!({}))
431                .optional_range("s", 0.0, 10.0)
432                .finish()
433                .is_ok()
434        );
435        // In range → ok.
436        assert!(
437            Validator::new(&json!({ "s": 6.0 }))
438                .optional_range("s", 0.0, 10.0)
439                .finish()
440                .is_ok()
441        );
442        // Out of range → err (the fail-open case the plugins guard against).
443        for bad in [json!({ "s": 999999.0 }), json!({ "s": -1.0 })] {
444            assert!(
445                Validator::new(&bad)
446                    .optional_range("s", 0.0, 10.0)
447                    .finish()
448                    .is_err()
449            );
450        }
451    }
452
453    #[test]
454    fn optional_non_negative_absent_ok_negative_fails() {
455        assert!(
456            Validator::new(&json!({}))
457                .optional_non_negative("c")
458                .finish()
459                .is_ok()
460        );
461        assert!(
462            Validator::new(&json!({ "c": 0.0 }))
463                .optional_non_negative("c")
464                .finish()
465                .is_ok()
466        );
467        assert!(
468            Validator::new(&json!({ "c": -999.0 }))
469                .optional_non_negative("c")
470                .finish()
471                .is_err()
472        );
473    }
474
475    #[test]
476    fn readers_extract_values() {
477        let input = json!({ "n": 3.5, "s": "hi", "bad": "x" });
478        assert_eq!(num(&input, "n"), Some(3.5));
479        assert_eq!(num(&input, "bad"), None);
480        assert_eq!(str_of(&input, "s"), Some("hi"));
481    }
482}