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("&"),
258 '<' => result.push_str("<"),
259 '>' => result.push_str(">"),
260 '"' => result.push_str("""),
261 '\'' => result.push_str("'"),
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>"), "<script>");
332 assert_eq!(xml_escape("a&b"), "a&b");
333 assert_eq!(xml_escape("\"quoted\""), ""quoted"");
334 assert_eq!(xml_escape("it's"), "it's");
335 assert_eq!(
336 xml_escape("</CurrentURI><Injected>"),
337 "</CurrentURI><Injected>"
338 );
339 assert_eq!(xml_escape(""), "");
340 }
341}