#![forbid(unsafe_code)]
mod error;
pub use error::SoapError;
use quick_xml::events::Event;
use quick_xml::Reader;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
const UPNP_ERROR_ELEMENT: &str = "UPnPError";
const UPNP_ERROR_ELEMENT_LEGACY: &str = "UpnPError";
const UNKNOWN_FAULT_CODE: u16 = 500;
#[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<String, 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| map_ureq_error(e, action))?;
let xml_text = response
.into_string()
.map_err(|e| SoapError::Network(e.to_string()))?;
check_response(&xml_text, action)?;
Ok(xml_text)
}
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| map_subscription_error("SUBSCRIBE", e))?;
require_success("SUBSCRIBE", &response)?;
let sid = response
.header("SID")
.ok_or_else(|| {
SoapError::Parse("Missing SID header in SUBSCRIBE response".to_string())
})?
.to_string();
Ok(SubscriptionResponse {
sid,
timeout_seconds: granted_timeout(&response, 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| map_subscription_error("SUBSCRIBE renewal", e))?;
require_success("SUBSCRIBE renewal", &response)?;
Ok(granted_timeout(&response, 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| map_subscription_error("UNSUBSCRIBE", e))?;
require_success("UNSUBSCRIBE", &response)?;
Ok(())
}
}
impl Default for SoapClient {
fn default() -> Self {
Self::get().clone()
}
}
fn is_success(status: u16) -> bool {
(200..300).contains(&status)
}
fn require_success(operation: &str, response: &ureq::Response) -> Result<(), SoapError> {
if is_success(response.status()) {
Ok(())
} else {
Err(SoapError::Network(format!(
"{operation} failed: HTTP {}",
response.status()
)))
}
}
fn granted_timeout(response: &ureq::Response, requested: u32) -> u32 {
response
.header("TIMEOUT")
.and_then(|s| s.strip_prefix("Second-")?.parse::<u32>().ok())
.unwrap_or(requested)
}
fn map_ureq_error(error: ureq::Error, action: &str) -> SoapError {
match error {
ureq::Error::Status(status, response) => match response.into_string() {
Ok(body) => match check_response(&body, action) {
Ok(()) => SoapError::Network(format!("HTTP {status}")),
Err(SoapError::Fault { code, description }) => {
SoapError::Fault { code, description }
}
Err(_) => SoapError::Network(format!("HTTP {status}: {body}")),
},
Err(e) => SoapError::Network(format!("HTTP {status}: failed to read body: {e}")),
},
ureq::Error::Transport(transport) => SoapError::Network(transport.to_string()),
}
}
fn map_subscription_error(operation: &str, error: ureq::Error) -> SoapError {
match error {
ureq::Error::Status(status, _) => {
SoapError::Network(format!("{operation} failed: HTTP {status}"))
}
ureq::Error::Transport(transport) => {
SoapError::Network(format!("{operation} failed: {transport}"))
}
}
}
#[derive(Debug)]
enum EnvelopeScan {
Response,
Fault {
code: u16,
description: Option<String>,
},
MissingBody,
MissingResponse,
Malformed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FaultField {
Code,
Description,
}
#[derive(Debug, Default)]
struct FaultFields {
code: Option<String>,
description: Option<String>,
}
impl FaultFields {
fn push(&mut self, field: FaultField, text: &str) {
let slot = match field {
FaultField::Code => &mut self.code,
FaultField::Description => &mut self.description,
};
slot.get_or_insert_with(String::new).push_str(text);
}
fn is_empty(&self) -> bool {
self.code.is_none() && self.description.is_none()
}
fn into_scan(self) -> EnvelopeScan {
EnvelopeScan::Fault {
code: self
.code
.as_deref()
.and_then(|t| t.trim().parse::<u16>().ok())
.unwrap_or(UNKNOWN_FAULT_CODE),
description: self
.description
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty()),
}
}
}
fn check_response(xml: &str, action: &str) -> Result<(), SoapError> {
match scan_envelope(xml, action) {
EnvelopeScan::Response => Ok(()),
scan => Err(scan_to_error(scan, action)),
}
}
fn scan_to_error(scan: EnvelopeScan, action: &str) -> SoapError {
match scan {
EnvelopeScan::Fault { code, description } => SoapError::Fault { code, description },
EnvelopeScan::MissingBody => SoapError::Parse("Missing SOAP Body".to_string()),
EnvelopeScan::MissingResponse => {
SoapError::Parse(format!("Missing {action}Response element"))
}
EnvelopeScan::Malformed(msg) => SoapError::Parse(msg),
EnvelopeScan::Response => unreachable!("Response is not an error"),
}
}
fn at_path(path: &[String], expected: &[&str]) -> bool {
path.len() == expected.len() + 1 && path[1..].iter().zip(expected).all(|(a, b)| a == b)
}
fn fault_field_at(path: &[String]) -> Option<(bool, FaultField)> {
for (is_spec, element) in [
(true, UPNP_ERROR_ELEMENT),
(false, UPNP_ERROR_ELEMENT_LEGACY),
] {
for (field, name) in [
(FaultField::Code, "errorCode"),
(FaultField::Description, "errorDescription"),
] {
if at_path(path, &["Body", "Fault", "detail", element, name]) {
return Some((is_spec, field));
}
}
}
None
}
fn fields_for<'a>(
is_spec: bool,
spec: &'a mut FaultFields,
legacy: &'a mut FaultFields,
) -> &'a mut FaultFields {
if is_spec {
spec
} else {
legacy
}
}
fn scan_envelope(xml: &str, action: &str) -> EnvelopeScan {
let response_name = format!("{action}Response");
let mut reader = Reader::from_str(xml);
let mut path: Vec<String> = Vec::new();
let mut saw_root = false;
let mut saw_body = false;
let mut saw_fault = false;
let mut saw_response = false;
let mut spec_fields = FaultFields::default();
let mut legacy_fields = FaultFields::default();
let mut collecting: Option<(bool, FaultField)> = None;
loop {
let event = match reader.read_event() {
Ok(event) => event,
Err(e) => return EnvelopeScan::Malformed(e.to_string()),
};
match event {
Event::Eof => break,
Event::Start(start) => {
path.push(String::from_utf8_lossy(start.local_name().as_ref()).into_owned());
saw_root = true;
if at_path(&path, &["Body"]) {
saw_body = true;
} else if at_path(&path, &["Body", "Fault"]) {
saw_fault = true;
} else if path.len() == 3 && path[1] == "Body" && path[2] == response_name {
saw_response = true;
} else {
collecting = fault_field_at(&path);
}
}
Event::Empty(start) => {
path.push(String::from_utf8_lossy(start.local_name().as_ref()).into_owned());
saw_root = true;
if at_path(&path, &["Body"]) {
saw_body = true;
} else if at_path(&path, &["Body", "Fault"]) {
saw_fault = true;
} else if path.len() == 3 && path[1] == "Body" && path[2] == response_name {
saw_response = true;
}
path.pop();
}
Event::Text(text) => {
if let Some((is_spec, field)) = collecting {
let decoded = match text.unescape() {
Ok(decoded) => decoded,
Err(e) => return EnvelopeScan::Malformed(e.to_string()),
};
fields_for(is_spec, &mut spec_fields, &mut legacy_fields).push(field, &decoded);
}
}
Event::CData(cdata) => {
if let Some((is_spec, field)) = collecting {
let decoded = String::from_utf8_lossy(&cdata).into_owned();
fields_for(is_spec, &mut spec_fields, &mut legacy_fields).push(field, &decoded);
}
}
Event::End(_) => {
path.pop();
collecting = fault_field_at(&path);
}
_ => {}
}
}
if !saw_root {
return EnvelopeScan::Malformed("response body is not an XML document".to_string());
}
if !path.is_empty() {
return EnvelopeScan::Malformed(format!(
"unexpected end of XML: <{}> was never closed",
path[path.len() - 1]
));
}
if !saw_body {
return EnvelopeScan::MissingBody;
}
if saw_fault {
return if spec_fields.is_empty() {
legacy_fields.into_scan()
} else {
spec_fields.into_scan()
};
}
if !saw_response {
return EnvelopeScan::MissingResponse;
}
EnvelopeScan::Response
}
#[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 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>
"#;
assert!(check_response(xml_str, "Play").is_ok());
}
#[test]
fn test_extract_response_rejects_other_action_response() {
let xml_str = r#"
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:PauseResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
</u:PauseResponse>
</s:Body>
</s:Envelope>
"#;
match check_response(xml_str, "Play").unwrap_err() {
SoapError::Parse(msg) => assert!(msg.contains("Missing PlayResponse element")),
other => panic!("Expected SoapError::Parse, got {other:?}"),
}
}
#[test]
fn test_extract_response_accepts_self_closing_response() {
let xml_str = r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body><u:PlayResponse xmlns:u="urn:x"/></s:Body>
</s:Envelope>"#;
assert!(check_response(xml_str, "Play").is_ok());
}
#[test]
fn test_extract_response_ignores_non_toplevel_fault() {
let xml_str = r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:PlayResponse xmlns:u="urn:x">
<Detail><Fault>not a soap fault</Fault></Detail>
</u:PlayResponse>
</s:Body>
</s:Envelope>"#;
assert!(check_response(xml_str, "Play").is_ok());
}
#[test]
fn test_extract_response_with_non_xml_body() {
match check_response("device busy", "Play").unwrap_err() {
SoapError::Parse(_) => {}
other => panic!("Expected SoapError::Parse, got {other:?}"),
}
}
#[test]
fn test_extract_response_rejects_truncated_envelope() {
let truncated = r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body><u:PlayResponse xmlns:u="urn:x">"#;
match check_response(truncated, "Play").unwrap_err() {
SoapError::Parse(msg) => {
assert!(msg.contains("never closed"), "unexpected message: {msg}");
}
other => panic!("Expected SoapError::Parse, got {other:?}"),
}
}
#[test]
fn test_extract_response_with_soap_fault() {
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>
"#;
match check_response(xml_str, "Play").unwrap_err() {
SoapError::Fault { code, description } => {
assert_eq!(code, 401);
assert_eq!(description.as_deref(), Some("Invalid Action"));
}
other => panic!("Expected SoapError::Fault, got {other:?}"),
}
}
#[test]
fn test_extract_response_with_legacy_upnperror_spelling() {
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>701</errorCode>
<errorDescription>Transition not available</errorDescription>
</UpnPError>
</detail>
</s:Fault>
</s:Body>
</s:Envelope>
"#;
match check_response(xml_str, "Play").unwrap_err() {
SoapError::Fault { code, description } => {
assert_eq!(code, 701);
assert_eq!(description.as_deref(), Some("Transition not available"));
}
other => panic!("Expected SoapError::Fault, got {other:?}"),
}
}
#[test]
fn test_status_500_with_fault_body_yields_fault() {
let fault_body = 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>402</errorCode>
<errorDescription>Invalid Args</errorDescription>
</UPnPError>
</detail>
</s:Fault>
</s:Body>
</s:Envelope>"#;
let response = ureq::Response::new(500, "Internal Server Error", fault_body).unwrap();
let error = ureq::Error::Status(500, response);
match map_ureq_error(error, "SetVolume") {
SoapError::Fault { code, description } => {
assert_eq!(code, 402);
assert_eq!(description.as_deref(), Some("Invalid Args"));
}
other => panic!("Expected SoapError::Fault, got {other:?}"),
}
}
#[test]
fn test_status_error_without_fault_body_yields_network() {
let response = ureq::Response::new(503, "Service Unavailable", "device busy").unwrap();
let error = ureq::Error::Status(503, response);
match map_ureq_error(error, "Play") {
SoapError::Network(msg) => assert!(msg.contains("503"), "unexpected message: {msg}"),
other => panic!("Expected SoapError::Network, got {other:?}"),
}
}
#[test]
fn test_is_success_accepts_non_200_success_codes() {
assert!(is_success(200));
assert!(is_success(201));
assert!(!is_success(302));
}
#[test]
fn test_require_success_preserves_status_and_operation() {
let ok = ureq::Response::new(200, "OK", "").unwrap();
assert!(require_success("SUBSCRIBE", &ok).is_ok());
let redirect = ureq::Response::new(302, "Found", "").unwrap();
match require_success("UNSUBSCRIBE", &redirect).unwrap_err() {
SoapError::Network(msg) => {
assert!(msg.contains("UNSUBSCRIBE"), "unexpected message: {msg}");
assert!(msg.contains("302"), "unexpected message: {msg}");
}
other => panic!("Expected SoapError::Network, got {other:?}"),
}
}
#[test]
fn test_granted_timeout_parsing_and_fallback() {
let with = |header: Option<&str>| {
let mut raw = "HTTP/1.1 200 OK\r\n".to_string();
if let Some(value) = header {
raw.push_str(&format!("TIMEOUT: {value}\r\n"));
}
raw.push_str("\r\n");
let response: ureq::Response = raw.parse().unwrap();
granted_timeout(&response, 1800)
};
assert_eq!(with(Some("Second-600")), 600);
assert_eq!(with(Some("Second-infinite")), 1800);
assert_eq!(with(Some("600")), 1800);
assert_eq!(with(None), 1800);
}
#[test]
fn test_extract_response_missing_body() {
let xml_str = r#"
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
</s:Envelope>
"#;
match check_response(xml_str, "Play").unwrap_err() {
SoapError::Parse(msg) => assert!(msg.contains("Missing SOAP Body")),
_ => panic!("Expected SoapError::Parse"),
}
}
#[test]
fn test_extract_response_missing_action_response() {
let xml_str = r#"
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
</s:Body>
</s:Envelope>
"#;
match check_response(xml_str, "Play").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 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>
"#;
match check_response(xml_str, "Play").unwrap_err() {
SoapError::Fault { code, description } => {
assert_eq!(code, UNKNOWN_FAULT_CODE);
assert_eq!(description, None);
}
other => panic!("Expected SoapError::Fault, got {other:?}"),
}
}
#[test]
fn test_soap_fault_blank_description_is_none() {
let xml_str = r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<s:Fault>
<detail>
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
<errorCode>402</errorCode>
<errorDescription> </errorDescription>
</UPnPError>
</detail>
</s:Fault>
</s:Body>
</s:Envelope>"#;
match check_response(xml_str, "Play").unwrap_err() {
SoapError::Fault { code, description } => {
assert_eq!(code, 402);
assert_eq!(description, None);
}
other => panic!("Expected SoapError::Fault, got {other:?}"),
}
}
#[test]
fn test_soap_fault_unparseable_code_falls_back() {
let xml_str = r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<s:Fault>
<detail>
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
<errorCode>not-a-number</errorCode>
<errorDescription>Invalid Args</errorDescription>
</UPnPError>
</detail>
</s:Fault>
</s:Body>
</s:Envelope>"#;
match check_response(xml_str, "Play").unwrap_err() {
SoapError::Fault { code, description } => {
assert_eq!(code, UNKNOWN_FAULT_CODE);
assert_eq!(description.as_deref(), Some("Invalid Args"));
}
other => panic!("Expected SoapError::Fault, got {other:?}"),
}
}
#[test]
fn test_soap_fault_description_is_unescaped() {
let xml_str = r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<s:Fault>
<detail>
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
<errorCode>402</errorCode>
<errorDescription>Bad & wrong</errorDescription>
</UPnPError>
</detail>
</s:Fault>
</s:Body>
</s:Envelope>"#;
match check_response(xml_str, "Play").unwrap_err() {
SoapError::Fault { description, .. } => {
assert_eq!(description.as_deref(), Some("Bad & wrong"));
}
other => panic!("Expected SoapError::Fault, got {other:?}"),
}
}
#[test]
fn test_subscription_error_preserves_http_status() {
let response = ureq::Response::new(412, "Precondition Failed", "").unwrap();
let error = ureq::Error::Status(412, response);
match map_subscription_error("SUBSCRIBE", error) {
SoapError::Network(msg) => {
assert!(msg.contains("SUBSCRIBE"), "unexpected message: {msg}");
assert!(msg.contains("412"), "unexpected message: {msg}");
}
other => panic!("Expected SoapError::Network, got {other:?}"),
}
}
}