1use async_trait::async_trait;
34use reqwest::Method;
35use serde::{Deserialize, Serialize};
36use smbcloud_ascapi_core::jsonapi::{
37 CreateBody, CreateData, Document, Resource, ResourceId, ToMany, ToOne,
38};
39use smbcloud_ascapi_core::Client;
40use smbcloud_ascapi_core::Result;
41
42pub const RESOURCE_TYPE: &str = "profiles";
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub enum ProfileType {
57 #[serde(rename = "IOS_APP_DEVELOPMENT")]
58 IosAppDevelopment,
59 #[serde(rename = "IOS_APP_STORE")]
60 IosAppStore,
61 #[serde(rename = "IOS_APP_ADHOC")]
62 IosAppAdHoc,
63 #[serde(rename = "IOS_APP_INHOUSE")]
64 IosAppInHouse,
65 #[serde(rename = "MAC_APP_DEVELOPMENT")]
66 MacAppDevelopment,
67 #[serde(rename = "MAC_APP_STORE")]
70 MacAppStore,
71 #[serde(rename = "MAC_APP_DIRECT")]
72 MacAppDirect,
73 #[serde(rename = "TVOS_APP_DEVELOPMENT")]
74 TvOsAppDevelopment,
75 #[serde(rename = "TVOS_APP_STORE")]
76 TvOsAppStore,
77 #[serde(rename = "TVOS_APP_ADHOC")]
78 TvOsAppAdHoc,
79 #[serde(rename = "TVOS_APP_INHOUSE")]
80 TvOsAppInHouse,
81 #[serde(rename = "MAC_CATALYST_APP_DEVELOPMENT")]
82 MacCatalystAppDevelopment,
83 #[serde(rename = "MAC_CATALYST_APP_STORE")]
84 MacCatalystAppStore,
85 #[serde(rename = "MAC_CATALYST_APP_DIRECT")]
86 MacCatalystAppDirect,
87}
88
89impl ProfileType {
90 pub fn as_api_str(self) -> &'static str {
93 match self {
94 Self::IosAppDevelopment => "IOS_APP_DEVELOPMENT",
95 Self::IosAppStore => "IOS_APP_STORE",
96 Self::IosAppAdHoc => "IOS_APP_ADHOC",
97 Self::IosAppInHouse => "IOS_APP_INHOUSE",
98 Self::MacAppDevelopment => "MAC_APP_DEVELOPMENT",
99 Self::MacAppStore => "MAC_APP_STORE",
100 Self::MacAppDirect => "MAC_APP_DIRECT",
101 Self::TvOsAppDevelopment => "TVOS_APP_DEVELOPMENT",
102 Self::TvOsAppStore => "TVOS_APP_STORE",
103 Self::TvOsAppAdHoc => "TVOS_APP_ADHOC",
104 Self::TvOsAppInHouse => "TVOS_APP_INHOUSE",
105 Self::MacCatalystAppDevelopment => "MAC_CATALYST_APP_DEVELOPMENT",
106 Self::MacCatalystAppStore => "MAC_CATALYST_APP_STORE",
107 Self::MacCatalystAppDirect => "MAC_CATALYST_APP_DIRECT",
108 }
109 }
110
111 pub fn takes_devices(self) -> bool {
117 matches!(
118 self,
119 Self::IosAppDevelopment
120 | Self::IosAppAdHoc
121 | Self::MacAppDevelopment
122 | Self::TvOsAppDevelopment
123 | Self::TvOsAppAdHoc
124 | Self::MacCatalystAppDevelopment
125 )
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136pub enum ProfileState {
137 #[serde(rename = "ACTIVE")]
138 Active,
139 #[serde(rename = "INVALID")]
140 Invalid,
141}
142
143impl ProfileState {
144 pub fn as_api_str(self) -> &'static str {
145 match self {
146 Self::Active => "ACTIVE",
147 Self::Invalid => "INVALID",
148 }
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct ProfileAttributes {
155 pub name: Option<String>,
156 pub platform: Option<String>,
157 pub profile_type: Option<ProfileType>,
158 pub profile_state: Option<ProfileState>,
159 pub uuid: Option<String>,
163 pub created_date: Option<String>,
164 pub expiration_date: Option<String>,
167 pub profile_content: Option<String>,
171}
172
173pub type Profile = Resource<ProfileAttributes>;
174
175#[derive(Debug, Clone, Serialize)]
176#[serde(rename_all = "camelCase")]
177pub struct ProfileCreateAttributes {
178 pub name: String,
182 pub profile_type: ProfileType,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct ProfileBundleIdAttributes {
191 pub identifier: Option<String>,
192 pub platform: Option<String>,
193}
194
195pub type ProfileBundleId = Resource<ProfileBundleIdAttributes>;
196
197#[derive(Debug, Clone, Serialize)]
203#[serde(rename_all = "camelCase")]
204pub struct ProfileCreateRelationships {
205 pub bundle_id: ToOne,
206 pub certificates: ToMany,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub devices: Option<ToMany>,
209}
210
211impl ProfileCreateRelationships {
212 pub fn new(
215 profile_type: ProfileType,
216 bundle_id: &str,
217 certificate_ids: &[String],
218 device_ids: &[String],
219 ) -> Self {
220 let devices = if profile_type.takes_devices() && !device_ids.is_empty() {
221 Some(ToMany {
222 data: device_ids
223 .iter()
224 .map(|id| ResourceId {
225 resource_type: "devices",
226 id: id.clone(),
227 })
228 .collect(),
229 })
230 } else {
231 None
232 };
233
234 Self {
235 bundle_id: ToOne {
236 data: ResourceId {
237 resource_type: "bundleIds",
238 id: bundle_id.to_string(),
239 },
240 },
241 certificates: ToMany {
242 data: certificate_ids
243 .iter()
244 .map(|id| ResourceId {
245 resource_type: "certificates",
246 id: id.clone(),
247 })
248 .collect(),
249 },
250 devices,
251 }
252 }
253}
254
255#[async_trait]
262pub trait ProfilesApi {
263 async fn list_profiles(
270 &self,
271 filter_name: Option<&str>,
272 filter_type: Option<ProfileType>,
273 ) -> Result<Vec<Profile>>;
274
275 async fn get_profile_content(&self, id: &str) -> Result<Profile>;
282
283 async fn create_profile(
291 &self,
292 attributes: ProfileCreateAttributes,
293 relationships: ProfileCreateRelationships,
294 ) -> Result<Profile>;
295
296 async fn get_profile_bundle_id(&self, id: &str) -> Result<ProfileBundleId>;
302
303 async fn delete_profile(&self, id: &str) -> Result<()>;
309}
310
311#[async_trait]
312impl ProfilesApi for Client {
313 async fn list_profiles(
314 &self,
315 filter_name: Option<&str>,
316 filter_type: Option<ProfileType>,
317 ) -> Result<Vec<Profile>> {
318 let mut query = Vec::new();
319 if let Some(name) = filter_name {
320 query.push(("filter[name]", name));
321 }
322 if let Some(profile_type) = filter_type {
323 query.push(("filter[profileType]", profile_type.as_api_str()));
324 }
325 query.push(("limit", "200"));
329
330 self.list_all::<ProfileAttributes>("/v1/profiles", &query)
331 .await
332 }
333
334 async fn get_profile_content(&self, id: &str) -> Result<Profile> {
335 let query = [(
336 "fields[profiles]",
337 "name,platform,profileType,profileState,uuid,createdDate,expirationDate,profileContent",
338 )];
339 let doc: Document<ProfileAttributes> = self
340 .request(
341 Method::GET,
342 &format!("/v1/profiles/{id}"),
343 &query,
344 None::<&()>,
345 )
346 .await?;
347 Ok(doc.data)
348 }
349
350 async fn create_profile(
351 &self,
352 attributes: ProfileCreateAttributes,
353 relationships: ProfileCreateRelationships,
354 ) -> Result<Profile> {
355 let body = CreateBody {
356 data: CreateData {
357 resource_type: RESOURCE_TYPE,
358 attributes,
359 relationships: Some(relationships),
360 },
361 };
362 let doc: Document<ProfileAttributes> = self
363 .request(Method::POST, "/v1/profiles", &[], Some(&body))
364 .await?;
365 Ok(doc.data)
366 }
367
368 async fn get_profile_bundle_id(&self, id: &str) -> Result<ProfileBundleId> {
369 let doc: Document<ProfileBundleIdAttributes> = self
370 .request(
371 Method::GET,
372 &format!("/v1/profiles/{id}/bundleId"),
373 &[],
374 None::<&()>,
375 )
376 .await?;
377 Ok(doc.data)
378 }
379
380 async fn delete_profile(&self, id: &str) -> Result<()> {
381 self.request_no_content(
382 Method::DELETE,
383 &format!("/v1/profiles/{id}"),
384 &[],
385 None::<&()>,
386 )
387 .await
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn profile_type_round_trips_through_the_api_wire_format() {
397 for profile_type in [
401 ProfileType::IosAppDevelopment,
402 ProfileType::IosAppStore,
403 ProfileType::IosAppAdHoc,
404 ProfileType::IosAppInHouse,
405 ProfileType::MacAppDevelopment,
406 ProfileType::MacAppStore,
407 ProfileType::MacAppDirect,
408 ProfileType::TvOsAppDevelopment,
409 ProfileType::TvOsAppStore,
410 ProfileType::TvOsAppAdHoc,
411 ProfileType::TvOsAppInHouse,
412 ProfileType::MacCatalystAppDevelopment,
413 ProfileType::MacCatalystAppStore,
414 ProfileType::MacCatalystAppDirect,
415 ] {
416 let json = serde_json::to_string(&profile_type).expect("serializes");
417 let unquoted = json.trim_matches('"');
418 assert_eq!(
419 unquoted,
420 profile_type.as_api_str(),
421 "serde and as_api_str disagree for {profile_type:?}"
422 );
423
424 let parsed: ProfileType = serde_json::from_str(&json).expect("deserializes");
425 assert_eq!(parsed, profile_type);
426 }
427 }
428
429 #[test]
430 fn profile_state_round_trips_through_the_api_wire_format() {
431 for state in [ProfileState::Active, ProfileState::Invalid] {
432 let json = serde_json::to_string(&state).expect("serializes");
433 assert_eq!(json.trim_matches('"'), state.as_api_str());
434 let parsed: ProfileState = serde_json::from_str(&json).expect("deserializes");
435 assert_eq!(parsed, state);
436 }
437 }
438
439 #[test]
440 fn store_profiles_omit_devices_entirely() {
441 let relationships = ProfileCreateRelationships::new(
445 ProfileType::MacAppStore,
446 "P59W5V5953",
447 &["CERT1".to_string()],
448 &["DEVICE1".to_string()],
449 );
450 let json = serde_json::to_value(&relationships).expect("serializes");
451 assert!(
452 json.get("devices").is_none(),
453 "a store profile must not carry devices: {json}"
454 );
455 assert_eq!(json["bundleId"]["data"]["type"], "bundleIds");
456 assert_eq!(json["certificates"]["data"][0]["type"], "certificates");
457 }
458
459 #[test]
460 fn development_profiles_keep_their_devices() {
461 let relationships = ProfileCreateRelationships::new(
462 ProfileType::IosAppDevelopment,
463 "P59W5V5953",
464 &["CERT1".to_string()],
465 &["DEVICE1".to_string(), "DEVICE2".to_string()],
466 );
467 let json = serde_json::to_value(&relationships).expect("serializes");
468 assert_eq!(json["devices"]["data"].as_array().expect("array").len(), 2);
469 assert_eq!(json["devices"]["data"][0]["type"], "devices");
470 }
471
472 #[test]
473 fn a_development_profile_with_no_devices_omits_the_key() {
474 let relationships = ProfileCreateRelationships::new(
477 ProfileType::IosAppDevelopment,
478 "P59W5V5953",
479 &["CERT1".to_string()],
480 &[],
481 );
482 let json = serde_json::to_value(&relationships).expect("serializes");
483 assert!(json.get("devices").is_none());
484 }
485
486 #[test]
487 fn reads_the_bundle_id_behind_a_profile() {
488 let doc: Document<ProfileBundleIdAttributes> = serde_json::from_value(serde_json::json!({
489 "data": { "id": "C3PP53MLM3", "type": "bundleIds",
490 "attributes": { "identifier": "ai.splitfire.SplitfireAI",
491 "platform": "UNIVERSAL", "name": "SplitFire AI", "seedId": "2TQF86ZACD" } }
492 }))
493 .unwrap();
494 assert_eq!(doc.data.id, "C3PP53MLM3");
495 assert_eq!(
496 doc.data.attributes.identifier.as_deref(),
497 Some("ai.splitfire.SplitfireAI")
498 );
499 }
500}