Skip to main content

smbcloud_ascapi_frontend/
review.rs

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
8use serde::Serialize;
9use smbcloud_ascapi_aso::app_store_version::Platform;
10use smbcloud_ascapi_aso::prelude::*;
11use smbcloud_ascapi_aso::review_submission::OPEN_STATES;
12use smbcloud_ascapi_core::Client;
13
14/// What [`submit_for_review`] will do, worked out from reads only.
15#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
16pub 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)]
40pub struct SubmittedReview {
41    pub submission_id: String,
42    pub state: Option<String>,
43    pub submitted_date: Option<String>,
44    pub plan: SubmissionPlan,
45}
46
47pub 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.
103pub 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.
145fn 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
166fn 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
175fn 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)]
186mod 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}