Skip to main content

dpp_digital_link/digital_link/
link.rs

1//! [`DigitalLink`] — a parsed GS1 Digital Link URI.
2
3use dpp_domain::Gtin;
4
5use super::ai::{AiRole, ai_descriptor};
6use super::codec::{normalize_gtin_to_14, percent_decode, percent_encode};
7use super::error::DigitalLinkError;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct DigitalLink {
11    /// Base resolver URL including any path prefix before the `/01/` segment
12    /// (e.g. `https://id.odal-node.io` or `https://example.com/resolve`).
13    pub resolver_base: String,
14    /// Validated 14-digit GTIN.
15    pub gtin: Gtin,
16    /// Consumer product variant (AI 22).
17    pub variant: Option<String>,
18    /// Batch / lot number (AI 10).
19    pub batch: Option<String>,
20    /// Serial number (AI 21).
21    pub serial: Option<String>,
22    /// Third-party controlled serial number (AI 235).
23    pub tpcsn: Option<String>,
24}
25
26impl DigitalLink {
27    /// Parse a GS1 Digital Link URI.
28    ///
29    /// Accepted forms:
30    /// - `https://id.odal-node.io/01/09506000134352/21/ABC123`
31    /// - `https://id.odal-node.io/01/09506000134352/10/BATCH01/21/SN001`
32    /// - `https://example.com/resolve/01/09506000134352/21/SN001` (path prefix)
33    ///
34    /// GTIN-8 / GTIN-12 / GTIN-13 are normalised to 14 digits by left-padding.
35    /// Unknown AI codes produce `UnknownApplicationIdentifier`.
36    /// Qualifiers out of canonical order produce `QualifiersOutOfOrder`.
37    pub fn parse(uri: &str) -> Result<Self, DigitalLinkError> {
38        // Strip query string so `?linkType=…` never corrupts the last value.
39        let path_end = uri.find('?').unwrap_or(uri.len());
40        let uri_no_query = &uri[..path_end];
41
42        if !uri_no_query.starts_with("https://") {
43            let scheme = uri_no_query.split("://").next().unwrap_or("").to_owned();
44            return Err(DigitalLinkError::InvalidScheme(scheme));
45        }
46
47        let without_scheme = &uri_no_query["https://".len()..];
48        let slash_pos = without_scheme.find('/').unwrap_or(without_scheme.len());
49        let host = &without_scheme[..slash_pos];
50        let path = &without_scheme[slash_pos..];
51
52        let all_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
53
54        // Locate the primary key (AI 01) — everything before it is the resolver
55        // path prefix.
56        let gtin_seg_pos = all_segments
57            .iter()
58            .position(|s| *s == "01")
59            .ok_or(DigitalLinkError::MissingGtin)?;
60
61        let path_prefix = if gtin_seg_pos > 0 {
62            format!("/{}", all_segments[..gtin_seg_pos].join("/"))
63        } else {
64            String::new()
65        };
66
67        // Process AI segments starting at "01".
68        let ai_segments = &all_segments[gtin_seg_pos..];
69        let mut i = 0;
70        let mut gtin: Option<Gtin> = None;
71        let mut variant: Option<String> = None;
72        let mut batch: Option<String> = None;
73        let mut serial: Option<String> = None;
74        let mut tpcsn: Option<String> = None;
75        let mut last_qualifier_order: u8 = 0;
76        let mut last_qualifier_code: &str = "";
77
78        while i + 1 < ai_segments.len() {
79            let code = ai_segments[i];
80            let desc = ai_descriptor(code)
81                .ok_or_else(|| DigitalLinkError::UnknownApplicationIdentifier(code.to_owned()))?;
82
83            let raw_value = ai_segments[i + 1];
84            let value = percent_decode(raw_value);
85
86            // GS1 mandates a maximum length per AI; enforce it so an untrusted
87            // URI cannot smuggle an unbounded value downstream.
88            let value_len = value.chars().count();
89            if value_len > desc.max_len {
90                return Err(DigitalLinkError::ValueTooLong {
91                    code: code.to_owned(),
92                    max_len: desc.max_len,
93                    actual: value_len,
94                });
95            }
96
97            match desc.role {
98                AiRole::PrimaryKey => {
99                    // A second '01' segment must not silently overwrite the
100                    // GTIN parsed from the first.
101                    if gtin.is_some() {
102                        return Err(DigitalLinkError::DuplicatePrimaryKey);
103                    }
104                    let padded = normalize_gtin_to_14(&value)?;
105                    gtin = Some(Gtin::parse(&padded)?);
106                }
107                AiRole::Qualifier => {
108                    let order = desc.qualifier_order.unwrap_or(0);
109                    if order <= last_qualifier_order && last_qualifier_order > 0 {
110                        return Err(DigitalLinkError::QualifiersOutOfOrder {
111                            before: last_qualifier_code.to_owned(),
112                            before_ord: last_qualifier_order,
113                            after: code.to_owned(),
114                            after_ord: order,
115                        });
116                    }
117                    last_qualifier_order = order;
118                    last_qualifier_code = code;
119                    match code {
120                        "22" => variant = Some(value),
121                        "10" => batch = Some(value),
122                        "21" => serial = Some(value),
123                        "235" => tpcsn = Some(value),
124                        _ => {}
125                    }
126                }
127                AiRole::DataAttribute => {
128                    // Informational only; silently accepted.
129                }
130            }
131
132            i += 2;
133        }
134
135        // An odd segment count leaves a trailing AI code with no value — reject
136        // it rather than silently dropping the dangling qualifier.
137        if i < ai_segments.len() {
138            return Err(DigitalLinkError::TrailingUnpairedSegment(
139                ai_segments[i].to_owned(),
140            ));
141        }
142
143        let gtin = gtin.ok_or(DigitalLinkError::MissingGtin)?;
144
145        Ok(Self {
146            resolver_base: format!("https://{host}{path_prefix}"),
147            gtin,
148            variant,
149            batch,
150            serial,
151            tpcsn,
152        })
153    }
154
155    /// Build a canonical GS1 Digital Link URI with qualifiers in standard order.
156    ///
157    /// AI values containing reserved characters are percent-encoded.
158    pub fn build(&self) -> String {
159        let mut uri = format!(
160            "{}/01/{}",
161            self.resolver_base.trim_end_matches('/'),
162            self.gtin.as_str()
163        );
164        if let Some(v) = &self.variant {
165            uri.push_str(&format!("/22/{}", percent_encode(v)));
166        }
167        if let Some(b) = &self.batch {
168            uri.push_str(&format!("/10/{}", percent_encode(b)));
169        }
170        if let Some(s) = &self.serial {
171            uri.push_str(&format!("/21/{}", percent_encode(s)));
172        }
173        if let Some(t) = &self.tpcsn {
174            uri.push_str(&format!("/235/{}", percent_encode(t)));
175        }
176        uri
177    }
178}