rsoap 0.3.0

A SOAP client library for Rust with compile-time code generation from WSDL files
Documentation
//! HTTP transport for SOAP clients.
//!
//! Exposes [`SoapClient`] — a wrapper around reqwest that handles
//! SOAP envelope construction, fault detection, and XML deserialization
//! at runtime.

use crate::envelope::{self, SoapVersion};
#[cfg(feature = "wss")]
use crate::error::CertError;
use crate::error::SoapError;
use reqwest::Client as HttpClient;
use serde::{de::DeserializeOwned, Serialize};
use std::collections::HashMap;

/// A soap operation that has been generated by the `#[derive(SoapOperation)]` macro.
///
/// Implementing this trait allows runtime code to:
/// - Build SOAP envelopes from typed request structs
/// - Deserialze SOAP response XML into typed response structs
/// - Know the operation's action, endpoint, body element name, and SOAP version
pub trait SoapOperation {
    /// The request input type for this operation.
    type Request;

    /// The response output type for this operation.
    type Response;

    /// The WSDL action URI for this operation.
    const ACTION: &'static str;

    /// Default endpoint URL from the WSDL.
    const ENDPOINT: &'static str;

    /// The XML element name to use as the root of the body content.
    const BODY_ELEMENT: &'static str;

    /// The SOAP protocol version this operation speaks.
    /// Defaults to [`SoapVersion::V11`] for backward compatibility.
    /// The `#[derive(SoapOperation)]` macro auto-detects this from the WSDL
    /// (presence of `http://schemas.xmlsoap.org/wsdl/soap12/` bindings).
    const VERSION: SoapVersion = SoapVersion::V11;

    /// Serialize a request struct into (soap_action, body_xml).
    fn build_request_body(
        &self,
        request: &Self::Request,
    ) -> Result<(String, String), quick_xml::se::SeError>
    where
        Self::Request: Serialize,
    {
        let action = Self::ACTION.to_string();
        let xml = quick_xml::se::to_string_with_root(Self::BODY_ELEMENT, request)?;
        Ok((action, xml))
    }

    /// Deserialize a SOAP response string into the typed response.
    /// Checks for SOAP faults before attempting deserialization.
    fn parse_response(&self, response_xml: &str) -> Result<Self::Response, SoapError>
    where
        Self::Response: DeserializeOwned,
    {
        // Check for soap fault before deserializing
        if envelope::is_soap_fault(response_xml) {
            let (code, message) = envelope::parse_soap_fault(response_xml)
                .map_err(|e| SoapError::DeserializeResponse(Box::new(e)))?;
            return Err(SoapError::SoapFault { code, message });
        }

        envelope::deserialize_response::<Self::Response>(response_xml)
            .map_err(|e| SoapError::DeserializeResponse(Box::new(e)))
    }
}

/// A configured SOAP client ready to make requests.
///
/// Create with [`SoapClient::new`] and customize headers/builders before use.
#[derive(Clone)]
#[must_use]
pub struct SoapClient {
    http: HttpClient,
    endpoint: String,
    default_headers: HashMap<String, String>,
}

impl SoapClient {
    /// Create a new soap client pointing at the given endpoint URL.
    ///
    /// # Errors
    /// Returns [`SoapError::HttpStatus`] if the URL is not a valid HTTP/HTTPS URL.
    pub fn new(endpoint: impl Into<String>) -> Result<Self, SoapError> {
        let endpoint = endpoint.into();

        // Parse to validate the URL — handles edge cases like uppercase schemes,
        // ports, query strings, and fragments that a `starts_with` check would reject.
        reqwest::Url::parse(&endpoint)
            .map_err(|_| SoapError::http_status(reqwest::StatusCode::BAD_REQUEST))?;

        Ok(Self {
            http: HttpClient::new(),
            endpoint,
            default_headers: HashMap::new(),
        })
    }

