327 lines
10 KiB
Rust
327 lines
10 KiB
Rust
use std::{collections::BTreeMap, time::Duration};
|
|
|
|
use crank_core::{SoapTarget, SoapVersion};
|
|
use crank_mapping::JsonPath;
|
|
use reqwest::{
|
|
Client,
|
|
header::{HeaderMap, HeaderName, HeaderValue},
|
|
};
|
|
use serde_json::{Value, json};
|
|
|
|
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_headers = resolve_envelope_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),
|
|
target.binding_style,
|
|
&envelope_headers,
|
|
);
|
|
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 resolve_envelope_headers(
|
|
target: &SoapTarget,
|
|
request: &SoapRequest,
|
|
) -> Result<Vec<xml::SoapEnvelopeHeader>, SoapAdapterError> {
|
|
let context = json!({
|
|
"request": {
|
|
"body": request.body.clone(),
|
|
},
|
|
"mcp": request.body.clone(),
|
|
});
|
|
let mut rendered = Vec::new();
|
|
|
|
for header in &target.headers {
|
|
let Some(value) = resolve_header_value(header, &context, &request.body)? else {
|
|
if header.required {
|
|
return Err(SoapAdapterError::MissingRequiredHeader {
|
|
name: header.name.clone(),
|
|
});
|
|
}
|
|
continue;
|
|
};
|
|
|
|
rendered.push(xml::SoapEnvelopeHeader {
|
|
name: header.name.clone(),
|
|
namespace_uri: header.namespace_uri.clone(),
|
|
value,
|
|
});
|
|
}
|
|
|
|
Ok(rendered)
|
|
}
|
|
|
|
fn resolve_header_value(
|
|
header: &crank_core::SoapHeaderConfig,
|
|
context: &Value,
|
|
body: &Value,
|
|
) -> Result<Option<Value>, SoapAdapterError> {
|
|
if let Some(path) = header.value_path.as_deref() {
|
|
let path = JsonPath::parse(path).map_err(|_| SoapAdapterError::InvalidHeaderValuePath {
|
|
path: path.to_owned(),
|
|
})?;
|
|
return Ok(path.read(context).cloned());
|
|
}
|
|
|
|
Ok(match body {
|
|
Value::Object(map) => map.get(&header.name).cloned(),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn renders_soap_headers_and_rpc_literal_children() {
|
|
async fn handler(body: Bytes) -> String {
|
|
let text = String::from_utf8_lossy(&body);
|
|
assert!(text.contains("<soap:Header>"));
|
|
assert!(
|
|
text.contains(
|
|
r#"<h:CorrelationId xmlns:h="urn:headers">corr-123</h:CorrelationId>"#
|
|
)
|
|
);
|
|
assert!(text.contains("<m:CreateLead"));
|
|
assert!(text.contains("<m:email>user@example.com</m:email>"));
|
|
r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<CreateLeadResponse>
|
|
<id>lead_rpc</id>
|
|
</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();
|
|
});
|
|
|
|
let target = SoapTarget {
|
|
binding_style: SoapBindingStyle::RpcLiteral,
|
|
headers: vec![crank_core::SoapHeaderConfig {
|
|
name: "CorrelationId".to_owned(),
|
|
namespace_uri: Some("urn:headers".to_owned()),
|
|
required: true,
|
|
value_path: Some("$.request.body.correlation_id".to_owned()),
|
|
}],
|
|
..test_target(format!("http://{}", address))
|
|
};
|
|
|
|
let adapter = SoapAdapter::new();
|
|
let response = adapter
|
|
.execute(
|
|
&target,
|
|
&SoapRequest {
|
|
headers: BTreeMap::new(),
|
|
body: json!({
|
|
"email": "user@example.com",
|
|
"correlation_id": "corr-123"
|
|
}),
|
|
timeout_ms: 1_000,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.body["id"], "lead_rpc");
|
|
}
|
|
}
|