use crate::auth::{OAuthConfig, TokenStoreKind};
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::env;
use std::path::PathBuf;
use std::time::Duration;
use url::Url;
const ALLOWED_API_DOMAINS: &[&str] = &[
"api.schwabapi.com",
"streamer.schwabapi.com",
"streamer-api.schwab.com",
];
const LOOPBACK_HOSTS: &[&str] = &["127.0.0.1", "localhost"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchwabConfig {
pub oauth: OAuthConfig,
pub client: ClientConfig,
pub streaming: Option<StreamConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientConfig {
pub base_url: String,
#[serde(with = "humantime_serde")]
pub timeout: Duration,
pub retry: RetryConfig,
pub rate_limit: RateLimitConfig,
pub circuit_breaker: CircuitBreakerConfig,
pub user_agent: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChannelKind {
Unbounded,
Bounded(usize),
}
pub const DEFAULT_STREAMER_URL: &str = "wss://streamer-api.schwab.com/ws";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamConfig {
pub websocket_url: String,
pub reconnect: ReconnectConfig,
#[serde(with = "humantime_serde")]
pub heartbeat_interval: Duration,
#[serde(with = "humantime_serde")]
pub ping_timeout: Duration,
pub max_subscriptions: usize,
pub channel_kind: ChannelKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryConfig {
pub max_retries: u32,
#[serde(with = "humantime_serde")]
pub initial_backoff: Duration,
#[serde(with = "humantime_serde")]
pub max_backoff: Duration,
pub backoff_multiplier: f64,
pub retry_on_status: Vec<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReconnectConfig {
pub enabled: bool,
pub max_retries: Option<usize>,
#[serde(with = "humantime_serde")]
pub initial_backoff: Duration,
#[serde(with = "humantime_serde")]
pub max_backoff: Duration,
pub backoff_multiplier: f64,
#[serde(default = "default_reconnect_jitter")]
pub jitter: f64,
}
fn default_reconnect_jitter() -> f64 {
0.25
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
pub enabled: bool,
pub requests_per_second: u32,
pub burst_size: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircuitBreakerConfig {
pub enabled: bool,
pub failure_threshold: u32,
pub success_threshold: u32,
#[serde(with = "humantime_serde")]
pub open_duration: Duration,
pub half_open_max_requests: u32,
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
enabled: true,
failure_threshold: 5,
success_threshold: 3,
open_duration: Duration::from_secs(30),
half_open_max_requests: 1,
}
}
}
impl Default for SchwabConfig {
fn default() -> Self {
Self {
oauth: OAuthConfig::default(),
client: ClientConfig::default(),
streaming: Some(StreamConfig::default()),
}
}
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
base_url: "https://api.schwabapi.com".to_string(),
timeout: Duration::from_secs(10), retry: RetryConfig::default(),
rate_limit: RateLimitConfig::default(),
circuit_breaker: CircuitBreakerConfig::default(),
user_agent: format!("rustyschwab/{}", env!("CARGO_PKG_VERSION")),
}
}
}
impl StreamConfig {
pub fn set_socket_url(&mut self, url: &str) -> Result<()> {
SchwabConfig::validate_url_domain(url, "streamerSocketUrl")?;
self.websocket_url = url.to_owned();
Ok(())
}
}
impl Default for StreamConfig {
fn default() -> Self {
Self {
websocket_url: DEFAULT_STREAMER_URL.to_string(),
reconnect: ReconnectConfig::default(),
heartbeat_interval: Duration::from_secs(20), ping_timeout: Duration::from_secs(30), max_subscriptions: 500,
channel_kind: ChannelKind::Bounded(4096),
}
}
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(10),
backoff_multiplier: 2.0,
retry_on_status: vec![429, 500, 502, 503, 504],
}
}
}
impl Default for ReconnectConfig {
fn default() -> Self {
Self {
enabled: true,
max_retries: None, initial_backoff: Duration::from_secs(2), max_backoff: Duration::from_secs(128), backoff_multiplier: 2.0,
jitter: default_reconnect_jitter(),
}
}
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: true,
requests_per_second: 2,
burst_size: 10,
}
}
}
impl SchwabConfig {
pub fn validate_urls(&self) -> Result<()> {
Self::validate_url_domain(&self.oauth.auth_url, "auth_url")?;
Self::validate_url_domain(&self.oauth.token_url, "token_url")?;
Self::validate_url_domain(&self.client.base_url, "base_url")?;
if let Some(ref streaming) = self.streaming {
Self::validate_url_domain(&streaming.websocket_url, "websocket_url")?;
}
Ok(())
}
pub(crate) fn validate_url_domain(url_str: &str, field_name: &str) -> Result<()> {
let url = Url::parse(url_str)
.map_err(|e| Error::Config(format!("Invalid URL for {}: {}", field_name, e)))?;
let host = url.host_str().ok_or_else(|| {
Error::Config(format!(
"No host found in URL for {}: {}",
field_name, url_str
))
})?;
let scheme = url.scheme();
let is_localhost = LOOPBACK_HOSTS.contains(&host);
let is_allowed = is_localhost
|| ALLOWED_API_DOMAINS
.iter()
.any(|allowed| host == *allowed || host.ends_with(&format!(".{allowed}")));
if !is_allowed {
return Err(Error::Config(format!(
"Domain '{}' not in allowed list for {} (allowed: {:?} and loopback)",
host, field_name, ALLOWED_API_DOMAINS
)));
}
if !is_localhost {
if scheme != "https" && scheme != "wss" {
return Err(Error::Config(format!(
"Insecure scheme '{scheme}' used for external domain '{host}' in \
{field_name}. Must use 'https' or 'wss'."
)));
}
} else {
if scheme != "http" && scheme != "https" && scheme != "ws" && scheme != "wss" {
return Err(Error::Config(format!(
"Invalid scheme '{}' for localhost in {}.",
scheme, field_name
)));
}
}
Ok(())
}
pub fn from_env() -> Result<Self> {
let oauth = OAuthConfig {
app_key: env::var("SCHWAB_APP_KEY")
.map_err(|_| Error::Config("SCHWAB_APP_KEY not set".to_string()))?,
app_secret: env::var("SCHWAB_APP_SECRET")
.map_err(|_| Error::Config("SCHWAB_APP_SECRET not set".to_string()))?,
callback_url: env::var("SCHWAB_OAUTH_CALLBACK_URL")
.unwrap_or_else(|_| "https://127.0.0.1:8080".to_string()),
auth_url: env::var("SCHWAB_OAUTH_AUTH_URL")
.unwrap_or_else(|_| "https://api.schwabapi.com/v1/oauth/authorize".to_string()),
token_url: env::var("SCHWAB_OAUTH_TOKEN_URL")
.unwrap_or_else(|_| "https://api.schwabapi.com/v1/oauth/token".to_string()),
tokens_file: env::var("SCHWAB_TOKENS_FILE")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("schwab_tokens.json")),
capture_callback: env_flag("SCHWAB_CAPTURE_CALLBACK", false)?,
auto_refresh: env_flag("SCHWAB_AUTO_REFRESH", true)?,
refresh_buffer_seconds: env_parse("SCHWAB_REFRESH_BUFFER", 61)?,
pkce_enabled: env_flag("SCHWAB_PKCE_ENABLED", false)?,
token_store_kind: match env::var("SCHWAB_TOKEN_STORE").ok() {
None => TokenStoreKind::default(),
Some(raw) => match raw.to_lowercase().as_str() {
"keychain" => TokenStoreKind::Keychain,
"encrypted" | "encryptedfile" | "encrypted_file" => {
TokenStoreKind::EncryptedFile
}
"file" => TokenStoreKind::File,
other => {
return Err(Error::Config(format!(
"SCHWAB_TOKEN_STORE={other:?} not recognized \
(valid: keychain | encrypted | file)"
)));
}
},
},
allow_external_callback: env_flag("SCHWAB_ALLOW_EXTERNAL_CALLBACK", false)?,
callback_cert_file: env::var_os("SCHWAB_CALLBACK_CERT_FILE").map(PathBuf::from),
callback_key_file: env::var_os("SCHWAB_CALLBACK_KEY_FILE").map(PathBuf::from),
on_token_notification: None,
};
let client = ClientConfig {
base_url: env::var("SCHWAB_CLIENT_BASE_URL")
.unwrap_or_else(|_| "https://api.schwabapi.com".to_string()),
..ClientConfig::default()
};
let streaming = if env_flag("SCHWAB_STREAMING_ENABLED", true)? {
Some(StreamConfig {
websocket_url: env::var("SCHWAB_WEBSOCKET_URL")
.unwrap_or_else(|_| DEFAULT_STREAMER_URL.to_string()),
..StreamConfig::default()
})
} else {
None
};
let config = Self {
oauth,
client,
streaming,
};
config.validate_urls()?;
Ok(config)
}
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
let path = path.as_ref();
let contents = crate::security::read_private_file(path)?;
let config: Self = serde_json::from_slice(&contents)
.map_err(|e| Error::Config(format!("Failed to parse config file: {}", e)))?;
config.validate_urls()?;
Ok(config)
}
pub fn save(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
let mut json = zeroize::Zeroizing::new(Vec::with_capacity(64 * 1024));
serde_json::to_writer_pretty(&mut *json, self)
.map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?;
crate::security::secure_file_write(path.as_ref(), &json)
.map_err(|e| Error::Config(format!("Failed to write config file: {}", e)))?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
if self.oauth.app_key.is_empty() {
return Err(Error::Config("OAuth app_key is required".to_string()));
}
if self.oauth.app_secret.is_empty() {
return Err(Error::Config("OAuth app_secret is required".to_string()));
}
if self.client.retry.max_retries > 10 {
return Err(Error::Config(
"Maximum retries should not exceed 10".to_string(),
));
}
if self.client.rate_limit.requests_per_second > 500 {
return Err(Error::Config(
"Rate limit should not exceed 500 requests per second".to_string(),
));
}
let rate = &self.client.rate_limit;
if rate.enabled && (rate.requests_per_second == 0 || rate.burst_size == 0) {
return Err(Error::Config(
"Rate limit and burst must be positive when rate limiting is enabled".into(),
));
}
let retry = &self.client.retry;
if !retry.backoff_multiplier.is_finite()
|| retry.backoff_multiplier < 1.0
|| retry.initial_backoff.is_zero()
|| retry.max_backoff < retry.initial_backoff
{
return Err(Error::Config(
"Retry backoff needs a positive initial delay, a maximum at least as long, \
and a finite multiplier of at least 1"
.into(),
));
}
if self.client.timeout.is_zero() || self.client.timeout > Duration::from_secs(300) {
return Err(Error::Config(
"Request timeout must be positive and at most 300 seconds".into(),
));
}
if !(30..=1800).contains(&self.oauth.refresh_buffer_seconds) {
return Err(Error::Config(
"Token refresh buffer must be between 30 and 1800 seconds".into(),
));
}
if let Some(stream) = &self.streaming
&& (stream.heartbeat_interval.is_zero()
|| stream.ping_timeout <= stream.heartbeat_interval)
{
return Err(Error::Config(
"Stream heartbeat interval must be positive and shorter than the ping timeout"
.into(),
));
}
if self.oauth.capture_callback || !self.oauth.callback_url.is_empty() {
let callback = Url::parse(&self.oauth.callback_url)
.map_err(|e| Error::Config(format!("Invalid callback_url: {e}")))?;
let loopback = callback
.host_str()
.is_some_and(|host| LOOPBACK_HOSTS.contains(&host));
if callback.scheme() != "https" && !(loopback && callback.scheme() == "http") {
return Err(Error::Config(
"callback_url must use https (http only on loopback)".into(),
));
}
}
self.validate_urls()?;
Ok(())
}
}
fn env_parse<T: std::str::FromStr>(name: &str, default: T) -> Result<T> {
match env::var(name) {
Err(env::VarError::NotPresent) => Ok(default),
Err(env::VarError::NotUnicode(_)) => {
Err(Error::Config(format!("{name} is not valid UTF-8")))
}
Ok(raw) => raw
.trim()
.parse()
.map_err(|_| Error::Config(format!("{name}={raw:?} is not a valid value"))),
}
}
fn env_flag(name: &str, default: bool) -> Result<bool> {
match env::var(name) {
Err(env::VarError::NotPresent) => Ok(default),
Err(env::VarError::NotUnicode(_)) => {
Err(Error::Config(format!("{name} is not valid UTF-8")))
}
Ok(raw) => match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Ok(true),
"false" | "0" | "no" | "off" => Ok(false),
_ => Err(Error::Config(format!(
"{name}={raw:?} is not a boolean (true/false, 1/0, yes/no, on/off)"
))),
},
}
}
#[derive(Debug, Clone)]
#[must_use = "a builder does nothing until `build` is called"]
pub struct ConfigBuilder {
config: SchwabConfig,
}
impl ConfigBuilder {
pub fn new() -> Self {
Self {
config: SchwabConfig::default(),
}
}
pub fn app_key(mut self, key: impl Into<String>) -> Self {
self.config.oauth.app_key = key.into();
self
}
pub fn app_secret(mut self, secret: impl Into<String>) -> Self {
self.config.oauth.app_secret = secret.into();
self
}
pub fn callback_url(mut self, url: impl Into<String>) -> Self {
self.config.oauth.callback_url = url.into();
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.config.client.base_url = url.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.config.client.timeout = timeout;
self
}
pub fn max_retries(mut self, retries: u32) -> Self {
self.config.client.retry.max_retries = retries;
self
}
pub fn enable_streaming(mut self, enabled: bool) -> Self {
if enabled && self.config.streaming.is_none() {
self.config.streaming = Some(StreamConfig::default());
} else if !enabled {
self.config.streaming = None;
}
self
}
pub fn websocket_url(mut self, url: impl Into<String>) -> Self {
if let Some(ref mut streaming) = self.config.streaming {
streaming.websocket_url = url.into();
}
self
}
pub fn build(self) -> Result<SchwabConfig> {
self.config.validate()?;
Ok(self.config)
}
}
impl Default for ConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_builder() {
let config = ConfigBuilder::new()
.app_key("test_key")
.app_secret("test_secret")
.callback_url("http://localhost:8080")
.timeout(Duration::from_secs(60))
.build()
.unwrap();
assert_eq!(config.oauth.app_key, "test_key");
assert_eq!(config.oauth.app_secret, "test_secret");
assert_eq!(config.client.timeout, Duration::from_secs(60));
}
#[test]
fn test_config_validation() {
let config = SchwabConfig::default();
assert!(config.validate().is_err());
let config = ConfigBuilder::new()
.app_key("key")
.app_secret("secret")
.build()
.unwrap();
assert!(config.validate().is_ok());
}
}