pub mod admin;
pub mod config;
pub mod errors;
pub mod kvstore;
pub mod rpc;
pub mod sql;
pub mod streams;
pub mod webhooks;
pub use admin::ToolingAccessStatus;
pub use config::{
AdminConfig, CachedToken, ClientInfo, HttpConfig, KvStoreConfig, RpcConfig, SdkFullConfig,
SqlConfig, StreamsConfig, WebhooksConfig,
};
pub use kvstore::{
AddListItemParams, BulkSetsParams, CreateListParams, CreateSetParams, GetListData,
GetListParams, GetListResponse, GetListsData, GetListsParams, GetListsResponse, GetSetResponse,
GetSetsParams, GetSetsResponse, KvSetEntry, KvStoreApiClient, ListContainsItemResponse,
UpdateListParams,
};
pub use rpc::RpcApiClient;
pub use sql::{
ChainSchema, ColumnMeta, ColumnSchema, QueryParams, QueryResponse, QueryStatistics,
SqlApiClient, TableSchema,
};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::Client as ReqwestClient;
use std::sync::Arc;
use errors::SdkError;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
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,
)
}
fn default_rust_client_info() -> ClientInfo {
ClientInfo {
language: "rust".to_string(),
language_version: option_env!("CARGO_PKG_RUST_VERSION")
.unwrap_or("unknown")
.to_string(),
sdk_version: env!("CARGO_PKG_VERSION").to_string(),
}
}
#[derive(Clone)]
pub struct SdkConfig(Arc<SdkConfigInner>);
impl std::fmt::Debug for SdkConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SdkConfig")
.field("api_key", &"[redacted]")
.field("admin_base_url", &self.0.admin.base_url)
.field("streams_base_url", &self.0.streams.base_url)
.field("webhooks_base_url", &self.0.webhooks.base_url)
.field("kvstore_base_url", &self.0.kvstore.base_url)
.field("sql_base_url", &self.0.sql.base_url)
.finish()
}
}
struct SdkConfigInner {
http_client: ReqwestClient,
rpc_http_client: ReqwestClient,
admin: admin::ResolvedAdminConfig,
streams: streams::ResolvedStreamsConfig,
webhooks: webhooks::ResolvedWebhooksConfig,
kvstore: kvstore::ResolvedKvStoreConfig,
sql: sql::ResolvedSqlConfig,
}
impl SdkConfig {
pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
Self::new_with_client_info(config, None)
}
pub fn new_with_client_info(
config: &SdkFullConfig,
client_info: Option<ClientInfo>,
) -> Result<Self, SdkError> {
let timeout_secs = match &config.http {
Some(h) => match h.timeout_secs {
Some(secs) if secs < 0 => {
return Err(SdkError::Config("timeout_secs must be non-negative".into()));
}
Some(secs) => secs as u64,
None => DEFAULT_TIMEOUT_SECS,
},
None => DEFAULT_TIMEOUT_SECS,
};
let pool_max_idle_per_host = config
.http
.as_ref()
.and_then(|http| http.pool_max_idle_per_host);
let make_builder = || {
let mut builder =
ReqwestClient::builder().timeout(std::time::Duration::from_secs(timeout_secs));
if let Some(max_idle) = pool_max_idle_per_host {
builder = builder.pool_max_idle_per_host(max_idle as usize);
}
builder
};
let mut common_headers = HeaderMap::new();
common_headers.insert(
reqwest::header::ACCEPT,
HeaderValue::from_static("application/json"),
);
common_headers.insert(
reqwest::header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
let ua = build_user_agent(&client_info.unwrap_or_else(default_rust_client_info));
common_headers.insert(
reqwest::header::USER_AGENT,
HeaderValue::from_str(&ua).map_err(|e| SdkError::Config(e.to_string()))?,
);
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}"))
})?;
common_headers.insert(header_name, header_value);
}
}
}
let mut main_headers = HeaderMap::new();
main_headers.insert(
"x-api-key",
HeaderValue::from_str(&config.api_key).map_err(|e| SdkError::Config(e.to_string()))?,
);
main_headers.extend(common_headers.clone());
let http_client = make_builder()
.default_headers(main_headers)
.build()
.map_err(|e| SdkError::Config(e.to_string()))?;
let rpc_http_client = make_builder()
.default_headers(common_headers)
.build()
.map_err(|e| SdkError::Config(e.to_string()))?;
Ok(Self(Arc::new(SdkConfigInner {
http_client,
rpc_http_client,
admin: admin::ResolvedAdminConfig::from_config(config.admin.as_ref())?,
streams: streams::ResolvedStreamsConfig::from_config(config.streams.as_ref())?,
webhooks: webhooks::ResolvedWebhooksConfig::from_config(config.webhooks.as_ref())?,
kvstore: kvstore::ResolvedKvStoreConfig::from_config(config.kvstore.as_ref())?,
sql: sql::ResolvedSqlConfig::from_config(config.sql.as_ref())?,
})))
}
pub(crate) fn http_client(&self) -> &ReqwestClient {
&self.0.http_client
}
pub(crate) fn rpc_http_client(&self) -> &ReqwestClient {
&self.0.rpc_http_client
}
pub(crate) fn admin(&self) -> &admin::ResolvedAdminConfig {
&self.0.admin
}
pub(crate) fn streams(&self) -> &streams::ResolvedStreamsConfig {
&self.0.streams
}
pub(crate) fn webhooks(&self) -> &webhooks::ResolvedWebhooksConfig {
&self.0.webhooks
}
pub(crate) fn kvstore(&self) -> &kvstore::ResolvedKvStoreConfig {
&self.0.kvstore
}
pub(crate) fn sql(&self) -> &sql::ResolvedSqlConfig {
&self.0.sql
}
}
pub struct QuicknodeSdk {
pub admin: admin::AdminApiClient,
pub streams: streams::StreamsApiClient,
pub webhooks: webhooks::WebhooksApiClient,
pub kvstore: kvstore::KvStoreApiClient,
pub sql: sql::SqlApiClient,
pub rpc: rpc::RpcApiClient,
}
impl QuicknodeSdk {
pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
Self::new_with_client_info(config, None)
}
pub fn new_with_client_info(
config: &SdkFullConfig,
client_info: Option<ClientInfo>,
) -> Result<Self, SdkError> {
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()),
webhooks: webhooks::WebhooksApiClient::new(sdk_config.clone()),
kvstore: kvstore::KvStoreApiClient::new(sdk_config.clone()),
sql: sql::SqlApiClient::new(sdk_config.clone()),
rpc: rpc::RpcApiClient::new(sdk_config, config.rpc.as_ref()),
})
}
pub fn from_env() -> Result<Self, SdkError> {
Self::new(&SdkFullConfig::from_env()?)
}
pub fn from_env_with_client_info(client_info: Option<ClientInfo>) -> Result<Self, SdkError> {
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,
sql: None,
rpc: 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();
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"))
.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,
sql: None,
rpc: None,
};
let sdk = QuicknodeSdk::new(&cfg).unwrap();
sdk.admin
.get_endpoints(&admin::GetEndpointsRequest::default())
.await
.unwrap();
}
}