Skip to main content

qobuz_api/api/
requests.rs

1//! HTTP request primitives: GET, POST, signed GET, response parsing, retry-with-backoff.
2
3use 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
16/// Generates a signed request function with the standard parameter list.
17macro_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
33/// Maximum number of retry attempts on rate limiting.
34const MAX_RETRIES: u32 = 3;
35/// Base delay in milliseconds for exponential backoff.
36const BASE_BACKOFF_MS: u64 = 500;
37
38/// Bundles application credentials and user token for signed API requests.
39pub struct RequestAuth<'a> {
40    /// Application ID.
41    pub app_id: &'a str,
42    /// Application secret for request signing.
43    pub app_secret: &'a str,
44    /// User authentication token.
45    pub user_auth_token: &'a str,
46}
47
48/// Appends `app_id`, `request_ts`, and `request_sig` to params for request signing.
49///
50/// # Arguments
51///
52/// * `params` - Parameter vector to modify
53/// * `method` - HTTP method (e.g., `"GET"`, `"POST"`)
54/// * `endpoint` - API endpoint path
55/// * `auth` - Application credentials for signing
56fn 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
68/// Executes a POST request with form parameters and parses the response.
69///
70/// # Arguments
71///
72/// * `client` - HTTP client implementation
73/// * `base_url` - API base URL
74/// * `endpoint` - API endpoint path
75/// * `params` - Key-value form parameters
76///
77/// # Returns
78///
79/// Parsed JSON response of type `T`.
80///
81/// # Errors
82///
83/// Returns a `QobuzApiError` on HTTP failures or JSON parse errors.
84async 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, &param_refs).await?;
97
98    parse_response::<T>(response, endpoint).await
99}
100
101signed_request!(
102    /// Sends a signed GET request and parses the JSON response.
103    ///
104    /// # Arguments
105    ///
106    /// * `client` - HTTP client implementation
107    /// * `base_url` - API base URL
108    /// * `endpoint` - API endpoint path (e.g., `"/album/search"`)
109    /// * `params` - Key-value parameter pairs (will be sorted for signing)
110    /// * `auth` - Application credentials and user authentication token
111    ///
112    /// # Returns
113    ///
114    /// Parsed JSON response of type `T`.
115    ///
116    /// # Errors
117    ///
118    /// Returns a `QobuzApiError` on HTTP failures, rate limiting, or JSON parse errors.
119    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
129/// Sends a POST request with form parameters and parses the JSON response.
130///
131/// # Arguments
132///
133/// * `client` - HTTP client implementation
134/// * `base_url` - API base URL (e.g., `"https://www.qobuz.com/api.json/0.2"`)
135/// * `endpoint` - API endpoint path
136/// * `params` - Key-value form parameters
137/// * `app_id` - Application ID
138/// * `user_auth_token` - User authentication token (may be empty for login)
139///
140/// # Returns
141///
142/// Parsed JSON response of type `T`.
143///
144/// # Errors
145///
146/// Returns a `QobuzApiError` on HTTP failures or JSON parse errors.
147pub 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    /// Sends a signed POST request with form parameters and parses the JSON response.
165    ///
166    /// # Arguments
167    ///
168    /// * `client` - HTTP client implementation
169    /// * `base_url` - API base URL
170    /// * `endpoint` - API endpoint path
171    /// * `params` - Key-value form parameters (will be sorted for signing)
172    /// * `auth` - Application credentials and user authentication token
173    ///
174    /// # Returns
175    ///
176    /// Parsed JSON response of type `T`.
177    ///
178    /// # Errors
179    ///
180    /// Returns a `QobuzApiError` on HTTP failures, rate limiting, or JSON parse errors.
181    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
192/// Sends a GET request to an authenticated endpoint for binary download.
193///
194/// # Arguments
195///
196/// * `client` - HTTP client implementation
197/// * `url` - Full download URL
198/// * `token` - User auth token
199/// * `range` - Optional Range header value (e.g., `"bytes=1024-"`)
200///
201/// # Returns
202///
203/// The raw `reqwest::Response` for streaming.
204///
205/// # Errors
206///
207/// Returns a `QobuzApiError` on HTTP failures, 404, or non-success status codes.
208pub 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
231/// Retries a GET request with exponential backoff on rate limiting (HTTP 429).
232///
233/// # Arguments
234///
235/// * `client` - HTTP client implementation
236/// * `url` - Full URL with query parameters
237/// * `user_auth_token` - User authentication token
238///
239/// # Returns
240///
241/// The successful HTTP response.
242///
243/// # Errors
244///
245/// Returns a `QobuzApiError::RateLimitError` if all retries are exhausted.
246pub 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/// Builds a full URL with query parameters from an endpoint path.
275///
276/// # Arguments
277///
278/// * `base_url` - API base URL
279/// * `endpoint` - API endpoint path (e.g., `"/album/search"`)
280/// * `params` - Key-value parameter pairs
281///
282/// # Returns
283///
284/// A fully formed URL string with encoded query parameters.
285#[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
304/// URL-encodes special characters in a string.
305///
306/// # Arguments
307///
308/// * `s` - The string to encode
309///
310/// # Returns
311///
312/// A URL-encoded string with spaces as `+` and special chars as `%XX`.
313fn 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}