mod builder;
pub mod macros;
pub use builder::*;
use serde::{Deserialize, Serialize};
use xmltree::Element;
use crate::error::ApiError;
use crate::service::Service;
pub trait SonosOperation {
type Request: Serialize;
type Response: for<'de> Deserialize<'de>;
const SERVICE: Service;
const ACTION: &'static str;
fn build_payload(request: &Self::Request) -> String;
fn parse_response(xml: &Element) -> Result<Self::Response, ApiError>;
}
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("Parameter '{parameter}' value '{value}' is out of range ({min}..={max})")]
RangeError {
parameter: String,
value: String,
min: String,
max: String,
},
#[error("Parameter '{parameter}' value '{value}' is invalid: {reason}")]
InvalidValue {
parameter: String,
value: String,
reason: String,
},
#[error("Required parameter '{parameter}' is missing")]
MissingParameter { parameter: String },
#[error("Parameter '{parameter}' failed validation: {message}")]
Custom { parameter: String, message: String },
}
impl ValidationError {
pub fn range_error(
parameter: &str,
min: impl std::fmt::Display,
max: impl std::fmt::Display,
value: impl std::fmt::Display,
) -> Self {
Self::RangeError {
parameter: parameter.to_string(),
value: value.to_string(),
min: min.to_string(),
max: max.to_string(),
}
}
pub fn invalid_value(parameter: &str, value: impl std::fmt::Display) -> Self {
Self::InvalidValue {
parameter: parameter.to_string(),
value: value.to_string(),
reason: "invalid format or content".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ValidationLevel {
None,
#[default]
Basic,
}
pub trait Validate {
fn validate_basic(&self) -> Result<(), ValidationError> {
Ok(()) }
fn validate(&self, level: ValidationLevel) -> Result<(), ValidationError> {
match level {
ValidationLevel::None => Ok(()),
ValidationLevel::Basic => self.validate_basic(),
}
}
}
pub trait UPnPOperation {
type Request: Serialize + Validate;
type Response: for<'de> Deserialize<'de>;
const SERVICE: Service;
const ACTION: &'static str;
fn build_payload(request: &Self::Request) -> Result<String, ValidationError>;
fn parse_response(xml: &Element) -> Result<Self::Response, ApiError>;
fn dependencies() -> &'static [&'static str] {
&[]
}
fn can_batch_with<T: UPnPOperation>() -> bool {
true }
fn metadata() -> OperationMetadata {
OperationMetadata {
service: Self::SERVICE.name(),
action: Self::ACTION,
dependencies: Self::dependencies(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperationMetadata {
pub service: &'static str,
pub action: &'static str,
pub dependencies: &'static [&'static str],
}
pub fn parse_sonos_bool(xml: &Element, child_name: &str) -> bool {
xml.get_child(child_name)
.and_then(|e| e.get_text())
.map(|s| s.trim() == "1" || s.trim().eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
pub fn xml_escape(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => result.push_str("&"),
'<' => result.push_str("<"),
'>' => result.push_str(">"),
'"' => result.push_str("""),
'\'' => result.push_str("'"),
_ => result.push(c),
}
}
result
}
pub fn validate_channel(channel: &str) -> Result<(), ValidationError> {
match channel {
"Master" | "LF" | "RF" => Ok(()),
other => Err(ValidationError::Custom {
parameter: "channel".to_string(),
message: format!("Invalid channel '{other}'. Must be 'Master', 'LF', or 'RF'"),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validation_error_creation() {
let error = ValidationError::range_error("volume", 0, 100, 150);
assert!(error.to_string().contains("volume"));
assert!(error.to_string().contains("150"));
assert!(error.to_string().contains("0..=100"));
}
#[test]
fn test_validation_level_default() {
assert_eq!(ValidationLevel::default(), ValidationLevel::Basic);
}
struct TestRequest {
value: i32,
}
impl Validate for TestRequest {
fn validate_basic(&self) -> Result<(), ValidationError> {
if self.value < 0 || self.value > 100 {
Err(ValidationError::range_error("value", 0, 100, self.value))
} else {
Ok(())
}
}
}
#[test]
fn test_validation_levels() {
let valid_request = TestRequest { value: 50 };
assert!(valid_request.validate(ValidationLevel::None).is_ok());
assert!(valid_request.validate(ValidationLevel::Basic).is_ok());
let invalid_request = TestRequest { value: 150 };
assert!(invalid_request.validate(ValidationLevel::None).is_ok());
assert!(invalid_request.validate(ValidationLevel::Basic).is_err());
let negative_request = TestRequest { value: -10 };
assert!(negative_request.validate(ValidationLevel::None).is_ok());
assert!(negative_request.validate(ValidationLevel::Basic).is_err());
}
#[test]
fn test_xml_escape() {
assert_eq!(xml_escape("hello"), "hello");
assert_eq!(xml_escape("<script>"), "<script>");
assert_eq!(xml_escape("a&b"), "a&b");
assert_eq!(xml_escape("\"quoted\""), ""quoted"");
assert_eq!(xml_escape("it's"), "it's");
assert_eq!(
xml_escape("</CurrentURI><Injected>"),
"</CurrentURI><Injected>"
);
assert_eq!(xml_escape(""), "");
}
}