1pub mod admin;
2pub mod config;
3pub mod errors;
4pub mod kvstore;
5pub mod rpc;
6pub mod sql;
7pub mod streams;
8pub mod webhooks;
9
10pub use admin::ToolingAccessStatus;
11pub use config::{
12 AdminConfig, CachedToken, ClientInfo, HttpConfig, KvStoreConfig, RpcConfig, SdkFullConfig,
13 SqlConfig, StreamsConfig, WebhooksConfig,
14};
15pub use kvstore::{
16 AddListItemParams, BulkSetsParams, CreateListParams, CreateSetParams, GetListData,
17 GetListParams, GetListResponse, GetListsData, GetListsParams, GetListsResponse, GetSetResponse,
18 GetSetsParams, GetSetsResponse, KvSetEntry, KvStoreApiClient, ListContainsItemResponse,
19 UpdateListParams,
20};
21pub use rpc::RpcApiClient;
22#[cfg(feature = "payments")]
23pub use rpc::{
24 generate_payment_wallet, ChainKind, CreditBalance, DripReceipt, GatewaySession,
25 GeneratedWallet, PaymentConfig, PaymentReceipt, PaymentScheme, RpcCallResponse,
26};
27#[cfg(feature = "payments-tempo")]
28pub use rpc::{ChannelState, ChannelStatus};
29pub use sql::{
30 ChainSchema, ColumnMeta, ColumnSchema, QueryParams, QueryResponse, QueryStatistics,
31 SqlApiClient, TableSchema,
32};
33
34use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
35use reqwest::Client as ReqwestClient;
36use std::sync::Arc;
37
38use errors::SdkError;
39
40const DEFAULT_TIMEOUT_SECS: u64 = 30;
41
42fn build_user_agent(info: &ClientInfo) -> String {
46 format!(
47 "quicknode-sdk-{lang}/{ver} ({os}-{arch}; {lang}-{lang_ver})",
48 lang = info.language,
49 ver = info.sdk_version,
50 os = std::env::consts::OS,
51 arch = std::env::consts::ARCH,
52 lang_ver = info.language_version,
53 )
54}
55
56fn default_rust_client_info() -> ClientInfo {
58 ClientInfo {
59 language: "rust".to_string(),
60 language_version: option_env!("CARGO_PKG_RUST_VERSION")
64 .unwrap_or("unknown")
65 .to_string(),
66 sdk_version: env!("CARGO_PKG_VERSION").to_string(),
67 }
68}
69
70#[derive(Clone)]
72pub struct SdkConfig(Arc<SdkConfigInner>);
73
74impl std::fmt::Debug for SdkConfig {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.debug_struct("SdkConfig")
77 .field("api_key", &"[redacted]")
78 .field("admin_base_url", &self.0.admin.base_url)
79 .field("streams_base_url", &self.0.streams.base_url)
80 .field("webhooks_base_url", &self.0.webhooks.base_url)
81 .field("kvstore_base_url", &self.0.kvstore.base_url)
82 .field("sql_base_url", &self.0.sql.base_url)
83 .finish()
84 }
85}
86
87struct SdkConfigInner {
88 http_client: ReqwestClient,
89 rpc_http_client: ReqwestClient,
92 admin: admin::ResolvedAdminConfig,
93 streams: streams::ResolvedStreamsConfig,
94 webhooks: webhooks::ResolvedWebhooksConfig,
95 kvstore: kvstore::ResolvedKvStoreConfig,
96 sql: sql::ResolvedSqlConfig,
97}
98
99impl SdkConfig {
100 pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
103 Self::new_with_client_info(config, None)
104 }
105
106 pub fn new_with_client_info(
112 config: &SdkFullConfig,
113 client_info: Option<ClientInfo>,
114 ) -> Result<Self, SdkError> {
115 let timeout_secs = match &config.http {
116 Some(h) => match h.timeout_secs {
117 Some(secs) if secs < 0 => {
118 return Err(SdkError::Config("timeout_secs must be non-negative".into()));
119 }
120 Some(secs) => secs as u64,
121 None => DEFAULT_TIMEOUT_SECS,
122 },
123 None => DEFAULT_TIMEOUT_SECS,
124 };
125 let pool_max_idle_per_host = config
126 .http
127 .as_ref()
128 .and_then(|http| http.pool_max_idle_per_host);
129
130 let make_builder = || {
133 let mut builder =
134 ReqwestClient::builder().timeout(std::time::Duration::from_secs(timeout_secs));
135 if let Some(max_idle) = pool_max_idle_per_host {
136 builder = builder.pool_max_idle_per_host(max_idle as usize);
137 }
138 builder
139 };
140
141 let mut common_headers = HeaderMap::new();
146 common_headers.insert(
147 reqwest::header::ACCEPT,
148 HeaderValue::from_static("application/json"),
149 );
150 common_headers.insert(
151 reqwest::header::CONTENT_TYPE,
152 HeaderValue::from_static("application/json"),
153 );
154 let ua = build_user_agent(&client_info.unwrap_or_else(default_rust_client_info));
155 common_headers.insert(
156 reqwest::header::USER_AGENT,
157 HeaderValue::from_str(&ua).map_err(|e| SdkError::Config(e.to_string()))?,
158 );
159
160 if let Some(http) = &config.http {
163 if let Some(custom) = &http.headers {
164 for (name, value) in custom {
165 let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
166 SdkError::Config(format!("invalid header name {name:?}: {e}"))
167 })?;
168 let header_value = HeaderValue::from_str(value).map_err(|e| {
169 SdkError::Config(format!("invalid header value for {name:?}: {e}"))
170 })?;
171 common_headers.insert(header_name, header_value);
172 }
173 }
174 }
175
176 let mut main_headers = HeaderMap::new();
187 if let Some(api_key) = config.api_key.as_deref() {
188 main_headers.insert(
189 "x-api-key",
190 HeaderValue::from_str(api_key).map_err(|e| SdkError::Config(e.to_string()))?,
191 );
192 }
193 main_headers.extend(common_headers.clone());
194 let http_client = make_builder()
195 .default_headers(main_headers)
196 .build()
197 .map_err(|e| SdkError::Config(e.to_string()))?;
198
199 let rpc_http_client = make_builder()
203 .default_headers(common_headers)
204 .build()
205 .map_err(|e| SdkError::Config(e.to_string()))?;
206
207 Ok(Self(Arc::new(SdkConfigInner {
208 http_client,
209 rpc_http_client,
210 admin: admin::ResolvedAdminConfig::from_config(config.admin.as_ref())?,
211 streams: streams::ResolvedStreamsConfig::from_config(config.streams.as_ref())?,
212 webhooks: webhooks::ResolvedWebhooksConfig::from_config(config.webhooks.as_ref())?,
213 kvstore: kvstore::ResolvedKvStoreConfig::from_config(config.kvstore.as_ref())?,
214 sql: sql::ResolvedSqlConfig::from_config(config.sql.as_ref())?,
215 })))
216 }
217
218 pub(crate) fn http_client(&self) -> &ReqwestClient {
219 &self.0.http_client
220 }
221
222 pub(crate) fn rpc_http_client(&self) -> &ReqwestClient {
223 &self.0.rpc_http_client
224 }
225
226 pub(crate) fn admin(&self) -> &admin::ResolvedAdminConfig {
227 &self.0.admin
228 }
229
230 pub(crate) fn streams(&self) -> &streams::ResolvedStreamsConfig {
231 &self.0.streams
232 }
233
234 pub(crate) fn webhooks(&self) -> &webhooks::ResolvedWebhooksConfig {
235 &self.0.webhooks
236 }
237
238 pub(crate) fn kvstore(&self) -> &kvstore::ResolvedKvStoreConfig {
239 &self.0.kvstore
240 }
241
242 pub(crate) fn sql(&self) -> &sql::ResolvedSqlConfig {
243 &self.0.sql
244 }
245}
246
247pub struct QuicknodeSdk {
250 pub admin: admin::AdminApiClient,
253 pub streams: streams::StreamsApiClient,
255 pub webhooks: webhooks::WebhooksApiClient,
257 pub kvstore: kvstore::KvStoreApiClient,
260 pub sql: sql::SqlApiClient,
263 pub rpc: rpc::RpcApiClient,
266}
267
268impl QuicknodeSdk {
269 pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
271 Self::new_with_client_info(config, None)
272 }
273
274 pub fn new_with_client_info(
278 config: &SdkFullConfig,
279 client_info: Option<ClientInfo>,
280 ) -> Result<Self, SdkError> {
281 let sdk_config = SdkConfig::new_with_client_info(config, client_info)?;
282 Ok(Self {
283 admin: admin::AdminApiClient::new(sdk_config.clone()),
284 streams: streams::StreamsApiClient::new(sdk_config.clone()),
285 webhooks: webhooks::WebhooksApiClient::new(sdk_config.clone()),
286 kvstore: kvstore::KvStoreApiClient::new(sdk_config.clone()),
287 sql: sql::SqlApiClient::new(sdk_config.clone()),
288 rpc: rpc::RpcApiClient::new(sdk_config, config.rpc.as_ref()),
289 })
290 }
291
292 pub fn from_env() -> Result<Self, SdkError> {
294 Self::new(&SdkFullConfig::from_env()?)
295 }
296
297 pub fn from_env_with_client_info(client_info: Option<ClientInfo>) -> Result<Self, SdkError> {
299 Self::new_with_client_info(&SdkFullConfig::from_env()?, client_info)
300 }
301}
302
303#[cfg(test)]
304#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
305mod headers_tests {
306 use super::*;
307 use std::collections::HashMap;
308 use wiremock::matchers::{header, method, path};
309 use wiremock::{Mock, MockServer, ResponseTemplate};
310
311 fn base_config(api_key: &str) -> SdkFullConfig {
312 SdkFullConfig {
313 api_key: Some(api_key.to_string()),
314 http: None,
315 admin: None,
316 streams: None,
317 webhooks: None,
318 kvstore: None,
319 sql: None,
320 rpc: None,
321 }
322 }
323
324 fn binding_info() -> ClientInfo {
325 ClientInfo {
326 language: "python".to_string(),
327 language_version: "3.12.4".to_string(),
328 sdk_version: "1.2.3".to_string(),
329 }
330 }
331
332 #[test]
333 fn default_user_agent_identifies_rust_core() {
334 let ua = build_user_agent(&default_rust_client_info());
335 assert!(ua.starts_with("quicknode-sdk-rust/"));
336 assert!(ua.contains(env!("CARGO_PKG_VERSION")));
337 assert!(ua.contains(std::env::consts::OS));
338 assert!(ua.contains(std::env::consts::ARCH));
339 }
340
341 #[test]
342 fn binding_user_agent_identifies_language() {
343 let ua = build_user_agent(&binding_info());
344 let expected_prefix = "quicknode-sdk-python/1.2.3";
345 assert!(ua.starts_with(expected_prefix), "got: {ua}");
346 assert!(ua.contains("python-3.12.4"));
347 }
348
349 #[test]
350 fn invalid_custom_header_name_errors() {
351 let mut cfg = base_config("k");
352 let mut h = HashMap::new();
353 h.insert("bad header".to_string(), "v".to_string());
354 cfg.http = Some(HttpConfig {
355 timeout_secs: None,
356 pool_max_idle_per_host: None,
357 headers: Some(h),
358 });
359 assert!(matches!(SdkConfig::new(&cfg), Err(SdkError::Config(_))));
360 }
361
362 #[test]
363 fn invalid_custom_header_value_errors() {
364 let mut cfg = base_config("k");
365 let mut h = HashMap::new();
366 h.insert("X-Test".to_string(), "bad\nvalue".to_string());
368 cfg.http = Some(HttpConfig {
369 timeout_secs: None,
370 pool_max_idle_per_host: None,
371 headers: Some(h),
372 });
373 assert!(matches!(SdkConfig::new(&cfg), Err(SdkError::Config(_))));
374 }
375
376 #[tokio::test]
377 async fn default_user_agent_reaches_wire_and_custom_headers_override() {
378 let server = MockServer::start().await;
379 Mock::given(method("GET"))
380 .and(path("/endpoints"))
381 .and(header("user-agent", "custom-ua/9.9"))
382 .and(header("x-correlation-id", "abc"))
383 .and(header("x-api-key", "override-key"))
385 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
386 "data": [], "error": null, "pagination": null
387 })))
388 .mount(&server)
389 .await;
390
391 let mut headers = HashMap::new();
392 headers.insert("User-Agent".to_string(), "custom-ua/9.9".to_string());
393 headers.insert("X-Correlation-Id".to_string(), "abc".to_string());
394 headers.insert("x-api-key".to_string(), "override-key".to_string());
395
396 let cfg = SdkFullConfig {
397 api_key: Some("real-key".to_string()),
398 http: Some(HttpConfig {
399 timeout_secs: None,
400 pool_max_idle_per_host: None,
401 headers: Some(headers),
402 }),
403 admin: Some(AdminConfig {
404 base_url: Some(format!("{}/", server.uri())),
405 }),
406 streams: None,
407 webhooks: None,
408 kvstore: None,
409 sql: None,
410 rpc: None,
411 };
412
413 let sdk = QuicknodeSdk::new(&cfg).unwrap();
414 sdk.admin
415 .get_endpoints(&admin::GetEndpointsRequest::default())
416 .await
417 .unwrap();
418 }
419}