Files
crank/crates/crank-adapter-soap/src/xml.rs
T
2026-04-11 01:37:24 +03:00

320 lines
9.7 KiB
Rust

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<String>,
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#"<soap:Envelope xmlns:soap="{envelope_namespace}">"#
));
if !headers.is_empty() {
xml.push_str("<soap:Header>");
for header in headers {
append_header(&mut xml, header);
}
xml.push_str("</soap:Header>");
}
xml.push_str("<soap:Body>");
match namespace {
Some(namespace) => xml.push_str(&format!(r#"<m:{operation_name}" xmlns:m="{namespace}">"#)),
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!("</m:{operation_name}>")),
None => xml.push_str(&format!("</{operation_name}>")),
}
xml.push_str("</soap:Body></soap:Envelope>");
xml
}
pub fn decode_envelope(payload: &str) -> Result<Value, SoapAdapterError> {
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#"<h:{} xmlns:h="{}">"#,
header.name, namespace_uri
));
append_header_value(xml, &header.value);
xml.push_str(&format!("</h:{}>", header.name));
}
None => {
xml.push('<');
xml.push_str(&header.name);
xml.push('>');
append_header_value(xml, &header.value);
xml.push_str("</");
xml.push_str(&header.name);
xml.push('>');
}
}
}
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<String> {
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("</");
if let Some(prefix) = namespace_prefix {
xml.push_str(prefix);
xml.push(':');
}
xml.push_str(name);
xml.push('>');
}
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn node_to_json(node: Node<'_, '_>, _unwrap_root: bool) -> Value {
let children = node
.children()
.filter(|child| child.is_element())
.collect::<Vec<_>>();
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("<soap:Envelope"));
assert!(xml.contains("<m:CreateLead"));
assert!(xml.contains(r#"xmlns:m="urn:crm""#));
assert!(xml.contains("<email>user@example.com</email>"));
}
#[test]
fn decodes_wrapped_response_body() {
let value = decode_envelope(
r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<CreateLeadResponse>
<id>lead_123</id>
<status>created</status>
</CreateLeadResponse>
</soap:Body>
</soap:Envelope>"#,
)
.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("<soap:Header>"));
assert!(
xml.contains(r#"<h:CorrelationId xmlns:h="urn:headers">corr-123</h:CorrelationId>"#)
);
assert!(xml.contains("<m:email>user@example.com</m:email>"));
}
}