Skip to main content

soap_client/
lib.rs

1//! Internal implementation detail of [`sonos-sdk`](https://crates.io/crates/sonos-sdk). Not intended for direct use.
2//!
3//! Private SOAP client for UPnP device communication
4//!
5//! This crate provides a minimal SOAP client specifically designed for
6//! communicating with UPnP devices like Sonos speakers. It also supports
7//! UPnP event subscriptions using SUBSCRIBE/UNSUBSCRIBE methods.
8
9mod error;
10
11pub use error::SoapError;
12
13use std::sync::{Arc, LazyLock};
14use std::time::Duration;
15use xmltree::Element;
16
17/// Response from a UPnP subscription request
18#[derive(Debug, Clone)]
19pub struct SubscriptionResponse {
20    /// Subscription ID returned by the device
21    pub sid: String,
22    /// Actual timeout granted by the device (in seconds)
23    pub timeout_seconds: u32,
24}
25
26/// A minimal SOAP client for UPnP device communication
27///
28/// Uses Arc internally for efficient sharing of the underlying HTTP client
29/// and connection pool across multiple instances.
30#[derive(Debug, Clone)]
31pub struct SoapClient {
32    agent: Arc<ureq::Agent>,
33}
34
35/// Global shared SOAP client instance for maximum resource efficiency
36static SHARED_SOAP_CLIENT: LazyLock<SoapClient> = LazyLock::new(|| SoapClient {
37    agent: Arc::new(
38        ureq::AgentBuilder::new()
39            .timeout_connect(Duration::from_secs(5))
40            .timeout_read(Duration::from_secs(10))
41            .build(),
42    ),
43});
44
45impl SoapClient {
46    /// Get the global shared SOAP client instance
47    ///
48    /// This provides a singleton-like pattern for maximum resource efficiency.
49    /// All clients returned by this method share the same underlying HTTP agent
50    /// and connection pool, reducing memory usage and improving performance.
51    pub fn get() -> &'static Self {
52        &SHARED_SOAP_CLIENT
53    }
54
55    /// Create a SOAP client with a custom agent (for advanced use cases only)
56    ///
57    /// Most applications should use `SoapClient::get()` instead for better
58    /// resource efficiency. This method is provided for cases where custom
59    /// timeout values or other HTTP client configuration is needed.
60    pub fn with_agent(agent: Arc<ureq::Agent>) -> Self {
61        Self { agent }
62    }
63
64    /// Create a new SOAP client with default configuration
65    ///
66    /// **DEPRECATED**: Use `SoapClient::get()` instead for better resource efficiency.
67    /// This method creates a separate HTTP agent instance, which wastes resources
68    /// when multiple SOAP clients are used.
69    #[deprecated(since = "0.1.0", note = "Use SoapClient::get() for shared resources")]
70    pub fn new() -> Self {
71        Self::with_agent(Arc::new(
72            ureq::AgentBuilder::new()
73                .timeout_connect(Duration::from_secs(5))
74                .timeout_read(Duration::from_secs(10))
75                .build(),
76        ))
77    }
78
79    /// Send a SOAP request and return the parsed response element
80    pub fn call(
81        &self,
82        ip: &str,
83        endpoint: &str,
84        service_uri: &str,
85        action: &str,
86        payload: &str,
87    ) -> Result<Element, SoapError> {
88        // Inline SOAP envelope construction - no separate module needed
89        let body = format!(
90            r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
91                <s:Body>
92                    <u:{action} xmlns:u="{service_uri}">
93                        {payload}
94                    </u:{action}>
95                </s:Body>
96            </s:Envelope>"#
97        );
98
99        let url = format!("http://{ip}:1400/{endpoint}");
100        let soap_action = format!("\"{service_uri}#{action}\"");
101
102        let response = self
103            .agent
104            .post(&url)
105            .set("Content-Type", "text/xml; charset=\"utf-8\"")
106            .set("SOAPACTION", &soap_action)
107            .send_string(&body)
108            .map_err(|e| SoapError::Network(e.to_string()))?;
109
110        let xml_text = response
111            .into_string()
112            .map_err(|e| SoapError::Network(e.to_string()))?;
113
114        let xml =
115            Element::parse(xml_text.as_bytes()).map_err(|e| SoapError::Parse(e.to_string()))?;
116
117        // Extract response or handle SOAP fault
118        self.extract_response(&xml, action)
119    }
120
121    /// Subscribe to UPnP events for a specific service endpoint
122    ///
123    /// # Arguments
124    /// * `ip` - Device IP address
125    /// * `port` - Device port (typically 1400)
126    /// * `event_endpoint` - Event endpoint path (e.g., "MediaRenderer/AVTransport/Event")
127    /// * `callback_url` - URL where events should be sent
128    /// * `timeout_seconds` - Requested subscription timeout in seconds
129    ///
130    /// # Returns
131    /// A `SubscriptionResponse` containing the SID and actual timeout
132    pub fn subscribe(
133        &self,
134        ip: &str,
135        port: u16,
136        event_endpoint: &str,
137        callback_url: &str,
138        timeout_seconds: u32,
139    ) -> Result<SubscriptionResponse, SoapError> {
140        let url = format!("http://{ip}:{port}/{event_endpoint}");
141        let host = format!("{ip}:{port}");
142
143        let response = self
144            .agent
145            .request("SUBSCRIBE", &url)
146            .set("HOST", &host)
147            .set("CALLBACK", &format!("<{callback_url}>"))
148            .set("NT", "upnp:event")
149            .set("TIMEOUT", &format!("Second-{timeout_seconds}"))
150            .call()
151            .map_err(|e| SoapError::Network(e.to_string()))?;
152
153        if response.status() != 200 {
154            return Err(SoapError::Network(format!(
155                "SUBSCRIBE failed: HTTP {}",
156                response.status()
157            )));
158        }
159
160        // Extract SID from response headers
161        let sid = response
162            .header("SID")
163            .ok_or_else(|| {
164                SoapError::Parse("Missing SID header in SUBSCRIBE response".to_string())
165            })?
166            .to_string();
167
168        // Extract timeout from response headers (optional, fallback to requested timeout)
169        let actual_timeout_seconds = response
170            .header("TIMEOUT")
171            .and_then(|s| {
172                // Parse "Second-1800" format
173                if s.starts_with("Second-") {
174                    s.strip_prefix("Second-")?.parse::<u32>().ok()
175                } else {
176                    None
177                }
178            })
179            .unwrap_or(timeout_seconds);
180
181        Ok(SubscriptionResponse {
182            sid,
183            timeout_seconds: actual_timeout_seconds,
184        })
185    }
186
187    /// Renew an existing UPnP subscription
188    ///
189    /// # Arguments
190    /// * `ip` - Device IP address
191    /// * `port` - Device port (typically 1400)
192    /// * `event_endpoint` - Event endpoint path
193    /// * `sid` - Subscription ID to renew
194    /// * `timeout_seconds` - Requested renewal timeout in seconds
195    ///
196    /// # Returns
197    /// The actual timeout granted by the device
198    pub fn renew_subscription(
199        &self,
200        ip: &str,
201        port: u16,
202        event_endpoint: &str,
203        sid: &str,
204        timeout_seconds: u32,
205    ) -> Result<u32, SoapError> {
206        let url = format!("http://{ip}:{port}/{event_endpoint}");
207        let host = format!("{ip}:{port}");
208
209        let response = self
210            .agent
211            .request("SUBSCRIBE", &url)
212            .set("HOST", &host)
213            .set("SID", sid)
214            .set("TIMEOUT", &format!("Second-{timeout_seconds}"))
215            .call()
216            .map_err(|e| SoapError::Network(e.to_string()))?;
217
218        if response.status() != 200 {
219            return Err(SoapError::Network(format!(
220                "SUBSCRIBE renewal failed: HTTP {}",
221                response.status()
222            )));
223        }
224
225        // Extract timeout from response headers
226        let actual_timeout_seconds = response
227            .header("TIMEOUT")
228            .and_then(|s| {
229                if s.starts_with("Second-") {
230                    s.strip_prefix("Second-")?.parse::<u32>().ok()
231                } else {
232                    None
233                }
234            })
235            .unwrap_or(timeout_seconds);
236
237        Ok(actual_timeout_seconds)
238    }
239
240    /// Unsubscribe from UPnP events
241    ///
242    /// # Arguments
243    /// * `ip` - Device IP address
244    /// * `port` - Device port (typically 1400)
245    /// * `event_endpoint` - Event endpoint path
246    /// * `sid` - Subscription ID to cancel
247    pub fn unsubscribe(
248        &self,
249        ip: &str,
250        port: u16,
251        event_endpoint: &str,
252        sid: &str,
253    ) -> Result<(), SoapError> {
254        let url = format!("http://{ip}:{port}/{event_endpoint}");
255        let host = format!("{ip}:{port}");
256
257        let response = self
258            .agent
259            .request("UNSUBSCRIBE", &url)
260            .set("HOST", &host)
261            .set("SID", sid)
262            .call()
263            .map_err(|e| SoapError::Network(e.to_string()))?;
264
265        if response.status() != 200 {
266            return Err(SoapError::Network(format!(
267                "UNSUBSCRIBE failed: HTTP {}",
268                response.status()
269            )));
270        }
271
272        Ok(())
273    }
274
275    fn extract_response(&self, xml: &Element, action: &str) -> Result<Element, SoapError> {
276        let body = xml
277            .get_child("Body")
278            .ok_or_else(|| SoapError::Parse("Missing SOAP Body".to_string()))?;
279
280        // Check for SOAP fault first
281        if let Some(fault) = body.get_child("Fault") {
282            let error_code = fault
283                .get_child("detail")
284                .and_then(|d| d.get_child("UpnPError"))
285                .and_then(|e| e.get_child("errorCode"))
286                .and_then(|c| c.get_text())
287                .and_then(|t| t.parse::<u16>().ok())
288                .unwrap_or(500);
289            return Err(SoapError::Fault(error_code));
290        }
291
292        // Extract the action response
293        let response_name = format!("{action}Response");
294        body.get_child(response_name.as_str())
295            .cloned()
296            .ok_or_else(|| SoapError::Parse(format!("Missing {response_name} element")))
297    }
298}
299
300impl Default for SoapClient {
301    fn default() -> Self {
302        Self::get().clone()
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_soap_client_creation() {
312        // Test singleton pattern
313        let _client = SoapClient::get();
314
315        // Test that the client can be created without panicking
316        // and that it has the expected timeout configuration
317        // We can't easily test the timeout values directly, but we can verify
318        // the client was created successfully
319        let _default_client = SoapClient::default();
320
321        // Test that cloning works efficiently
322        let _cloned_client = SoapClient::get().clone();
323    }
324
325    #[test]
326    fn test_singleton_pattern_consistency() {
327        // Test that multiple calls to get() return references to the same instance
328        let client1 = SoapClient::get();
329        let client2 = SoapClient::get();
330
331        // Both should point to the same static instance
332        assert!(std::ptr::eq(client1, client2));
333
334        // Clones should have the same Arc reference count
335        let cloned1 = client1.clone();
336        let cloned2 = client2.clone();
337
338        // All clones should share the same underlying agent
339        assert!(Arc::ptr_eq(&cloned1.agent, &cloned2.agent));
340    }
341
342    #[test]
343    fn test_extract_response_with_valid_response() {
344        let client = SoapClient::get();
345
346        let xml_str = r#"
347            <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
348                <s:Body>
349                    <u:PlayResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
350                    </u:PlayResponse>
351                </s:Body>
352            </s:Envelope>
353        "#;
354
355        let xml = Element::parse(xml_str.as_bytes()).unwrap();
356        let result = client.extract_response(&xml, "Play");
357
358        assert!(result.is_ok());
359        let response = result.unwrap();
360        assert_eq!(response.name, "PlayResponse");
361    }
362
363    #[test]
364    fn test_extract_response_with_soap_fault() {
365        let client = SoapClient::get();
366
367        let xml_str = r#"
368            <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
369                <s:Body>
370                    <s:Fault>
371                        <faultcode>s:Client</faultcode>
372                        <faultstring>UPnPError</faultstring>
373                        <detail>
374                            <UpnPError xmlns="urn:schemas-upnp-org:control-1-0">
375                                <errorCode>401</errorCode>
376                                <errorDescription>Invalid Action</errorDescription>
377                            </UpnPError>
378                        </detail>
379                    </s:Fault>
380                </s:Body>
381            </s:Envelope>
382        "#;
383
384        let xml = Element::parse(xml_str.as_bytes()).unwrap();
385        let result = client.extract_response(&xml, "Play");
386
387        assert!(result.is_err());
388        match result.unwrap_err() {
389            SoapError::Fault(code) => assert_eq!(code, 401),
390            _ => panic!("Expected SoapError::Fault"),
391        }
392    }
393
394    #[test]
395    fn test_extract_response_missing_body() {
396        let client = SoapClient::get();
397
398        let xml_str = r#"
399            <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
400            </s:Envelope>
401        "#;
402
403        let xml = Element::parse(xml_str.as_bytes()).unwrap();
404        let result = client.extract_response(&xml, "Play");
405
406        assert!(result.is_err());
407        match result.unwrap_err() {
408            SoapError::Parse(msg) => assert!(msg.contains("Missing SOAP Body")),
409            _ => panic!("Expected SoapError::Parse"),
410        }
411    }
412
413    #[test]
414    fn test_extract_response_missing_action_response() {
415        let client = SoapClient::get();
416
417        let xml_str = r#"
418            <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
419                <s:Body>
420                </s:Body>
421            </s:Envelope>
422        "#;
423
424        let xml = Element::parse(xml_str.as_bytes()).unwrap();
425        let result = client.extract_response(&xml, "Play");
426
427        assert!(result.is_err());
428        match result.unwrap_err() {
429            SoapError::Parse(msg) => assert!(msg.contains("Missing PlayResponse element")),
430            _ => panic!("Expected SoapError::Parse"),
431        }
432    }
433
434    #[test]
435    fn test_soap_fault_with_default_error_code() {
436        let client = SoapClient::get();
437
438        let xml_str = r#"
439            <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
440                <s:Body>
441                    <s:Fault>
442                        <faultcode>s:Server</faultcode>
443                        <faultstring>Internal Error</faultstring>
444                    </s:Fault>
445                </s:Body>
446            </s:Envelope>
447        "#;
448
449        let xml = Element::parse(xml_str.as_bytes()).unwrap();
450        let result = client.extract_response(&xml, "Play");
451
452        assert!(result.is_err());
453        match result.unwrap_err() {
454            SoapError::Fault(code) => assert_eq!(code, 500), // Default error code
455            _ => panic!("Expected SoapError::Fault"),
456        }
457    }
458}