Skip to main content

smbcloud_ascapi_aso/
app_store_version_localization.rs

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
12/// Maximum `description` length App Store Connect accepts, in characters.
13pub const DESCRIPTION_MAX_LEN: usize = 4000;
14/// Maximum `keywords` length, in characters, commas included.
15pub const KEYWORDS_MAX_LEN: usize = 100;
16/// Maximum `promotionalText` length, in characters.
17pub const PROMOTIONAL_TEXT_MAX_LEN: usize = 170;
18/// Maximum `whatsNew` length, in characters. Beta "What to Test" notes
19/// share the same limit.
20pub 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    /// 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")]
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/// 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]
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    /// `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]
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        // `•` 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}