| 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 | |
| 14 | use async_trait::async_trait; |
| 15 | use reqwest::Method; |
| 16 | use serde::{Deserialize, Serialize}; |
| 17 | use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData}; |
| 18 | use smbcloud_ascapi_core::Client; |
| 19 | use smbcloud_ascapi_core::Result; |
| 20 | |
| 21 | use crate::app_store_version::Platform; |
| 22 | |
| 23 | pub const RESOURCE_TYPE: &str = "builds"; |
| 24 | pub const PRE_RELEASE_VERSION_RESOURCE_TYPE: &str = "preReleaseVersions"; |
| 25 | |
| 26 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 27 | #[serde(rename_all = "camelCase")] |
| 28 | pub 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 | |
| 42 | pub type Build = Resource<BuildAttributes>; |
| 43 | |
| 44 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 45 | #[serde(rename_all = "camelCase")] |
| 46 | pub struct PreReleaseVersionAttributes { |
| 47 | pub version: Option<String>, |
| 48 | pub platform: Option<Platform>, |
| 49 | } |
| 50 | |
| 51 | pub 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")] |
| 57 | pub 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)] |
| 71 | pub 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 | |
| 84 | impl 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] |
| 109 | pub 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] |
| 150 | impl 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)] |
| 198 | mod 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 | } |