Skip to main content

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 `&#32;` 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>"), "&lt;script&gt;");
457        assert_eq!(xml_escape("a&b"), "a&amp;b");
458        assert_eq!(xml_escape("\"quoted\""), "&quot;quoted&quot;");
459        assert_eq!(xml_escape("it's"), "it&apos;s");
460        assert_eq!(
461            xml_escape("</CurrentURI><Injected>"),
462            "&lt;/CurrentURI&gt;&lt;Injected&gt;"
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 `&amp;`.
503    #[test]
504    fn test_response_text_unescapes_entities() {
505        let xml = "<A><CurrentURI>x-sonosapi-stream:s1?sid=254&amp;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 `&#32;`/`&#9;`. 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}