mod error;
pub use error::SoapError;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use xmltree::Element;
#[derive(Debug, Clone)]
pub struct SubscriptionResponse {
pub sid: String,
pub timeout_seconds: u32,
}
#[derive(Debug, Clone)]
pub struct SoapClient {
agent: Arc<ureq::Agent>,
}
static SHARED_SOAP_CLIENT: LazyLock<SoapClient> = LazyLock::new(|| SoapClient {
agent: Arc::new(
ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(5))
.timeout_read(Duration::from_secs(10))
.build(),
),
});
impl SoapClient {
pub fn get() -> &'static Self {
&SHARED_SOAP_CLIENT
}
pub fn with_agent(agent: Arc<ureq::Agent>) -> Self {
Self { agent }
}
#[deprecated(since = "0.1.0", note = "Use SoapClient::get() for shared resources")]
pub fn new() -> Self {
Self::with_agent(Arc::new(
ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(5))
.timeout_read(Duration::from_secs(10))
.build(),
))
}
pub fn call(
&self,
ip: &str,
endpoint: &str,
service_uri: &str,
action: &str,
payload: &str,
) -> Result<Element, SoapError> {
let body = format!(
r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:{action} xmlns:u="{service_uri}">
{payload}
</u:{action}>
</s:Body>
</s:Envelope>"#
);
let url = format!("http://{ip}:1400/{endpoint}");
let soap_action = format!("\"{service_uri}#{action}\"");
let response = self
.agent
.post(&url)
.set("Content-Type", "text/xml; charset=\"utf-8\"")
.set("SOAPACTION", &soap_action)
.send_string(&body)
.map_err(|e| SoapError::Network(e.to_string()))?;
let xml_text = response
.into_string()
.map_err(|e| SoapError::Network(e.to_string()))?;
let xml =
Element::parse(xml_text.as_bytes()).map_err(|e| SoapError::Parse(e.to_string()))?;
self.extract_response(&xml, action)
}
pub fn subscribe(
&self,
ip: &str,
port: u16,
event_endpoint: &str,
callback_url: &str,
timeout_seconds: u32,
) -> Result<SubscriptionResponse, SoapError> {
let url = format!("http://{ip}:{port}/{event_endpoint}");
let host = format!("{ip}:{port}");
let response = self
.agent
.request("SUBSCRIBE", &url)
.set("HOST", &host)
.set("CALLBACK", &format!("<{callback_url}>"))
.set("NT", "upnp:event")
.set("TIMEOUT", &format!("Second-{timeout_seconds}"))
.call()
.map_err(|e| SoapError::Network(e.to_string()))?;
if response.status() != 200 {
return Err(SoapError::Network(format!(
"SUBSCRIBE failed: HTTP {}",
response.status()
)));
}
let sid = response
.header("SID")
.ok_or_else(|| {
SoapError::Parse("Missing SID header in SUBSCRIBE response".to_string())
})?
.to_string();
let actual_timeout_seconds = response
.header("TIMEOUT")
.and_then(|s| {
if s.starts_with("Second-") {
s.strip_prefix("Second-")?.parse::<u32>().ok()
} else {
None
}
})
.unwrap_or(timeout_seconds);
Ok(SubscriptionResponse {
sid,
timeout_seconds: actual_timeout_seconds,
})
}
pub fn renew_subscription(
&self,
ip: &str,
port: u16,
event_endpoint: &str,
sid: &str,
timeout_seconds: u32,
) -> Result<u32, SoapError> {
let url = format!("http://{ip}:{port}/{event_endpoint}");
let host = format!("{ip}:{port}");
let response = self
.agent
.request("SUBSCRIBE", &url)
.set("HOST", &host)
.set("SID", sid)
.set("TIMEOUT", &format!("Second-{timeout_seconds}"))
.call()
.map_err(|e| SoapError::Network(e.to_string()))?;
if response.status() != 200 {
return Err(SoapError::Network(format!(
"SUBSCRIBE renewal failed: HTTP {}",
response.status()
)));
}
let actual_timeout_seconds = response
.header("TIMEOUT")
.and_then(|s| {
if s.starts_with("Second-") {
s.strip_prefix("Second-")?.parse::<u32>().ok()
} else {
None
}
})
.unwrap_or(timeout_seconds);
Ok(actual_timeout_seconds)
}
pub fn unsubscribe(
&self,
ip: &str,
port: u16,
event_endpoint: &str,
sid: &str,
) -> Result<(), SoapError> {
let url = format!("http://{ip}:{port}/{event_endpoint}");
let host = format!("{ip}:{port}");
let response = self
.agent
.request("UNSUBSCRIBE", &url)
.set("HOST", &host)
.set("SID", sid)
.call()
.map_err(|e| SoapError::Network(e.to_string()))?;
if response.status() != 200 {
return Err(SoapError::Network(format!(
"UNSUBSCRIBE failed: HTTP {}",
response.status()
)));
}
Ok(())
}
fn extract_response(&self, xml: &Element, action: &str) -> Result<Element, SoapError> {
let body = xml
.get_child("Body")
.ok_or_else(|| SoapError::Parse("Missing SOAP Body".to_string()))?;
if let Some(fault) = body.get_child("Fault") {
let error_code = fault
.get_child("detail")
.and_then(|d| d.get_child("UpnPError"))
.and_then(|e| e.get_child("errorCode"))
.and_then(|c| c.get_text())
.and_then(|t| t.parse::<u16>().ok())
.unwrap_or(500);
return Err(SoapError::Fault(error_code));
}
let response_name = format!("{action}Response");
body.get_child(response_name.as_str())
.cloned()
.ok_or_else(|| SoapError::Parse(format!("Missing {response_name} element")))
}
}
impl Default for SoapClient {
fn default() -> Self {
Self::get().clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_soap_client_creation() {
let _client = SoapClient::get();
let _default_client = SoapClient::default();
let _cloned_client = SoapClient::get().clone();
}
#[test]
fn test_singleton_pattern_consistency() {
let client1 = SoapClient::get();
let client2 = SoapClient::get();
assert!(std::ptr::eq(client1, client2));
let cloned1 = client1.clone();
let cloned2 = client2.clone();
assert!(Arc::ptr_eq(&cloned1.agent, &cloned2.agent));
}
#[test]
fn test_extract_response_with_valid_response() {
let client = SoapClient::get();
let xml_str = r#"
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:PlayResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
</u:PlayResponse>
</s:Body>
</s:Envelope>
"#;
let xml = Element::parse(xml_str.as_bytes()).unwrap();
let result = client.extract_response(&xml, "Play");
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.name, "PlayResponse");
}
#[test]
fn test_extract_response_with_soap_fault() {
let client = SoapClient::get();
let xml_str = r#"
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<s:Fault>
<faultcode>s:Client</faultcode>
<faultstring>UPnPError</faultstring>
<detail>
<UpnPError xmlns="urn:schemas-upnp-org:control-1-0">
<errorCode>401</errorCode>
<errorDescription>Invalid Action</errorDescription>
</UpnPError>
</detail>
</s:Fault>
</s:Body>
</s:Envelope>
"#;
let xml = Element::parse(xml_str.as_bytes()).unwrap();
let result = client.extract_response(&xml, "Play");
assert!(result.is_err());
match result.unwrap_err() {
SoapError::Fault(code) => assert_eq!(code, 401),
_ => panic!("Expected SoapError::Fault"),
}
}
#[test]
fn test_extract_response_missing_body() {
let client = SoapClient::get();
let xml_str = r#"
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
</s:Envelope>
"#;
let xml = Element::parse(xml_str.as_bytes()).unwrap();
let result = client.extract_response(&xml, "Play");
assert!(result.is_err());
match result.unwrap_err() {
SoapError::Parse(msg) => assert!(msg.contains("Missing SOAP Body")),
_ => panic!("Expected SoapError::Parse"),
}
}
#[test]
fn test_extract_response_missing_action_response() {
let client = SoapClient::get();
let xml_str = r#"
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
</s:Body>
</s:Envelope>
"#;
let xml = Element::parse(xml_str.as_bytes()).unwrap();
let result = client.extract_response(&xml, "Play");
assert!(result.is_err());
match result.unwrap_err() {
SoapError::Parse(msg) => assert!(msg.contains("Missing PlayResponse element")),
_ => panic!("Expected SoapError::Parse"),
}
}
#[test]
fn test_soap_fault_with_default_error_code() {
let client = SoapClient::get();
let xml_str = r#"
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<s:Fault>
<faultcode>s:Server</faultcode>
<faultstring>Internal Error</faultstring>
</s:Fault>
</s:Body>
</s:Envelope>
"#;
let xml = Element::parse(xml_str.as_bytes()).unwrap();
let result = client.extract_response(&xml, "Play");
assert!(result.is_err());
match result.unwrap_err() {
SoapError::Fault(code) => assert_eq!(code, 500), _ => panic!("Expected SoapError::Fault"),
}
}
}