Skip to main content

smbcloud_ascapi_aso/
app_store_version.rs

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
8use async_trait::async_trait;
9use reqwest::Method;
10use serde::{Deserialize, Serialize};
11use smbcloud_ascapi_core::jsonapi::{
12    CreateBody, CreateData, Document, OptionalDocument, Resource, ResourceId, ToOne, UpdateBody,
13    UpdateData, UpdateRelationshipsBody, UpdateRelationshipsData,
14};
15use smbcloud_ascapi_core::Client;
16use smbcloud_ascapi_core::Result;
17
18use crate::build::{Build, BuildAttributes};
19
20pub const RESOURCE_TYPE: &str = "appStoreVersions";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub 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
34impl 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")]
47pub 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
56pub type AppStoreVersion = Resource<AppStoreVersionAttributes>;
57
58#[derive(Debug, Clone, Serialize)]
59#[serde(rename_all = "camelCase")]
60pub 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")]
69pub 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")]
81pub 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]
95pub 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]
146impl 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")]
255pub struct AppStoreVersionBuildRelationship {
256    pub build: ToOne,
257}
258
259#[cfg(test)]
260mod 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}