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, ¶m_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}