1use std::io::Read;
21use std::sync::{Arc, PoisonError, RwLock};
22use std::time::Duration;
23
24use serde_json::Value;
25use thiserror::Error;
26
27pub use types::*;
28
29pub const DEFAULT_BASE: &str = "http://localhost:8080";
32
33pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8);
35
36pub const STREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
39
40#[derive(Debug, Error)]
41pub enum RestError {
42 #[error("http {code}{suffix}", suffix = status_detail_suffix(.body.as_ref()))]
44 Status { code: u16, body: Option<Value> },
45 #[error("transport error: {0}")]
46 Transport(String),
47 #[error("response was not valid json: {0}")]
48 Json(String),
49}
50
51impl RestError {
52 pub fn code(&self) -> Option<u16> {
54 match self {
55 RestError::Status { code, .. } => Some(*code),
56 _ => None,
57 }
58 }
59
60 pub fn body(&self) -> Option<&Value> {
62 match self {
63 RestError::Status { body, .. } => body.as_ref(),
64 _ => None,
65 }
66 }
67
68 pub fn detail(&self) -> String {
71 self.body().map(status_detail).unwrap_or_default()
72 }
73}
74
75pub fn status_detail(body: &Value) -> String {
81 const PATHS: [&[&str]; 9] = [
82 &["detail"],
83 &["message"],
84 &["error"],
85 &["detail", "message"],
86 &["detail", "error", "message"],
87 &["detail", "error"],
88 &["error", "message"],
89 &["detail", "error", "code"],
90 &["error", "code"],
91 ];
92 PATHS
93 .iter()
94 .find_map(|p| {
95 p.iter()
96 .try_fold(body, |v, k| v.get(*k))
97 .and_then(Value::as_str)
98 })
99 .unwrap_or_default()
100 .to_string()
101}
102
103fn status_detail_suffix(body: Option<&Value>) -> String {
104 match body.map(status_detail) {
105 Some(d) if !d.is_empty() => format!(": {d}"),
106 _ => String::new(),
107 }
108}
109
110#[derive(Debug, Clone, Default)]
113pub struct Auth {
114 pub api_key: Option<String>,
116 pub session: Option<String>,
118}
119
120#[derive(Clone)]
123pub struct RestClient {
124 base: String,
125 auth: Arc<RwLock<Auth>>,
126 agent: ureq::Agent,
127 stream_agent: ureq::Agent,
128}
129
130impl RestClient {
131 pub fn new(base: impl Into<String>) -> Self {
133 let agent = ureq::AgentBuilder::new().timeout(DEFAULT_TIMEOUT).build();
134 let stream_agent = ureq::AgentBuilder::new()
135 .timeout_connect(STREAM_CONNECT_TIMEOUT)
136 .build();
137 Self {
138 base: base.into().trim_end_matches('/').to_string(),
139 auth: Arc::default(),
140 agent,
141 stream_agent,
142 }
143 }
144
145 pub fn local() -> Self {
147 Self::new(DEFAULT_BASE)
148 }
149
150 pub fn with_api_key(self, key: impl Into<String>) -> Self {
152 self.set_api_key(Some(key.into()));
153 self
154 }
155
156 pub fn set_api_key(&self, key: Option<String>) {
158 self.auth
159 .write()
160 .unwrap_or_else(PoisonError::into_inner)
161 .api_key = key;
162 }
163
164 pub fn set_session(&self, session: Option<String>) {
166 self.auth
167 .write()
168 .unwrap_or_else(PoisonError::into_inner)
169 .session = session;
170 }
171
172 pub fn auth(&self) -> Auth {
174 self.auth
175 .read()
176 .unwrap_or_else(PoisonError::into_inner)
177 .clone()
178 }
179
180 pub fn base(&self) -> &str {
182 &self.base
183 }
184
185 fn request(&self, agent: &ureq::Agent, method: &str, path: &str) -> ureq::Request {
186 let url = format!("{}{}", self.base, path);
187 let mut req = agent.request(method, &url);
188 let auth = self.auth();
189 if let Some(key) = auth.api_key.as_deref() {
190 req = req.set("X-ADOS-Key", key);
191 }
192 if let Some(session) = auth.session.as_deref() {
193 req = req.set("X-ADOS-Dashboard-Session", session);
194 }
195 req
196 }
197
198 pub fn get_json(&self, path: &str) -> Result<Value, RestError> {
200 self.send_json("GET", path, None, DEFAULT_TIMEOUT)
201 }
202
203 pub fn get_json_timeout(&self, path: &str, timeout: Duration) -> Result<Value, RestError> {
206 self.send_json("GET", path, None, timeout)
207 }
208
209 pub fn send_json(
212 &self,
213 method: &str,
214 path: &str,
215 body: Option<&Value>,
216 timeout: Duration,
217 ) -> Result<Value, RestError> {
218 let req = self.request(&self.agent, method, path).timeout(timeout);
219 let result = match body {
220 Some(b) => req.send_json(b),
221 None => req.call(),
222 };
223 match result {
224 Ok(resp) => {
225 let text = resp
226 .into_string()
227 .map_err(|e| RestError::Json(e.to_string()))?;
228 if text.trim().is_empty() {
229 return Ok(Value::Null);
230 }
231 serde_json::from_str(&text).map_err(|e| RestError::Json(e.to_string()))
232 }
233 Err(ureq::Error::Status(code, resp)) => Err(RestError::Status {
234 code,
235 body: resp.into_json::<Value>().ok(),
236 }),
237 Err(ureq::Error::Transport(t)) => Err(RestError::Transport(t.to_string())),
238 }
239 }
240
241 pub fn open_stream(&self, path: &str) -> Result<Box<dyn Read + Send>, RestError> {
245 let req = self
246 .request(&self.stream_agent, "GET", path)
247 .set("Accept", "text/event-stream");
248 match req.call() {
249 Ok(resp) => Ok(Box::new(resp.into_reader())),
250 Err(ureq::Error::Status(code, resp)) => Err(RestError::Status {
251 code,
252 body: resp.into_json::<Value>().ok(),
253 }),
254 Err(ureq::Error::Transport(t)) => Err(RestError::Transport(t.to_string())),
255 }
256 }
257
258 pub fn status(&self) -> Result<Value, RestError> {
260 self.get_json("/api/status")
261 }
262
263 pub fn setup_status(&self) -> Result<Value, RestError> {
265 self.get_json("/api/v1/setup/status")
266 }
267}
268
269pub mod types {
280 use serde::{Deserialize, Serialize};
281 use serde_json::{Map, Value};
282
283 pub type Extra = Map<String, Value>;
287
288 #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
290 pub struct ParamProgress {
291 #[serde(default)]
293 pub got: u64,
294 #[serde(default)]
296 pub expected: u64,
297 }
298
299 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
301 pub struct ParamsResponse {
302 #[serde(default)]
305 pub params: std::collections::BTreeMap<String, f64>,
306 #[serde(default)]
308 pub count: u64,
309 #[serde(default)]
311 pub cached: u64,
312 #[serde(default)]
314 pub priming: bool,
315 #[serde(default)]
317 pub priming_timeout: bool,
318 #[serde(default)]
320 pub priming_send_failed: bool,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub progress: Option<ParamProgress>,
324 #[serde(flatten)]
326 pub extra: Extra,
327 }
328
329 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
335 pub struct WfbLinkState {
336 #[serde(default)]
338 pub status: String,
339 #[serde(default)]
341 pub active: bool,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub channel: Option<u32>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub rssi_dbm: Option<i32>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub valid_rx_packets_per_s: Option<f64>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub tx_bytes_per_s: Option<f64>,
354 #[serde(flatten)]
356 pub extra: Extra,
357 }
358
359 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
365 pub struct MeshState {
366 #[serde(default)]
368 pub neighbors: Vec<Value>,
369 #[serde(default)]
371 pub routes: Vec<Value>,
372 #[serde(default)]
374 pub gateways: Vec<Value>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub role: Option<String>,
378 #[serde(flatten)]
380 pub extra: Extra,
381 }
382
383 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
385 pub struct ServiceEntry {
386 pub name: String,
388 pub state: String,
390 #[serde(default)]
392 pub active: bool,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub sub_state: Option<String>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub memory_mb: Option<f64>,
399 #[serde(flatten)]
401 pub extra: Extra,
402 }
403
404 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
406 pub struct ServicesResponse {
407 #[serde(default)]
409 pub services: Vec<ServiceEntry>,
410 #[serde(flatten)]
412 pub extra: Extra,
413 }
414
415 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
417 pub struct FleetEnrollment {
418 #[serde(default)]
420 pub enrolled: bool,
421 #[serde(flatten)]
423 pub extra: Extra,
424 }
425
426 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
429 pub struct FleetPeers {
430 #[serde(default)]
432 pub peers: Vec<Value>,
433 #[serde(flatten)]
435 pub extra: Extra,
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use serde_json::{json, Map};
443 use std::io::{Read, Write};
444 use std::net::TcpListener;
445
446 fn one_shot_server(status_line: &'static str, body: &'static str) -> String {
449 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
450 let addr = listener.local_addr().unwrap();
451 std::thread::spawn(move || {
452 if let Ok((mut stream, _)) = listener.accept() {
453 let mut buf = [0u8; 1024];
454 let _ = stream.read(&mut buf); let response = format!(
456 "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
457 body.len()
458 );
459 let _ = stream.write_all(response.as_bytes());
460 }
461 });
462 format!("http://{addr}")
463 }
464
465 #[test]
466 fn get_json_parses_a_200_response() {
467 let base = one_shot_server("200 OK", r#"{"armed":false,"mode":"STABILIZE"}"#);
468 let client = RestClient::new(base);
469 let v = client.status().unwrap();
470 assert_eq!(v["mode"], "STABILIZE");
471 assert_eq!(v["armed"], false);
472 }
473
474 #[test]
475 fn non_2xx_maps_to_status_error() {
476 let base = one_shot_server("401 Unauthorized", r#"{"detail":"missing key"}"#);
477 let client = RestClient::new(base).with_api_key("wrong");
478 match client.status() {
479 Err(e @ RestError::Status { code: 401, .. }) => {
480 assert_eq!(e.detail(), "missing key");
481 assert_eq!(e.to_string(), "http 401: missing key");
482 }
483 other => panic!("expected 401 status error, got {other:?}"),
484 }
485 }
486
487 #[test]
488 fn nested_error_bodies_yield_their_message() {
489 let nested = json!({"detail": {"error": {"code": "E_WIFI_STATUS_UNAVAILABLE", "message": "nmcli missing"}}});
490 assert_eq!(status_detail(&nested), "nmcli missing");
491 let coded = json!({"detail": {"error": {"code": "E_RIG_INVALID"}}});
492 assert_eq!(status_detail(&coded), "E_RIG_INVALID");
493 assert_eq!(status_detail(&json!({"detail": {"message": "m"}})), "m");
494 assert_eq!(status_detail(&json!({"other": 1})), "");
495 }
496
497 #[test]
498 fn an_error_body_rides_the_status_error() {
499 let base = one_shot_server(
500 "409 Conflict",
501 r#"{"error":"E_ARMED","message":"vehicle is armed"}"#,
502 );
503 let client = RestClient::new(base);
504 let err = client
505 .send_json(
506 "PUT",
507 "/api/config",
508 Some(&json!({"key": "a", "value": 1})),
509 DEFAULT_TIMEOUT,
510 )
511 .unwrap_err();
512 assert_eq!(err.code(), Some(409));
513 assert_eq!(err.body().unwrap()["error"], "E_ARMED");
514 assert_eq!(err.detail(), "vehicle is armed");
516 }
517
518 #[test]
519 fn an_empty_2xx_body_is_null() {
520 let base = one_shot_server("204 No Content", "");
521 let client = RestClient::new(base);
522 let v = client
523 .send_json("DELETE", "/api/v1/network/client", None, DEFAULT_TIMEOUT)
524 .unwrap();
525 assert_eq!(v, Value::Null);
526 }
527
528 #[test]
529 fn transport_error_when_nothing_listens() {
530 let client = RestClient::new("http://127.0.0.1:1");
532 assert!(matches!(client.status(), Err(RestError::Transport(_))));
533 }
534
535 fn assert_round_trip<T>(value: &T)
539 where
540 T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
541 {
542 let wire = serde_json::to_value(value).unwrap();
543 let back: T = serde_json::from_value(wire.clone()).unwrap();
544 assert_eq!(value, &back, "typed value changed across a round-trip");
545 let rewire = serde_json::to_value(&back).unwrap();
546 assert_eq!(wire, rewire, "wire JSON changed across a round-trip");
547 }
548
549 #[test]
550 fn params_response_reads_the_wire_map() {
551 let raw = json!({
552 "params": {"ARMING_CHECK": 1.0, "BATT_CAPACITY": 5200.0},
553 "count": 700,
554 "cached": 2,
555 "priming": true,
556 "priming_timeout": false,
557 "priming_send_failed": false,
558 "progress": {"got": 2, "expected": 700}
559 });
560 let resp: ParamsResponse = serde_json::from_value(raw).unwrap();
561 assert_eq!(resp.params["BATT_CAPACITY"], 5200.0);
562 assert_eq!(
563 resp.progress,
564 Some(ParamProgress {
565 got: 2,
566 expected: 700
567 })
568 );
569 assert!(resp.priming);
570 assert_round_trip(&resp);
571 }
572
573 #[test]
574 fn wfb_link_state_round_trips() {
575 let state = WfbLinkState {
576 status: "locked".into(),
577 active: true,
578 channel: Some(149),
579 rssi_dbm: Some(-51),
580 valid_rx_packets_per_s: Some(598.0),
581 tx_bytes_per_s: Some(750_000.0),
582 extra: Map::new(),
583 };
584 assert_round_trip(&state);
585 }
586
587 #[test]
588 fn mesh_state_round_trips() {
589 let state = MeshState {
590 neighbors: vec![json!({"mac": "aa:bb:cc", "rssi": -60})],
591 routes: vec![json!({"dest": "bat0", "metric": 2})],
592 gateways: vec![],
593 role: Some("relay".into()),
594 extra: Map::new(),
595 };
596 assert_round_trip(&state);
597 }
598
599 #[test]
600 fn services_response_round_trips() {
601 let resp = ServicesResponse {
602 services: vec![ServiceEntry {
603 name: "ados-supervisor".into(),
604 state: "active".into(),
605 active: true,
606 sub_state: Some("running".into()),
607 memory_mb: Some(24.5),
608 extra: Map::new(),
609 }],
610 extra: Map::new(),
611 };
612 assert_round_trip(&resp);
613 }
614
615 #[test]
616 fn fleet_shapes_round_trip() {
617 assert_round_trip(&FleetEnrollment {
618 enrolled: true,
619 extra: Map::new(),
620 });
621 assert_round_trip(&FleetPeers {
622 peers: vec![json!({"id": "local-abc", "online": true})],
623 extra: Map::new(),
624 });
625 }
626
627 #[test]
628 fn unknown_fields_survive_the_flatten_map() {
629 let raw = json!({
632 "status": "locked",
633 "active": true,
634 "channel": 149,
635 "antenna_diversity": {"best": 1, "count": 4},
636 "fec_recovered": 12
637 });
638 let state: WfbLinkState = serde_json::from_value(raw.clone()).unwrap();
639 assert_eq!(state.extra["fec_recovered"], 12);
640 assert_eq!(state.extra["antenna_diversity"]["best"], 1);
641 let back = serde_json::to_value(&state).unwrap();
643 assert_eq!(back, raw);
644 }
645
646 #[test]
647 fn missing_optional_fields_default_cleanly() {
648 let raw = json!({"status": "down", "active": false});
652 let state: WfbLinkState = serde_json::from_value(raw.clone()).unwrap();
653 assert_eq!(state.channel, None);
654 assert_eq!(state.rssi_dbm, None);
655 let back = serde_json::to_value(&state).unwrap();
656 assert_eq!(back, raw);
657 }
658}