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]
299 fn test_normalize_path() {
300 assert_eq!(normalize_path("/users/{id}"), "/users/:id");
301 assert_eq!(normalize_path("/users/123"), "/users/:id");
302 assert_eq!(normalize_path("/users/550e8400-e29b-41d4-a716-446655440000"), "/users/:id");
303 assert_eq!(normalize_path("/users/list"), "/users/list");
304 assert_eq!(
305 normalize_path("/api/v1/users/{id}/posts/{postId}"),
306 "/api/v1/users/:id/posts/:id"
307 );
308 }
309
310 #[test]
311 fn test_is_uuid() {
312 assert!(is_uuid("550e8400-e29b-41d4-a716-446655440000"));
313 assert!(!is_uuid("not-a-uuid"));
314 assert!(!is_uuid("123"));
315 }
316
317 #[tokio::test]
318 async fn test_calculate_coverage_empty() {
319 let routes = vec![];
320 let report = calculate_coverage(&routes).await;
321
322 assert_eq!(report.total_routes, 0);
323 assert_eq!(report.covered_routes, 0);
324 assert_eq!(report.coverage_percentage, 0.0);
325 }
326
327 #[tokio::test]
328 async fn test_calculate_coverage_with_routes() {
329 let routes = vec![
330 RouteInfo {
331 method: "GET".to_string(),
332 path: "/users".to_string(),
333 operation_id: Some("getUsers".to_string()),
334 summary: Some("Get all users".to_string()),
335 description: None,
336 parameters: vec![],
337 },
338 RouteInfo {
339 method: "POST".to_string(),
340 path: "/users".to_string(),
341 operation_id: Some("createUser".to_string()),
342 summary: Some("Create a user".to_string()),
343 description: None,
344 parameters: vec![],
345 },
346 ];
347
348 let report = calculate_coverage(&routes).await;
349
350 assert_eq!(report.total_routes, 2);
351 assert_eq!(report.routes.len(), 2);
352 assert!(report.coverage_percentage >= 0.0 && report.coverage_percentage <= 100.0);
354 }
355}