| 1 | //! Submitting an App Store Version for App Review. |
| 2 | //! |
| 3 | //! Split into a read-only [`plan_submission`] and a [`submit_for_review`] |
| 4 | //! that carries the plan out, so `--dry-run` can show exactly what would |
| 5 | //! happen (reuse an open submission or create one, add the version or not) |
| 6 | //! without writing anything. |
| 7 | |
| 8 | use serde::Serialize; |
| 9 | use smbcloud_ascapi_aso::app_store_version::Platform; |
| 10 | use smbcloud_ascapi_aso::prelude::*; |
| 11 | use smbcloud_ascapi_aso::review_submission::OPEN_STATES; |
| 12 | use smbcloud_ascapi_core::Client; |
| 13 | |
| 14 | /// What [`submit_for_review`] will do, worked out from reads only. |
| 15 | #[derive(Debug, Clone, Serialize, schemars::JsonSchema)] |
| 16 | pub struct SubmissionPlan { |
| 17 | pub app_id: String, |
| 18 | pub app_store_version_id: String, |
| 19 | pub version_string: Option<String>, |
| 20 | pub platform: String, |
| 21 | pub app_version_state: Option<String>, |
| 22 | /// An open submission for this app and platform to reuse: a draft |
| 23 | /// (`READY_FOR_REVIEW`), or one App Review sent back |
| 24 | /// (`UNRESOLVED_ISSUES`), which is how a rejected version is |
| 25 | /// resubmitted. `None` means a new one gets created. |
| 26 | pub existing_submission_id: Option<String>, |
| 27 | /// That submission's state, when there is one. |
| 28 | pub existing_submission_state: Option<String>, |
| 29 | /// Whether the open submission already carries an item. App Store |
| 30 | /// Connect allows one item per version, so when this is true the item |
| 31 | /// step is skipped. |
| 32 | pub existing_submission_has_items: bool, |
| 33 | /// Why this version can't be submitted, when its state says it already |
| 34 | /// was (or is live). [`submit_for_review`] refuses a plan that has one. |
| 35 | pub blocked: Option<String>, |
| 36 | } |
| 37 | |
| 38 | /// The result of a submission, as reported to callers. |
| 39 | #[derive(Debug, Clone, Serialize, schemars::JsonSchema)] |
| 40 | pub struct SubmittedReview { |
| 41 | pub submission_id: String, |
| 42 | pub state: Option<String>, |
| 43 | pub submitted_date: Option<String>, |
| 44 | pub plan: SubmissionPlan, |
| 45 | } |
| 46 | |
| 47 | pub async fn plan_submission( |
| 48 | client: &Client, |
| 49 | app_id: &str, |
| 50 | app_store_version_id: &str, |
| 51 | ) -> Result<SubmissionPlan, String> { |
| 52 | let version = client |
| 53 | .get_app_store_version(app_store_version_id) |
| 54 | .await |
| 55 | .map_err(|error| error.to_string())?; |
| 56 | let platform = version.attributes.platform; |
| 57 | |
| 58 | let open = client |
| 59 | .list_review_submissions(app_id, Some(platform), &OPEN_STATES) |
| 60 | .await |
| 61 | .map_err(|error| error.to_string())?; |
| 62 | let existing = open.into_iter().next(); |
| 63 | let existing_submission_state = existing |
| 64 | .as_ref() |
| 65 | .and_then(|submission| submission.attributes.state.clone()); |
| 66 | |
| 67 | let existing_submission_has_items = match &existing { |
| 68 | Some(submission) => !client |
| 69 | .list_review_submission_items(&submission.id) |
| 70 | .await |
| 71 | .map_err(|error| error.to_string())? |
| 72 | .is_empty(), |
| 73 | None => false, |
| 74 | }; |
| 75 | |
| 76 | let blocked = version |
| 77 | .attributes |
| 78 | .app_version_state |
| 79 | .as_deref() |
| 80 | .and_then(blocked_reason); |
| 81 | |
| 82 | Ok(SubmissionPlan { |
| 83 | blocked, |
| 84 | app_id: app_id.to_string(), |
| 85 | app_store_version_id: app_store_version_id.to_string(), |
| 86 | version_string: version.attributes.version_string, |
| 87 | platform: platform_name(platform).to_string(), |
| 88 | app_version_state: version.attributes.app_version_state, |
| 89 | existing_submission_id: existing.map(|submission| submission.id), |
| 90 | existing_submission_state, |
| 91 | existing_submission_has_items, |
| 92 | }) |
| 93 | } |
| 94 | |
| 95 | /// Carry out a plan from [`plan_submission`]: reuse or create the |
| 96 | /// submission, add the version unless it's already on it, then submit. |
| 97 | /// |
| 98 | /// An open submission that already has an item is assumed to hold this |
| 99 | /// version, because App Store Connect keeps one open submission per app |
| 100 | /// and platform and this is the platform's only version in an editable |
| 101 | /// state. If it holds something else, App Store Connect rejects the |
| 102 | /// submit and the error says so. |
| 103 | pub async fn submit_for_review( |
| 104 | client: &Client, |
| 105 | plan: SubmissionPlan, |
| 106 | ) -> Result<SubmittedReview, String> { |
| 107 | if let Some(reason) = &plan.blocked { |
| 108 | return Err(reason.clone()); |
| 109 | } |
| 110 | let platform = platform_from_name(&plan.platform)?; |
| 111 | let submission_id = match &plan.existing_submission_id { |
| 112 | Some(id) => id.clone(), |
| 113 | None => { |
| 114 | client |
| 115 | .create_review_submission(&plan.app_id, platform) |
| 116 | .await |
| 117 | .map_err(|error| error.to_string())? |
| 118 | .id |
| 119 | } |
| 120 | }; |
| 121 | |
| 122 | if !plan.existing_submission_has_items { |
| 123 | client |
| 124 | .add_app_store_version_to_review_submission(&submission_id, &plan.app_store_version_id) |
| 125 | .await |
| 126 | .map_err(|error| error.to_string())?; |
| 127 | } |
| 128 | |
| 129 | let submitted = client |
| 130 | .submit_review_submission(&submission_id) |
| 131 | .await |
| 132 | .map_err(|error| error.to_string())?; |
| 133 | |
| 134 | Ok(SubmittedReview { |
| 135 | submission_id: submitted.id, |
| 136 | state: submitted.attributes.state, |
| 137 | submitted_date: submitted.attributes.submitted_date, |
| 138 | plan, |
| 139 | }) |
| 140 | } |
| 141 | |
| 142 | /// States in which a version has already gone to App Review or is past |
| 143 | /// it. Submitting again would only fail at the API, after the submission |
| 144 | /// and item had been created, so stop before any write. |
| 145 | fn blocked_reason(state: &str) -> Option<String> { |
| 146 | const SUBMITTED_OR_LIVE: [&str; 9] = [ |
| 147 | "WAITING_FOR_REVIEW", |
| 148 | "IN_REVIEW", |
| 149 | "PENDING_APPLE_RELEASE", |
| 150 | "PENDING_DEVELOPER_RELEASE", |
| 151 | "PROCESSING_FOR_DISTRIBUTION", |
| 152 | "READY_FOR_DISTRIBUTION", |
| 153 | "READY_FOR_SALE", |
| 154 | "ACCEPTED", |
| 155 | "REPLACED_WITH_NEW_VERSION", |
| 156 | ]; |
| 157 | SUBMITTED_OR_LIVE.contains(&state).then(|| { |
| 158 | format!( |
| 159 | "this version is {state}, so it has already been submitted; \ |
| 160 | `review-submissions list` shows its submission, and \ |
| 161 | `review-submissions cancel` withdraws one still waiting for review" |
| 162 | ) |
| 163 | }) |
| 164 | } |
| 165 | |
| 166 | fn platform_name(platform: Platform) -> &'static str { |
| 167 | match platform { |
| 168 | Platform::Ios => "IOS", |
| 169 | Platform::MacOs => "MAC_OS", |
| 170 | Platform::TvOs => "TV_OS", |
| 171 | Platform::VisionOs => "VISION_OS", |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | fn platform_from_name(name: &str) -> Result<Platform, String> { |
| 176 | match name { |
| 177 | "IOS" => Ok(Platform::Ios), |
| 178 | "MAC_OS" => Ok(Platform::MacOs), |
| 179 | "TV_OS" => Ok(Platform::TvOs), |
| 180 | "VISION_OS" => Ok(Platform::VisionOs), |
| 181 | other => Err(format!("unknown platform {other:?} in submission plan")), |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | #[cfg(test)] |
| 186 | mod tests { |
| 187 | use super::*; |
| 188 | |
| 189 | #[test] |
| 190 | fn blocks_a_version_that_was_already_submitted() { |
| 191 | assert!(blocked_reason("WAITING_FOR_REVIEW").is_some()); |
| 192 | assert!(blocked_reason("READY_FOR_DISTRIBUTION").is_some()); |
| 193 | assert_eq!(blocked_reason("PREPARE_FOR_SUBMISSION"), None); |
| 194 | assert_eq!(blocked_reason("DEVELOPER_REJECTED"), None); |
| 195 | assert_eq!(blocked_reason("REJECTED"), None); |
| 196 | } |
| 197 | |
| 198 | #[test] |
| 199 | fn platform_names_round_trip() { |
| 200 | for platform in [ |
| 201 | Platform::Ios, |
| 202 | Platform::MacOs, |
| 203 | Platform::TvOs, |
| 204 | Platform::VisionOs, |
| 205 | ] { |
| 206 | assert_eq!(platform_from_name(platform_name(platform)), Ok(platform)); |
| 207 | } |
| 208 | assert!(platform_from_name("WATCH_OS").is_err()); |
| 209 | } |
| 210 | } |