1use crate::error::{Error, Result};
2use crate::jsonapi::{path_and_query, ErrorDocument, ListDocument, Resource};
3use crate::token::{IntoTokenSource, TokenSource};
4use reqwest::{Method, StatusCode};
5use serde::de::DeserializeOwned;
6use serde::Serialize;
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10#[derive(Debug, PartialEq, Eq)]
13enum Page<'a> {
14 First {
16 path: &'a str,
17 query: &'a [(&'a str, &'a str)],
18 },
19 Next(String),
23}
24
25impl<'a> Page<'a> {
26 fn request(&self) -> (&str, &[(&str, &str)]) {
28 match self {
29 Page::First { path, query } => (*path, *query),
30 Page::Next(path) => (path.as_str(), &[]),
31 }
32 }
33}
34
35const BASE_URL: &str = "https://api.appstoreconnect.apple.com";
36const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(60);
39
40struct CachedToken {
41 value: String,
42 minted_at: Instant,
43 lifetime: Duration,
44}
45
46pub struct Client {
57 http: reqwest::Client,
58 token_source: Box<dyn TokenSource>,
59 base_url: String,
60 token: Mutex<Option<CachedToken>>,
61}
62
63impl Client {
64 pub fn new(token_source: impl IntoTokenSource) -> Self {
69 Self {
70 http: reqwest::Client::new(),
71 token_source: token_source.into_token_source(),
72 base_url: BASE_URL.to_string(),
73 token: Mutex::new(None),
74 }
75 }
76
77 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
80 self.base_url = base_url.into();
81 self
82 }
83
84 fn bearer_token(&self) -> Result<String> {
85 let mut guard = self
86 .token
87 .lock()
88 .unwrap_or_else(|poisoned| poisoned.into_inner());
89
90 if let Some(cached) = guard.as_ref() {
91 if cached.minted_at.elapsed() + TOKEN_REFRESH_MARGIN < cached.lifetime {
92 return Ok(cached.value.clone());
93 }
94 }
95
96 let minted = self.token_source.mint()?;
97 *guard = Some(CachedToken {
98 value: minted.value.clone(),
99 minted_at: Instant::now(),
100 lifetime: minted.lifetime,
101 });
102 Ok(minted.value)
103 }
104
105 pub async fn request<B: Serialize, T: DeserializeOwned>(
109 &self,
110 method: Method,
111 path: &str,
112 query: &[(&str, &str)],
113 body: Option<&B>,
114 ) -> Result<T> {
115 let (status, bytes) = self.send(method, path, query, body, &[]).await?;
116 if !status.is_success() {
117 return Err(api_error(status, &bytes));
118 }
119 Ok(serde_json::from_slice(&bytes)?)
120 }
121
122 pub async fn list_all<A>(&self, path: &str, query: &[(&str, &str)]) -> Result<Vec<Resource<A>>>
136 where
137 A: DeserializeOwned,
138 {
139 let mut rows = Vec::new();
140 let mut page = Page::First { path, query };
141
142 loop {
143 let (page_path, page_query) = page.request();
144 let doc: ListDocument<A> = self
145 .request(Method::GET, page_path, page_query, None::<&()>)
146 .await?;
147 rows.extend(doc.data);
148
149 let Some(next) = next_page(doc.links.and_then(|links| links.next)) else {
150 return Ok(rows);
151 };
152 page = next;
153 }
154 }
155
156 pub async fn request_no_content<B: Serialize>(
158 &self,
159 method: Method,
160 path: &str,
161 query: &[(&str, &str)],
162 body: Option<&B>,
163 ) -> Result<()> {
164 let (status, bytes) = self.send(method, path, query, body, &[]).await?;
165 if !status.is_success() {
166 return Err(api_error(status, &bytes));
167 }
168 Ok(())
169 }
170
171 pub async fn request_bytes<B: Serialize>(
175 &self,
176 method: Method,
177 path: &str,
178 query: &[(&str, &str)],
179 body: Option<&B>,
180 headers: &[(&str, &str)],
181 ) -> Result<Vec<u8>> {
182 let (status, bytes) = self.send(method, path, query, body, headers).await?;
183 if !status.is_success() {
184 return Err(api_error(status, &bytes));
185 }
186 Ok(bytes)
187 }
188
189 async fn send<B: Serialize>(
190 &self,
191 method: Method,
192 path: &str,
193 query: &[(&str, &str)],
194 body: Option<&B>,
195 headers: &[(&str, &str)],
196 ) -> Result<(StatusCode, Vec<u8>)> {
197 let url = format!("{}{}", self.base_url, path);
198 let token = self.bearer_token()?;
199 let mut req = self.http.request(method, &url).bearer_auth(token);
200 if !query.is_empty() {
201 req = req.query(query);
202 }
203 if let Some(b) = body {
204 req = req.json(b);
205 }
206 for (name, value) in headers {
207 req = req.header(*name, *value);
208 }
209
210 let response = req.send().await?;
211 let status = response.status();
212 let bytes = response.bytes().await?.to_vec();
213 Ok((status, bytes))
214 }
215
216 pub async fn upload_bytes(
223 &self,
224 method: Method,
225 url: &str,
226 headers: &[(String, String)],
227 body: Vec<u8>,
228 ) -> Result<()> {
229 let mut req = self.http.request(method, url);
230 for (name, value) in headers {
231 req = req.header(name.as_str(), value.as_str());
232 }
233 let response = req.body(body).send().await?;
234 let status = response.status();
235 if !status.is_success() {
236 let bytes = response.bytes().await?.to_vec();
237 return Err(api_error(status, &bytes));
238 }
239 Ok(())
240 }
241}
242
243fn next_page(next_link: Option<String>) -> Option<Page<'static>> {
251 Some(Page::Next(path_and_query(&next_link?)?))
252}
253
254fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
255 let detail = match serde_json::from_slice::<ErrorDocument>(bytes) {
256 Ok(doc) if !doc.errors.is_empty() => doc
257 .errors
258 .iter()
259 .map(|e| format!("{} ({}): {}", e.title, e.code, e.detail))
260 .collect::<Vec<_>>()
261 .join("; "),
262 _ => String::from_utf8_lossy(bytes).into_owned(),
263 };
264 Error::Api {
265 status: status.as_u16(),
266 detail,
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::token::{MintedToken, StaticToken};
274 use std::sync::atomic::{AtomicUsize, Ordering};
275 use std::sync::Arc;
276
277 struct CountingSource {
280 mints: Arc<AtomicUsize>,
281 lifetime_secs: u64,
282 }
283
284 impl TokenSource for CountingSource {
285 fn mint(&self) -> Result<MintedToken> {
286 let n = self.mints.fetch_add(1, Ordering::SeqCst) + 1;
287 Ok(MintedToken::new(format!("token-{n}"), self.lifetime_secs))
288 }
289 }
290
291 fn counting_client(lifetime_secs: u64) -> (Client, Arc<AtomicUsize>) {
292 let mints = Arc::new(AtomicUsize::new(0));
293 let client = Client::new(CountingSource {
294 mints: mints.clone(),
295 lifetime_secs,
296 });
297 (client, mints)
298 }
299
300 #[test]
301 fn a_fresh_token_is_reused_from_the_cache() {
302 let (client, mints) = counting_client(20 * 60);
303 assert_eq!(client.bearer_token().unwrap(), "token-1");
304 assert_eq!(client.bearer_token().unwrap(), "token-1");
305 assert_eq!(mints.load(Ordering::SeqCst), 1);
306 }
307
308 #[test]
312 fn a_token_inside_the_refresh_margin_is_minted_again() {
313 let (client, mints) = counting_client(TOKEN_REFRESH_MARGIN.as_secs());
314 assert_eq!(client.bearer_token().unwrap(), "token-1");
315 assert_eq!(client.bearer_token().unwrap(), "token-2");
316 assert_eq!(mints.load(Ordering::SeqCst), 2);
317 }
318
319 #[test]
320 fn new_accepts_a_static_token_and_a_boxed_source() {
321 let client = Client::new(StaticToken::new("eyJ.static"));
322 assert_eq!(client.bearer_token().unwrap(), "eyJ.static");
323
324 let boxed: Box<dyn TokenSource> = Box::new(StaticToken::new("eyJ.boxed"));
325 assert_eq!(Client::new(boxed).bearer_token().unwrap(), "eyJ.boxed");
326 }
327
328 #[test]
331 fn follows_a_next_link_as_a_path() {
332 let link =
333 "https://api.appstoreconnect.apple.com/v1/apps?limit%5Bapps%5D=200&cursor=AQ%3D%3D";
334 assert_eq!(
335 next_page(Some(link.into())),
336 Some(Page::Next(
337 "/v1/apps?limit%5Bapps%5D=200&cursor=AQ%3D%3D".into()
338 ))
339 );
340 }
341
342 #[test]
345 fn ends_the_walk_on_a_last_page() {
346 assert_eq!(next_page(None), None);
347 }
348
349 #[test]
352 fn ends_the_walk_on_a_link_it_cannot_parse() {
353 assert_eq!(next_page(Some("/v1/apps?cursor=AQ".into())), None);
354 assert_eq!(
355 next_page(Some("https://api.appstoreconnect.apple.com".into())),
356 None
357 );
358 }
359
360 #[test]
362 fn first_page_keeps_the_callers_query() {
363 let query = [("limit", "200"), ("filter[bundleId]", "com.x.y")];
364 let page = Page::First {
365 path: "/v1/apps",
366 query: &query,
367 };
368 assert_eq!(page.request(), ("/v1/apps", &query[..]));
369 }
370
371 #[test]
374 fn later_pages_send_only_the_path_apple_named() {
375 let page = Page::Next("/v1/apps?limit=200&cursor=AQ".into());
376 assert_eq!(page.request(), ("/v1/apps?limit=200&cursor=AQ", &[][..]));
377 }
378}