Skip to main content

smbcloud_ascapi_core/
client.rs

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/// 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)]
13enum 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
25impl<'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
35const 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.
38const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(60);
39
40struct 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.
56pub 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    /// 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`].
250fn 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    /// 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}