ridewithgps_client/
poi.rs

1//! Points of Interest related types and methods
2//!
3//! Note: These endpoints are only available to organization accounts.
4
5use crate::{PaginatedResponse, Result, RideWithGpsClient};
6use serde::{Deserialize, Serialize};
7
8/// A point of interest
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct PointOfInterest {
11    /// POI ID
12    pub id: u64,
13
14    /// POI name
15    pub name: Option<String>,
16
17    /// POI description
18    pub description: Option<String>,
19
20    /// Latitude
21    #[serde(alias = "latitude")]
22    pub lat: Option<f64>,
23
24    /// Longitude
25    #[serde(alias = "longitude")]
26    pub lng: Option<f64>,
27
28    /// POI type/category
29    #[serde(alias = "poi_type")]
30    pub r#type: Option<String>,
31
32    /// Type ID
33    pub type_id: Option<u64>,
34
35    /// Type name
36    pub type_name: Option<String>,
37
38    /// Icon identifier
39    pub icon: Option<String>,
40
41    /// User ID of the POI owner
42    pub user_id: Option<u64>,
43
44    /// Organization ID
45    pub organization_id: Option<u64>,
46
47    /// API URL
48    pub url: Option<String>,
49
50    /// Created timestamp
51    pub created_at: Option<String>,
52
53    /// Updated timestamp
54    pub updated_at: Option<String>,
55
56    /// Address
57    pub address: Option<String>,
58
59    /// Phone number
60    pub phone: Option<String>,
61
62    /// Website URL
63    pub website: Option<String>,
64
65    /// Tag names
66    pub tag_names: Option<Vec<String>>,
67}
68
69/// Parameters for listing POIs
70#[derive(Debug, Clone, Default, Serialize)]
71pub struct ListPointsOfInterestParams {
72    /// Filter by POI name
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub name: Option<String>,
75
76    /// Filter by POI type
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub poi_type: Option<String>,
79
80    /// Page number
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub page: Option<u32>,
83
84    /// Page size
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub page_size: Option<u32>,
87}
88
89/// Request to create or update a POI
90#[derive(Debug, Clone, Serialize)]
91pub struct PointOfInterestRequest {
92    /// POI name
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub name: Option<String>,
95
96    /// POI description
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub description: Option<String>,
99
100    /// Latitude
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub latitude: Option<f64>,
103
104    /// Longitude
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub longitude: Option<f64>,
107
108    /// POI type/category
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub poi_type: Option<String>,
111
112    /// Icon identifier
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub icon: Option<String>,
115
116    /// Address
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub address: Option<String>,
119
120    /// Phone number
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub phone: Option<String>,
123
124    /// Website URL
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub website: Option<String>,
127}
128
129impl RideWithGpsClient {
130    /// List points of interest
131    ///
132    /// Note: This endpoint is only available to organization accounts.
133    ///
134    /// # Arguments
135    ///
136    /// * `params` - Optional parameters for filtering and pagination
137    ///
138    /// # Example
139    ///
140    /// ```rust,no_run
141    /// use ridewithgps_client::RideWithGpsClient;
142    ///
143    /// let client = RideWithGpsClient::new(
144    ///     "https://ridewithgps.com",
145    ///     "your-api-key",
146    ///     Some("your-auth-token")
147    /// );
148    ///
149    /// let pois = client.list_points_of_interest(None).unwrap();
150    /// println!("Found {} POIs", pois.results.len());
151    /// ```
152    pub fn list_points_of_interest(
153        &self,
154        params: Option<&ListPointsOfInterestParams>,
155    ) -> Result<PaginatedResponse<PointOfInterest>> {
156        let mut url = "/api/v1/points_of_interest.json".to_string();
157
158        if let Some(params) = params {
159            let query = serde_json::to_value(params)?;
160            if let Some(obj) = query.as_object() {
161                if !obj.is_empty() {
162                    let query_str = serde_urlencoded::to_string(obj).map_err(|e| {
163                        crate::Error::ApiError(format!("Failed to encode query: {}", e))
164                    })?;
165                    url.push('?');
166                    url.push_str(&query_str);
167                }
168            }
169        }
170
171        self.get(&url)
172    }
173
174    /// Create a new point of interest
175    ///
176    /// Note: This endpoint is only available to organization accounts.
177    ///
178    /// # Arguments
179    ///
180    /// * `poi` - The POI data
181    ///
182    /// # Example
183    ///
184    /// ```rust,no_run
185    /// use ridewithgps_client::{RideWithGpsClient, PointOfInterestRequest};
186    ///
187    /// let client = RideWithGpsClient::new(
188    ///     "https://ridewithgps.com",
189    ///     "your-api-key",
190    ///     Some("your-auth-token")
191    /// );
192    ///
193    /// let poi_req = PointOfInterestRequest {
194    ///     name: Some("Coffee Shop".to_string()),
195    ///     description: Some("Great coffee stop".to_string()),
196    ///     latitude: Some(37.7749),
197    ///     longitude: Some(-122.4194),
198    ///     poi_type: Some("cafe".to_string()),
199    ///     icon: Some("coffee".to_string()),
200    ///     address: None,
201    ///     phone: None,
202    ///     website: None,
203    /// };
204    ///
205    /// let poi = client.create_point_of_interest(&poi_req).unwrap();
206    /// println!("Created POI: {}", poi.id);
207    /// ```
208    pub fn create_point_of_interest(
209        &self,
210        poi: &PointOfInterestRequest,
211    ) -> Result<PointOfInterest> {
212        #[derive(Deserialize)]
213        struct PoiWrapper {
214            point_of_interest: PointOfInterest,
215        }
216
217        let wrapper: PoiWrapper = self.post("/api/v1/points_of_interest.json", poi)?;
218        Ok(wrapper.point_of_interest)
219    }
220
221    /// Get a specific point of interest by ID
222    ///
223    /// Note: This endpoint is only available to organization accounts.
224    ///
225    /// # Arguments
226    ///
227    /// * `id` - The POI ID
228    ///
229    /// # Example
230    ///
231    /// ```rust,no_run
232    /// use ridewithgps_client::RideWithGpsClient;
233    ///
234    /// let client = RideWithGpsClient::new(
235    ///     "https://ridewithgps.com",
236    ///     "your-api-key",
237    ///     Some("your-auth-token")
238    /// );
239    ///
240    /// let poi = client.get_point_of_interest(12345).unwrap();
241    /// println!("POI: {:?}", poi);
242    /// ```
243    pub fn get_point_of_interest(&self, id: u64) -> Result<PointOfInterest> {
244        #[derive(Deserialize)]
245        struct PoiWrapper {
246            point_of_interest: PointOfInterest,
247        }
248
249        let wrapper: PoiWrapper = self.get(&format!("/api/v1/points_of_interest/{}.json", id))?;
250        Ok(wrapper.point_of_interest)
251    }
252
253    /// Update a point of interest
254    ///
255    /// Note: This endpoint is only available to organization accounts.
256    ///
257    /// # Arguments
258    ///
259    /// * `id` - The POI ID
260    /// * `poi` - The updated POI data
261    ///
262    /// # Example
263    ///
264    /// ```rust,no_run
265    /// use ridewithgps_client::{RideWithGpsClient, PointOfInterestRequest};
266    ///
267    /// let client = RideWithGpsClient::new(
268    ///     "https://ridewithgps.com",
269    ///     "your-api-key",
270    ///     Some("your-auth-token")
271    /// );
272    ///
273    /// let poi_req = PointOfInterestRequest {
274    ///     name: Some("Updated Coffee Shop".to_string()),
275    ///     description: None,
276    ///     latitude: None,
277    ///     longitude: None,
278    ///     poi_type: None,
279    ///     icon: None,
280    ///     address: None,
281    ///     phone: None,
282    ///     website: None,
283    /// };
284    ///
285    /// let poi = client.update_point_of_interest(12345, &poi_req).unwrap();
286    /// println!("Updated POI: {:?}", poi);
287    /// ```
288    pub fn update_point_of_interest(
289        &self,
290        id: u64,
291        poi: &PointOfInterestRequest,
292    ) -> Result<PointOfInterest> {
293        #[derive(Deserialize)]
294        struct PoiWrapper {
295            point_of_interest: PointOfInterest,
296        }
297
298        let wrapper: PoiWrapper =
299            self.put(&format!("/api/v1/points_of_interest/{}.json", id), poi)?;
300        Ok(wrapper.point_of_interest)
301    }
302
303    /// Delete a point of interest
304    ///
305    /// Note: This endpoint is only available to organization accounts.
306    ///
307    /// # Arguments
308    ///
309    /// * `id` - The POI ID
310    ///
311    /// # Example
312    ///
313    /// ```rust,no_run
314    /// use ridewithgps_client::RideWithGpsClient;
315    ///
316    /// let client = RideWithGpsClient::new(
317    ///     "https://ridewithgps.com",
318    ///     "your-api-key",
319    ///     Some("your-auth-token")
320    /// );
321    ///
322    /// client.delete_point_of_interest(12345).unwrap();
323    /// ```
324    pub fn delete_point_of_interest(&self, id: u64) -> Result<()> {
325        self.delete(&format!("/api/v1/points_of_interest/{}.json", id))
326    }
327
328    /// Associate a point of interest with a route
329    ///
330    /// Note: This endpoint is only available to organization accounts.
331    ///
332    /// # Arguments
333    ///
334    /// * `poi_id` - The POI ID
335    /// * `route_id` - The route ID
336    ///
337    /// # Example
338    ///
339    /// ```rust,no_run
340    /// use ridewithgps_client::RideWithGpsClient;
341    ///
342    /// let client = RideWithGpsClient::new(
343    ///     "https://ridewithgps.com",
344    ///     "your-api-key",
345    ///     Some("your-auth-token")
346    /// );
347    ///
348    /// client.associate_poi_with_route(12345, 67890).unwrap();
349    /// ```
350    pub fn associate_poi_with_route(&self, poi_id: u64, route_id: u64) -> Result<()> {
351        let url = format!(
352            "/api/v1/points_of_interest/{}/routes/{}.json",
353            poi_id, route_id
354        );
355        let response = self
356            .client
357            .post(self.base_url.join(&url)?)
358            .headers(self.build_headers()?)
359            .send()?;
360
361        match response.status().as_u16() {
362            200 | 201 | 204 => Ok(()),
363            _ => {
364                let status = response.status();
365                let text = response.text().unwrap_or_default();
366                Err(self.error_from_status(status.as_u16(), &text))
367            }
368        }
369    }
370
371    /// Disassociate a point of interest from a route
372    ///
373    /// Note: This endpoint is only available to organization accounts.
374    ///
375    /// # Arguments
376    ///
377    /// * `poi_id` - The POI ID
378    /// * `route_id` - The route ID
379    ///
380    /// # Example
381    ///
382    /// ```rust,no_run
383    /// use ridewithgps_client::RideWithGpsClient;
384    ///
385    /// let client = RideWithGpsClient::new(
386    ///     "https://ridewithgps.com",
387    ///     "your-api-key",
388    ///     Some("your-auth-token")
389    /// );
390    ///
391    /// client.disassociate_poi_from_route(12345, 67890).unwrap();
392    /// ```
393    pub fn disassociate_poi_from_route(&self, poi_id: u64, route_id: u64) -> Result<()> {
394        let url = format!(
395            "/api/v1/points_of_interest/{}/routes/{}.json",
396            poi_id, route_id
397        );
398        self.delete(&url)
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_poi_deserialization() {
408        let json = r#"{
409            "id": 999,
410            "name": "Coffee Shop",
411            "description": "Great coffee",
412            "latitude": 37.7749,
413            "longitude": -122.4194,
414            "poi_type": "cafe",
415            "icon": "coffee"
416        }"#;
417
418        let poi: PointOfInterest = serde_json::from_str(json).unwrap();
419        assert_eq!(poi.id, 999);
420        assert_eq!(poi.name.as_deref(), Some("Coffee Shop"));
421        assert_eq!(poi.lat, Some(37.7749));
422        assert_eq!(poi.lng, Some(-122.4194));
423        assert_eq!(poi.r#type.as_deref(), Some("cafe"));
424    }
425
426    #[test]
427    fn test_poi_request_serialization() {
428        let req = PointOfInterestRequest {
429            name: Some("Bike Shop".to_string()),
430            description: Some("Full service".to_string()),
431            latitude: Some(40.7128),
432            longitude: Some(-74.0060),
433            poi_type: Some("bike_shop".to_string()),
434            icon: Some("bicycle".to_string()),
435            address: Some("123 Main St".to_string()),
436            phone: Some("555-1234".to_string()),
437            website: Some("https://example.com".to_string()),
438        };
439
440        let json = serde_json::to_value(&req).unwrap();
441        assert_eq!(json.get("name").unwrap(), "Bike Shop");
442        assert_eq!(json.get("latitude").unwrap(), 40.7128);
443        assert_eq!(json.get("poi_type").unwrap(), "bike_shop");
444    }
445
446    #[test]
447    fn test_poi_wrapper_deserialization() {
448        let json = r#"{
449            "point_of_interest": {
450                "id": 777,
451                "name": "Wrapped POI",
452                "latitude": 40.0,
453                "longitude": -120.0,
454                "poi_type": "rest_stop"
455            }
456        }"#;
457
458        #[derive(Deserialize)]
459        struct PoiWrapper {
460            point_of_interest: PointOfInterest,
461        }
462
463        let wrapper: PoiWrapper = serde_json::from_str(json).unwrap();
464        assert_eq!(wrapper.point_of_interest.id, 777);
465        assert_eq!(
466            wrapper.point_of_interest.name.as_deref(),
467            Some("Wrapped POI")
468        );
469        assert_eq!(wrapper.point_of_interest.lat, Some(40.0));
470        assert_eq!(
471            wrapper.point_of_interest.r#type.as_deref(),
472            Some("rest_stop")
473        );
474    }
475
476    #[test]
477    fn test_poi_with_tags_and_type_info() {
478        let json = r#"{
479            "id": 444,
480            "name": "Tagged POI",
481            "poi_type": "cafe",
482            "type_id": 5,
483            "type_name": "Coffee Shop",
484            "tag_names": ["espresso", "wifi", "outdoor-seating"]
485        }"#;
486
487        let poi: PointOfInterest = serde_json::from_str(json).unwrap();
488        assert_eq!(poi.id, 444);
489        assert_eq!(poi.r#type.as_deref(), Some("cafe"));
490        assert_eq!(poi.type_id, Some(5));
491        assert_eq!(poi.type_name.as_deref(), Some("Coffee Shop"));
492        assert!(poi.tag_names.is_some());
493        let tags = poi.tag_names.unwrap();
494        assert_eq!(tags.len(), 3);
495        assert_eq!(tags[0], "espresso");
496    }
497
498    #[test]
499    fn test_poi_field_aliases() {
500        // Test that both latitude/lat and longitude/lng work
501        let json_with_full_names = r#"{
502            "id": 111,
503            "latitude": 37.5,
504            "longitude": -122.5,
505            "poi_type": "water"
506        }"#;
507
508        let poi1: PointOfInterest = serde_json::from_str(json_with_full_names).unwrap();
509        assert_eq!(poi1.lat, Some(37.5));
510        assert_eq!(poi1.lng, Some(-122.5));
511        assert_eq!(poi1.r#type.as_deref(), Some("water"));
512    }
513}