use crate::error::{Error, Result};
use crate::jsonapi::{path_and_query, ErrorDocument, ListDocument, Resource};
use crate::token::{IntoTokenSource, TokenSource};
use reqwest::{Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug, PartialEq, Eq)]
enum Page<'a> {
First {
path: &'a str,
query: &'a [(&'a str, &'a str)],
},
Next(String),
}
impl<'a> Page<'a> {
fn request(&self) -> (&str, &[(&str, &str)]) {
match self {
Page::First { path, query } => (*path, *query),
Page::Next(path) => (path.as_str(), &[]),
}
}
}
const BASE_URL: &str = "https://api.appstoreconnect.apple.com";
const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(60);
struct CachedToken {
value: String,
minted_at: Instant,
lifetime: Duration,
}
pub struct Client {
http: reqwest::Client,
token_source: Box<dyn TokenSource>,
base_url: String,
token: Mutex<Option<CachedToken>>,
}
impl Client {
pub fn new(token_source: impl IntoTokenSource) -> Self {
Self {
http: reqwest::Client::new(),
token_source: token_source.into_token_source(),
base_url: BASE_URL.to_string(),
token: Mutex::new(None),
}
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
fn bearer_token(&self) -> Result<String> {
let mut guard = self
.token
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(cached) = guard.as_ref() {
if cached.minted_at.elapsed() + TOKEN_REFRESH_MARGIN < cached.lifetime {
return Ok(cached.value.clone());
}
}
let minted = self.token_source.mint()?;
*guard = Some(CachedToken {
value: minted.value.clone(),
minted_at: Instant::now(),
lifetime: minted.lifetime,
});
Ok(minted.value)
}
pub async fn request<B: Serialize, T: DeserializeOwned>(
&self,
method: Method,
path: &str,
query: &[(&str, &str)],
body: Option<&B>,
) -> Result<T> {
let (status, bytes) = self.send(method, path, query, body, &[]).await?;
if !status.is_success() {
return Err(api_error(status, &bytes));
}
Ok(serde_json::from_slice(&bytes)?)
}
pub async fn list_all<A>(&self, path: &str, query: &[(&str, &str)]) -> Result<Vec<Resource<A>>>
where
A: DeserializeOwned,
{
let mut rows = Vec::new();
let mut page = Page::First { path, query };
loop {
let (page_path, page_query) = page.request();
let doc: ListDocument<A> = self
.request(Method::GET, page_path, page_query, None::<&()>)
.await?;
rows.extend(doc.data);
let Some(next) = next_page(doc.links.and_then(|links| links.next)) else {
return Ok(rows);
};
page = next;
}
}
pub async fn request_no_content<B: Serialize>(
&self,
method: Method,
path: &str,
query: &[(&str, &str)],
body: Option<&B>,
) -> Result<()> {
let (status, bytes) = self.send(method, path, query, body, &[]).await?;
if !status.is_success() {
return Err(api_error(status, &bytes));
}
Ok(())
}
pub async fn request_bytes<B: Serialize>(
&self,
method: Method,
path: &str,
query: &[(&str, &str)],
body: Option<&B>,
headers: &[(&str, &str)],
) -> Result<Vec<u8>> {
let (status, bytes) = self.send(method, path, query, body, headers).await?;
if !status.is_success() {
return Err(api_error(status, &bytes));
}
Ok(bytes)
}
async fn send<B: Serialize>(
&self,
method: Method,
path: &str,
query: &[(&str, &str)],
body: Option<&B>,
headers: &[(&str, &str)],
) -> Result<(StatusCode, Vec<u8>)> {
let url = format!("{}{}", self.base_url, path);
let token = self.bearer_token()?;
let mut req = self.http.request(method, &url).bearer_auth(token);
if !query.is_empty() {
req = req.query(query);
}
if let Some(b) = body {
req = req.json(b);
}
for (name, value) in headers {
req = req.header(*name, *value);
}
let response = req.send().await?;
let status = response.status();
let bytes = response.bytes().await?.to_vec();
Ok((status, bytes))
}
pub async fn upload_bytes(
&self,
method: Method,
url: &str,
headers: &[(String, String)],
body: Vec<u8>,
) -> Result<()> {
let mut req = self.http.request(method, url);
for (name, value) in headers {
req = req.header(name.as_str(), value.as_str());
}
let response = req.body(body).send().await?;
let status = response.status();
if !status.is_success() {
let bytes = response.bytes().await?.to_vec();
return Err(api_error(status, &bytes));
}
Ok(())
}
}
fn next_page(next_link: Option<String>) -> Option<Page<'static>> {
Some(Page::Next(path_and_query(&next_link?)?))
}
fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
let detail = match serde_json::from_slice::<ErrorDocument>(bytes) {
Ok(doc) if !doc.errors.is_empty() => doc
.errors
.iter()
.map(|e| format!("{} ({}): {}", e.title, e.code, e.detail))
.collect::<Vec<_>>()
.join("; "),
_ => String::from_utf8_lossy(bytes).into_owned(),
};
Error::Api {
status: status.as_u16(),
detail,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::token::{MintedToken, StaticToken};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct CountingSource {
mints: Arc<AtomicUsize>,
lifetime_secs: u64,
}
impl TokenSource for CountingSource {
fn mint(&self) -> Result<MintedToken> {
let n = self.mints.fetch_add(1, Ordering::SeqCst) + 1;
Ok(MintedToken::new(format!("token-{n}"), self.lifetime_secs))
}
}
fn counting_client(lifetime_secs: u64) -> (Client, Arc<AtomicUsize>) {
let mints = Arc::new(AtomicUsize::new(0));
let client = Client::new(CountingSource {
mints: mints.clone(),
lifetime_secs,
});
(client, mints)
}
#[test]
fn a_fresh_token_is_reused_from_the_cache() {
let (client, mints) = counting_client(20 * 60);
assert_eq!(client.bearer_token().unwrap(), "token-1");
assert_eq!(client.bearer_token().unwrap(), "token-1");
assert_eq!(mints.load(Ordering::SeqCst), 1);
}
#[test]
fn a_token_inside_the_refresh_margin_is_minted_again() {
let (client, mints) = counting_client(TOKEN_REFRESH_MARGIN.as_secs());
assert_eq!(client.bearer_token().unwrap(), "token-1");
assert_eq!(client.bearer_token().unwrap(), "token-2");
assert_eq!(mints.load(Ordering::SeqCst), 2);
}
#[test]
fn new_accepts_a_static_token_and_a_boxed_source() {
let client = Client::new(StaticToken::new("eyJ.static"));
assert_eq!(client.bearer_token().unwrap(), "eyJ.static");
let boxed: Box<dyn TokenSource> = Box::new(StaticToken::new("eyJ.boxed"));
assert_eq!(Client::new(boxed).bearer_token().unwrap(), "eyJ.boxed");
}
#[test]
fn follows_a_next_link_as_a_path() {
let link =
"https://api.appstoreconnect.apple.com/v1/apps?limit%5Bapps%5D=200&cursor=AQ%3D%3D";
assert_eq!(
next_page(Some(link.into())),
Some(Page::Next(
"/v1/apps?limit%5Bapps%5D=200&cursor=AQ%3D%3D".into()
))
);
}
#[test]
fn ends_the_walk_on_a_last_page() {
assert_eq!(next_page(None), None);
}
#[test]
fn ends_the_walk_on_a_link_it_cannot_parse() {
assert_eq!(next_page(Some("/v1/apps?cursor=AQ".into())), None);
assert_eq!(
next_page(Some("https://api.appstoreconnect.apple.com".into())),
None
);
}
#[test]
fn first_page_keeps_the_callers_query() {
let query = [("limit", "200"), ("filter[bundleId]", "com.x.y")];
let page = Page::First {
path: "/v1/apps",
query: &query,
};
assert_eq!(page.request(), ("/v1/apps", &query[..]));
}
#[test]
fn later_pages_send_only_the_path_apple_named() {
let page = Page::Next("/v1/apps?limit=200&cursor=AQ".into());
assert_eq!(page.request(), ("/v1/apps?limit=200&cursor=AQ", &[][..]));
}
}