| 1 | use async_trait::async_trait; |
| 2 | use reqwest::Method; |
| 3 | use serde::{Deserialize, Serialize}; |
| 4 | use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, Resource}; |
| 5 | use smbcloud_ascapi_core::Client; |
| 6 | use smbcloud_ascapi_core::Result; |
| 7 | |
| 8 | pub const RESOURCE_TYPE: &str = "bundleIds"; |
| 9 | |
| 10 | /// Note this is a *different* enum from |
| 11 | /// [`crate::app_store_version::Platform`] — App Store Connect uses a |
| 12 | /// coarser set of platforms when registering a bundle ID than when |
| 13 | /// declaring an App Store Version's platform. A visionOS app typically |
| 14 | /// registers its bundle ID as `Ios` (visionOS shares the iOS identifier |
| 15 | /// namespace) rather than a dedicated visionOS value, because there isn't |
| 16 | /// one here. |
| 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 18 | pub enum BundleIdPlatform { |
| 19 | #[serde(rename = "IOS")] |
| 20 | Ios, |
| 21 | #[serde(rename = "MAC_OS")] |
| 22 | MacOs, |
| 23 | #[serde(rename = "UNIVERSAL")] |
| 24 | Universal, |
| 25 | /// Not an app platform: the identifier kind Apple assigns to Services |
| 26 | /// IDs (Sign in with Apple, push-only identifiers, and similar). |
| 27 | /// |
| 28 | /// Present because `GET /v1/bundleIds` returns every identifier the |
| 29 | /// team owns, mixed together. Without this variant, one Services ID |
| 30 | /// anywhere in the account fails the deserialization of the whole |
| 31 | /// list, so listing breaks for reasons that have nothing to do with |
| 32 | /// the app being looked up. |
| 33 | #[serde(rename = "SERVICES")] |
| 34 | Services, |
| 35 | } |
| 36 | |
| 37 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 38 | #[serde(rename_all = "camelCase")] |
| 39 | pub struct BundleIdAttributes { |
| 40 | pub name: Option<String>, |
| 41 | pub identifier: Option<String>, |
| 42 | pub platform: Option<BundleIdPlatform>, |
| 43 | pub seed_id: Option<String>, |
| 44 | } |
| 45 | |
| 46 | pub type BundleId = Resource<BundleIdAttributes>; |
| 47 | |
| 48 | #[derive(Debug, Clone, Serialize)] |
| 49 | #[serde(rename_all = "camelCase")] |
| 50 | pub struct BundleIdCreateAttributes { |
| 51 | pub identifier: String, |
| 52 | pub name: String, |
| 53 | pub platform: BundleIdPlatform, |
| 54 | } |
| 55 | |
| 56 | /// Registered reverse-DNS identifiers. |
| 57 | /// |
| 58 | /// An extension trait rather than inherent methods, because `Client` |
| 59 | /// lives in the core crate and Rust only allows inherent impls in the |
| 60 | /// crate that defines the type. Import it, or the crate's `prelude`, |
| 61 | /// to call these on a `Client`. |
| 62 | #[async_trait] |
| 63 | pub trait BundleIdsApi { |
| 64 | /// `GET /v1/bundleIds`, optionally filtered to an exact identifier — |
| 65 | /// check whether a bundle ID is already registered before trying to |
| 66 | /// create it, or before creating an App Store Version under an app |
| 67 | /// that uses it. |
| 68 | /// |
| 69 | /// App Store Connect's `filter[identifier]` is a case-insensitive |
| 70 | /// substring match: `ai.splitfire.SplitfireAI` also returns |
| 71 | /// `….topshelf`, `-Debug` and `ai.splitfire.SplitFireAIWatch`. The rows |
| 72 | /// are cut to the exact identifier here, so an empty result really |
| 73 | /// means "not registered". |
| 74 | async fn list_bundle_ids(&self, filter_identifier: Option<&str>) -> Result<Vec<BundleId>>; |
| 75 | |
| 76 | /// `POST /v1/bundleIds` — registers a new bundle ID with the developer |
| 77 | /// account (Certificates, Identifiers & Profiles). |
| 78 | async fn create_bundle_id(&self, attributes: BundleIdCreateAttributes) -> Result<BundleId>; |
| 79 | } |
| 80 | |
| 81 | #[async_trait] |
| 82 | impl BundleIdsApi for Client { |
| 83 | async fn list_bundle_ids(&self, filter_identifier: Option<&str>) -> Result<Vec<BundleId>> { |
| 84 | let mut query = Vec::new(); |
| 85 | if let Some(identifier) = filter_identifier { |
| 86 | query.push(("filter[identifier]", identifier)); |
| 87 | } |
| 88 | // 200 rows per page instead of Apple's default 20 — fewer round |
| 89 | // trips. `list_all` follows `links.next` so no page boundary can |
| 90 | // truncate the result. |
| 91 | query.push(("limit", "200")); |
| 92 | |
| 93 | let bundle_ids = self |
| 94 | .list_all::<BundleIdAttributes>("/v1/bundleIds", &query) |
| 95 | .await?; |
| 96 | Ok(match filter_identifier { |
| 97 | Some(identifier) => exact_identifier(bundle_ids, identifier), |
| 98 | None => bundle_ids, |
| 99 | }) |
| 100 | } |
| 101 | |
| 102 | async fn create_bundle_id(&self, attributes: BundleIdCreateAttributes) -> Result<BundleId> { |
| 103 | let body = CreateBody { |
| 104 | data: CreateData { |
| 105 | resource_type: RESOURCE_TYPE, |
| 106 | attributes, |
| 107 | relationships: None::<()>, |
| 108 | }, |
| 109 | }; |
| 110 | let doc: Document<BundleIdAttributes> = self |
| 111 | .request(Method::POST, "/v1/bundleIds", &[], Some(&body)) |
| 112 | .await?; |
| 113 | Ok(doc.data) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | /// Keep the bundle IDs whose identifier is exactly `identifier`. |
| 118 | fn exact_identifier(bundle_ids: Vec<BundleId>, identifier: &str) -> Vec<BundleId> { |
| 119 | bundle_ids |
| 120 | .into_iter() |
| 121 | .filter(|bundle_id| bundle_id.attributes.identifier.as_deref() == Some(identifier)) |
| 122 | .collect() |
| 123 | } |
| 124 | |
| 125 | #[cfg(test)] |
| 126 | mod tests { |
| 127 | use super::*; |
| 128 | use smbcloud_ascapi_core::jsonapi::ListDocument; |
| 129 | |
| 130 | /// Trimmed from what `filter[identifier]=ai.splitfire.SplitfireAI` |
| 131 | /// actually returned: neighbours, a Services ID, and a different app |
| 132 | /// matched through case folding. |
| 133 | #[test] |
| 134 | fn an_identifier_filter_keeps_only_the_exact_match() { |
| 135 | let doc: ListDocument<BundleIdAttributes> = serde_json::from_value(serde_json::json!({ |
| 136 | "data": [ |
| 137 | { "id": "A", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitfireAIAuth", "platform": "SERVICES" } }, |
| 138 | { "id": "B", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitfireAI", "platform": "UNIVERSAL" } }, |
| 139 | { "id": "C", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitfireAI.topshelf", "platform": "UNIVERSAL" } }, |
| 140 | { "id": "D", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitFireAIWatch", "platform": "UNIVERSAL" } } |
| 141 | ] |
| 142 | })) |
| 143 | .unwrap(); |
| 144 | let exact = exact_identifier(doc.data.clone(), "ai.splitfire.SplitfireAI"); |
| 145 | assert_eq!( |
| 146 | exact.iter().map(|b| b.id.as_str()).collect::<Vec<_>>(), |
| 147 | ["B"] |
| 148 | ); |
| 149 | assert!(exact_identifier(doc.data, "ai.splitfire.Splitfire").is_empty()); |
| 150 | } |
| 151 | |
| 152 | #[test] |
| 153 | fn a_services_identifier_does_not_break_the_list() { |
| 154 | // Regression: `GET /v1/bundleIds` returns every identifier a team |
| 155 | // owns, and a single Services ID used to fail the whole response's |
| 156 | // deserialization — so `bundle-ids list` reported nothing at all |
| 157 | // for accounts that have one. |
| 158 | let body = serde_json::json!({ |
| 159 | "data": [ |
| 160 | { |
| 161 | "id": "AAA", |
| 162 | "type": "bundleIds", |
| 163 | "attributes": { |
| 164 | "name": "An app", |
| 165 | "identifier": "xyz.example.app", |
| 166 | "platform": "UNIVERSAL", |
| 167 | "seedId": "TEAMID" |
| 168 | } |
| 169 | }, |
| 170 | { |
| 171 | "id": "BBB", |
| 172 | "type": "bundleIds", |
| 173 | "attributes": { |
| 174 | "name": "Sign in with Apple", |
| 175 | "identifier": "xyz.example.service", |
| 176 | "platform": "SERVICES", |
| 177 | "seedId": "TEAMID" |
| 178 | } |
| 179 | } |
| 180 | ] |
| 181 | }); |
| 182 | |
| 183 | let doc: ListDocument<BundleIdAttributes> = |
| 184 | serde_json::from_value(body).expect("a Services identifier must not fail the list"); |
| 185 | assert_eq!(doc.data.len(), 2); |
| 186 | assert_eq!( |
| 187 | doc.data[1].attributes.platform, |
| 188 | Some(BundleIdPlatform::Services) |
| 189 | ); |
| 190 | } |
| 191 | } |