Skip to main content

quicknode_sdk/
config.rs

1#[cfg(feature = "rust")]
2use bon::Builder;
3#[cfg(feature = "node")]
4use napi_derive::napi;
5#[cfg(feature = "python")]
6use pyo3::{pyclass, pymethods};
7#[cfg(feature = "python")]
8use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
9
10use crate::errors::SdkError;
11
12#[cfg_attr(feature = "python", gen_stub_pyclass)]
13#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
14#[cfg_attr(feature = "node", napi(object))]
15#[cfg_attr(feature = "rust", derive(Builder))]
16#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
17pub struct HttpConfig {
18    pub timeout_secs: Option<i64>,
19    pub pool_max_idle_per_host: Option<i32>,
20    /// Custom HTTP headers added to every outbound request.
21    ///
22    /// **These headers OVERRIDE any SDK-managed header with the same name**,
23    /// including `User-Agent`, `x-api-key`, `Accept`, and `Content-Type`.
24    /// Header names are matched case-insensitively. Use this to override the
25    /// auto-generated User-Agent or inject correlation IDs, proxy auth, etc.
26    pub headers: Option<std::collections::HashMap<String, String>>,
27}
28
29#[cfg(feature = "python")]
30#[gen_stub_pymethods]
31#[pymethods]
32impl HttpConfig {
33    #[new]
34    #[pyo3(signature = (timeout_secs=None, pool_max_idle_per_host=None, headers=None))]
35    pub fn new(
36        timeout_secs: Option<i64>,
37        pool_max_idle_per_host: Option<i32>,
38        headers: Option<std::collections::HashMap<String, String>>,
39    ) -> Self {
40        HttpConfig {
41            timeout_secs,
42            pool_max_idle_per_host,
43            headers,
44        }
45    }
46}
47
48/// Identifies the language and runtime making SDK calls. Each binding crate
49/// (Python, Node, Ruby) constructs this and passes it through
50/// [`SdkConfig::new_with_client_info`] so the SDK's auto-generated
51/// `User-Agent` reflects the actual caller, not the underlying Rust core.
52#[derive(Debug, Clone)]
53pub struct ClientInfo {
54    /// Short language identifier, e.g. `"python"`, `"node"`, `"ruby"`, `"rust"`.
55    pub language: String,
56    /// Runtime version of the language, e.g. `"3.12.4"`, `"20.10.0"`, `"3.3.0"`.
57    pub language_version: String,
58    /// Version string of the language-specific SDK package — read from the
59    /// language's own manifest (PyPI version, npm version, gem version).
60    pub sdk_version: String,
61}
62
63#[cfg_attr(feature = "python", gen_stub_pyclass)]
64#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
65#[cfg_attr(feature = "node", napi(object))]
66#[cfg_attr(feature = "rust", derive(Builder))]
67#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
68pub struct AdminConfig {
69    pub base_url: Option<String>,
70}
71
72#[cfg(feature = "python")]
73#[gen_stub_pymethods]
74#[pymethods]
75impl AdminConfig {
76    #[new]
77    #[pyo3(signature = (base_url=None))]
78    pub fn new(base_url: Option<String>) -> Self {
79        AdminConfig { base_url }
80    }
81}
82
83#[cfg_attr(feature = "python", gen_stub_pyclass)]
84#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
85#[cfg_attr(feature = "node", napi(object))]
86#[cfg_attr(feature = "rust", derive(Builder))]
87#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
88pub struct StreamsConfig {
89    pub base_url: Option<String>,
90}
91
92#[cfg(feature = "python")]
93#[gen_stub_pymethods]
94#[pymethods]
95impl StreamsConfig {
96    #[new]
97    #[pyo3(signature = (base_url=None))]
98    pub fn new(base_url: Option<String>) -> Self {
99        StreamsConfig { base_url }
100    }
101}
102
103#[cfg_attr(feature = "python", gen_stub_pyclass)]
104#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
105#[cfg_attr(feature = "node", napi(object))]
106#[cfg_attr(feature = "rust", derive(Builder))]
107#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
108pub struct WebhooksConfig {
109    pub base_url: Option<String>,
110}
111
112#[cfg(feature = "python")]
113#[gen_stub_pymethods]
114#[pymethods]
115impl WebhooksConfig {
116    #[new]
117    #[pyo3(signature = (base_url=None))]
118    pub fn new(base_url: Option<String>) -> Self {
119        WebhooksConfig { base_url }
120    }
121}
122
123#[cfg_attr(feature = "python", gen_stub_pyclass)]
124#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
125#[cfg_attr(feature = "node", napi(object))]
126#[cfg_attr(feature = "rust", derive(Builder))]
127#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
128pub struct KvStoreConfig {
129    pub base_url: Option<String>,
130}
131
132#[cfg(feature = "python")]
133#[gen_stub_pymethods]
134#[pymethods]
135impl KvStoreConfig {
136    #[new]
137    #[pyo3(signature = (base_url=None))]
138    pub fn new(base_url: Option<String>) -> Self {
139        KvStoreConfig { base_url }
140    }
141}
142
143/// A minted session JWT plus the endpoint it authenticates against and its
144/// wall-clock expiry. This is the unit cached by the RPC client and the unit a
145/// host persists between processes (e.g. the CLI's on-disk token cache).
146///
147/// `exp_unix` is the JWT `exp` claim (unix seconds), used directly so it
148/// survives a process restart (unlike a monotonic `Instant`).
149#[cfg_attr(feature = "python", gen_stub_pyclass)]
150#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
151#[cfg_attr(feature = "node", napi(object))]
152#[cfg_attr(feature = "rust", derive(Builder))]
153#[derive(Clone, serde::Serialize, serde::Deserialize)]
154pub struct CachedToken {
155    /// The provisioned tooling-access endpoint URL the JWT authenticates against.
156    pub endpoint_url: String,
157    /// The minted ES256 session JWT, presented as a Bearer token.
158    pub token: String,
159    /// JWT `exp` claim in unix seconds.
160    pub exp_unix: i64,
161}
162
163// Manual Debug that redacts the JWT: the token is a live bearer credential and
164// must never appear in logs or panic messages.
165impl std::fmt::Debug for CachedToken {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("CachedToken")
168            .field("endpoint_url", &self.endpoint_url)
169            .field("token", &"[redacted]")
170            .field("exp_unix", &self.exp_unix)
171            .finish()
172    }
173}
174
175#[cfg(feature = "python")]
176#[gen_stub_pymethods]
177#[pymethods]
178impl CachedToken {
179    #[new]
180    pub fn new(endpoint_url: String, token: String, exp_unix: i64) -> Self {
181        CachedToken {
182            endpoint_url,
183            token,
184            exp_unix,
185        }
186    }
187}
188
189#[cfg_attr(feature = "python", gen_stub_pyclass)]
190#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
191#[cfg_attr(feature = "node", napi(object))]
192#[cfg_attr(feature = "rust", derive(Builder))]
193#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
194pub struct RpcConfig {
195    /// Custom HTTP URL to send JSON-RPC calls to, bypassing the Tooling Access
196    /// endpoint. When set, every `rpc.call` on this client goes straight to this
197    /// URL with NO session token minted or attached — the URL is treated as a
198    /// self-authenticating endpoint (e.g. a provisioned `.quiknode.pro` URL that
199    /// already embeds its token, or a self-hosted node). A per-call
200    /// `endpoint_url` overrides this default. Unset means tooling-JWT mode.
201    pub endpoint_url: Option<String>,
202    /// Optional pre-existing token to seed the in-memory cache (e.g. loaded
203    /// from a host's on-disk cache). Advisory: a malformed or expired seed is
204    /// treated as a cache miss and a fresh token is minted.
205    pub seed: Option<CachedToken>,
206    /// Seconds before `exp` at which the client proactively refreshes. The
207    /// margin also absorbs clock skew between client and endpoint. Defaults to
208    /// 60 when unset.
209    pub refresh_margin_secs: Option<i64>,
210    /// Per-network URL map for multichain routing: network key (e.g.
211    /// `"solana-mainnet"`, `"polygon"`) -> full http_url. Built from
212    /// `admin.get_endpoint_urls(...).multichain_urls`. When set, `rpc.call` with
213    /// a `network` resolves the target URL here. Optional; the default-network
214    /// call path needs no map.
215    pub networks: Option<std::collections::HashMap<String, String>>,
216}
217
218#[cfg(feature = "python")]
219#[gen_stub_pymethods]
220#[pymethods]
221impl RpcConfig {
222    #[new]
223    #[pyo3(signature = (endpoint_url=None, seed=None, refresh_margin_secs=None, networks=None))]
224    pub fn new(
225        endpoint_url: Option<String>,
226        seed: Option<CachedToken>,
227        refresh_margin_secs: Option<i64>,
228        networks: Option<std::collections::HashMap<String, String>>,
229    ) -> Self {
230        RpcConfig {
231            endpoint_url,
232            seed,
233            refresh_margin_secs,
234            networks,
235        }
236    }
237}
238
239#[cfg_attr(feature = "python", gen_stub_pyclass)]
240#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
241#[cfg_attr(feature = "node", napi(object))]
242#[cfg_attr(feature = "rust", derive(Builder))]
243#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
244pub struct SqlConfig {
245    pub base_url: Option<String>,
246}
247
248#[cfg(feature = "python")]
249#[gen_stub_pymethods]
250#[pymethods]
251impl SqlConfig {
252    #[new]
253    #[pyo3(signature = (base_url=None))]
254    pub fn new(base_url: Option<String>) -> Self {
255        SqlConfig { base_url }
256    }
257}
258
259#[cfg_attr(feature = "python", gen_stub_pyclass)]
260#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
261#[cfg_attr(feature = "node", napi(object))]
262#[cfg_attr(feature = "rust", derive(Builder))]
263#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
264pub struct SdkFullConfig {
265    pub api_key: String,
266    pub http: Option<HttpConfig>,
267    pub admin: Option<AdminConfig>,
268    pub streams: Option<StreamsConfig>,
269    pub webhooks: Option<WebhooksConfig>,
270    pub kvstore: Option<KvStoreConfig>,
271    pub sql: Option<SqlConfig>,
272    pub rpc: Option<RpcConfig>,
273}
274
275impl SdkFullConfig {
276    pub fn from_api_key(api_key: String) -> Self {
277        SdkFullConfig {
278            api_key,
279            http: None,
280            admin: None,
281            streams: None,
282            webhooks: None,
283            kvstore: None,
284            sql: None,
285            rpc: None,
286        }
287    }
288
289    pub fn from_env() -> Result<Self, SdkError> {
290        config::Config::builder()
291            .add_source(
292                config::Environment::with_prefix("QN_SDK")
293                    .separator("__")
294                    .try_parsing(true),
295            )
296            .build()
297            .map_err(|e| SdkError::Config(e.to_string()))
298            .and_then(Self::from_config)
299    }
300
301    fn from_config(cfg: config::Config) -> Result<Self, SdkError> {
302        cfg.try_deserialize::<SdkFullConfig>()
303            .map_err(|e| SdkError::Config(e.to_string()))
304    }
305}
306
307#[cfg(feature = "python")]
308#[gen_stub_pymethods]
309#[pymethods]
310impl SdkFullConfig {
311    #[new]
312    #[pyo3(signature = (api_key, http=None, admin=None, streams=None, webhooks=None, kvstore=None, sql=None, rpc=None))]
313    #[allow(clippy::too_many_arguments)]
314    pub fn new(
315        api_key: String,
316        http: Option<HttpConfig>,
317        admin: Option<AdminConfig>,
318        streams: Option<StreamsConfig>,
319        webhooks: Option<WebhooksConfig>,
320        kvstore: Option<KvStoreConfig>,
321        sql: Option<SqlConfig>,
322        rpc: Option<RpcConfig>,
323    ) -> Self {
324        SdkFullConfig {
325            api_key,
326            http,
327            admin,
328            streams,
329            webhooks,
330            kvstore,
331            sql,
332            rpc,
333        }
334    }
335}
336
337#[cfg(test)]
338#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
339mod tests {
340    use super::*;
341
342    fn build_config(pairs: &[(&str, &str)]) -> config::Config {
343        let mut builder = config::Config::builder();
344        for (k, v) in pairs {
345            builder = builder.set_override(*k, *v).unwrap();
346        }
347        builder.build().unwrap()
348    }
349
350    #[test]
351    fn from_env_missing_api_key_returns_error() {
352        let cfg = config::Config::builder().build().unwrap();
353        assert!(matches!(
354            SdkFullConfig::from_config(cfg),
355            Err(SdkError::Config(_))
356        ));
357    }
358
359    #[test]
360    fn from_env_only_api_key() {
361        let cfg = build_config(&[("api_key", "test-key")]);
362        let config = SdkFullConfig::from_config(cfg).unwrap();
363        assert_eq!(config.api_key, "test-key");
364        assert!(config.http.is_none());
365        assert!(config.admin.is_none());
366    }
367
368    #[test]
369    fn from_env_all_fields() {
370        let cfg = build_config(&[
371            ("api_key", "my-api-key"),
372            ("http.timeout_secs", "30"),
373            ("http.pool_max_idle_per_host", "5"),
374            ("admin.base_url", "https://example.com/"),
375        ]);
376        let config = SdkFullConfig::from_config(cfg).unwrap();
377        assert_eq!(config.api_key, "my-api-key");
378        let http = config.http.unwrap();
379        assert_eq!(http.timeout_secs, Some(30));
380        assert_eq!(http.pool_max_idle_per_host, Some(5));
381        let admin = config.admin.unwrap();
382        assert_eq!(admin.base_url, Some("https://example.com/".to_string()));
383    }
384
385    #[test]
386    fn from_env_invalid_timeout_secs() {
387        let cfg = build_config(&[("api_key", "test-key"), ("http.timeout_secs", "abc")]);
388        assert!(matches!(
389            SdkFullConfig::from_config(cfg),
390            Err(SdkError::Config(_))
391        ));
392    }
393
394    #[test]
395    fn from_env_headers_round_trip() {
396        let cfg = build_config(&[
397            ("api_key", "k"),
398            ("http.headers.x-correlation-id", "abc"),
399            ("http.headers.user-agent", "custom-ua/1.0"),
400        ]);
401        let config = SdkFullConfig::from_config(cfg).unwrap();
402        let headers = config.http.unwrap().headers.unwrap();
403        assert_eq!(
404            headers.get("x-correlation-id").map(String::as_str),
405            Some("abc")
406        );
407        assert_eq!(
408            headers.get("user-agent").map(String::as_str),
409            Some("custom-ua/1.0")
410        );
411    }
412}