Skip to main content

quicknode_sdk/
lib.rs

1pub mod admin;
2pub mod config;
3pub mod errors;
4pub mod kvstore;
5pub mod sql;
6pub mod streams;
7pub mod webhooks;
8
9pub use config::{
10    AdminConfig, ClientInfo, HttpConfig, KvStoreConfig, SdkFullConfig, SqlConfig, StreamsConfig,
11    WebhooksConfig,
12};
13pub use kvstore::{
14    AddListItemParams, BulkSetsParams, CreateListParams, CreateSetParams, GetListData,
15    GetListParams, GetListResponse, GetListsData, GetListsParams, GetListsResponse, GetSetResponse,
16    GetSetsParams, GetSetsResponse, KvSetEntry, KvStoreApiClient, ListContainsItemResponse,
17    UpdateListParams,
18};
19pub use sql::{
20    ChainSchema, ColumnMeta, ColumnSchema, QueryParams, QueryResponse, QueryStatistics,
21    SqlApiClient, TableSchema,
22};
23
24use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
25use reqwest::Client as ReqwestClient;
26use std::sync::Arc;
27
28use errors::SdkError;
29
30const DEFAULT_TIMEOUT_SECS: u64 = 30;
31
32/// Build the auto-generated `User-Agent` value for a given caller.
33///
34/// Shape: `quicknode-sdk-{language}/{sdk_version} ({os}-{arch}; {language}-{language_version})`
35fn build_user_agent(info: &ClientInfo) -> String {
36    format!(
37        "quicknode-sdk-{lang}/{ver} ({os}-{arch}; {lang}-{lang_ver})",
38        lang = info.language,
39        ver = info.sdk_version,
40        os = std::env::consts::OS,
41        arch = std::env::consts::ARCH,
42        lang_ver = info.language_version,
43    )
44}
45
46/// `ClientInfo` used when `SdkConfig::new` is called directly (pure-Rust path).
47fn default_rust_client_info() -> ClientInfo {
48    ClientInfo {
49        language: "rust".to_string(),
50        // CARGO_PKG_RUST_VERSION is the MSRV declared in Cargo.toml. We have
51        // no way to read the actual rustc version that compiled the caller,
52        // so MSRV is the closest stable identifier.
53        language_version: option_env!("CARGO_PKG_RUST_VERSION")
54            .unwrap_or("unknown")
55            .to_string(),
56        sdk_version: env!("CARGO_PKG_VERSION").to_string(),
57    }
58}
59
60// Using Arc for the inner config to keep as a cheap clone
61#[derive(Clone)]
62pub struct SdkConfig(Arc<SdkConfigInner>);
63
64impl std::fmt::Debug for SdkConfig {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("SdkConfig")
67            .field("api_key", &"[redacted]")
68            .field("admin_base_url", &self.0.admin.base_url)
69            .field("streams_base_url", &self.0.streams.base_url)
70            .field("webhooks_base_url", &self.0.webhooks.base_url)
71            .field("kvstore_base_url", &self.0.kvstore.base_url)
72            .field("sql_base_url", &self.0.sql.base_url)
73            .finish()
74    }
75}
76
77struct SdkConfigInner {
78    http_client: ReqwestClient,
79    admin: admin::ResolvedAdminConfig,
80    streams: streams::ResolvedStreamsConfig,
81    webhooks: webhooks::ResolvedWebhooksConfig,
82    kvstore: kvstore::ResolvedKvStoreConfig,
83    sql: sql::ResolvedSqlConfig,
84}
85
86impl SdkConfig {
87    /// Build an `SdkConfig` for a pure-Rust caller. The `User-Agent` will
88    /// identify the core crate (`quicknode-sdk-rust/<version>`).
89    pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
90        Self::new_with_client_info(config, None)
91    }
92
93    /// Build an `SdkConfig` while attributing the `User-Agent` to a specific
94    /// language binding (Python/Node/Ruby). Used by the binding crates so
95    /// telemetry on the server side reflects the actual caller.
96    ///
97    /// If `client_info` is `None`, falls back to the pure-Rust identity.
98    pub fn new_with_client_info(
99        config: &SdkFullConfig,
100        client_info: Option<ClientInfo>,
101    ) -> Result<Self, SdkError> {
102        let mut builder = ReqwestClient::builder();
103
104        let timeout_secs = match &config.http {
105            Some(h) => match h.timeout_secs {
106                Some(secs) if secs < 0 => {
107                    return Err(SdkError::Config("timeout_secs must be non-negative".into()));
108                }
109                Some(secs) => secs as u64,
110                None => DEFAULT_TIMEOUT_SECS,
111            },
112            None => DEFAULT_TIMEOUT_SECS,
113        };
114        builder = builder.timeout(std::time::Duration::from_secs(timeout_secs));
115
116        if let Some(http) = &config.http {
117            if let Some(max_idle) = http.pool_max_idle_per_host {
118                builder = builder.pool_max_idle_per_host(max_idle as usize);
119            }
120        }
121
122        let mut default_headers = HeaderMap::new();
123        default_headers.insert(
124            reqwest::header::ACCEPT,
125            HeaderValue::from_static("application/json"),
126        );
127        default_headers.insert(
128            reqwest::header::CONTENT_TYPE,
129            HeaderValue::from_static("application/json"),
130        );
131        default_headers.insert(
132            "x-api-key",
133            HeaderValue::from_str(&config.api_key).map_err(|e| SdkError::Config(e.to_string()))?,
134        );
135        let ua = build_user_agent(&client_info.unwrap_or_else(default_rust_client_info));
136        default_headers.insert(
137            reqwest::header::USER_AGENT,
138            HeaderValue::from_str(&ua).map_err(|e| SdkError::Config(e.to_string()))?,
139        );
140
141        // Caller-supplied headers override anything above. `HeaderMap::insert`
142        // replaces existing values for the same name.
143        if let Some(http) = &config.http {
144            if let Some(custom) = &http.headers {
145                for (name, value) in custom {
146                    let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
147                        SdkError::Config(format!("invalid header name {name:?}: {e}"))
148                    })?;
149                    let header_value = HeaderValue::from_str(value).map_err(|e| {
150                        SdkError::Config(format!("invalid header value for {name:?}: {e}"))
151                    })?;
152                    default_headers.insert(header_name, header_value);
153                }
154            }
155        }
156
157        builder = builder.default_headers(default_headers);
158
159        let http_client = builder
160            .build()
161            .map_err(|e| SdkError::Config(e.to_string()))?;
162
163        Ok(Self(Arc::new(SdkConfigInner {
164            http_client,
165            admin: admin::ResolvedAdminConfig::from_config(config.admin.as_ref())?,
166            streams: streams::ResolvedStreamsConfig::from_config(config.streams.as_ref())?,
167            webhooks: webhooks::ResolvedWebhooksConfig::from_config(config.webhooks.as_ref())?,
168            kvstore: kvstore::ResolvedKvStoreConfig::from_config(config.kvstore.as_ref())?,
169            sql: sql::ResolvedSqlConfig::from_config(config.sql.as_ref())?,
170        })))
171    }
172
173    pub(crate) fn http_client(&self) -> &ReqwestClient {
174        &self.0.http_client
175    }
176
177    pub(crate) fn admin(&self) -> &admin::ResolvedAdminConfig {
178        &self.0.admin
179    }
180
181    pub(crate) fn streams(&self) -> &streams::ResolvedStreamsConfig {
182        &self.0.streams
183    }
184
185    pub(crate) fn webhooks(&self) -> &webhooks::ResolvedWebhooksConfig {
186        &self.0.webhooks
187    }
188
189    pub(crate) fn kvstore(&self) -> &kvstore::ResolvedKvStoreConfig {
190        &self.0.kvstore
191    }
192
193    pub(crate) fn sql(&self) -> &sql::ResolvedSqlConfig {
194        &self.0.sql
195    }
196}
197
198/// Top-level entry point for the Quicknode SDK. Holds sub-clients for each
199/// product area; all share a single HTTP client and API key.
200pub struct QuicknodeSdk {
201    /// Admin API client: manages endpoints, tags, teams, billing, usage,
202    /// metrics, security, and rate limits.
203    pub admin: admin::AdminApiClient,
204    /// Streams API client: creates and manages blockchain data streams.
205    pub streams: streams::StreamsApiClient,
206    /// Webhooks API client: creates and manages filter-template webhooks.
207    pub webhooks: webhooks::WebhooksApiClient,
208    /// Key-Value Store client: manages sets (single values) and lists
209    /// (ordered collections) under string keys.
210    pub kvstore: kvstore::KvStoreApiClient,
211    /// SQL Explorer client: executes SQL queries against indexed blockchain
212    /// data and fetches the database schema.
213    pub sql: sql::SqlApiClient,
214}
215
216impl QuicknodeSdk {
217    /// Creates a new SDK instance from an explicit configuration.
218    pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
219        Self::new_with_client_info(config, None)
220    }
221
222    /// Creates a new SDK instance, attributing the auto-generated `User-Agent`
223    /// to a specific language binding. Used internally by Python/Node/Ruby
224    /// binding crates.
225    pub fn new_with_client_info(
226        config: &SdkFullConfig,
227        client_info: Option<ClientInfo>,
228    ) -> Result<Self, SdkError> {
229        let sdk_config = SdkConfig::new_with_client_info(config, client_info)?;
230        Ok(Self {
231            admin: admin::AdminApiClient::new(sdk_config.clone()),
232            streams: streams::StreamsApiClient::new(sdk_config.clone()),
233            webhooks: webhooks::WebhooksApiClient::new(sdk_config.clone()),
234            kvstore: kvstore::KvStoreApiClient::new(sdk_config.clone()),
235            sql: sql::SqlApiClient::new(sdk_config),
236        })
237    }
238
239    /// Creates a new SDK instance using configuration from environment variables.
240    pub fn from_env() -> Result<Self, SdkError> {
241        Self::new(&SdkFullConfig::from_env()?)
242    }
243
244    /// Same as [`Self::from_env`] but with a binding-supplied [`ClientInfo`].
245    pub fn from_env_with_client_info(client_info: Option<ClientInfo>) -> Result<Self, SdkError> {
246        Self::new_with_client_info(&SdkFullConfig::from_env()?, client_info)
247    }
248}
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
252mod headers_tests {
253    use super::*;
254    use std::collections::HashMap;
255    use wiremock::matchers::{header, method, path};
256    use wiremock::{Mock, MockServer, ResponseTemplate};
257
258    fn base_config(api_key: &str) -> SdkFullConfig {
259        SdkFullConfig {
260            api_key: api_key.to_string(),
261            http: None,
262            admin: None,
263            streams: None,
264            webhooks: None,
265            kvstore: None,
266            sql: None,
267        }
268    }
269
270    fn binding_info() -> ClientInfo {
271        ClientInfo {
272            language: "python".to_string(),
273            language_version: "3.12.4".to_string(),
274            sdk_version: "1.2.3".to_string(),
275        }
276    }
277
278    #[test]
279    fn default_user_agent_identifies_rust_core() {
280        let ua = build_user_agent(&default_rust_client_info());
281        assert!(ua.starts_with("quicknode-sdk-rust/"));
282        assert!(ua.contains(env!("CARGO_PKG_VERSION")));
283        assert!(ua.contains(std::env::consts::OS));
284        assert!(ua.contains(std::env::consts::ARCH));
285    }
286
287    #[test]
288    fn binding_user_agent_identifies_language() {
289        let ua = build_user_agent(&binding_info());
290        let expected_prefix = "quicknode-sdk-python/1.2.3";
291        assert!(ua.starts_with(expected_prefix), "got: {ua}");
292        assert!(ua.contains("python-3.12.4"));
293    }
294
295    #[test]
296    fn invalid_custom_header_name_errors() {
297        let mut cfg = base_config("k");
298        let mut h = HashMap::new();
299        h.insert("bad header".to_string(), "v".to_string());
300        cfg.http = Some(HttpConfig {
301            timeout_secs: None,
302            pool_max_idle_per_host: None,
303            headers: Some(h),
304        });
305        assert!(matches!(SdkConfig::new(&cfg), Err(SdkError::Config(_))));
306    }
307
308    #[test]
309    fn invalid_custom_header_value_errors() {
310        let mut cfg = base_config("k");
311        let mut h = HashMap::new();
312        // Newline is not a valid header value byte.
313        h.insert("X-Test".to_string(), "bad\nvalue".to_string());
314        cfg.http = Some(HttpConfig {
315            timeout_secs: None,
316            pool_max_idle_per_host: None,
317            headers: Some(h),
318        });
319        assert!(matches!(SdkConfig::new(&cfg), Err(SdkError::Config(_))));
320    }
321
322    #[tokio::test]
323    async fn default_user_agent_reaches_wire_and_custom_headers_override() {
324        let server = MockServer::start().await;
325        Mock::given(method("GET"))
326            .and(path("/endpoints"))
327            .and(header("user-agent", "custom-ua/9.9"))
328            .and(header("x-correlation-id", "abc"))
329            // x-api-key override also wins
330            .and(header("x-api-key", "override-key"))
331            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
332                "data": [], "error": null, "pagination": null
333            })))
334            .mount(&server)
335            .await;
336
337        let mut headers = HashMap::new();
338        headers.insert("User-Agent".to_string(), "custom-ua/9.9".to_string());
339        headers.insert("X-Correlation-Id".to_string(), "abc".to_string());
340        headers.insert("x-api-key".to_string(), "override-key".to_string());
341
342        let cfg = SdkFullConfig {
343            api_key: "real-key".to_string(),
344            http: Some(HttpConfig {
345                timeout_secs: None,
346                pool_max_idle_per_host: None,
347                headers: Some(headers),
348            }),
349            admin: Some(AdminConfig {
350                base_url: Some(format!("{}/", server.uri())),
351            }),
352            streams: None,
353            webhooks: None,
354            kvstore: None,
355            sql: None,
356        };
357
358        let sdk = QuicknodeSdk::new(&cfg).unwrap();
359        sdk.admin
360            .get_endpoints(&admin::GetEndpointsRequest::default())
361            .await
362            .unwrap();
363    }
364}