diff --git a/crates/core/README.md b/crates/core/README.md index 861e2a9..5a5cb56 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -97,6 +97,33 @@ Environment variables (prefix `QN_SDK__`, separator `__`): | `QN_SDK__STREAMS__BASE_URL` | no | `https://api.quicknode.com/streams/rest/v1/` | Override streams base URL | | `QN_SDK__WEBHOOKS__BASE_URL` | no | `https://api.quicknode.com/webhooks/rest/v1/` | Override webhooks base URL | | `QN_SDK__KVSTORE__BASE_URL` | no | `https://api.quicknode.com/kv/rest/v1/` | Override KV store base URL | +| `QN_SDK__HTTP__HEADERS__` | no | — | Custom HTTP header sent on every request. Overrides SDK-managed headers (see below). | + +### Custom headers and `User-Agent` + +Every outbound HTTP request includes an auto-generated `User-Agent` of the form: + +``` +quicknode-sdk-/ (-; -) +``` + +You can attach arbitrary headers via `HttpConfig.headers`. **These headers OVERRIDE any SDK-managed header with the same name**, including `User-Agent`, `x-api-key`, `Accept`, and `Content-Type`. Use this to inject correlation IDs, proxy auth, or to replace the default `User-Agent`. Header names are matched case-insensitively. + +```rust +use std::collections::HashMap; +use quicknode_sdk::{HttpConfig, QuicknodeSdk, SdkFullConfig}; + +let mut headers = HashMap::new(); +headers.insert("X-Correlation-Id".to_string(), "abc-123".to_string()); +headers.insert("User-Agent".to_string(), "my-app/1.0".to_string()); // overrides SDK default + +let qn = QuicknodeSdk::new( + &SdkFullConfig::builder() + .api_key("your-key") + .http(HttpConfig { headers: Some(headers), ..Default::default() }) + .build(), +)?; +``` ## Platform Support diff --git a/crates/core/examples/admin_e2e.rs b/crates/core/examples/admin_e2e.rs index 5b406ec..6a76e2a 100644 --- a/crates/core/examples/admin_e2e.rs +++ b/crates/core/examples/admin_e2e.rs @@ -857,6 +857,34 @@ async fn main() { other => eprintln!("expected Api 404 from delete_rate_limit_override, got {other:?}"), } + // Custom headers smoke test — construct an SDK that overrides User-Agent + // and adds a correlation header. We don't verify what reaches the wire + // here; we just confirm the config is accepted and an API call succeeds. + let mut custom_headers = std::collections::HashMap::new(); + custom_headers.insert("User-Agent".to_string(), "qn-e2e-rust/1.0".to_string()); + custom_headers.insert("X-E2E-Correlation".to_string(), "rust-smoke".to_string()); + let with_headers = SdkFullConfig { + api_key: config.api_key.clone(), + http: Some(HttpConfig { + timeout_secs: None, + pool_max_idle_per_host: None, + headers: Some(custom_headers), + }), + admin: config.admin.clone(), + streams: None, + webhooks: None, + kvstore: None, + }; + let headered = QuicknodeSdk::new(&with_headers).expect("build sdk with custom headers"); + match headered + .admin + .get_endpoints(&GetEndpointsRequest::builder().limit(1).build()) + .await + { + Ok(_) => println!("custom-headers smoke: ok"), + Err(e) => eprintln!("custom-headers smoke error: {e}"), + } + // 2) Timeout path — unreachable base URL + 1s timeout forces a timeout // from reqwest, which maps to SdkError::Http with http_kind() == Timeout. let blackhole = SdkFullConfig { @@ -864,6 +892,7 @@ async fn main() { http: Some(HttpConfig { timeout_secs: Some(1), pool_max_idle_per_host: None, + headers: None, }), admin: Some(AdminConfig { base_url: Some("http://10.255.255.1/".to_string()), diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs index bda588f..d47be76 100644 --- a/crates/core/src/admin/mod.rs +++ b/crates/core/src/admin/mod.rs @@ -3667,6 +3667,7 @@ mod tests { http: Some(HttpConfig { timeout_secs: Some(-1), pool_max_idle_per_host: None, + headers: None, }), admin: None, streams: None, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 8007018..4baa1d5 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -17,6 +17,13 @@ use crate::errors::SdkError; pub struct HttpConfig { pub timeout_secs: Option, pub pool_max_idle_per_host: Option, + /// Custom HTTP headers added to every outbound request. + /// + /// **These headers OVERRIDE any SDK-managed header with the same name**, + /// including `User-Agent`, `x-api-key`, `Accept`, and `Content-Type`. + /// Header names are matched case-insensitively. Use this to override the + /// auto-generated User-Agent or inject correlation IDs, proxy auth, etc. + pub headers: Option>, } #[cfg(feature = "python")] @@ -24,15 +31,35 @@ pub struct HttpConfig { #[pymethods] impl HttpConfig { #[new] - #[pyo3(signature = (timeout_secs=None, pool_max_idle_per_host=None))] - pub fn new(timeout_secs: Option, pool_max_idle_per_host: Option) -> Self { + #[pyo3(signature = (timeout_secs=None, pool_max_idle_per_host=None, headers=None))] + pub fn new( + timeout_secs: Option, + pool_max_idle_per_host: Option, + headers: Option>, + ) -> Self { HttpConfig { timeout_secs, pool_max_idle_per_host, + headers, } } } +/// Identifies the language and runtime making SDK calls. Each binding crate +/// (Python, Node, Ruby) constructs this and passes it through +/// [`SdkConfig::new_with_client_info`] so the SDK's auto-generated +/// `User-Agent` reflects the actual caller, not the underlying Rust core. +#[derive(Debug, Clone)] +pub struct ClientInfo { + /// Short language identifier, e.g. `"python"`, `"node"`, `"ruby"`, `"rust"`. + pub language: String, + /// Runtime version of the language, e.g. `"3.12.4"`, `"20.10.0"`, `"3.3.0"`. + pub language_version: String, + /// Version string of the language-specific SDK package — read from the + /// language's own manifest (PyPI version, npm version, gem version). + pub sdk_version: String, +} + #[cfg_attr(feature = "python", gen_stub_pyclass)] #[cfg_attr(feature = "python", pyclass(get_all, set_all))] #[cfg_attr(feature = "node", napi(object))] @@ -238,4 +265,23 @@ mod tests { Err(SdkError::Config(_)) )); } + + #[test] + fn from_env_headers_round_trip() { + let cfg = build_config(&[ + ("api_key", "k"), + ("http.headers.x-correlation-id", "abc"), + ("http.headers.user-agent", "custom-ua/1.0"), + ]); + let config = SdkFullConfig::from_config(cfg).unwrap(); + let headers = config.http.unwrap().headers.unwrap(); + assert_eq!( + headers.get("x-correlation-id").map(String::as_str), + Some("abc") + ); + assert_eq!( + headers.get("user-agent").map(String::as_str), + Some("custom-ua/1.0") + ); + } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 02e5703..06c678e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,7 +6,8 @@ pub mod streams; pub mod webhooks; pub use config::{ - AdminConfig, HttpConfig, KvStoreConfig, SdkFullConfig, StreamsConfig, WebhooksConfig, + AdminConfig, ClientInfo, HttpConfig, KvStoreConfig, SdkFullConfig, StreamsConfig, + WebhooksConfig, }; pub use kvstore::{ AddListItemParams, BulkSetsParams, CreateListParams, CreateSetParams, GetListData, @@ -15,7 +16,7 @@ pub use kvstore::{ UpdateListParams, }; -use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use reqwest::Client as ReqwestClient; use std::sync::Arc; @@ -23,6 +24,34 @@ use errors::SdkError; const DEFAULT_TIMEOUT_SECS: u64 = 30; +/// Build the auto-generated `User-Agent` value for a given caller. +/// +/// Shape: `quicknode-sdk-{language}/{sdk_version} ({os}-{arch}; {language}-{language_version})` +fn build_user_agent(info: &ClientInfo) -> String { + format!( + "quicknode-sdk-{lang}/{ver} ({os}-{arch}; {lang}-{lang_ver})", + lang = info.language, + ver = info.sdk_version, + os = std::env::consts::OS, + arch = std::env::consts::ARCH, + lang_ver = info.language_version, + ) +} + +/// `ClientInfo` used when `SdkConfig::new` is called directly (pure-Rust path). +fn default_rust_client_info() -> ClientInfo { + ClientInfo { + language: "rust".to_string(), + // CARGO_PKG_RUST_VERSION is the MSRV declared in Cargo.toml. We have + // no way to read the actual rustc version that compiled the caller, + // so MSRV is the closest stable identifier. + language_version: option_env!("CARGO_PKG_RUST_VERSION") + .unwrap_or("unknown") + .to_string(), + sdk_version: env!("CARGO_PKG_VERSION").to_string(), + } +} + // Using Arc for the inner config to keep as a cheap clone #[derive(Clone)] pub struct SdkConfig(Arc); @@ -48,7 +77,21 @@ struct SdkConfigInner { } impl SdkConfig { + /// Build an `SdkConfig` for a pure-Rust caller. The `User-Agent` will + /// identify the core crate (`quicknode-sdk-rust/`). pub fn new(config: &SdkFullConfig) -> Result { + Self::new_with_client_info(config, None) + } + + /// Build an `SdkConfig` while attributing the `User-Agent` to a specific + /// language binding (Python/Node/Ruby). Used by the binding crates so + /// telemetry on the server side reflects the actual caller. + /// + /// If `client_info` is `None`, falls back to the pure-Rust identity. + pub fn new_with_client_info( + config: &SdkFullConfig, + client_info: Option, + ) -> Result { let mut builder = ReqwestClient::builder(); let timeout_secs = match &config.http { @@ -82,6 +125,28 @@ impl SdkConfig { "x-api-key", HeaderValue::from_str(&config.api_key).map_err(|e| SdkError::Config(e.to_string()))?, ); + let ua = build_user_agent(&client_info.unwrap_or_else(default_rust_client_info)); + default_headers.insert( + reqwest::header::USER_AGENT, + HeaderValue::from_str(&ua).map_err(|e| SdkError::Config(e.to_string()))?, + ); + + // Caller-supplied headers override anything above. `HeaderMap::insert` + // replaces existing values for the same name. + if let Some(http) = &config.http { + if let Some(custom) = &http.headers { + for (name, value) in custom { + let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| { + SdkError::Config(format!("invalid header name {name:?}: {e}")) + })?; + let header_value = HeaderValue::from_str(value).map_err(|e| { + SdkError::Config(format!("invalid header value for {name:?}: {e}")) + })?; + default_headers.insert(header_name, header_value); + } + } + } + builder = builder.default_headers(default_headers); let http_client = builder @@ -136,7 +201,17 @@ pub struct QuicknodeSdk { impl QuicknodeSdk { /// Creates a new SDK instance from an explicit configuration. pub fn new(config: &SdkFullConfig) -> Result { - let sdk_config = SdkConfig::new(config)?; + Self::new_with_client_info(config, None) + } + + /// Creates a new SDK instance, attributing the auto-generated `User-Agent` + /// to a specific language binding. Used internally by Python/Node/Ruby + /// binding crates. + pub fn new_with_client_info( + config: &SdkFullConfig, + client_info: Option, + ) -> Result { + let sdk_config = SdkConfig::new_with_client_info(config, client_info)?; Ok(Self { admin: admin::AdminApiClient::new(sdk_config.clone()), streams: streams::StreamsApiClient::new(sdk_config.clone()), @@ -149,4 +224,123 @@ impl QuicknodeSdk { pub fn from_env() -> Result { Self::new(&SdkFullConfig::from_env()?) } + + /// Same as [`Self::from_env`] but with a binding-supplied [`ClientInfo`]. + pub fn from_env_with_client_info(client_info: Option) -> Result { + Self::new_with_client_info(&SdkFullConfig::from_env()?, client_info) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod headers_tests { + use super::*; + use std::collections::HashMap; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn base_config(api_key: &str) -> SdkFullConfig { + SdkFullConfig { + api_key: api_key.to_string(), + http: None, + admin: None, + streams: None, + webhooks: None, + kvstore: None, + } + } + + fn binding_info() -> ClientInfo { + ClientInfo { + language: "python".to_string(), + language_version: "3.12.4".to_string(), + sdk_version: "1.2.3".to_string(), + } + } + + #[test] + fn default_user_agent_identifies_rust_core() { + let ua = build_user_agent(&default_rust_client_info()); + assert!(ua.starts_with("quicknode-sdk-rust/")); + assert!(ua.contains(env!("CARGO_PKG_VERSION"))); + assert!(ua.contains(std::env::consts::OS)); + assert!(ua.contains(std::env::consts::ARCH)); + } + + #[test] + fn binding_user_agent_identifies_language() { + let ua = build_user_agent(&binding_info()); + let expected_prefix = "quicknode-sdk-python/1.2.3"; + assert!(ua.starts_with(expected_prefix), "got: {ua}"); + assert!(ua.contains("python-3.12.4")); + } + + #[test] + fn invalid_custom_header_name_errors() { + let mut cfg = base_config("k"); + let mut h = HashMap::new(); + h.insert("bad header".to_string(), "v".to_string()); + cfg.http = Some(HttpConfig { + timeout_secs: None, + pool_max_idle_per_host: None, + headers: Some(h), + }); + assert!(matches!(SdkConfig::new(&cfg), Err(SdkError::Config(_)))); + } + + #[test] + fn invalid_custom_header_value_errors() { + let mut cfg = base_config("k"); + let mut h = HashMap::new(); + // Newline is not a valid header value byte. + h.insert("X-Test".to_string(), "bad\nvalue".to_string()); + cfg.http = Some(HttpConfig { + timeout_secs: None, + pool_max_idle_per_host: None, + headers: Some(h), + }); + assert!(matches!(SdkConfig::new(&cfg), Err(SdkError::Config(_)))); + } + + #[tokio::test] + async fn default_user_agent_reaches_wire_and_custom_headers_override() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/endpoints")) + .and(header("user-agent", "custom-ua/9.9")) + .and(header("x-correlation-id", "abc")) + // x-api-key override also wins + .and(header("x-api-key", "override-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [], "error": null, "pagination": null + }))) + .mount(&server) + .await; + + let mut headers = HashMap::new(); + headers.insert("User-Agent".to_string(), "custom-ua/9.9".to_string()); + headers.insert("X-Correlation-Id".to_string(), "abc".to_string()); + headers.insert("x-api-key".to_string(), "override-key".to_string()); + + let cfg = SdkFullConfig { + api_key: "real-key".to_string(), + http: Some(HttpConfig { + timeout_secs: None, + pool_max_idle_per_host: None, + headers: Some(headers), + }), + admin: Some(AdminConfig { + base_url: Some(format!("{}/", server.uri())), + }), + streams: None, + webhooks: None, + kvstore: None, + }; + + let sdk = QuicknodeSdk::new(&cfg).unwrap(); + sdk.admin + .get_endpoints(&admin::GetEndpointsRequest::default()) + .await + .unwrap(); + } } diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 0396090..7c78069 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -21,3 +21,4 @@ serde_json = "1.0" [build-dependencies] napi-build = "2" +serde_json = "1.0" diff --git a/crates/node/build.rs b/crates/node/build.rs index 9fc2367..b7bec03 100644 --- a/crates/node/build.rs +++ b/crates/node/build.rs @@ -1,5 +1,35 @@ +// Build scripts have no way to signal failure to cargo other than panicking, +// so the workspace-wide bans on `panic!` / `expect` do not apply here. +#![allow(clippy::panic, clippy::expect_used)] + extern crate napi_build; +use std::fs; +use std::path::PathBuf; + fn main() { napi_build::setup(); + + // Read the npm package version from npm/package.json and expose it as + // an env var the source can read with env!("NPM_PACKAGE_VERSION"). + // The npm package version (3.x.y) differs from the workspace version + // (0.x.y) because @quicknode/sdk 2.x already shipped on npm. + let pkg_path: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("npm") + .join("package.json"); + println!("cargo:rerun-if-changed={}", pkg_path.display()); + + let contents = fs::read_to_string(&pkg_path) + .unwrap_or_else(|e| panic!("read {}: {e}", pkg_path.display())); + let v: serde_json::Value = + serde_json::from_str(&contents).expect("npm/package.json is not valid JSON"); + let version = v + .get("version") + .and_then(|x| x.as_str()) + .expect("npm/package.json missing string \"version\"") + .to_string(); + + println!("cargo:rustc-env=NPM_PACKAGE_VERSION={version}"); } diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 385faf3..efbe26f 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -17,13 +17,35 @@ pub struct QuicknodeSdk { kvstore: KvStoreApiClient, } +/// Build a [`core::ClientInfo`] from the live Node.js runtime so the SDK's +/// `User-Agent` reflects the installed `@quicknode/sdk` npm package and the +/// running Node.js interpreter. Failures fall back to `"unknown"` rather +/// than aborting the SDK constructor. +fn node_client_info(env: &Env) -> core::ClientInfo { + let language_version = (|| -> Result { + let process: Object = env.get_global()?.get_named_property("process")?; + let s: String = process.get_named_property("version")?; + // Node's process.version is e.g. "v20.10.0" — strip the leading "v". + Ok(s.strip_prefix('v').unwrap_or(&s).to_string()) + })() + .unwrap_or_else(|_| "unknown".to_string()); + + core::ClientInfo { + language: "node".to_string(), + language_version, + sdk_version: env!("NPM_PACKAGE_VERSION").to_string(), + } +} + #[napi] impl QuicknodeSdk { /// Creates a new SDK instance from an explicit configuration. #[napi(constructor)] #[allow(clippy::needless_pass_by_value)] - pub fn new(config: core::SdkFullConfig) -> Result { - let sdk_config = core::SdkConfig::new(&config).map_err(errors::map_sdk_err)?; + pub fn new(env: Env, config: core::SdkFullConfig) -> Result { + let sdk_config = + core::SdkConfig::new_with_client_info(&config, Some(node_client_info(&env))) + .map_err(errors::map_sdk_err)?; Ok(Self { admin: AdminApiClient { inner: core::admin::AdminApiClient::new(sdk_config.clone()), diff --git a/crates/python/src/lib.rs b/crates/python/src/lib.rs index 421ea4b..2e41888 100644 --- a/crates/python/src/lib.rs +++ b/crates/python/src/lib.rs @@ -24,14 +24,44 @@ pub struct QuicknodeSdk { kvstore: KvStoreApiClient, } +/// Build a [`core::ClientInfo`] from the live Python runtime so the SDK's +/// `User-Agent` reflects the installed `quicknode-sdk` PyPI package and the +/// running Python interpreter. +fn python_client_info(py: Python<'_>) -> core::ClientInfo { + let language_version = py + .import("sys") + .and_then(|sys| sys.getattr("version_info")) + .and_then(|v| { + let major: u32 = v.getattr("major")?.extract()?; + let minor: u32 = v.getattr("minor")?.extract()?; + let micro: u32 = v.getattr("micro")?.extract()?; + Ok(format!("{major}.{minor}.{micro}")) + }) + .unwrap_or_else(|_| "unknown".to_string()); + + let sdk_version = py + .import("importlib.metadata") + .and_then(|m| m.call_method1("version", ("quicknode-sdk",))) + .and_then(|v| v.extract::()) + .unwrap_or_else(|_| "unknown".to_string()); + + core::ClientInfo { + language: "python".to_string(), + language_version, + sdk_version, + } +} + #[gen_stub_pymethods] #[pymethods] impl QuicknodeSdk { /// Creates a new SDK instance from an explicit configuration. #[new] #[allow(clippy::needless_pass_by_value)] - fn new(config: core::SdkFullConfig) -> PyResult { - let sdk_config = core::SdkConfig::new(&config).map_err(errors::map_sdk_err)?; + fn new(py: Python<'_>, config: core::SdkFullConfig) -> PyResult { + let sdk_config = + core::SdkConfig::new_with_client_info(&config, Some(python_client_info(py))) + .map_err(errors::map_sdk_err)?; Ok(Self { admin: AdminApiClient { inner: core::admin::AdminApiClient::new(sdk_config.clone()), @@ -50,8 +80,8 @@ impl QuicknodeSdk { /// Creates a new SDK instance using configuration from environment variables. #[staticmethod] - fn from_env() -> PyResult { - core::QuicknodeSdk::from_env() + fn from_env(py: Python<'_>) -> PyResult { + core::QuicknodeSdk::from_env_with_client_info(Some(python_client_info(py))) .map(|sdk| Self { admin: AdminApiClient { inner: sdk.admin }, streams: StreamsApiClient { inner: sdk.streams }, diff --git a/crates/ruby/build.rs b/crates/ruby/build.rs index 5d72549..be35320 100644 --- a/crates/ruby/build.rs +++ b/crates/ruby/build.rs @@ -1,3 +1,37 @@ +// Build scripts have no way to signal failure to cargo other than panicking, +// so the workspace-wide bans on `panic!` / `expect` do not apply here. +#![allow(clippy::panic, clippy::expect_used)] + +use std::fs; +use std::path::PathBuf; + fn main() { rb_sys_build::rb_config().print_cargo_args(); + + // Extract the gem version from the gemspec so the User-Agent reflects + // the published gem version. Parsed line-by-line (rather than executing + // the gemspec) so the build does not depend on a Ruby toolchain. + let gemspec_path: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("ruby") + .join("quicknode_sdk.gemspec"); + println!("cargo:rerun-if-changed={}", gemspec_path.display()); + + let contents = fs::read_to_string(&gemspec_path) + .unwrap_or_else(|e| panic!("read {}: {e}", gemspec_path.display())); + let version = contents + .lines() + .find_map(|line| { + let trimmed = line.trim(); + // Match lines like: s.version = "0.1.0-alpha.27" + let after_eq = trimmed.strip_prefix("s.version")?.trim_start(); + let after_eq = after_eq.strip_prefix('=')?.trim(); + let after_quote = after_eq.strip_prefix('"')?; + let end = after_quote.find('"')?; + Some(after_quote[..end].to_string()) + }) + .expect("could not parse s.version from quicknode_sdk.gemspec"); + + println!("cargo:rustc-env=GEM_VERSION={version}"); } diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index 63259ef..ed6ed14 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -241,9 +241,43 @@ pub struct QuicknodeSdk { inner: core::QuicknodeSdk, } +/// Build a [`core::ClientInfo`] from the live Ruby runtime so the SDK's +/// `User-Agent` reflects the installed `quicknode_sdk` gem and the running +/// Ruby interpreter. Failures fall back to `"unknown"` rather than +/// aborting the SDK constructor. +fn ruby_client_info() -> core::ClientInfo { + let language_version = + magnus::eval::("RUBY_VERSION").unwrap_or_else(|_| "unknown".to_string()); + + core::ClientInfo { + language: "ruby".to_string(), + language_version, + sdk_version: env!("GEM_VERSION").to_string(), + } +} + impl QuicknodeSdk { fn from_env() -> Result { - core::QuicknodeSdk::from_env() + core::QuicknodeSdk::from_env_with_client_info(Some(ruby_client_info())) + .map(|inner| Self { inner }) + .map_err(map_err) + } + + /// Build an SDK from an explicit configuration hash. Supports custom + /// headers, timeouts, and base URLs without going through env vars. + /// + /// Accepts the same nested shape `from_env` does, e.g. + /// `{ api_key: "...", http: { headers: { "X-Foo" => "bar" } } }`. + fn from_config(opts: RHash) -> Result { + validate_keys( + &opts, + &["api_key", "http", "admin", "streams", "webhooks", "kvstore"], + )?; + let config: core::SdkFullConfig = + serde_magnus::deserialize(&ruby(), opts).map_err(|e| { + Error::new(ruby().exception_arg_error(), format!("invalid config: {e}")) + })?; + core::QuicknodeSdk::new_with_client_info(&config, Some(ruby_client_info())) .map(|inner| Self { inner }) .map_err(map_err) } @@ -1763,6 +1797,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { // ── SDK root ────────────────────────────────────────────── let sdk = native.define_class("SDK", ruby.class_object())?; sdk.define_singleton_method("from_env", function!(QuicknodeSdk::from_env, 0))?; + sdk.define_singleton_method("from_config", function!(QuicknodeSdk::from_config, 1))?; sdk.define_method("admin", method!(QuicknodeSdk::admin, 0))?; sdk.define_method("streams", method!(QuicknodeSdk::streams, 0))?; sdk.define_method("webhooks", method!(QuicknodeSdk::webhooks, 0))?; diff --git a/npm/README.md b/npm/README.md index 24bf54e..fb92f5c 100644 --- a/npm/README.md +++ b/npm/README.md @@ -94,6 +94,31 @@ Environment variables (prefix `QN_SDK__`, separator `__`): | `QN_SDK__STREAMS__BASE_URL` | no | `https://api.quicknode.com/streams/rest/v1/` | Override streams base URL | | `QN_SDK__WEBHOOKS__BASE_URL` | no | `https://api.quicknode.com/webhooks/rest/v1/` | Override webhooks base URL | | `QN_SDK__KVSTORE__BASE_URL` | no | `https://api.quicknode.com/kv/rest/v1/` | Override KV store base URL | +| `QN_SDK__HTTP__HEADERS__` | no | — | Custom HTTP header sent on every request. Overrides SDK-managed headers (see below). | + +### Custom headers and `User-Agent` + +Every outbound HTTP request includes an auto-generated `User-Agent` of the form: + +``` +quicknode-sdk-/ (-; -) +``` + +You can attach arbitrary headers via `HttpConfig.headers`. **These headers OVERRIDE any SDK-managed header with the same name**, including `User-Agent`, `x-api-key`, `Accept`, and `Content-Type`. Use this to inject correlation IDs, proxy auth, or to replace the default `User-Agent`. Header names are matched case-insensitively. + +```ts +import { QuicknodeSdk } from "@quicknode/sdk"; + +const qn = new QuicknodeSdk({ + apiKey: "your-key", + http: { + headers: { + "X-Correlation-Id": "abc-123", + "User-Agent": "my-app/1.0", // overrides SDK default + }, + }, +}); +``` ## Platform Support diff --git a/npm/examples/admin.ts b/npm/examples/admin.ts index 6fa464a..ed40e7e 100644 --- a/npm/examples/admin.ts +++ b/npm/examples/admin.ts @@ -95,6 +95,24 @@ async function main() { console.log(`deleteRateLimitOverride api error ${e.status}: ${e.body.slice(0, 80)}`); } + // Custom headers smoke test — override User-Agent + add a correlation header. + const headered = new QuicknodeSdk({ + apiKey: process.env.QN_SDK__API_KEY ?? "", + http: { + headers: { + "User-Agent": "qn-e2e-node/1.0", + "X-E2E-Correlation": "node-smoke", + }, + }, + }); + try { + const resp = await headered.admin.getEndpoints({ limit: 1 }); + console.log(`custom-headers smoke: ok (${resp.data.length} endpoints)`); + } catch (e) { + if (!(e instanceof QuicknodeError)) throw e; + console.log(`custom-headers smoke error: ${e.message}`); + } + // 2) Timeout path — unreachable base URL + 1s timeout forces a timeout. const blackhole = new QuicknodeSdk({ apiKey: process.env.QN_SDK__API_KEY ?? "", diff --git a/npm/index.d.ts b/npm/index.d.ts index dd4bf16..031f424 100644 --- a/npm/index.d.ts +++ b/npm/index.d.ts @@ -938,6 +938,15 @@ export interface GetWebhooksParams { export interface HttpConfig { timeoutSecs?: number poolMaxIdlePerHost?: number + /** + * Custom HTTP headers added to every outbound request. + * + * **These headers OVERRIDE any SDK-managed header with the same name**, + * including `User-Agent`, `x-api-key`, `Accept`, and `Content-Type`. + * Header names are matched case-insensitively. Use this to override the + * auto-generated User-Agent or inject correlation IDs, proxy auth, etc. + */ + headers?: Record } /** Template arguments for a Hyperliquid wallet-events filter. */ diff --git a/python/README.md b/python/README.md index b963eeb..ef92d28 100644 --- a/python/README.md +++ b/python/README.md @@ -98,6 +98,31 @@ Environment variables (prefix `QN_SDK__`, separator `__`): | `QN_SDK__STREAMS__BASE_URL` | no | `https://api.quicknode.com/streams/rest/v1/` | Override streams base URL | | `QN_SDK__WEBHOOKS__BASE_URL` | no | `https://api.quicknode.com/webhooks/rest/v1/` | Override webhooks base URL | | `QN_SDK__KVSTORE__BASE_URL` | no | `https://api.quicknode.com/kv/rest/v1/` | Override KV store base URL | +| `QN_SDK__HTTP__HEADERS__` | no | — | Custom HTTP header sent on every request. Overrides SDK-managed headers (see below). | + +### Custom headers and `User-Agent` + +Every outbound HTTP request includes an auto-generated `User-Agent` of the form: + +``` +quicknode-sdk-/ (-; -) +``` + +You can attach arbitrary headers via `HttpConfig.headers`. **These headers OVERRIDE any SDK-managed header with the same name**, including `User-Agent`, `x-api-key`, `Accept`, and `Content-Type`. Use this to inject correlation IDs, proxy auth, or to replace the default `User-Agent`. Header names are matched case-insensitively. + +```python +from sdk import QuicknodeSdk, SdkFullConfig, HttpConfig + +qn = QuicknodeSdk( + SdkFullConfig( + api_key="your-key", + http=HttpConfig(headers={ + "X-Correlation-Id": "abc-123", + "User-Agent": "my-app/1.0", # overrides SDK default + }), + ) +) +``` ## Platform Support diff --git a/python/examples/admin.py b/python/examples/admin.py index 63efd9e..c60654e 100644 --- a/python/examples/admin.py +++ b/python/examples/admin.py @@ -88,6 +88,24 @@ async def main(): assert e.status == 404 print(f"delete_rate_limit_override api error {e.status}: {e.body[:80]}") + # Custom headers smoke test — override User-Agent + add a correlation header. + headered = QuicknodeSdk( + SdkFullConfig( + api_key=os.environ["QN_SDK__API_KEY"], + http=HttpConfig( + headers={ + "User-Agent": "qn-e2e-python/1.0", + "X-E2E-Correlation": "python-smoke", + } + ), + ) + ) + try: + resp = await headered.admin.get_endpoints(limit=1) + print(f"custom-headers smoke: ok ({len(resp.data)} endpoints)") + except QuicknodeError as e: + print(f"custom-headers smoke error: {e}") + # 2) Timeout path — unreachable base URL + 1s timeout forces a timeout. blackhole = QuicknodeSdk( SdkFullConfig( diff --git a/python/sdk/__init__.py b/python/sdk/__init__.py index 16034b3..ca75438 100644 --- a/python/sdk/__init__.py +++ b/python/sdk/__init__.py @@ -12,22 +12,12 @@ S3Attributes, AzureAttributes, PostgresAttributes, - MysqlAttributes, - MongoAttributes, - ClickhouseAttributes, - SnowflakeAttributes, KafkaAttributes, - RedisAttributes, StreamWebhookDestination, StreamS3Destination, StreamAzureDestination, StreamPostgresDestination, - StreamMysqlDestination, - StreamMongoDestination, - StreamClickhouseDestination, - StreamSnowflakeDestination, StreamKafkaDestination, - StreamRedisDestination, AddressBookConfig, Endpoint, EndpointTag, @@ -212,16 +202,6 @@ "AdminApiClient", "StreamsApiClient", "Stream", - "StreamWebhookDestination", - "StreamS3Destination", - "StreamAzureDestination", - "StreamPostgresDestination", - "StreamMysqlDestination", - "StreamMongoDestination", - "StreamClickhouseDestination", - "StreamSnowflakeDestination", - "StreamKafkaDestination", - "StreamRedisDestination", "ListStreamsResponse", "PageInfo", "TestFilterResponse", @@ -230,12 +210,12 @@ "S3Attributes", "AzureAttributes", "PostgresAttributes", - "MysqlAttributes", - "MongoAttributes", - "ClickhouseAttributes", - "SnowflakeAttributes", "KafkaAttributes", - "RedisAttributes", + "StreamWebhookDestination", + "StreamS3Destination", + "StreamAzureDestination", + "StreamPostgresDestination", + "StreamKafkaDestination", "AddressBookConfig", "StreamsConfig", "Endpoint", diff --git a/python/sdk/_core/__init__.pyi b/python/sdk/_core/__init__.pyi index b5cb51c..f7c6fc7 100644 --- a/python/sdk/_core/__init__.pyi +++ b/python/sdk/_core/__init__.pyi @@ -3517,7 +3517,27 @@ class HttpConfig: def pool_max_idle_per_host(self) -> typing.Optional[builtins.int]: ... @pool_max_idle_per_host.setter def pool_max_idle_per_host(self, value: typing.Optional[builtins.int]) -> None: ... - def __new__(cls, timeout_secs: typing.Optional[builtins.int] = None, pool_max_idle_per_host: typing.Optional[builtins.int] = None) -> HttpConfig: ... + @property + def headers(self) -> typing.Optional[builtins.dict[builtins.str, builtins.str]]: + r""" + Custom HTTP headers added to every outbound request. + + **These headers OVERRIDE any SDK-managed header with the same name**, + including `User-Agent`, `x-api-key`, `Accept`, and `Content-Type`. + Header names are matched case-insensitively. Use this to override the + auto-generated User-Agent or inject correlation IDs, proxy auth, etc. + """ + @headers.setter + def headers(self, value: typing.Optional[builtins.dict[builtins.str, builtins.str]]) -> None: + r""" + Custom HTTP headers added to every outbound request. + + **These headers OVERRIDE any SDK-managed header with the same name**, + including `User-Agent`, `x-api-key`, `Accept`, and `Content-Type`. + Header names are matched case-insensitively. Use this to override the + auto-generated User-Agent or inject correlation IDs, proxy auth, etc. + """ + def __new__(cls, timeout_secs: typing.Optional[builtins.int] = None, pool_max_idle_per_host: typing.Optional[builtins.int] = None, headers: typing.Optional[typing.Mapping[builtins.str, builtins.str]] = None) -> HttpConfig: ... @typing.final class HyperliquidWalletEventsFilterArgs: diff --git a/ruby/README.md b/ruby/README.md index e469ba1..92f235c 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -70,6 +70,10 @@ There are two ways to configure the SDK. ### Option A — Pass config directly +```ruby +qn = QuicknodeSdk::SDK.from_config(api_key: "your-key") +``` + ### Option B — Load from environment (`from_env()`) ```ruby @@ -88,6 +92,31 @@ Environment variables (prefix `QN_SDK__`, separator `__`): | `QN_SDK__STREAMS__BASE_URL` | no | `https://api.quicknode.com/streams/rest/v1/` | Override streams base URL | | `QN_SDK__WEBHOOKS__BASE_URL` | no | `https://api.quicknode.com/webhooks/rest/v1/` | Override webhooks base URL | | `QN_SDK__KVSTORE__BASE_URL` | no | `https://api.quicknode.com/kv/rest/v1/` | Override KV store base URL | +| `QN_SDK__HTTP__HEADERS__` | no | — | Custom HTTP header sent on every request. Overrides SDK-managed headers (see below). | + +### Custom headers and `User-Agent` + +Every outbound HTTP request includes an auto-generated `User-Agent` of the form: + +``` +quicknode-sdk-/ (-; -) +``` + +You can attach arbitrary headers via `HttpConfig.headers`. **These headers OVERRIDE any SDK-managed header with the same name**, including `User-Agent`, `x-api-key`, `Accept`, and `Content-Type`. Use this to inject correlation IDs, proxy auth, or to replace the default `User-Agent`. Header names are matched case-insensitively. + +```ruby +qn = QuicknodeSdk::SDK.from_config( + api_key: "your-key", + http: { + headers: { + "X-Correlation-Id" => "abc-123", + "User-Agent" => "my-app/1.0", # overrides SDK default + }, + }, +) +``` + +You can also set custom headers via env vars (`QN_SDK__HTTP__HEADERS__`) when using `from_env`. ## Platform Support diff --git a/ruby/examples/admin_e2e.rb b/ruby/examples/admin_e2e.rb index 7db2f57..86d4a6c 100644 --- a/ruby/examples/admin_e2e.rb +++ b/ruby/examples/admin_e2e.rb @@ -374,6 +374,23 @@ puts "delete_rate_limit_override api error #{e.status}: #{e.body[0, 80]}" end +# Custom headers smoke test — override User-Agent + add a correlation header. +headered = QuicknodeSdk::SDK.from_config( + api_key: ENV.fetch("QN_SDK__API_KEY"), + http: { + headers: { + "User-Agent" => "qn-e2e-ruby/1.0", + "X-E2E-Correlation" => "ruby-smoke", + }, + }, +) +begin + resp = headered.admin.get_endpoints(limit: 1) + puts "custom-headers smoke: ok (#{resp[:data].length} endpoints)" +rescue QuicknodeSdk::Error => e + puts "custom-headers smoke error: #{e.message}" +end + # 2) Timeout path — unreachable base URL + 1s timeout forces a timeout prev_url = ENV["QN_SDK__ADMIN__BASE_URL"] prev_timeout = ENV["QN_SDK__HTTP__TIMEOUT_SECS"] diff --git a/ruby/lib/quicknode_sdk/sdk.rb b/ruby/lib/quicknode_sdk/sdk.rb index b6c218b..91a7cce 100644 --- a/ruby/lib/quicknode_sdk/sdk.rb +++ b/ruby/lib/quicknode_sdk/sdk.rb @@ -4,6 +4,17 @@ def self.from_env new(Native::SDK.from_env) end + # Build an SDK from an explicit config hash. Supports custom headers, + # timeouts, and base URLs without relying on env vars. + # + # QuicknodeSdk::SDK.from_config( + # api_key: "...", + # http: { headers: { "X-Correlation-Id" => "abc" } } + # ) + def self.from_config(opts) + new(Native::SDK.from_config(opts)) + end + def initialize(native) @native = native end diff --git a/ruby/sig/quicknode_sdk.rbs b/ruby/sig/quicknode_sdk.rbs index 8ea9d6a..e888823 100644 --- a/ruby/sig/quicknode_sdk.rbs +++ b/ruby/sig/quicknode_sdk.rbs @@ -27,6 +27,7 @@ module QuicknodeSdk class SDK def self.from_env: () -> SDK + def self.from_config: (Hash[Symbol | String, untyped] opts) -> SDK def initialize: (untyped native) -> void def admin: () -> Admin def streams: () -> Streams