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