Skip to main content

mx20022_parse/
envelope.rs

1//! Utilities for detecting ISO 20022 message type from raw XML.
2//!
3//! ISO 20022 messages are wrapped in a root element (e.g. `<Document>` or
4//! `<AppHdr>`) whose `xmlns` attribute carries the message namespace in the
5//! form:
6//!
7//! ```text
8//! urn:iso:std:iso:20022:tech:xsd:{family}.{msg}.{variant}.{version}
9//! ```
10//!
11//! For example `urn:iso:std:iso:20022:tech:xsd:pacs.008.001.13` yields:
12//! - family   → `"pacs"`
13//! - `msg_id`   → `"008"`
14//! - variant  → `"001"`
15//! - version  → `"13"`
16
17use crate::ParseError;
18
19/// Message type identifier extracted from an ISO 20022 XML namespace URI.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct MessageId {
22    /// Message family, e.g. `"pacs"`, `"pain"`, `"camt"`, `"head"`.
23    pub family: String,
24    /// Message identifier number, e.g. `"008"`.
25    pub msg_id: String,
26    /// Variant number, e.g. `"001"`.
27    pub variant: String,
28    /// Version number, e.g. `"13"`.
29    pub version: String,
30}
31
32impl MessageId {
33    /// Returns the canonical dotted identifier, e.g. `"pacs.008.001.13"`.
34    pub fn dotted(&self) -> String {
35        format!(
36            "{}.{}.{}.{}",
37            self.family, self.msg_id, self.variant, self.version
38        )
39    }
40}
41
42/// Prefix that every ISO 20022 namespace begins with.
43const NS_PREFIX: &str = "urn:iso:std:iso:20022:tech:xsd:";
44
45/// Parse an ISO 20022 namespace URI into a [`MessageId`].
46///
47/// Expects the form `urn:iso:std:iso:20022:tech:xsd:{family}.{msg}.{variant}.{version}`.
48///
49/// # Errors
50///
51/// Returns [`ParseError::InvalidEnvelope`] if the namespace does not match the
52/// expected pattern.
53pub fn parse_namespace(ns: &str) -> Result<MessageId, ParseError> {
54    let suffix = ns.strip_prefix(NS_PREFIX).ok_or_else(|| {
55        ParseError::InvalidEnvelope(format!(
56            "namespace does not start with \"{NS_PREFIX}\": {ns}"
57        ))
58    })?;
59
60    // suffix should be e.g. "pacs.008.001.13"
61    let parts: Vec<&str> = suffix.splitn(4, '.').collect();
62    if parts.len() != 4 {
63        return Err(ParseError::InvalidEnvelope(format!(
64            "expected 4 dot-separated components in namespace suffix \"{suffix}\""
65        )));
66    }
67
68    Ok(MessageId {
69        family: parts[0].to_owned(),
70        msg_id: parts[1].to_owned(),
71        variant: parts[2].to_owned(),
72        version: parts[3].to_owned(),
73    })
74}
75
76/// Extract the ISO 20022 message type from the root element's `xmlns` attribute.
77///
78/// Scans the raw XML for the first occurrence of an `xmlns` attribute (or
79/// `xmlns=` on the root element) whose value matches the ISO 20022 namespace
80/// pattern.
81///
82/// # Errors
83///
84/// Returns [`ParseError::InvalidEnvelope`] if no matching namespace is found or
85/// the namespace is malformed.
86///
87/// # Examples
88///
89/// ```
90/// # use mx20022_parse::envelope::detect_message_type;
91/// let xml = r#"<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.13"><FIToFICstmrCdtTrf/></Document>"#;
92/// let id = detect_message_type(xml).unwrap();
93/// assert_eq!(id.family,  "pacs");
94/// assert_eq!(id.msg_id,  "008");
95/// assert_eq!(id.variant, "001");
96/// assert_eq!(id.version, "13");
97/// ```
98pub fn detect_message_type(xml: &str) -> Result<MessageId, ParseError> {
99    // Walk through all xmlns="..." occurrences and return the first that parses.
100    let mut search = xml;
101    while let Some(pos) = search.find("xmlns") {
102        let after_xmlns = &search[pos + 5..];
103
104        // Skip optional namespace prefix (xmlns:foo=) or plain xmlns=
105        let after_eq = if let Some(p) = after_xmlns.find('=') {
106            // Make sure there is no whitespace or '>' between "xmlns" and '='
107            let between = &after_xmlns[..p];
108            if between.contains('>') || between.contains('<') {
109                search = &search[pos + 5..];
110                continue;
111            }
112            &after_xmlns[p + 1..]
113        } else {
114            search = &search[pos + 5..];
115            continue;
116        };
117
118        // Skip leading whitespace then expect a quote
119        let after_eq = after_eq.trim_start();
120        let Some(quote_char @ ('"' | '\'')) = after_eq.chars().next() else {
121            search = &search[pos + 5..];
122            continue;
123        };
124
125        let after_open_quote = &after_eq[1..];
126        if let Some(close_pos) = after_open_quote.find(quote_char) {
127            let ns_value = &after_open_quote[..close_pos];
128            if ns_value.starts_with(NS_PREFIX) {
129                return parse_namespace(ns_value);
130            }
131        }
132
133        // Advance past this xmlns occurrence and keep looking
134        search = &search[pos + 5..];
135    }
136
137    Err(ParseError::InvalidEnvelope(
138        "no ISO 20022 namespace found in XML document".to_owned(),
139    ))
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn parse_namespace_pacs_008() {
148        let id = parse_namespace("urn:iso:std:iso:20022:tech:xsd:pacs.008.001.13").unwrap();
149        assert_eq!(id.family, "pacs");
150        assert_eq!(id.msg_id, "008");
151        assert_eq!(id.variant, "001");
152        assert_eq!(id.version, "13");
153        assert_eq!(id.dotted(), "pacs.008.001.13");
154    }
155
156    #[test]
157    fn parse_namespace_head_001() {
158        let id = parse_namespace("urn:iso:std:iso:20022:tech:xsd:head.001.001.04").unwrap();
159        assert_eq!(id.family, "head");
160        assert_eq!(id.msg_id, "001");
161        assert_eq!(id.variant, "001");
162        assert_eq!(id.version, "04");
163    }
164
165    #[test]
166    fn parse_namespace_invalid_prefix() {
167        let err = parse_namespace("urn:something:else:pacs.008.001.13").unwrap_err();
168        assert!(matches!(err, ParseError::InvalidEnvelope(_)));
169    }
170
171    #[test]
172    fn parse_namespace_wrong_component_count() {
173        let err = parse_namespace("urn:iso:std:iso:20022:tech:xsd:pacs.008.001").unwrap_err();
174        assert!(matches!(err, ParseError::InvalidEnvelope(_)));
175    }
176
177    #[test]
178    fn detect_message_type_document_root() {
179        let xml = r#"<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.13"><FIToFICstmrCdtTrf/></Document>"#;
180        let id = detect_message_type(xml).unwrap();
181        assert_eq!(id.dotted(), "pacs.008.001.13");
182    }
183
184    #[test]
185    fn detect_message_type_apphdr_root() {
186        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
187<AppHdr xmlns="urn:iso:std:iso:20022:tech:xsd:head.001.001.04">
188  <Fr/>
189</AppHdr>"#;
190        let id = detect_message_type(xml).unwrap();
191        assert_eq!(id.dotted(), "head.001.001.04");
192    }
193
194    #[test]
195    fn detect_message_type_no_namespace_returns_error() {
196        let xml = r#"<Document><FIToFICstmrCdtTrf/></Document>"#;
197        assert!(detect_message_type(xml).is_err());
198    }
199
200    #[test]
201    fn detect_message_type_non_iso_namespace_returns_error() {
202        let xml = r#"<root xmlns="http://example.com/other"/>"#;
203        assert!(detect_message_type(xml).is_err());
204    }
205
206    #[test]
207    fn detect_message_type_pacs_002() {
208        let xml = r#"<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.002.001.14"/>"#;
209        let id = detect_message_type(xml).unwrap();
210        assert_eq!(id.family, "pacs");
211        assert_eq!(id.msg_id, "002");
212        assert_eq!(id.version, "14");
213    }
214}