use async_trait::async_trait;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use smbcloud_ascapi_core::jsonapi::{
CreateBody, CreateData, Document, Resource, ResourceId, ToMany, ToOne,
};
use smbcloud_ascapi_core::Client;
use smbcloud_ascapi_core::Result;
pub const RESOURCE_TYPE: &str = "profiles";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProfileType {
#[serde(rename = "IOS_APP_DEVELOPMENT")]
IosAppDevelopment,
#[serde(rename = "IOS_APP_STORE")]
IosAppStore,
#[serde(rename = "IOS_APP_ADHOC")]
IosAppAdHoc,
#[serde(rename = "IOS_APP_INHOUSE")]
IosAppInHouse,
#[serde(rename = "MAC_APP_DEVELOPMENT")]
MacAppDevelopment,
#[serde(rename = "MAC_APP_STORE")]
MacAppStore,
#[serde(rename = "MAC_APP_DIRECT")]
MacAppDirect,
#[serde(rename = "TVOS_APP_DEVELOPMENT")]
TvOsAppDevelopment,
#[serde(rename = "TVOS_APP_STORE")]
TvOsAppStore,
#[serde(rename = "TVOS_APP_ADHOC")]
TvOsAppAdHoc,
#[serde(rename = "TVOS_APP_INHOUSE")]
TvOsAppInHouse,
#[serde(rename = "MAC_CATALYST_APP_DEVELOPMENT")]
MacCatalystAppDevelopment,
#[serde(rename = "MAC_CATALYST_APP_STORE")]
MacCatalystAppStore,
#[serde(rename = "MAC_CATALYST_APP_DIRECT")]
MacCatalystAppDirect,
}
impl ProfileType {
pub fn as_api_str(self) -> &'static str {
match self {
Self::IosAppDevelopment => "IOS_APP_DEVELOPMENT",
Self::IosAppStore => "IOS_APP_STORE",
Self::IosAppAdHoc => "IOS_APP_ADHOC",
Self::IosAppInHouse => "IOS_APP_INHOUSE",
Self::MacAppDevelopment => "MAC_APP_DEVELOPMENT",
Self::MacAppStore => "MAC_APP_STORE",
Self::MacAppDirect => "MAC_APP_DIRECT",
Self::TvOsAppDevelopment => "TVOS_APP_DEVELOPMENT",
Self::TvOsAppStore => "TVOS_APP_STORE",
Self::TvOsAppAdHoc => "TVOS_APP_ADHOC",
Self::TvOsAppInHouse => "TVOS_APP_INHOUSE",
Self::MacCatalystAppDevelopment => "MAC_CATALYST_APP_DEVELOPMENT",
Self::MacCatalystAppStore => "MAC_CATALYST_APP_STORE",
Self::MacCatalystAppDirect => "MAC_CATALYST_APP_DIRECT",
}
}
pub fn takes_devices(self) -> bool {
matches!(
self,
Self::IosAppDevelopment
| Self::IosAppAdHoc
| Self::MacAppDevelopment
| Self::TvOsAppDevelopment
| Self::TvOsAppAdHoc
| Self::MacCatalystAppDevelopment
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProfileState {
#[serde(rename = "ACTIVE")]
Active,
#[serde(rename = "INVALID")]
Invalid,
}
impl ProfileState {
pub fn as_api_str(self) -> &'static str {
match self {
Self::Active => "ACTIVE",
Self::Invalid => "INVALID",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileAttributes {
pub name: Option<String>,
pub platform: Option<String>,
pub profile_type: Option<ProfileType>,
pub profile_state: Option<ProfileState>,
pub uuid: Option<String>,
pub created_date: Option<String>,
pub expiration_date: Option<String>,
pub profile_content: Option<String>,
}
pub type Profile = Resource<ProfileAttributes>;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileCreateAttributes {
pub name: String,
pub profile_type: ProfileType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileBundleIdAttributes {
pub identifier: Option<String>,
pub platform: Option<String>,
}
pub type ProfileBundleId = Resource<ProfileBundleIdAttributes>;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileCreateRelationships {
pub bundle_id: ToOne,
pub certificates: ToMany,
#[serde(skip_serializing_if = "Option::is_none")]
pub devices: Option<ToMany>,
}
impl ProfileCreateRelationships {
pub fn new(
profile_type: ProfileType,
bundle_id: &str,
certificate_ids: &[String],
device_ids: &[String],
) -> Self {
let devices = if profile_type.takes_devices() && !device_ids.is_empty() {
Some(ToMany {
data: device_ids
.iter()
.map(|id| ResourceId {
resource_type: "devices",
id: id.clone(),
})
.collect(),
})
} else {
None
};
Self {
bundle_id: ToOne {
data: ResourceId {
resource_type: "bundleIds",
id: bundle_id.to_string(),
},
},
certificates: ToMany {
data: certificate_ids
.iter()
.map(|id| ResourceId {
resource_type: "certificates",
id: id.clone(),
})
.collect(),
},
devices,
}
}
}
#[async_trait]
pub trait ProfilesApi {
async fn list_profiles(
&self,
filter_name: Option<&str>,
filter_type: Option<ProfileType>,
) -> Result<Vec<Profile>>;
async fn get_profile_content(&self, id: &str) -> Result<Profile>;
async fn create_profile(
&self,
attributes: ProfileCreateAttributes,
relationships: ProfileCreateRelationships,
) -> Result<Profile>;
async fn get_profile_bundle_id(&self, id: &str) -> Result<ProfileBundleId>;
async fn delete_profile(&self, id: &str) -> Result<()>;
}
#[async_trait]
impl ProfilesApi for Client {
async fn list_profiles(
&self,
filter_name: Option<&str>,
filter_type: Option<ProfileType>,
) -> Result<Vec<Profile>> {
let mut query = Vec::new();
if let Some(name) = filter_name {
query.push(("filter[name]", name));
}
if let Some(profile_type) = filter_type {
query.push(("filter[profileType]", profile_type.as_api_str()));
}
query.push(("limit", "200"));
self.list_all::<ProfileAttributes>("/v1/profiles", &query)
.await
}
async fn get_profile_content(&self, id: &str) -> Result<Profile> {
let query = [(
"fields[profiles]",
"name,platform,profileType,profileState,uuid,createdDate,expirationDate,profileContent",
)];
let doc: Document<ProfileAttributes> = self
.request(
Method::GET,
&format!("/v1/profiles/{id}"),
&query,
None::<&()>,
)
.await?;
Ok(doc.data)
}
async fn create_profile(
&self,
attributes: ProfileCreateAttributes,
relationships: ProfileCreateRelationships,
) -> Result<Profile> {
let body = CreateBody {
data: CreateData {
resource_type: RESOURCE_TYPE,
attributes,
relationships: Some(relationships),
},
};
let doc: Document<ProfileAttributes> = self
.request(Method::POST, "/v1/profiles", &[], Some(&body))
.await?;
Ok(doc.data)
}
async fn get_profile_bundle_id(&self, id: &str) -> Result<ProfileBundleId> {
let doc: Document<ProfileBundleIdAttributes> = self
.request(
Method::GET,
&format!("/v1/profiles/{id}/bundleId"),
&[],
None::<&()>,
)
.await?;
Ok(doc.data)
}
async fn delete_profile(&self, id: &str) -> Result<()> {
self.request_no_content(
Method::DELETE,
&format!("/v1/profiles/{id}"),
&[],
None::<&()>,
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_type_round_trips_through_the_api_wire_format() {
for profile_type in [
ProfileType::IosAppDevelopment,
ProfileType::IosAppStore,
ProfileType::IosAppAdHoc,
ProfileType::IosAppInHouse,
ProfileType::MacAppDevelopment,
ProfileType::MacAppStore,
ProfileType::MacAppDirect,
ProfileType::TvOsAppDevelopment,
ProfileType::TvOsAppStore,
ProfileType::TvOsAppAdHoc,
ProfileType::TvOsAppInHouse,
ProfileType::MacCatalystAppDevelopment,
ProfileType::MacCatalystAppStore,
ProfileType::MacCatalystAppDirect,
] {
let json = serde_json::to_string(&profile_type).expect("serializes");
let unquoted = json.trim_matches('"');
assert_eq!(
unquoted,
profile_type.as_api_str(),
"serde and as_api_str disagree for {profile_type:?}"
);
let parsed: ProfileType = serde_json::from_str(&json).expect("deserializes");
assert_eq!(parsed, profile_type);
}
}
#[test]
fn profile_state_round_trips_through_the_api_wire_format() {
for state in [ProfileState::Active, ProfileState::Invalid] {
let json = serde_json::to_string(&state).expect("serializes");
assert_eq!(json.trim_matches('"'), state.as_api_str());
let parsed: ProfileState = serde_json::from_str(&json).expect("deserializes");
assert_eq!(parsed, state);
}
}
#[test]
fn store_profiles_omit_devices_entirely() {
let relationships = ProfileCreateRelationships::new(
ProfileType::MacAppStore,
"P59W5V5953",
&["CERT1".to_string()],
&["DEVICE1".to_string()],
);
let json = serde_json::to_value(&relationships).expect("serializes");
assert!(
json.get("devices").is_none(),
"a store profile must not carry devices: {json}"
);
assert_eq!(json["bundleId"]["data"]["type"], "bundleIds");
assert_eq!(json["certificates"]["data"][0]["type"], "certificates");
}
#[test]
fn development_profiles_keep_their_devices() {
let relationships = ProfileCreateRelationships::new(
ProfileType::IosAppDevelopment,
"P59W5V5953",
&["CERT1".to_string()],
&["DEVICE1".to_string(), "DEVICE2".to_string()],
);
let json = serde_json::to_value(&relationships).expect("serializes");
assert_eq!(json["devices"]["data"].as_array().expect("array").len(), 2);
assert_eq!(json["devices"]["data"][0]["type"], "devices");
}
#[test]
fn a_development_profile_with_no_devices_omits_the_key() {
let relationships = ProfileCreateRelationships::new(
ProfileType::IosAppDevelopment,
"P59W5V5953",
&["CERT1".to_string()],
&[],
);
let json = serde_json::to_value(&relationships).expect("serializes");
assert!(json.get("devices").is_none());
}
#[test]
fn reads_the_bundle_id_behind_a_profile() {
let doc: Document<ProfileBundleIdAttributes> = serde_json::from_value(serde_json::json!({
"data": { "id": "C3PP53MLM3", "type": "bundleIds",
"attributes": { "identifier": "ai.splitfire.SplitfireAI",
"platform": "UNIVERSAL", "name": "SplitFire AI", "seedId": "2TQF86ZACD" } }
}))
.unwrap();
assert_eq!(doc.data.id, "C3PP53MLM3");
assert_eq!(
doc.data.attributes.identifier.as_deref(),
Some("ai.splitfire.SplitfireAI")
);
}
}