1use 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#[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#[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
69pub 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#[derive(Serialize)]
118pub struct HealthResponse {
119 pub status: String,
120 pub uptime_message: String,
121}
122
123pub 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#[derive(Serialize)]
133pub struct VersionResponse {
134 pub version: String,
135 pub features: Vec<String>,
136}
137
138pub 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#[derive(Deserialize)]
154pub struct ValidateRequest {
155 pub file_path: String,
156}
157
158#[derive(Serialize, Default)]
160pub struct ValidateResponse {
161 pub valid: bool,
162 pub file_path: String,
163 pub message: String,
164}
165
166pub 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#[derive(Deserialize)]
186pub struct CalculateRequest {
187 pub file_path: String,
188 #[serde(default)]
189 pub dry_run: bool,
190}
191
192#[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
201pub 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#[derive(Deserialize)]
228pub struct AuditRequest {
229 pub file_path: String,
230 pub variable: String,
231}
232
233#[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
242pub 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#[derive(Deserialize)]
265pub struct ExportRequest {
266 pub yaml_path: String,
267 pub excel_path: String,
268}
269
270#[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
279pub 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#[derive(Deserialize)]
302pub struct ImportRequest {
303 pub excel_path: String,
304 pub yaml_path: String,
305}
306
307#[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
316pub 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 #[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 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 #[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 #[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 #[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 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 assert!(!json.contains("\"data\""));
566 assert!(json.contains("\"success\":false"));
567 assert!(json.contains("\"error\":\"error message\""));
568 }
569
570 #[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 #[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 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}