Skip to main content

qobuz_api/api/
requests.rs

1//! HTTP request primitives: GET, POST, signed GET, response parsing, retry-with-backoff.
2
3#[cfg(test)]
4pub mod dispatch;
5
6use std::time::Duration;
7
8use {reqwest::Response, serde::de::DeserializeOwned, tokio::time::sleep};
9
10use crate::{
11    api::{
12        http_client::HttpClient,
13        response::{parse_response, timestamp},
14    },
15    errors::QobuzApiError::{self, DownloadError, RateLimitError, ResourceNotFoundError},
16    signing::sign_request,
17};
18
19/// Generates a signed request function with the standard parameter list.
20macro_rules! signed_request {
21    (
22        $(#[$meta:meta])*
23        $vis:vis fn $name:ident($client:ident, $base_url:ident, $endpoint:ident, $params:ident, $auth:ident) -> $body:tt
24    ) => {
25        $(#[$meta])*
26        $vis async fn $name<T: DeserializeOwned>(
27            $client: &dyn HttpClient,
28            $base_url: &str,
29            $endpoint: &str,
30            $params: &mut Vec<(String, String)>,
31            $auth: &RequestAuth<'_>,
32        ) -> Result<T, QobuzApiError> $body
33    };
34}
35
36/// Maximum number of retry attempts on rate limiting.
37const MAX_RETRIES: u32 = 3;
38/// Base delay in milliseconds for exponential backoff.
39const BASE_BACKOFF_MS: u64 = 500;
40
41/// Bundles application credentials and user token for signed API requests.
42#[derive(Clone, Copy, Debug)]
43pub struct RequestAuth<'a> {
44    /// Application ID.
45    pub app_id: &'a str,
46    /// Application secret for request signing.
47    pub app_secret: &'a str,
48    /// User authentication token.
49    pub user_auth_token: &'a str,
50}
51
52/// Appends `app_id`, `request_ts`, and `request_sig` to params for request signing.
53///
54/// # Arguments
55///
56/// * `params` - Parameter vector to modify
57/// * `method` - HTTP method (e.g., `"GET"`, `"POST"`)
58/// * `endpoint` - API endpoint path
59/// * `auth` - Application credentials for signing
60fn append_signature(
61    params: &mut Vec<(String, String)>,
62    method: &str,
63    endpoint: &str,
64    auth: &RequestAuth<'_>,
65) {
66    params.push(("app_id".to_string(), auth.app_id.to_string()));
67    params.push(("request_ts".to_string(), timestamp()));
68    let sig = sign_request(method, endpoint, params, auth.app_secret);
69    params.push(("request_sig".to_string(), sig));
70}
71
72/// Executes a POST request with form parameters and parses the response.
73///
74/// # Arguments
75///
76/// * `client` - HTTP client implementation
77/// * `base_url` - API base URL
78/// * `endpoint` - API endpoint path
79/// * `params` - Key-value form parameters
80///
81/// # Returns
82///
83/// Parsed JSON response of type `T`.
84///
85/// # Errors
86///
87/// Returns a `QobuzApiError` on HTTP failures or JSON parse errors.
88async fn execute_post<T: DeserializeOwned>(
89    client: &dyn HttpClient,
90    base_url: &str,
91    endpoint: &str,
92    params: &[(String, String)],
93) -> Result<T, QobuzApiError> {
94    let url = format!("{base_url}{endpoint}");
95    let param_refs: Vec<(&str, &str)> = params
96        .iter()
97        .map(|(k, v)| (k.as_str(), v.as_str()))
98        .collect();
99
100    let response = client.post_form(&url, &param_refs).await?;
101
102    parse_response::<T>(response, endpoint).await
103}
104
105signed_request!(
106    /// Sends a signed GET request and parses the JSON response.
107    ///
108    /// # Arguments
109    ///
110    /// * `client` - HTTP client implementation
111    /// * `base_url` - API base URL
112    /// * `endpoint` - API endpoint path (e.g., `"/album/search"`)
113    /// * `params` - Key-value parameter pairs (will be sorted for signing)
114    /// * `auth` - Application credentials and user authentication token
115    ///
116    /// # Returns
117    ///
118    /// Parsed JSON response of type `T`.
119    ///
120    /// # Errors
121    ///
122    /// Returns a `QobuzApiError` on HTTP failures, rate limiting, or JSON parse errors.
123    pub fn signed_get(client, base_url, endpoint, params, auth) -> {
124        append_signature(params, "GET", endpoint, auth);
125
126        let url = build_url_with_params(base_url, endpoint, params);
127        let response = retry_with_backoff(client, &url, auth.user_auth_token).await?;
128
129        parse_response::<T>(response, endpoint).await
130    }
131);
132
133/// Sends a POST request with form parameters and parses the JSON response.
134///
135/// # Arguments
136///
137/// * `client` - HTTP client implementation
138/// * `base_url` - API base URL (e.g., `"https://www.qobuz.com/api.json/0.2"`)
139/// * `endpoint` - API endpoint path
140/// * `params` - Key-value form parameters
141/// * `app_id` - Application ID
142/// * `user_auth_token` - User authentication token (may be empty for login)
143///
144/// # Returns
145///
146/// Parsed JSON response of type `T`.
147///
148/// # Errors
149///
150/// Returns a `QobuzApiError` on HTTP failures or JSON parse errors.
151pub async fn post<T: DeserializeOwned>(
152    client: &dyn HttpClient,
153    base_url: &str,
154    endpoint: &str,
155    params: &mut Vec<(String, String)>,
156    app_id: &str,
157    user_auth_token: &str,
158) -> Result<T, QobuzApiError> {
159    params.push(("app_id".to_string(), app_id.to_string()));
160    if !user_auth_token.is_empty() {
161        params.push(("user_auth_token".to_string(), user_auth_token.to_string()));
162    }
163
164    execute_post::<T>(client, base_url, endpoint, params).await
165}
166
167signed_request!(
168    /// Sends a signed POST request with form parameters and parses the JSON response.
169    ///
170    /// # Arguments
171    ///
172    /// * `client` - HTTP client implementation
173    /// * `base_url` - API base URL
174    /// * `endpoint` - API endpoint path
175    /// * `params` - Key-value form parameters (will be sorted for signing)
176    /// * `auth` - Application credentials and user authentication token
177    ///
178    /// # Returns
179    ///
180    /// Parsed JSON response of type `T`.
181    ///
182    /// # Errors
183    ///
184    /// Returns a `QobuzApiError` on HTTP failures, rate limiting, or JSON parse errors.
185    pub fn signed_post(client, base_url, endpoint, params, auth) -> {
186        params.push((
187            "user_auth_token".to_string(),
188            auth.user_auth_token.to_string(),
189        ));
190        append_signature(params, "POST", endpoint, auth);
191
192        execute_post::<T>(client, base_url, endpoint, params).await
193    }
194);
195
196/// Sends a GET request to an authenticated endpoint for binary download.
197///
198/// # Arguments
199///
200/// * `client` - HTTP client implementation
201/// * `url` - Full download URL
202/// * `token` - User auth token
203/// * `range` - Optional Range header value (e.g., `"bytes=1024-"`)
204///
205/// # Returns
206///
207/// The raw `reqwest::Response` for streaming.
208///
209/// # Errors
210///
211/// Returns a `QobuzApiError` on HTTP failures, 404, or non-success status codes.
212pub async fn download_stream(
213    client: &dyn HttpClient,
214    url: &str,
215    token: &str,
216    range: Option<&str>,
217) -> Result<Response, QobuzApiError> {
218    let response = client.get_with_auth(url, token, range).await?;
219
220    let status = response.status();
221    if status.is_success() || status.as_u16() == 206 {
222        Ok(response)
223    } else if status.as_u16() == 404 {
224        Err(ResourceNotFoundError {
225            resource_type: "file".to_string(),
226            resource_id: url.to_string(),
227        })
228    } else {
229        Err(DownloadError {
230            message: format!("HTTP {status}"),
231        })
232    }
233}
234
235/// Retries a GET request with exponential backoff on rate limiting (HTTP 429).
236///
237/// # Arguments
238///
239/// * `client` - HTTP client implementation
240/// * `url` - Full URL with query parameters
241/// * `user_auth_token` - User authentication token
242///
243/// # Returns
244///
245/// The successful HTTP response.
246///
247/// # Errors
248///
249/// Returns a `QobuzApiError::RateLimitError` if all retries are exhausted.
250pub async fn retry_with_backoff(
251    client: &dyn HttpClient,
252    url: &str,
253    user_auth_token: &str,
254) -> Result<Response, QobuzApiError> {
255    let mut last_error: Option<QobuzApiError> = None;
256
257    for attempt in 0..=MAX_RETRIES {
258        let response = client.get_with_auth(url, user_auth_token, None).await?;
259
260        let status = response.status();
261        if status.as_u16() == 429 {
262            let delay = BASE_BACKOFF_MS.saturating_mul(2u64.pow(attempt));
263            sleep(Duration::from_millis(delay)).await;
264            last_error = Some(RateLimitError {
265                message: format!("Rate limited, retry {attempt}/{MAX_RETRIES}"),
266            });
267            continue;
268        }
269
270        return Ok(response);
271    }
272
273    Err(last_error.unwrap_or_else(|| RateLimitError {
274        message: "Rate limited: retries exhausted".to_string(),
275    }))
276}
277
278/// Builds a full URL with query parameters from an endpoint path.
279///
280/// # Arguments
281///
282/// * `base_url` - API base URL
283/// * `endpoint` - API endpoint path (e.g., `"/album/search"`)
284/// * `params` - Key-value parameter pairs
285///
286/// # Returns
287///
288/// A fully formed URL string with encoded query parameters.
289#[must_use]
290pub fn build_url_with_params(
291    base_url: &str,
292    endpoint: &str,
293    params: &[(String, String)],
294) -> String {
295    let mut url = format!("{base_url}{endpoint}");
296    if !params.is_empty() {
297        let query: String = params
298            .iter()
299            .map(|(k, v)| format!("{}={}", urlencoding(k), urlencoding(v)))
300            .collect::<Vec<_>>()
301            .join("&");
302        url.push('?');
303        url.push_str(&query);
304    }
305    url
306}
307
308/// URL-encodes special characters in a string.
309///
310/// # Arguments
311///
312/// * `s` - The string to encode
313///
314/// # Returns
315///
316/// A URL-encoded string with spaces as `+` and special chars as `%XX`.
317fn urlencoding(s: &str) -> String {
318    s.chars()
319        .map(|c| match c {
320            ' ' => "+".to_string(),
321            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
322            _ => format!("%{:02X}", u32::from(c)),
323        })
324        .collect()
325}