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