1use axum::{
7 extract::{Query, State},
8 response::Json,
9};
10use mockforge_observability::prometheus::{get_global_registry, MetricFamily};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14use crate::{HttpServerState, RouteInfo};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RouteCoverage {
19 pub method: String,
21 pub path: String,
23 pub operation_id: Option<String>,
25 pub summary: Option<String>,
27 pub covered: bool,
29 pub hit_count: u64,
31 pub status_breakdown: HashMap<u16, u64>,
33 pub avg_latency_seconds: Option<f64>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CoverageReport {
40 pub total_routes: usize,
42 pub covered_routes: usize,
44 pub coverage_percentage: f64,
46 pub routes: Vec<RouteCoverage>,
48 pub method_coverage: HashMap<String, MethodCoverage>,
50 pub timestamp: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct MethodCoverage {
57 pub total: usize,
59 pub covered: usize,
61 pub percentage: f64,
63}
64
65#[derive(Debug, Deserialize)]
67pub struct CoverageQuery {
68 pub method: Option<String>,
70 pub path: Option<String>,
72 pub uncovered_only: Option<bool>,
74}
75
76pub async fn calculate_coverage(routes: &[RouteInfo]) -> CoverageReport {
78 let metrics_registry = get_global_registry();
79
80 let metric_families = metrics_registry.registry().gather();
82 let path_metrics = extract_path_metrics(&metric_families);
83
84 let mut route_coverages = Vec::new();
85 let mut covered_count = 0;
86 let mut method_stats: HashMap<String, (usize, usize)> = HashMap::new();
87
88 for route in routes {
89 let normalized_path = normalize_path(&route.path);
90 let key = format!("{} {}", route.method, normalized_path);
91
92 let (covered, hit_count, status_breakdown) = if let Some(metrics) = path_metrics.get(&key) {
94 let total_hits: u64 = metrics.values().sum();
95 (total_hits > 0, total_hits, metrics.clone())
96 } else {
97 (false, 0, HashMap::new())
98 };
99
100 let avg_latency = if covered {
102 get_average_latency(&metric_families, &normalized_path, &route.method)
103 } else {
104 None
105 };
106
107 if covered {
108 covered_count += 1;
109 }
110
111 let method_entry = method_stats.entry(route.method.clone()).or_insert((0, 0));
113 method_entry.0 += 1; if covered {
115 method_entry.1 += 1; }
117
118 route_coverages.push(RouteCoverage {
119 method: route.method.clone(),
120 path: route.path.clone(),
121 operation_id: route.operation_id.clone(),
122 summary: route.summary.clone(),
123 covered,
124 hit_count,
125 status_breakdown,
126 avg_latency_seconds: avg_latency,
127 });
128 }
129
130 let total_routes = routes.len();
131 let coverage_percentage = if total_routes > 0 {
132 (covered_count as f64 / total_routes as f64) * 100.0
133 } else {
134 0.0
135 };
136
137 let method_coverage = method_stats
139 .into_iter()
140 .map(|(method, (total, covered))| {
141 let percentage = if total > 0 {
142 (covered as f64 / total as f64) * 100.0
143 } else {
144 0.0
145 };
146 (
147 method,
148 MethodCoverage {
149 total,
150 covered,
151 percentage,
152 },
153 )
154 })
155 .collect();
156
157 CoverageReport {
158 total_routes,
159 covered_routes: covered_count,
160 coverage_percentage,
161 routes: route_coverages,
162 method_coverage,
163 timestamp: chrono::Utc::now().to_rfc3339(),
164 }
165}
166
167fn extract_path_metrics(metric_families: &[MetricFamily]) -> HashMap<String, HashMap<u16, u64>> {
169 let mut path_metrics: HashMap<String, HashMap<u16, u64>> = HashMap::new();
170
171 for mf in metric_families {
173 if mf.name() == "mockforge_requests_by_path_total" {
174 for metric in mf.get_metric() {
175 let mut path = String::new();
176 let mut method = String::new();
177 let mut status = 0u16;
178
179 for label_pair in metric.get_label() {
181 match label_pair.name() {
182 "path" => path = label_pair.value().to_string(),
183 "method" => method = label_pair.value().to_string(),
184 "status" => {
185 status = label_pair.value().parse().unwrap_or(0);
186 }
187 _ => {}
188 }
189 }
190
191 let key = format!("{} {}", method, path);
192 let count = metric.get_counter().value.unwrap_or(0.0) as u64;
193
194 path_metrics.entry(key).or_default().insert(status, count);
195 }
196 }
197 }
198
199 path_metrics
200}
201
202fn get_average_latency(metric_families: &[MetricFamily], path: &str, method: &str) -> Option<f64> {
204 for mf in metric_families {
205 if mf.name() == "mockforge_average_latency_by_path_seconds" {
206 for metric in mf.get_metric() {
207 let mut metric_path = String::new();
208 let mut metric_method = String::new();
209
210 for label_pair in metric.get_label() {
211 match label_pair.name() {
212 "path" => metric_path = label_pair.value().to_string(),
213 "method" => metric_method = label_pair.value().to_string(),
214 _ => {}
215 }
216 }
217
218 if metric_path == path && metric_method == method {
219 if let Some(value) = metric.get_gauge().value {
220 return if value > 0.0 { Some(value) } else { None };
221 }
222 }
223 }
224 }
225 }
226
227 None
228}
229
230fn normalize_path(path: &str) -> String {
233 let mut segments: Vec<&str> = path.split('/').collect();
234
235 for segment in &mut segments {
236 if segment.starts_with('{') && segment.ends_with('}')
238 || is_uuid(segment)
239 || segment.parse::<i64>().is_ok()
240 || (segment.len() > 8 && segment.chars().all(|c| c.is_ascii_hexdigit()))
241 {
242 *segment = ":id";
243 }
244 }
245
246 segments.join("/")
247}
248
249fn is_uuid(s: &str) -> bool {
251 s.len() == 36 && s.chars().filter(|&c| c == '-').count() == 4
252}
253
254pub async fn get_coverage_handler(
256 State(state): State<HttpServerState>,
257 Query(params): Query<CoverageQuery>,
258) -> Json<CoverageReport> {
259 let mut report = calculate_coverage(&state.routes).await;
260
261 if let Some(method_filter) = params.method {
263 report.routes.retain(|r| r.method == method_filter);
264 report.total_routes = report.routes.len();
265 report.covered_routes = report.routes.iter().filter(|r| r.covered).count();
266 report.coverage_percentage = if report.total_routes > 0 {
267 (report.covered_routes as f64 / report.total_routes as f64) * 100.0
268 } else {
269 0.0
270 };
271 }
272
273 if let Some(path_filter) = params.path {
274 report.routes.retain(|r| r.path.contains(&path_filter));
275 report.total_routes = report.routes.len();
276 report.covered_routes = report.routes.iter().filter(|r| r.covered).count();
277 report.coverage_percentage = if report.total_routes > 0 {
278 (report.covered_routes as f64 / report.total_routes as f64) * 100.0
279 } else {
280 0.0
281 };
282 }
283
284 if params.uncovered_only.unwrap_or(false) {
285 report.routes.retain(|r| !r.covered);
286 report.total_routes = report.routes.len();
287 report.covered_routes = 0;
288 report.coverage_percentage = 0.0;
289 }
290
291 Json(report)
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
301 fn test_normalize_path() {
302 assert_eq!(normalize_path("/users/{id}"), "/users/:id");
303 assert_eq!(normalize_path("/users/123"), "/users/:id");
304 assert_eq!(normalize_path("/users/550e8400-e29b-41d4-a716-446655440000"), "/users/:id");
305 assert_eq!(normalize_path("/users/list"), "/users/list");
306 assert_eq!(
307 normalize_path("/api/v1/users/{id}/posts/{postId}"),
308 "/api/v1/users/:id/posts/:id"
309 );
310 }
311
312 #[test]
313 fn test_normalize_path_root() {
314 assert_eq!(normalize_path("/"), "/");
315 }
316
317 #[test]
318 fn test_normalize_path_deep_nested() {
319 assert_eq!(
320 normalize_path("/api/v1/org/{orgId}/team/{teamId}/member/{memberId}"),
321 "/api/v1/org/:id/team/:id/member/:id"
322 );
323 }
324
325 #[test]
326 fn test_normalize_path_no_params() {
327 assert_eq!(normalize_path("/health/check"), "/health/check");
328 assert_eq!(normalize_path("/api/v1/status"), "/api/v1/status");
329 }
330
331 #[test]
332 fn test_normalize_path_hex_ids() {
333 assert_eq!(normalize_path("/objects/abcdef1234567890"), "/objects/:id");
335 }
336
337 #[test]
340 fn test_is_uuid() {
341 assert!(is_uuid("550e8400-e29b-41d4-a716-446655440000"));
342 assert!(!is_uuid("not-a-uuid"));
343 assert!(!is_uuid("123"));
344 }
345
346 #[test]
347 fn test_is_uuid_various_formats() {
348 assert!(is_uuid("00000000-0000-0000-0000-000000000000"));
350 assert!(is_uuid("ffffffff-ffff-ffff-ffff-ffffffffffff"));
351 assert!(is_uuid("12345678-1234-1234-1234-123456789abc"));
352
353 assert!(!is_uuid("12345678-1234-1234-1234-123456789")); assert!(!is_uuid("12345678123412341234123456789abc")); assert!(!is_uuid("")); }
358
359 #[test]
362 fn test_route_coverage_creation() {
363 let coverage = RouteCoverage {
364 method: "GET".to_string(),
365 path: "/users".to_string(),
366 operation_id: Some("getUsers".to_string()),
367 summary: Some("List users".to_string()),
368 covered: true,
369 hit_count: 100,
370 status_breakdown: HashMap::new(),
371 avg_latency_seconds: Some(0.05),
372 };
373
374 assert_eq!(coverage.method, "GET");
375 assert!(coverage.covered);
376 assert_eq!(coverage.hit_count, 100);
377 }
378
379 #[test]
380 fn test_route_coverage_serialization() {
381 let coverage = RouteCoverage {
382 method: "POST".to_string(),
383 path: "/orders".to_string(),
384 operation_id: None,
385 summary: None,
386 covered: false,
387 hit_count: 0,
388 status_breakdown: HashMap::new(),
389 avg_latency_seconds: None,
390 };
391
392 let json = serde_json::to_string(&coverage).unwrap();
393 assert!(json.contains("POST"));
394 assert!(json.contains("/orders"));
395 assert!(json.contains("false"));
396 }
397
398 #[test]
399 fn test_route_coverage_with_status_breakdown() {
400 let mut status_breakdown = HashMap::new();
401 status_breakdown.insert(200, 50);
402 status_breakdown.insert(201, 30);
403 status_breakdown.insert(500, 5);
404
405 let coverage = RouteCoverage {
406 method: "POST".to_string(),
407 path: "/api/data".to_string(),
408 operation_id: Some("createData".to_string()),
409 summary: None,
410 covered: true,
411 hit_count: 85,
412 status_breakdown,
413 avg_latency_seconds: Some(0.1),
414 };
415
416 assert_eq!(coverage.status_breakdown.len(), 3);
417 assert_eq!(coverage.status_breakdown.get(&200), Some(&50));
418 }
419
420 #[test]
421 fn test_route_coverage_clone() {
422 let coverage = RouteCoverage {
423 method: "DELETE".to_string(),
424 path: "/items/{id}".to_string(),
425 operation_id: None,
426 summary: None,
427 covered: true,
428 hit_count: 10,
429 status_breakdown: HashMap::new(),
430 avg_latency_seconds: None,
431 };
432
433 let cloned = coverage.clone();
434 assert_eq!(cloned.method, coverage.method);
435 assert_eq!(cloned.hit_count, coverage.hit_count);
436 }
437
438 #[test]
441 fn test_coverage_report_creation() {
442 let report = CoverageReport {
443 total_routes: 10,
444 covered_routes: 7,
445 coverage_percentage: 70.0,
446 routes: vec![],
447 method_coverage: HashMap::new(),
448 timestamp: "2024-01-15T10:00:00Z".to_string(),
449 };
450
451 assert_eq!(report.total_routes, 10);
452 assert_eq!(report.covered_routes, 7);
453 assert_eq!(report.coverage_percentage, 70.0);
454 }
455
456 #[test]
457 fn test_coverage_report_serialization() {
458 let report = CoverageReport {
459 total_routes: 5,
460 covered_routes: 3,
461 coverage_percentage: 60.0,
462 routes: vec![],
463 method_coverage: HashMap::new(),
464 timestamp: "2024-01-15T10:00:00Z".to_string(),
465 };
466
467 let json = serde_json::to_string(&report).unwrap();
468 assert!(json.contains("60.0"));
469 assert!(json.contains("total_routes"));
470 }
471
472 #[test]
473 fn test_coverage_report_clone() {
474 let report = CoverageReport {
475 total_routes: 20,
476 covered_routes: 15,
477 coverage_percentage: 75.0,
478 routes: vec![],
479 method_coverage: HashMap::new(),
480 timestamp: "2024-01-15T10:00:00Z".to_string(),
481 };
482
483 let cloned = report.clone();
484 assert_eq!(cloned.total_routes, report.total_routes);
485 assert_eq!(cloned.coverage_percentage, report.coverage_percentage);
486 }
487
488 #[test]
491 fn test_method_coverage_creation() {
492 let coverage = MethodCoverage {
493 total: 10,
494 covered: 8,
495 percentage: 80.0,
496 };
497
498 assert_eq!(coverage.total, 10);
499 assert_eq!(coverage.covered, 8);
500 assert_eq!(coverage.percentage, 80.0);
501 }
502
503 #[test]
504 fn test_method_coverage_serialization() {
505 let coverage = MethodCoverage {
506 total: 5,
507 covered: 5,
508 percentage: 100.0,
509 };
510
511 let json = serde_json::to_string(&coverage).unwrap();
512 assert!(json.contains("100.0"));
513 }
514
515 #[test]
516 fn test_method_coverage_clone() {
517 let coverage = MethodCoverage {
518 total: 3,
519 covered: 2,
520 percentage: 66.67,
521 };
522
523 let cloned = coverage.clone();
524 assert_eq!(cloned.total, coverage.total);
525 }
526
527 #[test]
530 fn test_coverage_query_empty() {
531 let query = CoverageQuery {
532 method: None,
533 path: None,
534 uncovered_only: None,
535 };
536
537 assert!(query.method.is_none());
538 assert!(query.path.is_none());
539 assert!(query.uncovered_only.is_none());
540 }
541
542 #[test]
543 fn test_coverage_query_with_method_filter() {
544 let query = CoverageQuery {
545 method: Some("GET".to_string()),
546 path: None,
547 uncovered_only: None,
548 };
549
550 assert_eq!(query.method, Some("GET".to_string()));
551 }
552
553 #[test]
554 fn test_coverage_query_with_path_filter() {
555 let query = CoverageQuery {
556 method: None,
557 path: Some("/users".to_string()),
558 uncovered_only: None,
559 };
560
561 assert_eq!(query.path, Some("/users".to_string()));
562 }
563
564 #[test]
565 fn test_coverage_query_uncovered_only() {
566 let query = CoverageQuery {
567 method: None,
568 path: None,
569 uncovered_only: Some(true),
570 };
571
572 assert_eq!(query.uncovered_only, Some(true));
573 }
574
575 #[tokio::test]
578 async fn test_calculate_coverage_empty() {
579 let routes = vec![];
580 let report = calculate_coverage(&routes).await;
581
582 assert_eq!(report.total_routes, 0);
583 assert_eq!(report.covered_routes, 0);
584 assert_eq!(report.coverage_percentage, 0.0);
585 }
586
587 #[tokio::test]
588 async fn test_calculate_coverage_with_routes() {
589 let routes = vec![
590 RouteInfo {
591 method: "GET".to_string(),
592 path: "/users".to_string(),
593 operation_id: Some("getUsers".to_string()),
594 summary: Some("Get all users".to_string()),
595 description: None,
596 parameters: vec![],
597 },
598 RouteInfo {
599 method: "POST".to_string(),
600 path: "/users".to_string(),
601 operation_id: Some("createUser".to_string()),
602 summary: Some("Create a user".to_string()),
603 description: None,
604 parameters: vec![],
605 },
606 ];
607
608 let report = calculate_coverage(&routes).await;
609
610 assert_eq!(report.total_routes, 2);
611 assert_eq!(report.routes.len(), 2);
612 assert!(report.coverage_percentage >= 0.0 && report.coverage_percentage <= 100.0);
614 }
615
616 #[tokio::test]
617 async fn test_calculate_coverage_single_route() {
618 let routes = vec![RouteInfo {
619 method: "GET".to_string(),
620 path: "/health".to_string(),
621 operation_id: Some("healthCheck".to_string()),
622 summary: None,
623 description: None,
624 parameters: vec![],
625 }];
626
627 let report = calculate_coverage(&routes).await;
628
629 assert_eq!(report.total_routes, 1);
630 assert_eq!(report.routes.len(), 1);
631 assert_eq!(report.routes[0].method, "GET");
632 assert_eq!(report.routes[0].path, "/health");
633 }
634
635 #[tokio::test]
636 async fn test_calculate_coverage_method_breakdown() {
637 let routes = vec![
638 RouteInfo {
639 method: "GET".to_string(),
640 path: "/users".to_string(),
641 operation_id: None,
642 summary: None,
643 description: None,
644 parameters: vec![],
645 },
646 RouteInfo {
647 method: "GET".to_string(),
648 path: "/users/{id}".to_string(),
649 operation_id: None,
650 summary: None,
651 description: None,
652 parameters: vec![],
653 },
654 RouteInfo {
655 method: "POST".to_string(),
656 path: "/users".to_string(),
657 operation_id: None,
658 summary: None,
659 description: None,
660 parameters: vec![],
661 },
662 ];
663
664 let report = calculate_coverage(&routes).await;
665
666 assert_eq!(report.total_routes, 3);
667 assert!(report.method_coverage.contains_key("GET"));
668 assert!(report.method_coverage.contains_key("POST"));
669 assert_eq!(report.method_coverage.get("GET").unwrap().total, 2);
670 assert_eq!(report.method_coverage.get("POST").unwrap().total, 1);
671 }
672
673 #[test]
676 fn test_route_coverage_debug() {
677 let coverage = RouteCoverage {
678 method: "GET".to_string(),
679 path: "/test".to_string(),
680 operation_id: None,
681 summary: None,
682 covered: false,
683 hit_count: 0,
684 status_breakdown: HashMap::new(),
685 avg_latency_seconds: None,
686 };
687
688 let debug = format!("{:?}", coverage);
689 assert!(debug.contains("RouteCoverage"));
690 }
691
692 #[test]
693 fn test_coverage_report_debug() {
694 let report = CoverageReport {
695 total_routes: 0,
696 covered_routes: 0,
697 coverage_percentage: 0.0,
698 routes: vec![],
699 method_coverage: HashMap::new(),
700 timestamp: "test".to_string(),
701 };
702
703 let debug = format!("{:?}", report);
704 assert!(debug.contains("CoverageReport"));
705 }
706
707 #[test]
708 fn test_method_coverage_debug() {
709 let coverage = MethodCoverage {
710 total: 5,
711 covered: 3,
712 percentage: 60.0,
713 };
714
715 let debug = format!("{:?}", coverage);
716 assert!(debug.contains("MethodCoverage"));
717 }
718
719 #[test]
720 fn test_coverage_query_debug() {
721 let query = CoverageQuery {
722 method: Some("GET".to_string()),
723 path: None,
724 uncovered_only: None,
725 };
726
727 let debug = format!("{:?}", query);
728 assert!(debug.contains("CoverageQuery"));
729 }
730}