use crank_core::SoapBindingStyle; use roxmltree::{Document, Node}; use serde_json::{Map, Value}; use crate::SoapAdapterError; #[derive(Clone, Debug, PartialEq)] pub struct SoapEnvelopeHeader { pub name: String, pub namespace_uri: Option, pub value: Value, } pub fn build_envelope( operation_name: &str, namespace: Option<&str>, body: &Value, envelope_namespace: &str, binding_style: SoapBindingStyle, headers: &[SoapEnvelopeHeader], ) -> String { let mut xml = String::new(); xml.push_str(&format!( r#""# )); if !headers.is_empty() { xml.push_str(""); for header in headers { append_header(&mut xml, header); } xml.push_str(""); } xml.push_str(""); match namespace { Some(namespace) => xml.push_str(&format!(r#""#)), None => xml.push_str(&format!("<{operation_name}>")), } let child_prefix = match binding_style { SoapBindingStyle::DocumentLiteral => None, SoapBindingStyle::RpcLiteral => namespace.map(|_| "m"), }; append_value_children(&mut xml, body, child_prefix); match namespace { Some(_) => xml.push_str(&format!("")), None => xml.push_str(&format!("")), } xml.push_str(""); xml } pub fn decode_envelope(payload: &str) -> Result { let document = Document::parse(payload)?; let body = document .descendants() .find(|node| node.is_element() && node.tag_name().name() == "Body") .ok_or(SoapAdapterError::MissingBody)?; let body_child = body .children() .find(|node| node.is_element()) .ok_or(SoapAdapterError::EmptyBody)?; if body_child.tag_name().name() == "Fault" { return Err(parse_fault(body_child)); } Ok(node_to_json(body_child, true)) } fn parse_fault(fault: Node<'_, '_>) -> SoapAdapterError { let code = find_nested_text(fault, &["Code", "Value"]) .or_else(|| find_nested_text(fault, &["faultcode"])); let message = find_nested_text(fault, &["Reason", "Text"]) .or_else(|| find_nested_text(fault, &["faultstring"])) .unwrap_or_else(|| "SOAP fault".to_owned()); let detail = fault .children() .find(|node| node.is_element() && node.tag_name().name() == "Detail") .map(|node| node_to_json(node, false)); SoapAdapterError::SoapFault { code, message, detail, } } fn append_header(xml: &mut String, header: &SoapEnvelopeHeader) { match header.namespace_uri.as_deref() { Some(namespace_uri) => { xml.push_str(&format!( r#""#, header.name, namespace_uri )); append_header_value(xml, &header.value); xml.push_str(&format!("", header.name)); } None => { xml.push('<'); xml.push_str(&header.name); xml.push('>'); append_header_value(xml, &header.value); xml.push_str("'); } } } fn append_header_value(xml: &mut String, value: &Value) { match value { Value::Null => {} Value::Bool(value) => xml.push_str(if *value { "true" } else { "false" }), Value::Number(value) => xml.push_str(&value.to_string()), Value::String(value) => xml.push_str(&escape_xml(value)), Value::Array(_) | Value::Object(_) => append_value_children(xml, value, None), } } fn find_nested_text(node: Node<'_, '_>, path: &[&str]) -> Option { let mut current = node; for segment in path { current = current .children() .find(|child| child.is_element() && child.tag_name().name() == *segment)?; } current .text() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn append_value_children(xml: &mut String, value: &Value, namespace_prefix: Option<&str>) { match value { Value::Object(object) => { for (key, value) in object { append_named_value(xml, key, value, namespace_prefix); } } other => append_named_value(xml, "value", other, namespace_prefix), } } fn append_named_value(xml: &mut String, name: &str, value: &Value, namespace_prefix: Option<&str>) { match value { Value::Array(items) => { for item in items { append_named_value(xml, name, item, namespace_prefix); } } Value::Object(object) => { push_start_tag(xml, name, namespace_prefix); for (child_name, child_value) in object { append_named_value(xml, child_name, child_value, namespace_prefix); } push_end_tag(xml, name, namespace_prefix); } Value::Null => { push_empty_tag(xml, name, namespace_prefix); } Value::Bool(value) => { push_start_tag(xml, name, namespace_prefix); xml.push_str(if *value { "true" } else { "false" }); push_end_tag(xml, name, namespace_prefix); } Value::Number(value) => { push_start_tag(xml, name, namespace_prefix); xml.push_str(&value.to_string()); push_end_tag(xml, name, namespace_prefix); } Value::String(value) => { push_start_tag(xml, name, namespace_prefix); xml.push_str(&escape_xml(value)); push_end_tag(xml, name, namespace_prefix); } } } fn push_start_tag(xml: &mut String, name: &str, namespace_prefix: Option<&str>) { xml.push('<'); if let Some(prefix) = namespace_prefix { xml.push_str(prefix); xml.push(':'); } xml.push_str(name); xml.push('>'); } fn push_end_tag(xml: &mut String, name: &str, namespace_prefix: Option<&str>) { xml.push_str("'); } fn push_empty_tag(xml: &mut String, name: &str, namespace_prefix: Option<&str>) { xml.push('<'); if let Some(prefix) = namespace_prefix { xml.push_str(prefix); xml.push(':'); } xml.push_str(name); xml.push_str("/>"); } fn escape_xml(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn node_to_json(node: Node<'_, '_>, _unwrap_root: bool) -> Value { let children = node .children() .filter(|child| child.is_element()) .collect::>(); if children.is_empty() { return node .text() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| Value::String(value.to_owned())) .unwrap_or(Value::Null); } let mut object = Map::new(); for child in children { let key = child.tag_name().name().to_owned(); let value = node_to_json(child, false); match object.get_mut(&key) { Some(existing) => match existing { Value::Array(array) => array.push(value), other => { let previous = other.take(); *other = Value::Array(vec![previous, value]); } }, None => { object.insert(key, value); } } } Value::Object(object) } #[cfg(test)] mod tests { use serde_json::{Value, json}; use super::{SoapEnvelopeHeader, build_envelope, decode_envelope}; use crank_core::SoapBindingStyle; #[test] fn builds_document_literal_envelope() { let xml = build_envelope( "CreateLead", Some("urn:crm"), &json!({"email":"user@example.com","source":"mcp"}), "http://schemas.xmlsoap.org/soap/envelope/", SoapBindingStyle::DocumentLiteral, &[], ); assert!(xml.contains("user@example.com")); } #[test] fn decodes_wrapped_response_body() { let value = decode_envelope( r#" lead_123 created "#, ) .unwrap(); assert_eq!(value["id"], "lead_123"); assert_eq!(value["status"], "created"); } #[test] fn builds_rpc_literal_envelope_with_headers() { let xml = build_envelope( "CreateLead", Some("urn:crm"), &json!({"email":"user@example.com"}), "http://schemas.xmlsoap.org/soap/envelope/", SoapBindingStyle::RpcLiteral, &[SoapEnvelopeHeader { name: "CorrelationId".to_owned(), namespace_uri: Some("urn:headers".to_owned()), value: Value::String("corr-123".to_owned()), }], ); assert!(xml.contains("")); assert!( xml.contains(r#"corr-123"#) ); assert!(xml.contains("user@example.com")); } }