1use async_trait::async_trait;
15use reqwest::Method;
16use serde::{Deserialize, Serialize};
17use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData};
18use smbcloud_ascapi_core::Client;
19use smbcloud_ascapi_core::Result;
20
21use crate::app_store_version::Platform;
22
23pub const RESOURCE_TYPE: &str = "builds";
24pub const PRE_RELEASE_VERSION_RESOURCE_TYPE: &str = "preReleaseVersions";
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub 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 pub uses_non_exempt_encryption: Option<bool>,
40}
41
42pub type Build = Resource<BuildAttributes>;
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct PreReleaseVersionAttributes {
47 pub version: Option<String>,
48 pub platform: Option<Platform>,
49}
50
51pub type PreReleaseVersion = Resource<PreReleaseVersionAttributes>;
52
53#[derive(Debug, Clone, Default, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct BuildUpdateAttributes {
58 #[serde(skip_serializing_if = "Option::is_none")]
64 pub uses_non_exempt_encryption: Option<bool>,
65}
66
67#[derive(Debug, Clone, Default)]
71pub struct BuildFilter {
72 pub version: Option<String>,
76 pub pre_release_version: Option<String>,
79 pub processing_state: Option<String>,
82}
83
84impl BuildFilter {
85 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#[async_trait]
109pub trait BuildsApi {
110 async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>>;
129
130 async fn get_build(&self, id: &str) -> Result<Build>;
132
133 async fn update_build(&self, id: &str, attributes: BuildUpdateAttributes) -> Result<Build>;
136
137 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]
150impl 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)]
198mod tests {
199 use super::*;
200 use smbcloud_ascapi_core::jsonapi::ListDocument;
201
202 #[test]
203 fn default_filter_sends_no_query_pairs() {
204 assert!(BuildFilter::default().query_pairs().is_empty());
207 }
208
209 #[test]
210 fn each_field_maps_to_its_asc_filter_key() {
211 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}