Skip to main content

mockforge_sdk/
builder.rs

1//! Builder for configuring mock servers
2#![allow(clippy::missing_errors_doc)]
3
4use crate::server::MockServer;
5use crate::{Error, Result};
6use mockforge_core::{Config, ProxyConfig, ServerConfig};
7use mockforge_foundation::failure_injection::FailureConfig;
8use mockforge_foundation::latency::LatencyProfile;
9use std::net::TcpListener;
10use std::path::PathBuf;
11
12/// Builder for creating and configuring mock servers
13pub struct MockServerBuilder {
14    port: Option<u16>,
15    host: Option<String>,
16    config_file: Option<PathBuf>,
17    openapi_spec: Option<PathBuf>,
18    latency_profile: Option<LatencyProfile>,
19    failure_config: Option<FailureConfig>,
20    proxy_config: Option<ProxyConfig>,
21    enable_admin: bool,
22    admin_port: Option<u16>,
23    auto_port: bool,
24    port_range: Option<(u16, u16)>,
25}
26
27impl Default for MockServerBuilder {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl MockServerBuilder {
34    /// Create a new mock server builder
35    #[must_use]
36    pub const fn new() -> Self {
37        Self {
38            port: None,
39            host: None,
40            config_file: None,
41            openapi_spec: None,
42            latency_profile: None,
43            failure_config: None,
44            proxy_config: None,
45            enable_admin: false,
46            admin_port: None,
47            auto_port: false,
48            port_range: None,
49        }
50    }
51
52    /// Set the HTTP port
53    #[must_use]
54    pub const fn port(mut self, port: u16) -> Self {
55        self.port = Some(port);
56        self.auto_port = false;
57        self
58    }
59
60    /// Automatically discover an available port
61    ///
62    /// By default, uses port 0 which lets the OS assign any available ephemeral port.
63    /// This is atomic and avoids race conditions in parallel tests.
64    ///
65    /// Use `port_range()` if you need ports within a specific range.
66    #[must_use]
67    pub const fn auto_port(mut self) -> Self {
68        self.auto_port = true;
69        self.port = None;
70        self
71    }
72
73    /// Set the port range for automatic port discovery
74    ///
75    /// When set, `auto_port()` will scan this range for an available port
76    /// instead of using OS-assigned ports.
77    ///
78    /// Note: Scanning a range has a small TOCTOU race condition. For parallel
79    /// tests, prefer using `auto_port()` without a range.
80    #[must_use]
81    pub const fn port_range(mut self, start: u16, end: u16) -> Self {
82        self.port_range = Some((start, end));
83        self
84    }
85
86    /// Set the host address
87    #[must_use]
88    pub fn host(mut self, host: impl Into<String>) -> Self {
89        self.host = Some(host.into());
90        self
91    }
92
93    /// Load configuration from a YAML file
94    #[must_use]
95    pub fn config_file(mut self, path: impl Into<PathBuf>) -> Self {
96        self.config_file = Some(path.into());
97        self
98    }
99
100    /// Load routes from an `OpenAPI` specification
101    #[must_use]
102    pub fn openapi_spec(mut self, path: impl Into<PathBuf>) -> Self {
103        self.openapi_spec = Some(path.into());
104        self
105    }
106
107    /// Set the latency profile for simulating network delays
108    #[must_use]
109    pub fn latency(mut self, profile: LatencyProfile) -> Self {
110        self.latency_profile = Some(profile);
111        self
112    }
113
114    /// Enable failure injection with configuration
115    #[must_use]
116    pub fn failures(mut self, config: FailureConfig) -> Self {
117        self.failure_config = Some(config);
118        self
119    }
120
121    /// Enable proxy mode with configuration
122    #[must_use]
123    pub fn proxy(mut self, config: ProxyConfig) -> Self {
124        self.proxy_config = Some(config);
125        self
126    }
127
128    /// Enable admin API
129    #[must_use]
130    pub const fn admin(mut self, enabled: bool) -> Self {
131        self.enable_admin = enabled;
132        self
133    }
134
135    /// Set admin API port
136    #[must_use]
137    pub const fn admin_port(mut self, port: u16) -> Self {
138        self.admin_port = Some(port);
139        self
140    }
141
142    /// Start the mock server
143    pub async fn start(self) -> Result<MockServer> {
144        // Build the configuration
145        let mut config = if let Some(config_file) = self.config_file {
146            mockforge_core::load_config(&config_file)
147                .await
148                .map_err(|e| Error::InvalidConfig(e.to_string()))?
149        } else {
150            ServerConfig::default()
151        };
152
153        // Apply port settings
154        if self.auto_port {
155            if let Some((start, end)) = self.port_range {
156                // User specified a port range - scan for available port
157                let port = find_available_port(start, end)?;
158                config.http.port = port;
159            } else {
160                // No range specified - use port 0 for OS-assigned port
161                // This is atomic and avoids TOCTOU race conditions
162                config.http.port = 0;
163            }
164        } else if let Some(port) = self.port {
165            config.http.port = port;
166        }
167
168        // Apply other builder settings
169        if let Some(host) = self.host {
170            config.http.host = host;
171        }
172        if let Some(spec_path) = self.openapi_spec {
173            config.http.openapi_spec = Some(spec_path.to_string_lossy().to_string());
174        }
175
176        // Create core config
177        let mut core_config = Config::default();
178
179        if let Some(latency) = self.latency_profile {
180            core_config.latency_enabled = true;
181            core_config.default_latency = latency;
182        }
183
184        if let Some(failures) = self.failure_config {
185            core_config.failures_enabled = true;
186            core_config.failure_config = Some(failures);
187        }
188
189        if let Some(proxy) = self.proxy_config {
190            core_config.proxy = Some(proxy);
191        }
192
193        // Create and start the server
194        let mut server = MockServer::from_config(config, core_config)?;
195        server.start().await?;
196        Ok(server)
197    }
198}
199
200/// Check if a port is available by attempting to bind to it
201///
202/// Note: There is a small race condition (TOCTOU - Time Of Check, Time Of Use)
203/// between checking availability and the actual server binding. In practice,
204/// this is rarely an issue for test environments. For guaranteed port assignment,
205/// consider using `port(0)` to let the OS assign any available port.
206fn is_port_available(port: u16) -> bool {
207    TcpListener::bind(("127.0.0.1", port)).is_ok()
208}
209
210/// Find an available port in the specified range
211///
212/// Scans the port range and returns the first available port.
213/// Returns an error if no ports are available in the range.
214///
215/// # Arguments
216/// * `start` - Starting port number (inclusive)
217/// * `end` - Ending port number (inclusive)
218///
219/// # Errors
220/// Returns `Error::InvalidConfig` if start >= end
221/// Returns `Error::PortDiscoveryFailed` if no ports are available
222fn find_available_port(start: u16, end: u16) -> Result<u16> {
223    // Validate port range
224    if start >= end {
225        return Err(Error::InvalidConfig(format!(
226            "Invalid port range: start ({start}) must be less than end ({end})"
227        )));
228    }
229
230    // Try to find an available port
231    for port in start..=end {
232        if is_port_available(port) {
233            return Ok(port);
234        }
235    }
236
237    Err(Error::PortDiscoveryFailed(format!(
238        "No available ports found in range {start}-{end}"
239    )))
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use std::path::PathBuf;
246
247    #[test]
248    fn test_builder_new() {
249        let builder = MockServerBuilder::new();
250        assert!(builder.port.is_none());
251        assert!(builder.host.is_none());
252        assert!(!builder.enable_admin);
253        assert!(!builder.auto_port);
254    }
255
256    #[test]
257    fn test_builder_default() {
258        let builder = MockServerBuilder::default();
259        assert!(builder.port.is_none());
260        assert!(builder.host.is_none());
261    }
262
263    #[test]
264    fn test_builder_port() {
265        let builder = MockServerBuilder::new().port(8080);
266        assert_eq!(builder.port, Some(8080));
267        assert!(!builder.auto_port);
268    }
269
270    #[test]
271    fn test_builder_auto_port() {
272        let builder = MockServerBuilder::new().auto_port();
273        assert!(builder.auto_port);
274        assert!(builder.port.is_none());
275    }
276
277    #[test]
278    fn test_builder_auto_port_overrides_manual_port() {
279        let builder = MockServerBuilder::new().port(8080).auto_port();
280        assert!(builder.auto_port);
281        assert!(builder.port.is_none());
282    }
283
284    #[test]
285    fn test_builder_manual_port_overrides_auto_port() {
286        let builder = MockServerBuilder::new().auto_port().port(8080);
287        assert!(!builder.auto_port);
288        assert_eq!(builder.port, Some(8080));
289    }
290
291    #[test]
292    fn test_builder_port_range() {
293        let builder = MockServerBuilder::new().port_range(30000, 31000);
294        assert_eq!(builder.port_range, Some((30000, 31000)));
295    }
296
297    #[test]
298    fn test_builder_host() {
299        let builder = MockServerBuilder::new().host("0.0.0.0");
300        assert_eq!(builder.host, Some("0.0.0.0".to_string()));
301    }
302
303    #[test]
304    fn test_builder_config_file() {
305        let builder = MockServerBuilder::new().config_file("/path/to/config.yaml");
306        assert_eq!(builder.config_file, Some(PathBuf::from("/path/to/config.yaml")));
307    }
308
309    #[test]
310    fn test_builder_openapi_spec() {
311        let builder = MockServerBuilder::new().openapi_spec("/path/to/spec.yaml");
312        assert_eq!(builder.openapi_spec, Some(PathBuf::from("/path/to/spec.yaml")));
313    }
314
315    #[test]
316    fn test_builder_latency() {
317        let latency = LatencyProfile::new(100, 0);
318        let builder = MockServerBuilder::new().latency(latency);
319        assert!(builder.latency_profile.is_some());
320    }
321
322    #[test]
323    fn test_builder_failures() {
324        let failures = FailureConfig {
325            global_error_rate: 0.1,
326            default_status_codes: vec![500, 503],
327            ..Default::default()
328        };
329        let builder = MockServerBuilder::new().failures(failures);
330        assert!(builder.failure_config.is_some());
331    }
332
333    #[test]
334    fn test_builder_proxy() {
335        let proxy = ProxyConfig {
336            enabled: true,
337            target_url: Some("http://example.com".to_string()),
338            ..Default::default()
339        };
340        let builder = MockServerBuilder::new().proxy(proxy);
341        assert!(builder.proxy_config.is_some());
342    }
343
344    #[test]
345    fn test_builder_admin() {
346        let builder = MockServerBuilder::new().admin(true);
347        assert!(builder.enable_admin);
348    }
349
350    #[test]
351    fn test_builder_admin_port() {
352        let builder = MockServerBuilder::new().admin_port(9090);
353        assert_eq!(builder.admin_port, Some(9090));
354    }
355
356    #[test]
357    fn test_builder_fluent_chaining() {
358        let latency = LatencyProfile::new(50, 0);
359        let failures = FailureConfig {
360            global_error_rate: 0.05,
361            default_status_codes: vec![500],
362            ..Default::default()
363        };
364
365        let builder = MockServerBuilder::new()
366            .port(8080)
367            .host("localhost")
368            .latency(latency)
369            .failures(failures)
370            .admin(true)
371            .admin_port(9090);
372
373        assert_eq!(builder.port, Some(8080));
374        assert_eq!(builder.host, Some("localhost".to_string()));
375        assert!(builder.latency_profile.is_some());
376        assert!(builder.failure_config.is_some());
377        assert!(builder.enable_admin);
378        assert_eq!(builder.admin_port, Some(9090));
379    }
380
381    #[test]
382    fn test_is_port_available_unbound_port() {
383        // Port 0 should allow binding (OS will assign a port)
384        assert!(is_port_available(0));
385    }
386
387    #[test]
388    fn test_is_port_available_bound_port() {
389        // Bind to a port first
390        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
391        let addr = listener.local_addr().unwrap();
392        let port = addr.port();
393
394        // Now that port should not be available
395        assert!(!is_port_available(port));
396    }
397
398    #[test]
399    fn test_find_available_port_success() {
400        // Should find an available port in a large range
401        let result = find_available_port(30000, 35000);
402        assert!(result.is_ok());
403        let port = result.unwrap();
404        assert!((30000..=35000).contains(&port));
405    }
406
407    #[test]
408    fn test_find_available_port_invalid_range_equal() {
409        let result = find_available_port(8080, 8080);
410        assert!(result.is_err());
411        match result {
412            Err(Error::InvalidConfig(msg)) => {
413                assert!(msg.contains("Invalid port range"));
414                assert!(msg.contains("8080"));
415            }
416            _ => panic!("Expected InvalidConfig error"),
417        }
418    }
419
420    #[test]
421    fn test_find_available_port_invalid_range_reversed() {
422        let result = find_available_port(9000, 8000);
423        assert!(result.is_err());
424        match result {
425            Err(Error::InvalidConfig(msg)) => {
426                assert!(msg.contains("Invalid port range"));
427            }
428            _ => panic!("Expected InvalidConfig error"),
429        }
430    }
431
432    #[test]
433    fn test_find_available_port_no_ports_available() {
434        // Bind to all ports in a small range
435        let port1 = 40000;
436        let port2 = 40001;
437        let listener1 = TcpListener::bind(("127.0.0.1", port1)).ok();
438        let listener2 = TcpListener::bind(("127.0.0.1", port2)).ok();
439
440        // If both binds succeeded, the search should fail
441        if listener1.is_some() && listener2.is_some() {
442            let result = find_available_port(port1, port2);
443            assert!(result.is_err());
444            match result {
445                Err(Error::PortDiscoveryFailed(msg)) => {
446                    assert!(msg.contains("No available ports"));
447                    assert!(msg.contains("40000"));
448                    assert!(msg.contains("40001"));
449                }
450                _ => panic!("Expected PortDiscoveryFailed error"),
451            }
452        }
453    }
454
455    #[test]
456    fn test_find_available_port_single_port_range() {
457        // Even though start < end, this is a valid single-port range (inclusive)
458        let result = find_available_port(45000, 45001);
459        assert!(result.is_ok());
460        let port = result.unwrap();
461        assert!(port == 45000 || port == 45001);
462    }
463
464    #[test]
465    fn test_builder_multiple_config_sources() {
466        let builder = MockServerBuilder::new()
467            .config_file("/path/to/config.yaml")
468            .openapi_spec("/path/to/spec.yaml")
469            .port(8080)
470            .host("localhost");
471
472        assert!(builder.config_file.is_some());
473        assert!(builder.openapi_spec.is_some());
474        assert_eq!(builder.port, Some(8080));
475        assert_eq!(builder.host, Some("localhost".to_string()));
476    }
477
478    #[test]
479    fn test_builder_with_all_features() {
480        let latency = LatencyProfile::new(100, 0);
481        let failures = FailureConfig {
482            global_error_rate: 0.1,
483            default_status_codes: vec![500, 503],
484            ..Default::default()
485        };
486        let proxy = ProxyConfig {
487            enabled: true,
488            target_url: Some("http://backend.com".to_string()),
489            ..Default::default()
490        };
491
492        let builder = MockServerBuilder::new()
493            .port(8080)
494            .host("0.0.0.0")
495            .config_file("/config.yaml")
496            .openapi_spec("/spec.yaml")
497            .latency(latency)
498            .failures(failures)
499            .proxy(proxy)
500            .admin(true)
501            .admin_port(9090);
502
503        assert!(builder.port.is_some());
504        assert!(builder.host.is_some());
505        assert!(builder.config_file.is_some());
506        assert!(builder.openapi_spec.is_some());
507        assert!(builder.latency_profile.is_some());
508        assert!(builder.failure_config.is_some());
509        assert!(builder.proxy_config.is_some());
510        assert!(builder.enable_admin);
511        assert!(builder.admin_port.is_some());
512    }
513
514    #[test]
515    fn test_builder_port_range_default() {
516        let builder = MockServerBuilder::new().auto_port();
517        // Default range should be used if not specified
518        assert!(builder.port_range.is_none());
519    }
520
521    #[test]
522    fn test_builder_port_range_custom() {
523        let builder = MockServerBuilder::new().auto_port().port_range(40000, 50000);
524        assert_eq!(builder.port_range, Some((40000, 50000)));
525    }
526}