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 serde::{Deserialize, Serialize};
16use xmltree::Element;
17
18use crate::error::ApiError;
19use crate::service::Service;
20
21/// Base trait for all Sonos API operations (LEGACY)
22///
23/// This trait defines the common interface that all Sonos UPnP operations must implement.
24/// It provides type safety through associated types and ensures consistent patterns
25/// for request/response handling across all operations.
26///
27/// **Note**: This is the legacy trait. New code should use `UPnPOperation` instead.
28pub trait SonosOperation {
29    /// The request type for this operation, must be serializable
30    type Request: Serialize;
31
32    /// The response type for this operation, must be deserializable
33    type Response: for<'de> Deserialize<'de>;
34
35    /// The UPnP service this operation belongs to
36    const SERVICE: Service;
37
38    /// The SOAP action name for this operation
39    const ACTION: &'static str;
40
41    /// Build the SOAP payload from the request data
42    ///
43    /// This method should construct the XML payload that goes inside the SOAP envelope.
44    /// The payload should contain all the parameters needed for the UPnP action.
45    ///
46    /// # Arguments
47    /// * `request` - The typed request data
48    ///
49    /// # Returns
50    /// A string containing the XML payload (without SOAP envelope)
51    fn build_payload(request: &Self::Request) -> String;
52
53    /// Parse the SOAP response XML into the typed response
54    ///
55    /// This method extracts the relevant data from the SOAP response XML and
56    /// converts it into the strongly-typed response structure.
57    ///
58    /// # Arguments
59    /// * `xml` - The parsed XML element containing the response data
60    ///
61    /// # Returns
62    /// The typed response data or an error if parsing fails
63    fn parse_response(xml: &Element) -> Result<Self::Response, ApiError>;
64}
65
66/// Validation error types
67#[derive(Debug, thiserror::Error)]
68pub enum ValidationError {
69    #[error("Parameter '{parameter}' value '{value}' is out of range ({min}..={max})")]
70    RangeError {
71        parameter: String,
72        value: String,
73        min: String,
74        max: String,
75    },
76
77    #[error("Parameter '{parameter}' value '{value}' is invalid: {reason}")]
78    InvalidValue {
79        parameter: String,
80        value: String,
81        reason: String,
82    },
83
84    #[error("Required parameter '{parameter}' is missing")]
85    MissingParameter { parameter: String },
86
87    #[error("Parameter '{parameter}' failed validation: {message}")]
88    Custom { parameter: String, message: String },
89}
90
91impl ValidationError {
92    pub fn range_error(
93        parameter: &str,
94        min: impl std::fmt::Display,
95        max: impl std::fmt::Display,
96        value: impl std::fmt::Display,
97    ) -> Self {
98        Self::RangeError {
99            parameter: parameter.to_string(),
100            value: value.to_string(),
101            min: min.to_string(),
102            max: max.to_string(),
103        }
104    }
105
106    pub fn invalid_value(parameter: &str, value: impl std::fmt::Display) -> Self {
107        Self::InvalidValue {
108            parameter: parameter.to_string(),
109            value: value.to_string(),
110            reason: "invalid format or content".to_string(),
111        }
112    }
113}
114
115/// Validation levels for operation parameters
116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
117pub enum ValidationLevel {
118    /// No validation - maximum performance
119    None,
120    /// Basic validation - type and range checks
121    #[default]
122    Basic,
123}
124
125/// Trait for types that can be validated
126pub trait Validate {
127    /// Perform basic validation
128    ///
129    /// This should include type checks and range validation
130    /// to fail fast on obviously invalid input.
131    fn validate_basic(&self) -> Result<(), ValidationError> {
132        Ok(()) // Default: no validation
133    }
134
135    /// Validate with the specified level
136    fn validate(&self, level: ValidationLevel) -> Result<(), ValidationError> {
137        match level {
138            ValidationLevel::None => Ok(()),
139            ValidationLevel::Basic => self.validate_basic(),
140        }
141    }
142}
143
144/// Enhanced UPnP operation trait with composability support
145///
146/// This trait extends the original SonosOperation concept with:
147/// - Composability: operations can be chained, batched, or made conditional
148/// - Validation: flexible validation strategy with boundary and comprehensive levels
149/// - Dependencies: operations can declare dependencies on other operations
150/// - Batching: operations can indicate whether they can be batched with others
151pub trait UPnPOperation {
152    /// The request type for this operation, must be serializable and validatable
153    type Request: Serialize + Validate;
154
155    /// The response type for this operation, must be deserializable
156    type Response: for<'de> Deserialize<'de>;
157
158    /// The UPnP service this operation belongs to
159    const SERVICE: Service;
160
161    /// The SOAP action name for this operation
162    const ACTION: &'static str;
163
164    /// Build the SOAP payload from the request data with validation
165    ///
166    /// This method validates the request according to the validation level
167    /// and then constructs the XML payload for the SOAP envelope.
168    ///
169    /// # Arguments
170    /// * `request` - The typed request data
171    ///
172    /// # Returns
173    /// A string containing the XML payload or a validation error
174    fn build_payload(request: &Self::Request) -> Result<String, ValidationError>;
175
176    /// Parse the SOAP response XML into the typed response
177    ///
178    /// This method extracts the relevant data from the SOAP response XML and
179    /// converts it into the strongly-typed response structure.
180    ///
181    /// # Arguments
182    /// * `xml` - The parsed XML element containing the response data
183    ///
184    /// # Returns
185    /// The typed response data or an error if parsing fails
186    fn parse_response(xml: &Element) -> Result<Self::Response, ApiError>;
187
188    /// Get the list of operations this operation depends on
189    ///
190    /// This is used for operation ordering and dependency resolution
191    /// in batch and sequence operations.
192    ///
193    /// # Returns
194    /// A slice of action names that must be executed before this operation
195    fn dependencies() -> &'static [&'static str] {
196        &[]
197    }
198
199    /// Check if this operation can be batched with another operation
200    ///
201    /// Some operations may have conflicts or dependencies that prevent
202    /// them from being executed in parallel.
203    ///
204    /// # Type Parameters
205    /// * `T` - Another UPnP operation type to check compatibility with
206    ///
207    /// # Returns
208    /// True if the operations can be safely executed in parallel
209    fn can_batch_with<T: UPnPOperation>() -> bool {
210        true // Default: most operations can be batched
211    }
212
213    /// Get human-readable operation metadata
214    ///
215    /// This is useful for debugging, logging, and SDK development
216    fn metadata() -> OperationMetadata {
217        OperationMetadata {
218            service: Self::SERVICE.name(),
219            action: Self::ACTION,
220            dependencies: Self::dependencies(),
221        }
222    }
223}
224
225/// Metadata about a UPnP operation
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct OperationMetadata {
228    /// The service name (e.g., "AVTransport")
229    pub service: &'static str,
230    /// The action name (e.g., "Play")
231    pub action: &'static str,
232    /// List of operations this operation depends on
233    pub dependencies: &'static [&'static str],
234}
235
236/// Parse a Sonos UPnP boolean value from an XML response element.
237///
238/// Sonos devices return "0"/"1" for booleans, but Rust's `bool::parse()` only
239/// handles "true"/"false". This helper correctly parses "0", "1", "true", "false",
240/// and handles whitespace-padded variants.
241///
242/// Returns `false` if the child element is missing or empty.
243pub fn parse_sonos_bool(xml: &Element, child_name: &str) -> bool {
244    xml.get_child(child_name)
245        .and_then(|e| e.get_text())
246        .map(|s| s.trim() == "1" || s.trim().eq_ignore_ascii_case("true"))
247        .unwrap_or(false)
248}
249
250/// Escape XML special characters in a string for safe SOAP payload interpolation.
251///
252/// Replaces `&`, `<`, `>`, `"`, and `'` with their XML entity equivalents.
253pub fn xml_escape(s: &str) -> String {
254    let mut result = String::with_capacity(s.len());
255    for c in s.chars() {
256        match c {
257            '&' => result.push_str("&amp;"),
258            '<' => result.push_str("&lt;"),
259            '>' => result.push_str("&gt;"),
260            '"' => result.push_str("&quot;"),
261            '\'' => result.push_str("&apos;"),
262            _ => result.push(c),
263        }
264    }
265    result
266}
267
268/// Validate a RenderingControl channel parameter.
269///
270/// Sonos speakers accept "Master", "LF" (left front), and "RF" (right front) channels.
271pub fn validate_channel(channel: &str) -> Result<(), ValidationError> {
272    match channel {
273        "Master" | "LF" | "RF" => Ok(()),
274        other => Err(ValidationError::Custom {
275            parameter: "channel".to_string(),
276            message: format!("Invalid channel '{other}'. Must be 'Master', 'LF', or 'RF'"),
277        }),
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_validation_error_creation() {
287        let error = ValidationError::range_error("volume", 0, 100, 150);
288        assert!(error.to_string().contains("volume"));
289        assert!(error.to_string().contains("150"));
290        assert!(error.to_string().contains("0..=100"));
291    }
292
293    #[test]
294    fn test_validation_level_default() {
295        assert_eq!(ValidationLevel::default(), ValidationLevel::Basic);
296    }
297
298    // Mock validation implementation for testing
299    struct TestRequest {
300        value: i32,
301    }
302
303    impl Validate for TestRequest {
304        fn validate_basic(&self) -> Result<(), ValidationError> {
305            if self.value < 0 || self.value > 100 {
306                Err(ValidationError::range_error("value", 0, 100, self.value))
307            } else {
308                Ok(())
309            }
310        }
311    }
312
313    #[test]
314    fn test_validation_levels() {
315        let valid_request = TestRequest { value: 50 };
316        assert!(valid_request.validate(ValidationLevel::None).is_ok());
317        assert!(valid_request.validate(ValidationLevel::Basic).is_ok());
318
319        let invalid_request = TestRequest { value: 150 };
320        assert!(invalid_request.validate(ValidationLevel::None).is_ok());
321        assert!(invalid_request.validate(ValidationLevel::Basic).is_err());
322
323        let negative_request = TestRequest { value: -10 };
324        assert!(negative_request.validate(ValidationLevel::None).is_ok());
325        assert!(negative_request.validate(ValidationLevel::Basic).is_err());
326    }
327
328    #[test]
329    fn test_xml_escape() {
330        assert_eq!(xml_escape("hello"), "hello");
331        assert_eq!(xml_escape("<script>"), "&lt;script&gt;");
332        assert_eq!(xml_escape("a&b"), "a&amp;b");
333        assert_eq!(xml_escape("\"quoted\""), "&quot;quoted&quot;");
334        assert_eq!(xml_escape("it's"), "it&apos;s");
335        assert_eq!(
336            xml_escape("</CurrentURI><Injected>"),
337            "&lt;/CurrentURI&gt;&lt;Injected&gt;"
338        );
339        assert_eq!(xml_escape(""), "");
340    }
341}