1
+//! `ReviewSubmission` — how an App Store Version reaches App Review.
2
+//!
3
+//! Submitting takes three requests: a submission for the app and platform,
4
+//! an item on it pointing at the version, then a PATCH setting `submitted`.
5
+//! App Store Connect keeps at most one unsubmitted (`READY_FOR_REVIEW`)
6
+//! submission per app and platform, and creating a second one fails, so a
7
+//! caller should reuse an open one rather than always creating. The
8
+//! frontend crate's `review` module does that orchestration.
9
+
10
+use async_trait::async_trait;
11
+use reqwest::Method;
12
+use serde::{Deserialize, Serialize};
13
+use smbcloud_ascapi_core::jsonapi::{
14
+ CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
15
+};
16
+use smbcloud_ascapi_core::Client;
17
+use smbcloud_ascapi_core::Result;
18
+
19
+use crate::app_store_version::Platform;
20
+
21
+pub const RESOURCE_TYPE: &str = "reviewSubmissions";
22
+pub const ITEM_RESOURCE_TYPE: &str = "reviewSubmissionItems";
23
+
24
+/// A submission's `state`: `READY_FOR_REVIEW` (open, not yet submitted),
25
+/// `WAITING_FOR_REVIEW`, `IN_REVIEW`, `UNRESOLVED_ISSUES`, `CANCELING`,
26
+/// `COMPLETING`, or `COMPLETE`.
27
+pub const STATE_READY_FOR_REVIEW: &str = "READY_FOR_REVIEW";
28
+
29
+#[derive(Debug, Clone, Serialize, Deserialize)]
30
+#[serde(rename_all = "camelCase")]
31
+pub struct ReviewSubmissionAttributes {
32
+ pub platform: Option<Platform>,
33
+ pub state: Option<String>,
34
+ pub submitted_date: Option<String>,
35
+}
36
+
37
+pub type ReviewSubmission = Resource<ReviewSubmissionAttributes>;
38
+
39
+#[derive(Debug, Clone, Serialize, Deserialize)]
40
+#[serde(rename_all = "camelCase")]
41
+pub struct ReviewSubmissionItemAttributes {
42
+ pub state: Option<String>,
43
+}
44
+
45
+pub type ReviewSubmissionItem = Resource<ReviewSubmissionItemAttributes>;
46
+
47
+#[derive(Debug, Clone, Serialize)]
48
+struct CreateSubmissionAttributes {
49
+ platform: Platform,
50
+}
51
+
52
+#[derive(Debug, Clone, Serialize)]
53
+struct CreateSubmissionRelationships {
54
+ app: ToOne,
55
+}
56
+
57
+/// An item carries only relationships, so it gets its own body type rather
58
+/// than [`CreateBody`] with an empty `attributes` object.
59
+#[derive(Debug, Clone, Serialize)]
60
+struct CreateItemBody {
61
+ data: CreateItemData,
62
+}
63
+
64
+#[derive(Debug, Clone, Serialize)]
65
+struct CreateItemData {
66
+ #[serde(rename = "type")]
67
+ resource_type: &'static str,
68
+ relationships: CreateItemRelationships,
69
+}
70
+
71
+#[derive(Debug, Clone, Serialize)]
72
+#[serde(rename_all = "camelCase")]
73
+struct CreateItemRelationships {
74
+ review_submission: ToOne,
75
+ app_store_version: ToOne,
76
+}
77
+
78
+#[derive(Debug, Clone, Serialize)]
79
+struct SubmittedAttributes {
80
+ submitted: bool,
81
+}
82
+
83
+#[derive(Debug, Clone, Serialize)]
84
+struct CanceledAttributes {
85
+ canceled: bool,
86
+}
87
+
88
+/// Review submissions for an app.
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 ReviewSubmissionsApi {
96
+ /// `GET /v1/reviewSubmissions?filter[app]={app_id}`, optionally narrowed
97
+ /// to one platform and to submissions in one of `states`.
98
+ async fn list_review_submissions(
99
+ &self,
100
+ app_id: &str,
101
+ platform: Option<Platform>,
102
+ states: &[&str],
103
+ ) -> Result<Vec<ReviewSubmission>>;
104
+
105
+ /// `POST /v1/reviewSubmissions` — an empty, unsubmitted submission.
106
+ async fn create_review_submission(
107
+ &self,
108
+ app_id: &str,
109
+ platform: Platform,
110
+ ) -> Result<ReviewSubmission>;
111
+
112
+ /// `GET /v1/reviewSubmissions/{id}/items`.
113
+ async fn list_review_submission_items(
114
+ &self,
115
+ submission_id: &str,
116
+ ) -> Result<Vec<ReviewSubmissionItem>>;
117
+
118
+ /// `POST /v1/reviewSubmissionItems` — put an App Store Version on a
119
+ /// submission.
120
+ async fn add_app_store_version_to_review_submission(
121
+ &self,
122
+ submission_id: &str,
123
+ app_store_version_id: &str,
124
+ ) -> Result<ReviewSubmissionItem>;
125
+
126
+ /// `PATCH /v1/reviewSubmissions/{id}` with `submitted: true` — sends it
127
+ /// to App Review.
128
+ async fn submit_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission>;
129
+
130
+ /// `PATCH /v1/reviewSubmissions/{id}` with `canceled: true` — withdraws a
131
+ /// submission that hasn't been reviewed yet.
132
+ async fn cancel_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission>;
133
+}
134
+
135
+#[async_trait]
136
+impl ReviewSubmissionsApi for Client {
137
+ async fn list_review_submissions(
138
+ &self,
139
+ app_id: &str,
140
+ platform: Option<Platform>,
141
+ states: &[&str],
142
+ ) -> Result<Vec<ReviewSubmission>> {
143
+ let states = states.join(",");
144
+ let query = list_query(app_id, platform, &states);
145
+ self.list_all::<ReviewSubmissionAttributes>("/v1/reviewSubmissions", &query)
146
+ .await
147
+ }
148
+
149
+ async fn create_review_submission(
150
+ &self,
151
+ app_id: &str,
152
+ platform: Platform,
153
+ ) -> Result<ReviewSubmission> {
154
+ let body = CreateBody {
155
+ data: CreateData {
156
+ resource_type: RESOURCE_TYPE,
157
+ attributes: CreateSubmissionAttributes { platform },
158
+ relationships: Some(CreateSubmissionRelationships {
159
+ app: to_one(crate::app::RESOURCE_TYPE, app_id),
160
+ }),
161
+ },
162
+ };
163
+ let doc: Document<ReviewSubmissionAttributes> = self
164
+ .request(Method::POST, "/v1/reviewSubmissions", &[], Some(&body))
165
+ .await?;
166
+ Ok(doc.data)
167
+ }
168
+
169
+ async fn list_review_submission_items(
170
+ &self,
171
+ submission_id: &str,
172
+ ) -> Result<Vec<ReviewSubmissionItem>> {
173
+ let path = format!("/v1/reviewSubmissions/{submission_id}/items");
174
+ self.list_all::<ReviewSubmissionItemAttributes>(&path, &[])
175
+ .await
176
+ }
177
+
178
+ async fn add_app_store_version_to_review_submission(
179
+ &self,
180
+ submission_id: &str,
181
+ app_store_version_id: &str,
182
+ ) -> Result<ReviewSubmissionItem> {
183
+ let body = item_body(submission_id, app_store_version_id);
184
+ let doc: Document<ReviewSubmissionItemAttributes> = self
185
+ .request(Method::POST, "/v1/reviewSubmissionItems", &[], Some(&body))
186
+ .await?;
187
+ Ok(doc.data)
188
+ }
189
+
190
+ async fn submit_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission> {
191
+ patch_submission(self, submission_id, SubmittedAttributes { submitted: true }).await
192
+ }
193
+
194
+ async fn cancel_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission> {
195
+ patch_submission(self, submission_id, CanceledAttributes { canceled: true }).await
196
+ }
197
+}
198
+
199
+async fn patch_submission<A: Serialize + Send + Sync>(
200
+ client: &Client,
201
+ id: &str,
202
+ attributes: A,
203
+) -> Result<ReviewSubmission> {
204
+ let path = format!("/v1/reviewSubmissions/{id}");
205
+ let body = UpdateBody {
206
+ data: UpdateData {
207
+ resource_type: RESOURCE_TYPE,
208
+ id: id.to_string(),
209
+ attributes,
210
+ },
211
+ };
212
+ let doc: Document<ReviewSubmissionAttributes> = client
213
+ .request(Method::PATCH, &path, &[], Some(&body))
214
+ .await?;
215
+ Ok(doc.data)
216
+}
217
+
218
+fn to_one(resource_type: &'static str, id: &str) -> ToOne {
219
+ ToOne {
220
+ data: ResourceId {
221
+ resource_type,
222
+ id: id.to_string(),
223
+ },
224
+ }
225
+}
226
+
227
+fn list_query<'a>(
228
+ app_id: &'a str,
229
+ platform: Option<Platform>,
230
+ states: &'a str,
231
+) -> Vec<(&'static str, &'a str)> {
232
+ let mut query = vec![("filter[app]", app_id), ("limit", "200")];
233
+ if let Some(platform) = platform {
234
+ query.push(("filter[platform]", platform.as_query_value()));
235
+ }
236
+ if !states.is_empty() {
237
+ query.push(("filter[state]", states));
238
+ }
239
+ query
240
+}
241
+
242
+fn item_body(submission_id: &str, app_store_version_id: &str) -> CreateItemBody {
243
+ CreateItemBody {
244
+ data: CreateItemData {
245
+ resource_type: ITEM_RESOURCE_TYPE,
246
+ relationships: CreateItemRelationships {
247
+ review_submission: to_one(RESOURCE_TYPE, submission_id),
248
+ app_store_version: to_one(
249
+ crate::app_store_version::RESOURCE_TYPE,
250
+ app_store_version_id,
251
+ ),
252
+ },
253
+ },
254
+ }
255
+}
256
+
257
+#[cfg(test)]
258
+mod tests {
259
+ use super::*;
260
+ use serde_json::json;
261
+
262
+ #[test]
263
+ fn list_query_pins_the_filter_keys() {
264
+ assert_eq!(
265
+ list_query("6766108771", Some(Platform::VisionOs), "READY_FOR_REVIEW"),
266
+ vec![
267
+ ("filter[app]", "6766108771"),
268
+ ("limit", "200"),
269
+ ("filter[platform]", "VISION_OS"),
270
+ ("filter[state]", "READY_FOR_REVIEW"),
271
+ ]
272
+ );
273
+ assert_eq!(
274
+ list_query("6766108771", None, ""),
275
+ vec![("filter[app]", "6766108771"), ("limit", "200")]
276
+ );
277
+ }
278
+
279
+ #[test]
280
+ fn create_body_names_the_platform_and_app() {
281
+ let body = CreateBody {
282
+ data: CreateData {
283
+ resource_type: RESOURCE_TYPE,
284
+ attributes: CreateSubmissionAttributes {
285
+ platform: Platform::VisionOs,
286
+ },
287
+ relationships: Some(CreateSubmissionRelationships {
288
+ app: to_one(crate::app::RESOURCE_TYPE, "6766108771"),
289
+ }),
290
+ },
291
+ };
292
+ assert_eq!(
293
+ serde_json::to_value(body).unwrap(),
294
+ json!({ "data": {
295
+ "type": "reviewSubmissions",
296
+ "attributes": { "platform": "VISION_OS" },
297
+ "relationships": { "app": { "data": { "type": "apps", "id": "6766108771" } } }
298
+ }})
299
+ );
300
+ }
301
+
302
+ /// App Store Connect wants camelCase relationship names and no
303
+ /// `attributes` key on an item.
304
+ #[test]
305
+ fn item_body_has_relationships_only() {
306
+ assert_eq!(
307
+ serde_json::to_value(item_body("SUB1", "VER1")).unwrap(),
308
+ json!({ "data": {
309
+ "type": "reviewSubmissionItems",
310
+ "relationships": {
311
+ "reviewSubmission": { "data": { "type": "reviewSubmissions", "id": "SUB1" } },
312
+ "appStoreVersion": { "data": { "type": "appStoreVersions", "id": "VER1" } }
313
+ }
314
+ }})
315
+ );
316
+ }
317
+
318
+ #[test]
319
+ fn submit_and_cancel_patch_one_flag() {
320
+ let submit = UpdateBody {
321
+ data: UpdateData {
322
+ resource_type: RESOURCE_TYPE,
323
+ id: "SUB1".to_string(),
324
+ attributes: SubmittedAttributes { submitted: true },
325
+ },
326
+ };
327
+ assert_eq!(
328
+ serde_json::to_value(submit).unwrap(),
329
+ json!({ "data": { "type": "reviewSubmissions", "id": "SUB1",
330
+ "attributes": { "submitted": true } } })
331
+ );
332
+ assert_eq!(
333
+ serde_json::to_value(CanceledAttributes { canceled: true }).unwrap(),
334
+ json!({ "canceled": true })
335
+ );
336
+ }
337
+
338
+ #[test]
339
+ fn deserializes_a_submission() {
340
+ let doc: Document<ReviewSubmissionAttributes> = serde_json::from_value(json!({
341
+ "data": { "id": "SUB1", "type": "reviewSubmissions",
342
+ "attributes": { "platform": "VISION_OS", "state": "READY_FOR_REVIEW",
343
+ "submittedDate": null } }
344
+ }))
345
+ .unwrap();
346
+ assert_eq!(doc.data.attributes.platform, Some(Platform::VisionOs));
347
+ assert_eq!(
348
+ doc.data.attributes.state.as_deref(),
349
+ Some(STATE_READY_FOR_REVIEW)
350
+ );
351
+ }
352
+}