cometbft_rpc/client/transport/
http.rs

1//! HTTP-based transport for CometBFT RPC Client.
2
3use core::{
4    convert::{TryFrom, TryInto},
5    str::FromStr,
6};
7
8use async_trait::async_trait;
9use reqwest::{header, Proxy};
10
11use cometbft::{block::Height, evidence::Evidence, Hash};
12use cometbft_config::net;
13
14use super::auth;
15use crate::prelude::*;
16use crate::{
17    client::{Client, CompatMode},
18    dialect::{v0_34, Dialect, LatestDialect},
19    endpoint,
20    query::Query,
21    request::RequestMessage,
22    response::Response,
23    Error, Order, Scheme, SimpleRequest, Url,
24};
25
26const USER_AGENT: &str = concat!("cometbft.rs/", env!("CARGO_PKG_VERSION"));
27
28/// A JSON-RPC/HTTP CometBFT RPC client (implements [`crate::Client`]).
29///
30/// Supports both HTTP and HTTPS connections to CometBFT RPC endpoints, and
31/// allows for the use of HTTP proxies (see [`HttpClient::new_with_proxy`] for
32/// details).
33///
34/// Does not provide [`crate::event::Event`] subscription facilities (see
35/// [`crate::WebSocketClient`] for a client that does).
36///
37/// ## Examples
38///
39/// ```rust,ignore
40/// use cometbft_rpc::{HttpClient, Client};
41///
42/// #[tokio::main]
43/// async fn main() {
44///     let client = HttpClient::new("http://127.0.0.1:26657")
45///         .unwrap();
46///
47///     let abci_info = client.abci_info()
48///         .await
49///         .unwrap();
50///
51///     println!("Got ABCI info: {:?}", abci_info);
52/// }
53/// ```
54#[derive(Debug, Clone)]
55pub struct HttpClient {
56    inner: reqwest::Client,
57    url: reqwest::Url,
58    compat: CompatMode,
59}
60
61/// The builder pattern constructor for [`HttpClient`].
62pub struct Builder {
63    url: HttpClientUrl,
64    compat: CompatMode,
65    proxy_url: Option<HttpClientUrl>,
66}
67
68impl Builder {
69    /// Use the specified compatibility mode for the CometBFT RPC protocol.
70    ///
71    /// The default is the latest protocol version supported by this crate.
72    pub fn compat_mode(mut self, mode: CompatMode) -> Self {
73        self.compat = mode;
74        self
75    }
76
77    /// Specify the URL of a proxy server for the client to connect through.
78    ///
79    /// If the RPC endpoint is secured (HTTPS), the proxy will automatically
80    /// attempt to connect using the [HTTP CONNECT] method.
81    ///
82    /// [HTTP CONNECT]: https://en.wikipedia.org/wiki/HTTP_tunnel
83    pub fn proxy_url(mut self, url: HttpClientUrl) -> Self {
84        self.proxy_url = Some(url);
85        self
86    }
87
88    /// Try to create a client with the options specified for this builder.
89    pub fn build(self) -> Result<HttpClient, Error> {
90        let builder = reqwest::ClientBuilder::new().user_agent(USER_AGENT);
91        let inner = match self.proxy_url {
92            None => builder.build().map_err(Error::http)?,
93            Some(proxy_url) => {
94                let proxy = if self.url.0.is_secure() {
95                    Proxy::https(reqwest::Url::from(proxy_url.0)).map_err(Error::invalid_proxy)?
96                } else {
97                    Proxy::http(reqwest::Url::from(proxy_url.0)).map_err(Error::invalid_proxy)?
98                };
99                builder.proxy(proxy).build().map_err(Error::http)?
100            },
101        };
102        Ok(HttpClient {
103            inner,
104            url: self.url.into(),
105            compat: self.compat,
106        })
107    }
108}
109
110impl HttpClient {
111    /// Construct a new CometBFT RPC HTTP/S client connecting to the given
112    /// URL.
113    pub fn new<U>(url: U) -> Result<Self, Error>
114    where
115        U: TryInto<HttpClientUrl, Error = Error>,
116    {
117        let url = url.try_into()?;
118        Self::builder(url).build()
119    }
120
121    /// Construct a new CometBFT RPC HTTP/S client connecting to the given
122    /// URL, but via the specified proxy's URL.
123    ///
124    /// If the RPC endpoint is secured (HTTPS), the proxy will automatically
125    /// attempt to connect using the [HTTP CONNECT] method.
126    ///
127    /// [HTTP CONNECT]: https://en.wikipedia.org/wiki/HTTP_tunnel
128    pub fn new_with_proxy<U, P>(url: U, proxy_url: P) -> Result<Self, Error>
129    where
130        U: TryInto<HttpClientUrl, Error = Error>,
131        P: TryInto<HttpClientUrl, Error = Error>,
132    {
133        let url = url.try_into()?;
134        Self::builder(url).proxy_url(proxy_url.try_into()?).build()
135    }
136
137    /// Initiate a builder for a CometBFT RPC HTTP/S client connecting
138    /// to the given URL, so that more configuration options can be specified
139    /// with the builder.
140    pub fn builder(url: HttpClientUrl) -> Builder {
141        Builder {
142            url,
143            compat: Default::default(),
144            proxy_url: None,
145        }
146    }
147
148    /// Set compatibility mode on the instantiated client.
149    ///
150    /// As the HTTP client is stateless and does not support subscriptions,
151    /// the protocol version it uses can be changed at will, for example,
152    /// as a result of version discovery over the `/status` endpoint.
153    pub fn set_compat_mode(&mut self, compat: CompatMode) {
154        self.compat = compat;
155    }
156
157    fn build_request<R>(&self, request: R) -> Result<reqwest::Request, Error>
158    where
159        R: RequestMessage,
160    {
161        let request_body = request.into_json();
162
163        tracing::debug!(url = %self.url, body = %request_body, "outgoing request");
164
165        let mut builder = self
166            .inner
167            .post(self.url.clone())
168            .header(header::CONTENT_TYPE, "application/json")
169            .body(request_body.into_bytes());
170
171        if let Some(auth) = auth::authorize(&self.url) {
172            builder = builder.header(header::AUTHORIZATION, auth.to_string());
173        }
174
175        builder.build().map_err(Error::http)
176    }
177
178    async fn perform_with_dialect<R, S>(&self, request: R, _dialect: S) -> Result<R::Output, Error>
179    where
180        R: SimpleRequest<S>,
181        S: Dialect,
182    {
183        let request = self.build_request(request)?;
184        let response = self.inner.execute(request).await.map_err(Error::http)?;
185        let response_status = response.status();
186        let response_body = response.bytes().await.map_err(Error::http)?;
187
188        tracing::debug!(
189            status = %response_status,
190            body = %String::from_utf8_lossy(&response_body),
191            "incoming response"
192        );
193
194        // Successful JSON-RPC requests are expected to return a 200 OK HTTP status.
195        // Otherwise, this means that the HTTP request failed as a whole,
196        // as opposed to the JSON-RPC request returning an error,
197        // and we cannot expect the response body to be a valid JSON-RPC response.
198        if response_status != reqwest::StatusCode::OK {
199            return Err(Error::http_request_failed(response_status));
200        }
201
202        R::Response::from_string(&response_body).map(Into::into)
203    }
204}
205
206#[async_trait]
207impl Client for HttpClient {
208    async fn perform<R>(&self, request: R) -> Result<R::Output, Error>
209    where
210        R: SimpleRequest,
211    {
212        self.perform_with_dialect(request, LatestDialect).await
213    }
214
215    async fn block_results<H>(&self, height: H) -> Result<endpoint::block_results::Response, Error>
216    where
217        H: Into<Height> + Send,
218    {
219        perform_with_compat!(self, endpoint::block_results::Request::new(height.into()))
220    }
221
222    async fn latest_block_results(&self) -> Result<endpoint::block_results::Response, Error> {
223        perform_with_compat!(self, endpoint::block_results::Request::default())
224    }
225
226    async fn header<H>(&self, height: H) -> Result<endpoint::header::Response, Error>
227    where
228        H: Into<Height> + Send,
229    {
230        let height = height.into();
231        match self.compat {
232            CompatMode::V0_37 => self.perform(endpoint::header::Request::new(height)).await,
233            CompatMode::V0_34 => {
234                // Back-fill with a request to /block endpoint and
235                // taking just the header from the response.
236                let resp = self
237                    .perform_with_dialect(endpoint::block::Request::new(height), v0_34::Dialect)
238                    .await?;
239                Ok(resp.into())
240            },
241        }
242    }
243
244    async fn header_by_hash(
245        &self,
246        hash: Hash,
247    ) -> Result<endpoint::header_by_hash::Response, Error> {
248        match self.compat {
249            CompatMode::V0_37 => {
250                self.perform(endpoint::header_by_hash::Request::new(hash))
251                    .await
252            },
253            CompatMode::V0_34 => {
254                // Back-fill with a request to /block_by_hash endpoint and
255                // taking just the header from the response.
256                let resp = self
257                    .perform_with_dialect(
258                        endpoint::block_by_hash::Request::new(hash),
259                        v0_34::Dialect,
260                    )
261                    .await?;
262                Ok(resp.into())
263            },
264        }
265    }
266
267    /// `/broadcast_evidence`: broadcast an evidence.
268    async fn broadcast_evidence(&self, e: Evidence) -> Result<endpoint::evidence::Response, Error> {
269        match self.compat {
270            CompatMode::V0_37 => self.perform(endpoint::evidence::Request::new(e)).await,
271            CompatMode::V0_34 => {
272                self.perform_with_dialect(endpoint::evidence::Request::new(e), v0_34::Dialect)
273                    .await
274            },
275        }
276    }
277
278    async fn tx(&self, hash: Hash, prove: bool) -> Result<endpoint::tx::Response, Error> {
279        perform_with_compat!(self, endpoint::tx::Request::new(hash, prove))
280    }
281
282    async fn tx_search(
283        &self,
284        query: Query,
285        prove: bool,
286        page: u32,
287        per_page: u8,
288        order: Order,
289    ) -> Result<endpoint::tx_search::Response, Error> {
290        perform_with_compat!(
291            self,
292            endpoint::tx_search::Request::new(query, prove, page, per_page, order)
293        )
294    }
295
296    async fn broadcast_tx_commit<T>(
297        &self,
298        tx: T,
299    ) -> Result<endpoint::broadcast::tx_commit::Response, Error>
300    where
301        T: Into<Vec<u8>> + Send,
302    {
303        perform_with_compat!(self, endpoint::broadcast::tx_commit::Request::new(tx))
304    }
305}
306
307/// A URL limited to use with HTTP clients.
308///
309/// Facilitates useful type conversions and inferences.
310#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
311pub struct HttpClientUrl(Url);
312
313impl TryFrom<Url> for HttpClientUrl {
314    type Error = Error;
315
316    fn try_from(value: Url) -> Result<Self, Error> {
317        match value.scheme() {
318            Scheme::Http | Scheme::Https => Ok(Self(value)),
319            _ => Err(Error::invalid_url(value)),
320        }
321    }
322}
323
324impl FromStr for HttpClientUrl {
325    type Err = Error;
326
327    fn from_str(s: &str) -> Result<Self, Error> {
328        let url: Url = s.parse()?;
329        url.try_into()
330    }
331}
332
333impl TryFrom<&str> for HttpClientUrl {
334    type Error = Error;
335
336    fn try_from(value: &str) -> Result<Self, Error> {
337        value.parse()
338    }
339}
340
341impl TryFrom<net::Address> for HttpClientUrl {
342    type Error = Error;
343
344    fn try_from(value: net::Address) -> Result<Self, Error> {
345        match value {
346            net::Address::Tcp {
347                peer_id: _,
348                host,
349                port,
350            } => format!("http://{host}:{port}").parse(),
351            net::Address::Unix { .. } => Err(Error::invalid_network_address()),
352        }
353    }
354}
355
356impl From<HttpClientUrl> for Url {
357    fn from(url: HttpClientUrl) -> Self {
358        url.0
359    }
360}
361
362impl From<HttpClientUrl> for url::Url {
363    fn from(url: HttpClientUrl) -> Self {
364        url.0.into()
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use core::str::FromStr;
371
372    use reqwest::{header::AUTHORIZATION, Request};
373
374    use super::HttpClient;
375    use crate::endpoint::abci_info;
376    use crate::Url;
377
378    fn authorization(req: &Request) -> Option<&str> {
379        req.headers()
380            .get(AUTHORIZATION)
381            .map(|h| h.to_str().unwrap())
382    }
383
384    #[test]
385    fn without_basic_auth() {
386        let url = Url::from_str("http://example.com").unwrap();
387        let client = HttpClient::new(url).unwrap();
388        let req = HttpClient::build_request(&client, abci_info::Request).unwrap();
389
390        assert_eq!(authorization(&req), None);
391    }
392
393    #[test]
394    fn with_basic_auth() {
395        let url = Url::from_str("http://toto:tata@example.com").unwrap();
396        let client = HttpClient::new(url).unwrap();
397        let req = HttpClient::build_request(&client, abci_info::Request).unwrap();
398
399        assert_eq!(authorization(&req), Some("Basic dG90bzp0YXRh"));
400    }
401}