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;
22pub use sql::{
23 ChainSchema, ColumnMeta, ColumnSchema, QueryParams, QueryResponse, QueryStatistics,
24 SqlApiClient, TableSchema,
25};
26
27use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
28use reqwest::Client as ReqwestClient;
29use std::sync::Arc;
30
31use errors::SdkError;
32
33const DEFAULT_TIMEOUT_SECS: u64 = 30;
34
35fn build_user_agent(info: &ClientInfo) -> String {
39 format!(
40 "quicknode-sdk-{lang}/{ver} ({os}-{arch}; {lang}-{lang_ver})",
41 lang = info.language,
42 ver = info.sdk_version,
43 os = std::env::consts::OS,
44 arch = std::env::consts::ARCH,
45 lang_ver = info.language_version,
46 )
47}
48
49fn default_rust_client_info() -> ClientInfo {
51 ClientInfo {
52 language: "rust".to_string(),
53 language_version: option_env!("CARGO_PKG_RUST_VERSION")
57 .unwrap_or("unknown")
58 .to_string(),
59 sdk_version: env!("CARGO_PKG_VERSION").to_string(),
60 }
61}
62
63#[derive(Clone)]
65pub struct SdkConfig(Arc<SdkConfigInner>);
66
67impl std::fmt::Debug for SdkConfig {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("SdkConfig")
70 .field("api_key", &"[redacted]")
71 .field("admin_base_url", &self.0.admin.base_url)
72 .field("streams_base_url", &self.0.streams.base_url)
73 .field("webhooks_base_url", &self.0.webhooks.base_url)
74 .field("kvstore_base_url", &self.0.kvstore.base_url)
75 .field("sql_base_url", &self.0.sql.base_url)
76 .finish()
77 }
78}
79
80struct SdkConfigInner {
81 http_client: ReqwestClient,
82 rpc_http_client: ReqwestClient,
85 admin: admin::ResolvedAdminConfig,
86 streams: streams::ResolvedStreamsConfig,
87 webhooks: webhooks::ResolvedWebhooksConfig,
88 kvstore: kvstore::ResolvedKvStoreConfig,
89 sql: sql::ResolvedSqlConfig,
90}
91
92impl SdkConfig {
93 pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
96 Self::new_with_client_info(config, None)
97 }
98
99 pub fn new_with_client_info(
105 config: &SdkFullConfig,
106 client_info: Option<ClientInfo>,
107 ) -> Result<Self, SdkError> {
108 let timeout_secs = match &config.http {
109 Some(h) => match h.timeout_secs {
110 Some(secs) if secs < 0 => {
111 return Err(SdkError::Config("timeout_secs must be non-negative".into()));
112 }
113 Some(secs) => secs as u64,
114 None => DEFAULT_TIMEOUT_SECS,
115 },
116 None => DEFAULT_TIMEOUT_SECS,
117 };
118 let pool_max_idle_per_host = config
119 .http
120 .as_ref()
121 .and_then(|http| http.pool_max_idle_per_host);
122
123 let make_builder = || {
126 let mut builder =
127 ReqwestClient::builder().timeout(std::time::Duration::from_secs(timeout_secs));
128 if let Some(max_idle) = pool_max_idle_per_host {
129 builder = builder.pool_max_idle_per_host(max_idle as usize);
130 }
131 builder
132 };
133
134 let mut common_headers = HeaderMap::new();
139 common_headers.insert(
140 reqwest::header::ACCEPT,
141 HeaderValue::from_static("application/json"),
142 );
143 common_headers.insert(
144 reqwest::header::CONTENT_TYPE,
145 HeaderValue::from_static("application/json"),
146 );
147 let ua = build_user_agent(&client_info.unwrap_or_else(default_rust_client_info));
148 common_headers.insert(
149 reqwest::header::USER_AGENT,
150 HeaderValue::from_str(&ua).map_err(|e| SdkError::Config(e.to_string()))?,
151 );
152
153 if let Some(http) = &config.http {
156 if let Some(custom) = &http.headers {
157 for (name, value) in custom {
158 let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
159 SdkError::Config(format!("invalid header name {name:?}: {e}"))
160 })?;
161 let header_value = HeaderValue::from_str(value).map_err(|e| {
162 SdkError::Config(format!("invalid header value for {name:?}: {e}"))
163 })?;
164 common_headers.insert(header_name, header_value);
165 }
166 }
167 }
168
169 let mut main_headers = HeaderMap::new();
175 main_headers.insert(
176 "x-api-key",
177 HeaderValue::from_str(&config.api_key).map_err(|e| SdkError::Config(e.to_string()))?,
178 );
179 main_headers.extend(common_headers.clone());
180 let http_client = make_builder()
181 .default_headers(main_headers)
182 .build()
183 .map_err(|e| SdkError::Config(e.to_string()))?;
184
185 let rpc_http_client = make_builder()
189 .default_headers(common_headers)
190 .build()
191 .map_err(|e| SdkError::Config(e.to_string()))?;
192
193 Ok(Self(Arc::new(SdkConfigInner {
194 http_client,
195 rpc_http_client,
196 admin: admin::ResolvedAdminConfig::from_config(config.admin.as_ref())?,
197 streams: streams::ResolvedStreamsConfig::from_config(config.streams.as_ref())?,
198 webhooks: webhooks::ResolvedWebhooksConfig::from_config(config.webhooks.as_ref())?,
199 kvstore: kvstore::ResolvedKvStoreConfig::from_config(config.kvstore.as_ref())?,
200 sql: sql::ResolvedSqlConfig::from_config(config.sql.as_ref())?,
201 })))
202 }
203
204 pub(crate) fn http_client(&self) -> &ReqwestClient {
205 &self.0.http_client
206 }
207
208 pub(crate) fn rpc_http_client(&self) -> &ReqwestClient {
209 &self.0.rpc_http_client
210 }
211
212 pub(crate) fn admin(&self) -> &admin::ResolvedAdminConfig {
213 &self.0.admin
214 }
215
216 pub(crate) fn streams(&self) -> &streams::ResolvedStreamsConfig {
217 &self.0.streams
218 }
219
220 pub(crate) fn webhooks(&self) -> &webhooks::ResolvedWebhooksConfig {
221 &self.0.webhooks
222 }
223
224 pub(crate) fn kvstore(&self) -> &kvstore::ResolvedKvStoreConfig {
225 &self.0.kvstore
226 }
227
228 pub(crate) fn sql(&self) -> &sql::ResolvedSqlConfig {
229 &self.0.sql
230 }
231}
232
233pub struct QuicknodeSdk {
236 pub admin: admin::AdminApiClient,
239 pub streams: streams::StreamsApiClient,
241 pub webhooks: webhooks::WebhooksApiClient,
243 pub kvstore: kvstore::KvStoreApiClient,
246 pub sql: sql::SqlApiClient,
249 pub rpc: rpc::RpcApiClient,
252}
253
254impl QuicknodeSdk {
255 pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
257 Self::new_with_client_info(config, None)
258 }
259
260 pub fn new_with_client_info(
264 config: &SdkFullConfig,
265 client_info: Option<ClientInfo>,
266 ) -> Result<Self, SdkError> {
267 let sdk_config = SdkConfig::new_with_client_info(config, client_info)?;
268 Ok(Self {
269 admin: admin::AdminApiClient::new(sdk_config.clone()),
270 streams: streams::StreamsApiClient::new(sdk_config.clone()),
271 webhooks: webhooks::WebhooksApiClient::new(sdk_config.clone()),
272 kvstore: kvstore::KvStoreApiClient::new(sdk_config.clone()),
273 sql: sql::SqlApiClient::new(sdk_config.clone()),
274 rpc: rpc::RpcApiClient::new(sdk_config, config.rpc.as_ref()),
275 })
276 }
277
278 pub fn from_env() -> Result<Self, SdkError> {
280 Self::new(&SdkFullConfig::from_env()?)
281 }
282
283 pub fn from_env_with_client_info(client_info: Option<ClientInfo>) -> Result<Self, SdkError> {
285 Self::new_with_client_info(&SdkFullConfig::from_env()?, client_info)
286 }
287}
288
289#[cfg(test)]
290#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
291mod headers_tests {
292 use super::*;
293 use std::collections::HashMap;
294 use wiremock::matchers::{header, method, path};
295 use wiremock::{Mock, MockServer, ResponseTemplate};
296
297 fn base_config(api_key: &str) -> SdkFullConfig {
298 SdkFullConfig {
299 api_key: api_key.to_string(),
300 http: None,
301 admin: None,
302 streams: None,
303 webhooks: None,
304 kvstore: None,
305 sql: None,
306 rpc: None,
307 }
308 }
309
310 fn binding_info() -> ClientInfo {
311 ClientInfo {
312 language: "python".to_string(),
313 language_version: "3.12.4".to_string(),
314 sdk_version: "1.2.3".to_string(),
315 }
316 }
317
318 #[test]
319 fn default_user_agent_identifies_rust_core() {
320 let ua = build_user_agent(&default_rust_client_info());
321 assert!(ua.starts_with("quicknode-sdk-rust/"));
322 assert!(ua.contains(env!("CARGO_PKG_VERSION")));
323 assert!(ua.contains(std::env::consts::OS));
324 assert!(ua.contains(std::env::consts::ARCH));
325 }
326
327 #[test]
328 fn binding_user_agent_identifies_language() {
329 let ua = build_user_agent(&binding_info());
330 let expected_prefix = "quicknode-sdk-python/1.2.3";
331 assert!(ua.starts_with(expected_prefix), "got: {ua}");
332 assert!(ua.contains("python-3.12.4"));
333 }
334
335 #[test]
336 fn invalid_custom_header_name_errors() {
337 let mut cfg = base_config("k");
338 let mut h = HashMap::new();
339 h.insert("bad header".to_string(), "v".to_string());
340 cfg.http = Some(HttpConfig {
341 timeout_secs: None,
342 pool_max_idle_per_host: None,
343 headers: Some(h),
344 });
345 assert!(matches!(SdkConfig::new(&cfg), Err(SdkError::Config(_))));
346 }
347
348 #[test]
349 fn invalid_custom_header_value_errors() {
350 let mut cfg = base_config("k");
351 let mut h = HashMap::new();
352 h.insert("X-Test".to_string(), "bad\nvalue".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 #[tokio::test]
363 async fn default_user_agent_reaches_wire_and_custom_headers_override() {
364 let server = MockServer::start().await;
365 Mock::given(method("GET"))
366 .and(path("/endpoints"))
367 .and(header("user-agent", "custom-ua/9.9"))
368 .and(header("x-correlation-id", "abc"))
369 .and(header("x-api-key", "override-key"))
371 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
372 "data": [], "error": null, "pagination": null
373 })))
374 .mount(&server)
375 .await;
376
377 let mut headers = HashMap::new();
378 headers.insert("User-Agent".to_string(), "custom-ua/9.9".to_string());
379 headers.insert("X-Correlation-Id".to_string(), "abc".to_string());
380 headers.insert("x-api-key".to_string(), "override-key".to_string());
381
382 let cfg = SdkFullConfig {
383 api_key: "real-key".to_string(),
384 http: Some(HttpConfig {
385 timeout_secs: None,
386 pool_max_idle_per_host: None,
387 headers: Some(headers),
388 }),
389 admin: Some(AdminConfig {
390 base_url: Some(format!("{}/", server.uri())),
391 }),
392 streams: None,
393 webhooks: None,
394 kvstore: None,
395 sql: None,
396 rpc: None,
397 };
398
399 let sdk = QuicknodeSdk::new(&cfg).unwrap();
400 sdk.admin
401 .get_endpoints(&admin::GetEndpointsRequest::default())
402 .await
403 .unwrap();
404 }
405}