Skip to main content

dpp_domain/domain/sector/data/
sector_data.rs

1//! The [`SectorData`] discriminated union and its per-audience redaction.
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4
5use super::aluminium::AluminiumData;
6use super::battery::BatteryData;
7use super::construction::ConstructionData;
8use super::detergent::DetergentData;
9use super::electronics::ElectronicsData;
10use super::furniture::FurnitureData;
11use super::steel::SteelData;
12use super::textile::TextileData;
13use super::toy::ToyData;
14use super::tyre::TyreData;
15use super::unsold_goods::UnsoldGoodsReport;
16use crate::domain::sector::Sector;
17
18/// Typed, sector-specific DPP data — replaces the opaque `compliance_data: Value`.
19///
20/// Serialises as an internally-tagged object where `"sector"` is the
21/// discriminant field, e.g.:
22/// ```json
23/// { "sector": "battery", "gtin": "09506000134352", "nominalVoltageV": 3.2, ... }
24/// ```
25/// ```json
26/// { "sector": "textile", "fibreComposition": [...], "countryOfManufacturing": "BD" }
27/// ```
28/// An unknown `sector` tag deserialises to [`SectorData::Other`], which keeps
29/// both the tag and the payload verbatim — see the hand-written `Deserialize`
30/// below for why the derive cannot do this.
31#[derive(Debug, Clone, PartialEq)]
32#[non_exhaustive]
33pub enum SectorData {
34    Battery(BatteryData),
35    Textile(TextileData),
36    UnsoldGoods(UnsoldGoodsReport),
37    Steel(SteelData),
38    Electronics(ElectronicsData),
39    Construction(ConstructionData),
40    Tyre(TyreData),
41    Toy(ToyData),
42    Aluminium(AluminiumData),
43    Furniture(FurnitureData),
44    Detergent(DetergentData),
45    /// A product group this build has no typed variant for.
46    ///
47    /// `sector` is the wire tag verbatim and `data` is the whole object. A
48    /// passport for a sector added to the catalog after this crate was
49    /// released survives a round trip unchanged — which is the property that
50    /// makes adding a product group a data change rather than a release.
51    Other {
52        /// The wire tag exactly as received.
53        sector: String,
54        /// The full object, including its `sector` key.
55        data: serde_json::Value,
56    },
57}
58
59// ─── Wire format ─────────────────────────────────────────────────────────────
60//
61// Hand-written because the sector tag is open. `#[serde(tag = "sector")]`
62// enumerates its variants at compile time and rejects anything else, which
63// would make the wire the one closed part of an otherwise data-driven sector
64// model: the catalog, the schema registry and the plugin manifests are all
65// keyed by string, so a product group added to the catalog would still fail to
66// deserialize until this crate was released.
67//
68// The shape is unchanged — an internally-tagged object with `sector` as the
69// discriminant. Only the unknown-tag behaviour differs: fall through to
70// `Other`, keeping tag and payload, instead of failing.
71
72impl Serialize for SectorData {
73    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
74        // Serialise the payload, then stamp the tag onto it. Going through
75        // `Value` keeps one definition of the tag rather than repeating the
76        // struct field list per variant.
77        let mut value = match self {
78            Self::Battery(d) => serde_json::to_value(d),
79            Self::Textile(d) => serde_json::to_value(d),
80            Self::UnsoldGoods(d) => serde_json::to_value(d),
81            Self::Steel(d) => serde_json::to_value(d),
82            Self::Electronics(d) => serde_json::to_value(d),
83            Self::Construction(d) => serde_json::to_value(d),
84            Self::Tyre(d) => serde_json::to_value(d),
85            Self::Toy(d) => serde_json::to_value(d),
86            Self::Aluminium(d) => serde_json::to_value(d),
87            Self::Furniture(d) => serde_json::to_value(d),
88            Self::Detergent(d) => serde_json::to_value(d),
89            Self::Other { data, .. } => Ok(data.clone()),
90        }
91        .map_err(serde::ser::Error::custom)?;
92
93        let tag = self.sector();
94        if let serde_json::Value::Object(ref mut map) = value {
95            map.insert(
96                "sector".to_owned(),
97                serde_json::Value::String(tag.wire_str().to_owned()),
98            );
99        }
100        value.serialize(serializer)
101    }
102}
103
104impl<'de> Deserialize<'de> for SectorData {
105    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
106        use serde::de::Error as _;
107
108        let value = serde_json::Value::deserialize(deserializer)?;
109        let tag = value
110            .get("sector")
111            .and_then(serde_json::Value::as_str)
112            .ok_or_else(|| D::Error::missing_field("sector"))?
113            .to_owned();
114
115        // A typed variant ignores the `sector` key it does not declare, so the
116        // whole object can be handed to it unchanged.
117        macro_rules! typed {
118            ($variant:ident) => {
119                serde_json::from_value(value.clone())
120                    .map(Self::$variant)
121                    .map_err(D::Error::custom)
122            };
123        }
124
125        match Sector::from_wire_tag(&tag) {
126            Sector::Battery => typed!(Battery),
127            Sector::Textile => typed!(Textile),
128            Sector::UnsoldGoods => typed!(UnsoldGoods),
129            Sector::Steel => typed!(Steel),
130            Sector::Electronics => typed!(Electronics),
131            Sector::Construction => typed!(Construction),
132            Sector::Tyre => typed!(Tyre),
133            Sector::Toy => typed!(Toy),
134            Sector::Aluminium => typed!(Aluminium),
135            Sector::Furniture => typed!(Furniture),
136            Sector::Detergent => typed!(Detergent),
137            Sector::Other(sector) => Ok(Self::Other {
138                sector,
139                data: value,
140            }),
141        }
142    }
143}
144
145impl SectorData {
146    /// Build an untyped payload, reading the tag from the object's own
147    /// `sector` field. Falls back to `"other"` when the object carries none.
148    ///
149    /// Returns `None` if the tag names a sector this build *does* type.
150    ///
151    /// # Why this can fail
152    ///
153    /// `Other` holding a typed sector's tag would be a second representation of
154    /// that sector which does not compare equal to the first: `Other("battery")`
155    /// is not `Sector::Battery` under `Eq` or `Hash`, would miss every typed
156    /// match arm, and would be refused by `validate_sector_data` even though the
157    /// same bytes deserialize into a valid `Battery`. Deserialization already
158    /// routes known tags to their variants; this constructor must not offer a
159    /// way around it.
160    #[must_use]
161    pub fn other(data: serde_json::Value) -> Option<Self> {
162        let sector = data
163            .get("sector")
164            .and_then(serde_json::Value::as_str)
165            .unwrap_or("other")
166            .to_owned();
167        match Sector::from_wire_tag(&sector) {
168            Sector::Other(_) => Some(Self::Other { sector, data }),
169            _ => None,
170        }
171    }
172
173    /// Returns the `Sector` discriminant for this data.
174    pub fn sector(&self) -> Sector {
175        match self {
176            SectorData::Battery(_) => Sector::Battery,
177            SectorData::Textile(_) => Sector::Textile,
178            SectorData::UnsoldGoods(_) => Sector::UnsoldGoods,
179            SectorData::Steel(_) => Sector::Steel,
180            SectorData::Electronics(_) => Sector::Electronics,
181            SectorData::Construction(_) => Sector::Construction,
182            SectorData::Tyre(_) => Sector::Tyre,
183            SectorData::Toy(_) => Sector::Toy,
184            SectorData::Aluminium(_) => Sector::Aluminium,
185            SectorData::Furniture(_) => Sector::Furniture,
186            SectorData::Detergent(_) => Sector::Detergent,
187            SectorData::Other { sector, .. } => Sector::Other(sector.clone()),
188        }
189    }
190
191    /// The GTIN carried by this sector's typed data, if any.
192    ///
193    /// `UnsoldGoods` and `Other` carry no GTIN field — a discard-event report
194    /// and an untyped catch-all respectively, neither of which identifies a
195    /// trade item the way every other sector does.
196    pub fn gtin(&self) -> Option<&str> {
197        match self {
198            SectorData::Battery(d) => Some(d.gtin.as_str()),
199            SectorData::Textile(d) => Some(d.gtin.as_str()),
200            SectorData::Steel(d) => Some(d.gtin.as_str()),
201            SectorData::Electronics(d) => Some(d.gtin.as_str()),
202            SectorData::Construction(d) => Some(d.gtin.as_str()),
203            SectorData::Tyre(d) => Some(d.gtin.as_str()),
204            SectorData::Toy(d) => Some(d.gtin.as_str()),
205            SectorData::Aluminium(d) => Some(d.gtin.as_str()),
206            SectorData::Furniture(d) => Some(d.gtin.as_str()),
207            SectorData::Detergent(d) => Some(d.gtin.as_str()),
208            SectorData::UnsoldGoods(_) | SectorData::Other { .. } => None,
209        }
210    }
211}
212
213/// Serialize `data` to a JSON object and strip any top-level field the
214/// `audience` may not see.
215///
216/// `descriptor.disclosure` maps camelCase JSON field names to their
217/// [`Disclosure`](crate::domain::identity::Disclosure) class; visibility is
218/// decided by [`Audience::may_see`](crate::domain::identity::Audience::may_see),
219/// which is a lattice and not a threshold. Fields not listed in the map are
220/// always retained (default: Public).
221///
222/// Returns a `serde_json::Value::Object` with redacted fields removed.
223/// Returns `serde_json::Value::Null` if serialization fails.
224pub fn redact_sector_data(
225    data: &SectorData,
226    audience: crate::domain::identity::Audience,
227    descriptor: &crate::catalog::SectorDescriptor,
228) -> serde_json::Value {
229    let mut value = match serde_json::to_value(data) {
230        Ok(v) => v,
231        Err(_) => return serde_json::Value::Null,
232    };
233    if let Some(obj) = value.as_object_mut() {
234        obj.retain(|key, _| match descriptor.disclosure.get(key) {
235            Some(&class) => audience.may_see(class),
236            None => true,
237        });
238    }
239    value
240}