1use dpp_plugin_traits::{PluginError, PluginFieldError, PluginInput};
13use serde_json::Value;
14
15fn 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#[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#[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
38pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
266fn 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
285const 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 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 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 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 assert!(
430 Validator::new(&json!({}))
431 .optional_range("s", 0.0, 10.0)
432 .finish()
433 .is_ok()
434 );
435 assert!(
437 Validator::new(&json!({ "s": 6.0 }))
438 .optional_range("s", 0.0, 10.0)
439 .finish()
440 .is_ok()
441 );
442 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}