Skip to main content

smbcloud_ascapi_aso/
app.rs

1//! `GET /v1/apps`, `GET`/`PATCH /v1/apps/{id}`. Note there is no
2//! `POST /v1/apps` — App Store Connect has no "create an app" endpoint.
3//! An app record comes into existence the first time you register its
4//! bundle ID (see [`crate::bundle_id`]) and give it a first
5//! [`crate::app_store_version`]; the App Store Connect UI (or, per
6//! platform, a build upload) is what actually provisions the `App` row.
7//! This module only reads and updates one that already exists.
8
9use async_trait::async_trait;
10use reqwest::Method;
11use serde::{Deserialize, Serialize};
12use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData};
13use smbcloud_ascapi_core::Client;
14use smbcloud_ascapi_core::Result;
15
16pub const RESOURCE_TYPE: &str = "apps";
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct AppAttributes {
21    pub name: Option<String>,
22    pub bundle_id: Option<String>,
23    pub sku: Option<String>,
24    pub primary_locale: Option<String>,
25    pub is_or_ever_was_made_for_kids: Option<bool>,
26    pub content_rights_declaration: Option<String>,
27}
28
29pub type App = Resource<AppAttributes>;
30
31#[derive(Debug, Clone, Default, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct AppUpdateAttributes {
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub primary_locale: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub content_rights_declaration: Option<String>,
38}
39
40/// Apps: the top-level record for a product on the store.
41///
42/// An extension trait rather than inherent methods, because `Client`
43/// lives in the core crate and Rust only allows inherent impls in the
44/// crate that defines the type. Import it, or the crate's `prelude`,
45/// to call these on a `Client`.
46#[async_trait]
47pub trait AppsApi {
48    /// `GET /v1/apps`, optionally narrowed to the app with exactly this
49    /// bundle identifier — the usual way to resolve an app's ASC id from
50    /// the bundle identifier already baked into an Xcode project.
51    ///
52    /// App Store Connect's `filter[bundleId]` matches substrings, so
53    /// `ai.splitfire.KaroKowe` also returns `ai.splitfire.KaroKoweJam`, and
54    /// listed first. The filter still goes to the server to keep the
55    /// response small, and the rows are then cut to exact matches here.
56    async fn list_apps(&self, filter_bundle_id: Option<&str>) -> Result<Vec<App>>;
57
58    async fn get_app(&self, app_id: &str) -> Result<App>;
59
60    async fn update_app(&self, app_id: &str, attributes: AppUpdateAttributes) -> Result<App>;
61}
62
63#[async_trait]
64impl AppsApi for Client {
65    async fn list_apps(&self, filter_bundle_id: Option<&str>) -> Result<Vec<App>> {
66        let mut query = Vec::new();
67        if let Some(bundle_id) = filter_bundle_id {
68            query.push(("filter[bundleId]", bundle_id));
69        }
70        let apps = self.list_all::<AppAttributes>("/v1/apps", &query).await?;
71        Ok(match filter_bundle_id {
72            Some(bundle_id) => exact_bundle_id(apps, bundle_id),
73            None => apps,
74        })
75    }
76
77    async fn get_app(&self, app_id: &str) -> Result<App> {
78        let path = format!("/v1/apps/{app_id}");
79        let doc: Document<AppAttributes> =
80            self.request(Method::GET, &path, &[], None::<&()>).await?;
81        Ok(doc.data)
82    }
83
84    async fn update_app(&self, app_id: &str, attributes: AppUpdateAttributes) -> Result<App> {
85        let path = format!("/v1/apps/{app_id}");
86        let body = UpdateBody {
87            data: UpdateData {
88                resource_type: RESOURCE_TYPE,
89                id: app_id.to_string(),
90                attributes,
91            },
92        };
93        let doc: Document<AppAttributes> =
94            self.request(Method::PATCH, &path, &[], Some(&body)).await?;
95        Ok(doc.data)
96    }
97}
98
99/// Keep the apps whose bundle identifier is exactly `bundle_id`.
100fn exact_bundle_id(apps: Vec<App>, bundle_id: &str) -> Vec<App> {
101    apps.into_iter()
102        .filter(|app| app.attributes.bundle_id.as_deref() == Some(bundle_id))
103        .collect()
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use smbcloud_ascapi_core::jsonapi::ListDocument;
110
111    /// The shape App Store Connect returned for `filter[bundleId]=
112    /// ai.splitfire.KaroKowe`: the longer bundle ID first.
113    #[test]
114    fn a_bundle_id_filter_keeps_only_the_exact_match() {
115        let doc: ListDocument<AppAttributes> = serde_json::from_value(serde_json::json!({
116            "data": [
117                { "id": "6787445875", "type": "apps",
118                  "attributes": { "bundleId": "ai.splitfire.KaroKoweJam", "name": "KaroKowe Jam" } },
119                { "id": "6756801861", "type": "apps",
120                  "attributes": { "bundleId": "ai.splitfire.KaroKowe", "name": "Karaoke KaroKowe" } }
121            ]
122        }))
123        .unwrap();
124        let apps = exact_bundle_id(doc.data, "ai.splitfire.KaroKowe");
125        assert_eq!(apps.len(), 1);
126        assert_eq!(apps[0].id, "6756801861");
127    }
128}