feat: add soap adapter foundation

This commit is contained in:
a.tolmachev
2026-04-07 00:00:19 +03:00
parent 31fbdfdc02
commit a6388e4353
24 changed files with 1346 additions and 25 deletions
+212
View File
@@ -0,0 +1,212 @@
use std::{collections::BTreeMap, time::Duration};
use crank_core::{SoapTarget, SoapVersion};
use reqwest::{
Client,
header::{HeaderMap, HeaderName, HeaderValue},
};
use crate::{SoapAdapterError, SoapRequest, SoapResponse, xml};
#[derive(Clone, Debug)]
pub struct SoapAdapter {
client: Client,
}
impl Default for SoapAdapter {
fn default() -> Self {
Self::new()
}
}
impl SoapAdapter {
pub fn new() -> Self {
Self {
client: Client::new(),
}
}
pub async fn execute(
&self,
target: &SoapTarget,
request: &SoapRequest,
) -> Result<SoapResponse, SoapAdapterError> {
let endpoint = target
.endpoint_override
.as_deref()
.ok_or(SoapAdapterError::MissingEndpoint)?;
let headers = build_headers(target, request)?;
let envelope = xml::build_envelope(
&target.operation_name,
target.metadata.namespaces.first().map(String::as_str),
&request.body,
envelope_namespace(target.soap_version),
);
let response = self
.client
.post(endpoint)
.headers(headers)
.body(envelope)
.timeout(Duration::from_millis(request.timeout_ms))
.send()
.await?;
let status = response.status();
let headers = normalize_headers(response.headers());
let body_text = response.text().await?;
let body = xml::decode_envelope(&body_text)?;
if !status.is_success() {
return Err(SoapAdapterError::UnexpectedStatus {
status: status.as_u16(),
body,
});
}
Ok(SoapResponse {
status_code: status.as_u16(),
headers,
body,
})
}
}
fn build_headers(
target: &SoapTarget,
request: &SoapRequest,
) -> Result<HeaderMap, SoapAdapterError> {
let mut headers = HeaderMap::new();
let content_type = match target.soap_version {
SoapVersion::Soap11 => "text/xml; charset=utf-8".to_owned(),
SoapVersion::Soap12 => match target.soap_action.as_deref() {
Some(action) => format!(r#"application/soap+xml; charset=utf-8; action="{action}""#),
None => "application/soap+xml; charset=utf-8".to_owned(),
},
};
headers.insert(
reqwest::header::CONTENT_TYPE,
HeaderValue::from_str(&content_type).map_err(|_| SoapAdapterError::MissingEndpoint)?,
);
headers.insert(
reqwest::header::ACCEPT,
HeaderValue::from_static("application/soap+xml, text/xml, application/xml"),
);
if matches!(target.soap_version, SoapVersion::Soap11) {
if let Some(action) = target.soap_action.as_deref() {
headers.insert(
HeaderName::from_static("soapaction"),
HeaderValue::from_str(action).map_err(|_| SoapAdapterError::MissingEndpoint)?,
);
}
}
for (name, value) in &request.headers {
let header_name =
HeaderName::try_from(name.as_str()).map_err(|_| SoapAdapterError::MissingEndpoint)?;
let header_value =
HeaderValue::try_from(value).map_err(|_| SoapAdapterError::MissingEndpoint)?;
headers.insert(header_name, header_value);
}
Ok(headers)
}
fn envelope_namespace(version: SoapVersion) -> &'static str {
match version {
SoapVersion::Soap11 => "http://schemas.xmlsoap.org/soap/envelope/",
SoapVersion::Soap12 => "http://www.w3.org/2003/05/soap-envelope",
}
}
fn normalize_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
headers
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|value| (name.as_str().to_owned(), value.to_owned()))
})
.collect()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use axum::{Router, body::Bytes, routing::post};
use crank_core::{SoapBindingStyle, SoapOperationMetadata, SoapTarget, SoapVersion, Target};
use serde_json::json;
use tokio::net::TcpListener;
use crate::{SoapAdapter, SoapRequest};
async fn spawn_server() -> String {
async fn handler(body: Bytes) -> String {
let text = String::from_utf8_lossy(&body);
assert!(text.contains("<email>user@example.com</email>"));
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>"#
.to_owned()
}
let app = Router::new().route("/", post(handler));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{}", address)
}
fn test_target(endpoint: String) -> SoapTarget {
match Target::Soap(SoapTarget {
wsdl_ref: "sample_wsdl".into(),
service_name: "LeadService".to_owned(),
port_name: "LeadPort".to_owned(),
operation_name: "CreateLead".to_owned(),
endpoint_override: Some(endpoint),
soap_version: SoapVersion::Soap11,
soap_action: Some("urn:createLead".to_owned()),
binding_style: SoapBindingStyle::DocumentLiteral,
headers: Vec::new(),
fault_contract: None,
metadata: SoapOperationMetadata {
input_part_names: vec!["CreateLeadRequest".to_owned()],
output_part_names: vec!["CreateLeadResponse".to_owned()],
namespaces: vec!["urn:crm".to_owned()],
},
}) {
Target::Soap(target) => target,
_ => unreachable!(),
}
}
#[tokio::test]
async fn executes_soap_request_response() {
let endpoint = spawn_server().await;
let adapter = SoapAdapter::new();
let response = adapter
.execute(
&test_target(endpoint),
&SoapRequest {
headers: BTreeMap::new(),
body: json!({ "email": "user@example.com" }),
timeout_ms: 1_000,
},
)
.await
.unwrap();
assert_eq!(response.status_code, 200);
assert_eq!(response.body["id"], "lead_123");
}
}
+24
View File
@@ -0,0 +1,24 @@
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SoapAdapterError {
#[error("soap endpoint is missing")]
MissingEndpoint,
#[error("request failed")]
Transport(#[from] reqwest::Error),
#[error("invalid xml payload")]
InvalidXml(#[from] roxmltree::Error),
#[error("soap envelope body was not found")]
MissingBody,
#[error("soap response body was empty")]
EmptyBody,
#[error("soap endpoint returned status {status}")]
UnexpectedStatus { status: u16, body: Value },
#[error("soap fault: {message}")]
SoapFault {
code: Option<String>,
message: String,
detail: Option<Value>,
},
}
+12
View File
@@ -0,0 +1,12 @@
mod client;
mod error;
mod model;
mod wsdl;
mod xml;
pub use client::SoapAdapter;
pub use error::SoapAdapterError;
pub use model::{
SoapOperationSummary, SoapPortSummary, SoapRequest, SoapResponse, SoapServiceSummary,
};
pub use wsdl::inspect_wsdl;
+40
View File
@@ -0,0 +1,40 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SoapRequest {
pub headers: BTreeMap<String, String>,
pub body: Value,
pub timeout_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SoapResponse {
pub status_code: u16,
pub headers: BTreeMap<String, String>,
pub body: Value,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SoapOperationSummary {
pub operation_name: String,
pub soap_action: Option<String>,
pub binding_style: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SoapPortSummary {
pub port_name: String,
pub binding_name: String,
pub endpoint: Option<String>,
pub soap_version: String,
pub operations: Vec<SoapOperationSummary>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SoapServiceSummary {
pub service_name: String,
pub ports: Vec<SoapPortSummary>,
}
+165
View File
@@ -0,0 +1,165 @@
use std::collections::BTreeMap;
use roxmltree::Document;
use crate::{SoapAdapterError, SoapOperationSummary, SoapPortSummary, SoapServiceSummary};
#[derive(Clone, Debug)]
struct BindingSummary {
soap_version: String,
operations: Vec<SoapOperationSummary>,
}
pub fn inspect_wsdl(payload: &[u8]) -> Result<Vec<SoapServiceSummary>, SoapAdapterError> {
let text = std::str::from_utf8(payload).map_err(|_| SoapAdapterError::EmptyBody)?;
let document = Document::parse(text)?;
let bindings = collect_bindings(&document);
let services = document
.descendants()
.filter(|node| node.is_element() && node.tag_name().name() == "service")
.map(|service| {
let service_name = service.attribute("name").unwrap_or_default().to_owned();
let ports = service
.children()
.filter(|node| node.is_element() && node.tag_name().name() == "port")
.map(|port| {
let port_name = port.attribute("name").unwrap_or_default().to_owned();
let binding_name = local_name(port.attribute("binding").unwrap_or_default());
let endpoint = port
.descendants()
.find(|node| node.is_element() && node.tag_name().name() == "address")
.and_then(|node| node.attribute("location"))
.map(ToOwned::to_owned);
let binding = bindings
.get(&binding_name)
.cloned()
.unwrap_or(BindingSummary {
soap_version: "soap_11".to_owned(),
operations: Vec::new(),
});
SoapPortSummary {
port_name,
binding_name,
endpoint,
soap_version: binding.soap_version,
operations: binding.operations,
}
})
.collect();
SoapServiceSummary {
service_name,
ports,
}
})
.collect();
Ok(services)
}
fn collect_bindings(document: &Document<'_>) -> BTreeMap<String, BindingSummary> {
document
.descendants()
.filter(|node| node.is_element() && node.tag_name().name() == "binding")
.map(|binding| {
let name = binding.attribute("name").unwrap_or_default().to_owned();
let soap_binding = binding
.children()
.find(|node| node.is_element() && node.tag_name().name() == "binding");
let soap_namespace = soap_binding
.and_then(|node| node.tag_name().namespace())
.unwrap_or("http://schemas.xmlsoap.org/wsdl/soap/");
let soap_version = if soap_namespace.contains("soap12") {
"soap_12"
} else {
"soap_11"
}
.to_owned();
let binding_style = soap_binding
.and_then(|node| node.attribute("style"))
.map(|value| match value {
"rpc" => "rpc_literal",
_ => "document_literal",
})
.unwrap_or("document_literal")
.to_owned();
let operations = binding
.children()
.filter(|node| node.is_element() && node.tag_name().name() == "operation")
.map(|operation| SoapOperationSummary {
operation_name: operation.attribute("name").unwrap_or_default().to_owned(),
soap_action: operation
.children()
.find(|node| node.is_element() && node.tag_name().name() == "operation")
.and_then(|node| node.attribute("soapAction"))
.map(ToOwned::to_owned),
binding_style: operation
.children()
.find(|node| node.is_element() && node.tag_name().name() == "operation")
.and_then(|node| node.attribute("style"))
.map(|value| match value {
"rpc" => "rpc_literal",
_ => "document_literal",
})
.unwrap_or(binding_style.as_str())
.to_owned(),
})
.collect();
(
name,
BindingSummary {
soap_version,
operations,
},
)
})
.collect()
}
fn local_name(value: &str) -> String {
value.rsplit(':').next().unwrap_or(value).to_owned()
}
#[cfg(test)]
mod tests {
use super::inspect_wsdl;
#[test]
fn parses_services_ports_and_operations_from_wsdl() {
let services = inspect_wsdl(
br#"<?xml version="1.0"?>
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/"
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
xmlns:tns="urn:crm"
targetNamespace="urn:crm">
<binding name="LeadBinding" type="tns:LeadPortType">
<soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
<operation name="CreateLead">
<soap:operation soapAction="urn:createLead"/>
</operation>
</binding>
<service name="LeadService">
<port name="LeadPort" binding="tns:LeadBinding">
<soap:address location="https://soap.example.com/lead"/>
</port>
</service>
</definitions>"#,
)
.unwrap();
assert_eq!(services[0].service_name, "LeadService");
assert_eq!(services[0].ports[0].port_name, "LeadPort");
assert_eq!(services[0].ports[0].soap_version, "soap_11");
assert_eq!(
services[0].ports[0].operations[0].operation_name,
"CreateLead"
);
assert_eq!(
services[0].ports[0].operations[0].soap_action.as_deref(),
Some("urn:createLead")
);
}
}
+229
View File
@@ -0,0 +1,229 @@
use roxmltree::{Document, Node};
use serde_json::{Map, Value};
use crate::SoapAdapterError;
pub fn build_envelope(
operation_name: &str,
namespace: Option<&str>,
body: &Value,
envelope_namespace: &str,
) -> String {
let mut xml = String::new();
xml.push_str(&format!(
r#"<soap:Envelope xmlns:soap="{envelope_namespace}">"#
));
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}>")),
}
append_value_children(&mut xml, body);
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 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) {
match value {
Value::Object(object) => {
for (key, value) in object {
append_named_value(xml, key, value);
}
}
other => append_named_value(xml, "value", other),
}
}
fn append_named_value(xml: &mut String, name: &str, value: &Value) {
match value {
Value::Array(items) => {
for item in items {
append_named_value(xml, name, item);
}
}
Value::Object(object) => {
xml.push('<');
xml.push_str(name);
xml.push('>');
for (child_name, child_value) in object {
append_named_value(xml, child_name, child_value);
}
xml.push_str("</");
xml.push_str(name);
xml.push('>');
}
Value::Null => {
xml.push('<');
xml.push_str(name);
xml.push_str("/>");
}
Value::Bool(value) => {
xml.push('<');
xml.push_str(name);
xml.push('>');
xml.push_str(if *value { "true" } else { "false" });
xml.push_str("</");
xml.push_str(name);
xml.push('>');
}
Value::Number(value) => {
xml.push('<');
xml.push_str(name);
xml.push('>');
xml.push_str(&value.to_string());
xml.push_str("</");
xml.push_str(name);
xml.push('>');
}
Value::String(value) => {
xml.push('<');
xml.push_str(name);
xml.push('>');
xml.push_str(&escape_xml(value));
xml.push_str("</");
xml.push_str(name);
xml.push('>');
}
}
}
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::json;
use super::{build_envelope, decode_envelope};
#[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/",
);
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");
}
}