main
rs 271 lines 9.47 KB
Raw
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
15 use async_trait::async_trait;
16 use reqwest::Method;
17 use serde::{Deserialize, Deserializer, Serialize};
18 use smbcloud_ascapi_core::jsonapi::{
19 CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
20 };
21 use smbcloud_ascapi_core::Client;
22 use smbcloud_ascapi_core::Result;
23
24 pub 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.
30 fn null_as_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
31 where
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")]
40 pub struct RequestHeader {
41 pub name: String,
42 pub value: String,
43 }
44
45 #[derive(Debug, Clone, Serialize, Deserialize)]
46 #[serde(rename_all = "camelCase")]
47 pub 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")]
60 pub 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")]
70 pub 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
85 pub type AppScreenshot = Resource<AppScreenshotAttributes>;
86
87 #[derive(Debug, Clone, Serialize)]
88 #[serde(rename_all = "camelCase")]
89 pub struct AppScreenshotCreateAttributes {
90 pub file_name: String,
91 pub file_size: u64,
92 }
93
94 #[derive(Debug, Clone, Serialize)]
95 #[serde(rename_all = "camelCase")]
96 pub struct AppScreenshotSetRelationship {
97 pub app_screenshot_set: ToOne,
98 }
99
100 #[derive(Debug, Clone, Serialize)]
101 #[serde(rename_all = "camelCase")]
102 pub 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]
115 pub 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]
167 impl 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 }