use std::sync::Arc;
use reqwest::Client;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::auth::{AuthError, Credentials};
use crate::models::{JsonApiResponse, Resource};
const MAX_RATE_LIMIT_RETRIES: u32 = 3;
const DEFAULT_RETRY_AFTER_SECS: u64 = 5;
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("auth error: {0}")]
Auth(#[from] AuthError),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("api error: status={status}, body={body}")]
Status {
status: u16,
body: String,
},
#[error("deserialization error: {0}")]
Deserialize(String),
#[error("rate limited: exhausted {MAX_RATE_LIMIT_RETRIES} retries")]
RateLimited,
#[error("sales report error: {0}")]
SalesReport(String),
}
#[derive(Clone)]
pub struct AscClient {
http: Client,
credentials: Arc<Credentials>,
pub(crate) base_url: String,
}
impl AscClient {
pub fn new(credentials: Arc<Credentials>) -> Self {
Self {
http: Client::new(),
credentials,
base_url: "https://api.appstoreconnect.apple.com/v1".into(),
}
}
pub fn with_base_url(credentials: Arc<Credentials>, base_url: String) -> Self {
Self {
http: Client::new(),
credentials,
base_url,
}
}
pub(crate) async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
let url = format!("{}{}", self.base_url, path);
self.get_url(&url).await
}
pub(crate) async fn get_url<T: DeserializeOwned>(&self, url: &str) -> Result<T, ApiError> {
let token = self.credentials.token()?;
for attempt in 0..=MAX_RATE_LIMIT_RETRIES {
let resp = self.http.get(url).bearer_auth(&token).send().await?;
let status = resp.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
if attempt == MAX_RATE_LIMIT_RETRIES {
return Err(ApiError::RateLimited);
}
let retry_after = Self::parse_retry_after(&resp);
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
continue;
}
return self.handle_response(resp).await;
}
Err(ApiError::RateLimited)
}
pub(crate) async fn post<T: DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T, ApiError> {
let url = format!("{}{}", self.base_url, path);
let token = self.credentials.token()?;
let resp = self
.http
.post(&url)
.bearer_auth(&token)
.json(body)
.send()
.await?;
self.handle_response(resp).await
}
pub(crate) async fn get_raw(
&self,
path: &str,
query: &[(String, String)],
accept: &str,
) -> Result<Vec<u8>, ApiError> {
let url = format!("{}{}", self.base_url, path);
let token = self.credentials.token()?;
for attempt in 0..=MAX_RATE_LIMIT_RETRIES {
let resp = self
.http
.get(&url)
.bearer_auth(&token)
.header("Accept", accept)
.query(query)
.send()
.await?;
let status = resp.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
if attempt == MAX_RATE_LIMIT_RETRIES {
return Err(ApiError::RateLimited);
}
let retry_after = Self::parse_retry_after(&resp);
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
continue;
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(ApiError::Status {
status: status.as_u16(),
body,
});
}
return Ok(resp.bytes().await?.to_vec());
}
Err(ApiError::RateLimited)
}
pub async fn get_all_pages<A: DeserializeOwned>(
&self,
path: &str,
) -> Result<Vec<Resource<A>>, ApiError> {
let mut all_items = Vec::new();
let first: JsonApiResponse<Vec<Resource<A>>> = self.get(path).await?;
all_items.extend(first.data);
let mut next_url = first.links.and_then(|l| l.next);
while let Some(url) = next_url {
let page: JsonApiResponse<Vec<Resource<A>>> = self.get_url(&url).await?;
all_items.extend(page.data);
next_url = page.links.and_then(|l| l.next);
}
Ok(all_items)
}
async fn handle_response<T: DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<T, ApiError> {
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_else(|e| {
tracing::warn!("failed to read error response body: {e}");
String::new()
});
return Err(ApiError::Status {
status: status.as_u16(),
body,
});
}
let text = resp.text().await?;
serde_json::from_str(&text).map_err(|e| ApiError::Deserialize(e.to_string()))
}
fn parse_retry_after(resp: &reqwest::Response) -> u64 {
resp.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(DEFAULT_RETRY_AFTER_SECS)
}
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;