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