1use async_trait::async_trait;
34use reqwest::Method;
35use serde::{Deserialize, Serialize};
36use smbcloud_ascapi_core::jsonapi::{
37 CreateBody, CreateData, Document, ListDocument, 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)]
191#[serde(rename_all = "camelCase")]
192pub struct ProfileCreateRelationships {
193 pub bundle_id: ToOne,
194 pub certificates: ToMany,
195 #[serde(skip_serializing_if = "Option::is_none")]
196 pub devices: Option<ToMany>,
197}
198
199impl ProfileCreateRelationships {
200 pub fn new(
203 profile_type: ProfileType,
204 bundle_id: &str,
205 certificate_ids: &[String],
206 device_ids: &[String],
207 ) -> Self {
208 let devices = if profile_type.takes_devices() && !device_ids.is_empty() {
209 Some(ToMany {
210 data: device_ids
211 .iter()
212 .map(|id| ResourceId {
213 resource_type: "devices",
214 id: id.clone(),
215 })
216 .collect(),
217 })
218 } else {
219 None
220 };
221
222 Self {
223 bundle_id: ToOne {
224 data: ResourceId {
225 resource_type: "bundleIds",
226 id: bundle_id.to_string(),
227 },
228 },
229 certificates: ToMany {
230 data: certificate_ids
231 .iter()
232 .map(|id| ResourceId {
233 resource_type: "certificates",
234 id: id.clone(),
235 })
236 .collect(),
237 },
238 devices,
239 }
240 }
241}
242
243#[async_trait]
250pub trait ProfilesApi {
251 async fn list_profiles(
258 &self,
259 filter_name: Option<&str>,
260 filter_type: Option<ProfileType>,
261 ) -> Result<Vec<Profile>>;
262
263 async fn get_profile_content(&self, id: &str) -> Result<Profile>;
270
271 async fn create_profile(
279 &self,
280 attributes: ProfileCreateAttributes,
281 relationships: ProfileCreateRelationships,
282 ) -> Result<Profile>;
283
284 async fn delete_profile(&self, id: &str) -> Result<()>;
290}
291
292#[async_trait]
293impl ProfilesApi for Client {
294 async fn list_profiles(
295 &self,
296 filter_name: Option<&str>,
297 filter_type: Option<ProfileType>,
298 ) -> Result<Vec<Profile>> {
299 let mut query = Vec::new();
300 if let Some(name) = filter_name {
301 query.push(("filter[name]", name));
302 }
303 if let Some(profile_type) = filter_type {
304 query.push(("filter[profileType]", profile_type.as_api_str()));
305 }
306 query.push(("limit", "200"));
309
310 let doc: ListDocument<ProfileAttributes> = self
311 .request(Method::GET, "/v1/profiles", &query, None::<&()>)
312 .await?;
313 Ok(doc.data)
314 }
315
316 async fn get_profile_content(&self, id: &str) -> Result<Profile> {
317 let query = [(
318 "fields[profiles]",
319 "name,platform,profileType,profileState,uuid,createdDate,expirationDate,profileContent",
320 )];
321 let doc: Document<ProfileAttributes> = self
322 .request(
323 Method::GET,
324 &format!("/v1/profiles/{id}"),
325 &query,
326 None::<&()>,
327 )
328 .await?;
329 Ok(doc.data)
330 }
331
332 async fn create_profile(
333 &self,
334 attributes: ProfileCreateAttributes,
335 relationships: ProfileCreateRelationships,
336 ) -> Result<Profile> {
337 let body = CreateBody {
338 data: CreateData {
339 resource_type: RESOURCE_TYPE,
340 attributes,
341 relationships: Some(relationships),
342 },
343 };
344 let doc: Document<ProfileAttributes> = self
345 .request(Method::POST, "/v1/profiles", &[], Some(&body))
346 .await?;
347 Ok(doc.data)
348 }
349
350 async fn delete_profile(&self, id: &str) -> Result<()> {
351 self.request_no_content(
352 Method::DELETE,
353 &format!("/v1/profiles/{id}"),
354 &[],
355 None::<&()>,
356 )
357 .await
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn profile_type_round_trips_through_the_api_wire_format() {
367 for profile_type in [
371 ProfileType::IosAppDevelopment,
372 ProfileType::IosAppStore,
373 ProfileType::IosAppAdHoc,
374 ProfileType::IosAppInHouse,
375 ProfileType::MacAppDevelopment,
376 ProfileType::MacAppStore,
377 ProfileType::MacAppDirect,
378 ProfileType::TvOsAppDevelopment,
379 ProfileType::TvOsAppStore,
380 ProfileType::TvOsAppAdHoc,
381 ProfileType::TvOsAppInHouse,
382 ProfileType::MacCatalystAppDevelopment,
383 ProfileType::MacCatalystAppStore,
384 ProfileType::MacCatalystAppDirect,
385 ] {
386 let json = serde_json::to_string(&profile_type).expect("serializes");
387 let unquoted = json.trim_matches('"');
388 assert_eq!(
389 unquoted,
390 profile_type.as_api_str(),
391 "serde and as_api_str disagree for {profile_type:?}"
392 );
393
394 let parsed: ProfileType = serde_json::from_str(&json).expect("deserializes");
395 assert_eq!(parsed, profile_type);
396 }
397 }
398
399 #[test]
400 fn profile_state_round_trips_through_the_api_wire_format() {
401 for state in [ProfileState::Active, ProfileState::Invalid] {
402 let json = serde_json::to_string(&state).expect("serializes");
403 assert_eq!(json.trim_matches('"'), state.as_api_str());
404 let parsed: ProfileState = serde_json::from_str(&json).expect("deserializes");
405 assert_eq!(parsed, state);
406 }
407 }
408
409 #[test]
410 fn store_profiles_omit_devices_entirely() {
411 let relationships = ProfileCreateRelationships::new(
415 ProfileType::MacAppStore,
416 "P59W5V5953",
417 &["CERT1".to_string()],
418 &["DEVICE1".to_string()],
419 );
420 let json = serde_json::to_value(&relationships).expect("serializes");
421 assert!(
422 json.get("devices").is_none(),
423 "a store profile must not carry devices: {json}"
424 );
425 assert_eq!(json["bundleId"]["data"]["type"], "bundleIds");
426 assert_eq!(json["certificates"]["data"][0]["type"], "certificates");
427 }
428
429 #[test]
430 fn development_profiles_keep_their_devices() {
431 let relationships = ProfileCreateRelationships::new(
432 ProfileType::IosAppDevelopment,
433 "P59W5V5953",
434 &["CERT1".to_string()],
435 &["DEVICE1".to_string(), "DEVICE2".to_string()],
436 );
437 let json = serde_json::to_value(&relationships).expect("serializes");
438 assert_eq!(json["devices"]["data"].as_array().expect("array").len(), 2);
439 assert_eq!(json["devices"]["data"][0]["type"], "devices");
440 }
441
442 #[test]
443 fn a_development_profile_with_no_devices_omits_the_key() {
444 let relationships = ProfileCreateRelationships::new(
447 ProfileType::IosAppDevelopment,
448 "P59W5V5953",
449 &["CERT1".to_string()],
450 &[],
451 );
452 let json = serde_json::to_value(&relationships).expect("serializes");
453 assert!(json.get("devices").is_none());
454 }
455}