1pub mod admin;
2pub mod config;
3pub mod errors;
4pub mod kvstore;
5pub mod sql;
6pub mod streams;
7pub mod webhooks;
8
9pub use config::{
10 AdminConfig, ClientInfo, HttpConfig, KvStoreConfig, SdkFullConfig, SqlConfig, StreamsConfig,
11 WebhooksConfig,
12};
13pub use kvstore::{
14 AddListItemParams, BulkSetsParams, CreateListParams, CreateSetParams, GetListData,
15 GetListParams, GetListResponse, GetListsData, GetListsParams, GetListsResponse, GetSetResponse,
16 GetSetsParams, GetSetsResponse, KvSetEntry, KvStoreApiClient, ListContainsItemResponse,
17 UpdateListParams,
18};
19pub use sql::{
20 ChainSchema, ColumnMeta, ColumnSchema, QueryParams, QueryResponse, QueryStatistics,
21 SqlApiClient, TableSchema,
22};
23
24use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
25use reqwest::Client as ReqwestClient;
26use std::sync::Arc;
27
28use errors::SdkError;
29
30const DEFAULT_TIMEOUT_SECS: u64 = 30;
31
32fn build_user_agent(info: &ClientInfo) -> String {
36 format!(
37 "quicknode-sdk-{lang}/{ver} ({os}-{arch}; {lang}-{lang_ver})",
38 lang = info.language,
39 ver = info.sdk_version,
40 os = std::env::consts::OS,
41 arch = std::env::consts::ARCH,
42 lang_ver = info.language_version,
43 )
44}
45
46fn default_rust_client_info() -> ClientInfo {
48 ClientInfo {
49 language: "rust".to_string(),
50 language_version: option_env!("CARGO_PKG_RUST_VERSION")
54 .unwrap_or("unknown")
55 .to_string(),
56 sdk_version: env!("CARGO_PKG_VERSION").to_string(),
57 }
58}
59
60#[derive(Clone)]
62pub struct SdkConfig(Arc<SdkConfigInner>);
63
64impl std::fmt::Debug for SdkConfig {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("SdkConfig")
67 .field("api_key", &"[redacted]")
68 .field("admin_base_url", &self.0.admin.base_url)
69 .field("streams_base_url", &self.0.streams.base_url)
70 .field("webhooks_base_url", &self.0.webhooks.base_url)
71 .field("kvstore_base_url", &self.0.kvstore.base_url)
72 .field("sql_base_url", &self.0.sql.base_url)
73 .finish()
74 }
75}
76
77struct SdkConfigInner {
78 http_client: ReqwestClient,
79 admin: admin::ResolvedAdminConfig,
80 streams: streams::ResolvedStreamsConfig,
81 webhooks: webhooks::ResolvedWebhooksConfig,
82 kvstore: kvstore::ResolvedKvStoreConfig,
83 sql: sql::ResolvedSqlConfig,
84}
85
86impl SdkConfig {
87 pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
90 Self::new_with_client_info(config, None)
91 }
92
93 pub fn new_with_client_info(
99 config: &SdkFullConfig,
100 client_info: Option<ClientInfo>,
101 ) -> Result<Self, SdkError> {
102 let mut builder = ReqwestClient::builder();
103
104 let timeout_secs = match &config.http {
105 Some(h) => match h.timeout_secs {
106 Some(secs) if secs < 0 => {
107 return Err(SdkError::Config("timeout_secs must be non-negative".into()));
108 }
109 Some(secs) => secs as u64,
110 None => DEFAULT_TIMEOUT_SECS,
111 },
112 None => DEFAULT_TIMEOUT_SECS,
113 };
114 builder = builder.timeout(std::time::Duration::from_secs(timeout_secs));
115
116 if let Some(http) = &config.http {
117 if let Some(max_idle) = http.pool_max_idle_per_host {
118 builder = builder.pool_max_idle_per_host(max_idle as usize);
119 }
120 }
121
122 let mut default_headers = HeaderMap::new();
123 default_headers.insert(
124 reqwest::header::ACCEPT,
125 HeaderValue::from_static("application/json"),
126 );
127 default_headers.insert(
128 reqwest::header::CONTENT_TYPE,
129 HeaderValue::from_static("application/json"),
130 );
131 default_headers.insert(
132 "x-api-key",
133 HeaderValue::from_str(&config.api_key).map_err(|e| SdkError::Config(e.to_string()))?,
134 );
135 let ua = build_user_agent(&client_info.unwrap_or_else(default_rust_client_info));
136 default_headers.insert(
137 reqwest::header::USER_AGENT,
138 HeaderValue::from_str(&ua).map_err(|e| SdkError::Config(e.to_string()))?,
139 );
140
141 if let Some(http) = &config.http {
144 if let Some(custom) = &http.headers {
145 for (name, value) in custom {
146 let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
147 SdkError::Config(format!("invalid header name {name:?}: {e}"))
148 })?;
149 let header_value = HeaderValue::from_str(value).map_err(|e| {
150 SdkError::Config(format!("invalid header value for {name:?}: {e}"))
151 })?;
152 default_headers.insert(header_name, header_value);
153 }
154 }
155 }
156
157 builder = builder.default_headers(default_headers);
158
159 let http_client = builder
160 .build()
161 .map_err(|e| SdkError::Config(e.to_string()))?;
162
163 Ok(Self(Arc::new(SdkConfigInner {
164 http_client,
165 admin: admin::ResolvedAdminConfig::from_config(config.admin.as_ref())?,
166 streams: streams::ResolvedStreamsConfig::from_config(config.streams.as_ref())?,
167 webhooks: webhooks::ResolvedWebhooksConfig::from_config(config.webhooks.as_ref())?,
168 kvstore: kvstore::ResolvedKvStoreConfig::from_config(config.kvstore.as_ref())?,
169 sql: sql::ResolvedSqlConfig::from_config(config.sql.as_ref())?,
170 })))
171 }
172
173 pub(crate) fn http_client(&self) -> &ReqwestClient {
174 &self.0.http_client
175 }
176
177 pub(crate) fn admin(&self) -> &admin::ResolvedAdminConfig {
178 &self.0.admin
179 }
180
181 pub(crate) fn streams(&self) -> &streams::ResolvedStreamsConfig {
182 &self.0.streams
183 }
184
185 pub(crate) fn webhooks(&self) -> &webhooks::ResolvedWebhooksConfig {
186 &self.0.webhooks
187 }
188
189 pub(crate) fn kvstore(&self) -> &kvstore::ResolvedKvStoreConfig {
190 &self.0.kvstore
191 }
192
193 pub(crate) fn sql(&self) -> &sql::ResolvedSqlConfig {
194 &self.0.sql
195 }
196}
197
198pub struct QuicknodeSdk {
201 pub admin: admin::AdminApiClient,
204 pub streams: streams::StreamsApiClient,
206 pub webhooks: webhooks::WebhooksApiClient,
208 pub kvstore: kvstore::KvStoreApiClient,
211 pub sql: sql::SqlApiClient,
214}
215
216impl QuicknodeSdk {
217 pub fn new(config: &SdkFullConfig) -> Result<Self, SdkError> {
219 Self::new_with_client_info(config, None)
220 }
221
222 pub fn new_with_client_info(
226 config: &SdkFullConfig,
227 client_info: Option<ClientInfo>,
228 ) -> Result<Self, SdkError> {
229 let sdk_config = SdkConfig::new_with_client_info(config, client_info)?;
230 Ok(Self {
231 admin: admin::AdminApiClient::new(sdk_config.clone()),
232 streams: streams::StreamsApiClient::new(sdk_config.clone()),
233 webhooks: webhooks::WebhooksApiClient::new(sdk_config.clone()),
234 kvstore: kvstore::KvStoreApiClient::new(sdk_config.clone()),
235 sql: sql::SqlApiClient::new(sdk_config),
236 })
237 }
238
239 pub fn from_env() -> Result<Self, SdkError> {
241 Self::new(&SdkFullConfig::from_env()?)
242 }
243
244 pub fn from_env_with_client_info(client_info: Option<ClientInfo>) -> Result<Self, SdkError> {
246 Self::new_with_client_info(&SdkFullConfig::from_env()?, client_info)
247 }
248}
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
252mod headers_tests {
253 use super::*;
254 use std::collections::HashMap;
255 use wiremock::matchers::{header, method, path};
256 use wiremock::{Mock, MockServer, ResponseTemplate};
257
258 fn base_config(api_key: &str) -> SdkFullConfig {
259 SdkFullConfig {
260 api_key: api_key.to_string(),
261 http: None,
262 admin: None,
263 streams: None,
264 webhooks: None,
265 kvstore: None,
266 sql: None,
267 }
268 }
269
270 fn binding_info() -> ClientInfo {
271 ClientInfo {
272 language: "python".to_string(),
273 language_version: "3.12.4".to_string(),
274 sdk_version: "1.2.3".to_string(),
275 }
276 }
277
278 #[test]
279 fn default_user_agent_identifies_rust_core() {
280 let ua = build_user_agent(&default_rust_client_info());
281 assert!(ua.starts_with("quicknode-sdk-rust/"));
282 assert!(ua.contains(env!("CARGO_PKG_VERSION")));
283 assert!(ua.contains(std::env::consts::OS));
284 assert!(ua.contains(std::env::consts::ARCH));
285 }
286
287 #[test]
288 fn binding_user_agent_identifies_language() {
289 let ua = build_user_agent(&binding_info());
290 let expected_prefix = "quicknode-sdk-python/1.2.3";
291 assert!(ua.starts_with(expected_prefix), "got: {ua}");
292 assert!(ua.contains("python-3.12.4"));
293 }
294
295 #[test]
296 fn invalid_custom_header_name_errors() {
297 let mut cfg = base_config("k");
298 let mut h = HashMap::new();
299 h.insert("bad header".to_string(), "v".to_string());
300 cfg.http = Some(HttpConfig {
301 timeout_secs: None,
302 pool_max_idle_per_host: None,
303 headers: Some(h),
304 });
305 assert!(matches!(SdkConfig::new(&cfg), Err(SdkError::Config(_))));
306 }
307
308 #[test]
309 fn invalid_custom_header_value_errors() {
310 let mut cfg = base_config("k");
311 let mut h = HashMap::new();
312 h.insert("X-Test".to_string(), "bad\nvalue".to_string());
314 cfg.http = Some(HttpConfig {
315 timeout_secs: None,
316 pool_max_idle_per_host: None,
317 headers: Some(h),
318 });
319 assert!(matches!(SdkConfig::new(&cfg), Err(SdkError::Config(_))));
320 }
321
322 #[tokio::test]
323 async fn default_user_agent_reaches_wire_and_custom_headers_override() {
324 let server = MockServer::start().await;
325 Mock::given(method("GET"))
326 .and(path("/endpoints"))
327 .and(header("user-agent", "custom-ua/9.9"))
328 .and(header("x-correlation-id", "abc"))
329 .and(header("x-api-key", "override-key"))
331 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
332 "data": [], "error": null, "pagination": null
333 })))
334 .mount(&server)
335 .await;
336
337 let mut headers = HashMap::new();
338 headers.insert("User-Agent".to_string(), "custom-ua/9.9".to_string());
339 headers.insert("X-Correlation-Id".to_string(), "abc".to_string());
340 headers.insert("x-api-key".to_string(), "override-key".to_string());
341
342 let cfg = SdkFullConfig {
343 api_key: "real-key".to_string(),
344 http: Some(HttpConfig {
345 timeout_secs: None,
346 pool_max_idle_per_host: None,
347 headers: Some(headers),
348 }),
349 admin: Some(AdminConfig {
350 base_url: Some(format!("{}/", server.uri())),
351 }),
352 streams: None,
353 webhooks: None,
354 kvstore: None,
355 sql: None,
356 };
357
358 let sdk = QuicknodeSdk::new(&cfg).unwrap();
359 sdk.admin
360 .get_endpoints(&admin::GetEndpointsRequest::default())
361 .await
362 .unwrap();
363 }
364}