| 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 | } |