mx_message/plugin/
common.rs

1//! Common utilities for MX message plugin operations
2//!
3//! This module contains shared functionality used across multiple plugin handlers
4//! to reduce code duplication and improve maintainability.
5
6use dataflow_rs::engine::error::{DataflowError, Result};
7use serde_json::Value;
8
9/// Detect the format of an MX message payload (XML or JSON)
10///
11/// # Arguments
12/// * `payload` - The message payload to analyze
13///
14/// # Returns
15/// * "xml" if the payload appears to be XML
16/// * "json" if the payload appears to be JSON
17/// * "xml" as default if format cannot be determined
18pub fn detect_format(payload: &str) -> String {
19    let trimmed = payload.trim();
20    if trimmed.starts_with('<') {
21        "xml".to_string()
22    } else if trimmed.starts_with('{') || trimmed.starts_with('[') {
23        "json".to_string()
24    } else {
25        "xml".to_string() // Default to XML
26    }
27}
28
29/// Map ISO20022 document element names to message types
30///
31/// # Arguments
32/// * `element_name` - The document element name (e.g., "FIToFICstmrCdtTrf")
33///
34/// # Returns
35/// * Ok(message_type) - The corresponding message type (e.g., "pacs.008")
36/// * Err(error) - If the element name is not recognized
37pub fn map_document_element_to_message_type(element_name: &str) -> Result<String> {
38    let message_type = match element_name {
39        "FIToFICstmrCdtTrf" | "FIToFICustomerCreditTransferV08" => "pacs.008",
40        "FIToFIPmtStsRpt" | "FIToFIPaymentStatusReportV10" => "pacs.002",
41        "FinInstnCdtTrf" | "FinancialInstitutionCreditTransferV08" => "pacs.009",
42        "PmtRtr" | "PaymentReturnV09" => "pacs.004",
43        "FIToFICstmrDrctDbt" | "FIToFICustomerDirectDebitV08" => "pacs.003",
44        "FIDrctDbt" | "FinancialInstitutionDirectDebitV03" => "pacs.010",
45        "BkToCstmrStmt" | "BankToCustomerStatementV08" => "camt.053",
46        "BkToCstmrDbtCdtNtfctn" | "BankToCustomerDebitCreditNotificationV08" => "camt.054",
47        "AcctRptgReq" | "AccountReportingRequestV05" => "camt.060",
48        "BkToCstmrAcctRpt" | "BankToCustomerAccountReportV08" => "camt.052",
49        "RsltnOfInvstgtn" | "ResolutionOfInvestigationV09" => "camt.029",
50        "Rct" | "ReceiptV08" => "camt.025",
51        "FIToFIPmtCxlReq" | "FIToFIPaymentCancellationRequestV08" => "camt.056",
52        "NtfctnToRcv" | "NotificationToReceiveV06" => "camt.057",
53        "CstmrCdtTrfInitn" | "CustomerCreditTransferInitiationV09" => "pain.001",
54        "CstmrDrctDbtInitn" | "CustomerDirectDebitInitiationV08" => "pain.008",
55        "ChqPresntmntNtfctn" | "ChequePresentmentNotificationV01" => "camt.107",
56        "ChqCxlOrStopReq" | "ChequeCancellationOrStopRequestV01" => "camt.108",
57        "ChqCxlOrStopRpt" | "ChequeCancellationOrStopReportV01" => "camt.109",
58        "ClmNonRct" | "ClaimNonReceiptV07" => "camt.027",
59        _ => {
60            return Err(DataflowError::Validation(format!(
61                "Unknown document element: {}",
62                element_name
63            )));
64        }
65    };
66    Ok(message_type.to_string())
67}
68
69/// Extract message type from parsed JSON data
70///
71/// This function attempts to extract the message type from various locations
72/// in the parsed JSON structure:
73/// 1. Direct "message_type" field at root level
74/// 2. From Document structure by examining the first key
75///
76/// # Arguments
77/// * `data` - The parsed JSON data
78///
79/// # Returns
80/// * Ok(message_type) - The extracted message type
81/// * Err(error) - If message type cannot be determined
82pub fn extract_message_type(data: &Value) -> Result<String> {
83    // Try to get message_type from root level
84    if let Some(mt) = data.get("message_type").and_then(Value::as_str) {
85        return Ok(mt.to_string());
86    }
87
88    // Try to extract from Document structure
89    if let Some(doc) = data.get("Document") {
90        // Get the first key under Document (e.g., "FIToFICstmrCdtTrf")
91        if let Some(obj) = doc.as_object()
92            && let Some(first_key) = obj.keys().next()
93        {
94            // Map document element name to message type
95            return map_document_element_to_message_type(first_key);
96        }
97    }
98
99    Err(DataflowError::Validation(
100        "Could not determine message type from parsed data".to_string(),
101    ))
102}
103
104/// Extract message type from XML string
105///
106/// This function performs a lightweight parsing of XML to extract the message type
107/// without fully parsing the entire document.
108///
109/// # Arguments
110/// * `xml_str` - The XML string to analyze
111///
112/// # Returns
113/// * Ok(message_type) - The extracted message type
114/// * Err(error) - If message type cannot be determined
115pub fn extract_message_type_from_xml(xml_str: &str) -> Result<String> {
116    let xml_str = xml_str.trim();
117
118    // Find the Document opening tag
119    if let Some(doc_start) = xml_str.find("<Document") {
120        // Find the end of the opening Document tag
121        if let Some(doc_end) = xml_str[doc_start..].find('>') {
122            let after_doc = &xml_str[doc_start + doc_end + 1..];
123
124            // Find the first element after Document
125            if let Some(elem_start) = after_doc.find('<')
126                && after_doc.as_bytes()[elem_start + 1] != b'/'
127            {
128                let elem_name_start = elem_start + 1;
129                // Find the end of the element name (space or >)
130                let elem_name_end = after_doc[elem_name_start..]
131                    .find([' ', '>', '/'])
132                    .map(|i| elem_name_start + i)
133                    .unwrap_or(after_doc.len());
134
135                let element_name = &after_doc[elem_name_start..elem_name_end];
136                // Remove namespace prefix if present
137                let element_name = if let Some(colon_pos) = element_name.rfind(':') {
138                    &element_name[colon_pos + 1..]
139                } else {
140                    element_name
141                };
142
143                return map_document_element_to_message_type(element_name);
144            }
145        }
146    }
147
148    Err(DataflowError::Validation(
149        "Could not extract message type from XML".to_string(),
150    ))
151}
152
153/// Extract MX content from a message field
154///
155/// This function handles various formats of MX message content:
156/// - Direct string values
157/// - Objects with mx_message field (from generate_mx output)
158/// - Payload field
159///
160/// # Arguments
161/// * `message_data` - The message data object
162/// * `field_name` - The field name to extract from
163/// * `message_payload` - The message payload (if field_name is "payload")
164///
165/// # Returns
166/// * Ok(content) - The extracted MX message content
167/// * Err(error) - If content cannot be extracted
168pub fn extract_mx_content(
169    message_data: &Value,
170    field_name: &str,
171    message_payload: &Value,
172) -> Result<String> {
173    if field_name == "payload" {
174        // Extract string value from the payload JSON
175        if let Some(s) = message_payload.as_str() {
176            Ok(s.to_string())
177        } else {
178            // If it's not a string directly, try to convert
179            Ok(message_payload.to_string().trim_matches('"').to_string())
180        }
181    } else {
182        // Check if the field contains an object with mx_message (from generate_mx output)
183        let field_value = message_data.get(field_name).ok_or_else(|| {
184            DataflowError::Validation(format!(
185                "MX message field '{}' not found in message data",
186                field_name
187            ))
188        })?;
189
190        // If it's an object with mx_message field, extract that
191        if let Some(mx_msg) = field_value.get("mx_message").and_then(Value::as_str) {
192            Ok(mx_msg.to_string())
193        } else if let Some(s) = field_value.as_str() {
194            // If it's a direct string, use it
195            Ok(s.to_string())
196        } else {
197            Err(DataflowError::Validation(format!(
198                "Field '{}' does not contain a valid MX message",
199                field_name
200            )))
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use serde_json::json;
209
210    #[test]
211    fn test_detect_format() {
212        assert_eq!(detect_format("<?xml version=\"1.0\"?>"), "xml");
213        assert_eq!(detect_format("<Document>"), "xml");
214        assert_eq!(detect_format("{\"key\": \"value\"}"), "json");
215        assert_eq!(detect_format("[1, 2, 3]"), "json");
216        assert_eq!(detect_format("unknown"), "xml"); // default
217        assert_eq!(detect_format("  <tag>  "), "xml"); // with whitespace
218    }
219
220    #[test]
221    fn test_map_document_element_to_message_type() {
222        assert_eq!(
223            map_document_element_to_message_type("FIToFICstmrCdtTrf").unwrap(),
224            "pacs.008"
225        );
226        assert_eq!(
227            map_document_element_to_message_type("BkToCstmrStmt").unwrap(),
228            "camt.053"
229        );
230        assert_eq!(
231            map_document_element_to_message_type("CstmrCdtTrfInitn").unwrap(),
232            "pain.001"
233        );
234        assert!(map_document_element_to_message_type("UnknownElement").is_err());
235    }
236
237    #[test]
238    fn test_extract_message_type() {
239        // Test with message_type at root
240        let data = json!({"message_type": "pacs.008", "other": "data"});
241        assert_eq!(extract_message_type(&data).unwrap(), "pacs.008");
242
243        // Test with Document structure
244        let data = json!({
245            "Document": {
246                "FIToFICstmrCdtTrf": {
247                    "GrpHdr": {}
248                }
249            }
250        });
251        assert_eq!(extract_message_type(&data).unwrap(), "pacs.008");
252
253        // Test with no message type
254        let data = json!({"other": "data"});
255        assert!(extract_message_type(&data).is_err());
256    }
257
258    #[test]
259    fn test_extract_mx_content() {
260        let payload = json!("test content");
261        let data = json!({
262            "field1": "direct string",
263            "field2": {
264                "mx_message": "nested message"
265            }
266        });
267
268        // Test payload extraction
269        assert_eq!(
270            extract_mx_content(&data, "payload", &payload).unwrap(),
271            "test content"
272        );
273
274        // Test direct string field
275        assert_eq!(
276            extract_mx_content(&data, "field1", &payload).unwrap(),
277            "direct string"
278        );
279
280        // Test nested mx_message field
281        assert_eq!(
282            extract_mx_content(&data, "field2", &payload).unwrap(),
283            "nested message"
284        );
285
286        // Test missing field
287        assert!(extract_mx_content(&data, "missing", &payload).is_err());
288    }
289}