Skip to main content

mollendorff_forge/api/
server.rs

1//! Forge API Server implementation
2//!
3//! HTTP REST API server using Axum for enterprise integrations.
4//! Provides endpoints for validate, calculate, audit, export, import.
5
6// During coverage builds, stubbed functions don't use all imports
7#![cfg_attr(coverage, allow(unused_imports, dead_code))]
8
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use axum::{
13    routing::{get, post},
14    Router,
15};
16use tower_http::cors::{Any, CorsLayer};
17use tower_http::trace::TraceLayer;
18use tracing::info;
19
20use super::handlers;
21
22/// API Server configuration
23#[derive(Clone)]
24pub struct ApiConfig {
25    pub host: String,
26    pub port: u16,
27}
28
29impl Default for ApiConfig {
30    fn default() -> Self {
31        Self {
32            host: "127.0.0.1".to_string(),
33            port: 8080,
34        }
35    }
36}
37
38/// Shared application state
39#[derive(Clone)]
40pub struct AppState {
41    pub version: String,
42}
43
44/// Run the API server
45///
46/// # Errors
47///
48/// Returns an error if the server address cannot be parsed, the TCP listener
49/// fails to bind, or the server encounters a fatal runtime error.
50///
51/// # Coverage Exclusion (ADR-006)
52/// This function binds to a real TCP port and runs forever until terminated.
53/// Cannot be unit tested - verified via integration tests in `binary_integration_tests.rs`
54#[cfg(not(coverage))]
55pub async fn run_api_server(config: ApiConfig) -> anyhow::Result<()> {
56    // Initialize tracing
57    tracing_subscriber::fmt()
58        .with_env_filter(
59            tracing_subscriber::EnvFilter::try_from_default_env()
60                .unwrap_or_else(|_| "forge=info,tower_http=info".into()),
61        )
62        .init();
63
64    let state = Arc::new(AppState {
65        version: env!("CARGO_PKG_VERSION").to_string(),
66    });
67
68    // CORS configuration
69    let cors = CorsLayer::new()
70        .allow_origin(Any)
71        .allow_methods(Any)
72        .allow_headers(Any);
73
74    // Build router
75    let app = Router::new()
76        // Health and info endpoints
77        .route("/", get(handlers::root))
78        .route("/health", get(handlers::health))
79        .route("/version", get(handlers::version))
80        // Core API endpoints
81        .route("/api/v1/validate", post(handlers::validate))
82        .route("/api/v1/calculate", post(handlers::calculate))
83        .route("/api/v1/audit", post(handlers::audit))
84        .route("/api/v1/export", post(handlers::export))
85        .route("/api/v1/import", post(handlers::import_excel))
86        // State and middleware
87        .with_state(state)
88        .layer(cors)
89        .layer(TraceLayer::new_for_http());
90
91    let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
92    info!("🔥 Forge API Server starting on http://{}", addr);
93    info!("   Endpoints: /api/v1/validate, /api/v1/calculate, /api/v1/audit, /api/v1/export, /api/v1/import");
94    info!("   Health: /health, Version: /version");
95
96    let listener = tokio::net::TcpListener::bind(addr).await?;
97    axum::serve(listener, app)
98        .with_graceful_shutdown(shutdown_signal())
99        .await?;
100
101    info!("Forge API Server shutdown complete");
102    Ok(())
103}
104
105/// Stub for coverage builds - see ADR-006
106#[cfg(coverage)]
107pub async fn run_api_server(_config: ApiConfig) -> anyhow::Result<()> {
108    Ok(())
109}
110
111/// Graceful shutdown signal handler
112///
113/// # Coverage Exclusion (ADR-006)
114/// Waits for OS signals (Ctrl+C, SIGTERM) - cannot be triggered in unit tests
115#[cfg(not(coverage))]
116async fn shutdown_signal() {
117    let ctrl_c = async {
118        tokio::signal::ctrl_c()
119            .await
120            .expect("failed to install Ctrl+C handler");
121    };
122
123    #[cfg(unix)]
124    let terminate = async {
125        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
126            .expect("failed to install signal handler")
127            .recv()
128            .await;
129    };
130
131    #[cfg(not(unix))]
132    let terminate = std::future::pending::<()>();
133
134    tokio::select! {
135        () = ctrl_c => {},
136        () = terminate => {},
137    }
138
139    info!("Shutdown signal received, stopping server...");
140}
141
142/// Stub for coverage builds - see ADR-006
143#[cfg(coverage)]
144async fn shutdown_signal() {}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    // ==================== ApiConfig Tests ====================
151
152    #[test]
153    fn test_default_config() {
154        let config = ApiConfig::default();
155        assert_eq!(config.host, "127.0.0.1");
156        assert_eq!(config.port, 8080);
157    }
158
159    #[test]
160    fn test_config_custom_values() {
161        let config = ApiConfig {
162            host: "0.0.0.0".to_string(),
163            port: 3000,
164        };
165        assert_eq!(config.host, "0.0.0.0");
166        assert_eq!(config.port, 3000);
167    }
168
169    #[test]
170    fn test_config_clone() {
171        let config1 = ApiConfig::default();
172        let config2 = config1.clone();
173        assert_eq!(config1.host, config2.host);
174        assert_eq!(config1.port, config2.port);
175    }
176
177    #[test]
178    fn test_config_address_format() {
179        let config = ApiConfig {
180            host: "192.168.1.100".to_string(),
181            port: 9090,
182        };
183        let addr_str = format!("{}:{}", config.host, config.port);
184        assert_eq!(addr_str, "192.168.1.100:9090");
185
186        // Verify it parses to SocketAddr
187        let addr: SocketAddr = addr_str.parse().unwrap();
188        assert_eq!(addr.port(), 9090);
189    }
190
191    // ==================== AppState Tests ====================
192
193    #[test]
194    fn test_app_state_version() {
195        let state = AppState {
196            version: "2.0.0".to_string(),
197        };
198        assert_eq!(state.version, "2.0.0");
199    }
200
201    #[test]
202    fn test_app_state_clone() {
203        let state1 = AppState {
204            version: "2.0.0".to_string(),
205        };
206        let state2 = state1.clone();
207        assert_eq!(state1.version, state2.version);
208    }
209
210    #[test]
211    fn test_app_state_in_arc() {
212        let state = Arc::new(AppState {
213            version: "2.0.0".to_string(),
214        });
215        let state_clone = Arc::clone(&state);
216        assert_eq!(state.version, state_clone.version);
217        assert_eq!(Arc::strong_count(&state), 2);
218    }
219
220    // ==================== Router Building Tests ====================
221
222    #[test]
223    fn test_build_router() {
224        let state = Arc::new(AppState {
225            version: "5.0.0".to_string(),
226        });
227
228        // Build the router with explicit type
229        let _app: Router = Router::new()
230            .route("/", get(handlers::root))
231            .route("/health", get(handlers::health))
232            .route("/version", get(handlers::version))
233            .route("/api/v1/validate", post(handlers::validate))
234            .route("/api/v1/calculate", post(handlers::calculate))
235            .route("/api/v1/audit", post(handlers::audit))
236            .route("/api/v1/export", post(handlers::export))
237            .route("/api/v1/import", post(handlers::import_excel))
238            .with_state(state);
239
240        // If we get here, router was built successfully
241    }
242
243    #[test]
244    fn test_socket_addr_parsing() {
245        let config = ApiConfig::default();
246        let addr_str = format!("{}:{}", config.host, config.port);
247        let addr: Result<SocketAddr, _> = addr_str.parse();
248        assert!(addr.is_ok());
249        assert_eq!(addr.unwrap().port(), 8080);
250    }
251
252    #[test]
253    fn test_socket_addr_parsing_ipv6() {
254        let config = ApiConfig {
255            host: "::1".to_string(),
256            port: 8080,
257        };
258        let addr_str = format!("[{}]:{}", config.host, config.port);
259        let addr: Result<SocketAddr, _> = addr_str.parse();
260        assert!(addr.is_ok());
261    }
262
263    #[test]
264    fn test_cors_layer_creation() {
265        let cors = CorsLayer::new()
266            .allow_origin(Any)
267            .allow_methods(Any)
268            .allow_headers(Any);
269        // If we get here, CORS layer was created successfully
270        let _ = cors;
271    }
272
273    // ==================== Integration Test - Router with Tower ====================
274
275    #[tokio::test]
276    async fn test_router_health_endpoint() {
277        use axum::body::Body;
278        use axum::http::{Request, StatusCode};
279        use tower::ServiceExt;
280
281        let state = Arc::new(AppState {
282            version: "5.0.0".to_string(),
283        });
284
285        let app = Router::new()
286            .route("/health", get(handlers::health))
287            .with_state(state);
288
289        let response = app
290            .oneshot(
291                Request::builder()
292                    .uri("/health")
293                    .body(Body::empty())
294                    .unwrap(),
295            )
296            .await
297            .unwrap();
298
299        assert_eq!(response.status(), StatusCode::OK);
300    }
301
302    #[tokio::test]
303    async fn test_router_version_endpoint() {
304        use axum::body::Body;
305        use axum::http::{Request, StatusCode};
306        use tower::ServiceExt;
307
308        let state = Arc::new(AppState {
309            version: "5.0.0".to_string(),
310        });
311
312        let app = Router::new()
313            .route("/version", get(handlers::version))
314            .with_state(state);
315
316        let response = app
317            .oneshot(
318                Request::builder()
319                    .uri("/version")
320                    .body(Body::empty())
321                    .unwrap(),
322            )
323            .await
324            .unwrap();
325
326        assert_eq!(response.status(), StatusCode::OK);
327    }
328
329    #[tokio::test]
330    async fn test_router_root_endpoint() {
331        use axum::body::Body;
332        use axum::http::{Request, StatusCode};
333        use tower::ServiceExt;
334
335        let state = Arc::new(AppState {
336            version: "5.0.0".to_string(),
337        });
338
339        let app = Router::new()
340            .route("/", get(handlers::root))
341            .with_state(state);
342
343        let response = app
344            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
345            .await
346            .unwrap();
347
348        assert_eq!(response.status(), StatusCode::OK);
349    }
350
351    #[tokio::test]
352    async fn test_router_validate_endpoint() {
353        use axum::body::Body;
354        use axum::http::{header, Method, Request, StatusCode};
355        use tower::ServiceExt;
356
357        let state = Arc::new(AppState {
358            version: "5.0.0".to_string(),
359        });
360
361        let app = Router::new()
362            .route("/api/v1/validate", post(handlers::validate))
363            .with_state(state);
364
365        let body = r#"{"file_path": "test-data/budget.yaml"}"#;
366
367        let response = app
368            .oneshot(
369                Request::builder()
370                    .method(Method::POST)
371                    .uri("/api/v1/validate")
372                    .header(header::CONTENT_TYPE, "application/json")
373                    .body(Body::from(body))
374                    .unwrap(),
375            )
376            .await
377            .unwrap();
378
379        assert_eq!(response.status(), StatusCode::OK);
380    }
381
382    #[tokio::test]
383    async fn test_router_calculate_endpoint() {
384        use axum::body::Body;
385        use axum::http::{header, Method, Request, StatusCode};
386        use tower::ServiceExt;
387
388        let state = Arc::new(AppState {
389            version: "5.0.0".to_string(),
390        });
391
392        let app = Router::new()
393            .route("/api/v1/calculate", post(handlers::calculate))
394            .with_state(state);
395
396        let body = r#"{"file_path": "test-data/budget.yaml", "dry_run": true}"#;
397
398        let response = app
399            .oneshot(
400                Request::builder()
401                    .method(Method::POST)
402                    .uri("/api/v1/calculate")
403                    .header(header::CONTENT_TYPE, "application/json")
404                    .body(Body::from(body))
405                    .unwrap(),
406            )
407            .await
408            .unwrap();
409
410        assert_eq!(response.status(), StatusCode::OK);
411    }
412
413    #[tokio::test]
414    async fn test_router_not_found() {
415        use axum::body::Body;
416        use axum::http::{Request, StatusCode};
417        use tower::ServiceExt;
418
419        let state = Arc::new(AppState {
420            version: "5.0.0".to_string(),
421        });
422
423        let app = Router::new()
424            .route("/health", get(handlers::health))
425            .with_state(state);
426
427        let response = app
428            .oneshot(
429                Request::builder()
430                    .uri("/nonexistent")
431                    .body(Body::empty())
432                    .unwrap(),
433            )
434            .await
435            .unwrap();
436
437        assert_eq!(response.status(), StatusCode::NOT_FOUND);
438    }
439}