    /// Add a default header that will be included in every request.
    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.default_headers.insert(key.into(), value.into());
        self
    }

    /// Set custom headers from a map.
    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
        self.default_headers.extend(headers);
        self
    }

    /// Configure a client certificate (mTLS / two-way SSL) for WS-Security
    /// transport authentication.
    ///
    /// Loads a PEM-encoded client certificate + private key bundle from
    /// `path` and rebuilds the underlying HTTP client to present that
    /// certificate on every request.  The PEM file must contain both the
    /// certificate chain and the private key (in that order, or interleaved).
    /// Protect the file with filesystem permissions — there is no password
    /// support in this entry point.  For PKCS#12 bundles, use reqwest's
    /// `default-tls` feature and call `reqwest::Identity::from_pkcs12_der`
    /// yourself, then attach via `with_identity`.
    ///
    /// This implements the transport-binding portion of WS-Security as
    /// described by OASIS WSS 1.1 — "Services will be available only over
    /// two-way SSL over HTTP. The requestor opens a connection to the Web
    /// service using a secure transport, i.e., SSL. In this scenario, the
    /// message confidentiality and integrity are handled using the existing
    /// transport security mechanisms."
    ///
    /// Other WSS profiles (UsernameToken, X.509 signing, encryption) are not
    /// yet supported — open an issue if you need them.
    ///
    /// # Errors
    /// Returns [`SoapError::CertLoad`] with a [`CertError`] source if the
    /// file cannot be read or the bundle is not a valid PEM-encoded
    /// certificate + key.
    #[cfg(feature = "wss")]
    pub fn with_client_cert(
        mut self,
        path: impl AsRef<std::path::Path>,
    ) -> Result<Self, SoapError> {
        let path = path.as_ref();
        let bytes = std::fs::read(path).map_err(|source| {
            SoapError::CertLoad(CertError::ReadCertFile {
                path: path.to_path_buf(),
                source,
            })
        })?;
        let identity = reqwest::Identity::from_pem(&bytes)
            .map_err(|source| SoapError::CertLoad(CertError::ParsePem(source)))?;
        self.http = reqwest::Client::builder()
            .identity(identity)
            .build()
            .map_err(|source| SoapError::CertLoad(CertError::BuildClient(source)))?;
        Ok(self)
    }

    /// Attach a pre-built `reqwest::Identity` for mTLS / two-way SSL.
    /// Use this if you need PKCS#12 (requires reqwest's `default-tls` feature)
    /// or want to load the identity from a non-file source.
    ///
    /// # Errors
    /// Returns [`SoapError::CertLoad`] wrapping [`CertError::BuildClient`]
    /// if the underlying HTTP client cannot be rebuilt.
    #[cfg(feature = "wss")]
    pub fn with_identity(mut self, identity: reqwest::Identity) -> Result<Self, SoapError> {
        self.http = reqwest::Client::builder()
            .identity(identity)
            .build()
            .map_err(|source| SoapError::CertLoad(CertError::BuildClient(source)))?;
        Ok(self)
    }

    /// Send a soap request using a typed operation and return the deserialized response.
    ///
    /// Automatically detects soap faults and converts them to [`SoapError::SoapFault`].
    /// Sets the `Content-Type` header and the action parameter based on the
    /// operation's [`SoapOperation::VERSION`].
    ///
    /// # Errors
    /// Returns various [`SoapError`] variants on network, parsing, or soap fault failures.
    pub async fn call<O>(
        &self,
        operation: &O,
        request: &O::Request,
    ) -> Result<O::Response, SoapError>
    where
        O: SoapOperation + Send,
        O::Request: Serialize + Sync,
        O::Response: DeserializeOwned,
    {
        let (action, body_xml) = operation
            .build_request_body(request)
            .map_err(SoapError::serialize_request)?;
        let xml_body = envelope::build_envelope(O::VERSION, &action, &body_xml);

        let content_type = match O::VERSION {
            SoapVersion::V11 => "text/xml; charset=utf-8".to_string(),
            SoapVersion::V12 => {
                format!("application/soap+xml; charset=utf-8; action=\"{action}\"")
            }
        };

        let mut request_builder = self
            .http
            .post(&self.endpoint)
            .header("Content-Type", content_type);

        // SOAP 1.1 uses a SOAPAction HTTP header in addition to Content-Type.
        // SOAP 1.2 carries the action in the Content-Type parameter (set above).
        if O::VERSION == SoapVersion::V11 {
            request_builder = request_builder.header("SOAPAction", format!("\"{action}\""));
        }

        // Apply default headers.
        for (key, value) in &self.default_headers {
            request_builder = request_builder.header(key.as_str(), value.as_str());
        }

        let response = request_builder.body(xml_body).send().await?;
        let status = response.status();
        let text = response.text().await?;

        // SOAP faults can be returned with any HTTP status (commonly 500 for server
        // faults). Detect faults in the body first, regardless of HTTP status.
        if envelope::is_soap_fault(&text) {
            if let Ok((code, message)) = envelope::parse_soap_fault(&text) {
                return Err(SoapError::SoapFault { code, message });
            }
        }

        if !status.is_success() {
            return Err(SoapError::http_status(status));
        }

        operation.parse_response(&text)
    }

    /// Return the endpoint URL this client is configured against.
    pub fn endpoint(&self) -> &str {
        &self.endpoint
    }
}

