Skip to main content

quicknode_sdk/
lib.rs

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
35/// Build the auto-generated `User-Agent` value for a given caller.
36///
37/// Shape: `quicknode-sdk-{language}/{sdk_version} ({os}-{arch}; {language}-{language_version})`
38fn 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
49/// `ClientInfo` used when `SdkConfig::new` is called directly (pure-Rust path).
50fn default_rust_client_info() -> ClientInfo {
51    ClientInfo {
52        language: "rust".to_string(),
53        // CARGO_PKG_RUST_VERSION is the MSRV declared in Cargo.toml. We have
54        // no way to read the actual rustc version that compiled the caller,
55        // so MSRV is the closest stable identifier.
56        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// Using Arc for the inner config to keep as a cheap clone
64#[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    // Separate client for RPC data-plane calls: no account `x-api-key` header,
83    // authenticates per-request with a Bearer JWT. See `new_with_client_info`.
84    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    /// Build an `SdkConfig` for a pure-Rust caller. The `User-Agent` will
94    /// identify the core crate (`quicknode-sdk-rust/<version>`).
95    pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
96        Self::new_with_client_info(config, None)
97    }
98
99    /// Build an `SdkConfig` while attributing the `User-Agent` to a specific
100    /// language binding (Python/Node/Ruby). Used by the binding crates so
101    /// telemetry on the server side reflects the actual caller.
102    ///
103    /// If `client_info` is `None`, falls back to the pure-Rust identity.
104    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        // `ClientBuilder` is not cloneable, so build a freshly-configured
124        // builder for each client from the shared transport settings.
125        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        // Headers common to every client. The account `x-api-key` is added on
135        // top of this only for the control-plane client below — RPC data-plane
136        // calls authenticate with a short-lived Bearer JWT and must never carry
137        // the long-lived account key.
138        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        // Caller-supplied headers override anything above. `HeaderMap::insert`
154        // replaces existing values for the same name.
155        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        // Control-plane client: carries the account `x-api-key` used to reach
170        // the QuickNode management APIs (admin, streams, webhooks, etc.). Insert
171        // the key first, then overlay `common_headers` so a caller-supplied
172        // `x-api-key` in custom headers still wins (custom headers override all
173        // SDK-managed defaults).
174        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        // RPC data-plane client: identical transport config, but WITHOUT the
186        // account `x-api-key` default header. RPC calls attach a Bearer JWT
187        // per-request so the account key never leaves the control plane.
188        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
233/// Top-level entry point for the Quicknode SDK. Holds sub-clients for each
234/// product area; all share a single HTTP client and API key.
235pub struct QuicknodeSdk {
236    /// Admin API client: manages endpoints, tags, teams, billing, usage,
237    /// metrics, security, and rate limits.
238    pub admin: admin::AdminApiClient,
239    /// Streams API client: creates and manages blockchain data streams.
240    pub streams: streams::StreamsApiClient,
241    /// Webhooks API client: creates and manages filter-template webhooks.
242    pub webhooks: webhooks::WebhooksApiClient,
243    /// Key-Value Store client: manages sets (single values) and lists
244    /// (ordered collections) under string keys.
245    pub kvstore: kvstore::KvStoreApiClient,
246    /// SQL Explorer client: executes SQL queries against indexed blockchain
247    /// data and fetches the database schema.
248    pub sql: sql::SqlApiClient,
249    /// JSON-RPC client: makes on-chain calls against the account's Tooling
250    /// Access endpoint using short-lived session JWTs.
251    pub rpc: rpc::RpcApiClient,
252}
253
254impl QuicknodeSdk {
255    /// Creates a new SDK instance from an explicit configuration.
256    pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
257        Self::new_with_client_info(config, None)
258    }
259
260    /// Creates a new SDK instance, attributing the auto-generated `User-Agent`
261    /// to a specific language binding. Used internally by Python/Node/Ruby
262    /// binding crates.
263    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    /// Creates a new SDK instance using configuration from environment variables.
279    pub fn from_env() -> Result<Self, SdkError> {
280        Self::new(&SdkFullConfig::from_env()?)
281    }
282
283    /// Same as [`Self::from_env`] but with a binding-supplied [`ClientInfo`].
284    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        // Newline is not a valid header value byte.
353        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            // x-api-key override also wins
370            .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}