1
-//! Read-only lookup for `Build` — the processed artifact behind an uploaded
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.
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
14
use async_trait::async_trait;
15
use reqwest::Method;
16
use serde::{Deserialize, Serialize};
16
-use smbcloud_ascapi_core::jsonapi::{Document, Resource};
17
+use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData};
18
use smbcloud_ascapi_core::Client;
19
use smbcloud_ascapi_core::Result;
20
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>;
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.
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`)
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,
268
"uploadedDate": "2024-01-02T03:04:05Z",
269
"expired": false,
270
"processingState": "VALID",
232
- "minOsVersion": "17.0"
271
+ "minOsVersion": "17.0",
272
+ "usesNonExemptEncryption": false
273
}
274
}
275
]
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]