1use std::{collections::HashMap, fmt, num::NonZeroUsize, time::Duration};
2
3use url::Url;
4
5use crate::{AuthConfig, Result, error::ConfigError, session::QueryOptions};
6
7#[derive(Clone, Debug)]
9pub struct ClientConfig {
10 username: String,
11 account: String,
12 auth: AuthConfig,
13 session: SessionConfig,
14 query: QueryConfig,
15 endpoint: EndpointConfig,
16 transport: TransportConfig,
17}
18
19#[derive(Default, Clone, Debug)]
25pub struct SessionConfig {
26 warehouse: Option<String>,
27 database: Option<String>,
28 schema: Option<String>,
29 role: Option<String>,
30 session_parameters: HashMap<String, serde_json::Value>,
31}
32
33pub(crate) const DEFAULT_QUERY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300);
34pub(crate) const DEFAULT_QUERY_CANCEL_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
35const DEFAULT_COLLECT_PREFETCH_CONCURRENCY: NonZeroUsize =
36 NonZeroUsize::new(8).expect("default concurrency is non-zero");
37
38#[derive(Clone, Debug)]
43pub struct QueryConfig {
44 query_response_timeout: Duration,
45 query_cancel_request_timeout: Duration,
46 collect_prefetch_concurrency: NonZeroUsize,
47}
48
49impl Default for QueryConfig {
50 fn default() -> Self {
51 Self {
52 query_response_timeout: DEFAULT_QUERY_RESPONSE_TIMEOUT,
53 query_cancel_request_timeout: DEFAULT_QUERY_CANCEL_REQUEST_TIMEOUT,
54 collect_prefetch_concurrency: DEFAULT_COLLECT_PREFETCH_CONCURRENCY,
55 }
56 }
57}
58
59#[non_exhaustive]
65#[derive(Default, Clone, Debug)]
66pub enum EndpointConfig {
67 #[default]
68 AccountDefault,
69 CustomBaseUrl(Url),
70}
71
72#[derive(Default, Clone, Debug)]
76pub struct TransportConfig {
77 proxy: Option<ProxyConfig>,
78}
79
80#[derive(Clone, Debug)]
84pub struct ProxyConfig {
85 url: Url,
86 auth: ProxyAuth,
87}
88
89#[derive(Clone)]
90pub(crate) enum ProxyAuth {
91 None,
92 Basic { username: String, password: String },
93}
94
95impl fmt::Debug for ProxyAuth {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 match self {
98 Self::None => f.write_str("None"),
99 Self::Basic { username, .. } => f
100 .debug_struct("Basic")
101 .field("username", username)
102 .field("password", &"<redacted>")
103 .finish(),
104 }
105 }
106}
107
108impl ClientConfig {
109 pub fn new(username: impl Into<String>, account: impl Into<String>, auth: AuthConfig) -> Self {
110 Self {
111 username: username.into(),
112 account: account.into(),
113 auth,
114 session: SessionConfig::default(),
115 query: QueryConfig::default(),
116 endpoint: EndpointConfig::default(),
117 transport: TransportConfig::default(),
118 }
119 }
120
121 pub fn with_session(mut self, session: SessionConfig) -> Self {
122 self.session = session;
123 self
124 }
125
126 pub fn with_query(mut self, query: QueryConfig) -> Self {
127 self.query = query;
128 self
129 }
130
131 pub fn with_endpoint(mut self, endpoint: EndpointConfig) -> Self {
132 self.endpoint = endpoint;
133 self
134 }
135
136 pub fn with_transport(mut self, transport: TransportConfig) -> Self {
137 self.transport = transport;
138 self
139 }
140
141 pub(crate) fn prepare(self) -> Result<PreparedClientConfig> {
147 let base_url = self.endpoint.resolve(&self.account)?;
148 let http = self.transport.build_http_client()?;
149
150 Ok(PreparedClientConfig {
151 login: ClientLoginConfig {
152 username: self.username,
153 account: self.account,
154 auth: self.auth,
155 initial_session: self.session.into(),
156 },
157 shared: PreparedClientShared {
158 http,
159 base_url,
160 query: self.query.into(),
161 },
162 })
163 }
164}
165
166impl SessionConfig {
167 pub fn new() -> Self {
168 Self::default()
169 }
170
171 pub fn with_warehouse(mut self, warehouse: impl Into<String>) -> Self {
172 self.warehouse = Some(warehouse.into());
173 self
174 }
175
176 pub fn with_database(mut self, database: impl Into<String>) -> Self {
177 self.database = Some(database.into());
178 self
179 }
180
181 pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
182 self.schema = Some(schema.into());
183 self
184 }
185
186 pub fn with_role(mut self, role: impl Into<String>) -> Self {
187 self.role = Some(role.into());
188 self
189 }
190
191 pub fn with_session_parameters(
192 mut self,
193 session_parameters: HashMap<String, serde_json::Value>,
194 ) -> Self {
195 self.session_parameters = session_parameters;
196 self
197 }
198
199 pub fn with_session_parameter(
200 mut self,
201 key: impl Into<String>,
202 value: serde_json::Value,
203 ) -> Self {
204 self.session_parameters.insert(key.into(), value);
205 self
206 }
207}
208
209impl QueryConfig {
210 pub fn new() -> Self {
211 Self::default()
212 }
213
214 pub fn with_query_response_timeout(mut self, timeout: Duration) -> Self {
231 self.query_response_timeout = timeout;
232 self
233 }
234
235 pub fn with_query_cancel_request_timeout(mut self, timeout: Duration) -> Self {
240 self.query_cancel_request_timeout = timeout;
241 self
242 }
243
244 pub fn with_collect_prefetch_concurrency(mut self, concurrency: NonZeroUsize) -> Self {
246 self.collect_prefetch_concurrency = concurrency;
247 self
248 }
249}
250
251pub(crate) struct PreparedClientConfig {
255 pub(crate) login: ClientLoginConfig,
256 pub(crate) shared: PreparedClientShared,
257}
258
259pub(crate) struct PreparedClientShared {
261 pub(crate) http: reqwest::Client,
262 pub(crate) base_url: Url,
263 pub(crate) query: QueryExecutionPolicy,
264}
265
266pub(crate) struct ClientLoginConfig {
268 username: String,
269 account: String,
270 auth: AuthConfig,
271 initial_session: InitialSessionConfig,
272}
273
274impl ClientLoginConfig {
275 pub(crate) fn username(&self) -> &str {
276 &self.username
277 }
278
279 pub(crate) fn account(&self) -> &str {
280 &self.account
281 }
282
283 pub(crate) fn auth(&self) -> &AuthConfig {
284 &self.auth
285 }
286
287 pub(crate) fn initial_session(&self) -> &InitialSessionConfig {
288 &self.initial_session
289 }
290}
291
292pub(crate) struct InitialSessionConfig {
294 warehouse: Option<String>,
295 database: Option<String>,
296 schema: Option<String>,
297 role: Option<String>,
298 session_parameters: HashMap<String, serde_json::Value>,
299}
300
301impl InitialSessionConfig {
302 pub(crate) fn warehouse(&self) -> Option<&str> {
303 self.warehouse.as_deref()
304 }
305
306 pub(crate) fn database(&self) -> Option<&str> {
307 self.database.as_deref()
308 }
309
310 pub(crate) fn schema(&self) -> Option<&str> {
311 self.schema.as_deref()
312 }
313
314 pub(crate) fn role(&self) -> Option<&str> {
315 self.role.as_deref()
316 }
317
318 pub(crate) fn session_parameters(&self) -> &HashMap<String, serde_json::Value> {
319 &self.session_parameters
320 }
321}
322
323impl From<SessionConfig> for InitialSessionConfig {
324 fn from(config: SessionConfig) -> Self {
325 Self {
326 warehouse: config.warehouse,
327 database: config.database,
328 schema: config.schema,
329 role: config.role,
330 session_parameters: config.session_parameters,
331 }
332 }
333}
334
335#[derive(Debug)]
337pub(crate) struct QueryExecutionPolicy {
338 query_response_timeout: Duration,
339 query_cancel_request_timeout: Duration,
340 collect_prefetch_concurrency: NonZeroUsize,
341}
342
343impl QueryExecutionPolicy {
344 pub(crate) fn resolve_options(&self, options: QueryOptions) -> QueryExecutionSettings {
346 QueryExecutionSettings {
347 query_response_timeout: options
348 .query_response_timeout
349 .unwrap_or(self.query_response_timeout),
350 query_cancel_request_timeout: options
351 .query_cancel_request_timeout
352 .unwrap_or(self.query_cancel_request_timeout),
353 collect_prefetch_concurrency: options
354 .collect_prefetch_concurrency
355 .unwrap_or(self.collect_prefetch_concurrency),
356 }
357 }
358}
359
360#[derive(Clone, Copy, Debug)]
362pub(crate) struct QueryExecutionSettings {
363 pub(crate) query_response_timeout: Duration,
364 pub(crate) query_cancel_request_timeout: Duration,
365 pub(crate) collect_prefetch_concurrency: NonZeroUsize,
366}
367
368impl From<QueryConfig> for QueryExecutionPolicy {
369 fn from(config: QueryConfig) -> Self {
370 Self {
371 query_response_timeout: config.query_response_timeout,
372 query_cancel_request_timeout: config.query_cancel_request_timeout,
373 collect_prefetch_concurrency: config.collect_prefetch_concurrency,
374 }
375 }
376}
377
378impl EndpointConfig {
379 pub fn custom_base_url(url: Url) -> Self {
380 Self::CustomBaseUrl(url)
381 }
382
383 pub(crate) fn resolve(&self, account: &str) -> Result<Url> {
384 match self {
385 Self::AccountDefault => Ok(Url::parse(&format!(
386 "https://{account}.snowflakecomputing.com"
387 ))
388 .map_err(|e| ConfigError::invalid_url(e.to_string()))?),
389 Self::CustomBaseUrl(url) => validate_custom_base_url(url.clone()),
390 }
391 }
392}
393
394const ALLOWED_ENDPOINT_SCHEMES: &[&str] = &["http", "https"];
395
396fn validate_custom_base_url(mut url: Url) -> Result<Url> {
397 if !ALLOWED_ENDPOINT_SCHEMES.contains(&url.scheme()) {
398 return Err(ConfigError::invalid_url(format!(
399 "unsupported custom base URL scheme '{}'; allowed: {}",
400 url.scheme(),
401 ALLOWED_ENDPOINT_SCHEMES.join(", "),
402 ))
403 .into());
404 }
405 if url.query().is_some() || url.fragment().is_some() {
406 return Err(
407 ConfigError::invalid_url("custom base URL must not contain query or fragment").into(),
408 );
409 }
410 if !url.username().is_empty() || url.password().is_some() {
411 return Err(
412 ConfigError::invalid_url("custom base URL must not contain credentials").into(),
413 );
414 }
415 if url.path() != "/" && !url.path().is_empty() {
416 return Err(ConfigError::invalid_url("custom base URL must not contain a path").into());
417 }
418 url.set_path("/");
419 Ok(url)
420}
421
422impl TransportConfig {
423 pub fn new() -> Self {
424 Self::default()
425 }
426
427 pub fn with_proxy(mut self, proxy: ProxyConfig) -> Self {
428 self.proxy = Some(proxy);
429 self
430 }
431
432 pub(crate) fn build_http_client(&self) -> Result<reqwest::Client> {
433 let builder = reqwest::ClientBuilder::new().gzip(true).use_rustls_tls();
434
435 let builder = builder.pool_max_idle_per_host(0);
439
440 let builder = if let Some(proxy) = &self.proxy {
441 builder.proxy(proxy.to_reqwest_proxy()?)
442 } else {
443 builder
444 };
445
446 Ok(builder
447 .build()
448 .map_err(ConfigError::client_builder_failure)?)
449 }
450}
451
452impl ProxyConfig {
453 pub fn new(url: Url) -> Self {
454 Self {
455 url,
456 auth: ProxyAuth::None,
457 }
458 }
459
460 pub fn with_basic_auth(
461 mut self,
462 username: impl Into<String>,
463 password: impl Into<String>,
464 ) -> Self {
465 self.auth = ProxyAuth::Basic {
466 username: username.into(),
467 password: password.into(),
468 };
469 self
470 }
471
472 pub(crate) fn to_reqwest_proxy(&self) -> Result<reqwest::Proxy> {
473 let url = validate_proxy_url(self.url.clone())?;
474 let mut proxy =
475 reqwest::Proxy::all(url.as_str()).map_err(ConfigError::client_builder_failure)?;
476
477 if let ProxyAuth::Basic { username, password } = &self.auth {
478 proxy = proxy.basic_auth(username, password);
479 }
480
481 Ok(proxy)
482 }
483}
484
485const ALLOWED_PROXY_SCHEMES: &[&str] = &["http", "https"];
486
487fn validate_proxy_url(url: Url) -> Result<Url> {
488 if !ALLOWED_PROXY_SCHEMES.contains(&url.scheme()) {
489 return Err(ConfigError::invalid_url(format!(
490 "unsupported proxy URL scheme '{}'; allowed: {}",
491 url.scheme(),
492 ALLOWED_PROXY_SCHEMES.join(", "),
493 ))
494 .into());
495 }
496 if !url.username().is_empty() || url.password().is_some() {
497 return Err(ConfigError::invalid_url(
498 "proxy URL must not contain credentials; use ProxyConfig::with_basic_auth() instead",
499 )
500 .into());
501 }
502 if url.query().is_some() || url.fragment().is_some() {
503 return Err(
504 ConfigError::invalid_url("proxy URL must not contain query or fragment").into(),
505 );
506 }
507 Ok(url)
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn query_config_defaults_to_300s_query_response_timeout() {
516 let policy: QueryExecutionPolicy = QueryConfig::default().into();
517 let settings = policy.resolve_options(QueryOptions::default());
518 assert_eq!(settings.query_response_timeout, Duration::from_secs(300));
519 assert_eq!(
520 settings.query_cancel_request_timeout,
521 Duration::from_secs(30)
522 );
523 }
524
525 #[test]
526 fn with_query_response_timeout_overrides_the_default() {
527 let policy: QueryExecutionPolicy = QueryConfig::default()
528 .with_query_response_timeout(Duration::from_secs(60))
529 .into();
530 let settings = policy.resolve_options(QueryOptions::default());
531 assert_eq!(settings.query_response_timeout, Duration::from_secs(60));
532 }
533
534 #[test]
535 fn query_options_default_inherits_query_config_defaults() {
536 let policy: QueryExecutionPolicy = QueryConfig::default()
537 .with_query_response_timeout(Duration::from_secs(120))
538 .with_collect_prefetch_concurrency(NonZeroUsize::new(4).unwrap())
539 .into();
540
541 let settings = policy.resolve_options(QueryOptions::default());
542
543 assert_eq!(settings.query_response_timeout, Duration::from_secs(120));
544 assert_eq!(
545 settings.collect_prefetch_concurrency,
546 NonZeroUsize::new(4).unwrap()
547 );
548 }
549
550 #[test]
551 fn query_options_with_query_response_timeout_overrides_only_timeout() {
552 let policy: QueryExecutionPolicy = QueryConfig::default()
553 .with_collect_prefetch_concurrency(NonZeroUsize::new(4).unwrap())
554 .into();
555
556 let settings = policy.resolve_options(
557 QueryOptions::default().with_query_response_timeout(Duration::from_secs(90)),
558 );
559
560 assert_eq!(settings.query_response_timeout, Duration::from_secs(90));
561 assert_eq!(
562 settings.collect_prefetch_concurrency,
563 NonZeroUsize::new(4).unwrap()
564 );
565 }
566
567 #[test]
568 fn query_options_with_query_cancel_request_timeout_overrides_only_cancel_timeout() {
569 let policy: QueryExecutionPolicy = QueryConfig::default()
570 .with_query_response_timeout(Duration::from_secs(120))
571 .with_collect_prefetch_concurrency(NonZeroUsize::new(4).unwrap())
572 .into();
573 let settings = policy.resolve_options(
574 QueryOptions::default().with_query_cancel_request_timeout(Duration::from_secs(7)),
575 );
576
577 assert_eq!(settings.query_response_timeout, Duration::from_secs(120));
578 assert_eq!(
579 settings.query_cancel_request_timeout,
580 Duration::from_secs(7)
581 );
582 assert_eq!(
583 settings.collect_prefetch_concurrency,
584 NonZeroUsize::new(4).unwrap()
585 );
586 }
587
588 #[test]
589 fn query_options_with_collect_prefetch_concurrency_overrides_only_concurrency() {
590 let policy: QueryExecutionPolicy = QueryConfig::default()
591 .with_query_response_timeout(Duration::from_secs(120))
592 .into();
593
594 let settings = policy.resolve_options(
595 QueryOptions::default()
596 .with_collect_prefetch_concurrency(NonZeroUsize::new(2).unwrap()),
597 );
598
599 assert_eq!(settings.query_response_timeout, Duration::from_secs(120));
600 assert_eq!(
601 settings.collect_prefetch_concurrency,
602 NonZeroUsize::new(2).unwrap()
603 );
604 }
605
606 #[test]
607 fn proxy_debug_redacts_basic_auth_password() {
608 let proxy = ProxyConfig::new(Url::parse("http://proxy.example.com:8080").unwrap())
609 .with_basic_auth("proxy_user", "s3cr3t-proxy-pass");
610 let rendered = format!("{proxy:?}");
611
612 assert!(
613 rendered.contains("proxy_user"),
614 "username should be visible: {rendered}"
615 );
616 assert!(
617 !rendered.contains("s3cr3t-proxy-pass"),
618 "password leaked into Debug: {rendered}"
619 );
620 assert!(
621 rendered.contains("<redacted>"),
622 "expected redaction marker: {rendered}"
623 );
624 }
625
626 #[test]
627 fn proxy_urls_with_supported_schemes_build_successfully() {
628 for proxy in [
629 ProxyConfig::new(Url::parse("http://proxy.example.com:8080").unwrap()),
630 ProxyConfig::new(Url::parse("https://proxy.example.com:8080").unwrap()),
631 ProxyConfig::new(Url::parse("http://proxy.example.com:8080").unwrap())
632 .with_basic_auth("user", "pass"),
633 ] {
634 assert!(proxy.to_reqwest_proxy().is_ok());
635 }
636 }
637
638 fn assert_proxy_rejected(url: &str, expected: &str) {
639 let proxy = ProxyConfig::new(Url::parse(url).unwrap());
640 let err = proxy.to_reqwest_proxy().unwrap_err();
641 assert!(
642 format!("{err}").contains(expected),
643 "unexpected error: {err}"
644 );
645 }
646
647 #[test]
648 fn proxy_urls_with_unsupported_schemes_are_rejected() {
649 for url in ["socks5://proxy.example.com:1080", "ftp://proxy.example.com"] {
650 assert_proxy_rejected(url, "unsupported proxy URL scheme");
651 }
652 }
653
654 #[test]
655 fn proxy_url_with_credentials_is_rejected() {
656 assert_proxy_rejected(
657 "http://user:pass@proxy.example.com:8080",
658 "must not contain credentials",
659 );
660 }
661
662 #[test]
663 fn no_proxy_builds_client_successfully() {
664 let transport = TransportConfig::default();
665 assert!(transport.build_http_client().is_ok());
666 }
667
668 #[test]
669 fn proxy_url_with_query_is_rejected() {
670 assert_proxy_rejected(
671 "http://proxy.example.com:8080?foo=bar",
672 "must not contain query or fragment",
673 );
674 }
675
676 #[test]
677 fn custom_base_urls_with_supported_schemes_are_accepted_and_normalized() {
678 for (url, expected) in [
679 (
680 "https://custom.snowflake.example.com",
681 "https://custom.snowflake.example.com/",
682 ),
683 ("http://localhost:8080", "http://localhost:8080/"),
684 (
685 "https://snowflake.example.com",
686 "https://snowflake.example.com/",
687 ),
688 ] {
689 let result = validate_custom_base_url(Url::parse(url).unwrap()).unwrap();
690 assert_eq!(result.as_str(), expected);
691 assert_eq!(result.path(), "/");
692 }
693 }
694
695 fn assert_custom_base_url_rejected(url: &str, expected: &str) {
696 let err = validate_custom_base_url(Url::parse(url).unwrap()).unwrap_err();
697 assert!(
698 format!("{err}").contains(expected),
699 "unexpected error: {err}"
700 );
701 }
702
703 #[test]
704 fn custom_base_urls_with_unsupported_schemes_are_rejected() {
705 for url in ["ftp://snowflake.example.com", "ws://snowflake.example.com"] {
706 assert_custom_base_url_rejected(url, "unsupported custom base URL scheme");
707 }
708 }
709
710 #[test]
711 fn custom_base_urls_with_query_or_fragment_are_rejected() {
712 for url in [
713 "https://snowflake.example.com?foo=bar",
714 "https://snowflake.example.com#section",
715 ] {
716 assert_custom_base_url_rejected(url, "must not contain query or fragment");
717 }
718 }
719
720 #[test]
721 fn custom_base_url_with_credentials_is_rejected() {
722 assert_custom_base_url_rejected(
723 "https://user:pass@snowflake.example.com",
724 "must not contain credentials",
725 );
726 }
727
728 #[test]
729 fn custom_base_url_with_path_is_rejected() {
730 assert_custom_base_url_rejected(
731 "https://snowflake.example.com/some/path",
732 "must not contain a path",
733 );
734 }
735
736 #[test]
737 fn endpoint_account_default_resolves() {
738 let endpoint = EndpointConfig::AccountDefault;
739 let url = endpoint.resolve("myaccount").unwrap();
740 assert_eq!(url.as_str(), "https://myaccount.snowflakecomputing.com/");
741 }
742
743 #[test]
744 fn endpoint_custom_base_url_resolves() {
745 let base = Url::parse("https://custom.example.com").unwrap();
746 let endpoint = EndpointConfig::custom_base_url(base);
747 let url = endpoint.resolve("ignored").unwrap();
748 assert_eq!(url.as_str(), "https://custom.example.com/");
749 }
750}