1use std::env;
13use std::fmt::{self, Debug};
14use std::sync::LazyLock;
15use std::time::Duration;
16
17use anyhow::bail;
18use futures::future::Either;
19use http::{HeaderMap, HeaderValue, header};
20use jsonrpsee::core::ClientError;
21use jsonrpsee::core::client::ClientT as _;
22use jsonrpsee::core::params::{ArrayParams, ObjectParams};
23use jsonrpsee::core::traits::ToRpcParams;
24use serde::de::DeserializeOwned;
25use tracing::{Instrument, Level, debug};
26use url::Url;
27
28use super::{ApiPaths, MAX_REQUEST_BODY_SIZE, MAX_RESPONSE_BODY_SIZE, Request};
29
30static USER_AGENT: LazyLock<HeaderValue> = LazyLock::new(|| {
35 HeaderValue::from_str(&format!(
36 "forest/{}",
37 crate::utils::version::FOREST_VERSION_STRING.as_str()
38 ))
39 .expect("Forest version string is a valid header value")
40});
41
42pub struct Client {
44 base_url: Url,
46 token: Option<String>,
47 v0: tokio::sync::OnceCell<UrlClient>,
49 v1: tokio::sync::OnceCell<UrlClient>,
50 v2: tokio::sync::OnceCell<UrlClient>,
51}
52
53impl Client {
54 pub fn default_or_from_env(token: Option<&str>) -> anyhow::Result<Self> {
58 static DEFAULT: LazyLock<Url> = LazyLock::new(|| {
59 "http://127.0.0.1:2345/"
60 .parse()
61 .expect("hardcoded default RPC URL is valid")
62 });
63
64 let mut base_url = match env::var("FULLNODE_API_INFO") {
65 Ok(it) => {
66 let crate::utils::UrlFromMultiAddr(url) = it.parse()?;
67 url
68 }
69 Err(env::VarError::NotPresent) => DEFAULT.clone(),
70 Err(e @ env::VarError::NotUnicode(_)) => bail!(e),
71 };
72 if token.is_some() && base_url.set_password(token).is_err() {
73 bail!("couldn't set override password")
74 }
75 if token.is_none() && base_url.password().is_none() {
77 let default_token_path = crate::cli_shared::default_token_path();
80 if default_token_path.is_file() {
81 if let Ok(token) = std::fs::read_to_string(&default_token_path) {
82 if base_url.set_password(Some(token.trim())).is_ok() {
83 tracing::debug!("Loaded the default RPC token");
84 } else {
85 tracing::warn!("Failed to set the default RPC token");
86 }
87 } else {
88 tracing::warn!("Failed to load the default token file");
89 }
90 }
91 }
92 Ok(Self::from_url(base_url))
93 }
94 pub fn from_url(mut base_url: Url) -> Self {
95 let token = base_url.password().map(Into::into);
96 let _defer = base_url.set_password(None);
97 Self {
98 token,
99 base_url,
100 v0: Default::default(),
101 v1: Default::default(),
102 v2: Default::default(),
103 }
104 }
105 pub fn base_url(&self) -> &Url {
106 &self.base_url
107 }
108 pub async fn call<T: crate::lotus_json::HasLotusJson + std::fmt::Debug>(
109 &self,
110 req: Request<T>,
111 ) -> Result<T, ClientError> {
112 let api_path = req.api_path;
113 let Request {
114 method_name,
115 params,
116 timeout,
117 ..
118 } = req;
119 let method_name = method_name.as_ref();
120 let client = self.get_or_init_client(api_path).await?;
121 let span = tracing::debug_span!("request", method = %method_name, url = %client.url);
122 let work = async {
123 let result_or_timeout = tokio::time::timeout(
127 timeout,
128 match params {
129 serde_json::Value::Null => Either::Left(Either::Left(
130 client.request::<T::LotusJson, _>(method_name, ArrayParams::new()),
131 )),
132 serde_json::Value::Array(it) => {
133 let mut params = ArrayParams::new();
134 for param in it {
135 params.insert(param)?
136 }
137 trace_params(params.clone());
138 Either::Left(Either::Right(client.request(method_name, params)))
139 }
140 serde_json::Value::Object(it) => {
141 let mut params = ObjectParams::new();
142 for (name, param) in it {
143 params.insert(&name, param)?
144 }
145 trace_params(params.clone());
146 Either::Right(client.request(method_name, params))
147 }
148 prim @ (serde_json::Value::Bool(_)
149 | serde_json::Value::Number(_)
150 | serde_json::Value::String(_)) => {
151 return Err(ClientError::Custom(format!(
152 "invalid parameter type: `{prim}`"
153 )));
154 }
155 },
156 )
157 .await;
158 let result = match result_or_timeout {
159 Ok(Ok(it)) => Ok(T::from_lotus_json(it)),
160 Ok(Err(e)) => Err(e),
161 Err(_) => Err(ClientError::RequestTimeout),
162 };
163 debug!(?result);
164 result
165 };
166 work.instrument(span.or_current()).await
167 }
168 async fn get_or_init_client(&self, path: ApiPaths) -> Result<&UrlClient, ClientError> {
169 match path {
170 ApiPaths::V0 => &self.v0,
171 ApiPaths::V1 => &self.v1,
172 ApiPaths::V2 => &self.v2,
173 }
174 .get_or_try_init(|| async {
175 let url = self.base_url.join(path.path()).map_err(|it| {
176 ClientError::Custom(format!("creating url for endpoint failed: {it}"))
177 })?;
178 UrlClient::new(url, self.token.clone()).await
179 })
180 .await
181 }
182}
183
184fn trace_params(params: impl jsonrpsee::core::traits::ToRpcParams) {
185 if tracing::enabled!(Level::TRACE) {
186 match params.to_rpc_params() {
187 Ok(Some(it)) => tracing::trace!(params = %it),
188 Ok(None) => tracing::trace!("no params"),
189 Err(error) => tracing::trace!(%error, "couldn't decode params"),
190 }
191 }
192}
193
194pub struct UrlClient {
197 url: Url,
198 inner: UrlClientInner,
199}
200
201impl Debug for UrlClient {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 f.debug_struct("OneClient")
204 .field("url", &self.url)
205 .finish_non_exhaustive()
206 }
207}
208
209impl UrlClient {
210 pub async fn new(url: Url, token: impl Into<Option<String>>) -> Result<Self, ClientError> {
211 const ONE_DAY: Duration = Duration::from_secs(24 * 3600); let mut headers = HeaderMap::from_iter([(header::USER_AGENT, USER_AGENT.clone())]);
213 if let Some(token) = token.into() {
214 let value = HeaderValue::try_from(format!("Bearer {token}"))
215 .map_err(|e| ClientError::Custom(format!("Invalid authorization token: {e}")))?;
216 headers.insert(header::AUTHORIZATION, value);
217 }
218 let inner = match url.scheme() {
219 "ws" | "wss" => UrlClientInner::Ws(
220 jsonrpsee::ws_client::WsClientBuilder::new()
221 .set_headers(headers)
222 .max_request_size(MAX_REQUEST_BODY_SIZE)
223 .max_response_size(*MAX_RESPONSE_BODY_SIZE)
224 .request_timeout(ONE_DAY)
225 .build(&url)
226 .await?,
227 ),
228 "http" | "https" => UrlClientInner::Https(
229 jsonrpsee::http_client::HttpClientBuilder::new()
230 .set_headers(headers)
231 .max_request_size(MAX_REQUEST_BODY_SIZE)
232 .max_response_size(*MAX_RESPONSE_BODY_SIZE)
233 .request_timeout(ONE_DAY)
234 .build(&url)?,
235 ),
236 it => {
237 return Err(ClientError::Custom(format!("Unsupported URL scheme: {it}")));
238 }
239 };
240 Ok(Self { url, inner })
241 }
242}
243
244#[allow(clippy::large_enum_variant)]
245enum UrlClientInner {
246 Ws(jsonrpsee::ws_client::WsClient),
247 Https(jsonrpsee::http_client::HttpClient),
248}
249
250impl jsonrpsee::core::client::ClientT for UrlClient {
251 fn notification<Params>(
252 &self,
253 method: &str,
254 params: Params,
255 ) -> impl Future<Output = Result<(), jsonrpsee::core::client::Error>> + Send
256 where
257 Params: ToRpcParams + Send,
258 {
259 match &self.inner {
260 UrlClientInner::Ws(it) => Either::Left(it.notification(method, params)),
261 UrlClientInner::Https(it) => Either::Right(it.notification(method, params)),
262 }
263 }
264
265 fn request<R, Params>(
266 &self,
267 method: &str,
268 params: Params,
269 ) -> impl Future<Output = Result<R, jsonrpsee::core::client::Error>> + Send
270 where
271 R: DeserializeOwned,
272 Params: ToRpcParams + Send,
273 {
274 match &self.inner {
275 UrlClientInner::Ws(it) => Either::Left(it.request(method, params)),
276 UrlClientInner::Https(it) => Either::Right(it.request(method, params)),
277 }
278 }
279
280 fn batch_request<'a, R>(
281 &self,
282 batch: jsonrpsee::core::params::BatchRequestBuilder<'a>,
283 ) -> impl Future<
284 Output = Result<
285 jsonrpsee::core::client::BatchResponse<'a, R>,
286 jsonrpsee::core::client::Error,
287 >,
288 > + Send
289 where
290 R: DeserializeOwned + fmt::Debug + 'a,
291 {
292 match &self.inner {
293 UrlClientInner::Ws(it) => Either::Left(it.batch_request(batch)),
294 UrlClientInner::Https(it) => Either::Right(it.batch_request(batch)),
295 }
296 }
297}
298
299impl jsonrpsee::core::client::SubscriptionClientT for UrlClient {
300 fn subscribe<'a, N, Params>(
301 &self,
302 subscribe_method: &'a str,
303 params: Params,
304 unsubscribe_method: &'a str,
305 ) -> impl Future<
306 Output = Result<jsonrpsee::core::client::Subscription<N>, jsonrpsee::core::client::Error>,
307 >
308 where
309 Params: ToRpcParams + Send,
310 N: DeserializeOwned,
311 {
312 match &self.inner {
313 UrlClientInner::Ws(it) => {
314 Either::Left(it.subscribe(subscribe_method, params, unsubscribe_method))
315 }
316 UrlClientInner::Https(it) => {
317 Either::Right(it.subscribe(subscribe_method, params, unsubscribe_method))
318 }
319 }
320 }
321
322 fn subscribe_to_method<N>(
323 &self,
324 method: &str,
325 ) -> impl Future<
326 Output = Result<jsonrpsee::core::client::Subscription<N>, jsonrpsee::core::client::Error>,
327 >
328 where
329 N: DeserializeOwned,
330 {
331 match &self.inner {
332 UrlClientInner::Ws(it) => Either::Left(it.subscribe_to_method(method)),
333 UrlClientInner::Https(it) => Either::Right(it.subscribe_to_method(method)),
334 }
335 }
336}
337
338pub fn humanize_rpc_error(e: anyhow::Error) -> anyhow::Error {
341 match e.downcast_ref::<ClientError>() {
342 Some(ClientError::Call(obj)) => {
343 let contexts: Vec<String> = e
344 .chain()
345 .take_while(|cause| cause.downcast_ref::<ClientError>().is_none())
346 .map(ToString::to_string)
347 .collect();
348 let mut out = match obj.data() {
349 Some(data) => anyhow::anyhow!("{} (data: {data})", obj.message()),
350 None => anyhow::anyhow!("{}", obj.message()),
351 };
352 for context in contexts.into_iter().rev() {
353 out = out.context(context);
354 }
355 out
356 }
357 _ => e,
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::cli_shared::FOREST_DATA_DIR_ENV;
365 use jsonrpsee::types::ErrorObject;
366
367 fn call_error() -> ClientError {
368 ClientError::Call(ErrorObject::owned(1, "export already running", None::<()>))
369 }
370
371 #[test]
372 fn rpc_call_errors_render_as_their_message() {
373 assert_eq!(
374 format!("{:#}", humanize_rpc_error(call_error().into())),
375 "export already running"
376 );
377
378 let wrapped = anyhow::Error::from(call_error()).context("failed to check F3 sync status");
379 assert_eq!(
380 format!("{:#}", humanize_rpc_error(wrapped)),
381 "failed to check F3 sync status: export already running"
382 );
383
384 let other = anyhow::anyhow!("inner").context("outer");
386 assert_eq!(format!("{:#}", humanize_rpc_error(other)), "outer: inner");
387 }
388
389 #[test]
393 #[serial_test::serial]
394 fn default_token_is_loaded_from_forest_path_data_dir() {
395 let tmp_dir = tempfile::tempdir().unwrap();
396 std::fs::write(tmp_dir.path().join("token"), "secret-token").unwrap();
397
398 unsafe {
399 env::remove_var("FULLNODE_API_INFO");
400 env::set_var(FOREST_DATA_DIR_ENV, tmp_dir.path());
401 }
402 let client = Client::default_or_from_env(None).unwrap();
403 unsafe { env::remove_var(FOREST_DATA_DIR_ENV) };
404
405 assert_eq!(client.token.as_deref(), Some("secret-token"));
406 }
407}