1use async_trait::async_trait;
2use reqwest::Method;
3use serde::{Deserialize, Serialize};
4use smbcloud_ascapi_core::jsonapi::{
5 CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
6};
7use smbcloud_ascapi_core::Client;
8use smbcloud_ascapi_core::Result;
9
10pub const RESOURCE_TYPE: &str = "appStoreVersionLocalizations";
11
12pub const DESCRIPTION_MAX_LEN: usize = 4000;
14pub const KEYWORDS_MAX_LEN: usize = 100;
16pub const PROMOTIONAL_TEXT_MAX_LEN: usize = 170;
18pub const WHATS_NEW_MAX_LEN: usize = 4000;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub 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
34pub type AppStoreVersionLocalization = Resource<AppStoreVersionLocalizationAttributes>;
35
36#[derive(Debug, Clone, Default, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub 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
53impl AppStoreVersionLocalizationFields {
54 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")]
100pub 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")]
108pub struct AppStoreVersionLocalizationRelationships {
109 pub app_store_version: ToOne,
110}
111
112#[async_trait]
119pub trait AppStoreVersionLocalizationsApi {
120 async fn list_app_store_version_localizations(
121 &self,
122 app_store_version_id: &str,
123 ) -> Result<Vec<AppStoreVersionLocalization>>;
124
125 async fn create_app_store_version_localization(
128 &self,
129 app_store_version_id: &str,
130 attributes: AppStoreVersionLocalizationCreateAttributes,
131 ) -> Result<AppStoreVersionLocalization>;
132
133 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]
150impl 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)]
227mod 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 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}