Skip to main content

hotfix/
config.rs

1//! Configuration for FIX sessions.
2//!
3//! Refer to [Config] for the supported configuration options.
4//! [Config] objects can be constructed manually or by creating a `toml`
5//! config file. See the
6//! [example project's config file](https://github.com/Validus-Risk-Management/hotfix/blob/main/examples/simple-new-order/config/test-config.toml)
7//! for more detail.
8use chrono::{NaiveTime, Weekday};
9use chrono_tz::Tz;
10use serde::Deserialize;
11use std::fs;
12use std::path::Path;
13
14/// The configuration for multiple sessions.
15#[derive(Clone, Debug, Deserialize)]
16pub struct Config {
17    pub sessions: Vec<SessionConfig>,
18}
19
20impl Config {
21    /// Load a [Config] from a `toml` file.
22    pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
23        let config_str = fs::read_to_string(path)?;
24        let config = toml::from_str::<Self>(&config_str)?;
25        Ok(config)
26    }
27}
28
29/// TLS encryption details with configurable trust store.
30#[derive(Clone, Debug, Deserialize, PartialEq)]
31#[serde(tag = "trust_store", rename_all = "snake_case")]
32pub enum TlsConfig {
33    /// Use a custom CA certificate file (PEM format).
34    File {
35        /// Path to the CA certificate file.
36        ca_certificate_path: String,
37    },
38    /// Use the operating system's native certificate store.
39    Native,
40    /// Use Mozilla's bundled root certificates (via webpki-roots).
41    Webpki,
42}
43
44/// Session schedule configuration
45#[derive(Clone, Debug, Deserialize, PartialEq)]
46pub struct ScheduleConfig {
47    pub start_time: Option<NaiveTime>,
48    pub end_time: Option<NaiveTime>,
49    pub start_day: Option<Weekday>,
50    pub end_day: Option<Weekday>,
51    #[serde(default)]
52    pub weekdays: Vec<Weekday>,
53    pub timezone: Option<Tz>,
54}
55
56fn default_reconnect_interval() -> u64 {
57    30
58}
59
60fn default_logon_timeout() -> u64 {
61    10
62}
63
64fn default_logout_timeout() -> u64 {
65    2
66}
67
68/// The configuration of a single FIX session.
69#[derive(Clone, Debug, Deserialize)]
70pub struct SessionConfig {
71    /// The begin string specifying the FIX version.
72    pub begin_string: String,
73
74    /// The sender's comp ID.
75    pub sender_comp_id: String,
76
77    /// The target's comp ID.
78    pub target_comp_id: String,
79
80    /// The path to the data dictionary to use.
81    pub data_dictionary_path: Option<String>,
82
83    /// The host to connect to.
84    ///
85    /// This can be any representation of a host that can be interpreted
86    /// as a host object.
87    pub connection_host: String,
88
89    /// The port to use to connect.
90    pub connection_port: u16,
91
92    /// The TLS configuration for the session, if TLS is used.
93    #[serde(flatten)]
94    pub tls_config: Option<TlsConfig>,
95
96    /// The heartbeat interval to agree on with the peer in seconds.
97    pub heartbeat_interval: u64,
98
99    /// The time we wait in seconds for Logon responses before timing out.
100    #[serde(default = "default_logon_timeout")]
101    pub logon_timeout: u64,
102
103    /// The time we wait in seconds for Logon responses before timing out.
104    #[serde(default = "default_logout_timeout")]
105    pub logout_timeout: u64,
106
107    /// The interval we should attempt to reconnect at in seconds.
108    #[serde(default = "default_reconnect_interval")]
109    pub reconnect_interval: u64,
110
111    /// Specifies whether we should reset the state of the message store on logon.
112    #[serde(default)]
113    pub reset_on_logon: bool,
114
115    /// The schedule configuration for the session
116    pub schedule: Option<ScheduleConfig>,
117
118    /// The validation configuration for the session
119    #[serde(default)]
120    pub validation: ValidationConfig,
121}
122
123#[derive(Clone, Debug, Deserialize)]
124/// The configuration of validation rules.
125pub struct ValidationConfig {
126    /// Specifies whether unknown user-defined tags (>= 5000) should cause the message to be rejected.
127    #[serde(default = "default_true")]
128    pub validate_user_defined_fields: bool,
129}
130
131impl ValidationConfig {
132    pub fn builder() -> ValidationConfigBuilder {
133        ValidationConfigBuilder::default()
134    }
135}
136
137impl Default for ValidationConfig {
138    fn default() -> Self {
139        ValidationConfigBuilder::default().build()
140    }
141}
142
143pub struct ValidationConfigBuilder {
144    validate_user_defined_fields: bool,
145}
146
147impl Default for ValidationConfigBuilder {
148    fn default() -> Self {
149        Self {
150            validate_user_defined_fields: true,
151        }
152    }
153}
154
155impl ValidationConfigBuilder {
156    pub fn new() -> Self {
157        Self::default()
158    }
159
160    pub fn validate_user_defined_fields(mut self, value: bool) -> Self {
161        self.validate_user_defined_fields = value;
162        self
163    }
164
165    pub fn build(self) -> ValidationConfig {
166        ValidationConfig {
167            validate_user_defined_fields: self.validate_user_defined_fields,
168        }
169    }
170}
171
172fn default_true() -> bool {
173    true
174}
175
176/// Errors that may occur when loading configuration.
177#[derive(Debug, thiserror::Error)]
178pub enum ConfigError {
179    #[error("failed to read config file")]
180    Io(#[from] std::io::Error),
181
182    #[error("failed to parse config")]
183    Parse(#[from] toml::de::Error),
184}
185
186#[cfg(test)]
187mod tests {
188    use crate::config::{
189        Config, ConfigError, TlsConfig, ValidationConfig, ValidationConfigBuilder,
190    };
191    use chrono::{NaiveTime, Weekday};
192    use std::io::Write;
193    use tempfile::NamedTempFile;
194
195    #[test]
196    fn test_simple_config() {
197        let config_contents = r#"
198[[sessions]]
199begin_string = "FIX.4.4"
200sender_comp_id = "send-comp-id"
201target_comp_id = "target-comp-id"
202data_dictionary_path = "./spec/FIX44.xml"
203
204connection_port = 443
205connection_host = "127.0.0.1"
206trust_store = "file"
207ca_certificate_path = "my_cert.crt"
208heartbeat_interval = 30
209reset_on_logon = false
210        "#;
211
212        let config: Config = toml::from_str(config_contents).unwrap();
213        assert_eq!(config.sessions.len(), 1);
214
215        let session_config = config.sessions.first().unwrap();
216        assert_eq!(session_config.begin_string, "FIX.4.4");
217        assert_eq!(session_config.sender_comp_id, "send-comp-id");
218        assert_eq!(session_config.target_comp_id, "target-comp-id");
219        assert_eq!(
220            session_config.data_dictionary_path,
221            Some("./spec/FIX44.xml".to_string())
222        );
223        assert_eq!(session_config.connection_port, 443);
224        assert_eq!(session_config.connection_host, "127.0.0.1");
225        assert_eq!(session_config.heartbeat_interval, 30);
226        let expected_tls_config = TlsConfig::File {
227            ca_certificate_path: "my_cert.crt".to_string(),
228        };
229        assert_eq!(session_config.tls_config, Some(expected_tls_config));
230        assert_eq!(session_config.reconnect_interval, 30);
231        assert_eq!(session_config.logon_timeout, 10);
232        assert!(session_config.validation.validate_user_defined_fields);
233    }
234
235    #[test]
236    fn test_tls_config_native() {
237        let config_contents = r#"
238[[sessions]]
239begin_string = "FIX.4.4"
240sender_comp_id = "send-comp-id"
241target_comp_id = "target-comp-id"
242connection_port = 443
243connection_host = "127.0.0.1"
244heartbeat_interval = 30
245trust_store = "native"
246        "#;
247
248        let config: Config = toml::from_str(config_contents).unwrap();
249        let session_config = config.sessions.first().unwrap();
250        assert_eq!(session_config.tls_config, Some(TlsConfig::Native));
251    }
252
253    #[test]
254    fn test_tls_config_webpki() {
255        let config_contents = r#"
256[[sessions]]
257begin_string = "FIX.4.4"
258sender_comp_id = "send-comp-id"
259target_comp_id = "target-comp-id"
260connection_port = 443
261connection_host = "127.0.0.1"
262heartbeat_interval = 30
263trust_store = "webpki"
264        "#;
265
266        let config: Config = toml::from_str(config_contents).unwrap();
267        let session_config = config.sessions.first().unwrap();
268        assert_eq!(session_config.tls_config, Some(TlsConfig::Webpki));
269    }
270
271    #[test]
272    fn test_no_tls_config() {
273        let config_contents = r#"
274[[sessions]]
275begin_string = "FIX.4.4"
276sender_comp_id = "send-comp-id"
277target_comp_id = "target-comp-id"
278connection_port = 9880
279connection_host = "127.0.0.1"
280heartbeat_interval = 30
281        "#;
282
283        let config: Config = toml::from_str(config_contents).unwrap();
284        let session_config = config.sessions.first().unwrap();
285        assert_eq!(session_config.tls_config, None);
286    }
287
288    #[test]
289    fn test_schedule_config_weekdays() {
290        let config_contents = r#"
291[[sessions]]
292begin_string = "FIX.4.4"
293sender_comp_id = "send-comp-id"
294target_comp_id = "target-comp-id"
295heartbeat_interval = 30
296
297connection_port = 443
298connection_host = "127.0.0.1"
299
300[sessions.schedule]
301start_time = "00:00:00"
302end_time = "23:55:00"
303weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
304        "#;
305
306        let config: Config = toml::from_str(config_contents).unwrap();
307        assert_eq!(config.sessions.len(), 1);
308        let session = config.sessions.first().unwrap();
309
310        assert!(session.schedule.is_some());
311        let schedule = session.schedule.as_ref().unwrap();
312
313        assert_eq!(schedule.start_time, NaiveTime::from_hms_opt(0, 0, 0));
314        assert_eq!(schedule.end_time, NaiveTime::from_hms_opt(23, 55, 0));
315        assert_eq!(
316            schedule.weekdays,
317            vec![
318                Weekday::Mon,
319                Weekday::Tue,
320                Weekday::Wed,
321                Weekday::Thu,
322                Weekday::Fri
323            ]
324        );
325        assert_eq!(schedule.start_day, None);
326        assert_eq!(schedule.end_day, None);
327    }
328
329    #[test]
330    fn test_schedule_config_weeklong_session() {
331        let config_contents = r#"
332[[sessions]]
333begin_string = "FIX.4.4"
334sender_comp_id = "send-comp-id"
335target_comp_id = "target-comp-id"
336heartbeat_interval = 30
337
338connection_port = 443
339connection_host = "127.0.0.1"
340
341[sessions.schedule]
342start_time = "00:00:00"
343end_time = "23:55:00"
344start_day = "Monday"
345end_day = "Friday"
346        "#;
347
348        let config: Config = toml::from_str(config_contents).unwrap();
349        assert_eq!(config.sessions.len(), 1);
350        let session = config.sessions.first().unwrap();
351
352        assert!(session.schedule.is_some());
353        let schedule = session.schedule.as_ref().unwrap();
354
355        assert_eq!(schedule.start_time, NaiveTime::from_hms_opt(0, 0, 0));
356        assert_eq!(schedule.end_time, NaiveTime::from_hms_opt(23, 55, 0));
357        assert_eq!(schedule.start_day, Some(Weekday::Mon));
358        assert_eq!(schedule.end_day, Some(Weekday::Fri));
359    }
360
361    #[test]
362    fn test_schedule_config_with_new_york_timezone() {
363        use chrono_tz::Tz;
364
365        let config_contents = r#"
366    [[sessions]]
367    begin_string = "FIX.4.4"
368    sender_comp_id = "send-comp-id"
369    target_comp_id = "target-comp-id"
370    heartbeat_interval = 30
371
372    connection_port = 443
373    connection_host = "127.0.0.1"
374
375    [sessions.schedule]
376    start_time = "08:00:00"
377    end_time = "16:30:00"
378    weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
379    timezone = "America/New_York"
380        "#;
381
382        let config: Config = toml::from_str(config_contents).unwrap();
383        assert_eq!(config.sessions.len(), 1);
384        let session = config.sessions.first().unwrap();
385
386        assert!(session.schedule.is_some());
387        let schedule = session.schedule.as_ref().unwrap();
388
389        assert_eq!(schedule.start_time, NaiveTime::from_hms_opt(8, 0, 0));
390        assert_eq!(schedule.end_time, NaiveTime::from_hms_opt(16, 30, 0));
391        assert_eq!(schedule.timezone, Some(Tz::America__New_York));
392    }
393
394    #[test]
395    fn test_schedule_config_with_utc_timezone() {
396        use chrono_tz::Tz;
397
398        let config_contents = r#"
399    [[sessions]]
400    begin_string = "FIX.4.4"
401    sender_comp_id = "send-comp-id"
402    target_comp_id = "target-comp-id"
403    heartbeat_interval = 30
404
405    connection_port = 443
406    connection_host = "127.0.0.1"
407
408    [sessions.schedule]
409    start_time = "00:00:00"
410    end_time = "23:55:00"
411    weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
412    timezone = "UTC"
413        "#;
414
415        let config: Config = toml::from_str(config_contents).unwrap();
416        let session = config.sessions.first().unwrap();
417        let schedule = session.schedule.as_ref().unwrap();
418
419        assert_eq!(schedule.start_time, NaiveTime::from_hms_opt(0, 0, 0));
420        assert_eq!(schedule.end_time, NaiveTime::from_hms_opt(23, 55, 0));
421        assert_eq!(schedule.timezone, Some(Tz::UTC));
422    }
423
424    #[test]
425    fn test_schedule_config_with_london_timezone() {
426        use chrono_tz::Tz;
427
428        let config_contents = r#"
429    [[sessions]]
430    begin_string = "FIX.4.4"
431    sender_comp_id = "send-comp-id"
432    target_comp_id = "target-comp-id"
433    heartbeat_interval = 30
434
435    connection_port = 443
436    connection_host = "127.0.0.1"
437
438    [sessions.schedule]
439    start_time = "09:30:00"
440    end_time = "17:00:00"
441    weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
442    timezone = "Europe/London"
443        "#;
444
445        let config: Config = toml::from_str(config_contents).unwrap();
446        let session = config.sessions.first().unwrap();
447        let schedule = session.schedule.as_ref().unwrap();
448
449        assert_eq!(schedule.start_time, NaiveTime::from_hms_opt(9, 30, 0));
450        assert_eq!(schedule.end_time, NaiveTime::from_hms_opt(17, 0, 0));
451        assert_eq!(schedule.timezone, Some(Tz::Europe__London));
452    }
453
454    #[test]
455    fn test_logon_timeout_config() {
456        let config_contents = r#"
457    [[sessions]]
458    begin_string = "FIX.4.4"
459    sender_comp_id = "send-comp-id"
460    target_comp_id = "target-comp-id"
461    data_dictionary_path = "./spec/FIX44.xml"
462
463    connection_port = 443
464    connection_host = "127.0.0.1"
465    trust_store = "file"
466    ca_certificate_path = "my_cert.crt"
467    heartbeat_interval = 30
468    logon_timeout = 20
469        "#;
470
471        let config: Config = toml::from_str(config_contents).unwrap();
472        assert_eq!(config.sessions.len(), 1);
473
474        let session_config = config.sessions.first().unwrap();
475        assert_eq!(session_config.logon_timeout, 20);
476    }
477
478    #[test]
479    fn test_reconnect_interval_config() {
480        let config_contents = r#"
481    [[sessions]]
482    begin_string = "FIX.4.4"
483    sender_comp_id = "send-comp-id"
484    target_comp_id = "target-comp-id"
485    data_dictionary_path = "./spec/FIX44.xml"
486
487    connection_port = 443
488    connection_host = "127.0.0.1"
489    trust_store = "file"
490    ca_certificate_path = "my_cert.crt"
491    heartbeat_interval = 30
492    reconnect_interval = 15
493        "#;
494
495        let config: Config = toml::from_str(config_contents).unwrap();
496        assert_eq!(config.sessions.len(), 1);
497
498        let session_config = config.sessions.first().unwrap();
499        assert_eq!(session_config.reconnect_interval, 15);
500    }
501
502    #[test]
503    fn test_verification_config_defaults_when_omitted() {
504        let config_contents = r#"
505[[sessions]]
506begin_string = "FIX.4.4"
507sender_comp_id = "send-comp-id"
508target_comp_id = "target-comp-id"
509connection_port = 443
510connection_host = "127.0.0.1"
511heartbeat_interval = 30
512        "#;
513
514        let config: Config = toml::from_str(config_contents).unwrap();
515        let session_config = config.sessions.first().unwrap();
516
517        assert!(session_config.validation.validate_user_defined_fields);
518    }
519
520    #[test]
521    fn test_verification_config_can_disable_user_defined_field_validation() {
522        let config_contents = r#"
523[[sessions]]
524begin_string = "FIX.4.4"
525sender_comp_id = "send-comp-id"
526target_comp_id = "target-comp-id"
527connection_port = 443
528connection_host = "127.0.0.1"
529heartbeat_interval = 30
530
531[sessions.validation]
532validate_user_defined_fields = false
533        "#;
534
535        let config: Config = toml::from_str(config_contents).unwrap();
536        let session_config = config.sessions.first().unwrap();
537
538        assert!(!session_config.validation.validate_user_defined_fields);
539    }
540
541    #[test]
542    fn test_validation_config_builder_produces_default() {
543        let config = ValidationConfig::builder().build();
544        assert!(config.validate_user_defined_fields);
545    }
546
547    #[test]
548    fn test_verification_config_builder_new_and_override() {
549        let enabled = ValidationConfigBuilder::new().build();
550        assert!(enabled.validate_user_defined_fields);
551
552        let disabled = ValidationConfigBuilder::new()
553            .validate_user_defined_fields(false)
554            .build();
555        assert!(!disabled.validate_user_defined_fields);
556    }
557
558    #[test]
559    fn test_validation_field_default_when_section_present_but_field_omitted() {
560        let config_contents = r#"
561[[sessions]]
562begin_string = "FIX.4.4"
563sender_comp_id = "send-comp-id"
564target_comp_id = "target-comp-id"
565connection_port = 443
566connection_host = "127.0.0.1"
567heartbeat_interval = 30
568
569[sessions.validation]
570        "#;
571
572        let config: Config = toml::from_str(config_contents).unwrap();
573        let session_config = config.sessions.first().unwrap();
574
575        // The [sessions.validation] table is present but omits the field, so the
576        // field-level serde default (`default_true`) supplies the value, unlike
577        // `test_verification_config_defaults_when_omitted` where the whole table
578        // is absent and `ValidationConfig::default()` is used instead.
579        assert!(session_config.validation.validate_user_defined_fields);
580    }
581
582    #[test]
583    fn test_load_from_path_success() {
584        let config_contents = r#"
585[[sessions]]
586begin_string = "FIX.4.4"
587sender_comp_id = "sender"
588target_comp_id = "target"
589connection_host = "127.0.0.1"
590connection_port = 9876
591heartbeat_interval = 30
592"#;
593
594        let mut temp_file = NamedTempFile::new().unwrap();
595        temp_file.write_all(config_contents.as_bytes()).unwrap();
596
597        let config = Config::load_from_path(temp_file.path()).unwrap();
598        assert_eq!(config.sessions.len(), 1);
599
600        let session = config.sessions.first().unwrap();
601        assert_eq!(session.begin_string, "FIX.4.4");
602        assert_eq!(session.sender_comp_id, "sender");
603        assert_eq!(session.target_comp_id, "target");
604        assert_eq!(session.connection_host, "127.0.0.1");
605        assert_eq!(session.connection_port, 9876);
606        assert_eq!(session.heartbeat_interval, 30);
607    }
608
609    #[test]
610    fn test_load_from_path_missing_file() {
611        let result = Config::load_from_path("/nonexistent/path/to/config.toml");
612        assert!(result.is_err());
613        assert!(matches!(result.unwrap_err(), ConfigError::Io(_)));
614    }
615
616    #[test]
617    fn test_load_from_path_invalid_toml() {
618        let invalid_toml = "this is not valid toml {{{{";
619
620        let mut temp_file = NamedTempFile::new().unwrap();
621        temp_file.write_all(invalid_toml.as_bytes()).unwrap();
622
623        let result = Config::load_from_path(temp_file.path());
624        assert!(result.is_err());
625        assert!(matches!(result.unwrap_err(), ConfigError::Parse(_)));
626    }
627}