Skip to main content

dpp_aas/
builder.rs

1//! [`build_aas_from_passport`] — the primary entry point mapping a passport to
2//! a complete AAS shell + submodels.
3
4use dpp_domain::access::{SectorAccessPolicy, filter_by_audience};
5use dpp_domain::{Audience, Passport, SectorCatalog};
6
7use super::model::{AasShell, AasSubmodel, AasSubmodelRef, AssetInformation, SpecificAssetId};
8use super::sectors;
9
10/// Why an AAS projection could not be built.
11#[derive(Debug)]
12pub enum AasError {
13    /// The passport did not survive a masking round-trip. Structural, not a
14    /// permissions failure: the disclosure policy removed a field the passport
15    /// requires to exist, so no honest projection can be produced for that
16    /// audience. Fails closed rather than emitting a partial shell.
17    Masking(String),
18}
19
20impl std::fmt::Display for AasError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Self::Masking(m) => write!(f, "passport did not survive masking: {m}"),
24        }
25    }
26}
27
28impl std::error::Error for AasError {}
29
30/// Map a [`Passport`] and its GS1 GTIN into a complete AAS shell + submodels,
31/// carrying only what `audience` may see.
32///
33/// Returns `(AasShell, Vec<AasSubmodel>)`. The shell's `submodels` list
34/// contains only ID references; the payloads are in the `Vec`.
35///
36/// `gtin` is the 14-digit GTIN identifying the product model. It becomes the
37/// `globalAssetId` and a `specificAssetId` entry for GS1 Digital Link routing.
38///
39/// # Masking
40///
41/// The passport is filtered **before** any mapper sees it, through the same
42/// [`filter_by_audience`] seam the public view uses — not filtered afterwards,
43/// and never by the mappers themselves. This is the whole contract: a mapper
44/// that assembled its own field list would eventually disagree with the
45/// canonical one, and the direction it disagrees in is the direction that
46/// leaks. There is deliberately no unmasked entry point.
47///
48/// The round-trip works because every non-public field in the catalog is
49/// optional on its typed struct, so a redacted document still deserialises with
50/// those fields absent and the mappers simply do not emit them.
51///
52/// # Errors
53///
54/// [`AasError::Masking`] if the filtered document no longer deserialises into a
55/// `Passport` — a required field was classified non-public, which is a policy
56/// defect rather than a caller error.
57pub fn build_aas_from_passport(
58    passport: &Passport,
59    gtin: &str,
60    audience: Audience,
61) -> Result<(AasShell, Vec<AasSubmodel>), AasError> {
62    let passport = &mask(passport, audience)?;
63    let passport_id = passport.id.to_string();
64
65    let mut specific_asset_ids = vec![
66        SpecificAssetId {
67            name: "gtin".into(),
68            value: gtin.to_owned(),
69        },
70        SpecificAssetId {
71            name: "serialId".into(),
72            value: passport_id.clone(),
73        },
74    ];
75    if let Some(batch) = &passport.batch_id {
76        specific_asset_ids.push(SpecificAssetId {
77            name: "batchId".into(),
78            value: batch.clone(),
79        });
80    }
81
82    let mut submodels = vec![
83        sectors::build_product_identification_submodel(passport),
84        sectors::build_manufacturer_submodel(passport),
85        sectors::build_environmental_impact_submodel(passport),
86        sectors::build_material_composition_submodel(passport),
87        sectors::build_repairability_submodel(passport),
88    ];
89    if let Some(sd) = &passport.sector_data {
90        submodels.push(sectors::build_sector_submodel(sd, &passport_id));
91    }
92
93    let shell = AasShell {
94        id: format!("urn:odal-node:aas:{passport_id}"),
95        id_short: "DigitalProductPassport".into(),
96        model_type: "AssetAdministrationShell".into(),
97        kind: "Instance".into(),
98        asset_information: AssetInformation {
99            global_asset_id: format!("urn:odal-node:product:{gtin}"),
100            specific_asset_ids,
101        },
102        submodels: submodels
103            .iter()
104            .map(|s| AasSubmodelRef { id: s.id.clone() })
105            .collect(),
106    };
107
108    Ok((shell, submodels))
109}
110
111/// Apply the sector's disclosure policy to the whole passport document.
112///
113/// Policy resolution mirrors the public view: the sector's own classes from the
114/// catalog when it has an entry, otherwise the passport-level defaults — so a
115/// sector this build has never seen is masked by the conservative default
116/// rather than passed through unfiltered.
117fn mask(passport: &Passport, audience: Audience) -> Result<Passport, AasError> {
118    let policy = SectorAccessPolicy::from_catalog(catalog(), passport.sector.catalog_key())
119        .unwrap_or_else(SectorAccessPolicy::passport_default);
120
121    let document =
122        serde_json::to_value(passport).map_err(|e| AasError::Masking(format!("serialise: {e}")))?;
123    let decision = filter_by_audience(&document, &policy, audience);
124
125    serde_json::from_value(decision.filtered_data)
126        .map_err(|e| AasError::Masking(format!("redacted document no longer valid: {e}")))
127}
128
129/// The embedded sector catalog, built once.
130fn catalog() -> &'static SectorCatalog {
131    static CATALOG: std::sync::OnceLock<SectorCatalog> = std::sync::OnceLock::new();
132    CATALOG.get_or_init(SectorCatalog::new)
133}