Skip to main content

dpp_domain/domain/
validation.rs

1//! JSON Schema + cross-field validation for sector-specific DPP data.
2//!
3//! The schema step routes through the shared [`VersionedSchemaRegistry`] at the
4//! version the [`SectorCatalog`] marks current for the sector — there are no
5//! per-sector validators and no hardcoded versions here. Cross-field regulatory
6//! rules (which JSON Schema cannot express, e.g. "fibre percentages sum to
7//! ~100%") come from `dpp-rules` via the `dpp-domain` adapters.
8//! See `docs/architecture/SECTOR-MODEL-CONSOLIDATION.md` (step C2).
9//!
10//! **Note**: excluded from wasm32 builds since jsonschema depends on reqwest's
11//! blocking API.
12
13#![cfg(not(target_arch = "wasm32"))]
14
15use std::sync::OnceLock;
16
17use semver::Version;
18
19use crate::catalog::SectorCatalog;
20use crate::domain::field_error::{FieldError, ValidationErrors};
21use crate::domain::sector::{
22    SectorData, SvhcSubstance, validate_fibre_composition, validate_surfactants,
23    validate_svhc_substances,
24};
25use crate::schemas::VersionedSchemaRegistry;
26
27// ─── Extensibility: runtime sector validators ─────────────────────────────────
28
29/// Trait for runtime-registered sector validators.
30///
31/// Register an implementation in [`SectorValidatorRegistry`] to provide JSON
32/// Schema + cross-field validation for sectors that are not known to this crate
33/// at compile time (e.g., plugin-defined sectors carrying `SectorData::Other`).
34pub trait SectorValidator: Send + Sync {
35    /// Validate the sector payload (the inner data, without the `"sector"` tag key).
36    fn validate(&self, data: &serde_json::Value) -> Result<(), Vec<FieldError>>;
37}
38
39/// Registry of runtime sector validators, keyed by catalog sector key.
40///
41/// An empty registry (the default) causes `SectorData::Other` to fail
42/// validation with an "unknown sector" error — silent pass-through is not safe.
43#[derive(Default)]
44pub struct SectorValidatorRegistry {
45    validators: std::collections::HashMap<String, std::sync::Arc<dyn SectorValidator>>,
46}
47
48impl SectorValidatorRegistry {
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    pub fn register(
54        &mut self,
55        key: impl Into<String>,
56        validator: std::sync::Arc<dyn SectorValidator>,
57    ) {
58        self.validators.insert(key.into(), validator);
59    }
60
61    fn get(&self, key: &str) -> Option<&dyn SectorValidator> {
62        self.validators.get(key).map(std::sync::Arc::as_ref)
63    }
64}
65
66// ─── Process-wide defaults ─────────────────────────────────────────────────────
67
68/// The embedded schema registry, built once.
69fn default_registry() -> &'static VersionedSchemaRegistry {
70    static REGISTRY: OnceLock<VersionedSchemaRegistry> = OnceLock::new();
71    REGISTRY.get_or_init(VersionedSchemaRegistry::new)
72}
73
74/// The embedded sector catalog, built once.
75fn default_catalog() -> &'static SectorCatalog {
76    static CATALOG: OnceLock<SectorCatalog> = OnceLock::new();
77    CATALOG.get_or_init(SectorCatalog::new)
78}
79
80// `FieldError` and `ValidationErrors` moved to `crate::domain::field_error`
81// (wasm-safe) so `DppError` can carry structured validation detail. Imported above.
82
83// ─── Public API ─────────────────────────────────────────────────────────────────
84
85/// Validate `sector_data` against the appropriate JSON Schema and any
86/// sector-specific cross-field rules (e.g. fibre composition sum).
87///
88/// `SectorData::Other` is a **hard error** here — pass a
89/// [`SectorValidatorRegistry`] via [`validate_sector_data_with_registry`] to
90/// handle runtime-registered sectors.
91///
92/// # Errors
93///
94/// Returns `ValidationErrors` listing every failing field when validation
95/// fails. The `Ok(())` path means the data is structurally valid.
96pub fn validate_sector_data(sector_data: &SectorData) -> Result<(), ValidationErrors> {
97    validate_sector_data_with_registry(sector_data, &SectorValidatorRegistry::default())
98}
99
100/// Like [`validate_sector_data`] but accepts a runtime validator registry.
101///
102/// For `SectorData::Other(v)`, dispatches to the validator registered under
103/// key `"other"` in `registry`. Returns a hard error if no validator is
104/// registered for that key.
105pub fn validate_sector_data_with_registry(
106    sector_data: &SectorData,
107    registry: &SectorValidatorRegistry,
108) -> Result<(), ValidationErrors> {
109    let mut errors: Vec<FieldError> = Vec::new();
110    if let SectorData::Other(_) = sector_data {
111        match registry.get("other") {
112            Some(v) => {
113                if let Err(field_errors) = v.validate(&sector_data_instance(sector_data)) {
114                    errors.extend(field_errors);
115                }
116            }
117            None => errors.push(FieldError {
118                field: "/sector".to_owned(),
119                message: "SectorData::Other cannot be validated without a registered validator; \
120                          pass a SectorValidatorRegistry with an \"other\" entry"
121                    .to_owned(),
122            }),
123        }
124    } else {
125        schema_errors(sector_data, &mut errors);
126        cross_field_errors(sector_data, &mut errors);
127    }
128    if errors.is_empty() {
129        Ok(())
130    } else {
131        Err(ValidationErrors { errors })
132    }
133}
134
135/// Validate raw sector JSON using the embedded schema registry and any
136/// runtime cross-field validator.
137///
138/// This is the extension point for the plugin host: when a plugin produces a
139/// DPP with a sector key not present in the compile-time `SectorData` enum,
140/// pass the raw JSON through this function with an appropriate
141/// `SectorValidatorRegistry`.
142///
143/// Validation steps:
144/// 1. JSON Schema — resolved via the embedded [`SectorCatalog`] and
145///    [`VersionedSchemaRegistry`] (for sectors with a registered schema).
146/// 2. Cross-field — dispatched to `registry.get(sector_key)` when present.
147/// 3. Hard error — if neither a schema nor a registered validator exists for
148///    `sector_key`.
149pub fn validate_raw_sector_data(
150    sector_key: &str,
151    data: &serde_json::Value,
152    registry: &SectorValidatorRegistry,
153) -> Result<(), ValidationErrors> {
154    let mut errors: Vec<FieldError> = Vec::new();
155    let catalog = default_catalog();
156    let has_schema = catalog.current_schema_version(sector_key).is_some();
157
158    if let Some(version_str) = catalog.current_schema_version(sector_key)
159        && let Ok(version) = version_str.parse::<semver::Version>()
160        && let Err(ve) = default_registry().validate(sector_key, &version, data)
161    {
162        errors.extend(ve.errors);
163    }
164
165    match registry.get(sector_key) {
166        Some(v) => {
167            if let Err(field_errors) = v.validate(data) {
168                errors.extend(field_errors);
169            }
170        }
171        None if !has_schema => {
172            errors.push(FieldError {
173                field: "/sector".to_owned(),
174                message: format!(
175                    "unknown sector \"{sector_key}\": no JSON schema or cross-field validator registered"
176                ),
177            });
178        }
179        None => {}
180    }
181
182    if errors.is_empty() {
183        Ok(())
184    } else {
185        Err(ValidationErrors { errors })
186    }
187}
188
189/// Schema validation via the registry at the catalog-resolved current version.
190fn schema_errors(sector_data: &SectorData, errors: &mut Vec<FieldError>) {
191    let key = sector_data.sector().catalog_key();
192    // No catalog entry (e.g. `Other`) → no schema to validate against.
193    let Some(version_str) = default_catalog().current_schema_version(key) else {
194        return;
195    };
196    let Ok(version) = version_str.parse::<Version>() else {
197        return;
198    };
199    let instance = sector_data_instance(sector_data);
200    if let Err(ve) = default_registry().validate(key, &version, &instance) {
201        errors.extend(ve.errors);
202    }
203}
204
205/// The JSON the schema expects: the inner sector fields without the `"sector"`
206/// discriminant tag that `SectorData` serialises (schemas forbid extra props).
207fn sector_data_instance(sector_data: &SectorData) -> serde_json::Value {
208    let mut value = serde_json::to_value(sector_data).expect("SectorData serializes to Value");
209    if let Some(obj) = value.as_object_mut() {
210        obj.remove("sector");
211    }
212    value
213}
214
215/// Cross-field regulatory rules that JSON Schema cannot express, delegated to
216/// `dpp-rules` through the `dpp-domain` adapters.
217fn cross_field_errors(sector_data: &SectorData, errors: &mut Vec<FieldError>) {
218    match sector_data {
219        SectorData::Textile(d) => {
220            if let Err(msg) = validate_fibre_composition(&d.fibre_composition) {
221                errors.push(FieldError {
222                    field: "/fibreComposition".to_owned(),
223                    message: msg,
224                });
225            }
226            push_svhc(d.svhc_substances.as_deref(), errors);
227            if let Some(ds) = d.durability_score
228                && !(0.0..=10.0).contains(&ds)
229            {
230                errors.push(FieldError {
231                    field: "/durabilityScore".to_owned(),
232                    message: format!("durability_score {ds} must be 0.0–10.0"),
233                });
234            }
235        }
236        SectorData::Electronics(d) => push_svhc(d.svhc_substances.as_deref(), errors),
237        SectorData::Toy(d) => push_svhc(d.svhc_substances.as_deref(), errors),
238        SectorData::Furniture(d) => push_svhc(d.svhc_substances.as_deref(), errors),
239        SectorData::Detergent(d) => {
240            if let Err(msg) = validate_surfactants(&d.surfactants) {
241                errors.push(FieldError {
242                    field: "/surfactants".to_owned(),
243                    message: msg,
244                });
245            }
246        }
247        _ => {}
248    }
249}
250
251fn push_svhc(substances: Option<&[SvhcSubstance]>, errors: &mut Vec<FieldError>) {
252    if let Some(s) = substances
253        && let Err(msg) = validate_svhc_substances(s)
254    {
255        errors.push(FieldError {
256            field: "/svhcSubstances".to_owned(),
257            message: msg,
258        });
259    }
260}
261
262// ─── Batch validation ────────────────────────────────────────────────────────
263
264/// Result of validating a single item in a batch.
265#[derive(Debug, Clone)]
266pub struct BatchValidationItem {
267    /// Zero-based index in the input slice.
268    pub index: usize,
269    /// Validation result: `Ok(())` if valid, `Err` with field-level errors otherwise.
270    pub result: Result<(), ValidationErrors>,
271}
272
273/// Validate a batch of sector data items, collecting all errors per item.
274///
275/// The returned `Vec` has the same length and order as the input.
276pub fn validate_sector_data_batch(items: &[SectorData]) -> Vec<BatchValidationItem> {
277    items
278        .iter()
279        .enumerate()
280        .map(|(index, data)| BatchValidationItem {
281            index,
282            result: validate_sector_data(data),
283        })
284        .collect()
285}
286
287/// Returns only the failures from a batch validation run.
288pub fn batch_errors(results: &[BatchValidationItem]) -> Vec<&BatchValidationItem> {
289    results.iter().filter(|item| item.result.is_err()).collect()
290}
291
292// ─── Tests ────────────────────────────────────────────────────────────────────
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::domain::gtin::Gtin;
298    use crate::domain::sector::{BatteryChemistry, BatteryData, FibreEntry, TextileData};
299
300    fn valid_battery() -> SectorData {
301        SectorData::Battery(BatteryData {
302            gtin: Gtin::parse("09506000134352").unwrap(),
303            battery_chemistry: BatteryChemistry::Lfp,
304            nominal_voltage_v: 48.0,
305            nominal_capacity_ah: 100.0,
306            expected_lifetime_cycles: 3000,
307            co2e_per_unit_kg: 85.4,
308            recycled_content_cobalt_pct: None,
309            recycled_content_lithium_pct: None,
310            recycled_content_nickel_pct: None,
311            state_of_health_pct: None,
312            rated_capacity_kwh: None,
313            carbon_footprint_class: None,
314            due_diligence_url: None,
315            cathode_material: None,
316            anode_material: None,
317            electrolyte_material: None,
318            critical_raw_materials: None,
319            disassembly_instructions_url: None,
320            soh_methodology: None,
321            operating_temp_min_c: None,
322            operating_temp_max_c: None,
323            rated_energy_wh: None,
324            recycled_content_lead_pct: None,
325            battery_weight_kg: None,
326            battery_type: None,
327            round_trip_efficiency_pct: None,
328            internal_resistance_mohm: None,
329            manufacturing_date: None,
330            manufacturing_place: None,
331            battery_model_id: None,
332            battery_passport_number: None,
333        })
334    }
335
336    fn valid_textile() -> SectorData {
337        SectorData::Textile(TextileData {
338            fibre_composition: vec![
339                FibreEntry {
340                    fibre: "cotton".into(),
341                    pct: 60.0,
342                    country_of_origin: None,
343                },
344                FibreEntry {
345                    fibre: "polyester".into(),
346                    pct: 40.0,
347                    country_of_origin: None,
348                },
349            ],
350            country_of_manufacturing: "BD".into(),
351            care_instructions: "30°C machine wash".into(),
352            chemical_compliance_standard: "OEKO-TEX 100".into(),
353            recycled_content_pct: None,
354            carbon_footprint_kg_co2e: None,
355            water_use_litres: None,
356            microplastic_shedding_mg_per_wash: None,
357            repair_score: None,
358            durability_score: None,
359            expected_wash_cycles: None,
360            country_of_raw_material_origin: None,
361            svhc_substances: None,
362            allergens: None,
363            substances_of_concern: None,
364            recyclability_class: None,
365            end_of_life_instructions: None,
366            reuse_condition: None,
367            prior_use_cycles: None,
368            disassembly_instructions: None,
369            spare_parts_available: None,
370            product_weight_grams: None,
371            repair_history_url: None,
372            repair_count: None,
373            pef_score: None,
374        })
375    }
376
377    #[test]
378    fn valid_battery_passes() {
379        // Routed through the registry at the catalog's current battery version (v2.0.0).
380        assert!(validate_sector_data(&valid_battery()).is_ok());
381    }
382
383    #[test]
384    fn valid_textile_passes() {
385        assert!(validate_sector_data(&valid_textile()).is_ok());
386    }
387
388    // The following exercise the schema layer directly through the registry,
389    // crafting structurally invalid instances the type system would otherwise
390    // prevent.
391
392    #[test]
393    fn battery_missing_required_field_fails() {
394        let reg = VersionedSchemaRegistry::new();
395        let v: Version = "1.0.0".parse().unwrap();
396        let instance = serde_json::json!({
397            "batteryChemistry": "LFP",
398            "nominalVoltageV": 48.0,
399            "nominalCapacityAh": 100.0,
400            "expectedLifetimeCycles": 3000,
401            "co2ePerUnitKg": 85.4
402            // "gtin" intentionally missing
403        });
404        let err = reg.validate("battery", &v, &instance).unwrap_err();
405        assert!(
406            err.errors.iter().any(|e| e.message.contains("gtin")),
407            "expected gtin error, got: {err:?}"
408        );
409    }
410
411    #[test]
412    fn battery_invalid_gtin_pattern_fails() {
413        let reg = VersionedSchemaRegistry::new();
414        let v: Version = "1.0.0".parse().unwrap();
415        let instance = serde_json::json!({
416            "gtin": "123", // too short
417            "batteryChemistry": "LFP",
418            "nominalVoltageV": 48.0,
419            "nominalCapacityAh": 100.0,
420            "expectedLifetimeCycles": 3000,
421            "co2ePerUnitKg": 85.4
422        });
423        assert!(reg.validate("battery", &v, &instance).is_err());
424    }
425
426    #[test]
427    fn textile_missing_care_instructions_fails() {
428        let reg = VersionedSchemaRegistry::new();
429        let v: Version = "1.1.0".parse().unwrap();
430        let instance = serde_json::json!({
431            "fibreComposition": [{"fibre": "cotton", "pct": 100}],
432            "countryOfManufacturing": "BD",
433            // "careInstructions" intentionally missing
434            "chemicalComplianceStandard": "REACH"
435        });
436        let err = reg.validate("textile", &v, &instance).unwrap_err();
437        assert!(
438            err.errors
439                .iter()
440                .any(|e| e.message.contains("careInstructions")),
441            "expected careInstructions error, got: {err:?}"
442        );
443    }
444
445    #[test]
446    fn textile_empty_fibre_composition_fails() {
447        let reg = VersionedSchemaRegistry::new();
448        let v: Version = "1.1.0".parse().unwrap();
449        let instance = serde_json::json!({
450            "fibreComposition": [], // minItems: 1
451            "countryOfManufacturing": "DE",
452            "careInstructions": "dry clean only",
453            "chemicalComplianceStandard": "GOTS"
454        });
455        assert!(reg.validate("textile", &v, &instance).is_err());
456    }
457
458    #[test]
459    fn textile_fibre_sum_not_100_fails() {
460        // Schema passes (pct 0–100 individually); the cross-field rule fails.
461        let data = SectorData::Textile(TextileData {
462            fibre_composition: vec![
463                FibreEntry {
464                    fibre: "cotton".into(),
465                    pct: 60.0,
466                    country_of_origin: None,
467                },
468                FibreEntry {
469                    fibre: "polyester".into(),
470                    pct: 30.0, // sums to 90
471                    country_of_origin: None,
472                },
473            ],
474            country_of_manufacturing: "PT".into(),
475            care_instructions: "Hand wash only".into(),
476            chemical_compliance_standard: "REACH".into(),
477            recycled_content_pct: None,
478            carbon_footprint_kg_co2e: None,
479            water_use_litres: None,
480            microplastic_shedding_mg_per_wash: None,
481            repair_score: None,
482            durability_score: None,
483            expected_wash_cycles: None,
484            country_of_raw_material_origin: None,
485            svhc_substances: None,
486            allergens: None,
487            substances_of_concern: None,
488            recyclability_class: None,
489            end_of_life_instructions: None,
490            reuse_condition: None,
491            prior_use_cycles: None,
492            disassembly_instructions: None,
493            spare_parts_available: None,
494            product_weight_grams: None,
495            repair_history_url: None,
496            repair_count: None,
497            pef_score: None,
498        });
499        let err = validate_sector_data(&data).unwrap_err();
500        assert!(
501            err.errors.iter().any(|e| e.field == "/fibreComposition"),
502            "expected /fibreComposition error, got: {err:?}"
503        );
504    }
505
506    // ── SectorValidatorRegistry / validate_raw_sector_data tests ─────────────
507
508    #[test]
509    fn other_sector_data_fails_without_registry() {
510        let data = SectorData::Other(serde_json::json!({"field": "value"}));
511        let err = validate_sector_data(&data).unwrap_err();
512        assert!(
513            err.errors.iter().any(|e| e.field == "/sector"),
514            "expected /sector error for Other without registry"
515        );
516    }
517
518    #[test]
519    fn other_sector_data_passes_with_registered_validator() {
520        use std::sync::Arc;
521
522        struct AlwaysOkValidator;
523        impl SectorValidator for AlwaysOkValidator {
524            fn validate(&self, _: &serde_json::Value) -> Result<(), Vec<FieldError>> {
525                Ok(())
526            }
527        }
528
529        let mut registry = SectorValidatorRegistry::new();
530        registry.register("other", Arc::new(AlwaysOkValidator));
531
532        let data = SectorData::Other(serde_json::json!({"field": "value"}));
533        assert!(
534            validate_sector_data_with_registry(&data, &registry).is_ok(),
535            "registered AlwaysOkValidator must allow Other sector"
536        );
537    }
538
539    #[test]
540    fn other_sector_data_validator_errors_propagate() {
541        use std::sync::Arc;
542
543        struct AlwaysFailValidator;
544        impl SectorValidator for AlwaysFailValidator {
545            fn validate(&self, _: &serde_json::Value) -> Result<(), Vec<FieldError>> {
546                Err(vec![FieldError {
547                    field: "/field".to_owned(),
548                    message: "injected failure".to_owned(),
549                }])
550            }
551        }
552
553        let mut registry = SectorValidatorRegistry::new();
554        registry.register("other", Arc::new(AlwaysFailValidator));
555
556        let data = SectorData::Other(serde_json::json!({"field": "bad"}));
557        let err = validate_sector_data_with_registry(&data, &registry).unwrap_err();
558        assert!(
559            err.errors
560                .iter()
561                .any(|e| e.message.contains("injected failure")),
562            "validator errors must propagate"
563        );
564    }
565
566    #[test]
567    fn validate_raw_sector_data_known_sector_succeeds() {
568        // "battery" has an embedded schema — validate known-good raw JSON.
569        let data = serde_json::json!({
570            "gtin": "09506000134352",
571            "batteryChemistry": "LFP",
572            "nominalVoltageV": 48.0,
573            "nominalCapacityAh": 100.0,
574            "expectedLifetimeCycles": 3000,
575            "co2ePerUnitKg": 85.4
576        });
577        let registry = SectorValidatorRegistry::default();
578        assert!(validate_raw_sector_data("battery", &data, &registry).is_ok());
579    }
580
581    #[test]
582    fn validate_raw_sector_data_unknown_sector_fails() {
583        let data = serde_json::json!({"field": "value"});
584        let registry = SectorValidatorRegistry::default();
585        let err = validate_raw_sector_data("nonexistent-sector", &data, &registry).unwrap_err();
586        assert!(
587            err.errors
588                .iter()
589                .any(|e| e.message.contains("nonexistent-sector")),
590            "expected error naming the unknown sector key"
591        );
592    }
593
594    #[test]
595    fn batch_validation_mixed_results() {
596        let items = vec![
597            valid_battery(),
598            valid_textile(),
599            // Invalid: fibre sum != 100
600            SectorData::Textile(TextileData {
601                fibre_composition: vec![FibreEntry {
602                    fibre: "cotton".into(),
603                    pct: 50.0,
604                    country_of_origin: None,
605                }],
606                country_of_manufacturing: "PT".into(),
607                care_instructions: "Hand wash".into(),
608                chemical_compliance_standard: "REACH".into(),
609                recycled_content_pct: None,
610                carbon_footprint_kg_co2e: None,
611                water_use_litres: None,
612                microplastic_shedding_mg_per_wash: None,
613                repair_score: None,
614                durability_score: None,
615                expected_wash_cycles: None,
616                country_of_raw_material_origin: None,
617                svhc_substances: None,
618                allergens: None,
619                substances_of_concern: None,
620                recyclability_class: None,
621                end_of_life_instructions: None,
622                reuse_condition: None,
623                prior_use_cycles: None,
624                disassembly_instructions: None,
625                spare_parts_available: None,
626                product_weight_grams: None,
627                repair_history_url: None,
628                repair_count: None,
629                pef_score: None,
630            }),
631        ];
632
633        let results = validate_sector_data_batch(&items);
634        assert_eq!(results.len(), 3);
635        assert!(results[0].result.is_ok());
636        assert!(results[1].result.is_ok());
637        assert!(results[2].result.is_err());
638
639        let errors = batch_errors(&results);
640        assert_eq!(errors.len(), 1);
641        assert_eq!(errors[0].index, 2);
642    }
643}