sonos_api/operation/mod.rs
1//! Enhanced operation framework with composability and validation support
2//!
3//! This module provides the core framework for UPnP operations with advanced features:
4//! - Composable operations that can be chained, batched, or made conditional
5//! - Dual validation strategy (boundary vs comprehensive)
6//! - Fluent builder pattern for operation construction
7//! - Strong type safety with minimal boilerplate
8
9mod builder;
10pub mod macros;
11
12pub use builder::*;
13
14// Legacy SonosOperation trait for backward compatibility
15use quick_xml::events::Event;
16use quick_xml::Reader;
17use serde::{Deserialize, Serialize};
18use std::str::FromStr;
19
20use crate::error::ApiError;
21use crate::service::Service;
22
23/// Base trait for all Sonos API operations (LEGACY)
24///
25/// This trait defines the common interface that all Sonos UPnP operations must implement.
26/// It provides type safety through associated types and ensures consistent patterns
27/// for request/response handling across all operations.
28///
29/// **Note**: This is the legacy trait. New code should use `UPnPOperation` instead.
30pub trait SonosOperation {
31 /// The request type for this operation, must be serializable
32 type Request: Serialize;
33
34 /// The response type for this operation, must be deserializable
35 type Response: for<'de> Deserialize<'de>;
36
37 /// The UPnP service this operation belongs to
38 const SERVICE: Service;
39
40 /// The SOAP action name for this operation
41 const ACTION: &'static str;
42
43 /// Build the SOAP payload from the request data
44 ///
45 /// This method should construct the XML payload that goes inside the SOAP envelope.
46 /// The payload should contain all the parameters needed for the UPnP action.
47 ///
48 /// # Arguments
49 /// * `request` - The typed request data
50 ///
51 /// # Returns
52 /// A string containing the XML payload (without SOAP envelope)
53 fn build_payload(request: &Self::Request) -> String;
54
55 /// Parse the SOAP response XML into the typed response
56 ///
57 /// This method extracts the relevant data from the SOAP response XML and
58 /// converts it into the strongly-typed response structure.
59 ///
60 /// # Arguments
61 /// * `xml` - The raw SOAP response body
62 ///
63 /// # Returns
64 /// The typed response data or an error if parsing fails
65 fn parse_response(xml: &str) -> Result<Self::Response, ApiError>;
66}
67
68/// Validation error types
69#[derive(Debug, thiserror::Error)]
70pub enum ValidationError {
71 #[error("Parameter '{parameter}' value '{value}' is out of range ({min}..={max})")]
72 RangeError {
73 parameter: String,
74 value: String,
75 min: String,
76 max: String,
77 },
78
79 #[error("Parameter '{parameter}' value '{value}' is invalid: {reason}")]
80 InvalidValue {
81 parameter: String,
82 value: String,
83 reason: String,
84 },
85
86 #[error("Required parameter '{parameter}' is missing")]
87 MissingParameter { parameter: String },
88
89 #[error("Parameter '{parameter}' failed validation: {message}")]
90 Custom { parameter: String, message: String },
91}
92
93impl ValidationError {
94 pub fn range_error(
95 parameter: &str,
96 min: impl std::fmt::Display,
97 max: impl std::fmt::Display,
98 value: impl std::fmt::Display,
99 ) -> Self {
100 Self::RangeError {
101 parameter: parameter.to_string(),
102 value: value.to_string(),
103 min: min.to_string(),
104 max: max.to_string(),
105 }
106 }
107
108 pub fn invalid_value(parameter: &str, value: impl std::fmt::Display) -> Self {
109 Self::InvalidValue {
110 parameter: parameter.to_string(),
111 value: value.to_string(),
112 reason: "invalid format or content".to_string(),
113 }
114 }
115}
116
117/// Validation levels for operation parameters
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
119pub enum ValidationLevel {
120 /// No validation - maximum performance
121 None,
122 /// Basic validation - type and range checks
123 #[default]
124 Basic,
125}
126
127/// Trait for types that can be validated
128pub trait Validate {
129 /// Perform basic validation
130 ///
131 /// This should include type checks and range validation
132 /// to fail fast on obviously invalid input.
133 fn validate_basic(&self) -> Result<(), ValidationError> {
134 Ok(()) // Default: no validation
135 }
136
137 /// Validate with the specified level
138 fn validate(&self, level: ValidationLevel) -> Result<(), ValidationError> {
139 match level {
140 ValidationLevel::None => Ok(()),
141 ValidationLevel::Basic => self.validate_basic(),
142 }
143 }
144}
145
146/// Enhanced UPnP operation trait with composability support
147///
148/// This trait extends the original SonosOperation concept with:
149/// - Composability: operations can be chained, batched, or made conditional
150/// - Validation: flexible validation strategy with boundary and comprehensive levels
151/// - Dependencies: operations can declare dependencies on other operations
152/// - Batching: operations can indicate whether they can be batched with others
153pub trait UPnPOperation {
154 /// The request type for this operation, must be serializable and validatable
155 type Request: Serialize + Validate;
156
157 /// The response type for this operation, must be deserializable
158 type Response: for<'de> Deserialize<'de>;
159
160 /// The UPnP service this operation belongs to
161 const SERVICE: Service;
162
163 /// The SOAP action name for this operation
164 const ACTION: &'static str;
165
166 /// Build the SOAP payload from the request data with validation
167 ///
168 /// This method validates the request according to the validation level
169 /// and then constructs the XML payload for the SOAP envelope.
170 ///
171 /// # Arguments
172 /// * `request` - The typed request data
173 ///
174 /// # Returns
175 /// A string containing the XML payload or a validation error
176 fn build_payload(request: &Self::Request) -> Result<String, ValidationError>;
177
178 /// Parse the SOAP response XML into the typed response
179 ///
180 /// This method extracts the relevant data from the SOAP response XML and
181 /// converts it into the strongly-typed response structure.
182 ///
183 /// # Arguments
184 /// * `xml` - The raw SOAP response body
185 ///
186 /// # Returns
187 /// The typed response data or an error if parsing fails
188 fn parse_response(xml: &str) -> Result<Self::Response, ApiError>;
189
190 /// Get the list of operations this operation depends on
191 ///
192 /// This is used for operation ordering and dependency resolution
193 /// in batch and sequence operations.
194 ///
195 /// # Returns
196 /// A slice of action names that must be executed before this operation
197 fn dependencies() -> &'static [&'static str] {
198 &[]
199 }
200
201 /// Check if this operation can be batched with another operation
202 ///
203 /// Some operations may have conflicts or dependencies that prevent
204 /// them from being executed in parallel.
205 ///
206 /// # Type Parameters
207 /// * `T` - Another UPnP operation type to check compatibility with
208 ///
209 /// # Returns
210 /// True if the operations can be safely executed in parallel
211 fn can_batch_with<T: UPnPOperation>() -> bool {
212 true // Default: most operations can be batched
213 }
214
215 /// Get human-readable operation metadata
216 ///
217 /// This is useful for debugging, logging, and SDK development
218 fn metadata() -> OperationMetadata {
219 OperationMetadata {
220 service: Self::SERVICE.name(),
221 action: Self::ACTION,
222 dependencies: Self::dependencies(),
223 }
224 }
225}
226
227/// Metadata about a UPnP operation
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct OperationMetadata {
230 /// The service name (e.g., "AVTransport")
231 pub service: &'static str,
232 /// The action name (e.g., "Play")
233 pub action: &'static str,
234 /// List of operations this operation depends on
235 pub dependencies: &'static [&'static str],
236}
237
238/// Read the text content of a named argument out of a SOAP response body.
239///
240/// UPnP action responses are flat: every out-argument is a leaf element directly
241/// under `<{action}Response>`, which is itself under `<Body>`. Rather than build a
242/// DOM, this walks the document once with `quick-xml` and returns the text of the
243/// first element whose **local** name matches `name`, so namespace prefixes
244/// (`u:CurrentVolume`) match unprefixed lookups. Text split across several nodes is
245/// concatenated, and entities are decoded.
246///
247/// Returns `None` when the element is absent, and `Some("")` when it is present but
248/// empty — the same distinction `xmltree`'s `get_child(..).get_text()` drew, except
249/// that `get_text()` also returned `None` for a childless element. Callers here all
250/// funnel through `unwrap_or_default()`/`parse().ok()`, so the two are equivalent
251/// downstream.
252pub fn response_text(xml: &str, name: &str) -> Option<String> {
253 let mut reader = Reader::from_str(xml);
254 let mut depth_in_target: Option<usize> = None;
255 let mut text = String::new();
256
257 loop {
258 match reader.read_event() {
259 Ok(Event::Eof) | Err(_) => break,
260
261 Ok(Event::Start(start)) => {
262 match depth_in_target {
263 // Nested markup inside the argument. Its text is *not*
264 // collected (only direct text children count, as with
265 // `xmltree`'s `get_text`), but the depth must be tracked so
266 // its end tag does not terminate the search early.
267 Some(depth) => depth_in_target = Some(depth + 1),
268 None => {
269 if start.local_name().as_ref() == name.as_bytes() {
270 depth_in_target = Some(0);
271 }
272 }
273 }
274 }
275
276 Ok(Event::Empty(empty)) => {
277 // A self-closing match has no text content.
278 if depth_in_target.is_none() && empty.local_name().as_ref() == name.as_bytes() {
279 return Some(String::new());
280 }
281 }
282
283 Ok(Event::Text(raw)) => {
284 if depth_in_target == Some(0) {
285 if let Ok(decoded) = raw.unescape() {
286 text.push_str(&decoded);
287 }
288 }
289 }
290
291 Ok(Event::CData(raw)) => {
292 if depth_in_target == Some(0) {
293 text.push_str(&String::from_utf8_lossy(&raw));
294 }
295 }
296
297 Ok(Event::End(_)) => match depth_in_target {
298 Some(0) => return Some(text),
299 Some(depth) => depth_in_target = Some(depth - 1),
300 None => {}
301 },
302
303 _ => {}
304 }
305 }
306
307 None
308}
309
310/// Read a named response argument and parse it, falling back to the type's
311/// default when the argument is absent or unparseable.
312///
313/// This is the exact behavior of the old
314/// `get_child(..).get_text().parse().ok().unwrap_or_default()` chain that every
315/// macro-generated `parse_response` used, kept in one place.
316pub fn response_field<T: FromStr + Default>(xml: &str, name: &str) -> T {
317 response_text(xml, name)
318 .and_then(|s| s.parse().ok())
319 .unwrap_or_default()
320}
321
322/// Read a named response argument as an owned string, defaulting to empty.
323pub fn response_string(xml: &str, name: &str) -> String {
324 response_text(xml, name).unwrap_or_default()
325}
326
327/// Parse a Sonos UPnP boolean argument out of a SOAP response body.
328///
329/// Sonos devices return "0"/"1" for booleans, but Rust's `bool::parse()` only
330/// handles "true"/"false". This helper correctly parses "0", "1", "true", "false",
331/// and handles whitespace-padded variants.
332///
333/// Returns `false` if the argument is missing or empty.
334pub fn parse_sonos_bool(xml: &str, name: &str) -> bool {
335 response_text(xml, name)
336 .map(|s| s.trim() == "1" || s.trim().eq_ignore_ascii_case("true"))
337 .unwrap_or(false)
338}
339
340/// Escape XML special characters in a string for safe SOAP payload interpolation.
341///
342/// Replaces `&`, `<`, `>`, `"`, and `'` with their XML entity equivalents.
343///
344/// Delegates to `quick_xml::escape::escape`, whose predicate is exactly those five
345/// characters. Notably **not** `partial_escape`, which leaves `"` and `'` alone and
346/// would therefore be unsafe for values interpolated in attribute position.
347/// Whitespace is left verbatim: `escape`'s predicate never matches space or tab, so
348/// the numeric-reference arms inside quick-xml's shared `_escape` helper (which exist
349/// for `xs:list` delimiters) are unreachable from here. That matters because SOAP
350/// payloads carry track titles and URIs where ` ` would corrupt the value.
351pub fn xml_escape(s: &str) -> String {
352 quick_xml::escape::escape(s).into_owned()
353}
354
355/// Capitalize the first character of a snake_case field name.
356///
357/// Used by `define_operation_with_response!` to derive the UPnP element name for
358/// single-word request arguments (`channel` -> `Channel`).
359pub fn capitalize_first(s: &str) -> String {
360 let mut chars = s.chars();
361 match chars.next() {
362 None => String::new(),
363 Some(first) => first.to_uppercase().chain(chars).collect(),
364 }
365}
366
367/// Compile-time guard rejecting request field names whose UPnP element name cannot be
368/// derived by capitalizing the first character.
369///
370/// UPnP argument names come from each device's SCPD and use casing that snake_case
371/// does not preserve (`object_id` -> `ObjectID`, `enqueued_uri` -> `EnqueuedURI`).
372/// Capitalizing only the first character would emit `<Object_id>`, which devices
373/// reject. Multi-word request fields must therefore declare their element name via
374/// the `request_xml_mapping:` block; this function makes forgetting a compile error
375/// rather than a malformed request discovered at runtime.
376///
377/// # Panics
378///
379/// Panics (at compile time, when used in a `const` context) if `name` contains `_`.
380pub const fn assert_derivable_arg_name(name: &str) {
381 let bytes = name.as_bytes();
382 let mut i = 0;
383 while i < bytes.len() {
384 assert!(
385 bytes[i] != b'_',
386 "multi-word request field needs an explicit `request_xml_mapping:` entry: \
387 UPnP element casing cannot be derived from snake_case"
388 );
389 i += 1;
390 }
391}
392
393/// Validate a RenderingControl channel parameter.
394///
395/// Sonos speakers accept "Master", "LF" (left front), and "RF" (right front) channels.
396pub fn validate_channel(channel: &str) -> Result<(), ValidationError> {
397 match channel {
398 "Master" | "LF" | "RF" => Ok(()),
399 other => Err(ValidationError::Custom {
400 parameter: "channel".to_string(),
401 message: format!("Invalid channel '{other}'. Must be 'Master', 'LF', or 'RF'"),
402 }),
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn test_validation_error_creation() {
412 let error = ValidationError::range_error("volume", 0, 100, 150);
413 assert!(error.to_string().contains("volume"));
414 assert!(error.to_string().contains("150"));
415 assert!(error.to_string().contains("0..=100"));
416 }
417
418 #[test]
419 fn test_validation_level_default() {
420 assert_eq!(ValidationLevel::default(), ValidationLevel::Basic);
421 }
422
423 // Mock validation implementation for testing
424 struct TestRequest {
425 value: i32,
426 }
427
428 impl Validate for TestRequest {
429 fn validate_basic(&self) -> Result<(), ValidationError> {
430 if self.value < 0 || self.value > 100 {
431 Err(ValidationError::range_error("value", 0, 100, self.value))
432 } else {
433 Ok(())
434 }
435 }
436 }
437
438 #[test]
439 fn test_validation_levels() {
440 let valid_request = TestRequest { value: 50 };
441 assert!(valid_request.validate(ValidationLevel::None).is_ok());
442 assert!(valid_request.validate(ValidationLevel::Basic).is_ok());
443
444 let invalid_request = TestRequest { value: 150 };
445 assert!(invalid_request.validate(ValidationLevel::None).is_ok());
446 assert!(invalid_request.validate(ValidationLevel::Basic).is_err());
447
448 let negative_request = TestRequest { value: -10 };
449 assert!(negative_request.validate(ValidationLevel::None).is_ok());
450 assert!(negative_request.validate(ValidationLevel::Basic).is_err());
451 }
452
453 #[test]
454 fn test_xml_escape() {
455 assert_eq!(xml_escape("hello"), "hello");
456 assert_eq!(xml_escape("<script>"), "<script>");
457 assert_eq!(xml_escape("a&b"), "a&b");
458 assert_eq!(xml_escape("\"quoted\""), ""quoted"");
459 assert_eq!(xml_escape("it's"), "it's");
460 assert_eq!(
461 xml_escape("</CurrentURI><Injected>"),
462 "</CurrentURI><Injected>"
463 );
464 assert_eq!(xml_escape(""), "");
465 }
466
467 /// A realistic SOAP envelope: the argument is nested two levels deep and the
468 /// response element is namespace-prefixed.
469 const ENVELOPE: &str = r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
470 <s:Body>
471 <u:GetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
472 <CurrentVolume>42</CurrentVolume>
473 </u:GetVolumeResponse>
474 </s:Body>
475 </s:Envelope>"#;
476
477 #[test]
478 fn test_response_text_reads_nested_argument() {
479 assert_eq!(
480 response_text(ENVELOPE, "CurrentVolume").as_deref(),
481 Some("42")
482 );
483 assert_eq!(response_text(ENVELOPE, "NoSuchArgument"), None);
484 }
485
486 /// Namespace prefixes on the argument itself must not defeat the lookup;
487 /// matching is on the local name, as `xmltree`'s `get_child` was.
488 #[test]
489 fn test_response_text_ignores_namespace_prefix() {
490 let xml = r#"<s:Body><u:GetVolumeResponse><u:CurrentVolume>7</u:CurrentVolume></u:GetVolumeResponse></s:Body>"#;
491 assert_eq!(response_text(xml, "CurrentVolume").as_deref(), Some("7"));
492 }
493
494 /// An empty element is present-but-blank, distinct from absent.
495 #[test]
496 fn test_response_text_distinguishes_empty_from_absent() {
497 assert_eq!(response_text("<A><B></B></A>", "B").as_deref(), Some(""));
498 assert_eq!(response_text("<A><B/></A>", "B").as_deref(), Some(""));
499 assert_eq!(response_text("<A></A>", "B"), None);
500 }
501
502 /// Escaped entities are decoded: streaming URIs routinely arrive with `&`.
503 #[test]
504 fn test_response_text_unescapes_entities() {
505 let xml = "<A><CurrentURI>x-sonosapi-stream:s1?sid=254&flags=32</CurrentURI></A>";
506 assert_eq!(
507 response_text(xml, "CurrentURI").as_deref(),
508 Some("x-sonosapi-stream:s1?sid=254&flags=32")
509 );
510 }
511
512 /// CDATA is text too - devices wrap DIDL metadata this way.
513 #[test]
514 fn test_response_text_reads_cdata() {
515 let xml = "<A><Meta><![CDATA[<DIDL-Lite/>]]></Meta></A>";
516 assert_eq!(response_text(xml, "Meta").as_deref(), Some("<DIDL-Lite/>"));
517 }
518
519 /// The parse-with-default chain the macros generate.
520 #[test]
521 fn test_response_field_defaults_on_missing_or_unparseable() {
522 assert_eq!(response_field::<u8>(ENVELOPE, "CurrentVolume"), 42);
523 assert_eq!(response_field::<u8>(ENVELOPE, "Absent"), 0);
524 assert_eq!(response_field::<u8>("<A><B>not-a-number</B></A>", "B"), 0);
525 assert_eq!(response_field::<i8>("<A><B>-5</B></A>", "B"), -5);
526 }
527
528 #[test]
529 fn test_parse_sonos_bool_accepts_sonos_and_rust_spellings() {
530 assert!(parse_sonos_bool("<A><M>1</M></A>", "M"));
531 assert!(parse_sonos_bool("<A><M>true</M></A>", "M"));
532 assert!(parse_sonos_bool("<A><M> TRUE </M></A>", "M"));
533 assert!(!parse_sonos_bool("<A><M>0</M></A>", "M"));
534 assert!(!parse_sonos_bool("<A><M>false</M></A>", "M"));
535 // Absent or blank is false, not an error.
536 assert!(!parse_sonos_bool("<A></A>", "M"));
537 assert!(!parse_sonos_bool("<A><M></M></A>", "M"));
538 }
539
540 /// Malformed XML yields `None` rather than panicking; callers then fall back
541 /// to defaults exactly as they did when `xmltree` failed to build a DOM.
542 #[test]
543 fn test_response_text_on_malformed_xml() {
544 assert_eq!(response_text("<A><B>unclosed", "B"), None);
545 assert_eq!(response_text("", "B"), None);
546 }
547
548 /// `quick_xml::escape::escape` shares an internal helper with an `xs:list`
549 /// variant that maps space and tab to ` `/`	`. Those arms must stay
550 /// unreachable here: SOAP payloads carry track titles and URIs where escaped
551 /// whitespace would corrupt the value the device receives.
552 #[test]
553 fn test_xml_escape_leaves_whitespace_verbatim() {
554 assert_eq!(
555 xml_escape("Bohemian Rhapsody\t(Remastered 2011)"),
556 "Bohemian Rhapsody\t(Remastered 2011)"
557 );
558 }
559}