Skip to main content

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}