Skip to main content

mollendorff_forge/api/
handlers.rs

1//! API request handlers
2//!
3//! Handlers for all REST API endpoints.
4
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use axum::{extract::State, response::IntoResponse, Json};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::cli::{
13    audit as cli_audit, calculate as cli_calculate, export as cli_export, import as cli_import,
14    validate as cli_validate,
15};
16
17use super::server::AppState;
18
19/// Standard API response wrapper
20#[derive(Serialize)]
21pub struct ApiResponse<T> {
22    pub success: bool,
23    pub request_id: String,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub data: Option<T>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub error: Option<String>,
28}
29
30impl<T: Serialize> ApiResponse<T> {
31    pub fn ok(data: T) -> Self {
32        Self {
33            success: true,
34            request_id: Uuid::new_v4().to_string(),
35            data: Some(data),
36            error: None,
37        }
38    }
39
40    pub fn err(message: impl Into<String>) -> Self
41    where
42        T: Default,
43    {
44        Self {
45            success: false,
46            request_id: Uuid::new_v4().to_string(),
47            data: None,
48            error: Some(message.into()),
49        }
50    }
51}
52
53/// Root endpoint response
54#[derive(Serialize)]
55pub struct RootResponse {
56    pub name: String,
57    pub version: String,
58    pub description: String,
59    pub endpoints: Vec<EndpointInfo>,
60}
61
62#[derive(Serialize)]
63pub struct EndpointInfo {
64    pub path: String,
65    pub method: String,
66    pub description: String,
67}
68
69/// GET / - Root info
70pub async fn root(State(state): State<Arc<AppState>>) -> impl IntoResponse {
71    let response = RootResponse {
72        name: "Forge API Server".to_string(),
73        version: state.version.clone(),
74        description: "Enterprise HTTP API for YAML formula calculations".to_string(),
75        endpoints: vec![
76            EndpointInfo {
77                path: "/health".to_string(),
78                method: "GET".to_string(),
79                description: "Health check endpoint".to_string(),
80            },
81            EndpointInfo {
82                path: "/version".to_string(),
83                method: "GET".to_string(),
84                description: "Get server version".to_string(),
85            },
86            EndpointInfo {
87                path: "/api/v1/validate".to_string(),
88                method: "POST".to_string(),
89                description: "Validate a YAML model file".to_string(),
90            },
91            EndpointInfo {
92                path: "/api/v1/calculate".to_string(),
93                method: "POST".to_string(),
94                description: "Calculate formulas in a YAML model".to_string(),
95            },
96            EndpointInfo {
97                path: "/api/v1/audit".to_string(),
98                method: "POST".to_string(),
99                description: "Audit a variable's dependency tree".to_string(),
100            },
101            EndpointInfo {
102                path: "/api/v1/export".to_string(),
103                method: "POST".to_string(),
104                description: "Export YAML to Excel".to_string(),
105            },
106            EndpointInfo {
107                path: "/api/v1/import".to_string(),
108                method: "POST".to_string(),
109                description: "Import Excel to YAML".to_string(),
110            },
111        ],
112    };
113    Json(ApiResponse::ok(response))
114}
115
116/// Health check response
117#[derive(Serialize)]
118pub struct HealthResponse {
119    pub status: String,
120    pub uptime_message: String,
121}
122
123/// GET /health - Health check
124pub async fn health() -> impl IntoResponse {
125    Json(ApiResponse::ok(HealthResponse {
126        status: "healthy".to_string(),
127        uptime_message: "Server is running".to_string(),
128    }))
129}
130
131/// Version response
132#[derive(Serialize)]
133pub struct VersionResponse {
134    pub version: String,
135    pub features: Vec<String>,
136}
137
138/// GET /version - Server version
139pub async fn version(State(state): State<Arc<AppState>>) -> impl IntoResponse {
140    Json(ApiResponse::ok(VersionResponse {
141        version: state.version.clone(),
142        features: vec![
143            "validate".to_string(),
144            "calculate".to_string(),
145            "audit".to_string(),
146            "export".to_string(),
147            "import".to_string(),
148        ],
149    }))
150}
151
152/// Validate request
153#[derive(Deserialize)]
154pub struct ValidateRequest {
155    pub file_path: String,
156}
157
158/// Validate response
159#[derive(Serialize, Default)]
160pub struct ValidateResponse {
161    pub valid: bool,
162    pub file_path: String,
163    pub message: String,
164}
165
166/// POST /api/v1/validate - Validate a YAML model
167pub async fn validate(Json(req): Json<ValidateRequest>) -> impl IntoResponse {
168    let path = PathBuf::from(&req.file_path);
169
170    match cli_validate(&[path]) {
171        Ok(()) => Json(ApiResponse::ok(ValidateResponse {
172            valid: true,
173            file_path: req.file_path,
174            message: "Validation successful".to_string(),
175        })),
176        Err(e) => Json(ApiResponse::ok(ValidateResponse {
177            valid: false,
178            file_path: req.file_path,
179            message: e.to_string(),
180        })),
181    }
182}
183
184/// Calculate request
185#[derive(Deserialize)]
186pub struct CalculateRequest {
187    pub file_path: String,
188    #[serde(default)]
189    pub dry_run: bool,
190}
191
192/// Calculate response
193#[derive(Serialize, Default)]
194pub struct CalculateResponse {
195    pub calculated: bool,
196    pub file_path: String,
197    pub dry_run: bool,
198    pub message: String,
199}
200
201/// POST /api/v1/calculate - Calculate formulas
202pub async fn calculate(Json(req): Json<CalculateRequest>) -> impl IntoResponse {
203    let path = PathBuf::from(&req.file_path);
204    let dry_run = req.dry_run;
205
206    match cli_calculate(&path, dry_run, false, None) {
207        Ok(()) => Json(ApiResponse::ok(CalculateResponse {
208            calculated: true,
209            file_path: req.file_path,
210            dry_run,
211            message: if dry_run {
212                "Dry run completed".to_string()
213            } else {
214                "Calculation completed and file updated".to_string()
215            },
216        })),
217        Err(e) => Json(ApiResponse::ok(CalculateResponse {
218            calculated: false,
219            file_path: req.file_path,
220            dry_run,
221            message: format!("Error: {e}"),
222        })),
223    }
224}
225
226/// Audit request
227#[derive(Deserialize)]
228pub struct AuditRequest {
229    pub file_path: String,
230    pub variable: String,
231}
232
233/// Audit response
234#[derive(Serialize, Default)]
235pub struct AuditResponse {
236    pub audited: bool,
237    pub file_path: String,
238    pub variable: String,
239    pub message: String,
240}
241
242/// POST /api/v1/audit - Audit a variable
243pub async fn audit(Json(req): Json<AuditRequest>) -> impl IntoResponse {
244    let path = PathBuf::from(&req.file_path);
245    let variable = req.variable.clone();
246
247    match cli_audit(&path, &variable) {
248        Ok(()) => Json(ApiResponse::ok(AuditResponse {
249            audited: true,
250            file_path: req.file_path,
251            variable,
252            message: "Audit completed".to_string(),
253        })),
254        Err(e) => Json(ApiResponse::ok(AuditResponse {
255            audited: false,
256            file_path: req.file_path,
257            variable,
258            message: format!("Error: {e}"),
259        })),
260    }
261}
262
263/// Export request
264#[derive(Deserialize)]
265pub struct ExportRequest {
266    pub yaml_path: String,
267    pub excel_path: String,
268}
269
270/// Export response
271#[derive(Serialize, Default)]
272pub struct ExportResponse {
273    pub exported: bool,
274    pub yaml_path: String,
275    pub excel_path: String,
276    pub message: String,
277}
278
279/// POST /api/v1/export - Export YAML to Excel
280pub async fn export(Json(req): Json<ExportRequest>) -> impl IntoResponse {
281    let yaml_path = PathBuf::from(&req.yaml_path);
282    let excel_path = PathBuf::from(&req.excel_path);
283
284    match cli_export(&yaml_path, &excel_path, false) {
285        Ok(()) => Json(ApiResponse::ok(ExportResponse {
286            exported: true,
287            yaml_path: req.yaml_path,
288            excel_path: req.excel_path,
289            message: "Export completed".to_string(),
290        })),
291        Err(e) => Json(ApiResponse::ok(ExportResponse {
292            exported: false,
293            yaml_path: req.yaml_path,
294            excel_path: req.excel_path,
295            message: format!("Error: {e}"),
296        })),
297    }
298}
299
300/// Import request
301#[derive(Deserialize)]
302pub struct ImportRequest {
303    pub excel_path: String,
304    pub yaml_path: String,
305}
306
307/// Import response
308#[derive(Serialize, Default)]
309pub struct ImportResponse {
310    pub imported: bool,
311    pub excel_path: String,
312    pub yaml_path: String,
313    pub message: String,
314}
315
316/// POST /api/v1/import - Import Excel to YAML
317pub async fn import_excel(Json(req): Json<ImportRequest>) -> impl IntoResponse {
318    let excel_path = PathBuf::from(&req.excel_path);
319    let yaml_path = PathBuf::from(&req.yaml_path);
320
321    match cli_import(&excel_path, &yaml_path, false, false, false) {
322        Ok(()) => Json(ApiResponse::ok(ImportResponse {
323            imported: true,
324            excel_path: req.excel_path,
325            yaml_path: req.yaml_path,
326            message: "Import completed".to_string(),
327        })),
328        Err(e) => Json(ApiResponse::ok(ImportResponse {
329            imported: false,
330            excel_path: req.excel_path,
331            yaml_path: req.yaml_path,
332            message: format!("Error: {e}"),
333        })),
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    // ==================== ApiResponse Tests ====================
342
343    #[test]
344    fn test_api_response_ok_creates_success_response() {
345        let response: ApiResponse<String> = ApiResponse::ok("test data".to_string());
346
347        assert!(response.success);
348        assert_eq!(response.data, Some("test data".to_string()));
349        assert!(response.error.is_none());
350        assert!(!response.request_id.is_empty());
351        // Verify UUID format (8-4-4-4-12)
352        assert_eq!(response.request_id.len(), 36);
353    }
354
355    #[test]
356    fn test_api_response_ok_with_struct() {
357        let health = HealthResponse {
358            status: "healthy".to_string(),
359            uptime_message: "running".to_string(),
360        };
361        let response = ApiResponse::ok(health);
362
363        assert!(response.success);
364        assert!(response.data.is_some());
365        let data = response.data.unwrap();
366        assert_eq!(data.status, "healthy");
367        assert_eq!(data.uptime_message, "running");
368    }
369
370    #[test]
371    fn test_api_response_err_creates_error_response() {
372        let response: ApiResponse<String> = ApiResponse::err("Something went wrong");
373
374        assert!(!response.success);
375        assert!(response.data.is_none());
376        assert_eq!(response.error, Some("Something went wrong".to_string()));
377        assert!(!response.request_id.is_empty());
378    }
379
380    #[test]
381    fn test_api_response_request_id_is_unique() {
382        let response1: ApiResponse<String> = ApiResponse::ok("test1".to_string());
383        let response2: ApiResponse<String> = ApiResponse::ok("test2".to_string());
384
385        assert_ne!(response1.request_id, response2.request_id);
386    }
387
388    // ==================== Response Struct Default Tests ====================
389
390    #[test]
391    fn test_validate_response_default() {
392        let response = ValidateResponse::default();
393
394        assert!(!response.valid);
395        assert!(response.file_path.is_empty());
396        assert!(response.message.is_empty());
397    }
398
399    #[test]
400    fn test_calculate_response_default() {
401        let response = CalculateResponse::default();
402
403        assert!(!response.calculated);
404        assert!(!response.dry_run);
405        assert!(response.file_path.is_empty());
406        assert!(response.message.is_empty());
407    }
408
409    #[test]
410    fn test_audit_response_default() {
411        let response = AuditResponse::default();
412
413        assert!(!response.audited);
414        assert!(response.file_path.is_empty());
415        assert!(response.variable.is_empty());
416        assert!(response.message.is_empty());
417    }
418
419    #[test]
420    fn test_export_response_default() {
421        let response = ExportResponse::default();
422
423        assert!(!response.exported);
424        assert!(response.yaml_path.is_empty());
425        assert!(response.excel_path.is_empty());
426        assert!(response.message.is_empty());
427    }
428
429    #[test]
430    fn test_import_response_default() {
431        let response = ImportResponse::default();
432
433        assert!(!response.imported);
434        assert!(response.excel_path.is_empty());
435        assert!(response.yaml_path.is_empty());
436        assert!(response.message.is_empty());
437    }
438
439    // ==================== Request Deserialization Tests ====================
440
441    #[test]
442    fn test_validate_request_deserialize() {
443        let json = r#"{"file_path": "model.yaml"}"#;
444        let req: ValidateRequest = serde_json::from_str(json).unwrap();
445
446        assert_eq!(req.file_path, "model.yaml");
447    }
448
449    #[test]
450    fn test_calculate_request_deserialize_with_dry_run() {
451        let json = r#"{"file_path": "model.yaml", "dry_run": true}"#;
452        let req: CalculateRequest = serde_json::from_str(json).unwrap();
453
454        assert_eq!(req.file_path, "model.yaml");
455        assert!(req.dry_run);
456    }
457
458    #[test]
459    fn test_calculate_request_deserialize_dry_run_defaults_false() {
460        let json = r#"{"file_path": "model.yaml"}"#;
461        let req: CalculateRequest = serde_json::from_str(json).unwrap();
462
463        assert_eq!(req.file_path, "model.yaml");
464        assert!(!req.dry_run);
465    }
466
467    #[test]
468    fn test_audit_request_deserialize() {
469        let json = r#"{"file_path": "model.yaml", "variable": "total_revenue"}"#;
470        let req: AuditRequest = serde_json::from_str(json).unwrap();
471
472        assert_eq!(req.file_path, "model.yaml");
473        assert_eq!(req.variable, "total_revenue");
474    }
475
476    #[test]
477    fn test_export_request_deserialize() {
478        let json = r#"{"yaml_path": "model.yaml", "excel_path": "output.xlsx"}"#;
479        let req: ExportRequest = serde_json::from_str(json).unwrap();
480
481        assert_eq!(req.yaml_path, "model.yaml");
482        assert_eq!(req.excel_path, "output.xlsx");
483    }
484
485    #[test]
486    fn test_import_request_deserialize() {
487        let json = r#"{"excel_path": "input.xlsx", "yaml_path": "output.yaml"}"#;
488        let req: ImportRequest = serde_json::from_str(json).unwrap();
489
490        assert_eq!(req.excel_path, "input.xlsx");
491        assert_eq!(req.yaml_path, "output.yaml");
492    }
493
494    // ==================== Response Serialization Tests ====================
495
496    #[test]
497    fn test_health_response_serialize() {
498        let response = HealthResponse {
499            status: "healthy".to_string(),
500            uptime_message: "Server is running".to_string(),
501        };
502        let json = serde_json::to_string(&response).unwrap();
503
504        assert!(json.contains("\"status\":\"healthy\""));
505        assert!(json.contains("\"uptime_message\":\"Server is running\""));
506    }
507
508    #[test]
509    fn test_version_response_serialize() {
510        let response = VersionResponse {
511            version: "2.0.0".to_string(),
512            features: vec!["validate".to_string(), "calculate".to_string()],
513        };
514        let json = serde_json::to_string(&response).unwrap();
515
516        assert!(json.contains("\"version\":\"2.0.0\""));
517        assert!(json.contains("\"features\":[\"validate\",\"calculate\"]"));
518    }
519
520    #[test]
521    fn test_validate_response_serialize() {
522        let response = ValidateResponse {
523            valid: true,
524            file_path: "model.yaml".to_string(),
525            message: "Validation successful".to_string(),
526        };
527        let json = serde_json::to_string(&response).unwrap();
528
529        assert!(json.contains("\"valid\":true"));
530        assert!(json.contains("\"file_path\":\"model.yaml\""));
531        assert!(json.contains("\"message\":\"Validation successful\""));
532    }
533
534    #[test]
535    fn test_calculate_response_serialize() {
536        let response = CalculateResponse {
537            calculated: true,
538            file_path: "model.yaml".to_string(),
539            dry_run: false,
540            message: "Calculation completed".to_string(),
541        };
542        let json = serde_json::to_string(&response).unwrap();
543
544        assert!(json.contains("\"calculated\":true"));
545        assert!(json.contains("\"dry_run\":false"));
546    }
547
548    #[test]
549    fn test_api_response_serializes_without_none_fields() {
550        let response: ApiResponse<String> = ApiResponse::ok("data".to_string());
551        let json = serde_json::to_string(&response).unwrap();
552
553        // error field should be skipped when None
554        assert!(!json.contains("\"error\""));
555        assert!(json.contains("\"success\":true"));
556        assert!(json.contains("\"data\":\"data\""));
557    }
558
559    #[test]
560    fn test_api_response_error_serializes_without_data() {
561        let response: ApiResponse<String> = ApiResponse::err("error message");
562        let json = serde_json::to_string(&response).unwrap();
563
564        // data field should be skipped when None
565        assert!(!json.contains("\"data\""));
566        assert!(json.contains("\"success\":false"));
567        assert!(json.contains("\"error\":\"error message\""));
568    }
569
570    // ==================== EndpointInfo Tests ====================
571
572    #[test]
573    fn test_endpoint_info_serialize() {
574        let info = EndpointInfo {
575            path: "/api/v1/validate".to_string(),
576            method: "POST".to_string(),
577            description: "Validate a YAML model".to_string(),
578        };
579        let json = serde_json::to_string(&info).unwrap();
580
581        assert!(json.contains("\"path\":\"/api/v1/validate\""));
582        assert!(json.contains("\"method\":\"POST\""));
583        assert!(json.contains("\"description\":\"Validate a YAML model\""));
584    }
585
586    #[test]
587    fn test_root_response_has_all_endpoints() {
588        let response = RootResponse {
589            name: "Forge API Server".to_string(),
590            version: "2.0.0".to_string(),
591            description: "Enterprise HTTP API".to_string(),
592            endpoints: vec![
593                EndpointInfo {
594                    path: "/health".to_string(),
595                    method: "GET".to_string(),
596                    description: "Health check".to_string(),
597                },
598                EndpointInfo {
599                    path: "/api/v1/validate".to_string(),
600                    method: "POST".to_string(),
601                    description: "Validate".to_string(),
602                },
603            ],
604        };
605
606        assert_eq!(response.endpoints.len(), 2);
607        assert_eq!(response.endpoints[0].path, "/health");
608        assert_eq!(response.endpoints[1].path, "/api/v1/validate");
609    }
610
611    // ==================== Async Handler Tests ====================
612
613    #[tokio::test]
614    async fn test_health_handler() {
615        use axum::response::IntoResponse;
616
617        let response = health().await;
618        let response = response.into_response();
619
620        assert_eq!(response.status(), axum::http::StatusCode::OK);
621    }
622
623    #[tokio::test]
624    async fn test_version_handler() {
625        use axum::response::IntoResponse;
626
627        let state = Arc::new(AppState {
628            version: "5.0.0".to_string(),
629        });
630
631        let response = version(State(state)).await;
632        let response = response.into_response();
633
634        assert_eq!(response.status(), axum::http::StatusCode::OK);
635    }
636
637    #[tokio::test]
638    async fn test_root_handler() {
639        use axum::response::IntoResponse;
640
641        let state = Arc::new(AppState {
642            version: "5.0.0".to_string(),
643        });
644
645        let response = root(State(state)).await;
646        let response = response.into_response();
647
648        assert_eq!(response.status(), axum::http::StatusCode::OK);
649    }
650
651    #[tokio::test]
652    async fn test_validate_handler_nonexistent_file() {
653        use axum::response::IntoResponse;
654
655        let req = ValidateRequest {
656            file_path: "/nonexistent/file.yaml".to_string(),
657        };
658
659        let response = validate(Json(req)).await;
660        let response = response.into_response();
661
662        // Should return 200 with error in body (API convention)
663        assert_eq!(response.status(), axum::http::StatusCode::OK);
664    }
665
666    #[tokio::test]
667    async fn test_validate_handler_valid_file() {
668        use axum::response::IntoResponse;
669
670        let req = ValidateRequest {
671            file_path: "test-data/budget.yaml".to_string(),
672        };
673
674        let response = validate(Json(req)).await;
675        let response = response.into_response();
676
677        assert_eq!(response.status(), axum::http::StatusCode::OK);
678    }
679
680    #[tokio::test]
681    async fn test_calculate_handler_dry_run() {
682        use axum::response::IntoResponse;
683
684        let req = CalculateRequest {
685            file_path: "test-data/budget.yaml".to_string(),
686            dry_run: true,
687        };
688
689        let response = calculate(Json(req)).await;
690        let response = response.into_response();
691
692        assert_eq!(response.status(), axum::http::StatusCode::OK);
693    }
694
695    #[tokio::test]
696    async fn test_calculate_handler_nonexistent() {
697        use axum::response::IntoResponse;
698
699        let req = CalculateRequest {
700            file_path: "/nonexistent/file.yaml".to_string(),
701            dry_run: true,
702        };
703
704        let response = calculate(Json(req)).await;
705        let response = response.into_response();
706
707        assert_eq!(response.status(), axum::http::StatusCode::OK);
708    }
709
710    #[tokio::test]
711    async fn test_audit_handler() {
712        use axum::response::IntoResponse;
713
714        let req = AuditRequest {
715            file_path: "test-data/budget.yaml".to_string(),
716            variable: "profit".to_string(),
717        };
718
719        let response = audit(Json(req)).await;
720        let response = response.into_response();
721
722        assert_eq!(response.status(), axum::http::StatusCode::OK);
723    }
724
725    #[tokio::test]
726    async fn test_audit_handler_nonexistent() {
727        use axum::response::IntoResponse;
728
729        let req = AuditRequest {
730            file_path: "/nonexistent/file.yaml".to_string(),
731            variable: "test".to_string(),
732        };
733
734        let response = audit(Json(req)).await;
735        let response = response.into_response();
736
737        assert_eq!(response.status(), axum::http::StatusCode::OK);
738    }
739
740    #[tokio::test]
741    async fn test_export_handler() {
742        use axum::response::IntoResponse;
743        use tempfile::TempDir;
744
745        let temp_dir = TempDir::new().unwrap();
746        let output_path = temp_dir.path().join("test_export.xlsx");
747
748        let req = ExportRequest {
749            yaml_path: "test-data/budget.yaml".to_string(),
750            excel_path: output_path.to_string_lossy().to_string(),
751        };
752
753        let response = export(Json(req)).await;
754        let response = response.into_response();
755
756        assert_eq!(response.status(), axum::http::StatusCode::OK);
757    }
758
759    #[tokio::test]
760    async fn test_export_handler_nonexistent() {
761        use axum::response::IntoResponse;
762
763        let req = ExportRequest {
764            yaml_path: "/nonexistent/file.yaml".to_string(),
765            excel_path: "/tmp/test.xlsx".to_string(),
766        };
767
768        let response = export(Json(req)).await;
769        let response = response.into_response();
770
771        assert_eq!(response.status(), axum::http::StatusCode::OK);
772    }
773
774    #[tokio::test]
775    async fn test_import_handler_nonexistent() {
776        use axum::response::IntoResponse;
777
778        let req = ImportRequest {
779            excel_path: "/nonexistent/file.xlsx".to_string(),
780            yaml_path: "/tmp/test.yaml".to_string(),
781        };
782
783        let response = import_excel(Json(req)).await;
784        let response = response.into_response();
785
786        assert_eq!(response.status(), axum::http::StatusCode::OK);
787    }
788}