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    /// Crypto-micropayment lane. When set, `rpc.call` pays per request with a
217    /// stablecoin against Quicknode's x402/MPP gateways instead of using the
218    /// account API key + session JWT. `#[serde(skip)]` so `from_env` can never
219    /// populate it — an env-derived private key is exactly what we don't want;
220    /// callers must pass this programmatically. The field is always present
221    /// (plain data), but the payment lane is only wired into `rpc.call` when a
222    /// crypto feature (`payments`/`payments-svm`/`payments-tempo`) is enabled;
223    /// built without any of them, a set `payment` is ignored and `rpc.call`
224    /// keeps its normal tooling-JWT behavior. The precompiled Python/Node/Ruby
225    /// packages always ship with the payment features on.
226    #[serde(skip)]
227    pub payment: Option<PaymentConfig>,
228}
229
230/// Binding-facing crypto-micropayment configuration. **Plain data** — all
231/// fields are strings so this can be a `napi(object)` / `pyclass` / Ruby hash;
232/// it is converted to the internal `enum Signer` + resolved config at the Rust
233/// boundary. The private `key` field stays readable to the caller, but the
234/// SDK's own `Debug` redacts it (below) so an SDK log line or panic can't leak
235/// it.
236///
237/// **Do not log your own `PaymentConfig`** — `println!("{config:?}")` on the
238/// derived-Debug *binding* object (napi/pyclass/hash) still shows the raw key.
239/// Only the SDK's internal rendering is redacted.
240#[cfg_attr(feature = "python", gen_stub_pyclass)]
241#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
242#[cfg_attr(feature = "node", napi(object))]
243#[cfg_attr(feature = "rust", derive(Builder))]
244#[derive(Clone, serde::Serialize, serde::Deserialize)]
245pub struct PaymentConfig {
246    /// Payment protocol: `"x402"` (pay-per-request) or `"mpp"` (MPP charge).
247    pub scheme: String,
248    /// Raw private key. EVM/Tempo: hex (with or without `0x`). Solana: base58
249    /// 64-byte secret key.
250    pub key: String,
251    /// CAIP-2 pay network selector, e.g. `"eip155:84532"` (x402/EVM),
252    /// `"solana:5eykt4…"` (x402/Solana), or `"eip155:42431"` (MPP/Tempo).
253    pub pay_network: String,
254    /// Asset (token) address/mint to pay in. Matches the offered menu entry's
255    /// `asset`. EVM: token contract hex. Solana: mint base58.
256    pub asset: String,
257    /// Spend ceiling in base units of `asset` (integer string). **Required.**
258    /// The selector skips any offered entry above this, and the driver refuses
259    /// to sign one — guarding against a buggy/hostile gateway overcharging a
260    /// custodied key.
261    pub max_amount: String,
262    /// Explicit Solana RPC URL for x402/Solana payment-build reads: the mint
263    /// (for its decimals and owning token program) and a recent blockhash, so
264    /// two reads per payment. Optional; when unset the SDK falls back to a
265    /// public Solana RPC matching the pay cluster. **Set this at any real
266    /// volume** — the public default rate-limits aggressively.
267    pub svm_rpc_url: Option<String>,
268    /// Test-only gateway base override (points the lane at a mock gateway).
269    pub base_url_override: Option<String>,
270}
271
272// Manual redacting Debug: the SDK must never print the raw key in its own log
273// lines, error context, or panics. Mirrors the CachedToken pattern above. The
274// caller's own object is still readable (see the struct doc) — this only
275// governs the SDK's `{:?}` output.
276impl std::fmt::Debug for PaymentConfig {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.debug_struct("PaymentConfig")
279            .field("scheme", &self.scheme)
280            .field("key", &"[redacted]")
281            .field("pay_network", &self.pay_network)
282            .field("asset", &self.asset)
283            .field("max_amount", &self.max_amount)
284            .field("svm_rpc_url", &self.svm_rpc_url)
285            .field("base_url_override", &self.base_url_override)
286            .finish()
287    }
288}
289
290#[cfg(feature = "python")]
291#[gen_stub_pymethods]
292#[pymethods]
293impl PaymentConfig {
294    #[new]
295    #[pyo3(signature = (scheme, key, pay_network, asset, max_amount, svm_rpc_url=None, base_url_override=None))]
296    pub fn new(
297        scheme: String,
298        key: String,
299        pay_network: String,
300        asset: String,
301        max_amount: String,
302        svm_rpc_url: Option<String>,
303        base_url_override: Option<String>,
304    ) -> Self {
305        PaymentConfig {
306            scheme,
307            key,
308            pay_network,
309            asset,
310            max_amount,
311            svm_rpc_url,
312            base_url_override,
313        }
314    }
315}
316
317#[cfg(feature = "python")]
318#[gen_stub_pymethods]
319#[pymethods]
320impl RpcConfig {
321    #[new]
322    #[pyo3(signature = (endpoint_url=None, seed=None, refresh_margin_secs=None, networks=None, payment=None))]
323    pub fn new(
324        endpoint_url: Option<String>,
325        seed: Option<CachedToken>,
326        refresh_margin_secs: Option<i64>,
327        networks: Option<std::collections::HashMap<String, String>>,
328        payment: Option<PaymentConfig>,
329    ) -> Self {
330        RpcConfig {
331            endpoint_url,
332            seed,
333            refresh_margin_secs,
334            networks,
335            payment,
336        }
337    }
338}
339
340#[cfg_attr(feature = "python", gen_stub_pyclass)]
341#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
342#[cfg_attr(feature = "node", napi(object))]
343#[cfg_attr(feature = "rust", derive(Builder))]
344#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
345pub struct SqlConfig {
346    pub base_url: Option<String>,
347}
348
349#[cfg(feature = "python")]
350#[gen_stub_pymethods]
351#[pymethods]
352impl SqlConfig {
353    #[new]
354    #[pyo3(signature = (base_url=None))]
355    pub fn new(base_url: Option<String>) -> Self {
356        SqlConfig { base_url }
357    }
358}
359
360#[cfg_attr(feature = "python", gen_stub_pyclass)]
361#[cfg_attr(feature = "python", pyclass(get_all, set_all))]
362#[cfg_attr(feature = "node", napi(object))]
363#[cfg_attr(feature = "rust", derive(Builder))]
364#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
365pub struct SdkFullConfig {
366    /// Account API key. **Optional** so a keyless SDK can be built for the
367    /// crypto-micropayment lane (`rpc.call` with `RpcConfig.payment`). When
368    /// absent, no `x-api-key` header is installed: the payment lane works, while
369    /// the keyed surfaces (admin/streams/webhooks/kvstore/sql and tooling-JWT
370    /// `rpc.call`) send un-authenticated requests and the gateway rejects them
371    /// (surfacing as an `ApiError`, typically 401). `from_env` still requires
372    /// the key (validated in `from_config`) — only programmatic construction
373    /// may omit it.
374    #[serde(default)]
375    pub api_key: Option<String>,
376    pub http: Option<HttpConfig>,
377    pub admin: Option<AdminConfig>,
378    pub streams: Option<StreamsConfig>,
379    pub webhooks: Option<WebhooksConfig>,
380    pub kvstore: Option<KvStoreConfig>,
381    pub sql: Option<SqlConfig>,
382    pub rpc: Option<RpcConfig>,
383}
384
385impl SdkFullConfig {
386    pub fn from_api_key(api_key: String) -> Self {
387        SdkFullConfig {
388            api_key: Some(api_key),
389            http: None,
390            admin: None,
391            streams: None,
392            webhooks: None,
393            kvstore: None,
394            sql: None,
395            rpc: None,
396        }
397    }
398
399    /// Build a keyless config for the crypto-micropayment lane. No API key is
400    /// installed; the payment-lane `rpc.call` works, while every keyed surface
401    /// sends un-authenticated requests that the gateway rejects (`ApiError`).
402    pub fn keyless() -> Self {
403        SdkFullConfig {
404            api_key: None,
405            http: None,
406            admin: None,
407            streams: None,
408            webhooks: None,
409            kvstore: None,
410            sql: None,
411            rpc: None,
412        }
413    }
414
415    pub fn from_env() -> Result<Self, SdkError> {
416        config::Config::builder()
417            .add_source(
418                config::Environment::with_prefix("QN_SDK")
419                    .separator("__")
420                    .try_parsing(true),
421            )
422            .build()
423            .map_err(|e| SdkError::Config(e.to_string()))
424            .and_then(Self::from_config)
425    }
426
427    fn from_config(cfg: config::Config) -> Result<Self, SdkError> {
428        let parsed: SdkFullConfig = cfg
429            .try_deserialize::<SdkFullConfig>()
430            .map_err(|e| SdkError::Config(e.to_string()))?;
431        // from_env stays strict: it can't configure payments (payment is
432        // serde-skipped), so a from_env caller by definition wants the keyed
433        // lanes. Fail fast here rather than surfacing a confusing per-call
434        // Config error later from a typo'd env var.
435        if parsed.api_key.as_deref().unwrap_or("").is_empty() {
436            return Err(SdkError::Config(
437                "api_key is required (set QN_SDK__API_KEY)".into(),
438            ));
439        }
440        Ok(parsed)
441    }
442}
443
444#[cfg(feature = "python")]
445#[gen_stub_pymethods]
446#[pymethods]
447impl SdkFullConfig {
448    #[new]
449    #[pyo3(signature = (api_key=None, http=None, admin=None, streams=None, webhooks=None, kvstore=None, sql=None, rpc=None))]
450    #[allow(clippy::too_many_arguments)]
451    pub fn new(
452        api_key: Option<String>,
453        http: Option<HttpConfig>,
454        admin: Option<AdminConfig>,
455        streams: Option<StreamsConfig>,
456        webhooks: Option<WebhooksConfig>,
457        kvstore: Option<KvStoreConfig>,
458        sql: Option<SqlConfig>,
459        rpc: Option<RpcConfig>,
460    ) -> Self {
461        SdkFullConfig {
462            api_key,
463            http,
464            admin,
465            streams,
466            webhooks,
467            kvstore,
468            sql,
469            rpc,
470        }
471    }
472}
473
474#[cfg(test)]
475#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
476mod tests {
477    use super::*;
478
479    fn build_config(pairs: &[(&str, &str)]) -> config::Config {
480        let mut builder = config::Config::builder();
481        for (k, v) in pairs {
482            builder = builder.set_override(*k, *v).unwrap();
483        }
484        builder.build().unwrap()
485    }
486
487    #[test]
488    fn from_env_missing_api_key_returns_error() {
489        let cfg = config::Config::builder().build().unwrap();
490        assert!(matches!(
491            SdkFullConfig::from_config(cfg),
492            Err(SdkError::Config(_))
493        ));
494    }
495
496    #[test]
497    fn from_env_only_api_key() {
498        let cfg = build_config(&[("api_key", "test-key")]);
499        let config = SdkFullConfig::from_config(cfg).unwrap();
500        assert_eq!(config.api_key.as_deref(), Some("test-key"));
501        assert!(config.http.is_none());
502        assert!(config.admin.is_none());
503    }
504
505    #[test]
506    fn from_env_all_fields() {
507        let cfg = build_config(&[
508            ("api_key", "my-api-key"),
509            ("http.timeout_secs", "30"),
510            ("http.pool_max_idle_per_host", "5"),
511            ("admin.base_url", "https://example.com/"),
512        ]);
513        let config = SdkFullConfig::from_config(cfg).unwrap();
514        assert_eq!(config.api_key.as_deref(), Some("my-api-key"));
515        let http = config.http.unwrap();
516        assert_eq!(http.timeout_secs, Some(30));
517        assert_eq!(http.pool_max_idle_per_host, Some(5));
518        let admin = config.admin.unwrap();
519        assert_eq!(admin.base_url, Some("https://example.com/".to_string()));
520    }
521
522    #[test]
523    fn from_env_invalid_timeout_secs() {
524        let cfg = build_config(&[("api_key", "test-key"), ("http.timeout_secs", "abc")]);
525        assert!(matches!(
526            SdkFullConfig::from_config(cfg),
527            Err(SdkError::Config(_))
528        ));
529    }
530
531    #[test]
532    fn from_env_headers_round_trip() {
533        let cfg = build_config(&[
534            ("api_key", "k"),
535            ("http.headers.x-correlation-id", "abc"),
536            ("http.headers.user-agent", "custom-ua/1.0"),
537        ]);
538        let config = SdkFullConfig::from_config(cfg).unwrap();
539        let headers = config.http.unwrap().headers.unwrap();
540        assert_eq!(
541            headers.get("x-correlation-id").map(String::as_str),
542            Some("abc")
543        );
544        assert_eq!(
545            headers.get("user-agent").map(String::as_str),
546            Some("custom-ua/1.0")
547        );
548    }
549}