1
//! Read-only lookup for `Build` — the processed artifact behind an uploaded
2
-//! `.ipa`/`.pkg`. Uploading itself is out of this crate's scope (done via
3
-//! `xcrun altool`/Xcode, see the CLI's sibling build scripts); this module
4
-//! exists so a caller can check whether App Store Connect finished
5
-//! processing a build as `VALID` or `INVALID` after upload — the signal that
6
-//! actually confirms a binary-rejection fix worked, independent of an
7
-//! `AppStoreVersion`'s `appVersionState` (which only changes once a build is
8
-//! attached to the version and resubmitted — outside this crate's scope).
2
+//! `.ipa`/`.pkg` — and `PreReleaseVersion`, the version string App Store
3
+//! Connect groups builds under for TestFlight.
4
+//!
5
+//! Uploading the binary itself is out of this crate's scope (done via
6
+//! `xcrun altool`/Xcode); this module exists so a caller can check whether App
7
+//! Store Connect finished processing a build as `VALID` or `INVALID` after
8
+//! upload — the signal that actually confirms a binary-rejection fix worked,
9
+//! independent of an `AppStoreVersion`'s `appVersionState` (which only
10
+//! changes once a build is attached to the version and resubmitted — outside
11
+//! this crate's scope).
12
13
use async_trait::async_trait;
14
use reqwest::Method;
15
use serde::{Deserialize, Serialize};
13
-use smbcloud_ascapi_core::jsonapi::{ListDocument, Resource};
16
+use smbcloud_ascapi_core::jsonapi::{Document, Resource};
17
use smbcloud_ascapi_core::Client;
18
use smbcloud_ascapi_core::Result;
19
20
+use crate::app_store_version::Platform;
21
+
22
pub const RESOURCE_TYPE: &str = "builds";
23
+pub const PRE_RELEASE_VERSION_RESOURCE_TYPE: &str = "preReleaseVersions";
24
25
#[derive(Debug, Clone, Serialize, Deserialize)]
26
#[serde(rename_all = "camelCase")]
34
35
pub type Build = Resource<BuildAttributes>;
36
37
+#[derive(Debug, Clone, Serialize, Deserialize)]
38
+#[serde(rename_all = "camelCase")]
39
+pub struct PreReleaseVersionAttributes {
40
+ pub version: Option<String>,
41
+ pub platform: Option<Platform>,
42
+}
43
+
44
+pub type PreReleaseVersion = Resource<PreReleaseVersionAttributes>;
45
+
46
+/// How to narrow a `list_builds` query. `None` fields are left off the
47
+/// request, so the default — all fields `None` — lists every build for the
48
+/// app, matching the original parameter-free behavior.
49
+#[derive(Debug, Clone, Default)]
50
+pub struct BuildFilter {
51
+ /// `filter[version]` — the build (upload) number, e.g. `"42"`. Note this
52
+ /// is the build's own `version`, *not* the marketing `preReleaseVersion`
53
+ /// string; use [`BuildFilter::pre_release_version`] for the latter.
54
+ pub version: Option<String>,
55
+ /// `filter[preReleaseVersion.version]` — the marketing version the build
56
+ /// sits under, e.g. `"1.1.0"`.
57
+ pub pre_release_version: Option<String>,
58
+ /// `filter[processingState]` — one of `PROCESSING`, `FAILED`, `INVALID`,
59
+ /// `VALID`.
60
+ pub processing_state: Option<String>,
61
+}
62
+
63
+impl BuildFilter {
64
+ /// Collect the set fields into `filter[...]` query pairs. Returns owned
65
+ /// strings because the values come from the caller at runtime.
66
+ fn query_pairs(&self) -> Vec<(&'static str, String)> {
67
+ let mut pairs = Vec::new();
68
+ if let Some(v) = &self.version {
69
+ pairs.push(("filter[version]", v.clone()));
70
+ }
71
+ if let Some(v) = &self.pre_release_version {
72
+ pairs.push(("filter[preReleaseVersion.version]", v.clone()));
73
+ }
74
+ if let Some(v) = &self.processing_state {
75
+ pairs.push(("filter[processingState]", v.clone()));
76
+ }
77
+ pairs
78
+ }
79
+}
80
+
81
/// Uploaded builds, which a version must have attached.
82
///
83
/// An extension trait rather than inherent methods, because `Client`
86
/// to call these on a `Client`.
87
#[async_trait]
88
pub trait BuildsApi {
39
- /// `GET /v1/apps/{app_id}/builds`, sorted newest-first by
40
- /// `uploadedDate` (client-side — this endpoint's `sort` query parameter
41
- /// is rejected by the API, unlike most other list endpoints), so the
42
- /// build most recently uploaded (e.g. by a fixplist re-upload) is
43
- /// `list_builds(app_id).await?.first()`.
44
- async fn list_builds(&self, app_id: &str) -> Result<Vec<Build>>;
89
+ /// `GET /v1/builds?filter[app]={app_id}`, sorted newest-first by
90
+ /// `uploadedDate` client-side, so the build most recently uploaded (e.g.
91
+ /// by a fixplist re-upload) is `list_builds(app_id, &filter).await?.first()`.
92
+ ///
93
+ /// The top-level collection rather than `/v1/apps/{app_id}/builds`:
94
+ /// that related-resource endpoint only accepts `fields`/`limit` and
95
+ /// rejects `filter[...]` and `sort`.
96
+ ///
97
+ /// Pass `&BuildFilter::default()` for every build, or set fields to
98
+ /// narrow by build number, pre-release (marketing) version, or
99
+ /// processing state.
100
+ async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>>;
101
+
102
+ /// `GET /v1/builds/{id}` — a single build by its App Store Connect id.
103
+ async fn get_build(&self, id: &str) -> Result<Build>;
104
+
105
+ /// `GET /v1/preReleaseVersions?filter[app]={app_id}`, optionally narrowed
106
+ /// to one platform (the related `/v1/apps/{app_id}/preReleaseVersions`
107
+ /// endpoint does not accept `filter[platform]`). These are the marketing version strings (e.g. `1.1.0`)
108
+ /// TestFlight groups builds under; a build's
109
+ /// `filter[preReleaseVersion.version]` matches one of these.
110
+ async fn list_pre_release_versions(
111
+ &self,
112
+ app_id: &str,
113
+ filter_platform: Option<Platform>,
114
+ ) -> Result<Vec<PreReleaseVersion>>;
115
}
116
117
#[async_trait]
118
impl BuildsApi for Client {
49
- async fn list_builds(&self, app_id: &str) -> Result<Vec<Build>> {
50
- let path = format!("/v1/apps/{app_id}/builds");
51
- let doc: ListDocument<BuildAttributes> =
52
- self.request(Method::GET, &path, &[], None::<&()>).await?;
53
- let mut builds = doc.data;
119
+ async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>> {
120
+ let owned = filter.query_pairs();
121
+ let mut query: Vec<(&str, &str)> = vec![("filter[app]", app_id), ("limit", "200")];
122
+ query.extend(owned.iter().map(|(k, v)| (*k, v.as_str())));
123
+ let mut builds = self
124
+ .list_all::<BuildAttributes>("/v1/builds", &query)
125
+ .await?;
126
builds.sort_by(|a, b| b.attributes.uploaded_date.cmp(&a.attributes.uploaded_date));
127
Ok(builds)
128
}
129
+
130
+ async fn get_build(&self, id: &str) -> Result<Build> {
131
+ let path = format!("/v1/builds/{id}");
132
+ let doc: Document<BuildAttributes> =
133
+ self.request(Method::GET, &path, &[], None::<&()>).await?;
134
+ Ok(doc.data)
135
+ }
136
+
137
+ async fn list_pre_release_versions(
138
+ &self,
139
+ app_id: &str,
140
+ filter_platform: Option<Platform>,
141
+ ) -> Result<Vec<PreReleaseVersion>> {
142
+ let mut query: Vec<(&str, &str)> = vec![("filter[app]", app_id), ("limit", "200")];
143
+ if let Some(platform) = filter_platform {
144
+ query.push(("filter[platform]", platform.as_query_value()));
145
+ }
146
+ self.list_all::<PreReleaseVersionAttributes>("/v1/preReleaseVersions", &query)
147
+ .await
148
+ }
149
+}
150
+
151
+#[cfg(test)]
152
+mod tests {
153
+ use super::*;
154
+ use smbcloud_ascapi_core::jsonapi::ListDocument;
155
+
156
+ #[test]
157
+ fn default_filter_sends_no_query_pairs() {
158
+ // `BuildFilter::default()` must list every build for the app — the
159
+ // original parameter-free behavior — so it adds no `filter[...]`.
160
+ assert!(BuildFilter::default().query_pairs().is_empty());
161
+ }
162
+
163
+ #[test]
164
+ fn each_field_maps_to_its_asc_filter_key() {
165
+ // The ASC filter keys are load-bearing: `version` is the build
166
+ // number, but the marketing version is `preReleaseVersion.version`,
167
+ // not `preReleaseVersion`. Getting these wrong silently returns the
168
+ // wrong builds (or all of them), so pin every key and value.
169
+ assert_eq!(
170
+ BuildFilter {
171
+ version: Some("42".into()),
172
+ ..Default::default()
173
+ }
174
+ .query_pairs(),
175
+ vec![("filter[version]", "42".to_string())],
176
+ );
177
+ assert_eq!(
178
+ BuildFilter {
179
+ pre_release_version: Some("1.1.0".into()),
180
+ ..Default::default()
181
+ }
182
+ .query_pairs(),
183
+ vec![("filter[preReleaseVersion.version]", "1.1.0".to_string())],
184
+ );
185
+ assert_eq!(
186
+ BuildFilter {
187
+ processing_state: Some("VALID".into()),
188
+ ..Default::default()
189
+ }
190
+ .query_pairs(),
191
+ vec![("filter[processingState]", "VALID".to_string())],
192
+ );
193
+ }
194
+
195
+ #[test]
196
+ fn all_fields_set_are_all_sent() {
197
+ let pairs = BuildFilter {
198
+ version: Some("42".into()),
199
+ pre_release_version: Some("1.1.0".into()),
200
+ processing_state: Some("VALID".into()),
201
+ }
202
+ .query_pairs();
203
+ assert_eq!(
204
+ pairs,
205
+ vec![
206
+ ("filter[version]", "42".to_string()),
207
+ ("filter[preReleaseVersion.version]", "1.1.0".to_string()),
208
+ ("filter[processingState]", "VALID".to_string()),
209
+ ],
210
+ );
211
+ }
212
+
213
+ #[test]
214
+ fn deserializes_a_builds_list_response() {
215
+ let body = serde_json::json!({
216
+ "data": [
217
+ {
218
+ "id": "BUILD1",
219
+ "type": "builds",
220
+ "attributes": {
221
+ "version": "42",
222
+ "uploadedDate": "2024-01-02T03:04:05Z",
223
+ "expired": false,
224
+ "processingState": "VALID",
225
+ "minOsVersion": "17.0"
226
+ }
227
+ }
228
+ ]
229
+ });
230
+
231
+ let doc: ListDocument<BuildAttributes> =
232
+ serde_json::from_value(body).expect("a builds list must deserialize");
233
+ assert_eq!(doc.data.len(), 1);
234
+ let build = &doc.data[0];
235
+ assert_eq!(build.id, "BUILD1");
236
+ assert_eq!(build.attributes.version.as_deref(), Some("42"));
237
+ assert_eq!(build.attributes.processing_state.as_deref(), Some("VALID"));
238
+ assert_eq!(build.attributes.expired, Some(false));
239
+ }
240
+
241
+ #[test]
242
+ fn deserializes_a_pre_release_versions_response() {
243
+ let body = serde_json::json!({
244
+ "data": [
245
+ {
246
+ "id": "PRV1",
247
+ "type": "preReleaseVersions",
248
+ "attributes": {
249
+ "version": "1.1.0",
250
+ "platform": "IOS"
251
+ }
252
+ }
253
+ ]
254
+ });
255
+
256
+ let doc: ListDocument<PreReleaseVersionAttributes> =
257
+ serde_json::from_value(body).expect("a preReleaseVersions list must deserialize");
258
+ assert_eq!(doc.data.len(), 1);
259
+ let prv = &doc.data[0];
260
+ assert_eq!(prv.attributes.version.as_deref(), Some("1.1.0"));
261
+ assert_eq!(prv.attributes.platform, Some(Platform::Ios));
262
+ }
263
}