clawspec_core/test_client/test_server.rs
1use std::future::Future;
2use std::net::TcpListener;
3use std::time::Duration;
4
5use crate::{ApiClient, ApiClientBuilder};
6
7/// A trait for implementing test server abstractions for different web frameworks.
8///
9/// This trait provides a generic interface for launching and managing test servers
10/// for various web frameworks (e.g., Axum, Warp, actix-web). It allows the TestClient
11/// to work with any server implementation in a framework-agnostic way.
12///
13/// # Associated Types
14///
15/// - [`Error`](TestServer::Error): Error type that can occur during server operations
16///
17/// # Required Methods
18///
19/// - [`launch`](TestServer::launch): Starts the server with the provided TcpListener
20///
21/// # Optional Methods
22///
23/// - [`is_healthy`](TestServer::is_healthy): Checks if the server is ready to accept requests
24/// - [`config`](TestServer::config): Provides configuration for the test framework
25///
26/// # Example
27///
28/// ```rust
29/// use clawspec_core::test_client::{TestServer, TestServerConfig, HealthStatus};
30/// use std::net::TcpListener;
31/// use std::time::Duration;
32///
33/// #[derive(Debug)]
34/// struct ServerError;
35///
36/// impl std::fmt::Display for ServerError {
37/// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38/// write!(f, "Server error")
39/// }
40/// }
41///
42/// impl std::error::Error for ServerError {}
43///
44/// #[derive(Debug)]
45/// struct MyTestServer;
46///
47/// impl TestServer for MyTestServer {
48/// type Error = ServerError;
49///
50/// async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
51/// // Convert to non-blocking for tokio compatibility
52/// listener.set_nonblocking(true).map_err(|_| ServerError)?;
53/// let tokio_listener = tokio::net::TcpListener::from_std(listener)
54/// .map_err(|_| ServerError)?;
55///
56/// // Start your web server here
57/// // For example, with axum:
58/// // axum::serve(tokio_listener, app).await.map_err(|_| ServerError)?;
59/// Ok(())
60/// }
61///
62/// async fn is_healthy(&self, client: &mut clawspec_core::ApiClient) -> Result<HealthStatus, Self::Error> {
63/// // Check if server is ready by making a health check request
64/// match client.get("/health").unwrap().await {
65/// Ok(_) => Ok(HealthStatus::Healthy),
66/// Err(_) => Ok(HealthStatus::Unhealthy),
67/// }
68/// }
69///
70/// fn config(&self) -> TestServerConfig {
71/// TestServerConfig {
72/// api_client: Some(
73/// clawspec_core::ApiClient::builder()
74/// .with_host("localhost")
75/// .with_base_path("/api").unwrap()
76/// ),
77/// min_backoff_delay: Duration::from_millis(10),
78/// max_backoff_delay: Duration::from_secs(1),
79/// backoff_jitter: true,
80/// max_retry_attempts: 10,
81/// }
82/// }
83/// }
84/// ```
85///
86/// # Framework Integration
87///
88/// ## Framework Integration Example
89///
90/// ```rust,no_run
91/// use clawspec_core::test_client::{TestServer, TestServerConfig};
92/// use std::net::TcpListener;
93///
94/// #[derive(Debug)]
95/// struct WebFrameworkTestServer {
96/// // Your web framework's app/router would go here
97/// // For example: app: axum::Router, or app: warp::Filter, etc.
98/// }
99///
100/// impl WebFrameworkTestServer {
101/// fn new(/* app: YourApp */) -> Self {
102/// Self { /* app */ }
103/// }
104/// }
105///
106/// impl TestServer for WebFrameworkTestServer {
107/// type Error = std::io::Error; // or your custom error type
108///
109/// async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
110/// listener.set_nonblocking(true)?;
111/// let tokio_listener = tokio::net::TcpListener::from_std(listener)?;
112///
113/// // Start your web framework here:
114/// // For Axum: axum::serve(tokio_listener, self.app.clone()).await?;
115/// // For Warp: warp::serve(self.app.clone()).run_async(tokio_listener).await;
116/// // For actix-web: HttpServer::new(|| self.app.clone()).listen(tokio_listener)?.run().await?;
117/// Ok(())
118/// }
119/// }
120/// ```
121///
122/// # Health Checking
123///
124/// The `is_healthy` method allows implementing custom health check logic:
125///
126/// - Return `Ok(HealthStatus::Healthy)` if the server is ready to accept requests
127/// - Return `Ok(HealthStatus::Unhealthy)` if the server is not ready
128/// - Return `Ok(HealthStatus::Uncheckable)` to use the default TCP connection test
129/// - Return `Err(Self::Error)` if an error occurs during health checking
130///
131/// The TestClient will wait for the server to become healthy before returning success.
132/// Health check status returned by the `is_healthy` method.
133///
134/// This enum provides more explicit control over health checking behavior
135/// compared to the previous `Option<bool>` approach.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum HealthStatus {
138 /// Server is healthy and ready to accept requests.
139 Healthy,
140 /// Server is not healthy or not ready yet.
141 Unhealthy,
142 /// Use the default TCP connection test to determine health.
143 /// This is equivalent to the previous `None` return value.
144 Uncheckable,
145}
146
147pub trait TestServer {
148 /// The error type that can be returned by server operations.
149 ///
150 /// This should implement [`std::error::Error`] to provide proper error handling
151 /// and error chain support.
152 type Error: std::error::Error + Send + Sync + 'static;
153
154 /// Launch the server using the provided TcpListener.
155 ///
156 /// This method should start the web server and bind it to the given listener.
157 /// The implementation should convert the std::net::TcpListener to a tokio::net::TcpListener
158 /// for async compatibility.
159 ///
160 /// # Arguments
161 ///
162 /// * `listener` - A TcpListener bound to a random port for testing
163 ///
164 /// # Returns
165 ///
166 /// * `Ok(())` - Server launched successfully
167 /// * `Err(Self::Error)` - Server failed to launch
168 ///
169 /// # Example
170 ///
171 /// ```rust
172 /// # use clawspec_core::test_client::{TestServer, HealthStatus};
173 /// # use std::net::TcpListener;
174 /// # #[derive(Debug)] struct ServerError;
175 /// # impl std::fmt::Display for ServerError {
176 /// # fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 /// # write!(f, "Server error")
178 /// # }
179 /// # }
180 /// # impl std::error::Error for ServerError {}
181 /// # struct MyServer;
182 /// impl TestServer for MyServer {
183 /// type Error = ServerError;
184 ///
185 /// async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
186 /// listener.set_nonblocking(true).map_err(|_| ServerError)?;
187 /// let tokio_listener = tokio::net::TcpListener::from_std(listener)
188 /// .map_err(|_| ServerError)?;
189 ///
190 /// // Start your server here
191 /// loop {
192 /// if let Ok((stream, _)) = tokio_listener.accept().await {
193 /// // Handle connection
194 /// }
195 /// }
196 /// }
197 /// }
198 /// ```
199 fn launch(&self, listener: TcpListener)
200 -> impl Future<Output = Result<(), Self::Error>> + Send;
201
202 /// Check if the server is healthy and ready to accept requests.
203 ///
204 /// This method is called periodically during server startup to determine
205 /// when the server is ready. The default implementation returns `HealthStatus::Uncheckable`,
206 /// which triggers a TCP connection test.
207 ///
208 /// # Arguments
209 ///
210 /// * `client` - An ApiClient configured for this test server
211 ///
212 /// # Returns
213 ///
214 /// * `Ok(HealthStatus::Healthy)` - Server is healthy and ready
215 /// * `Ok(HealthStatus::Unhealthy)` - Server is not healthy
216 /// * `Ok(HealthStatus::Uncheckable)` - Use default TCP connection test
217 /// * `Err(Self::Error)` - Error occurred during health check
218 ///
219 /// # Example
220 ///
221 /// ```rust
222 /// # use clawspec_core::{test_client::{TestServer, HealthStatus}, ApiClient};
223 /// # use std::net::TcpListener;
224 /// # #[derive(Debug)] struct ServerError;
225 /// # impl std::fmt::Display for ServerError {
226 /// # fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 /// # write!(f, "Server error")
228 /// # }
229 /// # }
230 /// # impl std::error::Error for ServerError {}
231 /// # struct MyServer;
232 /// impl TestServer for MyServer {
233 /// # type Error = ServerError;
234 /// # async fn launch(&self, _listener: TcpListener) -> Result<(), Self::Error> { Ok(()) }
235 /// async fn is_healthy(&self, client: &mut ApiClient) -> Result<HealthStatus, Self::Error> {
236 /// // Try to make a health check request
237 /// match client.get("/health").unwrap().await {
238 /// Ok(_) => Ok(HealthStatus::Healthy),
239 /// Err(_) => Ok(HealthStatus::Unhealthy),
240 /// }
241 /// }
242 /// }
243 /// ```
244 fn is_healthy(
245 &self,
246 _client: &mut ApiClient,
247 ) -> impl Future<Output = Result<HealthStatus, Self::Error>> + Send {
248 std::future::ready(Ok(HealthStatus::Uncheckable))
249 }
250
251 /// Provide configuration for the test framework.
252 ///
253 /// This method allows customizing the ApiClient and health check behavior
254 /// for the specific server implementation.
255 ///
256 /// # Returns
257 ///
258 /// A TestServerConfig with custom settings, or default if not overridden.
259 ///
260 /// # Example
261 ///
262 /// ```rust
263 /// # use clawspec_core::{test_client::{TestServer, TestServerConfig}, ApiClient};
264 /// # use std::{net::TcpListener, time::Duration};
265 /// # struct MyServer;
266 /// impl TestServer for MyServer {
267 /// # type Error = std::io::Error;
268 /// # async fn launch(&self, _listener: TcpListener) -> Result<(), Self::Error> { Ok(()) }
269 /// fn config(&self) -> TestServerConfig {
270 /// TestServerConfig {
271 /// api_client: Some(
272 /// ApiClient::builder()
273 /// .with_host("localhost")
274 /// .with_base_path("/api").unwrap()
275 /// ),
276 /// min_backoff_delay: Duration::from_millis(10),
277 /// max_backoff_delay: Duration::from_secs(1),
278 /// backoff_jitter: true,
279 /// max_retry_attempts: 10,
280 /// }
281 /// }
282 /// }
283 /// ```
284 fn config(&self) -> TestServerConfig {
285 TestServerConfig::default()
286 }
287}
288
289/// Configuration for test server behavior and client setup.
290///
291/// This struct allows customizing how the TestClient interacts with the test server,
292/// including the ApiClient configuration and exponential backoff timing for health checks.
293///
294/// # Fields
295///
296/// * `api_client` - Optional pre-configured ApiClient builder for custom client setup
297/// * `min_backoff_delay` - Minimum delay for exponential backoff between health check retries
298/// * `max_backoff_delay` - Maximum delay for exponential backoff between health check retries
299/// * `backoff_jitter` - Whether to add jitter to exponential backoff delays
300/// * `max_retry_attempts` - Maximum number of health check retry attempts
301///
302/// # Examples
303///
304/// ## Default Configuration
305///
306/// ```rust
307/// use clawspec_core::test_client::TestServerConfig;
308/// use std::time::Duration;
309///
310/// let config = TestServerConfig::default();
311/// assert!(config.api_client.is_none());
312/// assert_eq!(config.min_backoff_delay, Duration::from_millis(10));
313/// assert_eq!(config.max_backoff_delay, Duration::from_secs(1));
314/// assert_eq!(config.backoff_jitter, true);
315/// assert_eq!(config.max_retry_attempts, 10);
316/// ```
317///
318/// ## Custom Configuration
319///
320/// ```rust
321/// use clawspec_core::{test_client::TestServerConfig, ApiClient};
322/// use std::time::Duration;
323///
324/// let config = TestServerConfig {
325/// api_client: Some(
326/// ApiClient::builder()
327/// .with_host("test-server.local")
328/// .with_port(3000)
329/// .with_base_path("/api/v1").unwrap()
330/// ),
331/// min_backoff_delay: Duration::from_millis(50),
332/// max_backoff_delay: Duration::from_secs(5),
333/// backoff_jitter: false,
334/// max_retry_attempts: 3,
335/// };
336/// ```
337///
338/// ## Using with TestServer
339///
340/// ```rust
341/// use clawspec_core::test_client::{TestServer, TestServerConfig};
342/// use std::{net::TcpListener, time::Duration};
343///
344/// #[derive(Debug)]
345/// struct MyTestServer;
346///
347/// impl TestServer for MyTestServer {
348/// type Error = std::io::Error;
349///
350/// async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
351/// // Server implementation
352/// listener.set_nonblocking(true)?;
353/// let _tokio_listener = tokio::net::TcpListener::from_std(listener)?;
354/// Ok(())
355/// }
356///
357/// fn config(&self) -> TestServerConfig {
358/// TestServerConfig {
359/// api_client: Some(
360/// clawspec_core::ApiClient::builder()
361/// .with_host("localhost")
362/// .with_base_path("/api").unwrap()
363/// ),
364/// min_backoff_delay: Duration::from_millis(25),
365/// max_backoff_delay: Duration::from_secs(2),
366/// backoff_jitter: true,
367/// max_retry_attempts: 15,
368/// }
369/// }
370/// }
371/// ```
372#[derive(Debug, Clone)]
373pub struct TestServerConfig {
374 /// Optional pre-configured ApiClient builder.
375 ///
376 /// If provided, this builder will be used as the base for creating the ApiClient.
377 /// If None, a default builder will be used. The TestClient will automatically
378 /// configure the port based on the bound server address.
379 ///
380 /// # Example
381 ///
382 /// ```rust
383 /// use clawspec_core::{test_client::TestServerConfig, ApiClient};
384 ///
385 /// let config = TestServerConfig {
386 /// api_client: Some(
387 /// ApiClient::builder()
388 /// .with_host("api.example.com")
389 /// .with_base_path("/v1").unwrap()
390 /// ),
391 /// min_backoff_delay: std::time::Duration::from_millis(10),
392 /// max_backoff_delay: std::time::Duration::from_secs(1),
393 /// backoff_jitter: true,
394 /// max_retry_attempts: 10,
395 /// };
396 /// ```
397 pub api_client: Option<ApiClientBuilder>,
398
399 /// Minimum delay for exponential backoff between health check retries.
400 ///
401 /// This is the initial delay used when the server is unhealthy and needs
402 /// to be retried. The delay will increase exponentially up to `max_backoff_delay`.
403 ///
404 /// # Default
405 ///
406 /// 10 milliseconds
407 ///
408 /// # Example
409 ///
410 /// ```rust
411 /// use clawspec_core::test_client::TestServerConfig;
412 /// use std::time::Duration;
413 ///
414 /// let config = TestServerConfig {
415 /// min_backoff_delay: Duration::from_millis(50), // Start with 50ms
416 /// ..Default::default()
417 /// };
418 /// ```
419 pub min_backoff_delay: Duration,
420
421 /// Maximum delay for exponential backoff between health check retries.
422 ///
423 /// This is the upper bound for the exponential backoff delay. Once the
424 /// delay reaches this value, it will not increase further.
425 ///
426 /// # Default
427 ///
428 /// 1 second
429 ///
430 /// # Example
431 ///
432 /// ```rust
433 /// use clawspec_core::test_client::TestServerConfig;
434 /// use std::time::Duration;
435 ///
436 /// let config = TestServerConfig {
437 /// max_backoff_delay: Duration::from_secs(5), // Max 5 seconds
438 /// ..Default::default()
439 /// };
440 /// ```
441 pub max_backoff_delay: Duration,
442
443 /// Whether to add jitter to the exponential backoff delays.
444 ///
445 /// Jitter adds randomization to retry delays to prevent the "thundering herd"
446 /// problem when multiple clients retry simultaneously. This is generally
447 /// recommended for production use.
448 ///
449 /// # Default
450 ///
451 /// `true` (jitter enabled)
452 ///
453 /// # Example
454 ///
455 /// ```rust
456 /// use clawspec_core::test_client::TestServerConfig;
457 ///
458 /// let config = TestServerConfig {
459 /// backoff_jitter: false, // Disable jitter for predictable timing
460 /// ..Default::default()
461 /// };
462 /// ```
463 pub backoff_jitter: bool,
464
465 /// Maximum number of health check retry attempts.
466 ///
467 /// This limits the total number of health check attempts before giving up.
468 /// The health check will stop retrying once this number of attempts is reached,
469 /// preventing infinite loops when a server never becomes healthy.
470 ///
471 /// # Default
472 ///
473 /// 10 attempts
474 ///
475 /// # Example
476 ///
477 /// ```rust
478 /// use clawspec_core::test_client::TestServerConfig;
479 ///
480 /// let config = TestServerConfig {
481 /// max_retry_attempts: 5, // Only try 5 times before giving up
482 /// ..Default::default()
483 /// };
484 /// ```
485 pub max_retry_attempts: usize,
486}
487
488impl Default for TestServerConfig {
489 fn default() -> Self {
490 Self {
491 api_client: None,
492 min_backoff_delay: Duration::from_millis(10),
493 max_backoff_delay: Duration::from_secs(1),
494 backoff_jitter: true,
495 max_retry_attempts: 10,
496 }
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::ApiClient;
504 use std::net::TcpListener;
505 use std::time::Duration;
506 use tokio::net::TcpListener as TokioTcpListener;
507
508 /// Mock server implementation for testing
509 #[derive(Debug)]
510 struct MockServer {
511 config: TestServerConfig,
512 should_be_healthy: bool,
513 }
514
515 impl MockServer {
516 fn new() -> Self {
517 Self {
518 config: TestServerConfig::default(),
519 should_be_healthy: true,
520 }
521 }
522
523 fn with_health_status(mut self, healthy: bool) -> Self {
524 self.should_be_healthy = healthy;
525 self
526 }
527 }
528
529 impl TestServer for MockServer {
530 type Error = std::io::Error;
531
532 async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
533 // Convert to non-blocking for tokio compatibility
534 listener.set_nonblocking(true)?;
535 let tokio_listener = TokioTcpListener::from_std(listener)?;
536
537 // Simple echo server for testing
538 loop {
539 if let Ok((mut stream, _)) = tokio_listener.accept().await {
540 tokio::spawn(async move {
541 // Simple HTTP response
542 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
543 if let Err(e) =
544 tokio::io::AsyncWriteExt::write_all(&mut stream, response.as_bytes())
545 .await
546 {
547 eprintln!("Failed to write response: {e}");
548 }
549 });
550 }
551 }
552 }
553
554 async fn is_healthy(&self, _client: &mut ApiClient) -> Result<HealthStatus, Self::Error> {
555 Ok(if self.should_be_healthy {
556 HealthStatus::Healthy
557 } else {
558 HealthStatus::Unhealthy
559 })
560 }
561
562 fn config(&self) -> TestServerConfig {
563 self.config.clone()
564 }
565 }
566
567 #[test]
568 fn test_test_server_config_default() {
569 let config = TestServerConfig::default();
570
571 assert!(config.api_client.is_none());
572 assert_eq!(config.min_backoff_delay, Duration::from_millis(10));
573 assert_eq!(config.max_backoff_delay, Duration::from_secs(1));
574 assert!(config.backoff_jitter);
575 assert_eq!(config.max_retry_attempts, 10);
576 }
577
578 #[test]
579 fn test_test_server_config_custom() {
580 let min_delay = Duration::from_millis(50);
581 let max_delay = Duration::from_secs(5);
582 let client_builder = ApiClient::builder()
583 .with_host("test.example.com")
584 .with_port(8080);
585
586 let config = TestServerConfig {
587 api_client: Some(client_builder),
588 min_backoff_delay: min_delay,
589 max_backoff_delay: max_delay,
590 backoff_jitter: false,
591 max_retry_attempts: 5,
592 };
593
594 assert!(config.api_client.is_some());
595 assert_eq!(config.min_backoff_delay, min_delay);
596 assert_eq!(config.max_backoff_delay, max_delay);
597 assert!(!config.backoff_jitter);
598 assert_eq!(config.max_retry_attempts, 5);
599 }
600
601 #[tokio::test]
602 async fn test_mock_server_health_check_healthy() {
603 let server = MockServer::new().with_health_status(true);
604 let mut client = ApiClient::builder().build().expect("valid client");
605
606 let result = server.is_healthy(&mut client).await;
607 assert!(result.is_ok());
608 assert_eq!(result.unwrap(), HealthStatus::Healthy);
609 }
610
611 #[tokio::test]
612 async fn test_mock_server_health_check_unhealthy() {
613 let server = MockServer::new().with_health_status(false);
614 let mut client = ApiClient::builder().build().expect("valid client");
615
616 let result = server.is_healthy(&mut client).await;
617 assert!(result.is_ok());
618 assert_eq!(result.unwrap(), HealthStatus::Unhealthy);
619 }
620
621 #[test]
622 fn test_default_backoff_configuration() {
623 let config = TestServerConfig::default();
624
625 // Verify the default backoff configuration values
626 assert_eq!(config.min_backoff_delay, Duration::from_millis(10));
627 assert_eq!(config.max_backoff_delay, Duration::from_secs(1));
628 assert!(config.backoff_jitter);
629 assert_eq!(config.max_retry_attempts, 10);
630 }
631
632 #[test]
633 fn test_test_server_trait_bounds() {
634 // Test that MockServer implements the required traits
635 fn assert_test_server<T: TestServer + Send + Sync + 'static>(_: T) {}
636
637 let server = MockServer::new();
638 assert_test_server(server);
639 }
640}