sonos_api/operation/macros.rs
1//! Declarative macros for UPnP operation and service definitions
2//!
3//! This module provides macros that dramatically reduce boilerplate when defining
4//! UPnP operations. Instead of manually implementing traits and structs, developers
5//! can use simple declarative syntax to generate all necessary code.
6
7/// Simplified macro for defining UPnP operations with minimal boilerplate
8///
9/// This macro generates all the necessary structs and trait implementations
10/// for a UPnP operation.
11///
12/// # Example
13/// ```rust,ignore
14/// define_upnp_operation! {
15/// operation: PlayOperation,
16/// action: "Play",
17/// service: AVTransport,
18/// request: {
19/// speed: String,
20/// },
21/// response: (),
22/// payload: |req| format!("<InstanceID>{}</InstanceID><Speed>{}</Speed>", req.instance_id, req.speed),
23/// parse: |_xml| Ok(()),
24/// }
25/// ```
26#[macro_export]
27macro_rules! define_upnp_operation {
28 (
29 operation: $op_struct:ident,
30 action: $action:literal,
31 service: $service:ident,
32 request: {
33 $($field:ident: $field_type:ty),* $(,)?
34 },
35 response: $response_type:ty,
36 payload: |$req_param:ident| $payload_expr:expr,
37 parse: |$xml_param:ident| $parse_expr:expr $(,)?
38 ) => {
39 paste! {
40 #[derive(serde::Serialize, Clone, Debug, PartialEq)]
41 pub struct [<$op_struct Request>] {
42 $(pub $field: $field_type,)*
43 pub instance_id: u32,
44 }
45
46 // Note: Validate implementation can be provided manually if needed
47 // Default empty implementation is not generated to avoid conflicts
48
49 #[derive(serde::Deserialize, Debug, Clone, PartialEq)]
50 pub struct [<$op_struct Response>];
51
52 pub struct $op_struct;
53
54 impl $crate::operation::UPnPOperation for $op_struct {
55 type Request = [<$op_struct Request>];
56 type Response = $response_type;
57
58 const SERVICE: $crate::service::Service = $crate::service::Service::$service;
59 const ACTION: &'static str = $action;
60
61 fn build_payload(request: &Self::Request) -> Result<String, $crate::operation::ValidationError> {
62 request.validate($crate::operation::ValidationLevel::Basic)?;
63 let $req_param = request;
64 Ok($payload_expr)
65 }
66
67 fn parse_response(xml: &xmltree::Element) -> Result<Self::Response, $crate::error::ApiError> {
68 let $xml_param = xml;
69 $parse_expr
70 }
71 }
72
73 // Generate convenience function
74 pub fn [<$op_struct:snake>]($($field: $field_type),*) -> $crate::operation::OperationBuilder<$op_struct> {
75 let request = [<$op_struct Request>] {
76 $($field,)*
77 instance_id: 0,
78 };
79 $crate::operation::OperationBuilder::new(request)
80 }
81 }
82 };
83}
84
85/// Macro for defining operations with XML response parsing
86///
87/// # Example
88/// ```rust,ignore
89/// define_operation_with_response! {
90/// operation: GetVolumeOperation,
91/// action: "GetVolume",
92/// service: RenderingControl,
93/// request: {
94/// channel: String,
95/// },
96/// response: GetVolumeResponse {
97/// current_volume: u8,
98/// },
99/// xml_mapping: {
100/// current_volume: "CurrentVolume",
101/// },
102/// }
103/// ```
104///
105/// # Request element names
106///
107/// UPnP argument names come from each device's SCPD and use casing that cannot be
108/// derived mechanically from snake_case (`ObjectID`, `EnqueuedURI`, `NumberOfTracks`).
109/// For single-word request fields the macro derives the element name by capitalizing
110/// the first character (`channel` -> `Channel`). Multi-word request fields **must**
111/// declare their element name explicitly via the optional `request_xml_mapping:` block,
112/// which is otherwise a compile error:
113///
114/// ```rust,ignore
115/// define_operation_with_response! {
116/// operation: SaveQueueOperation,
117/// action: "SaveQueue",
118/// service: AVTransport,
119/// request: {
120/// title: String,
121/// object_id: String,
122/// },
123/// response: SaveQueueResponse {
124/// assigned_object_id: String,
125/// },
126/// request_xml_mapping: {
127/// title: "Title",
128/// object_id: "ObjectID",
129/// },
130/// xml_mapping: {
131/// assigned_object_id: "AssignedObjectID",
132/// },
133/// }
134/// ```
135///
136/// When present, `request_xml_mapping:` must list every request field (enforced by an
137/// exhaustive destructuring of the generated request struct) and its order determines
138/// the order arguments are written to the SOAP body.
139#[macro_export]
140macro_rules! define_operation_with_response {
141 // Variant with explicit request element names.
142 (
143 operation: $op_struct:ident,
144 action: $action:literal,
145 service: $service:ident,
146 request: {
147 $($field:ident: $field_type:ty),* $(,)?
148 },
149 response: $response_struct:ident {
150 $($resp_field:ident: $resp_type:ty),* $(,)?
151 },
152 request_xml_mapping: {
153 $($req_field:ident: $req_xml_name:literal),* $(,)?
154 },
155 xml_mapping: {
156 $($xml_field:ident: $xml_path:literal),* $(,)?
157 } $(,)?
158 ) => {
159 paste! {
160 #[derive(serde::Serialize, Clone, Debug, PartialEq)]
161 pub struct [<$op_struct Request>] {
162 $(pub $field: $field_type,)*
163 pub instance_id: u32,
164 }
165
166 #[derive(serde::Deserialize, Debug, Clone, PartialEq)]
167 pub struct $response_struct {
168 $(pub $resp_field: $resp_type,)*
169 }
170
171 pub struct $op_struct;
172
173 impl $crate::operation::UPnPOperation for $op_struct {
174 type Request = [<$op_struct Request>];
175 type Response = $response_struct;
176
177 const SERVICE: $crate::service::Service = $crate::service::Service::$service;
178 const ACTION: &'static str = $action;
179
180 fn build_payload(request: &Self::Request) -> Result<String, $crate::operation::ValidationError> {
181 request.validate($crate::operation::ValidationLevel::Basic)?;
182
183 // Exhaustive destructuring: omitting a request field from
184 // `request_xml_mapping` fails to compile.
185 let [<$op_struct Request>] { $($req_field,)* instance_id } = request;
186
187 #[allow(unused_mut)]
188 let mut xml = format!("<InstanceID>{}</InstanceID>", instance_id);
189 $(
190 let escaped = $crate::operation::xml_escape(&format!("{}", $req_field));
191 xml.push_str(&format!("<{0}>{1}</{0}>", $req_xml_name, escaped));
192 )*
193 Ok(xml)
194 }
195
196 fn parse_response(xml: &xmltree::Element) -> Result<Self::Response, $crate::error::ApiError> {
197 $(let $xml_field = xml
198 .get_child($xml_path)
199 .and_then(|e| e.get_text())
200 .and_then(|s| s.parse().ok())
201 .unwrap_or_default();)*
202
203 Ok($response_struct {
204 $($resp_field: $xml_field,)*
205 })
206 }
207 }
208
209 // Generate convenience function
210 pub fn [<$op_struct:snake>]($($field: $field_type),*) -> $crate::operation::OperationBuilder<$op_struct> {
211 let request = [<$op_struct Request>] {
212 $($field,)*
213 instance_id: 0,
214 };
215 $crate::operation::OperationBuilder::new(request)
216 }
217 }
218 };
219
220 // Variant without explicit request element names: only single-word request
221 // fields are allowed, since their element name is just the capitalized field.
222 (
223 operation: $op_struct:ident,
224 action: $action:literal,
225 service: $service:ident,
226 request: {
227 $($field:ident: $field_type:ty),* $(,)?
228 },
229 response: $response_struct:ident {
230 $($resp_field:ident: $resp_type:ty),* $(,)?
231 },
232 xml_mapping: {
233 $($xml_field:ident: $xml_path:literal),* $(,)?
234 } $(,)?
235 ) => {
236 paste! {
237 #[derive(serde::Serialize, Clone, Debug, PartialEq)]
238 pub struct [<$op_struct Request>] {
239 $(pub $field: $field_type,)*
240 pub instance_id: u32,
241 }
242
243 // Note: Validate implementation can be provided manually if needed
244 // Default empty implementation is not generated to avoid conflicts
245
246 #[derive(serde::Deserialize, Debug, Clone, PartialEq)]
247 pub struct $response_struct {
248 $(pub $resp_field: $resp_type,)*
249 }
250
251 pub struct $op_struct;
252
253 impl $crate::operation::UPnPOperation for $op_struct {
254 type Request = [<$op_struct Request>];
255 type Response = $response_struct;
256
257 const SERVICE: $crate::service::Service = $crate::service::Service::$service;
258 const ACTION: &'static str = $action;
259
260 fn build_payload(request: &Self::Request) -> Result<String, $crate::operation::ValidationError> {
261 request.validate($crate::operation::ValidationLevel::Basic)?;
262
263 #[allow(unused_mut)]
264 let mut xml = format!("<InstanceID>{}</InstanceID>", request.instance_id);
265 $(
266 // Only single-word fields can have their UPnP element name derived
267 // by capitalizing the first character. Multi-word fields need
268 // `request_xml_mapping:` because UPnP casing (ObjectID, EnqueuedURI)
269 // is not recoverable from snake_case.
270 const _: () = $crate::operation::assert_derivable_arg_name(stringify!($field));
271 let capitalized = $crate::operation::capitalize_first(stringify!($field));
272 let escaped = $crate::operation::xml_escape(&format!("{}", request.$field));
273 xml.push_str(&format!("<{0}>{1}</{0}>", capitalized, escaped));
274 )*
275 Ok(xml)
276 }
277
278 fn parse_response(xml: &xmltree::Element) -> Result<Self::Response, $crate::error::ApiError> {
279 // Create a temporary mapping from field names to XML paths
280 $(let $xml_field = xml
281 .get_child($xml_path)
282 .and_then(|e| e.get_text())
283 .and_then(|s| s.parse().ok())
284 .unwrap_or_default();)*
285
286 Ok($response_struct {
287 $($resp_field: $xml_field,)*
288 })
289 }
290 }
291
292 // Generate convenience function
293 pub fn [<$op_struct:snake>]($($field: $field_type),*) -> $crate::operation::OperationBuilder<$op_struct> {
294 let request = [<$op_struct Request>] {
295 $($field,)*
296 instance_id: 0,
297 };
298 $crate::operation::OperationBuilder::new(request)
299 }
300 }
301 };
302}
303
304#[cfg(test)]
305mod tests {
306 #[test]
307 fn test_macro_compilation() {
308 // Test that our macros compile without errors
309 // This is mainly a compilation test to ensure the macro syntax is correct
310
311 // Note: Actual usage tests would go in the services modules where the macros are used
312 // since we can't easily test macro expansion here without a more complex test setup
313 }
314}