1use std::time::Duration;
56
57use nav_math::Position;
58
59use crate::access::agent;
60use crate::{ClientError, Trust};
61
62const TIMEOUT: Duration = Duration::from_secs(10);
66
67#[derive(Debug, Clone, PartialEq)]
69pub struct Waypoint {
70 pub uuid: Option<String>,
74 pub name: Option<String>,
76 pub description: Option<String>,
78 pub category: Option<String>,
81 pub icon: Option<String>,
96 pub color: Option<String>,
100 pub position: Position,
102}
103
104#[derive(Debug, Clone, PartialEq)]
106pub struct RoutePoint {
107 pub position: Position,
109 pub name: Option<String>,
114}
115
116#[derive(Debug, Clone, PartialEq)]
118pub struct Route {
119 pub uuid: Option<String>,
123 pub name: Option<String>,
125 pub description: Option<String>,
127 pub distance_m: Option<f64>,
131 pub points: Vec<RoutePoint>,
133}
134
135#[derive(Debug, Clone, PartialEq)]
152pub struct Track {
153 pub uuid: Option<String>,
156 pub name: Option<String>,
158 pub description: Option<String>,
160 pub color: Option<String>,
166 pub distance_m: Option<f64>,
171 pub points: Vec<Position>,
176 pub timestamp: Option<String>,
186 pub recorded_from: Option<String>,
191 pub recorded_to: Option<String>,
194}
195
196pub struct Client {
204 http_base: String,
205 trust: Trust,
206 token: Option<String>,
207}
208
209impl Client {
210 #[must_use]
214 pub fn new(http_base: String, trust: Trust, token: Option<String>) -> Self {
215 Self { http_base, trust, token }
216 }
217
218 pub fn list_waypoints(&self) -> Result<Vec<Waypoint>, ClientError> {
225 let mut request = agent(&self.trust)?
226 .get(format!("{}/signalk/v2/api/resources/waypoints", self.http_base))
227 .config()
228 .timeout_global(Some(TIMEOUT))
229 .build();
230 if let Some(token) = &self.token {
231 request = request.header("Authorization", format!("Bearer {token}"));
232 }
233
234 let answer: serde_json::Value = request
235 .call()
236 .map_err(|error| ClientError::Http(error.to_string()))?
237 .body_mut()
238 .read_json()
239 .map_err(|error| ClientError::Http(error.to_string()))?;
240
241 let object = answer
242 .as_object()
243 .ok_or_else(|| ClientError::Protocol(format!("expected an object of waypoints, got {answer}")))?;
244 object
245 .iter()
246 .map(|(uuid, value)| read_waypoint(Some(uuid.clone()), value))
247 .collect()
248 }
249
250 pub fn publish_waypoint(&self, waypoint: &Waypoint) -> Result<String, ClientError> {
259 let Some(token) = &self.token else {
260 return Err(ClientError::Http(
261 "no token: publishing a waypoint needs one, the same as any other resource write".to_owned(),
262 ));
263 };
264
265 let body = write_waypoint(waypoint);
266 let agent = agent(&self.trust)?;
267
268 let mut response = if let Some(uuid) = &waypoint.uuid {
274 agent
275 .put(format!("{}/signalk/v2/api/resources/waypoints/{uuid}", self.http_base))
276 .header("Authorization", format!("Bearer {token}"))
277 .config()
278 .timeout_global(Some(TIMEOUT))
279 .http_status_as_error(false)
280 .build()
281 .send_json(&body)
282 .map_err(|error| ClientError::Http(error.to_string()))?
283 } else {
284 agent
285 .post(format!("{}/signalk/v2/api/resources/waypoints", self.http_base))
286 .header("Authorization", format!("Bearer {token}"))
287 .config()
288 .timeout_global(Some(TIMEOUT))
289 .http_status_as_error(false)
290 .build()
291 .send_json(&body)
292 .map_err(|error| ClientError::Http(error.to_string()))?
293 };
294
295 let answer: serde_json::Value = response
296 .body_mut()
297 .read_json()
298 .map_err(|error| ClientError::Http(error.to_string()))?;
299 read_action_response(&answer)
300 }
301
302 pub fn delete_waypoint(&self, uuid: &str) -> Result<(), ClientError> {
310 let Some(token) = &self.token else {
311 return Err(ClientError::Http(
312 "no token: deleting a waypoint needs one, the same as any other resource write".to_owned(),
313 ));
314 };
315
316 let mut response = agent(&self.trust)?
317 .delete(format!("{}/signalk/v2/api/resources/waypoints/{uuid}", self.http_base))
318 .header("Authorization", format!("Bearer {token}"))
319 .config()
320 .timeout_global(Some(TIMEOUT))
321 .http_status_as_error(false)
322 .build()
323 .call()
324 .map_err(|error| ClientError::Http(error.to_string()))?;
325
326 let answer: serde_json::Value = response
327 .body_mut()
328 .read_json()
329 .map_err(|error| ClientError::Http(error.to_string()))?;
330 read_action_response(&answer).map(|_id| ())
331 }
332
333 pub fn list_routes(&self) -> Result<Vec<Route>, ClientError> {
340 let mut request = agent(&self.trust)?
341 .get(format!("{}/signalk/v2/api/resources/routes", self.http_base))
342 .config()
343 .timeout_global(Some(TIMEOUT))
344 .build();
345 if let Some(token) = &self.token {
346 request = request.header("Authorization", format!("Bearer {token}"));
347 }
348
349 let answer: serde_json::Value = request
350 .call()
351 .map_err(|error| ClientError::Http(error.to_string()))?
352 .body_mut()
353 .read_json()
354 .map_err(|error| ClientError::Http(error.to_string()))?;
355
356 let object = answer
357 .as_object()
358 .ok_or_else(|| ClientError::Protocol(format!("expected an object of routes, got {answer}")))?;
359 object
360 .iter()
361 .map(|(uuid, value)| read_route(Some(uuid.clone()), value))
362 .collect()
363 }
364
365 pub fn publish_route(&self, route: &Route) -> Result<String, ClientError> {
374 let Some(token) = &self.token else {
375 return Err(ClientError::Http(
376 "no token: publishing a route needs one, the same as any other resource write".to_owned(),
377 ));
378 };
379
380 let body = write_route(route);
381 let agent = agent(&self.trust)?;
382
383 let mut response = if let Some(uuid) = &route.uuid {
384 agent
385 .put(format!("{}/signalk/v2/api/resources/routes/{uuid}", self.http_base))
386 .header("Authorization", format!("Bearer {token}"))
387 .config()
388 .timeout_global(Some(TIMEOUT))
389 .http_status_as_error(false)
390 .build()
391 .send_json(&body)
392 .map_err(|error| ClientError::Http(error.to_string()))?
393 } else {
394 agent
395 .post(format!("{}/signalk/v2/api/resources/routes", self.http_base))
396 .header("Authorization", format!("Bearer {token}"))
397 .config()
398 .timeout_global(Some(TIMEOUT))
399 .http_status_as_error(false)
400 .build()
401 .send_json(&body)
402 .map_err(|error| ClientError::Http(error.to_string()))?
403 };
404
405 let answer: serde_json::Value = response
406 .body_mut()
407 .read_json()
408 .map_err(|error| ClientError::Http(error.to_string()))?;
409 read_action_response(&answer)
410 }
411
412 pub fn delete_route(&self, uuid: &str) -> Result<(), ClientError> {
420 let Some(token) = &self.token else {
421 return Err(ClientError::Http(
422 "no token: deleting a route needs one, the same as any other resource write".to_owned(),
423 ));
424 };
425
426 let mut response = agent(&self.trust)?
427 .delete(format!("{}/signalk/v2/api/resources/routes/{uuid}", self.http_base))
428 .header("Authorization", format!("Bearer {token}"))
429 .config()
430 .timeout_global(Some(TIMEOUT))
431 .http_status_as_error(false)
432 .build()
433 .call()
434 .map_err(|error| ClientError::Http(error.to_string()))?;
435
436 let answer: serde_json::Value = response
437 .body_mut()
438 .read_json()
439 .map_err(|error| ClientError::Http(error.to_string()))?;
440 read_action_response(&answer).map(|_id| ())
441 }
442
443 pub fn list_tracks(&self) -> Result<Vec<Track>, ClientError> {
452 let mut request = agent(&self.trust)?
453 .get(format!("{}/signalk/v2/api/resources/tracks", self.http_base))
454 .config()
455 .timeout_global(Some(TIMEOUT))
456 .build();
457 if let Some(token) = &self.token {
458 request = request.header("Authorization", format!("Bearer {token}"));
459 }
460
461 let answer: serde_json::Value = request
462 .call()
463 .map_err(|error| ClientError::Http(error.to_string()))?
464 .body_mut()
465 .read_json()
466 .map_err(|error| ClientError::Http(error.to_string()))?;
467
468 let object = answer
469 .as_object()
470 .ok_or_else(|| ClientError::Protocol(format!("expected an object of tracks, got {answer}")))?;
471 object.iter().map(|(uuid, value)| read_track(Some(uuid.clone()), value)).collect()
472 }
473
474 pub fn publish_track(&self, track: &Track) -> Result<String, ClientError> {
486 let Some(token) = &self.token else {
487 return Err(ClientError::Http(
488 "no token: publishing a track needs one, the same as any other resource write".to_owned(),
489 ));
490 };
491
492 let body = write_track(track);
493 let agent = agent(&self.trust)?;
494
495 let mut response = if let Some(uuid) = &track.uuid {
496 agent
497 .put(format!("{}/signalk/v2/api/resources/tracks/{uuid}", self.http_base))
498 .header("Authorization", format!("Bearer {token}"))
499 .config()
500 .timeout_global(Some(TIMEOUT))
501 .http_status_as_error(false)
502 .build()
503 .send_json(&body)
504 .map_err(|error| ClientError::Http(error.to_string()))?
505 } else {
506 agent
507 .post(format!("{}/signalk/v2/api/resources/tracks", self.http_base))
508 .header("Authorization", format!("Bearer {token}"))
509 .config()
510 .timeout_global(Some(TIMEOUT))
511 .http_status_as_error(false)
512 .build()
513 .send_json(&body)
514 .map_err(|error| ClientError::Http(error.to_string()))?
515 };
516
517 let answer: serde_json::Value = response
518 .body_mut()
519 .read_json()
520 .map_err(|error| ClientError::Http(error.to_string()))?;
521 read_action_response(&answer)
522 }
523
524 pub fn delete_track(&self, uuid: &str) -> Result<(), ClientError> {
533 let Some(token) = &self.token else {
534 return Err(ClientError::Http(
535 "no token: deleting a track needs one, the same as any other resource write".to_owned(),
536 ));
537 };
538
539 let mut response = agent(&self.trust)?
540 .delete(format!("{}/signalk/v2/api/resources/tracks/{uuid}", self.http_base))
541 .header("Authorization", format!("Bearer {token}"))
542 .config()
543 .timeout_global(Some(TIMEOUT))
544 .http_status_as_error(false)
545 .build()
546 .call()
547 .map_err(|error| ClientError::Http(error.to_string()))?;
548
549 let answer: serde_json::Value = response
550 .body_mut()
551 .read_json()
552 .map_err(|error| ClientError::Http(error.to_string()))?;
553 read_action_response(&answer).map(|_id| ())
554 }
555}
556
557fn read_waypoint(uuid: Option<String>, value: &serde_json::Value) -> Result<Waypoint, ClientError> {
561 let name = value.get("name").and_then(serde_json::Value::as_str).map(str::to_owned);
562 let description = value.get("description").and_then(serde_json::Value::as_str).map(str::to_owned);
563 let category = value.get("type").and_then(serde_json::Value::as_str).map(str::to_owned);
564 let properties = value.get("feature").and_then(|feature| feature.get("properties"));
565 let icon = properties
566 .and_then(|properties| properties.get("icon"))
567 .and_then(serde_json::Value::as_str)
568 .map(str::to_owned);
569 let color = properties
570 .and_then(|properties| properties.get("color"))
571 .and_then(serde_json::Value::as_str)
572 .map(str::to_owned);
573
574 let coordinates = value
575 .get("feature")
576 .and_then(|feature| feature.get("geometry"))
577 .and_then(|geometry| geometry.get("coordinates"))
578 .and_then(serde_json::Value::as_array)
579 .ok_or_else(|| ClientError::Protocol(format!("waypoint has no feature.geometry.coordinates: {value}")))?;
580 let lon = coordinates
581 .first()
582 .and_then(serde_json::Value::as_f64)
583 .ok_or_else(|| ClientError::Protocol(format!("coordinates has no longitude: {value}")))?;
584 let lat = coordinates
585 .get(1)
586 .and_then(serde_json::Value::as_f64)
587 .ok_or_else(|| ClientError::Protocol(format!("coordinates has no latitude: {value}")))?;
588
589 Ok(Waypoint {
590 uuid,
591 name,
592 description,
593 category,
594 icon,
595 color,
596 position: Position::new(lat, lon),
597 })
598}
599
600fn write_waypoint(waypoint: &Waypoint) -> serde_json::Value {
605 let mut body = serde_json::json!({
606 "feature": {
607 "type": "Feature",
608 "geometry": {
609 "type": "Point",
610 "coordinates": [waypoint.position.lon_deg, waypoint.position.lat_deg],
611 },
612 },
613 });
614 if let Some(name) = &waypoint.name {
615 body["name"] = serde_json::Value::String(name.clone());
616 }
617 if let Some(description) = &waypoint.description {
618 body["description"] = serde_json::Value::String(description.clone());
619 }
620 if let Some(category) = &waypoint.category {
621 body["type"] = serde_json::Value::String(category.clone());
622 }
623 if let Some(icon) = &waypoint.icon {
624 body["feature"]["properties"]["icon"] = serde_json::Value::String(icon.clone());
630 }
631 if let Some(color) = &waypoint.color {
632 body["feature"]["properties"]["color"] = serde_json::Value::String(color.clone());
633 }
634 body
635}
636
637fn read_route(uuid: Option<String>, value: &serde_json::Value) -> Result<Route, ClientError> {
641 let name = value.get("name").and_then(serde_json::Value::as_str).map(str::to_owned);
642 let description = value.get("description").and_then(serde_json::Value::as_str).map(str::to_owned);
643 let distance_m = value.get("distance").and_then(serde_json::Value::as_f64);
644
645 let feature = value.get("feature");
646 let coordinates = feature
647 .and_then(|feature| feature.get("geometry"))
648 .and_then(|geometry| geometry.get("coordinates"))
649 .and_then(serde_json::Value::as_array)
650 .ok_or_else(|| ClientError::Protocol(format!("route has no feature.geometry.coordinates: {value}")))?;
651
652 let names = feature
656 .and_then(|feature| feature.get("properties"))
657 .and_then(|properties| properties.get("coordinatesMeta"))
658 .and_then(serde_json::Value::as_array);
659
660 let points = coordinates
661 .iter()
662 .enumerate()
663 .map(|(index, point)| {
664 let point = point
665 .as_array()
666 .ok_or_else(|| ClientError::Protocol(format!("route coordinate {index} is not [lon, lat]: {value}")))?;
667 let lon = point
668 .first()
669 .and_then(serde_json::Value::as_f64)
670 .ok_or_else(|| ClientError::Protocol(format!("route coordinate {index} has no longitude: {value}")))?;
671 let lat = point
672 .get(1)
673 .and_then(serde_json::Value::as_f64)
674 .ok_or_else(|| ClientError::Protocol(format!("route coordinate {index} has no latitude: {value}")))?;
675 let name = names
676 .and_then(|names| names.get(index))
677 .and_then(|meta| meta.get("name"))
678 .and_then(serde_json::Value::as_str)
679 .map(str::to_owned);
680 Ok(RoutePoint { position: Position::new(lat, lon), name })
681 })
682 .collect::<Result<Vec<_>, ClientError>>()?;
683
684 Ok(Route { uuid, name, description, distance_m, points })
685}
686
687fn write_route(route: &Route) -> serde_json::Value {
691 let coordinates: Vec<serde_json::Value> = route
692 .points
693 .iter()
694 .map(|point| serde_json::json!([point.position.lon_deg, point.position.lat_deg]))
695 .collect();
696
697 let mut body = serde_json::json!({
698 "feature": {
699 "type": "Feature",
700 "geometry": {
701 "type": "LineString",
702 "coordinates": coordinates,
703 },
704 },
705 });
706 if let Some(name) = &route.name {
707 body["name"] = serde_json::Value::String(name.clone());
708 }
709 if let Some(description) = &route.description {
710 body["description"] = serde_json::Value::String(description.clone());
711 }
712 if let Some(distance_m) = route.distance_m {
713 body["distance"] = serde_json::json!(distance_m);
714 }
715 if !route.points.is_empty() && route.points.iter().all(|point| point.name.is_some()) {
718 let meta: Vec<serde_json::Value> = route
719 .points
720 .iter()
721 .map(|point| serde_json::json!({ "name": point.name.as_deref().unwrap_or_default() }))
722 .collect();
723 body["feature"]["properties"]["coordinatesMeta"] = serde_json::Value::Array(meta);
724 }
725 body
726}
727
728fn read_track(uuid: Option<String>, value: &serde_json::Value) -> Result<Track, ClientError> {
732 let name = value.get("name").and_then(serde_json::Value::as_str).map(str::to_owned);
733 let description = value.get("description").and_then(serde_json::Value::as_str).map(str::to_owned);
734 let distance_m = value.get("distance").and_then(serde_json::Value::as_f64);
735 let timestamp = value.get("timestamp").and_then(serde_json::Value::as_str).map(str::to_owned);
736
737 let feature = value.get("feature");
738 let properties = feature.and_then(|feature| feature.get("properties"));
739 let color = properties.and_then(|properties| properties.get("color")).and_then(serde_json::Value::as_str).map(str::to_owned);
740 let recorded_from =
741 properties.and_then(|properties| properties.get("startTime")).and_then(serde_json::Value::as_str).map(str::to_owned);
742 let recorded_to =
743 properties.and_then(|properties| properties.get("endTime")).and_then(serde_json::Value::as_str).map(str::to_owned);
744
745 let coordinates = feature
746 .and_then(|feature| feature.get("geometry"))
747 .and_then(|geometry| geometry.get("coordinates"))
748 .and_then(serde_json::Value::as_array)
749 .ok_or_else(|| ClientError::Protocol(format!("track has no feature.geometry.coordinates: {value}")))?;
750
751 let points = coordinates
752 .iter()
753 .enumerate()
754 .map(|(index, point)| {
755 let point = point
756 .as_array()
757 .ok_or_else(|| ClientError::Protocol(format!("track coordinate {index} is not [lon, lat]: {value}")))?;
758 let lon = point
759 .first()
760 .and_then(serde_json::Value::as_f64)
761 .ok_or_else(|| ClientError::Protocol(format!("track coordinate {index} has no longitude: {value}")))?;
762 let lat = point
763 .get(1)
764 .and_then(serde_json::Value::as_f64)
765 .ok_or_else(|| ClientError::Protocol(format!("track coordinate {index} has no latitude: {value}")))?;
766 Ok(Position::new(lat, lon))
767 })
768 .collect::<Result<Vec<_>, ClientError>>()?;
769
770 Ok(Track { uuid, name, description, color, distance_m, points, timestamp, recorded_from, recorded_to })
771}
772
773fn write_track(track: &Track) -> serde_json::Value {
778 let coordinates: Vec<serde_json::Value> =
779 track.points.iter().map(|position| serde_json::json!([position.lon_deg, position.lat_deg])).collect();
780
781 let mut body = serde_json::json!({
782 "feature": {
783 "type": "Feature",
784 "geometry": {
785 "type": "LineString",
786 "coordinates": coordinates,
787 },
788 },
789 });
790 if let Some(name) = &track.name {
791 body["name"] = serde_json::Value::String(name.clone());
792 }
793 if let Some(description) = &track.description {
794 body["description"] = serde_json::Value::String(description.clone());
795 }
796 if let Some(distance_m) = track.distance_m {
797 body["distance"] = serde_json::json!(distance_m);
798 }
799 if let Some(color) = &track.color {
800 body["feature"]["properties"]["color"] = serde_json::Value::String(color.clone());
804 }
805 if let Some(recorded_from) = &track.recorded_from {
806 body["feature"]["properties"]["startTime"] = serde_json::Value::String(recorded_from.clone());
807 }
808 if let Some(recorded_to) = &track.recorded_to {
809 body["feature"]["properties"]["endTime"] = serde_json::Value::String(recorded_to.clone());
810 }
811 body
812}
813
814fn read_action_response(answer: &serde_json::Value) -> Result<String, ClientError> {
828 if let Some(id) = answer.get("id").and_then(serde_json::Value::as_str) {
829 return Ok(id.to_owned());
830 }
831 let message = answer.get("message").and_then(serde_json::Value::as_str);
832 let completed = answer.get("state").and_then(serde_json::Value::as_str) == Some("COMPLETED");
833 match message {
834 Some(text) if completed => Ok(text.to_owned()),
835 Some(text) => Err(ClientError::Protocol(text.to_owned())),
836 None => Err(ClientError::Protocol(
837 "the server's answer named no id and no message".to_owned(),
838 )),
839 }
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[test]
847 fn a_listed_waypoint_is_read_from_its_own_map_entry() {
848 let value: serde_json::Value = serde_json::from_str(
851 r#"{"name":"Fuel dock","description":"Diesel only","type":"fuel",
852 "feature":{"type":"Feature","geometry":{"type":"Point","coordinates":[13.568,45.515]}},
853 "timestamp":"2026-08-16T09:00:00Z","$source":"client"}"#,
854 )
855 .unwrap();
856
857 let waypoint = read_waypoint(Some("94052456-65fa-48ce-a85d-41b78a9d2111".to_owned()), &value).unwrap();
858 assert_eq!(waypoint.uuid.as_deref(), Some("94052456-65fa-48ce-a85d-41b78a9d2111"));
859 assert_eq!(waypoint.name.as_deref(), Some("Fuel dock"));
860 assert_eq!(waypoint.description.as_deref(), Some("Diesel only"));
861 assert_eq!(waypoint.category.as_deref(), Some("fuel"));
862 assert!((waypoint.position.lat_deg - 45.515).abs() < 1e-9);
863 assert!((waypoint.position.lon_deg - 13.568).abs() < 1e-9);
864 }
865
866 #[test]
867 fn a_waypoint_with_no_name_or_description_is_still_read() {
868 let value: serde_json::Value = serde_json::from_str(
869 r#"{"feature":{"type":"Feature","geometry":{"type":"Point","coordinates":[1.0,2.0]}}}"#,
870 )
871 .unwrap();
872 let waypoint = read_waypoint(None, &value).unwrap();
873 assert_eq!(waypoint.name, None);
874 assert_eq!(waypoint.description, None);
875 assert_eq!(waypoint.category, None);
876 assert_eq!(waypoint.icon, None);
877 assert_eq!(waypoint.color, None);
878 }
879
880 #[test]
881 fn icon_and_color_round_trip_through_feature_properties() {
882 let value: serde_json::Value = serde_json::from_str(
883 r#"{"name":"Fuel dock",
884 "feature":{"type":"Feature","geometry":{"type":"Point","coordinates":[13.568,45.515]},
885 "properties":{"icon":"location","color":"red"}}}"#,
886 )
887 .unwrap();
888 let waypoint = read_waypoint(None, &value).unwrap();
889 assert_eq!(waypoint.icon.as_deref(), Some("location"));
890 assert_eq!(waypoint.color.as_deref(), Some("red"));
891
892 let body = write_waypoint(&waypoint);
893 assert_eq!(body["feature"]["properties"]["icon"], "location");
894 assert_eq!(body["feature"]["properties"]["color"], "red");
895 }
896
897 #[test]
898 fn a_waypoint_with_no_geometry_is_a_protocol_error() {
899 let value: serde_json::Value = serde_json::from_str(r#"{"name":"nowhere"}"#).unwrap();
900 assert!(matches!(read_waypoint(None, &value), Err(ClientError::Protocol(_))));
901 }
902
903 #[test]
904 fn coordinates_are_written_lon_then_lat_geojsons_own_order() {
905 let waypoint = Waypoint {
906 uuid: None,
907 name: Some("Fuel dock".to_owned()),
908 description: None,
909 category: Some("fuel".to_owned()),
910 icon: None,
911 color: None,
912 position: Position::new(45.515, 13.568),
913 };
914 let body = write_waypoint(&waypoint);
915 assert_eq!(body["feature"]["geometry"]["coordinates"][0], 13.568);
916 assert_eq!(body["feature"]["geometry"]["coordinates"][1], 45.515);
917 assert_eq!(body["name"], "Fuel dock");
918 assert_eq!(body["type"], "fuel");
919 assert!(body.get("description").is_none());
920 assert!(body["feature"].get("properties").is_none());
921 }
922
923 #[test]
924 fn a_successful_post_response_names_its_id() {
925 let answer: serde_json::Value =
928 serde_json::from_str(r#"{"state":"COMPLETED","statusCode":201,"id":"c5c310ed"}"#).unwrap();
929 assert_eq!(read_action_response(&answer).unwrap(), "c5c310ed");
930 }
931
932 #[test]
933 fn a_successful_put_response_names_its_id_via_message() {
934 let answer: serde_json::Value = serde_json::from_str(
941 r#"{"state":"COMPLETED","statusCode":200,"message":"c0ffee00-1234-4abc-89ab-c0ffeec0ffee"}"#,
942 )
943 .unwrap();
944 assert_eq!(
945 read_action_response(&answer).unwrap(),
946 "c0ffee00-1234-4abc-89ab-c0ffeec0ffee"
947 );
948 }
949
950 #[test]
951 fn a_refused_publish_is_read_as_an_error_with_the_servers_own_message() {
952 let answer: serde_json::Value =
953 serde_json::from_str(r#"{"state":"FAILED","statusCode":400,"message":"Invalid waypoint"}"#).unwrap();
954 let error = read_action_response(&answer).unwrap_err();
955 assert!(matches!(error, ClientError::Protocol(ref message) if message == "Invalid waypoint"));
956 }
957
958 #[test]
959 fn a_refused_put_names_the_invalid_id_it_was_given() {
960 let answer: serde_json::Value = serde_json::from_str(
964 r#"{"state":"FAILED","statusCode":400,"message":"Invalid resource id provided (11111111-2222-3333-4444-555555555555)"}"#,
965 )
966 .unwrap();
967 let error = read_action_response(&answer).unwrap_err();
968 assert!(matches!(error, ClientError::Protocol(ref message) if message.contains("Invalid resource id")));
969 }
970
971 #[test]
972 fn a_successful_delete_response_is_read_the_same_way_a_put_is() {
973 let answer: serde_json::Value = serde_json::from_str(
977 r#"{"state":"COMPLETED","statusCode":200,"message":"c0ffee00-1234-4abc-89ab-c0ffeec0ffee"}"#,
978 )
979 .unwrap();
980 assert_eq!(
981 read_action_response(&answer).unwrap(),
982 "c0ffee00-1234-4abc-89ab-c0ffeec0ffee"
983 );
984 }
985
986 #[test]
987 fn deleting_without_a_token_is_refused_before_any_request_is_made() {
988 let client = Client::new("https://example.invalid".to_owned(), Trust::plaintext(), None);
989 let error = client.delete_waypoint("c0ffee00-1234-4abc-89ab-c0ffeec0ffee").unwrap_err();
990 assert!(matches!(error, ClientError::Http(ref message) if message.contains("no token")));
991 }
992
993 #[test]
994 fn a_listed_route_is_read_from_its_own_map_entry() {
995 let value: serde_json::Value = serde_json::from_str(
998 r#"{"name":"Piran to Koper","description":"Coastal hop","distance":9260.5,
999 "feature":{"type":"Feature",
1000 "geometry":{"type":"LineString","coordinates":[[13.568,45.515],[13.620,45.550],[13.730,45.548]]},
1001 "properties":{"coordinatesMeta":[{"name":"Piran"},null,{"name":"Koper"}]}},
1002 "timestamp":"2026-08-16T09:00:00Z","$source":"client"}"#,
1003 )
1004 .unwrap();
1005
1006 let route = read_route(Some("94052456-65fa-48ce-a85d-41b78a9d2111".to_owned()), &value).unwrap();
1007 assert_eq!(route.uuid.as_deref(), Some("94052456-65fa-48ce-a85d-41b78a9d2111"));
1008 assert_eq!(route.name.as_deref(), Some("Piran to Koper"));
1009 assert_eq!(route.description.as_deref(), Some("Coastal hop"));
1010 assert_eq!(route.distance_m, Some(9260.5));
1011 assert_eq!(route.points.len(), 3);
1012 assert_eq!(route.points[0].name.as_deref(), Some("Piran"));
1013 assert_eq!(route.points[1].name, None);
1014 assert_eq!(route.points[2].name.as_deref(), Some("Koper"));
1015 assert!((route.points[0].position.lat_deg - 45.515).abs() < 1e-9);
1016 assert!((route.points[0].position.lon_deg - 13.568).abs() < 1e-9);
1017 }
1018
1019 #[test]
1020 fn a_route_with_no_point_names_is_still_read() {
1021 let value: serde_json::Value = serde_json::from_str(
1022 r#"{"feature":{"type":"Feature","geometry":{"type":"LineString","coordinates":[[1.0,2.0],[3.0,4.0]]}}}"#,
1023 )
1024 .unwrap();
1025 let route = read_route(None, &value).unwrap();
1026 assert_eq!(route.name, None);
1027 assert_eq!(route.description, None);
1028 assert_eq!(route.distance_m, None);
1029 assert_eq!(route.points.len(), 2);
1030 assert_eq!(route.points[0].name, None);
1031 assert_eq!(route.points[1].name, None);
1032 }
1033
1034 #[test]
1035 fn a_route_with_no_geometry_is_a_protocol_error() {
1036 let value: serde_json::Value = serde_json::from_str(r#"{"name":"nowhere"}"#).unwrap();
1037 assert!(matches!(read_route(None, &value), Err(ClientError::Protocol(_))));
1038 }
1039
1040 #[test]
1041 fn a_route_with_a_single_point_is_still_read() {
1042 let value: serde_json::Value = serde_json::from_str(
1047 r#"{"feature":{"type":"Feature","geometry":{"type":"LineString","coordinates":[[13.568,45.515]]}}}"#,
1048 )
1049 .unwrap();
1050 let route = read_route(None, &value).unwrap();
1051 assert_eq!(route.points.len(), 1);
1052 }
1053
1054 #[test]
1055 fn a_malformed_route_coordinate_is_a_protocol_error() {
1056 let value: serde_json::Value = serde_json::from_str(
1060 r#"{"feature":{"type":"Feature","geometry":{"type":"LineString","coordinates":[[13.568,45.515],42]}}}"#,
1061 )
1062 .unwrap();
1063 assert!(matches!(read_route(None, &value), Err(ClientError::Protocol(_))));
1064 }
1065
1066 #[test]
1067 fn an_href_linked_route_point_is_read_as_unnamed_not_an_error() {
1068 let value: serde_json::Value = serde_json::from_str(
1076 r#"{"feature":{"type":"Feature",
1077 "geometry":{"type":"LineString","coordinates":[[13.568,45.515],[13.730,45.548]]},
1078 "properties":{"coordinatesMeta":[{"href":"/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111"},{"name":"Koper"}]}}}"#,
1079 )
1080 .unwrap();
1081 let route = read_route(None, &value).unwrap();
1082 assert_eq!(route.points[0].name, None);
1083 assert_eq!(route.points[1].name.as_deref(), Some("Koper"));
1084 }
1085
1086 #[test]
1087 fn route_points_are_written_lon_then_lat_and_a_fully_named_route_carries_coordinatesmeta() {
1088 let route = Route {
1089 uuid: None,
1090 name: Some("Piran to Koper".to_owned()),
1091 description: None,
1092 distance_m: Some(9260.5),
1093 points: vec![
1094 RoutePoint { position: Position::new(45.515, 13.568), name: Some("Piran".to_owned()) },
1095 RoutePoint { position: Position::new(45.548, 13.730), name: Some("Koper".to_owned()) },
1096 ],
1097 };
1098 let body = write_route(&route);
1099 assert_eq!(body["feature"]["geometry"]["coordinates"][0][0], 13.568);
1100 assert_eq!(body["feature"]["geometry"]["coordinates"][0][1], 45.515);
1101 assert_eq!(body["feature"]["geometry"]["coordinates"][1][0], 13.730);
1102 assert_eq!(body["feature"]["geometry"]["coordinates"][1][1], 45.548);
1103 assert_eq!(body["name"], "Piran to Koper");
1104 assert_eq!(body["distance"], 9260.5);
1105 assert!(body.get("description").is_none());
1106 assert_eq!(body["feature"]["properties"]["coordinatesMeta"][0]["name"], "Piran");
1107 assert_eq!(body["feature"]["properties"]["coordinatesMeta"][1]["name"], "Koper");
1108 }
1109
1110 #[test]
1111 fn a_route_with_no_named_points_writes_no_coordinatesmeta_at_all() {
1112 let route = Route {
1113 uuid: None,
1114 name: None,
1115 description: None,
1116 distance_m: None,
1117 points: vec![RoutePoint { position: Position::new(45.515, 13.568), name: None }],
1118 };
1119 let body = write_route(&route);
1120 assert!(body["feature"].get("properties").is_none());
1121 assert!(body.get("distance").is_none());
1122 }
1123
1124 #[test]
1125 fn a_route_with_some_but_not_all_points_named_writes_no_coordinatesmeta_either() {
1126 let route = Route {
1133 uuid: None,
1134 name: None,
1135 description: None,
1136 distance_m: None,
1137 points: vec![
1138 RoutePoint { position: Position::new(45.515, 13.568), name: Some("Piran".to_owned()) },
1139 RoutePoint { position: Position::new(45.548, 13.730), name: None },
1140 ],
1141 };
1142 let body = write_route(&route);
1143 assert!(body["feature"].get("properties").is_none());
1144 }
1145
1146 #[test]
1147 fn deleting_a_route_without_a_token_is_refused_before_any_request_is_made() {
1148 let client = Client::new("https://example.invalid".to_owned(), Trust::plaintext(), None);
1149 let error = client.delete_route("c0ffee00-1234-4abc-89ab-c0ffeec0ffee").unwrap_err();
1150 assert!(matches!(error, ClientError::Http(ref message) if message.contains("no token")));
1151 }
1152
1153 #[test]
1154 fn a_listed_track_is_read_from_its_own_map_entry() {
1155 let value: serde_json::Value = serde_json::from_str(
1156 r##"{"name":"Passage to Piran","distance":9260.5,
1157 "feature":{"type":"Feature",
1158 "geometry":{"type":"LineString","coordinates":[[13.568,45.515],[13.730,45.548]]},
1159 "properties":{"color":"#3584e4","startTime":"2026-09-12T14:00:00.000Z","endTime":"2026-09-12T14:14:52.032Z"}},
1160 "timestamp":"2026-09-12T14:14:52.032Z","$source":"resources-provider"}"##,
1161 )
1162 .unwrap();
1163 let track = read_track(Some("94052456-65fa-48ce-a85d-41b78a9d2111".to_owned()), &value).unwrap();
1164 assert_eq!(track.uuid.as_deref(), Some("94052456-65fa-48ce-a85d-41b78a9d2111"));
1165 assert_eq!(track.name.as_deref(), Some("Passage to Piran"));
1166 assert_eq!(track.color.as_deref(), Some("#3584e4"));
1167 assert_eq!(track.distance_m, Some(9260.5));
1168 assert_eq!(track.points.len(), 2);
1169 assert!((track.points[0].lat_deg - 45.515).abs() < 1e-9);
1170 assert!((track.points[0].lon_deg - 13.568).abs() < 1e-9);
1171 assert_eq!(track.timestamp.as_deref(), Some("2026-09-12T14:14:52.032Z"));
1172 assert_eq!(track.recorded_from.as_deref(), Some("2026-09-12T14:00:00.000Z"));
1173 assert_eq!(track.recorded_to.as_deref(), Some("2026-09-12T14:14:52.032Z"));
1174 }
1175
1176 #[test]
1177 fn a_track_with_no_geometry_is_a_protocol_error() {
1178 let value: serde_json::Value = serde_json::from_str(r#"{"name":"nowhere"}"#).unwrap();
1179 assert!(matches!(read_track(None, &value), Err(ClientError::Protocol(_))));
1180 }
1181
1182 #[test]
1183 fn track_points_are_written_lon_then_lat_with_colour_in_feature_properties() {
1184 let track = Track {
1185 uuid: None,
1186 name: Some("Passage to Piran".to_owned()),
1187 description: None,
1188 color: Some("#3584e4".to_owned()),
1189 distance_m: Some(9260.5),
1190 points: vec![Position::new(45.515, 13.568), Position::new(45.548, 13.730)],
1191 timestamp: None,
1192 recorded_from: Some("2026-09-12T14:00:00.000Z".to_owned()),
1193 recorded_to: Some("2026-09-12T14:14:52.032Z".to_owned()),
1194 };
1195 let body = write_track(&track);
1196 assert_eq!(body["feature"]["geometry"]["coordinates"][0][0], 13.568);
1197 assert_eq!(body["feature"]["geometry"]["coordinates"][0][1], 45.515);
1198 assert_eq!(body["name"], "Passage to Piran");
1199 assert_eq!(body["distance"], 9260.5);
1200 assert_eq!(body["feature"]["properties"]["color"], "#3584e4");
1201 assert_eq!(body["feature"]["properties"]["startTime"], "2026-09-12T14:00:00.000Z");
1202 assert_eq!(body["feature"]["properties"]["endTime"], "2026-09-12T14:14:52.032Z");
1203 assert!(body.get("description").is_none());
1204 }
1205
1206 #[test]
1207 fn a_track_with_no_colour_writes_no_properties_at_all() {
1208 let track = Track {
1209 uuid: None,
1210 name: None,
1211 description: None,
1212 color: None,
1213 distance_m: None,
1214 points: vec![Position::new(45.515, 13.568)],
1215 timestamp: None,
1216 recorded_from: None,
1217 recorded_to: None,
1218 };
1219 let body = write_track(&track);
1220 assert!(body["feature"].get("properties").is_none());
1221 }
1222
1223 #[test]
1224 fn deleting_a_track_without_a_token_is_refused_before_any_request_is_made() {
1225 let client = Client::new("https://example.invalid".to_owned(), Trust::plaintext(), None);
1226 let error = client.delete_track("c0ffee00-1234-4abc-89ab-c0ffeec0ffee").unwrap_err();
1227 assert!(matches!(error, ClientError::Http(ref message) if message.contains("no token")));
1228 }
1229}