1use std::sync::Arc;
2use std::time::Duration;
3
4use serde::de::DeserializeOwned;
5use serde_json::Value;
6
7use crate::api::{
8 AccountByKeyApi, Blockchain, BroadcastApi, DatabaseApi, HivemindApi, RcApi,
9 TransactionStatusApi,
10};
11use crate::error::Result;
12use crate::transport::{BackoffStrategy, FailoverTransport};
13use crate::types::ChainId;
14
15#[derive(Debug, Clone)]
16pub struct ClientOptions {
17 pub timeout: Duration,
18 pub failover_threshold: u32,
19 pub address_prefix: String,
20 pub chain_id: ChainId,
21 pub backoff: BackoffStrategy,
22}
23
24impl Default for ClientOptions {
25 fn default() -> Self {
26 #[cfg(feature = "testnet")]
27 let chain_id = ChainId::testnet();
28
29 #[cfg(not(feature = "testnet"))]
30 let chain_id = ChainId::mainnet();
31
32 Self {
33 timeout: Duration::from_secs(10),
34 failover_threshold: 3,
35 address_prefix: "STM".to_string(),
36 chain_id,
37 backoff: BackoffStrategy::default(),
38 }
39 }
40}
41
42#[derive(Debug)]
43pub(crate) struct ClientInner {
44 transport: Arc<FailoverTransport>,
45 options: ClientOptions,
46}
47
48impl ClientInner {
49 pub(crate) fn new(transport: Arc<FailoverTransport>, options: ClientOptions) -> Self {
50 Self { transport, options }
51 }
52
53 pub(crate) async fn call<T: DeserializeOwned>(
54 &self,
55 api: &str,
56 method: &str,
57 params: Value,
58 ) -> Result<T> {
59 self.transport.call(api, method, params).await
60 }
61
62 pub(crate) fn options(&self) -> &ClientOptions {
63 &self.options
64 }
65}
66
67#[derive(Debug, Clone)]
68pub struct Client {
69 inner: Arc<ClientInner>,
70
71 pub database: DatabaseApi,
72 pub broadcast: BroadcastApi,
73 pub blockchain: Blockchain,
74 pub hivemind: HivemindApi,
75 pub rc: RcApi,
76 pub keys: AccountByKeyApi,
77 pub transaction: TransactionStatusApi,
78}
79
80impl Client {
81 pub fn new(nodes: Vec<&str>, options: ClientOptions) -> Self {
82 let node_urls = nodes.into_iter().map(str::to_string).collect::<Vec<_>>();
83 assert!(!node_urls.is_empty(), "at least one node URL is required");
84
85 let transport = Arc::new(
86 FailoverTransport::new(
87 &node_urls,
88 options.timeout,
89 options.failover_threshold,
90 options.backoff.clone(),
91 )
92 .expect("failed to initialize transport"),
93 );
94
95 let inner = Arc::new(ClientInner::new(transport, options));
96
97 Self {
98 database: DatabaseApi::new(inner.clone()),
99 broadcast: BroadcastApi::new(inner.clone()),
100 blockchain: Blockchain::new(inner.clone()),
101 hivemind: HivemindApi::new(inner.clone()),
102 rc: RcApi::new(inner.clone()),
103 keys: AccountByKeyApi::new(inner.clone()),
104 transaction: TransactionStatusApi::new(inner.clone()),
105 inner,
106 }
107 }
108
109 pub fn new_default() -> Self {
110 Self::new(
111 vec!["https://api.hive.blog", "https://api.openhive.network"],
112 ClientOptions::default(),
113 )
114 }
115
116 pub fn options(&self) -> &ClientOptions {
117 self.inner.options()
118 }
119
120 pub async fn call<T: DeserializeOwned>(
121 &self,
122 api: &str,
123 method: &str,
124 params: Value,
125 ) -> Result<T> {
126 self.inner.call(api, method, params).await
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use serde_json::json;
133 use wiremock::matchers::{body_partial_json, method};
134 use wiremock::{Mock, MockServer, ResponseTemplate};
135
136 use crate::client::{Client, ClientOptions};
137
138 #[tokio::test]
139 async fn raw_call_routes_through_transport() {
140 let server = MockServer::start().await;
141 Mock::given(method("POST"))
142 .and(body_partial_json(json!({
143 "method": "call",
144 "params": ["condenser_api", "get_config", []]
145 })))
146 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
147 "id": 0,
148 "jsonrpc": "2.0",
149 "result": {
150 "ok": true
151 }
152 })))
153 .mount(&server)
154 .await;
155
156 let client = Client::new(vec![&server.uri()], ClientOptions::default());
157 let value: serde_json::Value = client
158 .call("condenser_api", "get_config", json!([]))
159 .await
160 .expect("call should succeed");
161 assert_eq!(value["ok"], json!(true));
162 }
163
164 #[tokio::test]
165 async fn database_api_is_wired_to_client() {
166 let server = MockServer::start().await;
167 Mock::given(method("POST"))
168 .and(body_partial_json(json!({
169 "method": "call",
170 "params": ["condenser_api", "get_account_count", []]
171 })))
172 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
173 "id": 0,
174 "jsonrpc": "2.0",
175 "result": 1337
176 })))
177 .mount(&server)
178 .await;
179
180 let client = Client::new(vec![&server.uri()], ClientOptions::default());
181 let count = client
182 .database
183 .get_account_count()
184 .await
185 .expect("database call should succeed");
186 assert_eq!(count, 1337);
187 }
188}