1use std::time::Duration;
4
5use {reqwest::Response, serde::de::DeserializeOwned, tokio::time::sleep};
6
7use crate::{
8 api::{
9 http_client::HttpClient,
10 response::{parse_response, timestamp},
11 },
12 errors::QobuzApiError::{self, DownloadError, RateLimitError, ResourceNotFoundError},
13 signing::sign_request,
14};
15
16macro_rules! signed_request {
18 (
19 $(#[$meta:meta])*
20 $vis:vis fn $name:ident($client:ident, $base_url:ident, $endpoint:ident, $params:ident, $auth:ident) -> $body:tt
21 ) => {
22 $(#[$meta])*
23 $vis async fn $name<T: DeserializeOwned>(
24 $client: &dyn HttpClient,
25 $base_url: &str,
26 $endpoint: &str,
27 $params: &mut Vec<(String, String)>,
28 $auth: &RequestAuth<'_>,
29 ) -> Result<T, QobuzApiError> $body
30 };
31}
32
33const MAX_RETRIES: u32 = 3;
35const BASE_BACKOFF_MS: u64 = 500;
37
38pub struct RequestAuth<'a> {
40 pub app_id: &'a str,
42 pub app_secret: &'a str,
44 pub user_auth_token: &'a str,
46}
47
48fn append_signature(
57 params: &mut Vec<(String, String)>,
58 method: &str,
59 endpoint: &str,
60 auth: &RequestAuth<'_>,
61) {
62 params.push(("app_id".to_string(), auth.app_id.to_string()));
63 params.push(("request_ts".to_string(), timestamp()));
64 let sig = sign_request(method, endpoint, params, auth.app_secret);
65 params.push(("request_sig".to_string(), sig));
66}
67
68async fn execute_post<T: DeserializeOwned>(
85 client: &dyn HttpClient,
86 base_url: &str,
87 endpoint: &str,
88 params: &[(String, String)],
89) -> Result<T, QobuzApiError> {
90 let url = format!("{base_url}{endpoint}");
91 let param_refs: Vec<(&str, &str)> = params
92 .iter()
93 .map(|(k, v)| (k.as_str(), v.as_str()))
94 .collect();
95
96 let response = client.post_form(&url, ¶m_refs).await?;
97
98 parse_response::<T>(response, endpoint).await
99}
100
101signed_request!(
102 pub fn signed_get(client, base_url, endpoint, params, auth) -> {
120 append_signature(params, "GET", endpoint, auth);
121
122 let url = build_url_with_params(base_url, endpoint, params);
123 let response = retry_with_backoff(client, &url, auth.user_auth_token).await?;
124
125 parse_response::<T>(response, endpoint).await
126 }
127);
128
129pub async fn post<T: DeserializeOwned>(
148 client: &dyn HttpClient,
149 base_url: &str,
150 endpoint: &str,
151 params: &mut Vec<(String, String)>,
152 app_id: &str,
153 user_auth_token: &str,
154) -> Result<T, QobuzApiError> {
155 params.push(("app_id".to_string(), app_id.to_string()));
156 if !user_auth_token.is_empty() {
157 params.push(("user_auth_token".to_string(), user_auth_token.to_string()));
158 }
159
160 execute_post::<T>(client, base_url, endpoint, params).await
161}
162
163signed_request!(
164 pub fn signed_post(client, base_url, endpoint, params, auth) -> {
182 params.push((
183 "user_auth_token".to_string(),
184 auth.user_auth_token.to_string(),
185 ));
186 append_signature(params, "POST", endpoint, auth);
187
188 execute_post::<T>(client, base_url, endpoint, params).await
189 }
190);
191
192pub async fn download_stream(
209 client: &dyn HttpClient,
210 url: &str,
211 token: &str,
212 range: Option<&str>,
213) -> Result<Response, QobuzApiError> {
214 let response = client.get_with_auth(url, token, range).await?;
215
216 let status = response.status();
217 if status.is_success() || status.as_u16() == 206 {
218 Ok(response)
219 } else if status.as_u16() == 404 {
220 Err(ResourceNotFoundError {
221 resource_type: "file".to_string(),
222 resource_id: url.to_string(),
223 })
224 } else {
225 Err(DownloadError {
226 message: format!("HTTP {status}"),
227 })
228 }
229}
230
231pub async fn retry_with_backoff(
247 client: &dyn HttpClient,
248 url: &str,
249 user_auth_token: &str,
250) -> Result<Response, QobuzApiError> {
251 let mut last_error: Option<QobuzApiError> = None;
252
253 for attempt in 0..=MAX_RETRIES {
254 let response = client.get_with_auth(url, user_auth_token, None).await?;
255
256 let status = response.status();
257 if status.as_u16() == 429 {
258 let delay = BASE_BACKOFF_MS * 2u64.pow(attempt);
259 sleep(Duration::from_millis(delay)).await;
260 last_error = Some(RateLimitError {
261 message: format!("Rate limited, retry {attempt}/{MAX_RETRIES}"),
262 });
263 continue;
264 }
265
266 return Ok(response);
267 }
268
269 Err(last_error.unwrap_or_else(|| RateLimitError {
270 message: "Rate limited: retries exhausted".to_string(),
271 }))
272}
273
274#[must_use]
286pub fn build_url_with_params(
287 base_url: &str,
288 endpoint: &str,
289 params: &[(String, String)],
290) -> String {
291 let mut url = format!("{base_url}{endpoint}");
292 if !params.is_empty() {
293 let query: String = params
294 .iter()
295 .map(|(k, v)| format!("{}={}", urlencoding(k), urlencoding(v)))
296 .collect::<Vec<_>>()
297 .join("&");
298 url.push('?');
299 url.push_str(&query);
300 }
301 url
302}
303
304fn urlencoding(s: &str) -> String {
314 s.chars()
315 .map(|c| match c {
316 ' ' => "+".to_string(),
317 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
318 _ => format!("%{:02X}", c as u32),
319 })
320 .collect()
321}
322
323#[cfg(test)]
324mod tests {
325 use {
326 anyhow::{Result, anyhow, ensure},
327 reqwest::Response,
328 tokio::runtime::Runtime,
329 };
330
331 use crate::{
332 api::{
333 requests::retry_with_backoff,
334 service::QobuzApiService,
335 test_support::{SequentialMockServer, make_service},
336 },
337 errors::QobuzApiError,
338 };
339
340 fn rate_limit_response() -> (u16, String) {
341 (
342 429,
343 r#"{"status":"error","message":"rate limited"}"#.to_string(),
344 )
345 }
346
347 fn make_test_request(service: &QobuzApiService) -> Result<Response, QobuzApiError> {
348 let rt = Runtime::new()?;
349 let client = service.http_client();
350 rt.block_on(retry_with_backoff(
351 client,
352 &format!("{}/test", service.base_url()),
353 "token",
354 ))
355 }
356
357 #[test]
358 fn rate_limit_retry_exhausts_retries() -> Result<()> {
359 let server = SequentialMockServer::start(vec![
360 rate_limit_response(),
361 rate_limit_response(),
362 rate_limit_response(),
363 rate_limit_response(),
364 ])?;
365 let service = make_service(&server.base_url())?;
366 let result = make_test_request(&service);
367 let err = result.err().ok_or_else(|| anyhow!("expected error"))?;
368 ensure!(format!("{err}").contains("Rate limited"));
369 Ok(())
370 }
371
372 #[test]
373 fn rate_limit_retry_succeeds_after_backoff() -> Result<()> {
374 let server = SequentialMockServer::start(vec![
375 rate_limit_response(),
376 rate_limit_response(),
377 (
378 200,
379 r#"{"url":"https://example.com/file.flac"}"#.to_string(),
380 ),
381 ])?;
382 let service = make_service(&server.base_url())?;
383 let result = make_test_request(&service)?;
384 ensure!(result.status().is_success());
385 Ok(())
386 }
387}