development
rs 378 lines 12.4 KB
Raw
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 /// A submission App Review sent back. It stays open, still holding its
30 /// items, and resubmitting means submitting this same one again.
31 pub const STATE_UNRESOLVED_ISSUES: &str = "UNRESOLVED_ISSUES";
32
33 /// States of a submission that is still open for this app and platform,
34 /// so a new submission can't be created alongside it.
35 pub const OPEN_STATES: [&str; 2] = [STATE_READY_FOR_REVIEW, STATE_UNRESOLVED_ISSUES];
36
37 #[derive(Debug, Clone, Serialize, Deserialize)]
38 #[serde(rename_all = "camelCase")]
39 pub struct ReviewSubmissionAttributes {
40 pub platform: Option<Platform>,
41 pub state: Option<String>,
42 pub submitted_date: Option<String>,
43 }
44
45 pub type ReviewSubmission = Resource<ReviewSubmissionAttributes>;
46
47 #[derive(Debug, Clone, Serialize, Deserialize)]
48 #[serde(rename_all = "camelCase")]
49 pub struct ReviewSubmissionItemAttributes {
50 pub state: Option<String>,
51 }
52
53 pub type ReviewSubmissionItem = Resource<ReviewSubmissionItemAttributes>;
54
55 #[derive(Debug, Clone, Serialize)]
56 struct CreateSubmissionAttributes {
57 platform: Platform,
58 }
59
60 #[derive(Debug, Clone, Serialize)]
61 struct CreateSubmissionRelationships {
62 app: ToOne,
63 }
64
65 /// An item carries only relationships, so it gets its own body type rather
66 /// than [`CreateBody`] with an empty `attributes` object.
67 #[derive(Debug, Clone, Serialize)]
68 struct CreateItemBody {
69 data: CreateItemData,
70 }
71
72 #[derive(Debug, Clone, Serialize)]
73 struct CreateItemData {
74 #[serde(rename = "type")]
75 resource_type: &'static str,
76 relationships: CreateItemRelationships,
77 }
78
79 #[derive(Debug, Clone, Serialize)]
80 #[serde(rename_all = "camelCase")]
81 struct CreateItemRelationships {
82 review_submission: ToOne,
83 app_store_version: ToOne,
84 }
85
86 #[derive(Debug, Clone, Serialize)]
87 struct SubmittedAttributes {
88 submitted: bool,
89 }
90
91 #[derive(Debug, Clone, Serialize)]
92 struct CanceledAttributes {
93 canceled: bool,
94 }
95
96 /// Review submissions for an app.
97 ///
98 /// An extension trait rather than inherent methods, because `Client`
99 /// lives in the core crate and Rust only allows inherent impls in the
100 /// crate that defines the type. Import it, or the crate's `prelude`,
101 /// to call these on a `Client`.
102 #[async_trait]
103 pub trait ReviewSubmissionsApi {
104 /// `GET /v1/reviewSubmissions?filter[app]={app_id}`, optionally narrowed
105 /// to one platform and to submissions in one of `states`.
106 async fn list_review_submissions(
107 &self,
108 app_id: &str,
109 platform: Option<Platform>,
110 states: &[&str],
111 ) -> Result<Vec<ReviewSubmission>>;
112
113 /// `POST /v1/reviewSubmissions` — an empty, unsubmitted submission.
114 async fn create_review_submission(
115 &self,
116 app_id: &str,
117 platform: Platform,
118 ) -> Result<ReviewSubmission>;
119
120 /// `GET /v1/reviewSubmissions/{id}/items`.
121 async fn list_review_submission_items(
122 &self,
123 submission_id: &str,
124 ) -> Result<Vec<ReviewSubmissionItem>>;
125
126 /// `POST /v1/reviewSubmissionItems` — put an App Store Version on a
127 /// submission.
128 async fn add_app_store_version_to_review_submission(
129 &self,
130 submission_id: &str,
131 app_store_version_id: &str,
132 ) -> Result<ReviewSubmissionItem>;
133
134 /// `PATCH /v1/reviewSubmissions/{id}` with `submitted: true` — sends it
135 /// to App Review.
136 async fn submit_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission>;
137
138 /// `PATCH /v1/reviewSubmissions/{id}` with `canceled: true` — withdraws a
139 /// submission that hasn't been reviewed yet.
140 async fn cancel_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission>;
141 }
142
143 #[async_trait]
144 impl ReviewSubmissionsApi for Client {
145 async fn list_review_submissions(
146 &self,
147 app_id: &str,
148 platform: Option<Platform>,
149 states: &[&str],
150 ) -> Result<Vec<ReviewSubmission>> {
151 let states = states.join(",");
152 let query = list_query(app_id, platform, &states);
153 self.list_all::<ReviewSubmissionAttributes>("/v1/reviewSubmissions", &query)
154 .await
155 }
156
157 async fn create_review_submission(
158 &self,
159 app_id: &str,
160 platform: Platform,
161 ) -> Result<ReviewSubmission> {
162 let body = CreateBody {
163 data: CreateData {
164 resource_type: RESOURCE_TYPE,
165 attributes: CreateSubmissionAttributes { platform },
166 relationships: Some(CreateSubmissionRelationships {
167 app: to_one(crate::app::RESOURCE_TYPE, app_id),
168 }),
169 },
170 };
171 let doc: Document<ReviewSubmissionAttributes> = self
172 .request(Method::POST, "/v1/reviewSubmissions", &[], Some(&body))
173 .await?;
174 Ok(doc.data)
175 }
176
177 async fn list_review_submission_items(
178 &self,
179 submission_id: &str,
180 ) -> Result<Vec<ReviewSubmissionItem>> {
181 let path = format!("/v1/reviewSubmissions/{submission_id}/items");
182 self.list_all::<ReviewSubmissionItemAttributes>(&path, &[])
183 .await
184 }
185
186 async fn add_app_store_version_to_review_submission(
187 &self,
188 submission_id: &str,
189 app_store_version_id: &str,
190 ) -> Result<ReviewSubmissionItem> {
191 let body = item_body(submission_id, app_store_version_id);
192 let doc: Document<ReviewSubmissionItemAttributes> = self
193 .request(Method::POST, "/v1/reviewSubmissionItems", &[], Some(&body))
194 .await?;
195 Ok(doc.data)
196 }
197
198 async fn submit_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission> {
199 patch_submission(self, submission_id, SubmittedAttributes { submitted: true }).await
200 }
201
202 async fn cancel_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission> {
203 patch_submission(self, submission_id, CanceledAttributes { canceled: true }).await
204 }
205 }
206
207 async fn patch_submission<A: Serialize + Send + Sync>(
208 client: &Client,
209 id: &str,
210 attributes: A,
211 ) -> Result<ReviewSubmission> {
212 let path = format!("/v1/reviewSubmissions/{id}");
213 let body = UpdateBody {
214 data: UpdateData {
215 resource_type: RESOURCE_TYPE,
216 id: id.to_string(),
217 attributes,
218 },
219 };
220 let doc: Document<ReviewSubmissionAttributes> = client
221 .request(Method::PATCH, &path, &[], Some(&body))
222 .await?;
223 Ok(doc.data)
224 }
225
226 fn to_one(resource_type: &'static str, id: &str) -> ToOne {
227 ToOne {
228 data: ResourceId {
229 resource_type,
230 id: id.to_string(),
231 },
232 }
233 }
234
235 fn list_query<'a>(
236 app_id: &'a str,
237 platform: Option<Platform>,
238 states: &'a str,
239 ) -> Vec<(&'static str, &'a str)> {
240 let mut query = vec![("filter[app]", app_id), ("limit", "200")];
241 if let Some(platform) = platform {
242 query.push(("filter[platform]", platform.as_query_value()));
243 }
244 if !states.is_empty() {
245 query.push(("filter[state]", states));
246 }
247 query
248 }
249
250 fn item_body(submission_id: &str, app_store_version_id: &str) -> CreateItemBody {
251 CreateItemBody {
252 data: CreateItemData {
253 resource_type: ITEM_RESOURCE_TYPE,
254 relationships: CreateItemRelationships {
255 review_submission: to_one(RESOURCE_TYPE, submission_id),
256 app_store_version: to_one(
257 crate::app_store_version::RESOURCE_TYPE,
258 app_store_version_id,
259 ),
260 },
261 },
262 }
263 }
264
265 #[cfg(test)]
266 mod tests {
267 use super::*;
268 use serde_json::json;
269
270 #[test]
271 fn list_query_pins_the_filter_keys() {
272 assert_eq!(
273 list_query("6766108771", Some(Platform::VisionOs), "READY_FOR_REVIEW"),
274 vec![
275 ("filter[app]", "6766108771"),
276 ("limit", "200"),
277 ("filter[platform]", "VISION_OS"),
278 ("filter[state]", "READY_FOR_REVIEW"),
279 ]
280 );
281 assert_eq!(
282 list_query("6766108771", None, ""),
283 vec![("filter[app]", "6766108771"), ("limit", "200")]
284 );
285 }
286
287 /// A rejected version's submission is `UNRESOLVED_ISSUES`, not
288 /// `READY_FOR_REVIEW`, so looking only for drafts misses it and the
289 /// caller tries to create a second submission, which App Store
290 /// Connect refuses.
291 #[test]
292 fn open_states_include_a_submission_app_review_sent_back() {
293 let states = OPEN_STATES.join(",");
294 assert_eq!(
295 list_query("6756177644", Some(Platform::Ios), &states),
296 vec![
297 ("filter[app]", "6756177644"),
298 ("limit", "200"),
299 ("filter[platform]", "IOS"),
300 ("filter[state]", "READY_FOR_REVIEW,UNRESOLVED_ISSUES"),
301 ]
302 );
303 }
304
305 #[test]
306 fn create_body_names_the_platform_and_app() {
307 let body = CreateBody {
308 data: CreateData {
309 resource_type: RESOURCE_TYPE,
310 attributes: CreateSubmissionAttributes {
311 platform: Platform::VisionOs,
312 },
313 relationships: Some(CreateSubmissionRelationships {
314 app: to_one(crate::app::RESOURCE_TYPE, "6766108771"),
315 }),
316 },
317 };
318 assert_eq!(
319 serde_json::to_value(body).unwrap(),
320 json!({ "data": {
321 "type": "reviewSubmissions",
322 "attributes": { "platform": "VISION_OS" },
323 "relationships": { "app": { "data": { "type": "apps", "id": "6766108771" } } }
324 }})
325 );
326 }
327
328 /// App Store Connect wants camelCase relationship names and no
329 /// `attributes` key on an item.
330 #[test]
331 fn item_body_has_relationships_only() {
332 assert_eq!(
333 serde_json::to_value(item_body("SUB1", "VER1")).unwrap(),
334 json!({ "data": {
335 "type": "reviewSubmissionItems",
336 "relationships": {
337 "reviewSubmission": { "data": { "type": "reviewSubmissions", "id": "SUB1" } },
338 "appStoreVersion": { "data": { "type": "appStoreVersions", "id": "VER1" } }
339 }
340 }})
341 );
342 }
343
344 #[test]
345 fn submit_and_cancel_patch_one_flag() {
346 let submit = UpdateBody {
347 data: UpdateData {
348 resource_type: RESOURCE_TYPE,
349 id: "SUB1".to_string(),
350 attributes: SubmittedAttributes { submitted: true },
351 },
352 };
353 assert_eq!(
354 serde_json::to_value(submit).unwrap(),
355 json!({ "data": { "type": "reviewSubmissions", "id": "SUB1",
356 "attributes": { "submitted": true } } })
357 );
358 assert_eq!(
359 serde_json::to_value(CanceledAttributes { canceled: true }).unwrap(),
360 json!({ "canceled": true })
361 );
362 }
363
364 #[test]
365 fn deserializes_a_submission() {
366 let doc: Document<ReviewSubmissionAttributes> = serde_json::from_value(json!({
367 "data": { "id": "SUB1", "type": "reviewSubmissions",
368 "attributes": { "platform": "VISION_OS", "state": "READY_FOR_REVIEW",
369 "submittedDate": null } }
370 }))
371 .unwrap();
372 assert_eq!(doc.data.attributes.platform, Some(Platform::VisionOs));
373 assert_eq!(
374 doc.data.attributes.state.as_deref(),
375 Some(STATE_READY_FOR_REVIEW)
376 );
377 }
378 }