Skip to main content

mapky_app_specs/models/
collection.rs

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/// Named list of places.
17/// URI: /pub/mapky.app/collections/:collection_id
18#[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        // Validate name
114        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        // Validate description
125        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        // Validate items (0–500 allowed; empty collection is valid per spec)
135        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        // Validate each item and check for duplicates
143        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        // Validate image URI
156        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        // Validate color (optional, hex format #RRGGBB)
162        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}