#[cfg(test)]
pub mod dispatch;
use std::time::Duration;
use {reqwest::Response, serde::de::DeserializeOwned, tokio::time::sleep};
use crate::{
api::{
http_client::HttpClient,
response::{parse_response, timestamp},
},
errors::QobuzApiError::{self, DownloadError, RateLimitError, ResourceNotFoundError},
signing::sign_request,
};
macro_rules! signed_request {
(
$(#[$meta:meta])*
$vis:vis fn $name:ident($client:ident, $base_url:ident, $endpoint:ident, $params:ident, $auth:ident) -> $ret:ty $body:block
) => {
$(#[$meta])*
$vis async fn $name<T>(
$client: &dyn HttpClient,
$base_url: &str,
$endpoint: &str,
$params: &mut Vec<(String, String)>,
$auth: &RequestAuth<'_>,
) -> $ret
where T: DeserializeOwned
$body
};
}
const MAX_RETRIES: u32 = 3;
const BASE_BACKOFF_MS: u64 = 500;
#[derive(Clone, Copy, Debug)]
pub struct RequestAuth<'a> {
pub app_id: &'a str,
pub app_secret: &'a str,
pub user_auth_token: &'a str,
}
fn append_signature(
params: &mut Vec<(String, String)>,
method: &str,
endpoint: &str,
auth: &RequestAuth<'_>,
) {
params.push(("app_id".to_string(), auth.app_id.to_string()));
params.push(("request_ts".to_string(), timestamp()));
let sig = sign_request(method, endpoint, params, auth.app_secret);
params.push(("request_sig".to_string(), sig));
}
async fn execute_post<T>(
client: &dyn HttpClient,
base_url: &str,
endpoint: &str,
params: &[(String, String)],
) -> Result<T, QobuzApiError>
where
T: DeserializeOwned,
{
let url = format!("{base_url}{endpoint}");
let param_refs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let response = client.post_form(&url, ¶m_refs).await?;
parse_response::<T>(response, endpoint).await
}
signed_request!(
pub fn signed_get(client, base_url, endpoint, params, auth) -> Result<T, QobuzApiError> {
append_signature(params, "GET", endpoint, auth);
let url = build_url_with_params(base_url, endpoint, params);
let response = retry_with_backoff(client, &url, auth.user_auth_token).await?;
parse_response::<T>(response, endpoint).await
}
);
pub async fn post<T>(
client: &dyn HttpClient,
base_url: &str,
endpoint: &str,
params: &mut Vec<(String, String)>,
app_id: &str,
user_auth_token: &str,
) -> Result<T, QobuzApiError>
where
T: DeserializeOwned,
{
params.push(("app_id".to_string(), app_id.to_string()));
if !user_auth_token.is_empty() {
params.push(("user_auth_token".to_string(), user_auth_token.to_string()));
}
execute_post::<T>(client, base_url, endpoint, params).await
}
signed_request!(
pub fn signed_post(client, base_url, endpoint, params, auth) -> Result<T, QobuzApiError> {
params.push((
"user_auth_token".to_string(),
auth.user_auth_token.to_string(),
));
append_signature(params, "POST", endpoint, auth);
execute_post::<T>(client, base_url, endpoint, params).await
}
);
pub async fn download_stream(
client: &dyn HttpClient,
url: &str,
token: &str,
range: Option<&str>,
) -> Result<Response, QobuzApiError> {
let response = client.get_with_auth(url, token, range).await?;
let status = response.status();
if status.is_success() || status.as_u16() == 206 {
Ok(response)
} else if status.as_u16() == 404 {
Err(ResourceNotFoundError {
resource_type: "file".to_string(),
resource_id: url.to_string(),
})
} else {
Err(DownloadError {
message: format!("HTTP {status}"),
})
}
}
pub async fn retry_with_backoff(
client: &dyn HttpClient,
url: &str,
user_auth_token: &str,
) -> Result<Response, QobuzApiError> {
let mut last_error: Option<QobuzApiError> = None;
for attempt in 0..=MAX_RETRIES {
let response = client.get_with_auth(url, user_auth_token, None).await?;
let status = response.status();
if status.as_u16() == 429 {
let delay = BASE_BACKOFF_MS.saturating_mul(2u64.pow(attempt));
sleep(Duration::from_millis(delay)).await;
last_error = Some(RateLimitError {
message: format!("Rate limited, retry {attempt}/{MAX_RETRIES}"),
});
continue;
}
return Ok(response);
}
Err(last_error.unwrap_or_else(|| RateLimitError {
message: "Rate limited: retries exhausted".to_string(),
}))
}
#[must_use]
pub fn build_url_with_params(
base_url: &str,
endpoint: &str,
params: &[(String, String)],
) -> String {
let mut url = format!("{base_url}{endpoint}");
if !params.is_empty() {
let query: String = params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding(k), urlencoding(v)))
.collect::<Vec<_>>()
.join("&");
url.push('?');
url.push_str(&query);
}
url
}
fn urlencoding(s: &str) -> String {
s.chars()
.map(|c| match c {
' ' => "+".to_string(),
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
_ => format!("%{:02X}", u32::from(c)),
})
.collect()
}