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: &str) -> 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: &str) -> Result<Self::Response, $crate::error::ApiError> {
197 $(let $xml_field = $crate::operation::response_field(xml, $xml_path);)*
198
199 Ok($response_struct {
200 $($resp_field: $xml_field,)*
201 })
202 }
203 }
204
205 // Generate convenience function
206 pub fn [<$op_struct:snake>]($($field: $field_type),*) -> $crate::operation::OperationBuilder<$op_struct> {
207 let request = [<$op_struct Request>] {
208 $($field,)*
209 instance_id: 0,
210 };
211 $crate::operation::OperationBuilder::new(request)
212 }
213 }
214 };
215
216 // Variant without explicit request element names: only single-word request
217 // fields are allowed, since their element name is just the capitalized field.
218 (
219 operation: $op_struct:ident,
220 action: $action:literal,
221 service: $service:ident,
222 request: {
223 $($field:ident: $field_type:ty),* $(,)?
224 },
225 response: $response_struct:ident {
226 $($resp_field:ident: $resp_type:ty),* $(,)?
227 },
228 xml_mapping: {
229 $($xml_field:ident: $xml_path:literal),* $(,)?
230 } $(,)?
231 ) => {
232 paste! {
233 #[derive(serde::Serialize, Clone, Debug, PartialEq)]
234 pub struct [<$op_struct Request>] {
235 $(pub $field: $field_type,)*
236 pub instance_id: u32,
237 }
238
239 // Note: Validate implementation can be provided manually if needed
240 // Default empty implementation is not generated to avoid conflicts
241
242 #[derive(serde::Deserialize, Debug, Clone, PartialEq)]
243 pub struct $response_struct {
244 $(pub $resp_field: $resp_type,)*
245 }
246
247 pub struct $op_struct;
248
249 impl $crate::operation::UPnPOperation for $op_struct {
250 type Request = [<$op_struct Request>];
251 type Response = $response_struct;
252
253 const SERVICE: $crate::service::Service = $crate::service::Service::$service;
254 const ACTION: &'static str = $action;
255
256 fn build_payload(request: &Self::Request) -> Result<String, $crate::operation::ValidationError> {
257 request.validate($crate::operation::ValidationLevel::Basic)?;
258
259 #[allow(unused_mut)]
260 let mut xml = format!("<InstanceID>{}</InstanceID>", request.instance_id);
261 $(
262 // Only single-word fields can have their UPnP element name derived
263 // by capitalizing the first character. Multi-word fields need
264 // `request_xml_mapping:` because UPnP casing (ObjectID, EnqueuedURI)
265 // is not recoverable from snake_case.
266 const _: () = $crate::operation::assert_derivable_arg_name(stringify!($field));
267 let capitalized = $crate::operation::capitalize_first(stringify!($field));
268 let escaped = $crate::operation::xml_escape(&format!("{}", request.$field));
269 xml.push_str(&format!("<{0}>{1}</{0}>", capitalized, escaped));
270 )*
271 Ok(xml)
272 }
273
274 fn parse_response(xml: &str) -> Result<Self::Response, $crate::error::ApiError> {
275 // Read each out-argument by its declared UPnP element name.
276 $(let $xml_field = $crate::operation::response_field(xml, $xml_path);)*
277
278 Ok($response_struct {
279 $($resp_field: $xml_field,)*
280 })
281 }
282 }
283
284 // Generate convenience function
285 pub fn [<$op_struct:snake>]($($field: $field_type),*) -> $crate::operation::OperationBuilder<$op_struct> {
286 let request = [<$op_struct Request>] {
287 $($field,)*
288 instance_id: 0,
289 };
290 $crate::operation::OperationBuilder::new(request)
291 }
292 }
293 };
294}
295
296#[cfg(test)]
297mod tests {
298 #[test]
299 fn test_macro_compilation() {
300 // Test that our macros compile without errors
301 // This is mainly a compilation test to ensure the macro syntax is correct
302
303 // Note: Actual usage tests would go in the services modules where the macros are used
304 // since we can't easily test macro expansion here without a more complex test setup
305 }
306}