Skip to main content

smbcloud_ascapi_aso/
build.rs

1//! Lookup for `Build` — the processed artifact behind an uploaded
2//! `.ipa`/`.pkg` — and `PreReleaseVersion`, the version string App Store
3//! Connect groups builds under for TestFlight. The one write is a build's
4//! export-compliance answer ([`BuildsApi::update_build`]).
5//!
6//! Uploading the binary itself is out of this crate's scope (done via
7//! `xcrun altool`/Xcode); this module exists so a caller can check whether App
8//! Store Connect finished processing a build as `VALID` or `INVALID` after
9//! upload — the signal that actually confirms a binary-rejection fix worked,
10//! independent of an `AppStoreVersion`'s `appVersionState` (which only
11//! changes once a build is attached to the version and resubmitted — outside
12//! this crate's scope).
13
14use async_trait::async_trait;
15use reqwest::Method;
16use serde::{Deserialize, Serialize};
17use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData};
18use smbcloud_ascapi_core::Client;
19use smbcloud_ascapi_core::Result;
20
21use crate::app_store_version::Platform;
22
23pub const RESOURCE_TYPE: &str = "builds";
24pub const PRE_RELEASE_VERSION_RESOURCE_TYPE: &str = "preReleaseVersions";
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct BuildAttributes {
29    pub version: Option<String>,
30    pub uploaded_date: Option<String>,
31    pub expired: Option<bool>,
32    pub processing_state: Option<String>,
33    pub min_os_version: Option<String>,
34    /// The build's export-compliance answer. `None` until someone answers it,
35    /// either here or through `ITSAppUsesNonExemptEncryption` in the Info.plist
36    /// of every bundle in the upload. A build with no answer shows "Missing
37    /// Compliance" and App Review rejects its version with
38    /// `STATE_ERROR.ENTITY_STATE_INVALID`.
39    pub uses_non_exempt_encryption: Option<bool>,
40}
41
42pub type Build = Resource<BuildAttributes>;
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct PreReleaseVersionAttributes {
47    pub version: Option<String>,
48    pub platform: Option<Platform>,
49}
50
51pub type PreReleaseVersion = Resource<PreReleaseVersionAttributes>;
52
53/// The writable attributes of `PATCH /v1/builds/{id}`. Unset fields are left
54/// off the body, so App Store Connect keeps their current value.
55#[derive(Debug, Clone, Default, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct BuildUpdateAttributes {
58    /// `false` answers export compliance with "None of the algorithms
59    /// mentioned above": the app uses no encryption, or only what Apple's
60    /// operating system provides (HTTPS through URLSession or
61    /// Security.framework counts). `true` means non-exempt encryption, which
62    /// then needs an `appEncryptionDeclaration`.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub uses_non_exempt_encryption: Option<bool>,
65}
66
67/// How to narrow a `list_builds` query. `None` fields are left off the
68/// request, so the default — all fields `None` — lists every build for the
69/// app, matching the original parameter-free behavior.
70#[derive(Debug, Clone, Default)]
71pub struct BuildFilter {
72    /// `filter[version]` — the build (upload) number, e.g. `"42"`. Note this
73    /// is the build's own `version`, *not* the marketing `preReleaseVersion`
74    /// string; use [`BuildFilter::pre_release_version`] for the latter.
75    pub version: Option<String>,
76    /// `filter[preReleaseVersion.version]` — the marketing version the build
77    /// sits under, e.g. `"1.1.0"`.
78    pub pre_release_version: Option<String>,
79    /// `filter[processingState]` — one of `PROCESSING`, `FAILED`, `INVALID`,
80    /// `VALID`.
81    pub processing_state: Option<String>,
82}
83
84impl BuildFilter {
85    /// Collect the set fields into `filter[...]` query pairs. Returns owned
86    /// strings because the values come from the caller at runtime.
87    fn query_pairs(&self) -> Vec<(&'static str, String)> {
88        let mut pairs = Vec::new();
89        if let Some(v) = &self.version {
90            pairs.push(("filter[version]", v.clone()));
91        }
92        if let Some(v) = &self.pre_release_version {
93            pairs.push(("filter[preReleaseVersion.version]", v.clone()));
94        }
95        if let Some(v) = &self.processing_state {
96            pairs.push(("filter[processingState]", v.clone()));
97        }
98        pairs
99    }
100}
101
102/// Uploaded builds, which a version must have attached.
103///
104/// An extension trait rather than inherent methods, because `Client`
105/// lives in the core crate and Rust only allows inherent impls in the
106/// crate that defines the type. Import it, or the crate's `prelude`,
107/// to call these on a `Client`.
108#[async_trait]
109pub trait BuildsApi {
110    /// `GET /v1/builds?filter[app]={app_id}`, sorted newest-first by
111    /// `uploadedDate` client-side, so the build most recently uploaded (e.g.
112    /// by a fixplist re-upload) is `list_builds(app_id, &filter).await?.first()`.
113    ///
114    /// The top-level collection rather than `/v1/apps/{app_id}/builds`:
115    /// that related-resource endpoint only accepts `fields`/`limit` and
116    /// rejects `filter[...]` and `sort`.
117    ///
118    /// Pass `&BuildFilter::default()` for every build, or set fields to
119    /// narrow by build number, pre-release (marketing) version, or
120    /// processing state.
121    ///
122    /// The `limit=200` this sends is a *page size*, not a ceiling on the
123    /// result: [`Client::list_all`] follows Apple's `links.next` cursor until
124    /// the collection is exhausted, so an app with more than 200 builds still
125    /// returns all of them — 200 is just the largest page ASC allows for this
126    /// collection, which keeps the round-trip count down. Do not read the
127    /// vector's length as "the 200 most recent builds."
128    async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>>;
129
130    /// `GET /v1/builds/{id}` — a single build by its App Store Connect id.
131    async fn get_build(&self, id: &str) -> Result<Build>;
132
133    /// `PATCH /v1/builds/{id}` — today, only to answer export compliance on a
134    /// build uploaded without `ITSAppUsesNonExemptEncryption`.
135    async fn update_build(&self, id: &str, attributes: BuildUpdateAttributes) -> Result<Build>;
136
137    /// `GET /v1/preReleaseVersions?filter[app]={app_id}`, optionally narrowed
138    /// to one platform (the related `/v1/apps/{app_id}/preReleaseVersions`
139    /// endpoint does not accept `filter[platform]`). These are the marketing version strings (e.g. `1.1.0`)
140    /// TestFlight groups builds under; a build's
141    /// `filter[preReleaseVersion.version]` matches one of these.
142    async fn list_pre_release_versions(
143        &self,
144        app_id: &str,
145        filter_platform: Option<Platform>,
146    ) -> Result<Vec<PreReleaseVersion>>;
147}
148
149#[async_trait]
150impl BuildsApi for Client {
151    async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>> {
152        let owned = filter.query_pairs();
153        let mut query: Vec<(&str, &str)> = vec![("filter[app]", app_id), ("limit", "200")];
154        query.extend(owned.iter().map(|(k, v)| (*k, v.as_str())));
155        let mut builds = self
156            .list_all::<BuildAttributes>("/v1/builds", &query)
157            .await?;
158        builds.sort_by(|a, b| b.attributes.uploaded_date.cmp(&a.attributes.uploaded_date));
159        Ok(builds)
160    }
161
162    async fn get_build(&self, id: &str) -> Result<Build> {
163        let path = format!("/v1/builds/{id}");
164        let doc: Document<BuildAttributes> =
165            self.request(Method::GET, &path, &[], None::<&()>).await?;
166        Ok(doc.data)
167    }
168
169    async fn update_build(&self, id: &str, attributes: BuildUpdateAttributes) -> Result<Build> {
170        let path = format!("/v1/builds/{id}");
171        let body = UpdateBody {
172            data: UpdateData {
173                resource_type: RESOURCE_TYPE,
174                id: id.to_string(),
175                attributes,
176            },
177        };
178        let doc: Document<BuildAttributes> =
179            self.request(Method::PATCH, &path, &[], Some(&body)).await?;
180        Ok(doc.data)
181    }
182
183    async fn list_pre_release_versions(
184        &self,
185        app_id: &str,
186        filter_platform: Option<Platform>,
187    ) -> Result<Vec<PreReleaseVersion>> {
188        let mut query: Vec<(&str, &str)> = vec![("filter[app]", app_id), ("limit", "200")];
189        if let Some(platform) = filter_platform {
190            query.push(("filter[platform]", platform.as_query_value()));
191        }
192        self.list_all::<PreReleaseVersionAttributes>("/v1/preReleaseVersions", &query)
193            .await
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use smbcloud_ascapi_core::jsonapi::ListDocument;
201
202    #[test]
203    fn default_filter_sends_no_query_pairs() {
204        // `BuildFilter::default()` must list every build for the app — the
205        // original parameter-free behavior — so it adds no `filter[...]`.
206        assert!(BuildFilter::default().query_pairs().is_empty());
207    }
208
209    #[test]
210    fn each_field_maps_to_its_asc_filter_key() {
211        // The ASC filter keys are load-bearing: `version` is the build
212        // number, but the marketing version is `preReleaseVersion.version`,
213        // not `preReleaseVersion`. Getting these wrong silently returns the
214        // wrong builds (or all of them), so pin every key and value.
215        assert_eq!(
216            BuildFilter {
217                version: Some("42".into()),
218                ..Default::default()
219            }
220            .query_pairs(),
221            vec![("filter[version]", "42".to_string())],
222        );
223        assert_eq!(
224            BuildFilter {
225                pre_release_version: Some("1.1.0".into()),
226                ..Default::default()
227            }
228            .query_pairs(),
229            vec![("filter[preReleaseVersion.version]", "1.1.0".to_string())],
230        );
231        assert_eq!(
232            BuildFilter {
233                processing_state: Some("VALID".into()),
234                ..Default::default()
235            }
236            .query_pairs(),
237            vec![("filter[processingState]", "VALID".to_string())],
238        );
239    }
240
241    #[test]
242    fn all_fields_set_are_all_sent() {
243        let pairs = BuildFilter {
244            version: Some("42".into()),
245            pre_release_version: Some("1.1.0".into()),
246            processing_state: Some("VALID".into()),
247        }
248        .query_pairs();
249        assert_eq!(
250            pairs,
251            vec![
252                ("filter[version]", "42".to_string()),
253                ("filter[preReleaseVersion.version]", "1.1.0".to_string()),
254                ("filter[processingState]", "VALID".to_string()),
255            ],
256        );
257    }
258
259    #[test]
260    fn deserializes_a_builds_list_response() {
261        let body = serde_json::json!({
262            "data": [
263                {
264                    "id": "BUILD1",
265                    "type": "builds",
266                    "attributes": {
267                        "version": "42",
268                        "uploadedDate": "2024-01-02T03:04:05Z",
269                        "expired": false,
270                        "processingState": "VALID",
271                        "minOsVersion": "17.0",
272                        "usesNonExemptEncryption": false
273                    }
274                }
275            ]
276        });
277
278        let doc: ListDocument<BuildAttributes> =
279            serde_json::from_value(body).expect("a builds list must deserialize");
280        assert_eq!(doc.data.len(), 1);
281        let build = &doc.data[0];
282        assert_eq!(build.id, "BUILD1");
283        assert_eq!(build.attributes.version.as_deref(), Some("42"));
284        assert_eq!(build.attributes.processing_state.as_deref(), Some("VALID"));
285        assert_eq!(build.attributes.expired, Some(false));
286        assert_eq!(build.attributes.uses_non_exempt_encryption, Some(false));
287    }
288
289    #[test]
290    fn update_body_sends_only_the_set_attribute() {
291        let body = UpdateBody {
292            data: UpdateData {
293                resource_type: RESOURCE_TYPE,
294                id: "BUILD1".to_string(),
295                attributes: BuildUpdateAttributes {
296                    uses_non_exempt_encryption: Some(false),
297                },
298            },
299        };
300        assert_eq!(
301            serde_json::to_value(&body).unwrap(),
302            serde_json::json!({
303                "data": {
304                    "type": "builds",
305                    "id": "BUILD1",
306                    "attributes": { "usesNonExemptEncryption": false }
307                }
308            }),
309        );
310        assert_eq!(
311            serde_json::to_value(BuildUpdateAttributes::default()).unwrap(),
312            serde_json::json!({}),
313        );
314    }
315
316    #[test]
317    fn deserializes_a_pre_release_versions_response() {
318        let body = serde_json::json!({
319            "data": [
320                {
321                    "id": "PRV1",
322                    "type": "preReleaseVersions",
323                    "attributes": {
324                        "version": "1.1.0",
325                        "platform": "IOS"
326                    }
327                }
328            ]
329        });
330
331        let doc: ListDocument<PreReleaseVersionAttributes> =
332            serde_json::from_value(body).expect("a preReleaseVersions list must deserialize");
333        assert_eq!(doc.data.len(), 1);
334        let prv = &doc.data[0];
335        assert_eq!(prv.attributes.version.as_deref(), Some("1.1.0"));
336        assert_eq!(prv.attributes.platform, Some(Platform::Ios));
337    }
338}