development
rs 128 lines 4.87 KB
Raw
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 }