use crate::types::oauth::{ClientSecret, RefreshToken};
use crate::{TastyTrade, TastyTradeError};
use pretty_simple_display::{DebugPretty, DisplaySimple};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::Path;
use tracing::{debug, warn};
const BASE_DEMO_URL: &str = "https://api.cert.tastyworks.com";
const BASE_URL: &str = "https://api.tastyworks.com";
const WEBSOCKET_DEMO_URL: &str = "wss://streamer.cert.tastyworks.com";
const WEBSOCKET_URL: &str = "wss://streamer.tastyworks.com";
#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
pub struct TastyTradeConfig {
#[serde(skip_serializing, default)]
pub client_secret: ClientSecret,
#[serde(skip_serializing, default)]
pub refresh_token: RefreshToken,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub redirect_uri: String,
pub use_demo: bool,
pub log_level: String,
pub base_url: String,
pub websocket_url: String,
}
impl Default for TastyTradeConfig {
fn default() -> Self {
Self::from_env()
}
}
impl TastyTradeConfig {
pub fn new() -> Self {
Self::from_env()
}
pub fn from_env() -> Self {
#[cfg(not(test))]
dotenv::dotenv().ok();
let client_secret =
ClientSecret::new(env::var("TASTYTRADE_CLIENT_SECRET").unwrap_or_default());
let refresh_token =
RefreshToken::new(env::var("TASTYTRADE_REFRESH_TOKEN").unwrap_or_default());
let client_id = env::var("TASTYTRADE_CLIENT_ID").unwrap_or_default();
let redirect_uri = env::var("TASTYTRADE_REDIRECT_URI").unwrap_or_default();
let log_level = env::var("LOGLEVEL").unwrap_or_else(|_| "INFO".to_string());
let use_demo = match env::var("TASTYTRADE_USE_DEMO") {
Ok(raw) => match raw.trim().parse::<bool>() {
Ok(value) => value,
Err(_) => {
warn!(
"TASTYTRADE_USE_DEMO is not a boolean; using the certification environment"
);
true
}
},
Err(_) => {
debug!("TASTYTRADE_USE_DEMO is unset; using the certification environment");
true
}
};
if !use_demo {
warn!("Using the tastytrade production environment: orders placed here are real");
}
Self {
client_secret,
refresh_token,
client_id,
redirect_uri,
use_demo,
log_level,
base_url: if use_demo {
BASE_DEMO_URL.to_string()
} else {
BASE_URL.to_string()
},
websocket_url: if use_demo {
WEBSOCKET_DEMO_URL.to_string()
} else {
WEBSOCKET_URL.to_string()
},
}
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, TastyTradeError> {
let contents = fs::read_to_string(path)?;
let config: TastyTradeConfig = serde_json::from_str(&contents)?;
Ok(config)
}
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), TastyTradeError> {
let contents = serde_json::to_string_pretty(self)?;
fs::write(path, contents)?;
Ok(())
}
pub fn environment(&self) -> crate::error::Environment {
if self.base_url.starts_with(BASE_DEMO_URL) {
crate::error::Environment::Certification
} else {
crate::error::Environment::Production
}
}
pub fn has_valid_credentials(&self) -> bool {
!self.client_secret.is_blank() && !self.refresh_token.is_blank()
}
pub async fn create_client(&self) -> Result<TastyTrade, TastyTradeError> {
TastyTrade::connect(self).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
const VARIABLES: [&str; 7] = [
"TASTYTRADE_CLIENT_SECRET",
"TASTYTRADE_REFRESH_TOKEN",
"TASTYTRADE_CLIENT_ID",
"TASTYTRADE_REDIRECT_URI",
"TASTYTRADE_USE_DEMO",
"LOGLEVEL",
"TASTYTRADE_REMEMBER_ME",
];
fn clear_environment() {
for name in VARIABLES {
unsafe {
env::remove_var(name);
}
}
}
#[test]
#[serial]
fn test_default_config() {
clear_environment();
let config = TastyTradeConfig::default();
assert!(config.client_secret.is_blank());
assert!(config.refresh_token.is_blank());
assert!(config.client_id.is_empty());
assert_eq!(config.log_level, "INFO");
assert!(
config.use_demo,
"an unset environment must not select production"
);
assert_eq!(config.base_url, BASE_DEMO_URL);
assert_eq!(config.websocket_url, WEBSOCKET_DEMO_URL);
}
#[test]
#[serial]
fn unparseable_use_demo_falls_back_to_certification() {
for raw in ["", "no", "0", "FALSE!", "prod", " "] {
unsafe {
env::set_var("TASTYTRADE_USE_DEMO", raw);
}
let config = TastyTradeConfig::from_env();
assert!(
config.use_demo,
"TASTYTRADE_USE_DEMO={raw:?} must not select production"
);
assert_eq!(config.base_url, BASE_DEMO_URL);
}
clear_environment();
}
#[test]
#[serial]
fn production_opt_in_tolerates_surrounding_whitespace() {
unsafe {
env::set_var("TASTYTRADE_USE_DEMO", " false ");
}
let config = TastyTradeConfig::from_env();
assert!(!config.use_demo);
assert_eq!(config.base_url, BASE_URL);
clear_environment();
}
#[tokio::test]
#[serial]
async fn missing_credentials_fail_locally_without_a_request() {
let config = TastyTradeConfig {
client_secret: ClientSecret::new(""),
refresh_token: RefreshToken::new(""),
client_id: String::new(),
redirect_uri: String::new(),
use_demo: true,
log_level: "WARN".to_string(),
base_url: "http://127.0.0.1:1".to_string(),
websocket_url: WEBSOCKET_DEMO_URL.to_string(),
};
let error = config
.create_client()
.await
.expect_err("missing credentials must not reach the venue");
assert!(
matches!(error, TastyTradeError::ConfigError(_)),
"expected a configuration error, got {error:?}"
);
let text = format!("{error}");
assert!(
text.contains("TASTYTRADE_CLIENT_SECRET") && text.contains("TASTYTRADE_REFRESH_TOKEN"),
"the error must name the variables to set: {text}"
);
}
#[test]
#[serial]
fn test_config_from_env() {
clear_environment();
unsafe {
env::set_var("TASTYTRADE_CLIENT_SECRET", "test_secret");
env::set_var("TASTYTRADE_REFRESH_TOKEN", "test_refresh");
env::set_var("TASTYTRADE_CLIENT_ID", "test_client");
env::set_var("TASTYTRADE_REDIRECT_URI", "https://app.example.com/cb");
env::set_var("TASTYTRADE_USE_DEMO", "true");
env::set_var("LOGLEVEL", "DEBUG");
}
let config = TastyTradeConfig::from_env();
assert_eq!(config.client_secret.expose_secret(), "test_secret");
assert_eq!(config.refresh_token.expose_secret(), "test_refresh");
assert_eq!(config.client_id, "test_client");
assert_eq!(config.redirect_uri, "https://app.example.com/cb");
assert!(config.use_demo);
assert_eq!(config.base_url, BASE_DEMO_URL.to_string());
assert_eq!(config.websocket_url, WEBSOCKET_DEMO_URL.to_string());
clear_environment();
}
#[test]
#[serial]
fn the_retired_remember_me_variable_is_not_read() {
clear_environment();
unsafe {
env::set_var("TASTYTRADE_REMEMBER_ME", "true");
}
let rendered = format!("{:?}", TastyTradeConfig::from_env());
assert!(
!rendered.contains("remember"),
"a retired setting must not reappear in the configuration: {rendered}"
);
clear_environment();
}
#[test]
#[serial]
fn whitespace_is_not_a_credential() {
let config = TastyTradeConfig {
client_secret: ClientSecret::new(" "),
refresh_token: RefreshToken::new("\t\n"),
client_id: String::new(),
redirect_uri: String::new(),
use_demo: true,
log_level: "WARN".to_string(),
base_url: BASE_DEMO_URL.to_string(),
websocket_url: WEBSOCKET_DEMO_URL.to_string(),
};
assert!(!config.has_valid_credentials());
}
#[test]
#[serial]
fn test_has_valid_credentials() {
clear_environment();
let mut config = TastyTradeConfig::default();
assert!(!config.has_valid_credentials());
config.client_secret = ClientSecret::new("secret");
assert!(!config.has_valid_credentials());
config.refresh_token = RefreshToken::new("refresh");
assert!(config.has_valid_credentials());
}
#[test]
fn test_serialize_deserialize() {
let config = TastyTradeConfig {
client_secret: ClientSecret::new("SENTINEL-client-secret-3Qv7"),
refresh_token: RefreshToken::new("SENTINEL-refresh-token-8Hb2"),
client_id: "client-abc".to_string(),
redirect_uri: "https://app.example.com/cb".to_string(),
use_demo: true,
log_level: "DEBUG".to_string(),
base_url: BASE_DEMO_URL.to_string(),
websocket_url: WEBSOCKET_DEMO_URL.to_string(),
};
let json = serde_json::to_string(&config).expect("the config serializes");
for rendered in [json.clone(), format!("{config:?}"), format!("{config}")] {
assert!(
!rendered.contains("SENTINEL"),
"a secret escaped: {rendered}"
);
}
assert!(json.contains("client-abc"), "{json}");
let deserialized: TastyTradeConfig =
serde_json::from_str(&json).expect("the config round-trips");
assert_eq!(config.client_id, deserialized.client_id);
assert_eq!(config.use_demo, deserialized.use_demo);
assert_eq!(config.log_level, deserialized.log_level);
assert!(
deserialized.client_secret.is_blank(),
"a saved configuration must not be able to carry the secret back"
);
}
#[test]
#[serial]
fn test_config_from_env_demo_false() {
clear_environment();
unsafe {
env::set_var("TASTYTRADE_CLIENT_SECRET", "test_secret");
env::set_var("TASTYTRADE_REFRESH_TOKEN", "test_refresh");
env::set_var("TASTYTRADE_USE_DEMO", "false");
env::set_var("LOGLEVEL", "DEBUG");
}
let config = TastyTradeConfig::from_env();
assert_eq!(config.client_secret.expose_secret(), "test_secret");
assert!(!config.use_demo);
assert_eq!(config.base_url, BASE_URL.to_string());
assert_eq!(config.websocket_url, WEBSOCKET_URL.to_string());
clear_environment();
}
}
#[cfg(test)]
mod environment_tests {
use super::*;
use crate::error::Environment;
fn config_with(base_url: &str, use_demo: bool) -> TastyTradeConfig {
TastyTradeConfig {
client_secret: ClientSecret::new("secret"),
refresh_token: RefreshToken::new("refresh"),
client_id: String::new(),
redirect_uri: String::new(),
use_demo,
log_level: "WARN".to_string(),
base_url: base_url.to_string(),
websocket_url: WEBSOCKET_DEMO_URL.to_string(),
}
}
#[test]
fn the_url_decides_not_the_flag() {
assert_eq!(
config_with(BASE_URL, true).environment(),
Environment::Production,
"use_demo must not relabel a production URL as certification"
);
assert_eq!(
config_with(BASE_DEMO_URL, false).environment(),
Environment::Certification
);
}
#[test]
fn an_unknown_host_is_reported_as_production() {
assert_eq!(
config_with("http://127.0.0.1:8080", true).environment(),
Environment::Production
);
}
}