Skip to main content

ig_client/application/
config.rs

1use crate::constants::{DAYS_TO_BACK_LOOK, DEFAULT_PAGE_SIZE, DEFAULT_SLEEP_TIME};
2use crate::utils::config::get_env_or_default;
3use dotenv::dotenv;
4use pretty_simple_display::{DebugPretty, DisplaySimple};
5use serde::{Deserialize, Serialize};
6use std::env;
7use tracing::error;
8use tracing::log::debug;
9
10/// Configuration for database connections
11///
12/// This is a pure configuration DTO with no I/O. It lives in the application
13/// config module (alongside the other `*Config` types) so the application layer
14/// can embed it in [`Config`] without depending on the storage layer. The
15/// storage layer re-exports it (see `storage::config`) and owns the actual pool
16/// construction (`storage::utils::create_connection_pool`).
17#[derive(Serialize, Deserialize, Clone)]
18pub struct DatabaseConfig {
19    /// Database connection URL
20    pub url: String,
21    /// Maximum number of connections in the connection pool
22    pub max_connections: u32,
23}
24
25// The connection `url` commonly embeds a password, so `Debug`/`Display` must
26// never print it — they redact the URL and show only `max_connections`.
27impl std::fmt::Debug for DatabaseConfig {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_struct("DatabaseConfig")
30            .field("url", &"<redacted>")
31            .field("max_connections", &self.max_connections)
32            .finish()
33    }
34}
35
36impl std::fmt::Display for DatabaseConfig {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        write!(
39            f,
40            "DatabaseConfig {{ url: <redacted>, max_connections: {} }}",
41            self.max_connections
42        )
43    }
44}
45
46#[derive(Serialize, Deserialize, Clone)]
47/// Authentication credentials for the IG Markets API
48pub struct Credentials {
49    /// Username for the IG Markets account
50    pub username: String,
51    /// Password for the IG Markets account
52    pub password: String,
53    /// Account ID for the IG Markets account
54    pub account_id: String,
55    /// API key for the IG Markets API
56    pub api_key: String,
57    /// Client token for the IG Markets API
58    pub client_token: Option<String>,
59    /// Account token for the IG Markets API
60    pub account_token: Option<String>,
61}
62
63// `password`, `api_key` and the tokens are secrets, so `Debug`/`Display` must
64// never print them — they show `<redacted>` and leave only `username` /
65// `account_id` (non-sensitive identifiers) visible.
66impl std::fmt::Debug for Credentials {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("Credentials")
69            .field("username", &self.username)
70            .field("password", &"<redacted>")
71            .field("account_id", &self.account_id)
72            .field("api_key", &"<redacted>")
73            .field(
74                "client_token",
75                &self.client_token.as_ref().map(|_| "<redacted>"),
76            )
77            .field(
78                "account_token",
79                &self.account_token.as_ref().map(|_| "<redacted>"),
80            )
81            .finish()
82    }
83}
84
85impl std::fmt::Display for Credentials {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        write!(
88            f,
89            "Credentials {{ username: {}, account_id: {}, password: <redacted>, \
90             api_key: <redacted>, client_token: {}, account_token: {} }}",
91            self.username,
92            self.account_id,
93            if self.client_token.is_some() {
94                "<redacted>"
95            } else {
96                "None"
97            },
98            if self.account_token.is_some() {
99                "<redacted>"
100            } else {
101                "None"
102            },
103        )
104    }
105}
106
107#[derive(Debug, Serialize, Deserialize, Clone)]
108/// Main configuration for the IG Markets API client.
109///
110/// `Debug` is derived and delegates to each field's `Debug`, so the redacting
111/// `Credentials` and `DatabaseConfig` impls keep secrets out of the output;
112/// `Display` is manual for the same reason.
113pub struct Config {
114    /// Authentication credentials
115    pub credentials: Credentials,
116    /// REST API configuration
117    pub rest_api: RestApiConfig,
118    /// WebSocket API configuration
119    pub websocket: WebSocketConfig,
120    /// Database configuration for data persistence
121    pub database: DatabaseConfig,
122    /// Rate limiter configuration for API requests
123    pub rate_limiter: RateLimiterConfig,
124    /// Number of hours between transaction fetching operations
125    pub sleep_hours: u64,
126    /// Number of items to retrieve per page in API requests
127    pub page_size: u32,
128    /// Number of days to look back when fetching historical data
129    pub days_to_look_back: i64,
130    /// API version to use for authentication (2 or 3). If None, auto-detect based on available tokens
131    pub api_version: Option<u8>,
132}
133
134// Manual `Display` (replacing the derived, serde-based `DisplaySimple`, which
135// would serialize the whole tree including credential secrets). It delegates to
136// the nested configs' own `Display` impls — `Credentials` and `DatabaseConfig`
137// redact their secrets — and shows only non-sensitive scalars directly.
138impl std::fmt::Display for Config {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        write!(
141            f,
142            "Config {{ credentials: {}, rest_api: {}, websocket: {}, database: {}, \
143             rate_limiter: {}, sleep_hours: {}, page_size: {}, days_to_look_back: {}, \
144             api_version: {:?} }}",
145            self.credentials,
146            self.rest_api,
147            self.websocket,
148            self.database,
149            self.rate_limiter,
150            self.sleep_hours,
151            self.page_size,
152            self.days_to_look_back,
153            self.api_version,
154        )
155    }
156}
157
158#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
159/// Configuration for the REST API
160pub struct RestApiConfig {
161    /// Base URL for the IG Markets REST API
162    pub base_url: String,
163    /// Timeout in seconds for REST API requests
164    pub timeout: u64,
165}
166
167#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
168/// Configuration for the WebSocket API
169pub struct WebSocketConfig {
170    /// URL for the IG Markets WebSocket API
171    pub url: String,
172    /// Reconnect interval in seconds for WebSocket connections
173    pub reconnect_interval: u64,
174}
175
176#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
177/// Configuration for rate limiting API requests
178pub struct RateLimiterConfig {
179    /// Maximum number of requests allowed per period
180    pub max_requests: u32,
181    /// Time period in seconds for the rate limit
182    pub period_seconds: u64,
183    /// Burst size - maximum number of requests that can be made at once
184    pub burst_size: u32,
185}
186
187impl Default for Config {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193impl Config {
194    /// Creates a new configuration instance with a specific rate limit type
195    ///
196    /// # Arguments
197    ///
198    /// * `rate_limit_type` - The type of rate limit to enforce
199    /// * `safety_margin` - A value between 0.0 and 1.0 representing the percentage of the actual limit to use
200    ///
201    /// # Returns
202    ///
203    /// A new `Config` instance
204    pub fn new() -> Self {
205        // Explicitly load the .env file
206        match dotenv() {
207            Ok(_) => debug!("Successfully loaded .env file"),
208            Err(e) => debug!("Failed to load .env file: {e}"),
209        }
210
211        // Check if environment variables are configured
212        let username = get_env_or_default("IG_USERNAME", String::from("default_username"));
213        let password = get_env_or_default("IG_PASSWORD", String::from("default_password"));
214        let api_key = get_env_or_default("IG_API_KEY", String::from("default_api_key"));
215        let sleep_hours = get_env_or_default("TX_LOOP_INTERVAL_HOURS", DEFAULT_SLEEP_TIME);
216        let page_size = get_env_or_default("TX_PAGE_SIZE", DEFAULT_PAGE_SIZE);
217        let days_to_look_back = get_env_or_default("TX_DAYS_LOOKBACK", DAYS_TO_BACK_LOOK);
218
219        let database_url = get_env_or_default(
220            "DATABASE_URL",
221            String::from(crate::constants::DEFAULT_DATABASE_URL),
222        );
223
224        // Check if we are using default values
225        if username == "default_username" {
226            error!("IG_USERNAME not found in environment variables or .env file");
227        }
228        if password == "default_password" {
229            error!("IG_PASSWORD not found in environment variables or .env file");
230        }
231        if api_key == "default_api_key" {
232            error!("IG_API_KEY not found in environment variables or .env file");
233        }
234        // Check the variable directly rather than comparing the resolved value
235        // to the placeholder: a user may intentionally set a credential-less URL
236        // equal to the placeholder, which is not the "unset" case we warn about.
237        if env::var("DATABASE_URL").is_err() {
238            // Falls back to the credential-less placeholder; persistence will not
239            // connect until DATABASE_URL is set. We never use a credentialed default.
240            error!(
241                "DATABASE_URL not found in environment variables or .env file; \
242                 using a credential-less placeholder and persistence will not connect"
243            );
244        }
245
246        Config {
247            credentials: Credentials {
248                username,
249                password,
250                account_id: get_env_or_default(
251                    "IG_ACCOUNT_ID",
252                    String::from(crate::constants::DEFAULT_ACCOUNT_ID),
253                ),
254                api_key,
255                client_token: None,
256                account_token: None,
257            },
258            rest_api: RestApiConfig {
259                base_url: get_env_or_default(
260                    "IG_REST_BASE_URL",
261                    String::from("https://demo-api.ig.com/gateway/deal"),
262                ),
263                timeout: get_env_or_default("IG_REST_TIMEOUT", 30),
264            },
265            websocket: WebSocketConfig {
266                url: get_env_or_default(
267                    "IG_WS_URL",
268                    String::from("wss://demo-apd.marketdatasystems.com"),
269                ),
270                reconnect_interval: get_env_or_default("IG_WS_RECONNECT_INTERVAL", 5),
271            },
272            database: DatabaseConfig {
273                url: database_url,
274                max_connections: get_env_or_default("DATABASE_MAX_CONNECTIONS", 5),
275            },
276            rate_limiter: RateLimiterConfig {
277                max_requests: get_env_or_default("IG_RATE_LIMIT_MAX_REQUESTS", 4), // 3
278                period_seconds: get_env_or_default("IG_RATE_LIMIT_PERIOD_SECONDS", 12), // 10
279                burst_size: get_env_or_default("IG_RATE_LIMIT_BURST_SIZE", 3),
280            },
281            sleep_hours,
282            page_size,
283            days_to_look_back,
284            api_version: env::var("IG_API_VERSION")
285                .ok()
286                .and_then(|v| v.parse::<u8>().ok())
287                .filter(|&v| v == 2 || v == 3)
288                .or(Some(3)), // Default to API v3 (OAuth) if not specified
289        }
290    }
291}
292
293#[cfg(test)]
294mod redaction_tests {
295    use super::*;
296
297    fn secret_credentials() -> Credentials {
298        Credentials {
299            username: "user@example.com".to_string(),
300            password: "SUPER-SECRET-PASSWORD".to_string(),
301            account_id: "ACC123".to_string(),
302            api_key: "SECRET-API-KEY".to_string(),
303            client_token: Some("SECRET-CST".to_string()),
304            account_token: Some("SECRET-XST".to_string()),
305        }
306    }
307
308    #[test]
309    fn test_credentials_debug_and_display_redact_secrets() {
310        let creds = secret_credentials();
311        for rendered in [format!("{creds:?}"), format!("{creds}")] {
312            for secret in [
313                "SUPER-SECRET-PASSWORD",
314                "SECRET-API-KEY",
315                "SECRET-CST",
316                "SECRET-XST",
317            ] {
318                assert!(
319                    !rendered.contains(secret),
320                    "credentials rendering leaked {secret}: {rendered}"
321                );
322            }
323            assert!(rendered.contains("<redacted>"));
324            // Non-sensitive identifiers stay visible.
325            assert!(rendered.contains("user@example.com"));
326            assert!(rendered.contains("ACC123"));
327        }
328    }
329
330    #[test]
331    fn test_config_debug_and_display_redact_credential_and_db_secrets() {
332        let config = Config {
333            credentials: secret_credentials(),
334            database: DatabaseConfig {
335                url: "postgres://dbuser:DB-SECRET-PW@host/db".to_string(),
336                max_connections: 5,
337            },
338            ..Config::default()
339        };
340        for rendered in [format!("{config:?}"), format!("{config}")] {
341            for secret in ["SUPER-SECRET-PASSWORD", "SECRET-API-KEY", "DB-SECRET-PW"] {
342                assert!(
343                    !rendered.contains(secret),
344                    "config rendering leaked {secret}: {rendered}"
345                );
346            }
347            assert!(rendered.contains("<redacted>"));
348        }
349    }
350}