use crate::constants::{DAYS_TO_BACK_LOOK, DEFAULT_PAGE_SIZE, DEFAULT_SLEEP_TIME};
use crate::utils::config::get_env_or_default;
use dotenv::dotenv;
use pretty_simple_display::{DebugPretty, DisplaySimple};
use serde::{Deserialize, Serialize};
use std::env;
use tracing::error;
use tracing::log::debug;
#[derive(Serialize, Deserialize, Clone)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
}
impl std::fmt::Debug for DatabaseConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DatabaseConfig")
.field("url", &"<redacted>")
.field("max_connections", &self.max_connections)
.finish()
}
}
impl std::fmt::Display for DatabaseConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"DatabaseConfig {{ url: <redacted>, max_connections: {} }}",
self.max_connections
)
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Credentials {
pub username: String,
pub password: String,
pub account_id: String,
pub api_key: String,
pub client_token: Option<String>,
pub account_token: Option<String>,
}
impl std::fmt::Debug for Credentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Credentials")
.field("username", &self.username)
.field("password", &"<redacted>")
.field("account_id", &self.account_id)
.field("api_key", &"<redacted>")
.field(
"client_token",
&self.client_token.as_ref().map(|_| "<redacted>"),
)
.field(
"account_token",
&self.account_token.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
impl std::fmt::Display for Credentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Credentials {{ username: {}, account_id: {}, password: <redacted>, \
api_key: <redacted>, client_token: {}, account_token: {} }}",
self.username,
self.account_id,
if self.client_token.is_some() {
"<redacted>"
} else {
"None"
},
if self.account_token.is_some() {
"<redacted>"
} else {
"None"
},
)
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub credentials: Credentials,
pub rest_api: RestApiConfig,
pub websocket: WebSocketConfig,
pub database: DatabaseConfig,
pub rate_limiter: RateLimiterConfig,
pub sleep_hours: u64,
pub page_size: u32,
pub days_to_look_back: i64,
pub api_version: Option<u8>,
}
impl std::fmt::Display for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Config {{ credentials: {}, rest_api: {}, websocket: {}, database: {}, \
rate_limiter: {}, sleep_hours: {}, page_size: {}, days_to_look_back: {}, \
api_version: {:?} }}",
self.credentials,
self.rest_api,
self.websocket,
self.database,
self.rate_limiter,
self.sleep_hours,
self.page_size,
self.days_to_look_back,
self.api_version,
)
}
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct RestApiConfig {
pub base_url: String,
pub timeout: u64,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct WebSocketConfig {
pub url: String,
pub reconnect_interval: u64,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct RateLimiterConfig {
pub max_requests: u32,
pub period_seconds: u64,
pub burst_size: u32,
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}
impl Config {
pub fn new() -> Self {
match dotenv() {
Ok(_) => debug!("Successfully loaded .env file"),
Err(e) => debug!("Failed to load .env file: {e}"),
}
let username = get_env_or_default("IG_USERNAME", String::from("default_username"));
let password = get_env_or_default("IG_PASSWORD", String::from("default_password"));
let api_key = get_env_or_default("IG_API_KEY", String::from("default_api_key"));
let sleep_hours = get_env_or_default("TX_LOOP_INTERVAL_HOURS", DEFAULT_SLEEP_TIME);
let page_size = get_env_or_default("TX_PAGE_SIZE", DEFAULT_PAGE_SIZE);
let days_to_look_back = get_env_or_default("TX_DAYS_LOOKBACK", DAYS_TO_BACK_LOOK);
let database_url = get_env_or_default(
"DATABASE_URL",
String::from(crate::constants::DEFAULT_DATABASE_URL),
);
if username == "default_username" {
error!("IG_USERNAME not found in environment variables or .env file");
}
if password == "default_password" {
error!("IG_PASSWORD not found in environment variables or .env file");
}
if api_key == "default_api_key" {
error!("IG_API_KEY not found in environment variables or .env file");
}
if env::var("DATABASE_URL").is_err() {
error!(
"DATABASE_URL not found in environment variables or .env file; \
using a credential-less placeholder and persistence will not connect"
);
}
Config {
credentials: Credentials {
username,
password,
account_id: get_env_or_default(
"IG_ACCOUNT_ID",
String::from(crate::constants::DEFAULT_ACCOUNT_ID),
),
api_key,
client_token: None,
account_token: None,
},
rest_api: RestApiConfig {
base_url: get_env_or_default(
"IG_REST_BASE_URL",
String::from("https://demo-api.ig.com/gateway/deal"),
),
timeout: get_env_or_default("IG_REST_TIMEOUT", 30),
},
websocket: WebSocketConfig {
url: get_env_or_default(
"IG_WS_URL",
String::from("wss://demo-apd.marketdatasystems.com"),
),
reconnect_interval: get_env_or_default("IG_WS_RECONNECT_INTERVAL", 5),
},
database: DatabaseConfig {
url: database_url,
max_connections: get_env_or_default("DATABASE_MAX_CONNECTIONS", 5),
},
rate_limiter: RateLimiterConfig {
max_requests: get_env_or_default("IG_RATE_LIMIT_MAX_REQUESTS", 4), period_seconds: get_env_or_default("IG_RATE_LIMIT_PERIOD_SECONDS", 12), burst_size: get_env_or_default("IG_RATE_LIMIT_BURST_SIZE", 3),
},
sleep_hours,
page_size,
days_to_look_back,
api_version: env::var("IG_API_VERSION")
.ok()
.and_then(|v| v.parse::<u8>().ok())
.filter(|&v| v == 2 || v == 3)
.or(Some(3)), }
}
}
#[cfg(test)]
mod redaction_tests {
use super::*;
fn secret_credentials() -> Credentials {
Credentials {
username: "user@example.com".to_string(),
password: "SUPER-SECRET-PASSWORD".to_string(),
account_id: "ACC123".to_string(),
api_key: "SECRET-API-KEY".to_string(),
client_token: Some("SECRET-CST".to_string()),
account_token: Some("SECRET-XST".to_string()),
}
}
#[test]
fn test_credentials_debug_and_display_redact_secrets() {
let creds = secret_credentials();
for rendered in [format!("{creds:?}"), format!("{creds}")] {
for secret in [
"SUPER-SECRET-PASSWORD",
"SECRET-API-KEY",
"SECRET-CST",
"SECRET-XST",
] {
assert!(
!rendered.contains(secret),
"credentials rendering leaked {secret}: {rendered}"
);
}
assert!(rendered.contains("<redacted>"));
assert!(rendered.contains("user@example.com"));
assert!(rendered.contains("ACC123"));
}
}
#[test]
fn test_config_debug_and_display_redact_credential_and_db_secrets() {
let config = Config {
credentials: secret_credentials(),
database: DatabaseConfig {
url: "postgres://dbuser:DB-SECRET-PW@host/db".to_string(),
max_connections: 5,
},
..Config::default()
};
for rendered in [format!("{config:?}"), format!("{config}")] {
for secret in ["SUPER-SECRET-PASSWORD", "SECRET-API-KEY", "DB-SECRET-PW"] {
assert!(
!rendered.contains(secret),
"config rendering leaked {secret}: {rendered}"
);
}
assert!(rendered.contains("<redacted>"));
}
}
}