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