mod builder;
pub mod macros;
pub use builder::*;
use quick_xml::events::Event;
use quick_xml::Reader;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
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: &str) -> 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: &str) -> 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 response_text(xml: &str, name: &str) -> Option<String> {
let mut reader = Reader::from_str(xml);
let mut depth_in_target: Option<usize> = None;
let mut text = String::new();
loop {
match reader.read_event() {
Ok(Event::Eof) | Err(_) => break,
Ok(Event::Start(start)) => {
match depth_in_target {
Some(depth) => depth_in_target = Some(depth + 1),
None => {
if start.local_name().as_ref() == name.as_bytes() {
depth_in_target = Some(0);
}
}
}
}
Ok(Event::Empty(empty)) => {
if depth_in_target.is_none() && empty.local_name().as_ref() == name.as_bytes() {
return Some(String::new());
}
}
Ok(Event::Text(raw)) => {
if depth_in_target == Some(0) {
if let Ok(decoded) = raw.unescape() {
text.push_str(&decoded);
}
}
}
Ok(Event::CData(raw)) => {
if depth_in_target == Some(0) {
text.push_str(&String::from_utf8_lossy(&raw));
}
}
Ok(Event::End(_)) => match depth_in_target {
Some(0) => return Some(text),
Some(depth) => depth_in_target = Some(depth - 1),
None => {}
},
_ => {}
}
}
None
}
pub fn response_field<T: FromStr + Default>(xml: &str, name: &str) -> T {
response_text(xml, name)
.and_then(|s| s.parse().ok())
.unwrap_or_default()
}
pub fn response_string(xml: &str, name: &str) -> String {
response_text(xml, name).unwrap_or_default()
}
pub fn parse_sonos_bool(xml: &str, name: &str) -> bool {
response_text(xml, name)
.map(|s| s.trim() == "1" || s.trim().eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
pub fn xml_escape(s: &str) -> String {
quick_xml::escape::escape(s).into_owned()
}
pub fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
}
pub const fn assert_derivable_arg_name(name: &str) {
let bytes = name.as_bytes();
let mut i = 0;
while i < bytes.len() {
assert!(
bytes[i] != b'_',
"multi-word request field needs an explicit `request_xml_mapping:` entry: \
UPnP element casing cannot be derived from snake_case"
);
i += 1;
}
}
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(""), "");
}
const ENVELOPE: &str = r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
<CurrentVolume>42</CurrentVolume>
</u:GetVolumeResponse>
</s:Body>
</s:Envelope>"#;
#[test]
fn test_response_text_reads_nested_argument() {
assert_eq!(
response_text(ENVELOPE, "CurrentVolume").as_deref(),
Some("42")
);
assert_eq!(response_text(ENVELOPE, "NoSuchArgument"), None);
}
#[test]
fn test_response_text_ignores_namespace_prefix() {
let xml = r#"<s:Body><u:GetVolumeResponse><u:CurrentVolume>7</u:CurrentVolume></u:GetVolumeResponse></s:Body>"#;
assert_eq!(response_text(xml, "CurrentVolume").as_deref(), Some("7"));
}
#[test]
fn test_response_text_distinguishes_empty_from_absent() {
assert_eq!(response_text("<A><B></B></A>", "B").as_deref(), Some(""));
assert_eq!(response_text("<A><B/></A>", "B").as_deref(), Some(""));
assert_eq!(response_text("<A></A>", "B"), None);
}
#[test]
fn test_response_text_unescapes_entities() {
let xml = "<A><CurrentURI>x-sonosapi-stream:s1?sid=254&flags=32</CurrentURI></A>";
assert_eq!(
response_text(xml, "CurrentURI").as_deref(),
Some("x-sonosapi-stream:s1?sid=254&flags=32")
);
}
#[test]
fn test_response_text_reads_cdata() {
let xml = "<A><Meta><![CDATA[<DIDL-Lite/>]]></Meta></A>";
assert_eq!(response_text(xml, "Meta").as_deref(), Some("<DIDL-Lite/>"));
}
#[test]
fn test_response_field_defaults_on_missing_or_unparseable() {
assert_eq!(response_field::<u8>(ENVELOPE, "CurrentVolume"), 42);
assert_eq!(response_field::<u8>(ENVELOPE, "Absent"), 0);
assert_eq!(response_field::<u8>("<A><B>not-a-number</B></A>", "B"), 0);
assert_eq!(response_field::<i8>("<A><B>-5</B></A>", "B"), -5);
}
#[test]
fn test_parse_sonos_bool_accepts_sonos_and_rust_spellings() {
assert!(parse_sonos_bool("<A><M>1</M></A>", "M"));
assert!(parse_sonos_bool("<A><M>true</M></A>", "M"));
assert!(parse_sonos_bool("<A><M> TRUE </M></A>", "M"));
assert!(!parse_sonos_bool("<A><M>0</M></A>", "M"));
assert!(!parse_sonos_bool("<A><M>false</M></A>", "M"));
assert!(!parse_sonos_bool("<A></A>", "M"));
assert!(!parse_sonos_bool("<A><M></M></A>", "M"));
}
#[test]
fn test_response_text_on_malformed_xml() {
assert_eq!(response_text("<A><B>unclosed", "B"), None);
assert_eq!(response_text("", "B"), None);
}
#[test]
fn test_xml_escape_leaves_whitespace_verbatim() {
assert_eq!(
xml_escape("Bohemian Rhapsody\t(Remastered 2011)"),
"Bohemian Rhapsody\t(Remastered 2011)"
);
}
}