| 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 | } |