Skip to main content

quicknode_sdk/
lib.rs

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