Skip to main content

audd/
helpers.rs

1//! Pure helpers: longpoll category derivation, callback parsing, return-URL builder.
2//! No HTTP or SDK state.
3
4use serde_json::Value;
5use url::Url;
6
7use crate::errors::AudDError;
8use crate::models::{CallbackEvent, StreamCallbackMatch, StreamCallbackNotification};
9
10/// Compute the 9-char longpoll category locally from `(api_token, radio_id)`.
11///
12/// Formula (per docs.audd.io/streams.md): `MD5(MD5(api_token) || str(radio_id))[:9]`.
13#[must_use]
14pub fn derive_longpoll_category(api_token: &str, radio_id: i64) -> String {
15    let inner = format!("{:x}", md5::compute(api_token.as_bytes()));
16    let combined = format!("{inner}{radio_id}");
17    let outer = format!("{:x}", md5::compute(combined.as_bytes()));
18    outer[..9].to_string()
19}
20
21/// Parse raw callback POST bytes into a typed [`CallbackEvent`].
22///
23/// Recognition callbacks have an outer `result` block; lifecycle-notification
24/// callbacks have a `notification` block. The discriminator is by-key.
25///
26/// Web frameworks differ on how to extract the request body — `axum`,
27/// `actix-web`, `rocket`, `hyper`, etc. all expose it through different APIs.
28/// Rather than depend on any one of them, this function takes raw bytes that
29/// you've already extracted from your framework's request type. See the
30/// `streams_callback_handler` example for an end-to-end skeleton.
31///
32/// # Errors
33///
34/// Returns [`AudDError::Serialization`] if the body isn't valid JSON, doesn't
35/// match either shape, or has an empty `result.results` array.
36pub fn handle_callback(body: impl AsRef<[u8]>) -> Result<CallbackEvent, AudDError> {
37    let bytes = body.as_ref();
38    let value: Value = serde_json::from_slice(bytes).map_err(|e| AudDError::Serialization {
39        message: format!("callback body is not valid JSON: {e}"),
40        raw_text: String::from_utf8_lossy(bytes).into_owned(),
41    })?;
42    parse_callback(value)
43}
44
45/// Parse an already-deserialized JSON callback body into a typed
46/// [`CallbackEvent`].
47///
48/// Prefer [`handle_callback`] when you have the raw bytes from your framework
49/// — it surfaces the original payload in error messages. Use this entry point
50/// for unusual transports (queue consumers, replay tools).
51///
52/// # Errors
53///
54/// Returns [`AudDError::Serialization`] if the body doesn't match either the
55/// recognition or notification shape.
56pub fn parse_callback(body: Value) -> Result<CallbackEvent, AudDError> {
57    if let Some(notif_val) = body.get("notification").cloned() {
58        let mut notif: StreamCallbackNotification =
59            serde_json::from_value(notif_val).map_err(|e| AudDError::Serialization {
60                message: format!("callback notification: {e}"),
61                raw_text: body.to_string(),
62            })?;
63        notif.time = body.get("time").and_then(Value::as_i64);
64        notif.raw_response = body;
65        return Ok(CallbackEvent::Notification(notif));
66    }
67
68    if let Some(result_val) = body.get("result").cloned() {
69        let mut m: StreamCallbackMatch =
70            serde_json::from_value(result_val).map_err(|e| AudDError::Serialization {
71                message: format!("callback result: {e}"),
72                raw_text: body.to_string(),
73            })?;
74        m.raw_response = body;
75        return Ok(CallbackEvent::Match(m));
76    }
77
78    Err(AudDError::Serialization {
79        message: "callback body has neither `result` nor `notification`".into(),
80        raw_text: body.to_string(),
81    })
82}
83
84/// Append `?return=<metadata>` (or merge as `&return=`) to a callback URL.
85///
86/// If `return_metadata` is `None`, returns the URL unchanged. If the URL
87/// already carries a `return` query parameter, returns an
88/// [`AudDError::Api`] with [`ErrorKind::InvalidRequest`][crate::errors::ErrorKind::InvalidRequest]
89/// rather than silently overwriting.
90///
91/// # Errors
92///
93/// Returns [`AudDError::Source`] if the URL is unparseable, or
94/// [`AudDError::Api`] (synthetic invalid-request) if the URL already has a
95/// `return=` parameter.
96pub fn add_return_to_url(
97    url: &str,
98    return_metadata: Option<&[String]>,
99) -> Result<String, AudDError> {
100    let metadata = match return_metadata {
101        None => return Ok(url.to_string()),
102        Some(parts) if parts.is_empty() => return Ok(url.to_string()),
103        Some(parts) => parts.join(","),
104    };
105
106    let mut parsed = Url::parse(url)
107        .map_err(|e| AudDError::Source(format!("could not parse callback URL `{url}`: {e}")))?;
108    if parsed.query_pairs().any(|(k, _)| k == "return") {
109        return Err(duplicate_return_error());
110    }
111    parsed.query_pairs_mut().append_pair("return", &metadata);
112    Ok(parsed.to_string())
113}
114
115fn duplicate_return_error() -> AudDError {
116    use std::collections::HashMap;
117    AudDError::Api {
118        code: 0,
119        message: "URL already contains a `return` query parameter; pass return_metadata=None or remove the parameter from the URL — refusing to silently overwrite.".to_string(),
120        kind: crate::errors::ErrorKind::InvalidRequest,
121        http_status: 0,
122        request_id: None,
123        requested_params: HashMap::new(),
124        request_method: None,
125        branded_message: None,
126        raw_response: Value::Null,
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn derive_category_is_nine_hex_chars() {
136        let c = derive_longpoll_category("test", 7);
137        assert_eq!(c.len(), 9);
138        assert!(c.chars().all(|c| c.is_ascii_hexdigit()));
139    }
140
141    #[test]
142    fn derive_category_is_deterministic() {
143        let a = derive_longpoll_category("abc", 1);
144        let b = derive_longpoll_category("abc", 1);
145        assert_eq!(a, b);
146        let c = derive_longpoll_category("abc", 2);
147        assert_ne!(a, c);
148    }
149
150    #[test]
151    fn add_return_appends() {
152        let out = add_return_to_url(
153            "https://example.com/cb",
154            Some(&["apple_music".into(), "spotify".into()]),
155        )
156        .unwrap();
157        assert!(
158            out.contains("return=apple_music%2Cspotify")
159                || out.contains("return=apple_music,spotify"),
160            "got {out}"
161        );
162    }
163
164    #[test]
165    fn add_return_merges_with_existing_query() {
166        let out =
167            add_return_to_url("https://example.com/cb?utm=x", Some(&["spotify".into()])).unwrap();
168        assert!(out.contains("utm=x"));
169        assert!(out.contains("return=spotify"));
170    }
171
172    #[test]
173    fn add_return_rejects_duplicate() {
174        let err = add_return_to_url(
175            "https://example.com/cb?return=apple_music",
176            Some(&["spotify".into()]),
177        )
178        .unwrap_err();
179        assert!(err.is_invalid_request());
180    }
181
182    #[test]
183    fn add_return_no_metadata_passthrough() {
184        let out = add_return_to_url("https://example.com/cb", None).unwrap();
185        assert_eq!(out, "https://example.com/cb");
186    }
187
188    #[test]
189    fn parse_callback_match() {
190        let body = serde_json::json!({
191            "status": "success",
192            "result": {
193                "radio_id": 7,
194                "results": [{"artist": "X", "title": "Y", "score": 100}]
195            }
196        });
197        let ev = parse_callback(body).unwrap();
198        let m = ev.as_match().expect("should be a match");
199        assert_eq!(m.radio_id, 7);
200        assert_eq!(m.song.title, "Y");
201        assert!(m.alternatives.is_empty());
202    }
203
204    #[test]
205    fn parse_callback_notification() {
206        let body = serde_json::json!({
207            "status": "-",
208            "notification": {
209                "radio_id": 3,
210                "stream_running": false,
211                "notification_code": 650,
212                "notification_message": "x"
213            },
214            "time": 1
215        });
216        let ev = parse_callback(body).unwrap();
217        let n = ev.as_notification().expect("should be a notification");
218        assert_eq!(n.radio_id, 3);
219        assert_eq!(n.notification_code, 650);
220        assert_eq!(n.time, Some(1));
221    }
222
223    #[test]
224    fn handle_callback_parses_raw_bytes() {
225        let bytes = br#"{"result":{"radio_id":1,"results":[{"artist":"X","title":"Y","score":50}]}}"#;
226        let ev = handle_callback(bytes.as_slice()).unwrap();
227        assert_eq!(ev.as_match().unwrap().song.score, 50);
228    }
229
230    #[test]
231    fn handle_callback_invalid_json_is_serialization_error() {
232        let bytes = b"not json";
233        let err = handle_callback(bytes.as_slice()).unwrap_err();
234        match err {
235            AudDError::Serialization { message, raw_text } => {
236                assert!(message.contains("not valid JSON"));
237                assert_eq!(raw_text, "not json");
238            }
239            other => panic!("expected Serialization, got {other:?}"),
240        }
241    }
242
243    #[test]
244    fn parse_callback_neither_shape_errors() {
245        let body = serde_json::json!({"status": "success"});
246        let err = parse_callback(body).unwrap_err();
247        assert!(matches!(err, AudDError::Serialization { .. }));
248    }
249}