Skip to main content

dpp_plugin_sdk/
validate.rs

1//! Reusable field validation for product group 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, and [`threshold_status`] is the shared
11//! "measured value at or under threshold" classification they compare against.
12
13use dpp_plugin_traits::{PluginComplianceStatus, PluginError, PluginFieldError, PluginInput};
14use dpp_rules::common::identifier::{DidRejection, check_did, is_absolute_web_url};
15use serde_json::Value;
16
17/// A present, non-null value for `key`, or `None` if absent/null.
18fn present<'a>(input: &'a Value, key: &str) -> Option<&'a Value> {
19    match input.get(key) {
20        Some(Value::Null) | None => None,
21        other => other,
22    }
23}
24
25/// Read a finite number field (ignores absent/non-numeric/NaN/inf).
26#[must_use]
27pub fn num(input: &PluginInput, key: &str) -> Option<f64> {
28    input
29        .get(key)
30        .and_then(Value::as_f64)
31        .filter(|n| n.is_finite())
32}
33
34/// Read a string field.
35#[must_use]
36pub fn str_of<'a>(input: &'a PluginInput, key: &str) -> Option<&'a str> {
37    input.get(key).and_then(Value::as_str)
38}
39
40/// Classify a measured value against a compliance threshold: `Compliant` if
41/// present and at or under `threshold`, `NonCompliant` otherwise (including
42/// when `value` is absent — a missing measurement is not assumed compliant).
43#[must_use]
44pub fn threshold_status(value: Option<f64>, threshold: f64) -> PluginComplianceStatus {
45    if value.is_some_and(|v| v <= threshold) {
46        PluginComplianceStatus::Compliant
47    } else {
48        PluginComplianceStatus::NonCompliant
49    }
50}
51
52/// Fluent per-field validator. See module docs.
53pub struct Validator<'a> {
54    input: &'a Value,
55    errors: Vec<PluginFieldError>,
56}
57
58impl<'a> Validator<'a> {
59    #[must_use]
60    pub fn new(input: &'a PluginInput) -> Self {
61        Self {
62            input,
63            errors: Vec::new(),
64        }
65    }
66
67    fn push_opt(&mut self, key: &str, err: Option<(&str, String)>) {
68        if let Some((code, message)) = err {
69            self.errors.push(PluginFieldError {
70                field: format!("/{key}"),
71                code: code.to_owned(),
72                message,
73            });
74        }
75    }
76
77    /// Record a failure against an already-built field path.
78    ///
79    /// [`push_opt`](Self::push_opt) derives `/{key}` from a single field name,
80    /// which cannot name a field *inside* an object. A nested identifier
81    /// reports `/productIdentifier/gtin`, so the caller builds the path.
82    fn push_at(&mut self, field: String, code: &str, message: String) {
83        self.errors.push(PluginFieldError {
84            field,
85            code: code.to_owned(),
86            message,
87        });
88    }
89
90    /// Require a present, non-empty string.
91    pub fn require_str(&mut self, key: &str) -> &mut Self {
92        let err = match present(self.input, key) {
93            None => Some(("missing", format!("{key} is required"))),
94            Some(v) => match v.as_str() {
95                Some(s) if !s.trim().is_empty() => None,
96                Some(_) => Some(("empty", format!("{key} must not be empty"))),
97                None => Some(("type", format!("{key} must be a string"))),
98            },
99        };
100        self.push_opt(key, err);
101        self
102    }
103
104    /// Require a string field whose value is one of `allowed`.
105    pub fn require_enum(&mut self, key: &str, allowed: &[&str]) -> &mut Self {
106        let err = match present(self.input, key).and_then(Value::as_str) {
107            None => Some(("missing", format!("{key} is required"))),
108            Some(s) if allowed.contains(&s) => None,
109            Some(_) => Some(("out_of_range", format!("{key} must be one of {allowed:?}"))),
110        };
111        self.push_opt(key, err);
112        self
113    }
114
115    /// Require a 14-digit GS1 GTIN string with a valid check digit.
116    pub fn require_gtin(&mut self, key: &str) -> &mut Self {
117        let err = match present(self.input, key).and_then(Value::as_str) {
118            None => Some(("missing", format!("{key} is required"))),
119            Some(g) if g.len() == 14 && g.bytes().all(|b| b.is_ascii_digit()) => {
120                if gs1_check_digit_valid(g) {
121                    None
122                } else {
123                    Some(("checksum", format!("{key} has an invalid GS1 check digit")))
124                }
125            }
126            Some(_) => Some(("format", format!("{key} must be 14 digits"))),
127        };
128        self.push_opt(key, err);
129        self
130    }
131
132    /// Require an EN 18219:2026 clause 5 unique product identifier object.
133    ///
134    /// 🚨 **This replaces [`require_gtin`](Self::require_gtin) for product group
135    /// data.** Product group records used to carry a bare top-level `gtin`, and
136    /// every plugin required it. They now carry a `productIdentifier` object
137    /// whose shape depends on the scheme that issued it, and the GTIN — when
138    /// there is one at all — lives *inside* it:
139    ///
140    /// ```json
141    /// { "productIdentifier": { "scheme": "gs1", "gtin": "09506000134352" } }
142    /// ```
143    ///
144    /// A plugin still asking for `gtin` therefore reports *"gtin is required"*
145    /// against data that identifies itself perfectly well. That is not a
146    /// hypothetical: it is what every non-textile plugin did once the schemas
147    /// moved, and because both host call sites discard a plugin error, the
148    /// compliance determination silently stopped being made rather than failing
149    /// loudly.
150    ///
151    /// # Why the scheme decides which field is checked
152    ///
153    /// Clause 5.1 offers three schemes as **alternatives, not a hierarchy**.
154    /// Only scheme 1 is GS1-keyed, so only scheme 1 has a GTIN; schemes 2 and 3
155    /// are self-issuing and carry a URL and a DID respectively. Validating a
156    /// GTIN unconditionally would reject exactly the passports the identifier
157    /// work exists to enable, which is the defect this method replaces.
158    ///
159    /// An unrecognised scheme is refused rather than skipped: a scheme string
160    /// nobody has mapped is where an invented identifier passes unexamined.
161    pub fn require_product_identifier(&mut self, key: &str) -> &mut Self {
162        let Some(object) = present(self.input, key).and_then(Value::as_object) else {
163            self.push_opt(key, Some(("missing", format!("{key} is required"))));
164            return self;
165        };
166        let Some(scheme) = object.get("scheme").and_then(Value::as_str) else {
167            self.push_at(
168                format!("/{key}/scheme"),
169                "missing",
170                format!("{key}.scheme is required"),
171            );
172            return self;
173        };
174        // Each arm names the one field its scheme is keyed on. The branch field
175        // is required *because* the scheme was declared, so a missing one is
176        // reported against the field, not against the scheme that implies it.
177        let (field, err) = match scheme {
178            "gs1" => (
179                "gtin",
180                match object.get("gtin").and_then(Value::as_str) {
181                    None => Some(("missing", format!("{key}.gtin is required for scheme gs1"))),
182                    Some(g) if g.len() == 14 && g.bytes().all(|b| b.is_ascii_digit()) => {
183                        if gs1_check_digit_valid(g) {
184                            None
185                        } else {
186                            Some((
187                                "checksum",
188                                format!("{key}.gtin has an invalid GS1 check digit"),
189                            ))
190                        }
191                    }
192                    Some(_) => Some(("format", format!("{key}.gtin must be 14 digits"))),
193                },
194            ),
195            "identificationLink" => (
196                "url",
197                match object.get("url").and_then(Value::as_str) {
198                    None => Some((
199                        "missing",
200                        format!("{key}.url is required for scheme identificationLink"),
201                    )),
202                    // Checked only to be an absolute http(s) URL with a host.
203                    // EN IEC 61406 format rules are not applied, so passing is
204                    // not a conformance claim — the schema says the same.
205                    Some(u) if is_absolute_web_url(u) => None,
206                    Some(_) => Some(("format", format!("{key}.url must be an absolute URL"))),
207                },
208            ),
209            "did" => (
210                "did",
211                match object.get("did").and_then(Value::as_str) {
212                    None => Some(("missing", format!("{key}.did is required for scheme did"))),
213                    // The method set is closed: a DID method no reader can
214                    // resolve identifies nothing.
215                    Some(d) => match check_did(d) {
216                        Ok(()) => None,
217                        Err(DidRejection::UnsupportedMethod(method)) => Some((
218                            "format",
219                            format!(
220                                "{key}.did method '{method}' is not did:web, did:ethr or did:ebsi"
221                            ),
222                        )),
223                        Err(DidRejection::EmptyMethodId) => Some((
224                            "format",
225                            format!("{key}.did names a method but no identifier"),
226                        )),
227                        Err(DidRejection::Malformed) => {
228                            Some(("format", format!("{key}.did is not a well-formed W3C DID")))
229                        }
230                    },
231                },
232            ),
233            other => {
234                self.push_at(
235                    format!("/{key}/scheme"),
236                    "unknown",
237                    format!("{key}.scheme '{other}' is not an EN 18219 clause 5 scheme"),
238                );
239                return self;
240            }
241        };
242        if let Some((code, message)) = err {
243            self.push_at(format!("/{key}/{field}"), code, message);
244        }
245        self
246    }
247
248    /// Require a recognized ISO 3166-1 alpha-2 country code.
249    pub fn require_country(&mut self, key: &str) -> &mut Self {
250        let err = match present(self.input, key).and_then(Value::as_str) {
251            None => Some(("missing", format!("{key} is required"))),
252            Some(c) if c.len() == 2 && c.bytes().all(|b| b.is_ascii_uppercase()) => {
253                if dpp_rules::country_code_valid(c) {
254                    None
255                } else {
256                    Some((
257                        "invalid",
258                        format!("{key} is not a recognized ISO 3166-1 alpha-2 code"),
259                    ))
260                }
261            }
262            Some(_) => Some((
263                "format",
264                format!("{key} must be a 2-letter uppercase country code"),
265            )),
266        };
267        self.push_opt(key, err);
268        self
269    }
270
271    /// Require a present, finite number greater than 0.
272    pub fn require_positive(&mut self, key: &str) -> &mut Self {
273        let err = match num(self.input, key) {
274            None => Some((
275                "missing",
276                format!("{key} is required and must be a finite number"),
277            )),
278            Some(v) if v <= 0.0 => Some(("out_of_range", format!("{key} must be greater than 0"))),
279            Some(_) => None,
280        };
281        self.push_opt(key, err);
282        self
283    }
284
285    /// Require a present, finite number greater than or equal to 0.
286    pub fn require_non_negative(&mut self, key: &str) -> &mut Self {
287        let err = match num(self.input, key) {
288            None => Some((
289                "missing",
290                format!("{key} is required and must be a finite number"),
291            )),
292            Some(v) if v < 0.0 => Some(("out_of_range", format!("{key} must be 0 or greater"))),
293            Some(_) => None,
294        };
295        self.push_opt(key, err);
296        self
297    }
298
299    /// Require a present, finite number in `[0, 100]`.
300    pub fn require_pct(&mut self, key: &str) -> &mut Self {
301        let err = match num(self.input, key) {
302            None => Some((
303                "missing",
304                format!("{key} is required and must be a number in 0..=100"),
305            )),
306            Some(v) if !(0.0..=100.0).contains(&v) => {
307                Some(("out_of_range", format!("{key} must be in 0..=100")))
308            }
309            Some(_) => None,
310        };
311        self.push_opt(key, err);
312        self
313    }
314
315    /// Require a present non-negative integer that is at least 1.
316    pub fn require_positive_int(&mut self, key: &str) -> &mut Self {
317        let err = match present(self.input, key).and_then(Value::as_u64) {
318            None => Some((
319                "missing",
320                format!("{key} is required and must be a non-negative integer"),
321            )),
322            Some(0) => Some(("out_of_range", format!("{key} must be at least 1"))),
323            Some(_) => None,
324        };
325        self.push_opt(key, err);
326        self
327    }
328
329    /// If present (and non-null), the value must be an integer of at least 1.
330    ///
331    /// The optional counterpart of [`Self::require_positive_int`], for a field
332    /// whose *presence* is conditional but whose *value*, once given, is still
333    /// constrained. Deliberately not expressible as
334    /// [`Self::optional_non_negative`], which would admit `0` and a fractional
335    /// count.
336    pub fn optional_positive_int(&mut self, key: &str) -> &mut Self {
337        let err = match present(self.input, key) {
338            None => None,
339            Some(v) => match v.as_u64() {
340                None => Some((
341                    "invalid_type",
342                    format!("{key} must be a non-negative integer"),
343                )),
344                Some(0) => Some(("out_of_range", format!("{key} must be at least 1"))),
345                Some(_) => None,
346            },
347        };
348        self.push_opt(key, err);
349        self
350    }
351
352    /// Require a present boolean.
353    pub fn require_bool(&mut self, key: &str) -> &mut Self {
354        let err = match present(self.input, key) {
355            None => Some(("missing", format!("{key} is required"))),
356            Some(v) if v.is_boolean() => None,
357            Some(_) => Some(("type", format!("{key} must be a boolean"))),
358        };
359        self.push_opt(key, err);
360        self
361    }
362
363    /// Require a present, non-empty array.
364    pub fn require_non_empty_array(&mut self, key: &str) -> &mut Self {
365        let err = match present(self.input, key).and_then(Value::as_array) {
366            None => Some(("missing", format!("{key} is required and must be an array"))),
367            Some(a) if a.is_empty() => Some(("empty", format!("{key} must not be empty"))),
368            Some(_) => None,
369        };
370        self.push_opt(key, err);
371        self
372    }
373
374    /// Require a present, non-empty object.
375    ///
376    /// The counterpart to [`require_non_empty_array`](Self::require_non_empty_array)
377    /// for a schema-required field whose value is a nested object — an
378    /// unsold-goods report's `entity` and `financialYear`, for instance.
379    ///
380    /// 🚨 **Presence only. It does not reach inside.** `present` is
381    /// `input.get(key)` with no path traversal, so nothing here can assert a
382    /// field *within* the object; the nested `required` list is enforced by
383    /// schema validation at publish and not at this tier. An empty object is
384    /// refused because it satisfies "present" while carrying none of what made
385    /// the field required.
386    pub fn require_object(&mut self, key: &str) -> &mut Self {
387        let err = match present(self.input, key).and_then(Value::as_object) {
388            None => Some((
389                "missing",
390                format!("{key} is required and must be an object"),
391            )),
392            Some(o) if o.is_empty() => Some(("empty", format!("{key} must not be empty"))),
393            Some(_) => None,
394        };
395        self.push_opt(key, err);
396        self
397    }
398
399    /// If present (and non-null), the value must be a finite number in `[0, 100]`.
400    pub fn optional_pct(&mut self, key: &str) -> &mut Self {
401        let err = match present(self.input, key) {
402            None => None,
403            Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
404                Some(n) if (0.0..=100.0).contains(&n) => None,
405                _ => Some(("out_of_range", format!("{key} must be a number in 0..=100"))),
406            },
407        };
408        self.push_opt(key, err);
409        self
410    }
411
412    /// If present (and non-null), the value must be a finite number in `[min, max]`.
413    ///
414    /// For a field that is optional (its absence is meaningful) but must be
415    /// bounded when supplied — e.g. a 0–10 repairability score that gates a
416    /// verdict only when present.
417    pub fn optional_range(&mut self, key: &str, min: f64, max: f64) -> &mut Self {
418        let err = match present(self.input, key) {
419            None => None,
420            Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
421                Some(n) if (min..=max).contains(&n) => None,
422                _ => Some((
423                    "out_of_range",
424                    format!("{key} must be a number in {min}..={max}"),
425                )),
426            },
427        };
428        self.push_opt(key, err);
429        self
430    }
431
432    /// If present (and non-null), the value must be a finite number ≥ 0.
433    pub fn optional_non_negative(&mut self, key: &str) -> &mut Self {
434        let err = match present(self.input, key) {
435            None => None,
436            Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
437                Some(n) if n >= 0.0 => None,
438                _ => Some(("out_of_range", format!("{key} must be a finite number ≥ 0"))),
439            },
440        };
441        self.push_opt(key, err);
442        self
443    }
444
445    /// Finish validation, returning every collected error at once.
446    pub fn finish(&mut self) -> Result<(), PluginError> {
447        if self.errors.is_empty() {
448            Ok(())
449        } else {
450            Err(PluginError::ValidationErrors(std::mem::take(
451                &mut self.errors,
452            )))
453        }
454    }
455}
456
457/// GS1 check-digit validation for GTIN-14.
458///
459/// Even-indexed positions (0, 2, 4, … 12) are weighted ×3; odd-indexed ×1.
460/// The check digit at position 13 must equal `(10 − sum mod 10) mod 10`.
461fn gs1_check_digit_valid(gtin: &str) -> bool {
462    let bytes = gtin.as_bytes();
463    debug_assert_eq!(bytes.len(), 14, "caller must check length == 14 first");
464    let sum: u32 = bytes[..13]
465        .iter()
466        .enumerate()
467        .map(|(i, &b)| {
468            let d = (b - b'0') as u32;
469            if i % 2 == 0 { d * 3 } else { d }
470        })
471        .sum();
472    let expected = (10 - sum % 10) % 10;
473    expected == (bytes[13] - b'0') as u32
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use serde_json::json;
480
481    #[test]
482    fn collects_all_failures() {
483        let input = json!({ "gtin": "12-34", "voltage": -1.0 });
484        let err = Validator::new(&input)
485            .require_gtin("gtin")
486            .require_positive("voltage")
487            .require_str("name")
488            .finish()
489            .unwrap_err();
490        match err {
491            PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
492            other => panic!("expected ValidationErrors, got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn valid_input_passes() {
498        let input = json!({
499            "gtin": "12345678901231",
500            "country": "DE",
501            "pct": 42.0,
502            "count": 3,
503            "flag": true,
504            "items": [1]
505        });
506        assert!(
507            Validator::new(&input)
508                .require_gtin("gtin")
509                .require_country("country")
510                .require_pct("pct")
511                .require_positive_int("count")
512                .require_bool("flag")
513                .require_non_empty_array("items")
514                .finish()
515                .is_ok()
516        );
517    }
518
519    #[test]
520    fn enum_and_country_and_pct_bounds() {
521        let input = json!({ "cls": "Z", "country": "de", "pct": 150.0 });
522        let err = Validator::new(&input)
523            .require_enum("cls", &["A", "B"])
524            .require_country("country")
525            .require_pct("pct")
526            .finish()
527            .unwrap_err();
528        match err {
529            PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
530            other => panic!("expected ValidationErrors, got {other:?}"),
531        }
532    }
533
534    #[test]
535    fn gtin_invalid_check_digit_is_rejected() {
536        // "12345678901234" — correct digits, correct length, but check digit should be 1, not 4.
537        let input = json!({ "gtin": "12345678901234" });
538        let err = Validator::new(&input)
539            .require_gtin("gtin")
540            .finish()
541            .unwrap_err();
542        match err {
543            PluginError::ValidationErrors(errs) => {
544                assert_eq!(errs.len(), 1);
545                assert_eq!(errs[0].code, "checksum");
546            }
547            other => panic!("expected ValidationErrors, got {other:?}"),
548        }
549    }
550
551    #[test]
552    fn gtin_valid_check_digit_passes() {
553        // "12345678901231" — check digit = 1, matches GS1 calculation.
554        let input = json!({ "gtin": "12345678901231" });
555        assert!(Validator::new(&input).require_gtin("gtin").finish().is_ok());
556    }
557
558    #[test]
559    fn country_not_in_iso_list_is_rejected() {
560        // "XX" has the right format (2 uppercase letters) but is not an assigned code.
561        let input = json!({ "country": "XX" });
562        let err = Validator::new(&input)
563            .require_country("country")
564            .finish()
565            .unwrap_err();
566        match err {
567            PluginError::ValidationErrors(errs) => {
568                assert_eq!(errs.len(), 1);
569                assert_eq!(errs[0].code, "invalid");
570            }
571            other => panic!("expected ValidationErrors, got {other:?}"),
572        }
573    }
574
575    #[test]
576    fn country_valid_iso_code_passes() {
577        for code in ["DE", "NO", "FR", "US", "JP"] {
578            let input = json!({ "country": code });
579            assert!(
580                Validator::new(&input)
581                    .require_country("country")
582                    .finish()
583                    .is_ok(),
584                "{code} should be a valid ISO 3166-1 alpha-2 code"
585            );
586        }
587    }
588
589    #[test]
590    fn optional_pct_absent_is_ok_present_out_of_range_fails() {
591        let ok = json!({});
592        assert!(Validator::new(&ok).optional_pct("x").finish().is_ok());
593        let bad = json!({ "x": 101.0 });
594        assert!(Validator::new(&bad).optional_pct("x").finish().is_err());
595    }
596
597    #[test]
598    fn optional_range_absent_ok_present_bounded() {
599        // Absent → ok (the field's absence is meaningful).
600        assert!(
601            Validator::new(&json!({}))
602                .optional_range("s", 0.0, 10.0)
603                .finish()
604                .is_ok()
605        );
606        // In range → ok.
607        assert!(
608            Validator::new(&json!({ "s": 6.0 }))
609                .optional_range("s", 0.0, 10.0)
610                .finish()
611                .is_ok()
612        );
613        // Out of range → err (the fail-open case the plugins guard against).
614        for bad in [json!({ "s": 999999.0 }), json!({ "s": -1.0 })] {
615            assert!(
616                Validator::new(&bad)
617                    .optional_range("s", 0.0, 10.0)
618                    .finish()
619                    .is_err()
620            );
621        }
622    }
623
624    #[test]
625    fn optional_non_negative_absent_ok_negative_fails() {
626        assert!(
627            Validator::new(&json!({}))
628                .optional_non_negative("c")
629                .finish()
630                .is_ok()
631        );
632        assert!(
633            Validator::new(&json!({ "c": 0.0 }))
634                .optional_non_negative("c")
635                .finish()
636                .is_ok()
637        );
638        assert!(
639            Validator::new(&json!({ "c": -999.0 }))
640                .optional_non_negative("c")
641                .finish()
642                .is_err()
643        );
644    }
645
646    #[test]
647    fn readers_extract_values() {
648        let input = json!({ "n": 3.5, "s": "hi", "bad": "x" });
649        assert_eq!(num(&input, "n"), Some(3.5));
650        assert_eq!(num(&input, "bad"), None);
651        assert_eq!(str_of(&input, "s"), Some("hi"));
652    }
653}