use dpp_plugin_traits::{PluginComplianceStatus, PluginError, PluginFieldError, PluginInput};
use serde_json::Value;
fn present<'a>(input: &'a Value, key: &str) -> Option<&'a Value> {
match input.get(key) {
Some(Value::Null) | None => None,
other => other,
}
}
#[must_use]
pub fn num(input: &PluginInput, key: &str) -> Option<f64> {
input
.get(key)
.and_then(Value::as_f64)
.filter(|n| n.is_finite())
}
#[must_use]
pub fn str_of<'a>(input: &'a PluginInput, key: &str) -> Option<&'a str> {
input.get(key).and_then(Value::as_str)
}
#[must_use]
pub fn threshold_status(value: Option<f64>, threshold: f64) -> PluginComplianceStatus {
if value.is_some_and(|v| v <= threshold) {
PluginComplianceStatus::Compliant
} else {
PluginComplianceStatus::NonCompliant
}
}
pub struct Validator<'a> {
input: &'a Value,
errors: Vec<PluginFieldError>,
}
impl<'a> Validator<'a> {
#[must_use]
pub fn new(input: &'a PluginInput) -> Self {
Self {
input,
errors: Vec::new(),
}
}
fn push_opt(&mut self, key: &str, err: Option<(&str, String)>) {
if let Some((code, message)) = err {
self.errors.push(PluginFieldError {
field: format!("/{key}"),
code: code.to_owned(),
message,
});
}
}
pub fn require_str(&mut self, key: &str) -> &mut Self {
let err = match present(self.input, key) {
None => Some(("missing", format!("{key} is required"))),
Some(v) => match v.as_str() {
Some(s) if !s.trim().is_empty() => None,
Some(_) => Some(("empty", format!("{key} must not be empty"))),
None => Some(("type", format!("{key} must be a string"))),
},
};
self.push_opt(key, err);
self
}
pub fn require_enum(&mut self, key: &str, allowed: &[&str]) -> &mut Self {
let err = match present(self.input, key).and_then(Value::as_str) {
None => Some(("missing", format!("{key} is required"))),
Some(s) if allowed.contains(&s) => None,
Some(_) => Some(("out_of_range", format!("{key} must be one of {allowed:?}"))),
};
self.push_opt(key, err);
self
}
pub fn require_gtin(&mut self, key: &str) -> &mut Self {
let err = match present(self.input, key).and_then(Value::as_str) {
None => Some(("missing", format!("{key} is required"))),
Some(g) if g.len() == 14 && g.bytes().all(|b| b.is_ascii_digit()) => {
if gs1_check_digit_valid(g) {
None
} else {
Some(("checksum", format!("{key} has an invalid GS1 check digit")))
}
}
Some(_) => Some(("format", format!("{key} must be 14 digits"))),
};
self.push_opt(key, err);
self
}
pub fn require_country(&mut self, key: &str) -> &mut Self {
let err = match present(self.input, key).and_then(Value::as_str) {
None => Some(("missing", format!("{key} is required"))),
Some(c) if c.len() == 2 && c.bytes().all(|b| b.is_ascii_uppercase()) => {
if dpp_rules::country_code_valid(c) {
None
} else {
Some((
"invalid",
format!("{key} is not a recognized ISO 3166-1 alpha-2 code"),
))
}
}
Some(_) => Some((
"format",
format!("{key} must be a 2-letter uppercase country code"),
)),
};
self.push_opt(key, err);
self
}
pub fn require_positive(&mut self, key: &str) -> &mut Self {
let err = match num(self.input, key) {
None => Some((
"missing",
format!("{key} is required and must be a finite number"),
)),
Some(v) if v <= 0.0 => Some(("out_of_range", format!("{key} must be greater than 0"))),
Some(_) => None,
};
self.push_opt(key, err);
self
}
pub fn require_non_negative(&mut self, key: &str) -> &mut Self {
let err = match num(self.input, key) {
None => Some((
"missing",
format!("{key} is required and must be a finite number"),
)),
Some(v) if v < 0.0 => Some(("out_of_range", format!("{key} must be 0 or greater"))),
Some(_) => None,
};
self.push_opt(key, err);
self
}
pub fn require_pct(&mut self, key: &str) -> &mut Self {
let err = match num(self.input, key) {
None => Some((
"missing",
format!("{key} is required and must be a number in 0..=100"),
)),
Some(v) if !(0.0..=100.0).contains(&v) => {
Some(("out_of_range", format!("{key} must be in 0..=100")))
}
Some(_) => None,
};
self.push_opt(key, err);
self
}
pub fn require_positive_int(&mut self, key: &str) -> &mut Self {
let err = match present(self.input, key).and_then(Value::as_u64) {
None => Some((
"missing",
format!("{key} is required and must be a non-negative integer"),
)),
Some(0) => Some(("out_of_range", format!("{key} must be at least 1"))),
Some(_) => None,
};
self.push_opt(key, err);
self
}
pub fn require_bool(&mut self, key: &str) -> &mut Self {
let err = match present(self.input, key) {
None => Some(("missing", format!("{key} is required"))),
Some(v) if v.is_boolean() => None,
Some(_) => Some(("type", format!("{key} must be a boolean"))),
};
self.push_opt(key, err);
self
}
pub fn require_non_empty_array(&mut self, key: &str) -> &mut Self {
let err = match present(self.input, key).and_then(Value::as_array) {
None => Some(("missing", format!("{key} is required and must be an array"))),
Some(a) if a.is_empty() => Some(("empty", format!("{key} must not be empty"))),
Some(_) => None,
};
self.push_opt(key, err);
self
}
pub fn optional_pct(&mut self, key: &str) -> &mut Self {
let err = match present(self.input, key) {
None => None,
Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
Some(n) if (0.0..=100.0).contains(&n) => None,
_ => Some(("out_of_range", format!("{key} must be a number in 0..=100"))),
},
};
self.push_opt(key, err);
self
}
pub fn optional_range(&mut self, key: &str, min: f64, max: f64) -> &mut Self {
let err = match present(self.input, key) {
None => None,
Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
Some(n) if (min..=max).contains(&n) => None,
_ => Some((
"out_of_range",
format!("{key} must be a number in {min}..={max}"),
)),
},
};
self.push_opt(key, err);
self
}
pub fn optional_non_negative(&mut self, key: &str) -> &mut Self {
let err = match present(self.input, key) {
None => None,
Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
Some(n) if n >= 0.0 => None,
_ => Some(("out_of_range", format!("{key} must be a finite number ≥ 0"))),
},
};
self.push_opt(key, err);
self
}
pub fn finish(&mut self) -> Result<(), PluginError> {
if self.errors.is_empty() {
Ok(())
} else {
Err(PluginError::ValidationErrors(std::mem::take(
&mut self.errors,
)))
}
}
}
fn gs1_check_digit_valid(gtin: &str) -> bool {
let bytes = gtin.as_bytes();
debug_assert_eq!(bytes.len(), 14, "caller must check length == 14 first");
let sum: u32 = bytes[..13]
.iter()
.enumerate()
.map(|(i, &b)| {
let d = (b - b'0') as u32;
if i % 2 == 0 { d * 3 } else { d }
})
.sum();
let expected = (10 - sum % 10) % 10;
expected == (bytes[13] - b'0') as u32
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn collects_all_failures() {
let input = json!({ "gtin": "12-34", "voltage": -1.0 });
let err = Validator::new(&input)
.require_gtin("gtin")
.require_positive("voltage")
.require_str("name")
.finish()
.unwrap_err();
match err {
PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
other => panic!("expected ValidationErrors, got {other:?}"),
}
}
#[test]
fn valid_input_passes() {
let input = json!({
"gtin": "12345678901231",
"country": "DE",
"pct": 42.0,
"count": 3,
"flag": true,
"items": [1]
});
assert!(
Validator::new(&input)
.require_gtin("gtin")
.require_country("country")
.require_pct("pct")
.require_positive_int("count")
.require_bool("flag")
.require_non_empty_array("items")
.finish()
.is_ok()
);
}
#[test]
fn enum_and_country_and_pct_bounds() {
let input = json!({ "cls": "Z", "country": "de", "pct": 150.0 });
let err = Validator::new(&input)
.require_enum("cls", &["A", "B"])
.require_country("country")
.require_pct("pct")
.finish()
.unwrap_err();
match err {
PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
other => panic!("expected ValidationErrors, got {other:?}"),
}
}
#[test]
fn gtin_invalid_check_digit_is_rejected() {
let input = json!({ "gtin": "12345678901234" });
let err = Validator::new(&input)
.require_gtin("gtin")
.finish()
.unwrap_err();
match err {
PluginError::ValidationErrors(errs) => {
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, "checksum");
}
other => panic!("expected ValidationErrors, got {other:?}"),
}
}
#[test]
fn gtin_valid_check_digit_passes() {
let input = json!({ "gtin": "12345678901231" });
assert!(Validator::new(&input).require_gtin("gtin").finish().is_ok());
}
#[test]
fn country_not_in_iso_list_is_rejected() {
let input = json!({ "country": "XX" });
let err = Validator::new(&input)
.require_country("country")
.finish()
.unwrap_err();
match err {
PluginError::ValidationErrors(errs) => {
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, "invalid");
}
other => panic!("expected ValidationErrors, got {other:?}"),
}
}
#[test]
fn country_valid_iso_code_passes() {
for code in ["DE", "NO", "FR", "US", "JP"] {
let input = json!({ "country": code });
assert!(
Validator::new(&input)
.require_country("country")
.finish()
.is_ok(),
"{code} should be a valid ISO 3166-1 alpha-2 code"
);
}
}
#[test]
fn optional_pct_absent_is_ok_present_out_of_range_fails() {
let ok = json!({});
assert!(Validator::new(&ok).optional_pct("x").finish().is_ok());
let bad = json!({ "x": 101.0 });
assert!(Validator::new(&bad).optional_pct("x").finish().is_err());
}
#[test]
fn optional_range_absent_ok_present_bounded() {
assert!(
Validator::new(&json!({}))
.optional_range("s", 0.0, 10.0)
.finish()
.is_ok()
);
assert!(
Validator::new(&json!({ "s": 6.0 }))
.optional_range("s", 0.0, 10.0)
.finish()
.is_ok()
);
for bad in [json!({ "s": 999999.0 }), json!({ "s": -1.0 })] {
assert!(
Validator::new(&bad)
.optional_range("s", 0.0, 10.0)
.finish()
.is_err()
);
}
}
#[test]
fn optional_non_negative_absent_ok_negative_fails() {
assert!(
Validator::new(&json!({}))
.optional_non_negative("c")
.finish()
.is_ok()
);
assert!(
Validator::new(&json!({ "c": 0.0 }))
.optional_non_negative("c")
.finish()
.is_ok()
);
assert!(
Validator::new(&json!({ "c": -999.0 }))
.optional_non_negative("c")
.finish()
.is_err()
);
}
#[test]
fn readers_extract_values() {
let input = json!({ "n": 3.5, "s": "hi", "bad": "x" });
assert_eq!(num(&input, "n"), Some(3.5));
assert_eq!(num(&input, "bad"), None);
assert_eq!(str_of(&input, "s"), Some("hi"));
}
}