smbcloud-ascapi-aso 0.2.0

App Store Connect App Metadata resources: apps, app infos, versions, bundle IDs, localizations, and screenshots.
Documentation
use async_trait::async_trait;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, Resource};
use smbcloud_ascapi_core::Client;
use smbcloud_ascapi_core::Result;

pub const RESOURCE_TYPE: &str = "bundleIds";

/// Note this is a *different* enum from
/// [`crate::app_store_version::Platform`] — App Store Connect uses a
/// coarser set of platforms when registering a bundle ID than when
/// declaring an App Store Version's platform. A visionOS app typically
/// registers its bundle ID as `Ios` (visionOS shares the iOS identifier
/// namespace) rather than a dedicated visionOS value, because there isn't
/// one here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BundleIdPlatform {
    #[serde(rename = "IOS")]
    Ios,
    #[serde(rename = "MAC_OS")]
    MacOs,
    #[serde(rename = "UNIVERSAL")]
    Universal,
    /// Not an app platform: the identifier kind Apple assigns to Services
    /// IDs (Sign in with Apple, push-only identifiers, and similar).
    ///
    /// Present because `GET /v1/bundleIds` returns every identifier the
    /// team owns, mixed together. Without this variant, one Services ID
    /// anywhere in the account fails the deserialization of the whole
    /// list, so listing breaks for reasons that have nothing to do with
    /// the app being looked up.
    #[serde(rename = "SERVICES")]
    Services,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BundleIdAttributes {
    pub name: Option<String>,
    pub identifier: Option<String>,
    pub platform: Option<BundleIdPlatform>,
    pub seed_id: Option<String>,
}

pub type BundleId = Resource<BundleIdAttributes>;

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BundleIdCreateAttributes {
    pub identifier: String,
    pub name: String,
    pub platform: BundleIdPlatform,
}

/// Registered reverse-DNS identifiers.
///
/// An extension trait rather than inherent methods, because `Client`
/// lives in the core crate and Rust only allows inherent impls in the
/// crate that defines the type. Import it, or the crate's `prelude`,
/// to call these on a `Client`.
#[async_trait]
pub trait BundleIdsApi {
    /// `GET /v1/bundleIds`, optionally filtered to an exact identifier —
    /// check whether a bundle ID is already registered before trying to
    /// create it, or before creating an App Store Version under an app
    /// that uses it.
    ///
    /// App Store Connect's `filter[identifier]` is a case-insensitive
    /// substring match: `ai.splitfire.SplitfireAI` also returns
    /// `….topshelf`, `-Debug` and `ai.splitfire.SplitFireAIWatch`. The rows
    /// are cut to the exact identifier here, so an empty result really
    /// means "not registered".
    async fn list_bundle_ids(&self, filter_identifier: Option<&str>) -> Result<Vec<BundleId>>;

    /// `POST /v1/bundleIds` — registers a new bundle ID with the developer
    /// account (Certificates, Identifiers & Profiles).
    async fn create_bundle_id(&self, attributes: BundleIdCreateAttributes) -> Result<BundleId>;
}

#[async_trait]
impl BundleIdsApi for Client {
    async fn list_bundle_ids(&self, filter_identifier: Option<&str>) -> Result<Vec<BundleId>> {
        let mut query = Vec::new();
        if let Some(identifier) = filter_identifier {
            query.push(("filter[identifier]", identifier));
        }
        // 200 rows per page instead of Apple's default 20 — fewer round
        // trips. `list_all` follows `links.next` so no page boundary can
        // truncate the result.
        query.push(("limit", "200"));

        let bundle_ids = self
            .list_all::<BundleIdAttributes>("/v1/bundleIds", &query)
            .await?;
        Ok(match filter_identifier {
            Some(identifier) => exact_identifier(bundle_ids, identifier),
            None => bundle_ids,
        })
    }

    async fn create_bundle_id(&self, attributes: BundleIdCreateAttributes) -> Result<BundleId> {
        let body = CreateBody {
            data: CreateData {
                resource_type: RESOURCE_TYPE,
                attributes,
                relationships: None::<()>,
            },
        };
        let doc: Document<BundleIdAttributes> = self
            .request(Method::POST, "/v1/bundleIds", &[], Some(&body))
            .await?;
        Ok(doc.data)
    }
}

/// Keep the bundle IDs whose identifier is exactly `identifier`.
fn exact_identifier(bundle_ids: Vec<BundleId>, identifier: &str) -> Vec<BundleId> {
    bundle_ids
        .into_iter()
        .filter(|bundle_id| bundle_id.attributes.identifier.as_deref() == Some(identifier))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use smbcloud_ascapi_core::jsonapi::ListDocument;

    /// Trimmed from what `filter[identifier]=ai.splitfire.SplitfireAI`
    /// actually returned: neighbours, a Services ID, and a different app
    /// matched through case folding.
    #[test]
    fn an_identifier_filter_keeps_only_the_exact_match() {
        let doc: ListDocument<BundleIdAttributes> = serde_json::from_value(serde_json::json!({
            "data": [
                { "id": "A", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitfireAIAuth", "platform": "SERVICES" } },
                { "id": "B", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitfireAI", "platform": "UNIVERSAL" } },
                { "id": "C", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitfireAI.topshelf", "platform": "UNIVERSAL" } },
                { "id": "D", "type": "bundleIds", "attributes": { "identifier": "ai.splitfire.SplitFireAIWatch", "platform": "UNIVERSAL" } }
            ]
        }))
        .unwrap();
        let exact = exact_identifier(doc.data.clone(), "ai.splitfire.SplitfireAI");
        assert_eq!(
            exact.iter().map(|b| b.id.as_str()).collect::<Vec<_>>(),
            ["B"]
        );
        assert!(exact_identifier(doc.data, "ai.splitfire.Splitfire").is_empty());
    }

    #[test]
    fn a_services_identifier_does_not_break_the_list() {
        // Regression: `GET /v1/bundleIds` returns every identifier a team
        // owns, and a single Services ID used to fail the whole response's
        // deserialization — so `bundle-ids list` reported nothing at all
        // for accounts that have one.
        let body = serde_json::json!({
            "data": [
                {
                    "id": "AAA",
                    "type": "bundleIds",
                    "attributes": {
                        "name": "An app",
                        "identifier": "xyz.example.app",
                        "platform": "UNIVERSAL",
                        "seedId": "TEAMID"
                    }
                },
                {
                    "id": "BBB",
                    "type": "bundleIds",
                    "attributes": {
                        "name": "Sign in with Apple",
                        "identifier": "xyz.example.service",
                        "platform": "SERVICES",
                        "seedId": "TEAMID"
                    }
                }
            ]
        });

        let doc: ListDocument<BundleIdAttributes> =
            serde_json::from_value(body).expect("a Services identifier must not fail the list");
        assert_eq!(doc.data.len(), 2);
        assert_eq!(
            doc.data[1].attributes.platform,
            Some(BundleIdPlatform::Services)
        );
    }
}