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/// Capitalize the first character of a snake_case field name.
269///
270/// Used by `define_operation_with_response!` to derive the UPnP element name for
271/// single-word request arguments (`channel` -> `Channel`).
272pub fn capitalize_first(s: &str) -> String {
273    let mut chars = s.chars();
274    match chars.next() {
275        None => String::new(),
276        Some(first) => first.to_uppercase().chain(chars).collect(),
277    }
278}
279
280/// Compile-time guard rejecting request field names whose UPnP element name cannot be
281/// derived by capitalizing the first character.
282///
283/// UPnP argument names come from each device's SCPD and use casing that snake_case
284/// does not preserve (`object_id` -> `ObjectID`, `enqueued_uri` -> `EnqueuedURI`).
285/// Capitalizing only the first character would emit `<Object_id>`, which devices
286/// reject. Multi-word request fields must therefore declare their element name via
287/// the `request_xml_mapping:` block; this function makes forgetting a compile error
288/// rather than a malformed request discovered at runtime.
289///
290/// # Panics
291///
292/// Panics (at compile time, when used in a `const` context) if `name` contains `_`.
293pub const fn assert_derivable_arg_name(name: &str) {
294    let bytes = name.as_bytes();
295    let mut i = 0;
296    while i < bytes.len() {
297        assert!(
298            bytes[i] != b'_',
299            "multi-word request field needs an explicit `request_xml_mapping:` entry: \
300             UPnP element casing cannot be derived from snake_case"
301        );
302        i += 1;
303    }
304}
305
306/// Validate a RenderingControl channel parameter.
307///
308/// Sonos speakers accept "Master", "LF" (left front), and "RF" (right front) channels.
309pub fn validate_channel(channel: &str) -> Result<(), ValidationError> {
310    match channel {
311        "Master" | "LF" | "RF" => Ok(()),
312        other => Err(ValidationError::Custom {
313            parameter: "channel".to_string(),
314            message: format!("Invalid channel '{other}'. Must be 'Master', 'LF', or 'RF'"),
315        }),
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_validation_error_creation() {
325        let error = ValidationError::range_error("volume", 0, 100, 150);
326        assert!(error.to_string().contains("volume"));
327        assert!(error.to_string().contains("150"));
328        assert!(error.to_string().contains("0..=100"));
329    }
330
331    #[test]
332    fn test_validation_level_default() {
333        assert_eq!(ValidationLevel::default(), ValidationLevel::Basic);
334    }
335
336    // Mock validation implementation for testing
337    struct TestRequest {
338        value: i32,
339    }
340
341    impl Validate for TestRequest {
342        fn validate_basic(&self) -> Result<(), ValidationError> {
343            if self.value < 0 || self.value > 100 {
344                Err(ValidationError::range_error("value", 0, 100, self.value))
345            } else {
346                Ok(())
347            }
348        }
349    }
350
351    #[test]
352    fn test_validation_levels() {
353        let valid_request = TestRequest { value: 50 };
354        assert!(valid_request.validate(ValidationLevel::None).is_ok());
355        assert!(valid_request.validate(ValidationLevel::Basic).is_ok());
356
357        let invalid_request = TestRequest { value: 150 };
358        assert!(invalid_request.validate(ValidationLevel::None).is_ok());
359        assert!(invalid_request.validate(ValidationLevel::Basic).is_err());
360
361        let negative_request = TestRequest { value: -10 };
362        assert!(negative_request.validate(ValidationLevel::None).is_ok());
363        assert!(negative_request.validate(ValidationLevel::Basic).is_err());
364    }
365
366    #[test]
367    fn test_xml_escape() {
368        assert_eq!(xml_escape("hello"), "hello");
369        assert_eq!(xml_escape("<script>"), "&lt;script&gt;");
370        assert_eq!(xml_escape("a&b"), "a&amp;b");
371        assert_eq!(xml_escape("\"quoted\""), "&quot;quoted&quot;");
372        assert_eq!(xml_escape("it's"), "it&apos;s");
373        assert_eq!(
374            xml_escape("</CurrentURI><Injected>"),
375            "&lt;/CurrentURI&gt;&lt;Injected&gt;"
376        );
377        assert_eq!(xml_escape(""), "");
378    }
379}