Skip to main content

navcore_signalk_client/
resources.rs

1//! Standalone waypoints and routes, on the wire.
2//!
3//! Signal K's own Resources API, at `/signalk/v2/api/resources/waypoints`
4//! and `/signalk/v2/api/resources/routes` -- v2, not v1: the route
5//! registration (`signalk-server/dist/api/resources/index.js`,
6//! `RESOURCES_API_PATH`) is v2-only, and no v1 route exists at all. What
7//! a waypoint or route means to this workspace lives in `waypoints` and
8//! `routes`, the crates this module exists to serve; nothing here knows
9//! either crate exists, the same one-way dependency [`crate::stream`]
10//! already keeps toward `signalk` (units, not wire bytes), so that a
11//! wire-format module can be reused by a different domain crate later.
12//!
13//! Both resource kinds answer the same four calls, registered by the
14//! same generic code on the server side
15//! (`signalk-server/dist/api/resources/index.js` builds identical routes
16//! for every resource type it knows about, waypoints and routes alike):
17//!
18//! 1. `GET /resources/{kind}` -- every one the server holds, keyed by
19//!    UUID.
20//! 2. `POST /resources/{kind}` -- a new one; the server mints the UUID
21//!    and returns it.
22//! 3. `PUT /resources/{kind}/{id}` -- creates or replaces one at a
23//!    caller-chosen UUID. The server validates `{id}` as a proper UUID
24//!    v4 (`signalk-server/dist/api/resources/validate.js`'s own
25//!    `uuid` check); an id that is not refuses with `400 Invalid
26//!    resource id provided`, not a write.
27//! 4. `DELETE /resources/{kind}/{id}` -- removes it. Answers the same
28//!    `ActionResult` shape `PUT` does (`state`/`statusCode`/`message`,
29//!    the id echoed back in `message` on success), confirmed live
30//!    against a real server.
31//!
32//! A route's own `feature.geometry` is a `LineString`, not a `Point` --
33//! the one real shape difference from a waypoint -- and it may name each
34//! point along it via `feature.properties.coordinatesMeta`, an array
35//! aligned index-for-index with `geometry.coordinates`. Each entry is
36//! either `{name}` or `{href}` (the latter pointing at a standalone
37//! waypoint resource by uuid, so a route can be built from waypoints
38//! already in the library instead of bare positions); only the `name`
39//! variant is read or written here -- linking route points back to
40//! waypoint resources is a feature of its own, not needed for this
41//! crate to carry a route it can round-trip.
42//!
43//! There is no third, empty variant: each entry's own TypeBox schema
44//! (`resources-schemas.d.ts`) is `anyOf [{name}, {href}]`, not nullable,
45//! so a placeholder for an unnamed point in the middle of an
46//! otherwise-named route is refused outright, confirmed against a real
47//! server's own AJV validation error. `write_route` writes
48//! `coordinatesMeta` only when every point carries a name, and leaves
49//! it out entirely otherwise.
50//!
51//! Every write needs a token: unlike the stream, whose `allowReadonly`
52//! setting can let an unauthenticated client watch position updates, the
53//! REST interface refuses an unauthenticated write outright.
54
55use std::time::Duration;
56
57use nav_math::Position;
58
59use crate::access::agent;
60use crate::{ClientError, Trust};
61
62/// How long to wait for the server to answer one call -- the same figure
63/// [`crate::access::TIMEOUT`] uses, for the same reason: these are calls
64/// on a boat's own LAN, not calls to expect a slow answer from.
65const TIMEOUT: Duration = Duration::from_secs(10);
66
67/// A standalone waypoint, as the wire carries it.
68#[derive(Debug, Clone, PartialEq)]
69pub struct Waypoint {
70    /// The server's own id, once it has one. `None` for a waypoint not
71    /// yet published -- [`Client::publish_waypoint`] reads this to
72    /// decide `POST` (mint one) or `PUT` (use this one) accordingly.
73    pub uuid: Option<String>,
74    /// What the mariner calls it.
75    pub name: Option<String>,
76    /// Anything they wrote about it.
77    pub description: Option<String>,
78    /// The mariner's own grouping -- Signal K's own optional `type`
79    /// field on a waypoint resource, free text there and here alike.
80    pub category: Option<String>,
81    /// A platform-neutral marker shape -- `"location"`, `"bookmark"`,
82    /// `"hazard"`, `"flag"`, `"favorite"`, or something a newer client
83    /// invented that this build does not recognise yet. Carried in
84    /// `feature.properties.icon`, not a top-level field, since the
85    /// Signal K Waypoint schema itself defines none for this -- the
86    /// same reasoning this module's own doc gives for why nothing here
87    /// is a Signal K concept to begin with. This one
88    /// is not even a borrowed convention the way freeboard-sk's own
89    /// `properties.skIcon` is: it names no icon at all, only a shape
90    /// each client renders in its own native icon set, whichever that
91    /// is on its own platform. What decides
92    /// which shape maps to which glyph belongs to `waypoints`, the
93    /// crate this module exists to serve, and to each client after
94    /// that -- never here, which only carries the string.
95    pub icon: Option<String>,
96    /// A named accent colour for `icon` -- `"red"`, `"blue"`,
97    /// `"green"`, `"orange"`, or, the same as `icon`, something newer.
98    /// `feature.properties.color`, the same reasoning as `icon`.
99    pub color: Option<String>,
100    /// Where it is.
101    pub position: Position,
102}
103
104/// One named point along a [`Route`], as the wire carries it.
105#[derive(Debug, Clone, PartialEq)]
106pub struct RoutePoint {
107    /// Where it is.
108    pub position: Position,
109    /// What the mariner called it, if `feature.properties.coordinatesMeta`
110    /// carried a `name` entry for this point. `None` either for an
111    /// unnamed point or for one whose entry was the `href` variant --
112    /// see this module's own doc for why that variant is not read here.
113    pub name: Option<String>,
114}
115
116/// A standalone route, as the wire carries it.
117#[derive(Debug, Clone, PartialEq)]
118pub struct Route {
119    /// The server's own id, once it has one -- the same `None`-until-
120    /// published convention [`Waypoint::uuid`] uses, for the same
121    /// reason.
122    pub uuid: Option<String>,
123    /// What the mariner calls it.
124    pub name: Option<String>,
125    /// Anything they wrote about it.
126    pub description: Option<String>,
127    /// Total length, when the caller has one to send or the server sent
128    /// one back. Never trusted over a fresh local recomputation --
129    /// `routes::signalk`'s own doc gives the reasoning.
130    pub distance_m: Option<f64>,
131    /// The line itself, in the order it is sailed.
132    pub points: Vec<RoutePoint>,
133}
134
135/// A recorded track, as the wire carries it.
136///
137/// Not a Signal K spec resource type -- confirmed against
138/// `signalk-server`'s own `dist/api/resources/index.js`: only
139/// `waypoints`/`routes`/`regions`/`notes`/`charts` are ever registered
140/// by the server itself. `tracks` only exists at all once a server's own
141/// `@signalk/resources-provider` plugin has been configured with it as a
142/// "custom" collection -- at which point it answers the exact same
143/// generic `GET`/`POST`/`PUT`/`DELETE` shape every resource type here
144/// does, this module's own doc already explains why. No `isSignalKResourceType`
145/// schema validates a write against this shape server-side the way one
146/// does for `routes`/`waypoints`, so it is `tracks`'s crate's own
147/// choice, not a spec's -- see `tracks::signalk`'s own doc, which this
148/// exists to serve, for the fuller reasoning and for why it is not
149/// simply Freeboard-SK's own (undocumented, display-only) `tracks`
150/// convention adopted wholesale.
151#[derive(Debug, Clone, PartialEq)]
152pub struct Track {
153    /// The server's own id, once it has one -- the same `None`-until-
154    /// published convention [`Route::uuid`] uses, for the same reason.
155    pub uuid: Option<String>,
156    /// What the mariner calls it.
157    pub name: Option<String>,
158    /// Anything they wrote about it.
159    pub description: Option<String>,
160    /// A hex colour (`"#rrggbb"`), `feature.properties.color`, the same
161    /// place [`Waypoint::color`] already lives -- but a free hex value
162    /// here rather than [`Waypoint::color`]'s own small named palette:
163    /// telling several tracks apart on one chart at once wants more
164    /// distinct choices than a waypoint marker's four colours give.
165    pub color: Option<String>,
166    /// Total length, when the caller has one to send or the server sent
167    /// one back. Never trusted over a fresh local recomputation --
168    /// `tracks::signalk`'s own doc gives the same reasoning
169    /// `routes::signalk`'s already does for a route's own distance.
170    pub distance_m: Option<f64>,
171    /// The line itself, in the order it was recorded. No per-point
172    /// names the way [`RoutePoint`] can carry one: a recorded point has
173    /// nowhere a name would come from, unlike a route's own waypoints,
174    /// which a mariner placed and could label as they went.
175    pub points: Vec<Position>,
176    /// When the server itself last saved this -- the generic resource
177    /// wrapper's own top-level `timestamp`, RFC 3339, confirmed live
178    /// (`"$source":"resources-provider"` alongside it in a real `GET`
179    /// answer). `None` only for a [`Track`] not yet published, which has
180    /// no server-assigned timestamp to carry -- one this crate wrote
181    /// itself never sends this back up; it is read-only, the server's
182    /// own to set. Kept as the plain string rather than parsed: RFC 3339
183    /// UTC timestamps already sort correctly as plain text, and nothing
184    /// in this workspace pulls in a date/time crate merely to hold one.
185    pub timestamp: Option<String>,
186    /// When recording actually started, RFC 3339 --
187    /// `feature.properties.startTime`. Unlike [`Self::timestamp`], this
188    /// is the mariner's own client's to set, and *is* sent up on a
189    /// write.
190    pub recorded_from: Option<String>,
191    /// When recording actually stopped, RFC 3339 --
192    /// `feature.properties.endTime`. Same as [`Self::recorded_from`].
193    pub recorded_to: Option<String>,
194}
195
196/// A client for one server's own Resources API.
197///
198/// Built from what [`crate::stream::Connection`] and
199/// [`crate::access::request_access`] already needed -- the server's own
200/// HTTP base, which authority to trust, and (once one exists) the
201/// device's own token -- rather than a new set of credentials this
202/// module invents for itself.
203pub struct Client {
204    http_base: String,
205    trust: Trust,
206    token: Option<String>,
207}
208
209impl Client {
210    /// A client for the server at `http_base`, using `trust` to decide
211    /// which server may answer and `token` (once one exists) to
212    /// authenticate writes.
213    #[must_use]
214    pub fn new(http_base: String, trust: Trust, token: Option<String>) -> Self {
215        Self { http_base, trust, token }
216    }
217
218    /// Every waypoint the server currently holds.
219    ///
220    /// # Errors
221    ///
222    /// If the server cannot be reached, or answers something this cannot
223    /// make sense of.
224    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    /// Creates a new waypoint (`waypoint.uuid` is `None`) or replaces one
251    /// at its own known id (`Some`). Either way, answers the id the
252    /// waypoint now has on the server.
253    ///
254    /// # Errors
255    ///
256    /// If there is no token -- a write always needs one -- if the server
257    /// cannot be reached, or if it refuses the write.
258    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        // http_status_as_error(false), the same reason
269        // access::request_access's own doc gives: the server's answer to
270        // a refused write is meaningful, not just a status code to react
271        // to, and reading it needs the body of a 4xx/5xx response, which
272        // ureq discards by default.
273        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    /// Deletes a waypoint by id.
303    ///
304    /// # Errors
305    ///
306    /// If there is no token -- a write always needs one, the same as
307    /// [`Client::publish_waypoint`] -- if the server cannot be reached,
308    /// or if it refuses the delete.
309    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    /// Every route the server currently holds.
334    ///
335    /// # Errors
336    ///
337    /// If the server cannot be reached, or answers something this cannot
338    /// make sense of.
339    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    /// Creates a new route (`route.uuid` is `None`) or replaces one at
366    /// its own known id (`Some`). Either way, answers the id the route
367    /// now has on the server.
368    ///
369    /// # Errors
370    ///
371    /// If there is no token -- a write always needs one -- if the server
372    /// cannot be reached, or if it refuses the write.
373    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    /// Deletes a route by id.
413    ///
414    /// # Errors
415    ///
416    /// If there is no token -- a write always needs one, the same as
417    /// [`Client::publish_route`] -- if the server cannot be reached, or
418    /// if it refuses the delete.
419    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    /// Every track the server currently holds. See [`Client::list_routes`]'s
444    /// own doc for the identical shape; the only difference is the path.
445    ///
446    /// # Errors
447    ///
448    /// If the server cannot be reached, or it refuses the read --
449    /// including a server whose `tracks` custom collection was never
450    /// enabled at all, see [`Track`]'s own doc.
451    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    /// Creates a new track (`track.uuid` is `None`) or replaces one at
475    /// its own known id (`Some`). Either way, answers the id the track
476    /// now has on the server. See [`Client::publish_route`]'s own doc
477    /// for the identical shape; the only difference is the path.
478    ///
479    /// # Errors
480    ///
481    /// If there is no token -- a write always needs one -- if the server
482    /// cannot be reached, or if it refuses the write (including a
483    /// server whose `tracks` custom collection was never enabled at
484    /// all, see [`Track`]'s own doc).
485    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    /// Deletes a track by id. See [`Client::delete_route`]'s own doc for
525    /// the identical shape; the only difference is the path.
526    ///
527    /// # Errors
528    ///
529    /// If there is no token -- a write always needs one, the same as
530    /// [`Client::publish_track`] -- if the server cannot be reached, or
531    /// if it refuses the delete.
532    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
557/// Reads one waypoint out of the shape the server's own `GET` answers
558/// with -- `uuid` comes from the map key the caller already has, not
559/// from inside `value` itself, which never repeats it.
560fn 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
600/// The request body a `POST`/`PUT` sends -- Signal K's own `Waypoint`
601/// shape, `coordinates` written `[lon, lat]`, GeoJSON's own order and the
602/// opposite of `Position`'s own field order, which is why this is not a
603/// plain derive.
604fn 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        // `feature.properties` does not exist yet at this point --
625        // `serde_json::Value`'s own `IndexMut` auto-vivifies each
626        // missing key along the chain as an object, so this alone is
627        // enough to build it rather than needing a separate
628        // `json!({"properties": {"icon": icon}})` merge step.
629        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
637/// Reads one route out of the shape the server's own `GET` answers with
638/// -- `uuid` comes from the map key the caller already has, the same as
639/// [`read_waypoint`].
640fn 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    // `coordinatesMeta` is aligned index-for-index with `coordinates`,
653    // when it is present at all -- an unnamed point is one whose index
654    // has no entry, not one whose entry is null.
655    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
687/// The request body a `POST`/`PUT` sends -- Signal K's own `Route`
688/// shape, each point's `coordinates` written `[lon, lat]`, the same
689/// `GeoJSON` order [`write_waypoint`] already uses.
690fn 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    // Only when every point has a name: the schema's own entry shape has
716    // no stand-in for "this one has none" -- see this module's own doc.
717    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
728/// Reads one track out of the shape the server's own `GET` answers with
729/// -- `uuid` comes from the map key the caller already has, the same as
730/// [`read_route`].
731fn 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
773/// The request body a `POST`/`PUT` sends -- this crate's own `Track`
774/// shape (see [`Track`]'s own doc for why it is not a Signal K spec
775/// shape), each point's `coordinates` written `[lon, lat]`, the same
776/// `GeoJSON` order [`write_route`] already uses.
777fn 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        // `feature.properties` does not exist yet at this point --
801        // see write_waypoint's own comment on the same auto-vivifying
802        // behaviour this leans on too.
803        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
814/// Reads a `200`/`201 ActionResponse` for the id it names, or an
815/// `ErrorResponse` for the message explaining why there is none -- the
816/// one reader shared by `POST`, `PUT` and `DELETE`, since all three
817/// answer with the same `ActionResult` shape.
818///
819/// Not simply "an `id` field, or else an error": a real server's `PUT`
820/// (and `DELETE`) answer on success carries no `id` field at all --
821/// only `POST` does -- and instead puts the id in `message`, the very
822/// field a refusal also uses for its own explanation, confirmed live
823/// against a real server. The one field that tells the two apart is
824/// `state`: `"COMPLETED"` for success, `"FAILED"` for a refusal, the
825/// same field [`crate::access::read_requested`] already leans on for
826/// the same reason.
827fn 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        // The shape a real server answers GET /resources/waypoints with,
849        // kept verbatim -- one entry of the uuid-keyed object.
850        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        // A real POST /resources/waypoints 201 answer, captured live
926        // against a real server.
927        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        // A real PUT /resources/waypoints/{id} 200 answer, captured live
935        // against a real server -- no `id` field at all here, unlike POST's own
936        // answer; the id comes back in `message`, the exact field a
937        // refusal also uses for its own explanation. `state` is what
938        // tells the two apart, not which field is present -- see
939        // read_action_response's own doc.
940        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        // A real PUT refusal, captured live against a real server for a
961        // non-v4-shaped id -- state FAILED is what makes this an error
962        // despite carrying a message that looks like it could be data.
963        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        // A real DELETE /resources/waypoints/{id} 200 answer, captured
974        // live against a real server -- the exact same ActionResult shape a PUT
975        // success carries, id echoed back in `message`.
976        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        // The shape a real server answers GET /resources/routes with,
996        // kept verbatim -- one entry of the uuid-keyed object.
997        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        // Geometrically odd -- a `LineString` of one point draws nothing
1043        // -- but not this reader's place to refuse it; `routes::Route`
1044        // is what decides a route needs at least two points to have any
1045        // legs, not the wire layer.
1046        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        // The first point parses; the second is a bare number, not a
1057        // `[lon, lat]` pair -- caught per-point rather than assuming
1058        // every entry in `coordinates` shares the first one's shape.
1059        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        // The `{href}` variant `coordinatesMeta` may carry instead of
1069        // `{name}` -- a point linking back to a standalone waypoint
1070        // resource, which this crate does not resolve (see this
1071        // module's own doc). A server holding routes built by a client
1072        // that *does* use that variant must not make this one choke on
1073        // them; it just reads such a point as unnamed, the same as one
1074        // with no `coordinatesMeta` entry at all.
1075        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        // The schema's own entry shape is `{name}` or `{href}`, never a
1127        // third "nothing to say about this one" variant -- see this
1128        // module's own doc for the live refusal that established that.
1129        // Partial fidelity is not attempted; the whole property is left
1130        // out rather than reaching for a stand-in the schema does not
1131        // offer.
1132        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}