impl std::fmt::Debug for SoapClient {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let mut ds = f.debug_struct("SoapClient");
        ds.field("endpoint", &self.endpoint);

        if self.default_headers.is_empty() {
            ds.field("default_headers", &"<empty>");
        } else {
            let pairs: Vec<_> = self
                .default_headers
                .iter()
                .map(|(k, v)| (k.as_str(), v.as_str()))
                .collect();
            ds.field("default_headers", &pairs);
        }

        ds.finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn creates_client_with_valid_url() {
        let client = SoapClient::new("https://example.com/soap").unwrap();
        assert_eq!(client.endpoint(), "https://example.com/soap");
    }

    #[test]
    fn rejects_invalid_url() {
        SoapClient::new("not-a-url").unwrap_err();
    }

    #[test]
    fn builds_envelope_for_operation() {
        let xml = envelope::build_envelope(
            SoapVersion::V11,
            "GetTemperature",
            "<req:GetTemperature><lat>40</lat></req:GetTemperature>",
        );
        assert!(xml.contains("<soap:Envelope"));
        assert!(xml.contains("<Action"));
        assert!(xml.contains(">GetTemperature</Action>"));
        assert!(xml.contains("<soap:Body"));
        assert!(xml.contains("<req:GetTemperature>"));
    }

    #[test]
    fn client_is_debuggable() {
        let client = SoapClient::new("https://example.com")
            .unwrap()
            .with_header("X-Custom", "header");
        let debug_str = format!("{client:?}");
        assert!(debug_str.contains("SoapClient"));
    }

    #[test]
    fn is_soap_fault_detects_fault() {
        assert!(envelope::is_soap_fault("<soap:Fault>code</soap:Fault>"));
        assert!(envelope::is_soap_fault("<env:Fault>code</env:Fault>"));
        assert!(envelope::is_soap_fault("<Fault xmlns=\"...\">msg</Fault>"));
        assert!(!envelope::is_soap_fault(
            "<GetTempResponse><temp>72</temp></GetTempResponse>"
        ));
    }

    #[test]
    fn parse_soap_error() {
        let xml = r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
            <soap:Body>
                <soap:Fault xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
                    <faultcode>Server</faultcode>
                    <faultstring>Invalid credentials</faultstring>
                </soap:Fault>
            </soap:Body>
        </soap:Envelope>"#;

        let (code, message) = envelope::parse_soap_fault(xml).unwrap();
        assert_eq!(code, "Server");
        assert_eq!(message, "Invalid credentials");
    }

    #[test]
    fn extract_body_from_soap_envelope() {
        let xml = r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
      <soap:Body>
        <GetWeatherResponse>
          <temperature>72</temperature>
        </GetWeatherResponse>
      </soap:Body>
    </soap:Envelope>"#;

        let body = envelope::extract_body(xml).unwrap();
        assert!(body.contains("GetWeatherResponse"));
        assert!(body.contains("72"));
    }

    #[cfg(feature = "wss")]
    #[test]
    fn with_client_cert_errors_on_missing_file() {
        let result = SoapClient::new("https://example.com/soap")
            .unwrap()
            .with_client_cert("/nonexistent/path/to/cert.pem");
        match result {
            Err(SoapError::CertLoad(CertError::ReadCertFile { path, source })) => {
                assert_eq!(path, std::path::Path::new("/nonexistent/path/to/cert.pem"));
                assert!(matches!(source.kind(), std::io::ErrorKind::NotFound));
            }
            other => panic!("expected CertLoad(ReadCertFile), got {:?}", other),
        }
    }

    #[cfg(feature = "wss")]
    #[test]
    fn with_client_cert_errors_on_invalid_pem() {
        let dir = std::env::temp_dir().join("rsoap_wss_test");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("not_a_cert.pem");
        std::fs::write(&path, b"this is not a PEM file").unwrap();

        let result = SoapClient::new("https://example.com/soap")
            .unwrap()
            .with_client_cert(&path);
        match result {
            Err(SoapError::CertLoad(CertError::ParsePem(_))) => {}
            other => panic!("expected CertLoad(ParsePem), got {:?}", other),
        }

        let _ = std::fs::remove_file(&path);
    }
}