main
rs 297 lines 10.7 KB
Raw
1 use async_trait::async_trait;
2 use reqwest::Method;
3 use serde::{Deserialize, Serialize};
4 use smbcloud_ascapi_core::jsonapi::{
5 CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
6 };
7 use smbcloud_ascapi_core::Client;
8 use smbcloud_ascapi_core::Result;
9
10 pub const RESOURCE_TYPE: &str = "appStoreVersionLocalizations";
11
12 /// Maximum `description` length App Store Connect accepts, in characters.
13 pub const DESCRIPTION_MAX_LEN: usize = 4000;
14 /// Maximum `keywords` length, in characters, commas included.
15 pub const KEYWORDS_MAX_LEN: usize = 100;
16 /// Maximum `promotionalText` length, in characters.
17 pub const PROMOTIONAL_TEXT_MAX_LEN: usize = 170;
18 /// Maximum `whatsNew` length, in characters. Beta "What to Test" notes
19 /// share the same limit.
20 pub const WHATS_NEW_MAX_LEN: usize = 4000;
21
22 #[derive(Debug, Clone, Serialize, Deserialize)]
23 #[serde(rename_all = "camelCase")]
24 pub struct AppStoreVersionLocalizationAttributes {
25 pub locale: String,
26 pub description: Option<String>,
27 pub keywords: Option<String>,
28 pub marketing_url: Option<String>,
29 pub promotional_text: Option<String>,
30 pub support_url: Option<String>,
31 pub whats_new: Option<String>,
32 }
33
34 pub type AppStoreVersionLocalization = Resource<AppStoreVersionLocalizationAttributes>;
35
36 #[derive(Debug, Clone, Default, Serialize)]
37 #[serde(rename_all = "camelCase")]
38 pub struct AppStoreVersionLocalizationFields {
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub description: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub keywords: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub marketing_url: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub promotional_text: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub support_url: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub whats_new: Option<String>,
51 }
52
53 impl AppStoreVersionLocalizationFields {
54 /// Check the fields against App Store Connect's length limits before
55 /// anything is sent; Apple answers an over-long value with an opaque
56 /// 400. Lengths are counted in characters, as Apple counts them.
57 ///
58 /// Returns `Err` naming the first field over its limit (with its length
59 /// and the limit), or `Ok` with advisory warnings: a space after a
60 /// keyword comma and empty keyword entries both waste the 100-character
61 /// budget without changing what the store indexes.
62 pub fn validate(&self) -> std::result::Result<Vec<String>, String> {
63 let limits = [
64 ("description", &self.description, DESCRIPTION_MAX_LEN),
65 ("keywords", &self.keywords, KEYWORDS_MAX_LEN),
66 (
67 "promotionalText",
68 &self.promotional_text,
69 PROMOTIONAL_TEXT_MAX_LEN,
70 ),
71 ("whatsNew", &self.whats_new, WHATS_NEW_MAX_LEN),
72 ];
73 for (name, value, max) in limits {
74 let len = value.as_deref().map_or(0, |v| v.chars().count());
75 if len > max {
76 return Err(format!(
77 "{name} is {len} characters; App Store Connect accepts at most {max}"
78 ));
79 }
80 }
81 let mut warnings = Vec::new();
82 if let Some(keywords) = &self.keywords {
83 if keywords.contains(", ") {
84 warnings.push(
85 "keywords: a space after a comma wastes a character; use `a,b` not `a, b`"
86 .to_string(),
87 );
88 }
89 if keywords.split(',').any(|k| k.trim().is_empty()) && !keywords.is_empty() {
90 warnings
91 .push("keywords: contains an empty entry (`,,` or a stray comma)".to_string());
92 }
93 }
94 Ok(warnings)
95 }
96 }
97
98 #[derive(Debug, Clone, Serialize)]
99 #[serde(rename_all = "camelCase")]
100 pub struct AppStoreVersionLocalizationCreateAttributes {
101 pub locale: String,
102 #[serde(flatten)]
103 pub fields: AppStoreVersionLocalizationFields,
104 }
105
106 #[derive(Debug, Clone, Serialize)]
107 #[serde(rename_all = "camelCase")]
108 pub struct AppStoreVersionLocalizationRelationships {
109 pub app_store_version: ToOne,
110 }
111
112 /// Per-locale description, keywords, and release notes.
113 ///
114 /// An extension trait rather than inherent methods, because `Client`
115 /// lives in the core crate and Rust only allows inherent impls in the
116 /// crate that defines the type. Import it, or the crate's `prelude`,
117 /// to call these on a `Client`.
118 #[async_trait]
119 pub trait AppStoreVersionLocalizationsApi {
120 async fn list_app_store_version_localizations(
121 &self,
122 app_store_version_id: &str,
123 ) -> Result<Vec<AppStoreVersionLocalization>>;
124
125 /// `POST /v1/appStoreVersionLocalizations` — adds a locale's
126 /// description/keywords/etc to an `AppStoreVersion`.
127 async fn create_app_store_version_localization(
128 &self,
129 app_store_version_id: &str,
130 attributes: AppStoreVersionLocalizationCreateAttributes,
131 ) -> Result<AppStoreVersionLocalization>;
132
133 /// `GET /v1/appStoreVersionLocalizations/{id}` — read one localization
134 /// back, e.g. to verify a write stored what was sent.
135 async fn get_app_store_version_localization(
136 &self,
137 id: &str,
138 ) -> Result<AppStoreVersionLocalization>;
139
140 async fn update_app_store_version_localization(
141 &self,
142 id: &str,
143 fields: AppStoreVersionLocalizationFields,
144 ) -> Result<AppStoreVersionLocalization>;
145
146 async fn delete_app_store_version_localization(&self, id: &str) -> Result<()>;
147 }
148
149 #[async_trait]
150 impl AppStoreVersionLocalizationsApi for Client {
151 async fn list_app_store_version_localizations(
152 &self,
153 app_store_version_id: &str,
154 ) -> Result<Vec<AppStoreVersionLocalization>> {
155 let path =
156 format!("/v1/appStoreVersions/{app_store_version_id}/appStoreVersionLocalizations");
157 self.list_all::<AppStoreVersionLocalizationAttributes>(&path, &[])
158 .await
159 }
160
161 async fn create_app_store_version_localization(
162 &self,
163 app_store_version_id: &str,
164 attributes: AppStoreVersionLocalizationCreateAttributes,
165 ) -> Result<AppStoreVersionLocalization> {
166 let body = CreateBody {
167 data: CreateData {
168 resource_type: RESOURCE_TYPE,
169 attributes,
170 relationships: Some(AppStoreVersionLocalizationRelationships {
171 app_store_version: ToOne {
172 data: ResourceId {
173 resource_type: crate::app_store_version::RESOURCE_TYPE,
174 id: app_store_version_id.to_string(),
175 },
176 },
177 }),
178 },
179 };
180 let doc: Document<AppStoreVersionLocalizationAttributes> = self
181 .request(
182 Method::POST,
183 "/v1/appStoreVersionLocalizations",
184 &[],
185 Some(&body),
186 )
187 .await?;
188 Ok(doc.data)
189 }
190
191 async fn get_app_store_version_localization(
192 &self,
193 id: &str,
194 ) -> Result<AppStoreVersionLocalization> {
195 let path = format!("/v1/appStoreVersionLocalizations/{id}");
196 let doc: Document<AppStoreVersionLocalizationAttributes> =
197 self.request(Method::GET, &path, &[], None::<&()>).await?;
198 Ok(doc.data)
199 }
200
201 async fn update_app_store_version_localization(
202 &self,
203 id: &str,
204 fields: AppStoreVersionLocalizationFields,
205 ) -> Result<AppStoreVersionLocalization> {
206 let path = format!("/v1/appStoreVersionLocalizations/{id}");
207 let body = UpdateBody {
208 data: UpdateData {
209 resource_type: RESOURCE_TYPE,
210 id: id.to_string(),
211 attributes: fields,
212 },
213 };
214 let doc: Document<AppStoreVersionLocalizationAttributes> =
215 self.request(Method::PATCH, &path, &[], Some(&body)).await?;
216 Ok(doc.data)
217 }
218
219 async fn delete_app_store_version_localization(&self, id: &str) -> Result<()> {
220 let path = format!("/v1/appStoreVersionLocalizations/{id}");
221 self.request_no_content::<()>(Method::DELETE, &path, &[], None)
222 .await
223 }
224 }
225
226 #[cfg(test)]
227 mod tests {
228 use super::*;
229
230 fn only(
231 f: impl FnOnce(&mut AppStoreVersionLocalizationFields),
232 ) -> AppStoreVersionLocalizationFields {
233 let mut fields = AppStoreVersionLocalizationFields::default();
234 f(&mut fields);
235 fields
236 }
237
238 #[test]
239 fn each_field_over_its_limit_is_rejected_by_name() {
240 let cases = [
241 (
242 "description",
243 only(|f| f.description = Some("a".repeat(DESCRIPTION_MAX_LEN + 1))),
244 ),
245 (
246 "keywords",
247 only(|f| f.keywords = Some("a".repeat(KEYWORDS_MAX_LEN + 1))),
248 ),
249 (
250 "promotionalText",
251 only(|f| f.promotional_text = Some("a".repeat(PROMOTIONAL_TEXT_MAX_LEN + 1))),
252 ),
253 (
254 "whatsNew",
255 only(|f| f.whats_new = Some("a".repeat(WHATS_NEW_MAX_LEN + 1))),
256 ),
257 ];
258 for (name, fields) in cases {
259 let message = fields.validate().expect_err("over-limit must be rejected");
260 assert!(message.starts_with(name), "{message}");
261 assert!(message.contains("at most"), "{message}");
262 }
263 }
264
265 #[test]
266 fn fields_at_their_limit_pass() {
267 let fields = AppStoreVersionLocalizationFields {
268 description: Some("a".repeat(DESCRIPTION_MAX_LEN)),
269 keywords: Some("a".repeat(KEYWORDS_MAX_LEN)),
270 promotional_text: Some("a".repeat(PROMOTIONAL_TEXT_MAX_LEN)),
271 whats_new: Some("a".repeat(WHATS_NEW_MAX_LEN)),
272 ..Default::default()
273 };
274 assert_eq!(fields.validate(), Ok(vec![]));
275 }
276
277 #[test]
278 fn length_counts_characters_not_bytes() {
279 // `•` is 3 bytes in UTF-8 but one character.
280 let fields = only(|f| f.promotional_text = Some("•".repeat(PROMOTIONAL_TEXT_MAX_LEN)));
281 assert!(fields.validate().is_ok());
282 let over = only(|f| f.promotional_text = Some("•".repeat(PROMOTIONAL_TEXT_MAX_LEN + 1)));
283 assert!(over.validate().is_err());
284 }
285
286 #[test]
287 fn keyword_spacing_and_empty_entries_warn() {
288 let spaced = only(|f| f.keywords = Some("a, b".into()));
289 assert_eq!(spaced.validate().unwrap().len(), 1);
290 let empty = only(|f| f.keywords = Some("a,,b".into()));
291 let warnings = empty.validate().unwrap();
292 assert_eq!(warnings.len(), 1);
293 assert!(warnings[0].contains("empty"));
294 let clean = only(|f| f.keywords = Some("a,b".into()));
295 assert!(clean.validate().unwrap().is_empty());
296 }
297 }