Skip to main content

bee/
client.rs

1//! Top-level [`Client`] and the shared [`Inner`] HTTP plumbing.
2//!
3//! Mirrors bee-go's `bee.Client`. A `Client` is cheaply cloneable
4//! (`Arc<Inner>`) and yields per-domain handles via accessors:
5//!
6//! ```no_run
7//! # use bee::Client;
8//! # async fn run() -> Result<(), bee::Error> {
9//! let client = Client::new("http://localhost:1633")?;
10//! let health = client.debug().health().await?;
11//! # Ok(()) }
12//! ```
13
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17use bytes::{Bytes, BytesMut};
18use reqwest::{Method, RequestBuilder};
19use serde::de::DeserializeOwned;
20use url::Url;
21
22use crate::api::HeaderPairs;
23use crate::swarm::{Error, RESPONSE_BODY_CAP, redact_url};
24
25/// Maximum size of a structured JSON / NDJSON response body that the
26/// crate will buffer. Bee responses larger than this are rejected
27/// before the body is fully read; bulk file downloads should bypass
28/// the in-memory pipeline via [`crate::file::FileApi::download_file_response`].
29///
30/// 32 MiB matches bee-go's `swarm.MaxJSONResponseBytes` and bee-py's
31/// `MAX_RESPONSE_BYTES`.
32pub const MAX_JSON_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
33
34/// Shared HTTP/state used by every sub-service.
35#[derive(Debug)]
36pub(crate) struct Inner {
37    pub(crate) base_url: Url,
38    pub(crate) http: reqwest::Client,
39}
40
41impl Inner {
42    /// Resolve a path against the base URL. The base URL is normalized
43    /// to end with `/`, so `path` is treated as relative.
44    pub(crate) fn url(&self, path: &str) -> Result<Url, Error> {
45        self.base_url
46            .join(path)
47            .map_err(|e| Error::argument(format!("invalid url: {e}")))
48    }
49
50    /// Build a request, send it, and translate non-2xx responses into
51    /// [`Error::Response`] with method / URL / capped body captured.
52    ///
53    /// Emits `tracing::debug!` events at target `bee::http` carrying
54    /// `method`, `url`, `status`, and `elapsed_ms` for every request.
55    /// Subscribe with `RUST_LOG=bee::http=debug` (or any subscriber
56    /// that captures spans/events) to surface live API traffic — the
57    /// bee-tui command-log pane uses this.
58    pub(crate) async fn send(&self, builder: RequestBuilder) -> Result<reqwest::Response, Error> {
59        let request = builder.build()?;
60        let method = request.method().to_string();
61        // Redact query string and fragment before logging or storing in
62        // an error: Bee uses the query for SOC signatures (?sig=) and
63        // Act publisher keys (?recipient=); callers may also (mistakenly)
64        // put auth tokens there. The path itself is hex / identifier-only
65        // and considered public.
66        let redacted = redact_url(request.url());
67        let start = Instant::now();
68
69        let resp = self.http.execute(request).await?;
70        let elapsed_ms = start.elapsed().as_millis() as u64;
71        let status = resp.status().as_u16();
72
73        if resp.status().is_success() {
74            tracing::debug!(
75                target: "bee::http",
76                method = %method,
77                url = %redacted,
78                status,
79                elapsed_ms,
80                "bee api request"
81            );
82            return Ok(resp);
83        }
84        let status_text = format!(
85            "{status} {}",
86            resp.status().canonical_reason().unwrap_or("")
87        )
88        .trim_end()
89        .to_string();
90        let body = resp.bytes().await.map(|b| b.to_vec()).unwrap_or_default();
91        let n = body.len().min(RESPONSE_BODY_CAP);
92        tracing::debug!(
93            target: "bee::http",
94            method = %method,
95            url = %redacted,
96            status,
97            elapsed_ms,
98            body_len = body.len(),
99            "bee api error response"
100        );
101        Err(Error::Response {
102            method,
103            url: redacted,
104            status,
105            status_text,
106            body: body[..n].to_vec(),
107        })
108    }
109
110    /// Read the response body with a hard size cap. Use this anywhere
111    /// a structured (JSON / NDJSON) response is expected; bulk file
112    /// downloads should use [`reqwest::Response::bytes_stream`] or
113    /// [`reqwest::Response::chunk`] directly so the caller controls
114    /// the buffering policy.
115    ///
116    /// Rejects upfront based on `Content-Length` when present;
117    /// streams chunks otherwise and aborts as soon as the cap is
118    /// exceeded.
119    pub(crate) async fn read_capped(
120        mut resp: reqwest::Response,
121        max_bytes: usize,
122    ) -> Result<Bytes, Error> {
123        if let Some(len) = resp.content_length() {
124            if len > max_bytes as u64 {
125                return Err(Error::argument(format!(
126                    "response body exceeds limit ({len} > {max_bytes} bytes)"
127                )));
128            }
129        }
130        let mut buf = BytesMut::new();
131        while let Some(chunk) = resp.chunk().await? {
132            if buf.len() + chunk.len() > max_bytes {
133                return Err(Error::argument(format!(
134                    "response body exceeds limit (>{max_bytes} bytes)"
135                )));
136            }
137            buf.extend_from_slice(&chunk);
138        }
139        Ok(buf.freeze())
140    }
141
142    /// Send and parse the response body as JSON, capped at
143    /// [`MAX_JSON_RESPONSE_BYTES`].
144    pub(crate) async fn send_json<T: DeserializeOwned>(
145        &self,
146        builder: RequestBuilder,
147    ) -> Result<T, Error> {
148        let resp = self.send(builder).await?;
149        let bytes = Self::read_capped(resp, MAX_JSON_RESPONSE_BYTES).await?;
150        Ok(serde_json::from_slice(&bytes)?)
151    }
152
153    /// Apply a list of header pairs to a request builder.
154    pub(crate) fn apply_headers(builder: RequestBuilder, headers: HeaderPairs) -> RequestBuilder {
155        let mut b = builder;
156        for (name, value) in headers {
157            b = b.header(name, value);
158        }
159        b
160    }
161}
162
163/// Top-level Bee API client.
164#[derive(Clone, Debug)]
165pub struct Client {
166    pub(crate) inner: Arc<Inner>,
167}
168
169impl Client {
170    /// Construct a client from a base URL (e.g. `"http://localhost:1633"`).
171    /// A trailing slash is appended if missing so relative paths resolve
172    /// correctly.
173    pub fn new(url: &str) -> Result<Self, Error> {
174        let mut owned = url.to_owned();
175        if !owned.ends_with('/') {
176            owned.push('/');
177        }
178        let base_url =
179            Url::parse(&owned).map_err(|e| Error::argument(format!("invalid url: {e}")))?;
180        let http = reqwest::Client::builder()
181            .build()
182            .map_err(Error::Transport)?;
183        Ok(Self {
184            inner: Arc::new(Inner { base_url, http }),
185        })
186    }
187
188    /// Construct a client with a caller-provided [`reqwest::Client`].
189    /// Use this to share a connection pool with other code or to set
190    /// custom timeouts / TLS roots.
191    pub fn with_http_client(url: &str, http: reqwest::Client) -> Result<Self, Error> {
192        let mut owned = url.to_owned();
193        if !owned.ends_with('/') {
194            owned.push('/');
195        }
196        let base_url =
197            Url::parse(&owned).map_err(|e| Error::argument(format!("invalid url: {e}")))?;
198        Ok(Self {
199            inner: Arc::new(Inner { base_url, http }),
200        })
201    }
202
203    /// Construct a client that sends `Authorization: Bearer <token>`
204    /// on every request. Convenience for talking to a Bee node running
205    /// with restricted-mode auth.
206    ///
207    /// For more control (custom timeouts, TLS roots, additional
208    /// headers), build a [`reqwest::Client`] yourself and pass it via
209    /// [`Client::with_http_client`].
210    pub fn with_token(url: &str, token: &str) -> Result<Self, Error> {
211        use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
212        let value = HeaderValue::from_str(&format!("Bearer {token}"))
213            .map_err(|e| Error::argument(format!("invalid token: {e}")))?;
214        let mut headers = HeaderMap::new();
215        headers.insert(AUTHORIZATION, value);
216        let http = reqwest::Client::builder()
217            .default_headers(headers)
218            .build()
219            .map_err(Error::Transport)?;
220        Self::with_http_client(url, http)
221    }
222
223    /// Borrow the configured base URL.
224    pub fn base_url(&self) -> &Url {
225        &self.inner.base_url
226    }
227
228    /// `GET /health` round-trip latency. Useful for connection-status
229    /// indicators in dashboards and TUIs. Returns the elapsed
230    /// [`Duration`] regardless of body — the response is not parsed.
231    pub async fn ping(&self) -> Result<Duration, Error> {
232        let url = self.inner.url("health")?;
233        let builder = self.inner.http.request(Method::GET, url);
234        let start = Instant::now();
235        let _ = self.inner.send(builder).await?;
236        Ok(start.elapsed())
237    }
238
239    /// Sub-service: file / data / chunk / SOC / feed / collection
240    /// uploads and downloads.
241    pub fn file(&self) -> crate::file::FileApi {
242        crate::file::FileApi::new(self.inner.clone())
243    }
244
245    /// Sub-service: postage batch CRUD + stamp metadata. Stamp math
246    /// helpers live as free functions in [`crate::postage`].
247    pub fn postage(&self) -> crate::postage::PostageApi {
248        crate::postage::PostageApi::new(self.inner.clone())
249    }
250
251    /// Sub-service: debug / operator endpoints (health, versions,
252    /// peers, accounting, chequebook, stake).
253    pub fn debug(&self) -> crate::debug::DebugApi {
254        crate::debug::DebugApi::new(self.inner.clone())
255    }
256
257    /// Sub-service: generic `/api/*` endpoints (pin, tag, stewardship,
258    /// grantee, envelope).
259    pub fn api(&self) -> crate::api::ApiService {
260        crate::api::ApiService::new(self.inner.clone())
261    }
262
263    /// Sub-service: PSS send + websocket subscribe / receive.
264    pub fn pss(&self) -> crate::pss::PssApi {
265        crate::pss::PssApi::new(self.inner.clone())
266    }
267
268    /// Sub-service: GSOC send + websocket subscribe.
269    pub fn gsoc(&self) -> crate::gsoc::GsocApi {
270        crate::gsoc::GsocApi::new(self.inner.clone())
271    }
272}
273
274/// Shorthand: build a `RequestBuilder` for `(method, path)` against
275/// the inner HTTP client.
276pub(crate) fn request(inner: &Inner, method: Method, path: &str) -> Result<RequestBuilder, Error> {
277    let url = inner.url(path)?;
278    Ok(inner.http.request(method, url))
279}