development
rs 338 lines 13.1 KB
Raw
1 //! Lookup for `Build` — the processed artifact behind an uploaded
2 //! `.ipa`/`.pkg` — and `PreReleaseVersion`, the version string App Store
3 //! Connect groups builds under for TestFlight. The one write is a build's
4 //! export-compliance answer ([`BuildsApi::update_build`]).
5 //!
6 //! Uploading the binary itself is out of this crate's scope (done via
7 //! `xcrun altool`/Xcode); this module exists so a caller can check whether App
8 //! Store Connect finished processing a build as `VALID` or `INVALID` after
9 //! upload — the signal that actually confirms a binary-rejection fix worked,
10 //! independent of an `AppStoreVersion`'s `appVersionState` (which only
11 //! changes once a build is attached to the version and resubmitted — outside
12 //! this crate's scope).
13
14 use async_trait::async_trait;
15 use reqwest::Method;
16 use serde::{Deserialize, Serialize};
17 use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData};
18 use smbcloud_ascapi_core::Client;
19 use smbcloud_ascapi_core::Result;
20
21 use crate::app_store_version::Platform;
22
23 pub const RESOURCE_TYPE: &str = "builds";
24 pub const PRE_RELEASE_VERSION_RESOURCE_TYPE: &str = "preReleaseVersions";
25
26 #[derive(Debug, Clone, Serialize, Deserialize)]
27 #[serde(rename_all = "camelCase")]
28 pub struct BuildAttributes {
29 pub version: Option<String>,
30 pub uploaded_date: Option<String>,
31 pub expired: Option<bool>,
32 pub processing_state: Option<String>,
33 pub min_os_version: Option<String>,
34 /// The build's export-compliance answer. `None` until someone answers it,
35 /// either here or through `ITSAppUsesNonExemptEncryption` in the Info.plist
36 /// of every bundle in the upload. A build with no answer shows "Missing
37 /// Compliance" and App Review rejects its version with
38 /// `STATE_ERROR.ENTITY_STATE_INVALID`.
39 pub uses_non_exempt_encryption: Option<bool>,
40 }
41
42 pub type Build = Resource<BuildAttributes>;
43
44 #[derive(Debug, Clone, Serialize, Deserialize)]
45 #[serde(rename_all = "camelCase")]
46 pub struct PreReleaseVersionAttributes {
47 pub version: Option<String>,
48 pub platform: Option<Platform>,
49 }
50
51 pub type PreReleaseVersion = Resource<PreReleaseVersionAttributes>;
52
53 /// The writable attributes of `PATCH /v1/builds/{id}`. Unset fields are left
54 /// off the body, so App Store Connect keeps their current value.
55 #[derive(Debug, Clone, Default, Serialize)]
56 #[serde(rename_all = "camelCase")]
57 pub struct BuildUpdateAttributes {
58 /// `false` answers export compliance with "None of the algorithms
59 /// mentioned above": the app uses no encryption, or only what Apple's
60 /// operating system provides (HTTPS through URLSession or
61 /// Security.framework counts). `true` means non-exempt encryption, which
62 /// then needs an `appEncryptionDeclaration`.
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub uses_non_exempt_encryption: Option<bool>,
65 }
66
67 /// How to narrow a `list_builds` query. `None` fields are left off the
68 /// request, so the default — all fields `None` — lists every build for the
69 /// app, matching the original parameter-free behavior.
70 #[derive(Debug, Clone, Default)]
71 pub struct BuildFilter {
72 /// `filter[version]` — the build (upload) number, e.g. `"42"`. Note this
73 /// is the build's own `version`, *not* the marketing `preReleaseVersion`
74 /// string; use [`BuildFilter::pre_release_version`] for the latter.
75 pub version: Option<String>,
76 /// `filter[preReleaseVersion.version]` — the marketing version the build
77 /// sits under, e.g. `"1.1.0"`.
78 pub pre_release_version: Option<String>,
79 /// `filter[processingState]` — one of `PROCESSING`, `FAILED`, `INVALID`,
80 /// `VALID`.
81 pub processing_state: Option<String>,
82 }
83
84 impl BuildFilter {
85 /// Collect the set fields into `filter[...]` query pairs. Returns owned
86 /// strings because the values come from the caller at runtime.
87 fn query_pairs(&self) -> Vec<(&'static str, String)> {
88 let mut pairs = Vec::new();
89 if let Some(v) = &self.version {
90 pairs.push(("filter[version]", v.clone()));
91 }
92 if let Some(v) = &self.pre_release_version {
93 pairs.push(("filter[preReleaseVersion.version]", v.clone()));
94 }
95 if let Some(v) = &self.processing_state {
96 pairs.push(("filter[processingState]", v.clone()));
97 }
98 pairs
99 }
100 }
101
102 /// Uploaded builds, which a version must have attached.
103 ///
104 /// An extension trait rather than inherent methods, because `Client`
105 /// lives in the core crate and Rust only allows inherent impls in the
106 /// crate that defines the type. Import it, or the crate's `prelude`,
107 /// to call these on a `Client`.
108 #[async_trait]
109 pub trait BuildsApi {
110 /// `GET /v1/builds?filter[app]={app_id}`, sorted newest-first by
111 /// `uploadedDate` client-side, so the build most recently uploaded (e.g.
112 /// by a fixplist re-upload) is `list_builds(app_id, &filter).await?.first()`.
113 ///
114 /// The top-level collection rather than `/v1/apps/{app_id}/builds`:
115 /// that related-resource endpoint only accepts `fields`/`limit` and
116 /// rejects `filter[...]` and `sort`.
117 ///
118 /// Pass `&BuildFilter::default()` for every build, or set fields to
119 /// narrow by build number, pre-release (marketing) version, or
120 /// processing state.
121 ///
122 /// The `limit=200` this sends is a *page size*, not a ceiling on the
123 /// result: [`Client::list_all`] follows Apple's `links.next` cursor until
124 /// the collection is exhausted, so an app with more than 200 builds still
125 /// returns all of them — 200 is just the largest page ASC allows for this
126 /// collection, which keeps the round-trip count down. Do not read the
127 /// vector's length as "the 200 most recent builds."
128 async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>>;
129
130 /// `GET /v1/builds/{id}` — a single build by its App Store Connect id.
131 async fn get_build(&self, id: &str) -> Result<Build>;
132
133 /// `PATCH /v1/builds/{id}` — today, only to answer export compliance on a
134 /// build uploaded without `ITSAppUsesNonExemptEncryption`.
135 async fn update_build(&self, id: &str, attributes: BuildUpdateAttributes) -> Result<Build>;
136
137 /// `GET /v1/preReleaseVersions?filter[app]={app_id}`, optionally narrowed
138 /// to one platform (the related `/v1/apps/{app_id}/preReleaseVersions`
139 /// endpoint does not accept `filter[platform]`). These are the marketing version strings (e.g. `1.1.0`)
140 /// TestFlight groups builds under; a build's
141 /// `filter[preReleaseVersion.version]` matches one of these.
142 async fn list_pre_release_versions(
143 &self,
144 app_id: &str,
145 filter_platform: Option<Platform>,
146 ) -> Result<Vec<PreReleaseVersion>>;
147 }
148
149 #[async_trait]
150 impl BuildsApi for Client {
151 async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>> {
152 let owned = filter.query_pairs();
153 let mut query: Vec<(&str, &str)> = vec![("filter[app]", app_id), ("limit", "200")];
154 query.extend(owned.iter().map(|(k, v)| (*k, v.as_str())));
155 let mut builds = self
156 .list_all::<BuildAttributes>("/v1/builds", &query)
157 .await?;
158 builds.sort_by(|a, b| b.attributes.uploaded_date.cmp(&a.attributes.uploaded_date));
159 Ok(builds)
160 }
161
162 async fn get_build(&self, id: &str) -> Result<Build> {
163 let path = format!("/v1/builds/{id}");
164 let doc: Document<BuildAttributes> =
165 self.request(Method::GET, &path, &[], None::<&()>).await?;
166 Ok(doc.data)
167 }
168
169 async fn update_build(&self, id: &str, attributes: BuildUpdateAttributes) -> Result<Build> {
170 let path = format!("/v1/builds/{id}");
171 let body = UpdateBody {
172 data: UpdateData {
173 resource_type: RESOURCE_TYPE,
174 id: id.to_string(),
175 attributes,
176 },
177 };
178 let doc: Document<BuildAttributes> =
179 self.request(Method::PATCH, &path, &[], Some(&body)).await?;
180 Ok(doc.data)
181 }
182
183 async fn list_pre_release_versions(
184 &self,
185 app_id: &str,
186 filter_platform: Option<Platform>,
187 ) -> Result<Vec<PreReleaseVersion>> {
188 let mut query: Vec<(&str, &str)> = vec![("filter[app]", app_id), ("limit", "200")];
189 if let Some(platform) = filter_platform {
190 query.push(("filter[platform]", platform.as_query_value()));
191 }
192 self.list_all::<PreReleaseVersionAttributes>("/v1/preReleaseVersions", &query)
193 .await
194 }
195 }
196
197 #[cfg(test)]
198 mod tests {
199 use super::*;
200 use smbcloud_ascapi_core::jsonapi::ListDocument;
201
202 #[test]
203 fn default_filter_sends_no_query_pairs() {
204 // `BuildFilter::default()` must list every build for the app — the
205 // original parameter-free behavior — so it adds no `filter[...]`.
206 assert!(BuildFilter::default().query_pairs().is_empty());
207 }
208
209 #[test]
210 fn each_field_maps_to_its_asc_filter_key() {
211 // The ASC filter keys are load-bearing: `version` is the build
212 // number, but the marketing version is `preReleaseVersion.version`,
213 // not `preReleaseVersion`. Getting these wrong silently returns the
214 // wrong builds (or all of them), so pin every key and value.
215 assert_eq!(
216 BuildFilter {
217 version: Some("42".into()),
218 ..Default::default()
219 }
220 .query_pairs(),
221 vec![("filter[version]", "42".to_string())],
222 );
223 assert_eq!(
224 BuildFilter {
225 pre_release_version: Some("1.1.0".into()),
226 ..Default::default()
227 }
228 .query_pairs(),
229 vec![("filter[preReleaseVersion.version]", "1.1.0".to_string())],
230 );
231 assert_eq!(
232 BuildFilter {
233 processing_state: Some("VALID".into()),
234 ..Default::default()
235 }
236 .query_pairs(),
237 vec![("filter[processingState]", "VALID".to_string())],
238 );
239 }
240
241 #[test]
242 fn all_fields_set_are_all_sent() {
243 let pairs = BuildFilter {
244 version: Some("42".into()),
245 pre_release_version: Some("1.1.0".into()),
246 processing_state: Some("VALID".into()),
247 }
248 .query_pairs();
249 assert_eq!(
250 pairs,
251 vec![
252 ("filter[version]", "42".to_string()),
253 ("filter[preReleaseVersion.version]", "1.1.0".to_string()),
254 ("filter[processingState]", "VALID".to_string()),
255 ],
256 );
257 }
258
259 #[test]
260 fn deserializes_a_builds_list_response() {
261 let body = serde_json::json!({
262 "data": [
263 {
264 "id": "BUILD1",
265 "type": "builds",
266 "attributes": {
267 "version": "42",
268 "uploadedDate": "2024-01-02T03:04:05Z",
269 "expired": false,
270 "processingState": "VALID",
271 "minOsVersion": "17.0",
272 "usesNonExemptEncryption": false
273 }
274 }
275 ]
276 });
277
278 let doc: ListDocument<BuildAttributes> =
279 serde_json::from_value(body).expect("a builds list must deserialize");
280 assert_eq!(doc.data.len(), 1);
281 let build = &doc.data[0];
282 assert_eq!(build.id, "BUILD1");
283 assert_eq!(build.attributes.version.as_deref(), Some("42"));
284 assert_eq!(build.attributes.processing_state.as_deref(), Some("VALID"));
285 assert_eq!(build.attributes.expired, Some(false));
286 assert_eq!(build.attributes.uses_non_exempt_encryption, Some(false));
287 }
288
289 #[test]
290 fn update_body_sends_only_the_set_attribute() {
291 let body = UpdateBody {
292 data: UpdateData {
293 resource_type: RESOURCE_TYPE,
294 id: "BUILD1".to_string(),
295 attributes: BuildUpdateAttributes {
296 uses_non_exempt_encryption: Some(false),
297 },
298 },
299 };
300 assert_eq!(
301 serde_json::to_value(&body).unwrap(),
302 serde_json::json!({
303 "data": {
304 "type": "builds",
305 "id": "BUILD1",
306 "attributes": { "usesNonExemptEncryption": false }
307 }
308 }),
309 );
310 assert_eq!(
311 serde_json::to_value(BuildUpdateAttributes::default()).unwrap(),
312 serde_json::json!({}),
313 );
314 }
315
316 #[test]
317 fn deserializes_a_pre_release_versions_response() {
318 let body = serde_json::json!({
319 "data": [
320 {
321 "id": "PRV1",
322 "type": "preReleaseVersions",
323 "attributes": {
324 "version": "1.1.0",
325 "platform": "IOS"
326 }
327 }
328 ]
329 });
330
331 let doc: ListDocument<PreReleaseVersionAttributes> =
332 serde_json::from_value(body).expect("a preReleaseVersions list must deserialize");
333 assert_eq!(doc.data.len(), 1);
334 let prv = &doc.data[0];
335 assert_eq!(prv.attributes.version.as_deref(), Some("1.1.0"));
336 assert_eq!(prv.attributes.platform, Some(Platform::Ios));
337 }
338 }