| 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 | |
| 9 | use async_trait::async_trait; |
| 10 | use reqwest::Method; |
| 11 | use serde::{Deserialize, Serialize}; |
| 12 | use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData}; |
| 13 | use smbcloud_ascapi_core::Client; |
| 14 | use smbcloud_ascapi_core::Result; |
| 15 | |
| 16 | pub const RESOURCE_TYPE: &str = "apps"; |
| 17 | |
| 18 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 19 | #[serde(rename_all = "camelCase")] |
| 20 | pub 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 | |
| 29 | pub type App = Resource<AppAttributes>; |
| 30 | |
| 31 | #[derive(Debug, Clone, Default, Serialize)] |
| 32 | #[serde(rename_all = "camelCase")] |
| 33 | pub 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] |
| 47 | pub 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] |
| 64 | impl 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`. |
| 100 | fn 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)] |
| 107 | mod 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 | } |