use crate::constants::{
DAYS_TO_BACK_LOOK, DEFAULT_API_VERSION, DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE,
DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS, DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS,
DEFAULT_DATABASE_MAX_CONNECTIONS, DEFAULT_DATABASE_URL, DEFAULT_PAGE_SIZE,
DEFAULT_REST_BASE_URL, DEFAULT_REST_TIMEOUT_SECS, DEFAULT_SLEEP_TIME,
DEFAULT_WS_RECONNECT_INTERVAL_SECS, DEFAULT_WS_URL,
};
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 RestApiConfig {
fn default() -> Self {
Self {
base_url: String::from(DEFAULT_REST_BASE_URL),
timeout: DEFAULT_REST_TIMEOUT_SECS,
}
}
}
impl Default for WebSocketConfig {
fn default() -> Self {
Self {
url: String::from(DEFAULT_WS_URL),
reconnect_interval: DEFAULT_WS_RECONNECT_INTERVAL_SECS,
}
}
}
impl Default for RateLimiterConfig {
fn default() -> Self {
Self {
max_requests: DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS,
period_seconds: DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS,
burst_size: DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE,
}
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: String::from(DEFAULT_DATABASE_URL),
max_connections: DEFAULT_DATABASE_MAX_CONNECTIONS,
}
}
}
impl Credentials {
#[must_use]
pub fn new(username: String, password: String, account_id: String, api_key: String) -> Self {
Self {
username,
password,
account_id,
api_key,
client_token: None,
account_token: None,
}
}
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}
impl Config {
#[must_use]
pub fn from_credentials(credentials: Credentials) -> Self {
Config {
credentials,
rest_api: RestApiConfig::default(),
websocket: WebSocketConfig::default(),
database: DatabaseConfig::default(),
rate_limiter: RateLimiterConfig::default(),
sleep_hours: DEFAULT_SLEEP_TIME,
page_size: DEFAULT_PAGE_SIZE,
days_to_look_back: DAYS_TO_BACK_LOOK,
api_version: Some(DEFAULT_API_VERSION),
}
}
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 rest_defaults = RestApiConfig::default();
let ws_defaults = WebSocketConfig::default();
let rate_limit_defaults = RateLimiterConfig::default();
let database_defaults = DatabaseConfig::default();
let database_url = get_env_or_default("DATABASE_URL", database_defaults.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", rest_defaults.base_url),
timeout: get_env_or_default("IG_REST_TIMEOUT", rest_defaults.timeout),
},
websocket: WebSocketConfig {
url: get_env_or_default("IG_WS_URL", ws_defaults.url),
reconnect_interval: get_env_or_default(
"IG_WS_RECONNECT_INTERVAL",
ws_defaults.reconnect_interval,
),
},
database: DatabaseConfig {
url: database_url,
max_connections: get_env_or_default(
"DATABASE_MAX_CONNECTIONS",
database_defaults.max_connections,
),
},
rate_limiter: RateLimiterConfig {
max_requests: get_env_or_default(
"IG_RATE_LIMIT_MAX_REQUESTS",
rate_limit_defaults.max_requests,
),
period_seconds: get_env_or_default(
"IG_RATE_LIMIT_PERIOD_SECONDS",
rate_limit_defaults.period_seconds,
),
burst_size: get_env_or_default(
"IG_RATE_LIMIT_BURST_SIZE",
rate_limit_defaults.burst_size,
),
},
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(DEFAULT_API_VERSION)), }
}
}
#[cfg(test)]
mod injection_tests {
use super::*;
fn injected_credentials() -> Credentials {
Credentials::new(
"embedder-user".to_string(),
"embedder-password".to_string(),
"EMBEDDER-ACC".to_string(),
"embedder-api-key".to_string(),
)
}
#[test]
fn test_credentials_new_leaves_session_tokens_unset() {
let credentials = injected_credentials();
assert_eq!(credentials.username, "embedder-user");
assert_eq!(credentials.password, "embedder-password");
assert_eq!(credentials.account_id, "EMBEDDER-ACC");
assert_eq!(credentials.api_key, "embedder-api-key");
assert!(credentials.client_token.is_none());
assert!(credentials.account_token.is_none());
}
#[test]
fn test_config_from_credentials_keeps_injected_credentials_and_env_free_defaults() {
let config = Config::from_credentials(injected_credentials());
assert_eq!(config.credentials.username, "embedder-user");
assert_eq!(config.credentials.api_key, "embedder-api-key");
assert_eq!(config.rest_api.base_url, DEFAULT_REST_BASE_URL);
assert_eq!(config.rest_api.timeout, DEFAULT_REST_TIMEOUT_SECS);
assert_eq!(config.websocket.url, DEFAULT_WS_URL);
assert_eq!(
config.websocket.reconnect_interval,
DEFAULT_WS_RECONNECT_INTERVAL_SECS
);
assert_eq!(config.database.url, DEFAULT_DATABASE_URL);
assert_eq!(
config.database.max_connections,
DEFAULT_DATABASE_MAX_CONNECTIONS
);
assert_eq!(
config.rate_limiter.max_requests,
DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
);
assert_eq!(
config.rate_limiter.period_seconds,
DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
);
assert_eq!(
config.rate_limiter.burst_size,
DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
);
assert_eq!(config.sleep_hours, DEFAULT_SLEEP_TIME);
assert_eq!(config.page_size, DEFAULT_PAGE_SIZE);
assert_eq!(config.days_to_look_back, DAYS_TO_BACK_LOOK);
assert_eq!(config.api_version, Some(DEFAULT_API_VERSION));
}
#[test]
fn test_config_from_credentials_struct_update_overrides_section() {
let config = Config {
rest_api: RestApiConfig {
base_url: "https://demo-api.ig.com/gateway/deal".to_string(),
timeout: 7,
},
..Config::from_credentials(injected_credentials())
};
assert_eq!(
config.rest_api.base_url,
"https://demo-api.ig.com/gateway/deal"
);
assert_eq!(config.rest_api.timeout, 7);
assert_eq!(config.websocket.url, DEFAULT_WS_URL);
}
#[test]
fn test_section_defaults_match_documented_constants() {
let rest = RestApiConfig::default();
let ws = WebSocketConfig::default();
let rate_limiter = RateLimiterConfig::default();
let database = DatabaseConfig::default();
assert_eq!(rest.base_url, DEFAULT_REST_BASE_URL);
assert_eq!(rest.timeout, DEFAULT_REST_TIMEOUT_SECS);
assert_eq!(ws.url, DEFAULT_WS_URL);
assert_eq!(ws.reconnect_interval, DEFAULT_WS_RECONNECT_INTERVAL_SECS);
assert_eq!(
rate_limiter.max_requests,
DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
);
assert_eq!(
rate_limiter.period_seconds,
DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
);
assert_eq!(
rate_limiter.burst_size,
DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
);
assert_eq!(database.url, DEFAULT_DATABASE_URL);
assert_eq!(database.max_connections, DEFAULT_DATABASE_MAX_CONNECTIONS);
assert_ne!(
rate_limiter.burst_size,
crate::constants::DEFAULT_RATE_LIMIT_BURST_SIZE
);
}
}
#[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>"));
}
}
}