Skip to main content

snowflake_connector_rs/
config.rs

1use std::{collections::HashMap, fmt, num::NonZeroUsize, time::Duration};
2
3use url::Url;
4
5use crate::{AuthConfig, Result, error::ConfigError, session::QueryOptions};
6
7/// Top-level configuration for a [`Client`](crate::Client).
8#[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/// Server-side session context sent to Snowflake at login time.
20///
21/// These values are passed as query parameters in the login request and determine the initial state of the
22/// Snowflake session (active warehouse, database, schema, and role). They correspond directly to Snowflake's
23/// session-level settings and do not affect client-side behavior.
24#[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/// Client-side query execution policy.
39///
40/// Controls how this connector behaves while executing queries — for example, how long to wait for Snowflake to
41/// return a query response. These settings are enforced entirely on the client side and are never sent to Snowflake.
42#[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/// Endpoint resolution strategy for the Snowflake API base URL.
60///
61/// By default the base URL is derived from the account name (`https://<account>.snowflakecomputing.com`).
62/// Use [`CustomBaseUrl`](Self::CustomBaseUrl) to override this — for example,
63/// when connecting through a PrivateLink endpoint or a local test server.
64#[non_exhaustive]
65#[derive(Default, Clone, Debug)]
66pub enum EndpointConfig {
67    #[default]
68    AccountDefault,
69    CustomBaseUrl(Url),
70}
71
72/// HTTP transport-layer options.
73///
74/// Configures how requests are physically delivered to Snowflake, independent of which endpoint they target.
75#[derive(Default, Clone, Debug)]
76pub struct TransportConfig {
77    proxy: Option<ProxyConfig>,
78}
79
80/// Configuration for an HTTP proxy used by [`TransportConfig`].
81///
82/// Specifies the proxy URL and optional authentication credentials. Only HTTP and HTTPS proxy schemes are accepted.
83#[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    /// Compile this public builder input into the internal model held by `Client`.
142    ///
143    /// # Errors
144    ///
145    /// Returns `ErrorKind::Config` when endpoint or transport configuration is invalid.
146    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    /// Sets the client-side timeout for obtaining a query response from Snowflake.
215    ///
216    /// This bounds how long [`Session::query()`](crate::Session::query) /
217    /// [`Session::query_as()`](crate::Session::query_as) wait for Snowflake to return a query response and for this
218    /// connector to build a [`ResultCursor`](crate::ResultCursor). It is not specific to async queries: it spans the
219    /// initial statement submit and, when Snowflake responds asynchronously, the subsequent result polling as a single
220    /// budget that is never reset once polling begins. Defaults to `300s`.
221    ///
222    /// This is a client-side timeout only. It does not cancel the query on Snowflake, so the statement may keep
223    /// running server-side after the deadline elapses. It also does not cover chunk download or row collection
224    /// performed through the returned `ResultCursor`.
225    ///
226    /// Snowflake can block the initial submit request for up to roughly 45 seconds before responding. Until that
227    /// response arrives the connector does not know the query id, so a timeout during submit yields an error whose
228    /// [`Error::query_id()`](crate::Error::query_id) is `None`. To reliably recover the query id from a timeout error,
229    /// set this to more than `45s` plus network/server buffer.
230    pub fn with_query_response_timeout(mut self, timeout: Duration) -> Self {
231        self.query_response_timeout = timeout;
232        self
233    }
234
235    /// Sets the client-side deadline for an explicit query cancellation request. Defaults to `30s`.
236    ///
237    /// The deadline covers abort request transport, response body reading, and bounded transport retries. It is
238    /// independent of [`Self::with_query_response_timeout`].
239    pub fn with_query_cancel_request_timeout(mut self, timeout: Duration) -> Self {
240        self.query_cancel_request_timeout = timeout;
241        self
242    }
243
244    /// Sets the default number of partitions fetched concurrently during collection. Defaults to `8`.
245    pub fn with_collect_prefetch_concurrency(mut self, concurrency: NonZeroUsize) -> Self {
246        self.collect_prefetch_concurrency = concurrency;
247        self
248    }
249}
250
251// Internal runtime config models: the prepared form of the public config types above, produced by `ClientConfig::prepare`.
252
253/// Private intermediate produced by consuming a [`ClientConfig`].
254pub(crate) struct PreparedClientConfig {
255    pub(crate) login: ClientLoginConfig,
256    pub(crate) shared: PreparedClientShared,
257}
258
259/// Prepared inputs for the connector-wide shared state.
260pub(crate) struct PreparedClientShared {
261    pub(crate) http: reqwest::Client,
262    pub(crate) base_url: Url,
263    pub(crate) query: QueryExecutionPolicy,
264}
265
266/// Login state retained by `Client` for every `create_session()` call.
267pub(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
292/// Internal login-request session context, the runtime form of [`SessionConfig`].
293pub(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/// Internal query execution policy, the runtime form of [`QueryConfig`].
336#[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    /// Resolve per-query overrides against these prepared defaults into the concrete settings for one execution.
345    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/// Internal concrete settings for a single statement execution, produced by [`QueryExecutionPolicy::resolve_options`].
361#[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        // Disable idle connection pooling: S3 (used for query-result chunks via presigned URLs) closes idle keep-alive
436        // connections aggressively, and a reused-but-closed connection surfaces as hyper `Error(IncompleteMessage)`.
437        // Acceptable because the bottleneck is data transfer, not TCP setup.
438        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}