Skip to main content

mockforge_sdk/
server.rs

1//! Mock server implementation
2#![allow(clippy::missing_errors_doc, clippy::too_many_lines)]
3
4use crate::builder::MockServerBuilder;
5use crate::stub::ResponseStub;
6use crate::{Error, Result};
7use axum::Router;
8use mockforge_core::config::{RouteConfig, RouteResponseConfig};
9use mockforge_core::{Config, ServerConfig};
10use serde_json::Value;
11use std::collections::HashMap;
12use std::net::SocketAddr;
13use std::sync::Arc;
14use tokio::sync::RwLock;
15use tokio::task::JoinHandle;
16
17/// A stored stub configuration for runtime matching
18#[derive(Debug, Clone)]
19struct StoredStub {
20    method: String,
21    path: String,
22    status: u16,
23    headers: HashMap<String, String>,
24    body: Value,
25}
26
27/// Shared stub store for runtime stub management
28type StubStore = Arc<RwLock<Vec<StoredStub>>>;
29
30/// A mock server that can be embedded in tests
31///
32/// The mock server supports dynamically adding stubs at runtime after the server
33/// has started. Stubs added via `stub_response()` or `add_stub()` will be served
34/// by a fallback handler that matches requests against the stub store.
35pub struct MockServer {
36    port: u16,
37    address: SocketAddr,
38    #[allow(dead_code)]
39    config: ServerConfig,
40    server_handle: Option<JoinHandle<()>>,
41    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
42    routes: Vec<RouteConfig>,
43    /// Shared stub store for runtime updates
44    stub_store: StubStore,
45}
46
47impl MockServer {
48    /// Create a new mock server builder
49    #[must_use]
50    #[allow(clippy::new_ret_no_self)]
51    pub const fn new() -> MockServerBuilder {
52        MockServerBuilder::new()
53    }
54
55    /// Create a mock server from configuration
56    pub(crate) fn from_config(server_config: ServerConfig, _core_config: Config) -> Result<Self> {
57        let port = server_config.http.port;
58        let host = server_config.http.host.clone();
59
60        let address: SocketAddr = format!("{host}:{port}")
61            .parse()
62            .map_err(|e| Error::InvalidConfig(format!("Invalid address: {e}")))?;
63
64        Ok(Self {
65            port,
66            address,
67            config: server_config,
68            server_handle: None,
69            shutdown_tx: None,
70            routes: Vec::new(),
71            stub_store: Arc::new(RwLock::new(Vec::new())),
72        })
73    }
74
75    /// Start the mock server
76    ///
77    /// # Panics
78    ///
79    /// Panics if the internal axum server task encounters an error.
80    pub async fn start(&mut self) -> Result<()> {
81        if self.server_handle.is_some() {
82            return Err(Error::ServerAlreadyStarted(self.port));
83        }
84
85        // Build the router with the shared stub store
86        let router = self.build_simple_router(self.stub_store.clone());
87
88        // Create shutdown channel
89        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
90        self.shutdown_tx = Some(shutdown_tx);
91
92        // Bind the listener BEFORE spawning so we can get the actual address
93        // This is important for port 0 (auto-assign) to work correctly
94        let listener = tokio::net::TcpListener::bind(self.address)
95            .await
96            .map_err(|e| Error::General(format!("Failed to bind to {}: {e}", self.address)))?;
97
98        // Get the actual bound address (important when using port 0)
99        let actual_address = listener
100            .local_addr()
101            .map_err(|e| Error::General(format!("Failed to get local address: {e}")))?;
102
103        // Update our address and port with the actual bound values
104        self.address = actual_address;
105        self.port = actual_address.port();
106
107        tracing::info!("MockForge SDK server listening on {}", actual_address);
108
109        // Spawn the server with the already-bound listener
110        let server_handle = tokio::spawn(async move {
111            axum::serve(listener, router)
112                .with_graceful_shutdown(async move {
113                    let _ = shutdown_rx.await;
114                })
115                .await
116                .expect("Server error");
117        });
118
119        self.server_handle = Some(server_handle);
120
121        // Wait for the server to be ready by polling health
122        self.wait_for_ready().await?;
123
124        Ok(())
125    }
126
127    /// Wait for the server to be ready
128    async fn wait_for_ready(&self) -> Result<()> {
129        let max_attempts = 50;
130        let delay = tokio::time::Duration::from_millis(100);
131
132        for attempt in 0..max_attempts {
133            // Try to connect to the server
134            let client = reqwest::Client::builder()
135                .timeout(tokio::time::Duration::from_millis(100))
136                .build()
137                .map_err(|e| Error::General(format!("Failed to create HTTP client: {e}")))?;
138
139            match client.get(format!("{}/health", self.url())).send().await {
140                Ok(response) if response.status().is_success() => return Ok(()),
141                _ => {
142                    if attempt < max_attempts - 1 {
143                        tokio::time::sleep(delay).await;
144                    }
145                }
146            }
147        }
148
149        let timeout_ms = u32::try_from(delay.as_millis()).unwrap_or(u32::MAX);
150        Err(Error::General(format!(
151            "Server failed to become ready within {}ms",
152            max_attempts * timeout_ms
153        )))
154    }
155
156    /// Build a simple router from stored routes
157    fn build_simple_router(&self, stub_store: StubStore) -> Router {
158        use axum::extract::{Path, Request};
159        use axum::http::StatusCode;
160        use axum::routing::{delete, get, post, put};
161        use axum::{response::IntoResponse, Json};
162
163        // Shared state for admin API (separate from stub store)
164        type MockStore = Arc<RwLock<HashMap<String, Value>>>;
165        let mock_store: MockStore = Arc::new(RwLock::new(HashMap::new()));
166
167        // Admin API handlers
168        let store_for_list = mock_store.clone();
169        let list_mocks = move || {
170            let store = store_for_list.clone();
171            async move {
172                let items: Vec<Value> = store.read().await.values().cloned().collect();
173                let total = items.len();
174                Json(serde_json::json!({
175                    "mocks": items,
176                    "total": total,
177                    "enabled": total  // All mocks are enabled by default
178                }))
179            }
180        };
181
182        let store_for_create = mock_store.clone();
183        let create_mock = move |Json(mut mock): Json<Value>| {
184            let store = store_for_create.clone();
185            async move {
186                let id = mock
187                    .get("id")
188                    .and_then(|v| v.as_str())
189                    .filter(|s| !s.is_empty())
190                    .map_or_else(|| uuid::Uuid::new_v4().to_string(), String::from);
191                mock["id"] = serde_json::json!(id);
192                store.write().await.insert(id, mock.clone());
193                (StatusCode::CREATED, Json(mock))
194            }
195        };
196
197        let store_for_get = mock_store.clone();
198        let get_mock = move |Path(id): Path<String>| {
199            let store = store_for_get.clone();
200            async move {
201                store.read().await.get(&id).map_or_else(
202                    || StatusCode::NOT_FOUND.into_response(),
203                    |mock| (StatusCode::OK, Json(mock.clone())).into_response(),
204                )
205            }
206        };
207
208        let store_for_update = mock_store.clone();
209        let update_mock = move |Path(id): Path<String>, Json(mut mock): Json<Value>| {
210            let store = store_for_update.clone();
211            async move {
212                mock["id"] = serde_json::json!(id.clone());
213                store.write().await.insert(id, mock.clone());
214                Json(mock)
215            }
216        };
217
218        let store_for_delete = mock_store.clone();
219        let delete_mock = move |Path(id): Path<String>| {
220            let store = store_for_delete.clone();
221            async move {
222                store.write().await.remove(&id);
223                StatusCode::NO_CONTENT
224            }
225        };
226
227        let get_stats = move || {
228            let store = mock_store.clone();
229            async move {
230                let count = store.read().await.len();
231                Json(serde_json::json!({
232                    "uptime_seconds": 1,  // Minimum uptime for tests
233                    "total_requests": 0,
234                    "active_mocks": count,
235                    "enabled_mocks": count,
236                    "registered_routes": count
237                }))
238            }
239        };
240
241        // Fallback handler that matches against dynamically added stubs
242        let fallback_handler = move |request: Request| {
243            let stub_store = stub_store.clone();
244            async move {
245                let method = request.method().to_string();
246                let path = request.uri().path().to_string();
247
248                // Search for a matching stub, cloning out before dropping the lock
249                let matching_stub = stub_store
250                    .read()
251                    .await
252                    .iter()
253                    .find(|s| s.method.eq_ignore_ascii_case(&method) && s.path == path)
254                    .cloned();
255
256                if let Some(stub) = matching_stub {
257                    let mut response = Json(stub.body).into_response();
258                    *response.status_mut() =
259                        StatusCode::from_u16(stub.status).unwrap_or(StatusCode::OK);
260
261                    for (key, value) in &stub.headers {
262                        if let Ok(header_name) = axum::http::HeaderName::from_bytes(key.as_bytes())
263                        {
264                            if let Ok(header_value) = axum::http::HeaderValue::from_str(value) {
265                                response.headers_mut().insert(header_name, header_value);
266                            }
267                        }
268                    }
269
270                    return response;
271                }
272
273                // No matching stub found
274                StatusCode::NOT_FOUND.into_response()
275            }
276        };
277
278        // Start with health and admin API endpoints
279        let mut router = Router::new()
280            .route("/health", get(|| async { (StatusCode::OK, "OK") }))
281            .route("/api/mocks", get(list_mocks).post(create_mock))
282            .route("/api/mocks/{id}", get(get_mock).put(update_mock).delete(delete_mock))
283            .route("/api/stats", get(get_stats));
284
285        // Add pre-defined routes (added before server start)
286        for route_config in &self.routes {
287            let status = route_config.response.status;
288            let body = route_config.response.body.clone();
289            let headers = route_config.response.headers.clone();
290
291            let handler = move || {
292                let body = body.clone();
293                let headers = headers.clone();
294                async move {
295                    let mut response = Json(body).into_response();
296                    *response.status_mut() = StatusCode::from_u16(status)
297                        .expect("status code validated at stub registration");
298
299                    for (key, value) in headers {
300                        if let Ok(header_name) = axum::http::HeaderName::from_bytes(key.as_bytes())
301                        {
302                            if let Ok(header_value) = axum::http::HeaderValue::from_str(&value) {
303                                response.headers_mut().insert(header_name, header_value);
304                            }
305                        }
306                    }
307
308                    response
309                }
310            };
311
312            let path = &route_config.path;
313
314            router = match route_config.method.to_uppercase().as_str() {
315                "GET" => router.route(path, get(handler)),
316                "POST" => router.route(path, post(handler)),
317                "PUT" => router.route(path, put(handler)),
318                "DELETE" => router.route(path, delete(handler)),
319                _ => router,
320            };
321        }
322
323        // Add fallback for dynamically added stubs
324        router.fallback(fallback_handler)
325    }
326
327    /// Stop the mock server
328    pub async fn stop(mut self) -> Result<()> {
329        if let Some(shutdown_tx) = self.shutdown_tx.take() {
330            let _ = shutdown_tx.send(());
331        }
332
333        if let Some(handle) = self.server_handle.take() {
334            let _ = handle.await;
335        }
336
337        Ok(())
338    }
339
340    /// Stub a response for a given method and path
341    pub async fn stub_response(
342        &mut self,
343        method: impl Into<String>,
344        path: impl Into<String>,
345        body: Value,
346    ) -> Result<()> {
347        let stub = ResponseStub::new(method, path, body);
348        self.add_stub(stub).await
349    }
350
351    /// Add a response stub
352    ///
353    /// Stubs can be added before or after the server starts.
354    /// Stubs added after start are served via a fallback handler.
355    pub async fn add_stub(&mut self, stub: ResponseStub) -> Result<()> {
356        // Add to the shared stub store (works at runtime)
357        let stored_stub = StoredStub {
358            method: stub.method.clone(),
359            path: stub.path.clone(),
360            status: stub.status,
361            headers: stub.headers.clone(),
362            body: stub.body.clone(),
363        };
364        self.stub_store.write().await.push(stored_stub);
365
366        // Also add to routes for pre-start configuration
367        let route_config = RouteConfig {
368            path: stub.path.clone(),
369            method: stub.method,
370            request: None,
371            response: RouteResponseConfig {
372                status: stub.status,
373                headers: stub.headers,
374                body: Some(stub.body),
375            },
376            fault_injection: None,
377            latency: None,
378        };
379
380        self.routes.push(route_config);
381
382        Ok(())
383    }
384
385    /// Remove all stubs
386    pub async fn clear_stubs(&mut self) -> Result<()> {
387        self.routes.clear();
388        self.stub_store.write().await.clear();
389        Ok(())
390    }
391
392    /// Get the server port
393    #[must_use]
394    pub const fn port(&self) -> u16 {
395        self.port
396    }
397
398    /// Get the server base URL
399    ///
400    /// Note: If the server is bound to `0.0.0.0` (all interfaces),
401    /// this returns `127.0.0.1` as the host for client connections.
402    #[must_use]
403    pub fn url(&self) -> String {
404        // 0.0.0.0 means "bind to all interfaces" but isn't a valid connection target
405        // Convert to localhost for client connections
406        if self.address.ip().is_unspecified() {
407            format!("http://127.0.0.1:{}", self.address.port())
408        } else {
409            format!("http://{}", self.address)
410        }
411    }
412
413    /// Check if the server is running
414    #[must_use]
415    pub const fn is_running(&self) -> bool {
416        self.server_handle.is_some()
417    }
418}
419
420impl Default for MockServer {
421    fn default() -> Self {
422        Self {
423            port: 0,
424            address: "127.0.0.1:0"
425                .parse()
426                .expect("hardcoded loopback literal parses as SocketAddr"),
427            config: ServerConfig::default(),
428            server_handle: None,
429            shutdown_tx: None,
430            routes: Vec::new(),
431            stub_store: Arc::new(RwLock::new(Vec::new())),
432        }
433    }
434}
435
436impl std::fmt::Debug for MockServer {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        f.debug_struct("MockServer")
439            .field("port", &self.port)
440            .field("address", &self.address)
441            .field("is_running", &self.server_handle.is_some())
442            .field("routes_count", &self.routes.len())
443            .finish_non_exhaustive()
444    }
445}
446
447// Implement Drop to ensure server is stopped
448impl Drop for MockServer {
449    fn drop(&mut self) {
450        if let Some(shutdown_tx) = self.shutdown_tx.take() {
451            let _ = shutdown_tx.send(());
452        }
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use serde_json::json;
460    use std::mem::{size_of, size_of_val};
461
462    #[test]
463    fn test_mock_server_new() {
464        let builder = MockServer::new();
465        // Should return a MockServerBuilder
466        assert_eq!(size_of_val(&builder), size_of::<MockServerBuilder>());
467    }
468
469    #[test]
470    fn test_mock_server_default() {
471        let server = MockServer::default();
472        assert_eq!(server.port, 0);
473        assert!(!server.is_running());
474        assert!(server.routes.is_empty());
475    }
476
477    #[test]
478    fn test_mock_server_port() {
479        let server = MockServer::default();
480        assert_eq!(server.port(), 0);
481    }
482
483    #[test]
484    fn test_mock_server_url() {
485        let mut server = MockServer::default();
486        server.port = 8080;
487        server.address = "127.0.0.1:8080".parse().unwrap();
488        assert_eq!(server.url(), "http://127.0.0.1:8080");
489    }
490
491    #[test]
492    fn test_mock_server_is_running_false() {
493        let server = MockServer::default();
494        assert!(!server.is_running());
495    }
496
497    #[test]
498    fn test_from_config_valid() {
499        let server_config = ServerConfig::default();
500        let core_config = Config::default();
501
502        let result = MockServer::from_config(server_config, core_config);
503        assert!(result.is_ok());
504
505        let server = result.unwrap();
506        assert!(!server.is_running());
507        assert!(server.routes.is_empty());
508    }
509
510    #[test]
511    fn test_from_config_invalid_address() {
512        let mut server_config = ServerConfig::default();
513        server_config.http.host = "invalid host with spaces".to_string();
514        let core_config = Config::default();
515
516        let result = MockServer::from_config(server_config, core_config);
517        assert!(result.is_err());
518        match result {
519            Err(Error::InvalidConfig(msg)) => {
520                assert!(msg.contains("Invalid address"));
521            }
522            _ => panic!("Expected InvalidConfig error"),
523        }
524    }
525
526    #[tokio::test]
527    async fn test_add_stub() {
528        let mut server = MockServer::default();
529        let stub = ResponseStub::new("GET", "/api/test", json!({"test": true}));
530
531        let result = server.add_stub(stub.clone()).await;
532        assert!(result.is_ok());
533        assert_eq!(server.routes.len(), 1);
534
535        let route = &server.routes[0];
536        assert_eq!(route.path, "/api/test");
537        assert_eq!(route.method, "GET");
538        assert_eq!(route.response.status, 200);
539    }
540
541    #[tokio::test]
542    async fn test_add_stub_with_custom_status() {
543        let mut server = MockServer::default();
544        let stub = ResponseStub::new("POST", "/api/create", json!({"created": true})).status(201);
545
546        let result = server.add_stub(stub).await;
547        assert!(result.is_ok());
548        assert_eq!(server.routes.len(), 1);
549
550        let route = &server.routes[0];
551        assert_eq!(route.response.status, 201);
552    }
553
554    #[tokio::test]
555    async fn test_add_stub_with_headers() {
556        let mut server = MockServer::default();
557        let stub = ResponseStub::new("GET", "/api/test", json!({}))
558            .header("Content-Type", "application/json")
559            .header("X-Custom", "value");
560
561        let result = server.add_stub(stub).await;
562        assert!(result.is_ok());
563
564        let route = &server.routes[0];
565        assert_eq!(
566            route.response.headers.get("Content-Type"),
567            Some(&"application/json".to_string())
568        );
569        assert_eq!(route.response.headers.get("X-Custom"), Some(&"value".to_string()));
570    }
571
572    #[tokio::test]
573    async fn test_stub_response() {
574        let mut server = MockServer::default();
575
576        let result = server.stub_response("GET", "/api/users", json!({"users": []})).await;
577        assert!(result.is_ok());
578        assert_eq!(server.routes.len(), 1);
579
580        let route = &server.routes[0];
581        assert_eq!(route.path, "/api/users");
582        assert_eq!(route.method, "GET");
583    }
584
585    #[tokio::test]
586    async fn test_clear_stubs() {
587        let mut server = MockServer::default();
588
589        server.stub_response("GET", "/api/test1", json!({})).await.unwrap();
590        server.stub_response("POST", "/api/test2", json!({})).await.unwrap();
591        assert_eq!(server.routes.len(), 2);
592
593        let result = server.clear_stubs().await;
594        assert!(result.is_ok());
595        assert_eq!(server.routes.len(), 0);
596    }
597
598    #[tokio::test]
599    async fn test_multiple_stubs() {
600        let mut server = MockServer::default();
601
602        server.stub_response("GET", "/api/users", json!({"users": []})).await.unwrap();
603        server
604            .stub_response("POST", "/api/users", json!({"created": true}))
605            .await
606            .unwrap();
607        server
608            .stub_response("DELETE", "/api/users/1", json!({"deleted": true}))
609            .await
610            .unwrap();
611
612        assert_eq!(server.routes.len(), 3);
613
614        assert_eq!(server.routes[0].method, "GET");
615        assert_eq!(server.routes[1].method, "POST");
616        assert_eq!(server.routes[2].method, "DELETE");
617    }
618
619    #[test]
620    fn test_build_simple_router_empty() {
621        let server = MockServer::default();
622        let router = server.build_simple_router(server.stub_store.clone());
623        // Router should be created without panicking
624        assert_eq!(size_of_val(&router), size_of::<Router>());
625    }
626
627    #[tokio::test]
628    async fn test_build_simple_router_with_routes() {
629        let mut server = MockServer::default();
630        server.stub_response("GET", "/test", json!({"test": true})).await.unwrap();
631        server.stub_response("POST", "/create", json!({"created": true})).await.unwrap();
632
633        let router = server.build_simple_router(server.stub_store.clone());
634        // Router should be built with the routes
635        assert_eq!(size_of_val(&router), size_of::<Router>());
636    }
637
638    #[tokio::test]
639    async fn test_start_server_already_started() {
640        // Use port 0 for OS assignment - the server now properly updates
641        // self.address after binding
642        let mut server = MockServer::default();
643        server.port = 0;
644        server.address = "127.0.0.1:0".parse().unwrap();
645
646        // Start the server
647        let result = server.start().await;
648        assert!(result.is_ok(), "Failed to start server: {:?}", result.err());
649        assert!(server.is_running());
650
651        // Verify the port was updated from 0 to an actual port
652        assert_ne!(server.port, 0, "Port should have been updated from 0");
653
654        // Try to start again
655        let result2 = server.start().await;
656        assert!(result2.is_err());
657        match result2 {
658            Err(Error::ServerAlreadyStarted(_)) => (),
659            _ => panic!("Expected ServerAlreadyStarted error"),
660        }
661
662        // Clean up
663        let _ = Box::pin(server.stop()).await;
664    }
665
666    #[test]
667    fn test_server_debug_format() {
668        let server = MockServer::default();
669        let debug_str = format!("{server:?}");
670        assert!(debug_str.contains("MockServer"));
671    }
672
673    #[tokio::test]
674    async fn test_route_config_conversion() {
675        let mut server = MockServer::default();
676        let stub = ResponseStub::new("PUT", "/api/update", json!({"updated": true}))
677            .status(200)
678            .header("X-Version", "1.0");
679
680        server.add_stub(stub).await.unwrap();
681
682        let route = &server.routes[0];
683        assert_eq!(route.path, "/api/update");
684        assert_eq!(route.method, "PUT");
685        assert_eq!(route.response.status, 200);
686        assert_eq!(route.response.headers.get("X-Version"), Some(&"1.0".to_string()));
687        assert!(route.response.body.is_some());
688        assert_eq!(route.response.body, Some(json!({"updated": true})));
689    }
690
691    #[tokio::test]
692    async fn test_server_with_different_methods() {
693        let mut server = MockServer::default();
694
695        server.stub_response("GET", "/test", json!({})).await.unwrap();
696        server.stub_response("POST", "/test", json!({})).await.unwrap();
697        server.stub_response("PUT", "/test", json!({})).await.unwrap();
698        server.stub_response("DELETE", "/test", json!({})).await.unwrap();
699        server.stub_response("PATCH", "/test", json!({})).await.unwrap();
700
701        assert_eq!(server.routes.len(), 5);
702
703        // Verify all methods are different
704        let methods: Vec<_> = server.routes.iter().map(|r| r.method.as_str()).collect();
705        assert!(methods.contains(&"GET"));
706        assert!(methods.contains(&"POST"));
707        assert!(methods.contains(&"PUT"));
708        assert!(methods.contains(&"DELETE"));
709        assert!(methods.contains(&"PATCH"));
710    }
711
712    #[tokio::test]
713    async fn test_server_url_format() {
714        let mut server = MockServer::default();
715        server.port = 3000;
716        server.address = "127.0.0.1:3000".parse().unwrap();
717
718        let url = server.url();
719        assert_eq!(url, "http://127.0.0.1:3000");
720        assert!(url.starts_with("http://"));
721    }
722
723    #[tokio::test]
724    async fn test_server_with_ipv6_address() {
725        let mut server = MockServer::default();
726        server.port = 8080;
727        server.address = "[::1]:8080".parse().unwrap();
728
729        let url = server.url();
730        assert_eq!(url, "http://[::1]:8080");
731    }
732}