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;
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
42/// Build the auto-generated `User-Agent` value for a given caller.
43///
44/// Shape: `quicknode-sdk-{language}/{sdk_version} ({os}-{arch}; {language}-{language_version})`
45fn 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
56/// `ClientInfo` used when `SdkConfig::new` is called directly (pure-Rust path).
57fn default_rust_client_info() -> ClientInfo {
58    ClientInfo {
59        language: "rust".to_string(),
60        // CARGO_PKG_RUST_VERSION is the MSRV declared in Cargo.toml. We have
61        // no way to read the actual rustc version that compiled the caller,
62        // so MSRV is the closest stable identifier.
63        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// Using Arc for the inner config to keep as a cheap clone
71#[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    // Separate client for RPC data-plane calls: no account `x-api-key` header,
90    // authenticates per-request with a Bearer JWT. See `new_with_client_info`.
91    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    /// Build an `SdkConfig` for a pure-Rust caller. The `User-Agent` will
101    /// identify the core crate (`quicknode-sdk-rust/<version>`).
102    pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
103        Self::new_with_client_info(config, None)
104    }
105
106    /// Build an `SdkConfig` while attributing the `User-Agent` to a specific
107    /// language binding (Python/Node/Ruby). Used by the binding crates so
108    /// telemetry on the server side reflects the actual caller.
109    ///
110    /// If `client_info` is `None`, falls back to the pure-Rust identity.
111    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        // `ClientBuilder` is not cloneable, so build a freshly-configured
131        // builder for each client from the shared transport settings.
132        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        // Headers common to every client. The account `x-api-key` is added on
142        // top of this only for the control-plane client below — RPC data-plane
143        // calls authenticate with a short-lived Bearer JWT and must never carry
144        // the long-lived account key.
145        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        // Caller-supplied headers override anything above. `HeaderMap::insert`
161        // replaces existing values for the same name.
162        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        // Control-plane client: carries the account `x-api-key` used to reach
177        // the QuickNode management APIs (admin, streams, webhooks, etc.). Insert
178        // the key first, then overlay `common_headers` so a caller-supplied
179        // `x-api-key` in custom headers still wins (custom headers override all
180        // SDK-managed defaults).
181        //
182        // A keyless config (no `api_key`) installs NO key header: the payment
183        // lane needs no account key. Keyed surfaces then send un-authenticated
184        // requests that the gateway rejects (surfacing as `ApiError`), rather
185        // than sending an empty key header.
186        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        // RPC data-plane client: identical transport config, but WITHOUT the
200        // account `x-api-key` default header. RPC calls attach a Bearer JWT
201        // per-request so the account key never leaves the control plane.
202        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
247/// Top-level entry point for the Quicknode SDK. Holds sub-clients for each
248/// product area; all share a single HTTP client and API key.
249pub struct QuicknodeSdk {
250    /// Admin API client: manages endpoints, tags, teams, billing, usage,
251    /// metrics, security, and rate limits.
252    pub admin: admin::AdminApiClient,
253    /// Streams API client: creates and manages blockchain data streams.
254    pub streams: streams::StreamsApiClient,
255    /// Webhooks API client: creates and manages filter-template webhooks.
256    pub webhooks: webhooks::WebhooksApiClient,
257    /// Key-Value Store client: manages sets (single values) and lists
258    /// (ordered collections) under string keys.
259    pub kvstore: kvstore::KvStoreApiClient,
260    /// SQL Explorer client: executes SQL queries against indexed blockchain
261    /// data and fetches the database schema.
262    pub sql: sql::SqlApiClient,
263    /// JSON-RPC client: makes on-chain calls against the account's Tooling
264    /// Access endpoint using short-lived session JWTs.
265    pub rpc: rpc::RpcApiClient,
266}
267
268impl QuicknodeSdk {
269    /// Creates a new SDK instance from an explicit configuration.
270    pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
271        Self::new_with_client_info(config, None)
272    }
273
274    /// Creates a new SDK instance, attributing the auto-generated `User-Agent`
275    /// to a specific language binding. Used internally by Python/Node/Ruby
276    /// binding crates.
277    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    /// Creates a new SDK instance using configuration from environment variables.
293    pub fn from_env() -> Result<Self, SdkError> {
294        Self::new(&SdkFullConfig::from_env()?)
295    }
296
297    /// Same as [`Self::from_env`] but with a binding-supplied [`ClientInfo`].
298    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        // Newline is not a valid header value byte.
367        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            // x-api-key override also wins
384            .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}