Skip to main content

smbcloud_ascapi_aso/
bundle_id.rs

1use async_trait::async_trait;
2use reqwest::Method;
3use serde::{Deserialize, Serialize};
4use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, Resource};
5use smbcloud_ascapi_core::Client;
6use smbcloud_ascapi_core::Result;
7
8pub 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)]
18pub 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")]
39pub 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
46pub type BundleId = Resource<BundleIdAttributes>;
47
48#[derive(Debug, Clone, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub 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]
63pub 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]
82impl 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`.
118fn 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)]
126mod 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}