Skip to main content

smbcloud_ascapi_aso/
app_screenshot.rs

1//! An `AppScreenshot` is one image binary within an `AppScreenshotSet`.
2//! Uploading one is a three-step JSON:API + raw-HTTP dance (see
3//! <https://developer.apple.com/documentation/appstoreconnectapi/uploading-assets-to-app-store-connect>):
4//!
5//! 1. `POST /v1/appScreenshots` reserves the asset and returns pre-signed
6//!    `uploadOperations` (one or more chunked PUT requests).
7//! 2. The image bytes are PUT to each operation's URL with its exact
8//!    `requestHeaders` — these URLs are *not* App Store Connect API
9//!    endpoints, so no bearer token is sent.
10//! 3. `PATCH /v1/appScreenshots/{id}` with `uploaded: true` and an MD5
11//!    checksum of the file commits the upload.
12//!
13//! [`AppScreenshotsApi::upload_app_screenshot`] does all three steps in one call.
14
15use async_trait::async_trait;
16use reqwest::Method;
17use serde::{Deserialize, Deserializer, Serialize};
18use smbcloud_ascapi_core::jsonapi::{
19    CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
20};
21use smbcloud_ascapi_core::Client;
22use smbcloud_ascapi_core::Result;
23
24pub const RESOURCE_TYPE: &str = "appScreenshots";
25
26/// App Store Connect sends `uploadOperations: null` (not an omitted field)
27/// once a screenshot has been committed — plain `#[serde(default)]` only
28/// covers a *missing* field, not an explicit `null`, so `Vec<T>` fails to
29/// deserialize without this.
30fn null_as_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
31where
32    D: Deserializer<'de>,
33    T: Default + Deserialize<'de>,
34{
35    Ok(Option::deserialize(deserializer)?.unwrap_or_default())
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct RequestHeader {
41    pub name: String,
42    pub value: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct UploadOperation {
48    pub method: String,
49    pub url: String,
50    #[serde(default)]
51    pub length: u64,
52    #[serde(default)]
53    pub offset: u64,
54    #[serde(default, deserialize_with = "null_as_default")]
55    pub request_headers: Vec<RequestHeader>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct AssetDeliveryState {
61    pub state: String,
62    #[serde(default, deserialize_with = "null_as_default")]
63    pub errors: Vec<serde_json::Value>,
64    #[serde(default, deserialize_with = "null_as_default")]
65    pub warnings: Vec<serde_json::Value>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct AppScreenshotAttributes {
71    pub file_name: String,
72    pub file_size: u64,
73    #[serde(default)]
74    pub source_file_checksum: Option<String>,
75    #[serde(default)]
76    pub image_asset: Option<serde_json::Value>,
77    #[serde(default)]
78    pub asset_delivery_state: Option<AssetDeliveryState>,
79    #[serde(default, deserialize_with = "null_as_default")]
80    pub upload_operations: Vec<UploadOperation>,
81    #[serde(default)]
82    pub uploaded: Option<bool>,
83}
84
85pub type AppScreenshot = Resource<AppScreenshotAttributes>;
86
87#[derive(Debug, Clone, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct AppScreenshotCreateAttributes {
90    pub file_name: String,
91    pub file_size: u64,
92}
93
94#[derive(Debug, Clone, Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct AppScreenshotSetRelationship {
97    pub app_screenshot_set: ToOne,
98}
99
100#[derive(Debug, Clone, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct AppScreenshotCommitAttributes {
103    pub uploaded: bool,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub source_file_checksum: Option<String>,
106}
107
108/// Screenshot image binaries within a set.
109///
110/// An extension trait rather than inherent methods, because `Client`
111/// lives in the core crate and Rust only allows inherent impls in the
112/// crate that defines the type. Import it, or the crate's `prelude`,
113/// to call these on a `Client`.
114#[async_trait]
115pub trait AppScreenshotsApi {
116    /// `GET /v1/appScreenshotSets/{id}/appScreenshots`.
117    async fn list_app_screenshots(&self, app_screenshot_set_id: &str)
118        -> Result<Vec<AppScreenshot>>;
119
120    /// `POST /v1/appScreenshots` — reserves the asset and returns
121    /// pre-signed `uploadOperations`. Prefer [`AppScreenshotsApi::upload_app_screenshot`]
122    /// unless you need to drive the upload/commit steps yourself.
123    async fn create_app_screenshot(
124        &self,
125        app_screenshot_set_id: &str,
126        attributes: AppScreenshotCreateAttributes,
127    ) -> Result<AppScreenshot>;
128
129    /// PUTs `bytes` to every one of `screenshot`'s reserved
130    /// `uploadOperations`, slicing the buffer per operation's `offset` /
131    /// `length`. Does not commit the upload — call
132    /// [`AppScreenshotsApi::commit_app_screenshot`] (or use
133    /// [`AppScreenshotsApi::upload_app_screenshot`]) after this succeeds.
134    async fn upload_app_screenshot_bytes(
135        &self,
136        screenshot: &AppScreenshot,
137        bytes: &[u8],
138    ) -> Result<()>;
139
140    /// `PATCH /v1/appScreenshots/{id}` with `uploaded: true` — tells App
141    /// Store Connect the binary is fully transferred so it can start
142    /// processing/validating the asset.
143    async fn commit_app_screenshot(
144        &self,
145        id: &str,
146        source_file_checksum: String,
147    ) -> Result<AppScreenshot>;
148
149    /// `DELETE /v1/appScreenshots/{id}`.
150    async fn delete_app_screenshot(&self, id: &str) -> Result<()>;
151
152    /// Reserve, upload, and commit an image in one call: `bytes` becomes an
153    /// `AppScreenshot` under `app_screenshot_set_id`, named `file_name`.
154    /// This is the entry point most callers want; the lower-level
155    /// `create_app_screenshot` / `upload_app_screenshot_bytes` /
156    /// `commit_app_screenshot` are exposed for callers that need to
157    /// checkpoint between steps (e.g. a CLI resuming a failed upload).
158    async fn upload_app_screenshot(
159        &self,
160        app_screenshot_set_id: &str,
161        file_name: String,
162        bytes: Vec<u8>,
163    ) -> Result<AppScreenshot>;
164}
165
166#[async_trait]
167impl AppScreenshotsApi for Client {
168    async fn list_app_screenshots(
169        &self,
170        app_screenshot_set_id: &str,
171    ) -> Result<Vec<AppScreenshot>> {
172        let path = format!("/v1/appScreenshotSets/{app_screenshot_set_id}/appScreenshots");
173        self.list_all::<AppScreenshotAttributes>(&path, &[]).await
174    }
175
176    async fn create_app_screenshot(
177        &self,
178        app_screenshot_set_id: &str,
179        attributes: AppScreenshotCreateAttributes,
180    ) -> Result<AppScreenshot> {
181        let body = CreateBody {
182            data: CreateData {
183                resource_type: RESOURCE_TYPE,
184                attributes,
185                relationships: Some(AppScreenshotSetRelationship {
186                    app_screenshot_set: ToOne {
187                        data: ResourceId {
188                            resource_type: crate::app_screenshot_set::RESOURCE_TYPE,
189                            id: app_screenshot_set_id.to_string(),
190                        },
191                    },
192                }),
193            },
194        };
195        let doc: Document<AppScreenshotAttributes> = self
196            .request(Method::POST, "/v1/appScreenshots", &[], Some(&body))
197            .await?;
198        Ok(doc.data)
199    }
200
201    async fn upload_app_screenshot_bytes(
202        &self,
203        screenshot: &AppScreenshot,
204        bytes: &[u8],
205    ) -> Result<()> {
206        for op in &screenshot.attributes.upload_operations {
207            let start = op.offset as usize;
208            let end = start + op.length as usize;
209            let chunk = bytes
210                .get(start..end)
211                .map(|slice| slice.to_vec())
212                .unwrap_or_default();
213            let headers: Vec<(String, String)> = op
214                .request_headers
215                .iter()
216                .map(|h| (h.name.clone(), h.value.clone()))
217                .collect();
218            let method: Method = op.method.parse().unwrap_or(Method::PUT);
219            self.upload_bytes(method, &op.url, &headers, chunk).await?;
220        }
221        Ok(())
222    }
223
224    async fn commit_app_screenshot(
225        &self,
226        id: &str,
227        source_file_checksum: String,
228    ) -> Result<AppScreenshot> {
229        let path = format!("/v1/appScreenshots/{id}");
230        let body = UpdateBody {
231            data: UpdateData {
232                resource_type: RESOURCE_TYPE,
233                id: id.to_string(),
234                attributes: AppScreenshotCommitAttributes {
235                    uploaded: true,
236                    source_file_checksum: Some(source_file_checksum),
237                },
238            },
239        };
240        let doc: Document<AppScreenshotAttributes> =
241            self.request(Method::PATCH, &path, &[], Some(&body)).await?;
242        Ok(doc.data)
243    }
244
245    async fn delete_app_screenshot(&self, id: &str) -> Result<()> {
246        let path = format!("/v1/appScreenshots/{id}");
247        self.request_no_content::<()>(Method::DELETE, &path, &[], None)
248            .await
249    }
250
251    async fn upload_app_screenshot(
252        &self,
253        app_screenshot_set_id: &str,
254        file_name: String,
255        bytes: Vec<u8>,
256    ) -> Result<AppScreenshot> {
257        let checksum = format!("{:x}", md5::compute(&bytes));
258        let screenshot = self
259            .create_app_screenshot(
260                app_screenshot_set_id,
261                AppScreenshotCreateAttributes {
262                    file_name,
263                    file_size: bytes.len() as u64,
264                },
265            )
266            .await?;
267        self.upload_app_screenshot_bytes(&screenshot, &bytes)
268            .await?;
269        self.commit_app_screenshot(&screenshot.id, checksum).await
270    }
271}