1use crate::{
2 common::sanitize_url,
3 constants::{MAX_COLLECTION_ITEMS, MAX_COLLECTION_NAME_LENGTH, MAX_DESCRIPTION_LENGTH},
4 traits::{HasIdPath, TimestampId, Validatable},
5 validation::validate_osm_url,
6 MAPKY_PATH, PUBLIC_PATH,
7};
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10
11#[cfg(target_arch = "wasm32")]
12use crate::traits::Json;
13#[cfg(target_arch = "wasm32")]
14use wasm_bindgen::prelude::*;
15
16#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
19#[derive(Serialize, Deserialize, Default, Clone, Debug)]
20pub struct MapkyAppCollection {
21 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
22 pub name: String,
23 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
24 pub description: Option<String>,
25 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
26 pub items: Vec<String>,
27 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
28 pub image_uri: Option<String>,
29 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
30 pub color: Option<String>,
31}
32
33#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
34impl MapkyAppCollection {
35 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))]
36 pub fn new(
37 name: String,
38 description: Option<String>,
39 items: Vec<String>,
40 image_uri: Option<String>,
41 color: Option<String>,
42 ) -> Self {
43 let collection = MapkyAppCollection {
44 name,
45 description,
46 items,
47 image_uri,
48 color,
49 };
50 collection.sanitize()
51 }
52}
53
54#[cfg(target_arch = "wasm32")]
55#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
56impl MapkyAppCollection {
57 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = fromJson))]
58 pub fn from_json(js_value: &JsValue) -> Result<Self, String> {
59 Self::import_json(js_value)
60 }
61
62 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = toJson))]
63 pub fn to_json(&self) -> Result<JsValue, String> {
64 self.export_json()
65 }
66
67 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
68 pub fn name(&self) -> String {
69 self.name.clone()
70 }
71
72 #[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
73 pub fn description(&self) -> Option<String> {
74 self.description.clone()
75 }
76}
77
78#[cfg(target_arch = "wasm32")]
79impl Json for MapkyAppCollection {}
80
81impl TimestampId for MapkyAppCollection {}
82
83impl HasIdPath for MapkyAppCollection {
84 const PATH_SEGMENT: &'static str = "collections/";
85
86 fn create_path(id: &str) -> String {
87 [PUBLIC_PATH, MAPKY_PATH, Self::PATH_SEGMENT, id].concat()
88 }
89}
90
91impl Validatable for MapkyAppCollection {
92 fn sanitize(self) -> Self {
93 let name = self.name.trim().to_string();
94 let description = self.description.map(|d| d.trim().to_string());
95 let image_uri = self.image_uri.map(|u| sanitize_url(&u));
96 let items = self.items.into_iter().map(|u| sanitize_url(&u)).collect();
97 let color = self.color.map(|c| c.trim().to_uppercase());
98
99 MapkyAppCollection {
100 name,
101 description,
102 items,
103 image_uri,
104 color,
105 }
106 }
107
108 fn validate(&self, id: Option<&str>) -> Result<(), String> {
109 if let Some(id) = id {
110 self.validate_id(id)?;
111 }
112
113 if self.name.trim().is_empty() {
115 return Err("Validation Error: Collection name cannot be empty".into());
116 }
117 if self.name.chars().count() > MAX_COLLECTION_NAME_LENGTH {
118 return Err(format!(
119 "Validation Error: Collection name exceeds maximum length of {} characters",
120 MAX_COLLECTION_NAME_LENGTH
121 ));
122 }
123
124 if let Some(ref desc) = self.description {
126 if desc.chars().count() > MAX_DESCRIPTION_LENGTH {
127 return Err(format!(
128 "Validation Error: Description exceeds maximum length of {} characters",
129 MAX_DESCRIPTION_LENGTH
130 ));
131 }
132 }
133
134 if self.items.len() > MAX_COLLECTION_ITEMS {
136 return Err(format!(
137 "Validation Error: Collection exceeds maximum of {} items",
138 MAX_COLLECTION_ITEMS
139 ));
140 }
141
142 let mut seen = HashSet::new();
144 for (i, item) in self.items.iter().enumerate() {
145 validate_osm_url(item)
146 .map_err(|e| format!("Validation Error: Item at index {}: {}", i, e))?;
147 if !seen.insert(item.clone()) {
148 return Err(format!(
149 "Validation Error: Duplicate item in collection: {}",
150 item
151 ));
152 }
153 }
154
155 if let Some(ref uri) = self.image_uri {
157 url::Url::parse(uri)
158 .map_err(|_| format!("Validation Error: Invalid image URI: {}", uri))?;
159 }
160
161 if let Some(ref color) = self.color {
163 if color.len() != 7
164 || !color.starts_with('#')
165 || !color[1..].chars().all(|c| c.is_ascii_hexdigit())
166 {
167 return Err(format!(
168 "Validation Error: Invalid color '{}'. Expected hex format #RRGGBB",
169 color
170 ));
171 }
172 }
173
174 Ok(())
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn test_items() -> Vec<String> {
183 vec![
184 "https://www.openstreetmap.org/node/1".into(),
185 "https://www.openstreetmap.org/node/2".into(),
186 ]
187 }
188
189 #[test]
190 fn test_create_id() {
191 let c = MapkyAppCollection::new("My List".into(), None, test_items(), None, None);
192 let id = c.create_id();
193 assert_eq!(id.len(), 13);
194 }
195
196 #[test]
197 fn test_create_path() {
198 let c = MapkyAppCollection::new("My List".into(), None, test_items(), None, None);
199 let id = c.create_id();
200 let path = MapkyAppCollection::create_path(&id);
201 assert!(path.starts_with("/pub/mapky.app/collections/"));
202 }
203
204 #[test]
205 fn test_validate_happy() {
206 let c = MapkyAppCollection::new("My List".into(), None, test_items(), None, None);
207 let id = c.create_id();
208 assert!(c.validate(Some(&id)).is_ok());
209 }
210
211 #[test]
212 fn test_validate_empty_name() {
213 let c = MapkyAppCollection::new("".into(), None, test_items(), None, None);
214 let id = c.create_id();
215 assert!(c.validate(Some(&id)).is_err());
216 }
217
218 #[test]
219 fn test_validate_empty_items_allowed() {
220 let c = MapkyAppCollection::new("List".into(), None, vec![], None, None);
221 let id = c.create_id();
222 assert!(c.validate(Some(&id)).is_ok());
223 }
224
225 #[test]
226 fn test_validate_duplicate_items() {
227 let items = vec![
228 "https://www.openstreetmap.org/node/1".into(),
229 "https://www.openstreetmap.org/node/1".into(),
230 ];
231 let c = MapkyAppCollection::new("List".into(), None, items, None, None);
232 let id = c.create_id();
233 let result = c.validate(Some(&id));
234 assert!(result.is_err());
235 assert!(result.unwrap_err().contains("Duplicate"));
236 }
237
238 #[test]
239 fn test_validate_name_too_long() {
240 let c = MapkyAppCollection::new(
241 "a".repeat(MAX_COLLECTION_NAME_LENGTH + 1),
242 None,
243 test_items(),
244 None,
245 None,
246 );
247 let id = c.create_id();
248 assert!(c.validate(Some(&id)).is_err());
249 }
250
251 #[test]
252 fn test_validate_invalid_item() {
253 let items = vec!["https://example.com/not-osm".into()];
254 let c = MapkyAppCollection::new("List".into(), None, items, None, None);
255 let id = c.create_id();
256 let result = c.validate(Some(&id));
257 assert!(result.is_err());
258 assert!(result.unwrap_err().contains("OSM URL"));
259 }
260
261 #[test]
262 fn test_try_from_valid() {
263 let json = r#"{
264 "name": "My Favorite Spots",
265 "description": null,
266 "items": [
267 "https://www.openstreetmap.org/node/1",
268 "https://www.openstreetmap.org/node/2"
269 ],
270 "image_uri": null
271 }"#;
272 let c = MapkyAppCollection::new("My Favorite Spots".into(), None, test_items(), None, None);
273 let id = c.create_id();
274 let result = <MapkyAppCollection as Validatable>::try_from(json.as_bytes(), &id);
275 assert!(result.is_ok());
276 }
277
278 #[test]
279 fn test_mixed_osm_types() {
280 let items = vec![
281 "https://www.openstreetmap.org/node/1".into(),
282 "https://www.openstreetmap.org/way/2".into(),
283 "https://www.openstreetmap.org/relation/3".into(),
284 ];
285 let c = MapkyAppCollection::new("Mixed".into(), None, items, None, None);
286 let id = c.create_id();
287 assert!(c.validate(Some(&id)).is_ok());
288 }
289}