1mod error;
10
11pub use error::SoapError;
12
13use std::sync::{Arc, LazyLock};
14use std::time::Duration;
15use xmltree::Element;
16
17#[derive(Debug, Clone)]
19pub struct SubscriptionResponse {
20 pub sid: String,
22 pub timeout_seconds: u32,
24}
25
26#[derive(Debug, Clone)]
31pub struct SoapClient {
32 agent: Arc<ureq::Agent>,
33}
34
35static 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 pub fn get() -> &'static Self {
52 &SHARED_SOAP_CLIENT
53 }
54
55 pub fn with_agent(agent: Arc<ureq::Agent>) -> Self {
61 Self { agent }
62 }
63
64 #[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 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 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 self.extract_response(&xml, action)
119 }
120
121 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 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 let actual_timeout_seconds = response
170 .header("TIMEOUT")
171 .and_then(|s| {
172 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 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 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 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 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 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 let _client = SoapClient::get();
314
315 let _default_client = SoapClient::default();
320
321 let _cloned_client = SoapClient::get().clone();
323 }
324
325 #[test]
326 fn test_singleton_pattern_consistency() {
327 let client1 = SoapClient::get();
329 let client2 = SoapClient::get();
330
331 assert!(std::ptr::eq(client1, client2));
333
334 let cloned1 = client1.clone();
336 let cloned2 = client2.clone();
337
338 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), _ => panic!("Expected SoapError::Fault"),
456 }
457 }
458}