| 1 | //! An `AppStoreVersion` is App Store Connect's per-platform record for an |
| 2 | //! app: `platform` is one of `IOS` / `MAC_OS` / `TV_OS` / `VISION_OS`. |
| 3 | //! Since there is no direct "create app for platform X" endpoint (see |
| 4 | //! [`crate::app`]), creating an `AppStoreVersion` for a platform an app |
| 5 | //! doesn't have one for yet — e.g. adding visionOS to an app that so far |
| 6 | //! only ships on iOS/macOS — *is* how you add that platform. |
| 7 | |
| 8 | use async_trait::async_trait; |
| 9 | use reqwest::Method; |
| 10 | use serde::{Deserialize, Serialize}; |
| 11 | use smbcloud_ascapi_core::jsonapi::{ |
| 12 | CreateBody, CreateData, Document, OptionalDocument, Resource, ResourceId, ToOne, UpdateBody, |
| 13 | UpdateData, UpdateRelationshipsBody, UpdateRelationshipsData, |
| 14 | }; |
| 15 | use smbcloud_ascapi_core::Client; |
| 16 | use smbcloud_ascapi_core::Result; |
| 17 | |
| 18 | use crate::build::{Build, BuildAttributes}; |
| 19 | |
| 20 | pub const RESOURCE_TYPE: &str = "appStoreVersions"; |
| 21 | |
| 22 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 23 | pub enum Platform { |
| 24 | #[serde(rename = "IOS")] |
| 25 | Ios, |
| 26 | #[serde(rename = "MAC_OS")] |
| 27 | MacOs, |
| 28 | #[serde(rename = "TV_OS")] |
| 29 | TvOs, |
| 30 | #[serde(rename = "VISION_OS")] |
| 31 | VisionOs, |
| 32 | } |
| 33 | |
| 34 | impl Platform { |
| 35 | pub(crate) fn as_query_value(self) -> &'static str { |
| 36 | match self { |
| 37 | Platform::Ios => "IOS", |
| 38 | Platform::MacOs => "MAC_OS", |
| 39 | Platform::TvOs => "TV_OS", |
| 40 | Platform::VisionOs => "VISION_OS", |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 46 | #[serde(rename_all = "camelCase")] |
| 47 | pub struct AppStoreVersionAttributes { |
| 48 | pub platform: Platform, |
| 49 | pub version_string: Option<String>, |
| 50 | pub app_version_state: Option<String>, |
| 51 | pub copyright: Option<String>, |
| 52 | pub created_date: Option<String>, |
| 53 | pub downloadable: Option<bool>, |
| 54 | } |
| 55 | |
| 56 | pub type AppStoreVersion = Resource<AppStoreVersionAttributes>; |
| 57 | |
| 58 | #[derive(Debug, Clone, Serialize)] |
| 59 | #[serde(rename_all = "camelCase")] |
| 60 | pub struct AppStoreVersionCreateAttributes { |
| 61 | pub platform: Platform, |
| 62 | pub version_string: String, |
| 63 | #[serde(skip_serializing_if = "Option::is_none")] |
| 64 | pub copyright: Option<String>, |
| 65 | } |
| 66 | |
| 67 | #[derive(Debug, Clone, Serialize)] |
| 68 | #[serde(rename_all = "camelCase")] |
| 69 | pub struct AppStoreVersionRelationships { |
| 70 | pub app: ToOne, |
| 71 | } |
| 72 | |
| 73 | /// Editable attributes for a `PATCH /v1/appStoreVersions/{id}`. App Store |
| 74 | /// Connect has no "rename a version" action: changing `version_string` on |
| 75 | /// the platform's editable version (e.g. a staged `1.0.9` to `1.1.0`) is a |
| 76 | /// plain attribute update. Only the fields set here are sent, so passing |
| 77 | /// just `version_string` leaves `copyright` untouched. The version must be |
| 78 | /// in an editable state (`PREPARE_FOR_SUBMISSION`, `DEVELOPER_REJECTED`). |
| 79 | #[derive(Debug, Clone, Default, Serialize)] |
| 80 | #[serde(rename_all = "camelCase")] |
| 81 | pub struct AppStoreVersionUpdateFields { |
| 82 | #[serde(skip_serializing_if = "Option::is_none")] |
| 83 | pub version_string: Option<String>, |
| 84 | #[serde(skip_serializing_if = "Option::is_none")] |
| 85 | pub copyright: Option<String>, |
| 86 | } |
| 87 | |
| 88 | /// Per-platform version records, the unit the store reviews. |
| 89 | /// |
| 90 | /// An extension trait rather than inherent methods, because `Client` |
| 91 | /// lives in the core crate and Rust only allows inherent impls in the |
| 92 | /// crate that defines the type. Import it, or the crate's `prelude`, |
| 93 | /// to call these on a `Client`. |
| 94 | #[async_trait] |
| 95 | pub trait AppStoreVersionsApi { |
| 96 | /// `GET /v1/apps/{app_id}/appStoreVersions`, optionally narrowed to one |
| 97 | /// platform. |
| 98 | async fn list_app_store_versions( |
| 99 | &self, |
| 100 | app_id: &str, |
| 101 | filter_platform: Option<Platform>, |
| 102 | ) -> Result<Vec<AppStoreVersion>>; |
| 103 | |
| 104 | async fn get_app_store_version(&self, id: &str) -> Result<AppStoreVersion>; |
| 105 | |
| 106 | /// `POST /v1/appStoreVersions`. |
| 107 | async fn create_app_store_version( |
| 108 | &self, |
| 109 | app_id: &str, |
| 110 | attributes: AppStoreVersionCreateAttributes, |
| 111 | ) -> Result<AppStoreVersion>; |
| 112 | |
| 113 | /// `PATCH /v1/appStoreVersions/{id}` with attributes — e.g. change the |
| 114 | /// version string of the platform's editable version. Apple exposes no |
| 115 | /// separate rename action; this is how `1.0.9` becomes `1.1.0`. |
| 116 | async fn update_app_store_version( |
| 117 | &self, |
| 118 | id: &str, |
| 119 | fields: AppStoreVersionUpdateFields, |
| 120 | ) -> Result<AppStoreVersion>; |
| 121 | |
| 122 | /// `DELETE /v1/appStoreVersions/{id}` — only allowed while the version |
| 123 | /// hasn't been submitted for review. |
| 124 | async fn delete_app_store_version(&self, id: &str) -> Result<()>; |
| 125 | |
| 126 | /// `PATCH /v1/appStoreVersions/{id}` with a `build` relationship — |
| 127 | /// attaches (or replaces) the `Build` this version will submit. Needed |
| 128 | /// after a re-upload fixes an `INVALID_BINARY` version: the fixed |
| 129 | /// build must be attached here before the version can be resubmitted |
| 130 | /// for review (resubmission itself is outside this crate's scope — see |
| 131 | /// [`crate::build`]'s module doc). |
| 132 | async fn set_app_store_version_build( |
| 133 | &self, |
| 134 | id: &str, |
| 135 | build_id: &str, |
| 136 | ) -> Result<AppStoreVersion>; |
| 137 | |
| 138 | /// `GET /v1/appStoreVersions/{id}/build` — the build currently attached |
| 139 | /// to this version, or `None` when there isn't one. The version resource |
| 140 | /// itself only carries a link to it, so this is how to confirm what a |
| 141 | /// submission would send. |
| 142 | async fn get_app_store_version_build(&self, id: &str) -> Result<Option<Build>>; |
| 143 | } |
| 144 | |
| 145 | #[async_trait] |
| 146 | impl AppStoreVersionsApi for Client { |
| 147 | async fn list_app_store_versions( |
| 148 | &self, |
| 149 | app_id: &str, |
| 150 | filter_platform: Option<Platform>, |
| 151 | ) -> Result<Vec<AppStoreVersion>> { |
| 152 | let path = format!("/v1/apps/{app_id}/appStoreVersions"); |
| 153 | let mut query = Vec::new(); |
| 154 | if let Some(platform) = filter_platform { |
| 155 | query.push(("filter[platform]", platform.as_query_value())); |
| 156 | } |
| 157 | self.list_all::<AppStoreVersionAttributes>(&path, &query) |
| 158 | .await |
| 159 | } |
| 160 | |
| 161 | async fn get_app_store_version(&self, id: &str) -> Result<AppStoreVersion> { |
| 162 | let path = format!("/v1/appStoreVersions/{id}"); |
| 163 | let doc: Document<AppStoreVersionAttributes> = |
| 164 | self.request(Method::GET, &path, &[], None::<&()>).await?; |
| 165 | Ok(doc.data) |
| 166 | } |
| 167 | |
| 168 | async fn create_app_store_version( |
| 169 | &self, |
| 170 | app_id: &str, |
| 171 | attributes: AppStoreVersionCreateAttributes, |
| 172 | ) -> Result<AppStoreVersion> { |
| 173 | let body = CreateBody { |
| 174 | data: CreateData { |
| 175 | resource_type: RESOURCE_TYPE, |
| 176 | attributes, |
| 177 | relationships: Some(AppStoreVersionRelationships { |
| 178 | app: ToOne { |
| 179 | data: ResourceId { |
| 180 | resource_type: crate::app::RESOURCE_TYPE, |
| 181 | id: app_id.to_string(), |
| 182 | }, |
| 183 | }, |
| 184 | }), |
| 185 | }, |
| 186 | }; |
| 187 | let doc: Document<AppStoreVersionAttributes> = self |
| 188 | .request(Method::POST, "/v1/appStoreVersions", &[], Some(&body)) |
| 189 | .await?; |
| 190 | Ok(doc.data) |
| 191 | } |
| 192 | |
| 193 | async fn update_app_store_version( |
| 194 | &self, |
| 195 | id: &str, |
| 196 | fields: AppStoreVersionUpdateFields, |
| 197 | ) -> Result<AppStoreVersion> { |
| 198 | let path = format!("/v1/appStoreVersions/{id}"); |
| 199 | let body = UpdateBody { |
| 200 | data: UpdateData { |
| 201 | resource_type: RESOURCE_TYPE, |
| 202 | id: id.to_string(), |
| 203 | attributes: fields, |
| 204 | }, |
| 205 | }; |
| 206 | let doc: Document<AppStoreVersionAttributes> = |
| 207 | self.request(Method::PATCH, &path, &[], Some(&body)).await?; |
| 208 | Ok(doc.data) |
| 209 | } |
| 210 | |
| 211 | async fn delete_app_store_version(&self, id: &str) -> Result<()> { |
| 212 | let path = format!("/v1/appStoreVersions/{id}"); |
| 213 | self.request_no_content::<()>(Method::DELETE, &path, &[], None) |
| 214 | .await |
| 215 | } |
| 216 | |
| 217 | async fn set_app_store_version_build( |
| 218 | &self, |
| 219 | id: &str, |
| 220 | build_id: &str, |
| 221 | ) -> Result<AppStoreVersion> { |
| 222 | let path = format!("/v1/appStoreVersions/{id}"); |
| 223 | let body = UpdateRelationshipsBody { |
| 224 | data: UpdateRelationshipsData { |
| 225 | resource_type: RESOURCE_TYPE, |
| 226 | id: id.to_string(), |
| 227 | relationships: AppStoreVersionBuildRelationship { |
| 228 | build: ToOne { |
| 229 | data: ResourceId { |
| 230 | resource_type: crate::build::RESOURCE_TYPE, |
| 231 | id: build_id.to_string(), |
| 232 | }, |
| 233 | }, |
| 234 | }, |
| 235 | }, |
| 236 | }; |
| 237 | let doc: Document<AppStoreVersionAttributes> = |
| 238 | self.request(Method::PATCH, &path, &[], Some(&body)).await?; |
| 239 | Ok(doc.data) |
| 240 | } |
| 241 | |
| 242 | async fn get_app_store_version_build(&self, id: &str) -> Result<Option<Build>> { |
| 243 | let path = format!("/v1/appStoreVersions/{id}/build"); |
| 244 | let doc: OptionalDocument<BuildAttributes> = |
| 245 | self.request(Method::GET, &path, &[], None::<&()>).await?; |
| 246 | Ok(doc.data) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | /// The `relationships` payload for pointing an App Store Version at a |
| 251 | /// build. Its own type because App Store Connect wants a PATCH body that |
| 252 | /// carries relationships and no attributes. |
| 253 | #[derive(Debug, Clone, Serialize)] |
| 254 | #[serde(rename_all = "camelCase")] |
| 255 | pub struct AppStoreVersionBuildRelationship { |
| 256 | pub build: ToOne, |
| 257 | } |
| 258 | |
| 259 | #[cfg(test)] |
| 260 | mod tests { |
| 261 | use super::*; |
| 262 | use serde_json::json; |
| 263 | |
| 264 | /// `PATCH` responses are deserialized as a whole |
| 265 | /// [`AppStoreVersionAttributes`], so every attribute except `platform` |
| 266 | /// has to stay optional — otherwise a response that carries only the |
| 267 | /// platform (as a rename does) fails to parse after App Store Connect |
| 268 | /// has already applied the update. Making one of these required turns |
| 269 | /// this into a failure. |
| 270 | #[test] |
| 271 | fn a_version_response_omitting_every_optional_attribute_still_parses() { |
| 272 | let doc: Document<AppStoreVersionAttributes> = serde_json::from_value(json!({ |
| 273 | "data": { |
| 274 | "id": "b1f9f3a2-6f9a-4a4e-8a4f-3f5b0a9c0d11", |
| 275 | "type": "appStoreVersions", |
| 276 | "attributes": { "platform": "IOS" } |
| 277 | } |
| 278 | })) |
| 279 | .unwrap(); |
| 280 | |
| 281 | assert_eq!(doc.data.attributes.platform, Platform::Ios); |
| 282 | assert!(doc.data.attributes.version_string.is_none()); |
| 283 | assert!(doc.data.attributes.copyright.is_none()); |
| 284 | } |
| 285 | |
| 286 | /// The other half of that contract: a response that does carry the |
| 287 | /// attributes round-trips, so `versionString` is read back from the |
| 288 | /// rename request's own reply. |
| 289 | #[test] |
| 290 | fn a_version_response_reads_back_the_renamed_string() { |
| 291 | let doc: Document<AppStoreVersionAttributes> = serde_json::from_value(json!({ |
| 292 | "data": { |
| 293 | "id": "b1f9f3a2-6f9a-4a4e-8a4f-3f5b0a9c0d11", |
| 294 | "type": "appStoreVersions", |
| 295 | "attributes": { |
| 296 | "platform": "MAC_OS", |
| 297 | "versionString": "1.1.0", |
| 298 | "appVersionState": "PREPARE_FOR_SUBMISSION", |
| 299 | "copyright": "2026 SplitFire AB" |
| 300 | } |
| 301 | } |
| 302 | })) |
| 303 | .unwrap(); |
| 304 | |
| 305 | assert_eq!(doc.data.attributes.platform, Platform::MacOs); |
| 306 | assert_eq!(doc.data.attributes.version_string.as_deref(), Some("1.1.0")); |
| 307 | assert_eq!( |
| 308 | doc.data.attributes.app_version_state.as_deref(), |
| 309 | Some("PREPARE_FOR_SUBMISSION") |
| 310 | ); |
| 311 | } |
| 312 | |
| 313 | /// Apple answers `{"data": null}` for a version with no build attached; |
| 314 | /// that has to parse as "none", not as an error. |
| 315 | #[test] |
| 316 | fn a_null_build_body_parses_to_none() { |
| 317 | let doc: OptionalDocument<BuildAttributes> = |
| 318 | serde_json::from_value(json!({ "data": null })).unwrap(); |
| 319 | assert!(doc.data.is_none()); |
| 320 | } |
| 321 | |
| 322 | #[test] |
| 323 | fn a_populated_build_body_parses_with_its_version() { |
| 324 | let doc: OptionalDocument<BuildAttributes> = serde_json::from_value(json!({ |
| 325 | "data": { |
| 326 | "id": "build-1", |
| 327 | "type": "builds", |
| 328 | "attributes": { |
| 329 | "version": "42", |
| 330 | "processingState": "VALID", |
| 331 | "usesNonExemptEncryption": false |
| 332 | } |
| 333 | } |
| 334 | })) |
| 335 | .unwrap(); |
| 336 | |
| 337 | let build = doc.data.unwrap(); |
| 338 | assert_eq!(build.id, "build-1"); |
| 339 | assert_eq!(build.attributes.version.as_deref(), Some("42")); |
| 340 | assert_eq!(build.attributes.uses_non_exempt_encryption, Some(false)); |
| 341 | } |
| 342 | } |