development
rs 210 lines 7.15 KB
Raw
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 }