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 { 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, 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, 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 { 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 { 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("user@example.com")); r#" lead_123 created "# .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("")); assert!( text.contains( r#"corr-123"# ) ); assert!(text.contains("user@example.com")); r#" lead_rpc "# .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"); } }