Skip to main content

smbcloud_ascapi_core/
client.rs

1use crate::auth::ApiKey;
2use crate::error::{Error, Result};
3use crate::jsonapi::ErrorDocument;
4use reqwest::{Method, StatusCode};
5use serde::de::DeserializeOwned;
6use serde::Serialize;
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10const BASE_URL: &str = "https://api.appstoreconnect.apple.com";
11// Refresh a bit before the token's real expiry so an in-flight request never
12// races a signature Apple has already started rejecting.
13const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(60);
14
15struct CachedToken {
16    value: String,
17    minted_at: Instant,
18    lifetime: Duration,
19}
20
21/// Thin async HTTP client for the App Store Connect API. Handles JWT minting
22/// (cached and refreshed automatically) and JSON:API request/response
23/// plumbing.
24///
25/// Resource-specific calls live in the domain crates as extension traits on
26/// this type: `smbcloud-ascapi-aso` for App Metadata, `smbcloud-ascapi-signing`
27/// for certificates. [`Client::request`], [`Client::request_no_content`], and
28/// [`Client::upload_bytes`] are the low-level seam those traits build on, and
29/// are public for that reason rather than because callers should reach for
30/// them directly.
31pub struct Client {
32    http: reqwest::Client,
33    api_key: ApiKey,
34    base_url: String,
35    token: Mutex<Option<CachedToken>>,
36}
37
38impl Client {
39    pub fn new(api_key: ApiKey) -> Self {
40        Self {
41            http: reqwest::Client::new(),
42            api_key,
43            base_url: BASE_URL.to_string(),
44            token: Mutex::new(None),
45        }
46    }
47
48    /// Override the API host — only meaningful for pointing the client at a
49    /// mock server in tests.
50    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
51        self.base_url = base_url.into();
52        self
53    }
54
55    fn bearer_token(&self) -> Result<String> {
56        let mut guard = self
57            .token
58            .lock()
59            .unwrap_or_else(|poisoned| poisoned.into_inner());
60
61        if let Some(cached) = guard.as_ref() {
62            if cached.minted_at.elapsed() + TOKEN_REFRESH_MARGIN < cached.lifetime {
63                return Ok(cached.value.clone());
64            }
65        }
66
67        let value = self.api_key.token()?;
68        *guard = Some(CachedToken {
69            value: value.clone(),
70            minted_at: Instant::now(),
71            lifetime: Duration::from_secs(self.api_key.lifetime_secs()),
72        });
73        Ok(value)
74    }
75
76    /// Send a request and decode a JSON:API response body into `T`. Use
77    /// `request_no_content` instead for calls (typically `DELETE`) that
78    /// return an empty `204` body.
79    pub async fn request<B: Serialize, T: DeserializeOwned>(
80        &self,
81        method: Method,
82        path: &str,
83        query: &[(&str, &str)],
84        body: Option<&B>,
85    ) -> Result<T> {
86        let (status, bytes) = self.send(method, path, query, body).await?;
87        if !status.is_success() {
88            return Err(api_error(status, &bytes));
89        }
90        Ok(serde_json::from_slice(&bytes)?)
91    }
92
93    /// Send a request that returns no body on success (typically `DELETE`).
94    pub async fn request_no_content<B: Serialize>(
95        &self,
96        method: Method,
97        path: &str,
98        query: &[(&str, &str)],
99        body: Option<&B>,
100    ) -> Result<()> {
101        let (status, bytes) = self.send(method, path, query, body).await?;
102        if !status.is_success() {
103            return Err(api_error(status, &bytes));
104        }
105        Ok(())
106    }
107
108    async fn send<B: Serialize>(
109        &self,
110        method: Method,
111        path: &str,
112        query: &[(&str, &str)],
113        body: Option<&B>,
114    ) -> Result<(StatusCode, Vec<u8>)> {
115        let url = format!("{}{}", self.base_url, path);
116        let token = self.bearer_token()?;
117        let mut req = self.http.request(method, &url).bearer_auth(token);
118        if !query.is_empty() {
119            req = req.query(query);
120        }
121        if let Some(b) = body {
122            req = req.json(b);
123        }
124
125        let response = req.send().await?;
126        let status = response.status();
127        let bytes = response.bytes().await?.to_vec();
128        Ok((status, bytes))
129    }
130
131    /// PUTs a raw byte body to a pre-signed asset-upload URL (as returned by
132    /// e.g. `appScreenshots.uploadOperations`). Unlike [`Client::request`],
133    /// this does **not** prefix `base_url` or attach the ASC bearer token —
134    /// upload URLs are pre-signed and carry their own auth in
135    /// `request_headers`; sending our JWT alongside would be wrong for a
136    /// host that isn't `api.appstoreconnect.apple.com`.
137    pub async fn upload_bytes(
138        &self,
139        method: Method,
140        url: &str,
141        headers: &[(String, String)],
142        body: Vec<u8>,
143    ) -> Result<()> {
144        let mut req = self.http.request(method, url);
145        for (name, value) in headers {
146            req = req.header(name.as_str(), value.as_str());
147        }
148        let response = req.body(body).send().await?;
149        let status = response.status();
150        if !status.is_success() {
151            let bytes = response.bytes().await?.to_vec();
152            return Err(api_error(status, &bytes));
153        }
154        Ok(())
155    }
156}
157
158fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
159    let detail = match serde_json::from_slice::<ErrorDocument>(bytes) {
160        Ok(doc) if !doc.errors.is_empty() => doc
161            .errors
162            .iter()
163            .map(|e| format!("{} ({}): {}", e.title, e.code, e.detail))
164            .collect::<Vec<_>>()
165            .join("; "),
166        _ => String::from_utf8_lossy(bytes).into_owned(),
167    };
168    Error::Api {
169        status: status.as_u16(),
170        detail,
171    }
172}