Skip to main content

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}