bee_tui/api/mod.rs
1#![allow(dead_code)] // wired up in the next commit (api → app → watch).
2
3//! Thin wrapper around [`bee::Client`] that binds it to a configured
4//! [`NodeConfig`]. The cockpit talks to Bee through one [`ApiClient`]
5//! per active profile.
6//!
7//! Construction:
8//!
9//! ```ignore
10//! use crate::api::ApiClient;
11//! let client = ApiClient::from_node(&node_config)?;
12//! let rtt = client.bee().ping().await?;
13//! ```
14//!
15//! Multi-node UX (v0.4) will hold a `HashMap<String, ApiClient>`
16//! keyed by node name; the screen-level `:context` command swaps the
17//! active key.
18
19use color_eyre::eyre::{WrapErr, eyre};
20
21use crate::config::NodeConfig;
22
23/// User-Agent header bee-tui sends on every Bee API call. Enables
24/// the Bee HTTP log-pane tab to filter bee-tui's own traffic out
25/// of the "everything Bee served" view, leaving only third-party
26/// clients (curl / swarm-cli / browser) visible. Format follows
27/// the convention `<product>/<version>` per RFC 7231 § 5.5.3.
28pub const BEE_TUI_USER_AGENT: &str = concat!("bee-tui/", env!("CARGO_PKG_VERSION"));
29
30/// Active connection to one Bee node. Cheap to clone (`bee::Client`
31/// is `Arc<Inner>` under the hood).
32#[derive(Clone, Debug)]
33pub struct ApiClient {
34 /// Friendly profile name shown in the header bar.
35 pub name: String,
36 /// Resolved base URL (the scheme has been validated to be `http`
37 /// or `https`).
38 pub url: String,
39 /// Whether the node was configured with a bearer token. We don't
40 /// store the token itself — `bee::Client` keeps it.
41 pub authenticated: bool,
42 inner: bee::Client,
43}
44
45impl ApiClient {
46 /// Build an [`ApiClient`] from a [`NodeConfig`]. Resolves the
47 /// optional `@env:VAR` indirection and wires the bearer token via
48 /// [`bee::Client::with_token`] when present.
49 pub fn from_node(node: &NodeConfig) -> color_eyre::Result<Self> {
50 let url = node.url.trim_end_matches('/').to_string();
51 let token = node.resolved_token();
52 let authenticated = token.is_some();
53 // Build a reqwest client that stamps every outbound call with
54 // bee-tui's User-Agent. Bee logs the UA on its `node/api`
55 // lines (when configured to do so), which lets the cockpit's
56 // Bee HTTP tab filter bee-tui's own traffic out — leaving the
57 // tab as a clean view of third-party clients.
58 let mut http_builder = reqwest::Client::builder().user_agent(BEE_TUI_USER_AGENT);
59 // Auth is normally injected by `with_token`'s default headers.
60 // Since we're handing in our own client, we replicate that
61 // here so bearer auth still works on restricted-mode nodes.
62 if let Some(t) = token.as_deref() {
63 let mut headers = reqwest::header::HeaderMap::new();
64 let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {t}"))
65 .map_err(|e| eyre!("invalid bearer token: {e}"))?;
66 headers.insert(reqwest::header::AUTHORIZATION, value);
67 http_builder = http_builder.default_headers(headers);
68 }
69 let http = http_builder
70 .build()
71 .map_err(|e| eyre!("failed to build http client: {e}"))?;
72 let inner = bee::Client::with_http_client(&url, http)
73 .map_err(|e| eyre!("invalid bee endpoint: {e}"))?;
74 Ok(Self {
75 name: node.name.clone(),
76 url,
77 authenticated,
78 inner,
79 })
80 }
81
82 /// Borrow the underlying [`bee::Client`] for direct API calls.
83 pub fn bee(&self) -> &bee::Client {
84 &self.inner
85 }
86
87 /// Health round-trip latency. Convenience over `self.bee().ping()`
88 /// for the connection-status indicator.
89 pub async fn ping(&self) -> color_eyre::Result<std::time::Duration> {
90 self.inner
91 .ping()
92 .await
93 .wrap_err_with(|| format!("ping {} ({}) failed", self.name, self.url))
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 fn node(url: &str, token: Option<&str>) -> NodeConfig {
102 NodeConfig {
103 name: "test".into(),
104 url: url.into(),
105 token: token.map(String::from),
106 default: false,
107 }
108 }
109
110 #[test]
111 fn from_node_strips_trailing_slash() {
112 let c = ApiClient::from_node(&node("http://localhost:1633/", None)).unwrap();
113 assert_eq!(c.url, "http://localhost:1633");
114 assert!(!c.authenticated);
115 }
116
117 #[test]
118 fn from_node_with_token_marks_authenticated() {
119 let c = ApiClient::from_node(&node("http://localhost:1633", Some("dummy-jwt"))).unwrap();
120 assert!(c.authenticated);
121 }
122
123 #[test]
124 fn from_node_rejects_bogus_url() {
125 let err = ApiClient::from_node(&node("not a url", None)).unwrap_err();
126 assert!(format!("{err}").contains("invalid"));
127 }
128}