main
rs 378 lines 13.4 KB
Raw
1 use crate::error::{Error, Result};
2 use crate::jsonapi::{path_and_query, ErrorDocument, ListDocument, Resource};
3 use crate::token::{IntoTokenSource, TokenSource};
4 use reqwest::{Method, StatusCode};
5 use serde::de::DeserializeOwned;
6 use serde::Serialize;
7 use std::sync::Mutex;
8 use std::time::{Duration, Instant};
9
10 /// Which request to send next while walking a paginated collection: the
11 /// caller's original `path`/`query`, or the page Apple's `links.next` named.
12 #[derive(Debug, PartialEq, Eq)]
13 enum Page<'a> {
14 /// The first page, as the caller asked for it.
15 First {
16 path: &'a str,
17 query: &'a [(&'a str, &'a str)],
18 },
19 /// A following page. Carries only a path because Apple's `next` URL
20 /// already embeds the original `include`/`filter`/`limit` plus its
21 /// `cursor`; re-appending `query` would duplicate every parameter.
22 Next(String),
23 }
24
25 impl<'a> Page<'a> {
26 /// The `path`/`query` pair to request this page with.
27 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
35 const BASE_URL: &str = "https://api.appstoreconnect.apple.com";
36 // Refresh a bit before the token's real expiry so an in-flight request never
37 // races a signature Apple has already started rejecting.
38 const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(60);
39
40 struct CachedToken {
41 value: String,
42 minted_at: Instant,
43 lifetime: Duration,
44 }
45
46 /// Thin async HTTP client for the App Store Connect API. Handles JWT minting
47 /// (cached and refreshed automatically) and JSON:API request/response
48 /// plumbing.
49 ///
50 /// Resource-specific calls live in the domain crates as extension traits on
51 /// this type: `smbcloud-ascapi-aso` for App Metadata, `smbcloud-ascapi-signing`
52 /// for certificates. [`Client::request`], [`Client::request_no_content`], and
53 /// [`Client::upload_bytes`] are the low-level seam those traits build on, and
54 /// are public for that reason rather than because callers should reach for
55 /// them directly.
56 pub struct Client {
57 http: reqwest::Client,
58 token_source: Box<dyn TokenSource>,
59 base_url: String,
60 token: Mutex<Option<CachedToken>>,
61 }
62
63 impl Client {
64 /// Build a client over anything that can mint a bearer token. An
65 /// [`ApiKey`](crate::ApiKey) is the usual argument and still works
66 /// unchanged, since it converts into a [`TokenSource`]; a
67 /// [`StaticToken`](crate::StaticToken) or any custom source works too.
68 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 /// Override the API host — only meaningful for pointing the client at a
78 /// mock server in tests.
79 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 /// Send a request and decode a JSON:API response body into `T`. Use
106 /// `request_no_content` instead for calls (typically `DELETE`) that
107 /// return an empty `204` body.
108 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 /// Read an entire collection by following `links.next`, so a caller sees
123 /// every row rather than Apple's first page.
124 ///
125 /// `path`/`query` describe the first page only. Each later page is
126 /// requested from the absolute `links.next` Apple returns, which already
127 /// carries the original `include`/`filter`/`limit` alongside its cursor —
128 /// re-appending `query` there would duplicate every parameter.
129 ///
130 /// No page size is chosen here. Apple's per-collection maximums differ and
131 /// only the caller knows which collection it is reading; picking a size
132 /// shapes the request, where following the cursor does not. Callers that
133 /// want 200-row pages pass `("limit", "200")` in `query`, as they already
134 /// do.
135 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 /// Send a request that returns no body on success (typically `DELETE`).
157 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 /// Send an authenticated request whose successful response is raw bytes.
172 /// Domain APIs use this for endpoints such as sales reports that return a
173 /// compressed file instead of a JSON:API document.
174 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 /// PUTs a raw byte body to a pre-signed asset-upload URL (as returned by
217 /// e.g. `appScreenshots.uploadOperations`). Unlike [`Client::request`],
218 /// this does **not** prefix `base_url` or attach the ASC bearer token —
219 /// upload URLs are pre-signed and carry their own auth in
220 /// `request_headers`; sending our JWT alongside would be wrong for a
221 /// host that isn't `api.appstoreconnect.apple.com`.
222 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
243 /// Decide the page that follows one carrying this `links.next`.
244 ///
245 /// Returns `None` when the collection is done. Two ways that happens:
246 /// Apple offered no `next` link, or it offered one we cannot turn into a
247 /// path. The second case ends the walk instead of failing it — stopping
248 /// early shows up as a short list, where a hard failure would take down a
249 /// command that was otherwise working. See [`path_and_query`].
250 fn next_page(next_link: Option<String>) -> Option<Page<'static>> {
251 Some(Page::Next(path_and_query(&next_link?)?))
252 }
253
254 fn 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)]
271 mod tests {
272 use super::*;
273 use crate::token::{MintedToken, StaticToken};
274 use std::sync::atomic::{AtomicUsize, Ordering};
275 use std::sync::Arc;
276
277 /// Counts mints so the tests can see when the client goes back to the
278 /// source instead of its cache.
279 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 /// A lifetime inside the refresh margin is already "about to expire",
309 /// so every request mints again rather than sending a token Apple may
310 /// reject mid-flight.
311 #[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 // `links.next` is the whole paging decision, so it is pinned against the
329 // URL shape App Store Connect actually sends rather than a hand-made one.
330 #[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 /// A collection is done when Apple stops offering a `next` link — which
343 /// is also how it signals a single-page collection.
344 #[test]
345 fn ends_the_walk_on_a_last_page() {
346 assert_eq!(next_page(None), None);
347 }
348
349 /// Stopping early shows up as a short list; failing here would take down
350 /// a command that was otherwise working.
351 #[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 /// The first page is the caller's request, unchanged.
361 #[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 /// Apple's `next` URL already carries `include`/`filter`/`limit`, so
372 /// re-appending the original query would duplicate every parameter.
373 #[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 }