1use std::fmt;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
13#[non_exhaustive]
14pub struct UriInfoEntry {
15 pub data: String,
17 pub metadata: Vec<(String, String)>,
21}
22
23impl UriInfoEntry {
24 pub fn param(&self, key: &str) -> Option<&str> {
26 self.metadata
27 .iter()
28 .find_map(|(k, v)| {
29 if k.eq_ignore_ascii_case(key) {
30 Some(v.as_str())
31 } else {
32 None
33 }
34 })
35 }
36
37 pub fn purpose(&self) -> Option<&str> {
39 self.param("purpose")
40 }
41}
42
43impl fmt::Display for UriInfoEntry {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 write!(f, "<{}>", self.data)?;
46 for (key, value) in &self.metadata {
47 if value.is_empty() {
48 write!(f, ";{key}")?;
49 } else {
50 write!(f, ";{key}={value}")?;
51 }
52 }
53 Ok(())
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct UriInfo(Vec<UriInfoEntry>);
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum UriInfoError {
76 Empty,
78 MissingAngleBrackets(String),
80}
81
82impl fmt::Display for UriInfoError {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 match self {
85 Self::Empty => write!(f, "empty URI-info header value"),
86 Self::MissingAngleBrackets(raw) => {
87 write!(f, "missing angle brackets in URI-info entry: {raw}")
88 }
89 }
90 }
91}
92
93impl std::error::Error for UriInfoError {}
94
95fn parse_entry(raw: &str) -> Result<UriInfoEntry, UriInfoError> {
96 let raw = raw.trim();
97 if raw.is_empty() {
98 return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
99 }
100
101 let (data_part, metadata_part) = match raw.split_once(';') {
104 Some((d, m)) => (d, Some(m)),
105 None => (raw, None),
106 };
107
108 let data = data_part
109 .trim()
110 .trim_matches(|c| c == '<' || c == '>')
111 .to_string();
112 if data.is_empty() {
113 return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
114 }
115
116 let mut metadata = Vec::new();
117 if let Some(meta_str) = metadata_part {
118 if !meta_str.is_empty() {
119 for segment in meta_str.split(';') {
120 let segment = segment.trim();
121 if segment.is_empty() {
122 continue;
123 }
124 if let Some((key, value)) = segment.split_once('=') {
125 metadata.push((
126 key.trim()
127 .to_ascii_lowercase(),
128 value
129 .trim()
130 .to_string(),
131 ));
132 } else {
133 metadata.push((segment.to_ascii_lowercase(), String::new()));
134 }
135 }
136 }
137 }
138
139 Ok(UriInfoEntry { data, metadata })
140}
141
142use crate::split_comma_entries;
143
144impl UriInfo {
145 pub fn parse(raw: &str) -> Result<Self, UriInfoError> {
147 let raw = raw.trim();
148 if raw.is_empty() {
149 return Err(UriInfoError::Empty);
150 }
151 Self::from_entries(split_comma_entries(raw))
152 }
153
154 pub fn from_entries<'a>(
160 entries: impl IntoIterator<Item = &'a str>,
161 ) -> Result<Self, UriInfoError> {
162 let entries: Vec<_> = entries
163 .into_iter()
164 .map(parse_entry)
165 .collect::<Result<_, _>>()?;
166 if entries.is_empty() {
167 return Err(UriInfoError::Empty);
168 }
169 Ok(Self(entries))
170 }
171
172 pub fn entries(&self) -> &[UriInfoEntry] {
174 &self.0
175 }
176
177 pub fn into_entries(self) -> Vec<UriInfoEntry> {
179 self.0
180 }
181
182 pub fn len(&self) -> usize {
184 self.0
185 .len()
186 }
187
188 pub fn is_empty(&self) -> bool {
190 self.0
191 .is_empty()
192 }
193}
194
195impl fmt::Display for UriInfo {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197 crate::fmt_joined(f, &self.0, ",")
198 }
199}
200
201impl<'a> IntoIterator for &'a UriInfo {
202 type Item = &'a UriInfoEntry;
203 type IntoIter = std::slice::Iter<'a, UriInfoEntry>;
204
205 fn into_iter(self) -> Self::IntoIter {
206 self.0
207 .iter()
208 }
209}
210
211impl IntoIterator for UriInfo {
212 type Item = UriInfoEntry;
213 type IntoIter = std::vec::IntoIter<UriInfoEntry>;
214
215 fn into_iter(self) -> Self::IntoIter {
216 self.0
217 .into_iter()
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
228 fn entry_no_metadata() {
229 let entry = parse_entry("<data>").unwrap();
230 assert_eq!(entry.data, "data");
231 assert!(entry
232 .metadata
233 .is_empty());
234 }
235
236 #[test]
237 fn entry_no_metadata_trailing_semicolon() {
238 let entry = parse_entry("<data>;").unwrap();
239 assert_eq!(entry.data, "data");
240 assert!(entry
241 .metadata
242 .is_empty());
243 }
244
245 #[test]
246 fn entry_no_value_metadata() {
247 let entry = parse_entry("<data>;meta1").unwrap();
248 assert_eq!(
249 entry
250 .metadata
251 .len(),
252 1
253 );
254 assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
255 }
256
257 #[test]
258 fn entry_empty_value_metadata() {
259 let entry = parse_entry("<data>;meta1=").unwrap();
260 assert_eq!(
261 entry
262 .metadata
263 .len(),
264 1
265 );
266 assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
267 }
268
269 #[test]
270 fn entry_two_metadata_items() {
271 let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
272 assert_eq!(entry.data, "data");
273 assert_eq!(
274 entry
275 .metadata
276 .len(),
277 2
278 );
279 assert_eq!(entry.param("meta1"), Some("one"));
280 assert_eq!(entry.param("meta2"), Some("two"));
281 }
282
283 #[test]
284 fn entry_strips_angle_brackets() {
285 let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
286 assert_eq!(entry.data, "data");
287 }
288
289 #[test]
290 fn entry_uppercase_metadata_key_lowercased() {
291 let entry = parse_entry("<data>;Meta-1=one").unwrap();
292 assert!(entry
293 .metadata
294 .iter()
295 .all(|(k, _)| k == &k.to_ascii_lowercase()));
296 assert_eq!(entry.param("meta-1"), Some("one"));
297 }
298
299 #[test]
300 fn entry_display_no_trailing_semicolon() {
301 let entry = parse_entry("<data>;").unwrap();
302 let s = entry.to_string();
303 assert!(!s.ends_with(';'));
304 }
305
306 #[test]
307 fn entry_display_metadata_no_trailing_semicolon() {
308 let entry = parse_entry("<data>;meta=one;").unwrap();
309 let s = entry.to_string();
310 assert!(!s.ends_with(';'));
311 }
312
313 #[test]
314 fn entry_display_contains_all_metadata() {
315 let entry = parse_entry("<http://somedata/?arg=123>").unwrap();
316 let mut entry = entry;
318 entry
319 .metadata
320 .push(("meta1".to_string(), "one".to_string()));
321 entry
322 .metadata
323 .push(("meta2".to_string(), "two".to_string()));
324 let s = entry.to_string();
325 assert!(
326 s.matches(';')
327 .count()
328 >= 2
329 );
330 }
331
332 #[test]
333 fn entry_display_no_value_key() {
334 let entry = parse_entry("<data>;flagkey").unwrap();
335 assert_eq!(entry.to_string(), "<data>;flagkey");
336 }
337
338 const SAMPLE_EMERGENCY: &str = "\
341<urn:emergency:uid:callid:20250401080740945abc123:bcf.example.com>;purpose=emergency-CallId,\
342<urn:emergency:uid:incidentid:20250401080740945def456:bcf.example.com>;purpose=emergency-IncidentId,\
343<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=abc>;purpose=EmergencyCallData.ProviderInfo,\
344<https://adr.example.com/api/v1/adr/call/serviceInfo?token=ghi>;purpose=EmergencyCallData.ServiceInfo";
345
346 const SAMPLE_WITH_SITE: &str = "\
347<urn:emergency:uid:callid:test:bcf.example.com>;purpose=emergency-CallId;site=bcf.example.com,\
348<urn:emergency:uid:incidentid:test:bcf.example.com>;purpose=emergency-IncidentId";
349
350 const SAMPLE_FULL: &str = "\
353<urn:nena:callid:20190912100022147abc:bcf1.example.com>;purpose=nena-CallId,\
354<https://eido.psap.example.com/EidoRetrievalService/urn:nena:incidentid:test>;purpose=emergency_incident_data_object,\
355<urn:nena:incidentid:20190912100022147def:bcf1.example.com>;purpose=nena-IncidentId,\
356<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=a>;purpose=EmergencyCallData.ProviderInfo,\
357<https://adr.example.com/api/v1/adr/call/providerInfo/telecom?token=b>;purpose=EmergencyCallData.ProviderInfo;site=bcf.example.com;,\
358<https://adr.example.com/api/v1/adr/call/serviceInfo?token=c>;purpose=EmergencyCallData.ServiceInfo,\
359<https://adr.example.com/api/v1/adr/call/subscriberInfo?token=d>;purpose=EmergencyCallData.SubscriberInfo,\
360<https://adr.example.com/api/v1/adr/call/comment?token=e>;purpose=EmergencyCallData.Comment";
361
362 #[test]
363 fn parse_comma_separated() {
364 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
365 assert_eq!(info.len(), 4);
366 assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
367 assert_eq!(info.entries()[1].purpose(), Some("emergency-IncidentId"));
368 }
369
370 #[test]
371 fn parse_full_fixture_all_entries() {
372 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
373 assert_eq!(info.len(), 8);
374 }
375
376 #[test]
377 fn full_fixture_nena_prefix_callid() {
378 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
379 let entry = info
380 .entries()
381 .iter()
382 .find(|e| e.purpose() == Some("nena-CallId"))
383 .unwrap();
384 assert!(entry
385 .data
386 .contains("callid"));
387 }
388
389 #[test]
390 fn full_fixture_legacy_eido_purpose() {
391 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
392 let eido: Vec<_> = info
393 .entries()
394 .iter()
395 .filter(|e| {
396 e.purpose()
397 .is_some_and(|p| p.contains("incident_data_object"))
398 })
399 .collect();
400 assert_eq!(eido.len(), 1);
401 assert!(eido[0]
402 .data
403 .contains("EidoRetrievalService"));
404 }
405
406 #[test]
407 fn full_fixture_trailing_semicolon_with_site() {
408 let info = UriInfo::parse(SAMPLE_FULL).unwrap();
409 let with_site: Vec<_> = info
410 .entries()
411 .iter()
412 .filter(|e| {
413 e.param("site")
414 .is_some()
415 })
416 .collect();
417 assert_eq!(with_site.len(), 1);
418 assert_eq!(with_site[0].param("site"), Some("bcf.example.com"));
419 }
420
421 #[test]
422 fn find_by_purpose() {
423 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
424
425 let call_id = info
426 .entries()
427 .iter()
428 .find(|e| e.purpose() == Some("emergency-CallId"))
429 .unwrap();
430 assert!(call_id
431 .data
432 .contains("callid"));
433
434 let incident = info
435 .entries()
436 .iter()
437 .find(|e| e.purpose() == Some("emergency-IncidentId"))
438 .unwrap();
439 assert!(incident
440 .data
441 .contains("incidentid"));
442 }
443
444 #[test]
445 fn param_lookup_by_purpose() {
446 let legacy = "<urn:nena:callid:test:example.ca>;purpose=nena-CallId";
447 let info = UriInfo::parse(legacy).unwrap();
448 assert_eq!(info.entries()[0].purpose(), Some("nena-CallId"));
449
450 let modern = "<urn:emergency:uid:callid:test:example.ca>;purpose=emergency-CallId";
451 let info = UriInfo::parse(modern).unwrap();
452 assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
453 }
454
455 #[test]
456 fn filter_entries_by_param() {
457 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
458 let adr: Vec<_> = info
459 .entries()
460 .iter()
461 .filter(|e| {
462 e.purpose()
463 .is_some_and(|p| p.ends_with("Info"))
464 })
465 .collect();
466 assert_eq!(adr.len(), 2);
467 }
468
469 #[test]
470 fn metadata_param_lookup() {
471 let info = UriInfo::parse(SAMPLE_WITH_SITE).unwrap();
472 assert_eq!(info.entries()[0].param("site"), Some("bcf.example.com"));
473 assert_eq!(info.entries()[0].param("purpose"), Some("emergency-CallId"));
474 assert!(info.entries()[1]
475 .param("site")
476 .is_none());
477 }
478
479 #[test]
480 fn display_roundtrip() {
481 let raw = "<urn:example:test>;purpose=test-purpose;site=example.com";
482 let info = UriInfo::parse(raw).unwrap();
483 assert_eq!(info.to_string(), raw);
484 }
485
486 #[test]
487 fn display_comma_count_matches_entries() {
488 let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
489 let s = info.to_string();
490 assert_eq!(
491 s.matches(',')
492 .count()
493 + 1,
494 info.len()
495 );
496 }
497
498 #[test]
499 fn empty_input() {
500 assert!(matches!(UriInfo::parse(""), Err(UriInfoError::Empty)));
501 }
502}