development
rs 342 lines 12.2 KB
Raw
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 }