dpp_digital_link/digital_link/mod.rs
1//! GS1 Digital Link parser, builder, and GTIN utilities.
2//!
3//! Canonical Odal form: `https://id.odal-node.io/01/{gtin}/21/{serial}`
4//!
5//! Supports the GS1 Digital Link standard (GS1 DL URI Syntax, v1.2).
6//! Application Identifiers (AIs) recognised in the path:
7//! - `01` — GTIN-14 (primary key; GTIN-8/12/13 normalised to 14 by left-padding)
8//! - `22` — Consumer product variant (qualifier; canonical order 1)
9//! - `10` — Batch/lot number (qualifier; canonical order 2)
10//! - `21` — Serial number (qualifier; canonical order 3)
11//! - `235` — Third-party controlled serial (qualifier; canonical order 4)
12//!
13//! Query parameters (`?…`) are split from the path before segmenting so they
14//! can never corrupt the value of the last qualifier.
15//! AI values are percent-decoded on parse and percent-encoded on build.
16//! The resolver base URL preserves any path prefix that precedes the `/01/`
17//! segment, so `https://example.com/resolve/01/…` round-trips correctly.
18//!
19//! ## Module layout
20//!
21//! - `ai` — the recognised Application Identifier table.
22//! - `error` — [`DigitalLinkError`].
23//! - `codec` — percent-encode/decode and GTIN normalisation (private helpers).
24//! - this `mod.rs` — [`DigitalLink`] plus the [`validate_gtin`] / [`build_qr_url`] helpers.
25
26mod ai;
27mod codec;
28mod error;
29#[cfg(test)]
30mod tests;
31
32use dpp_domain::Gtin;
33
34pub use ai::{AI_TABLE, AiDescriptor, AiRole, ai_descriptor};
35pub use error::DigitalLinkError;
36
37use codec::{normalize_gtin_to_14, percent_decode, percent_encode};
38
39// ---------------------------------------------------------------------------
40// Public GTIN / URL helpers
41// ---------------------------------------------------------------------------
42
43/// Validate a GTIN string (14 digits, correct GS1 mod-10 check digit).
44///
45/// Accepts GTIN-14 only. GTIN-8 / GTIN-12 / GTIN-13 should be normalised to
46/// 14 digits before calling; [`DigitalLink::parse`] does this automatically.
47pub fn validate_gtin(gtin: &str) -> Result<(), DigitalLinkError> {
48 Gtin::parse(gtin)
49 .map(|_| ())
50 .map_err(DigitalLinkError::from)
51}
52
53/// Build a GS1 Digital Link URL for a passport.
54///
55/// Uses the passport ID as the serial number (AI 21) and an optional
56/// batch/lot (AI 10). AI values are percent-encoded.
57pub fn build_qr_url(
58 resolver_base: &str,
59 gtin: &str,
60 passport_id: &str,
61 batch: Option<&str>,
62) -> String {
63 let mut uri = format!("{}/01/{}", resolver_base.trim_end_matches('/'), gtin);
64 if let Some(b) = batch {
65 uri.push_str(&format!("/10/{}", percent_encode(b)));
66 }
67 uri.push_str(&format!("/21/{}", percent_encode(passport_id)));
68 uri
69}
70
71// ---------------------------------------------------------------------------
72// DigitalLink
73// ---------------------------------------------------------------------------
74
75#[derive(Debug, Clone, PartialEq)]
76pub struct DigitalLink {
77 /// Base resolver URL including any path prefix before the `/01/` segment
78 /// (e.g. `https://id.odal-node.io` or `https://example.com/resolve`).
79 pub resolver_base: String,
80 /// Validated 14-digit GTIN.
81 pub gtin: Gtin,
82 /// Consumer product variant (AI 22).
83 pub variant: Option<String>,
84 /// Batch / lot number (AI 10).
85 pub batch: Option<String>,
86 /// Serial number (AI 21).
87 pub serial: Option<String>,
88 /// Third-party controlled serial number (AI 235).
89 pub tpcsn: Option<String>,
90}
91
92impl DigitalLink {
93 /// Parse a GS1 Digital Link URI.
94 ///
95 /// Accepted forms:
96 /// - `https://id.odal-node.io/01/09506000134352/21/ABC123`
97 /// - `https://id.odal-node.io/01/09506000134352/10/BATCH01/21/SN001`
98 /// - `https://example.com/resolve/01/09506000134352/21/SN001` (path prefix)
99 ///
100 /// GTIN-8 / GTIN-12 / GTIN-13 are normalised to 14 digits by left-padding.
101 /// Unknown AI codes produce `UnknownApplicationIdentifier`.
102 /// Qualifiers out of canonical order produce `QualifiersOutOfOrder`.
103 pub fn parse(uri: &str) -> Result<Self, DigitalLinkError> {
104 // Strip query string so `?linkType=…` never corrupts the last value.
105 let path_end = uri.find('?').unwrap_or(uri.len());
106 let uri_no_query = &uri[..path_end];
107
108 if !uri_no_query.starts_with("https://") {
109 let scheme = uri_no_query.split("://").next().unwrap_or("").to_owned();
110 return Err(DigitalLinkError::InvalidScheme(scheme));
111 }
112
113 let without_scheme = &uri_no_query["https://".len()..];
114 let slash_pos = without_scheme.find('/').unwrap_or(without_scheme.len());
115 let host = &without_scheme[..slash_pos];
116 let path = &without_scheme[slash_pos..];
117
118 let all_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
119
120 // Locate the primary key (AI 01) — everything before it is the resolver
121 // path prefix.
122 let gtin_seg_pos = all_segments
123 .iter()
124 .position(|s| *s == "01")
125 .ok_or(DigitalLinkError::MissingGtin)?;
126
127 let path_prefix = if gtin_seg_pos > 0 {
128 format!("/{}", all_segments[..gtin_seg_pos].join("/"))
129 } else {
130 String::new()
131 };
132
133 // Process AI segments starting at "01".
134 let ai_segments = &all_segments[gtin_seg_pos..];
135 let mut i = 0;
136 let mut gtin: Option<Gtin> = None;
137 let mut variant: Option<String> = None;
138 let mut batch: Option<String> = None;
139 let mut serial: Option<String> = None;
140 let mut tpcsn: Option<String> = None;
141 let mut last_qualifier_order: u8 = 0;
142 let mut last_qualifier_code: &str = "";
143
144 while i + 1 < ai_segments.len() {
145 let code = ai_segments[i];
146 let desc = ai_descriptor(code)
147 .ok_or_else(|| DigitalLinkError::UnknownApplicationIdentifier(code.to_owned()))?;
148
149 let raw_value = ai_segments[i + 1];
150 let value = percent_decode(raw_value);
151
152 match desc.role {
153 AiRole::PrimaryKey => {
154 let padded = normalize_gtin_to_14(&value)?;
155 gtin = Some(Gtin::parse(&padded)?);
156 }
157 AiRole::Qualifier => {
158 let order = desc.qualifier_order.unwrap_or(0);
159 if order <= last_qualifier_order && last_qualifier_order > 0 {
160 return Err(DigitalLinkError::QualifiersOutOfOrder {
161 before: last_qualifier_code.to_owned(),
162 before_ord: last_qualifier_order,
163 after: code.to_owned(),
164 after_ord: order,
165 });
166 }
167 last_qualifier_order = order;
168 last_qualifier_code = code;
169 match code {
170 "22" => variant = Some(value),
171 "10" => batch = Some(value),
172 "21" => serial = Some(value),
173 "235" => tpcsn = Some(value),
174 _ => {}
175 }
176 }
177 AiRole::DataAttribute => {
178 // Informational only; silently accepted.
179 }
180 }
181
182 i += 2;
183 }
184
185 let gtin = gtin.ok_or(DigitalLinkError::MissingGtin)?;
186
187 Ok(Self {
188 resolver_base: format!("https://{host}{path_prefix}"),
189 gtin,
190 variant,
191 batch,
192 serial,
193 tpcsn,
194 })
195 }
196
197 /// Build a canonical GS1 Digital Link URI with qualifiers in standard order.
198 ///
199 /// AI values containing reserved characters are percent-encoded.
200 pub fn build(&self) -> String {
201 let mut uri = format!(
202 "{}/01/{}",
203 self.resolver_base.trim_end_matches('/'),
204 self.gtin.as_str()
205 );
206 if let Some(v) = &self.variant {
207 uri.push_str(&format!("/22/{}", percent_encode(v)));
208 }
209 if let Some(b) = &self.batch {
210 uri.push_str(&format!("/10/{}", percent_encode(b)));
211 }
212 if let Some(s) = &self.serial {
213 uri.push_str(&format!("/21/{}", percent_encode(s)));
214 }
215 if let Some(t) = &self.tpcsn {
216 uri.push_str(&format!("/235/{}", percent_encode(t)));
217 }
218 uri
219 }
220}