pub mod config;
pub mod error;
pub mod row;
mod auth;
mod jwt;
mod session;
mod transport;
use std::sync::Arc;
pub use config::{AuthMethod, BrowserConfig, BrowserLaunch, KeyPairConfig, SnowflakeClientConfig};
pub use error::{Error, Result};
pub use row::{rows_to_payload, value_to_json, Column, Row};
pub use session::SnowflakeSession;
use transport::Transport;
pub struct SnowflakeClient {
transport: Arc<Transport>,
config: SnowflakeClientConfig,
}
impl SnowflakeClient {
pub fn new(config: SnowflakeClientConfig) -> Result<Self> {
let transport = Arc::new(Transport::new(&config.api_host(), config.http_timeout)?);
Ok(Self { transport, config })
}
#[must_use]
pub fn config(&self) -> &SnowflakeClientConfig {
&self.config
}
pub async fn create_session(&self) -> Result<SnowflakeSession> {
let tokens = match &self.config.auth {
AuthMethod::ExternalBrowser(browser) => {
auth::external_browser_login(&self.transport, &self.config, browser).await?
}
AuthMethod::ProgrammaticAccessToken { token } => {
auth::pat_login(&self.transport, &self.config, token).await?
}
AuthMethod::KeyPairJwt(keypair) => {
auth::keypair_jwt_login(&self.transport, &self.config, keypair).await?
}
};
Ok(SnowflakeSession::new(
Arc::clone(&self.transport),
tokens,
self.config.query_timeout,
))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
pub(crate) fn test_session(base_url: &str, query_timeout: std::time::Duration) -> SnowflakeSession {
use std::time::Duration;
let url = url::Url::parse(base_url).unwrap().join("/").unwrap();
let transport =
Arc::new(transport::Transport::with_base_url(url, Duration::from_secs(5)).unwrap());
SnowflakeSession::new(
transport,
session::LoginTokens {
session_token: "test-sess".into(),
master_token: "test-mast".into(),
session_validity_secs: 3600,
master_validity_secs: 14_400,
},
query_timeout,
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
pub(crate) fn test_client(base_url: &str, auth: AuthMethod) -> SnowflakeClient {
use std::time::Duration;
let url = url::Url::parse(base_url).unwrap().join("/").unwrap();
let transport =
Arc::new(transport::Transport::with_base_url(url, Duration::from_secs(5)).unwrap());
let mut config = SnowflakeClientConfig::external_browser("TESTACCT", "tester");
config.auth = auth;
SnowflakeClient { transport, config }
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn new_builds_a_client_and_exposes_its_config() {
let client = SnowflakeClient::new(SnowflakeClientConfig::external_browser("MyAcct", "me"))
.expect("a valid account resolves to a valid host");
assert_eq!(client.config().account, "MyAcct");
assert_eq!(client.config().user, "me");
assert_eq!(client.config().api_host(), "myacct.snowflakecomputing.com");
}
#[test]
fn new_rejects_an_invalid_host_override() {
let config = SnowflakeClientConfig {
host: Some("[".to_string()),
..SnowflakeClientConfig::external_browser("acct", "me")
};
assert!(matches!(
SnowflakeClient::new(config),
Err(Error::Protocol(_))
));
}
#[tokio::test]
async fn pat_create_session_sends_the_token_and_captures_login_tokens() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/session/v1/login-request"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"success": true,
"data": {
"token": "sess-tok",
"masterToken": "mast-tok",
"validityInSeconds": 3600,
"masterValidityInSeconds": 14_400,
},
})))
.expect(1)
.mount(&server)
.await;
let client = test_client(
&server.uri(),
AuthMethod::ProgrammaticAccessToken {
token: "pat-xyz".into(),
},
);
client
.create_session()
.await
.expect("PAT login should succeed against the mock");
let requests = server.received_requests().await.expect("recorded requests");
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(body["data"]["AUTHENTICATOR"], "PROGRAMMATIC_ACCESS_TOKEN");
assert_eq!(body["data"]["TOKEN"], "pat-xyz");
assert_eq!(body["data"]["LOGIN_NAME"], "tester");
assert_eq!(body["data"]["ACCOUNT_NAME"], "TESTACCT");
}
#[tokio::test]
async fn keypair_jwt_create_session_sends_a_jwt_and_captures_login_tokens() {
use aws_lc_rs::encoding::AsDer as _;
use base64::Engine as _;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/session/v1/login-request"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"success": true,
"data": {
"token": "sess-tok",
"masterToken": "mast-tok",
"validityInSeconds": 3600,
"masterValidityInSeconds": 14_400,
},
})))
.expect(1)
.mount(&server)
.await;
let key_pair = aws_lc_rs::rsa::KeyPair::generate(aws_lc_rs::rsa::KeySize::Rsa2048).unwrap();
let der = key_pair.as_der().unwrap();
let pem = format!(
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n",
base64::engine::general_purpose::STANDARD.encode(der.as_ref())
);
let client = test_client(
&server.uri(),
AuthMethod::KeyPairJwt(KeyPairConfig {
private_key_pem: pem.into(),
}),
);
client
.create_session()
.await
.expect("key-pair JWT login should succeed against the mock");
let requests = server.received_requests().await.expect("recorded requests");
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(body["data"]["AUTHENTICATOR"], "SNOWFLAKE_JWT");
let jwt = body["data"]["TOKEN"].as_str().expect("a JWT string");
assert_eq!(jwt.split('.').count(), 3, "TOKEN must be a compact JWS");
}
}