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,
58 pub covered: usize,
59 pub percentage: f64,
60}
61
62#[derive(Debug, Deserialize)]
64pub struct CoverageQuery {
65 pub method: Option<String>,
67 pub path: Option<String>,
69 pub uncovered_only: Option<bool>,
71}
72
73pub async fn calculate_coverage(routes: &[RouteInfo]) -> CoverageReport {
75 let metrics_registry = get_global_registry();
76
77 let metric_families = metrics_registry.registry().gather();
79 let path_metrics = extract_path_metrics(&metric_families);
80
81 let mut route_coverages = Vec::new();
82 let mut covered_count = 0;
83 let mut method_stats: HashMap<String, (usize, usize)> = HashMap::new();
84
85 for route in routes {
86 let normalized_path = normalize_path(&route.path);
87 let key = format!("{} {}", route.method, normalized_path);
88
89 let (covered, hit_count, status_breakdown) = if let Some(metrics) = path_metrics.get(&key) {
91 let total_hits: u64 = metrics.values().sum();
92 (total_hits > 0, total_hits, metrics.clone())
93 } else {
94 (false, 0, HashMap::new())
95 };
96
97 let avg_latency = if covered {
99 get_average_latency(&metric_families, &normalized_path, &route.method)
100 } else {
101 None
102 };
103
104 if covered {
105 covered_count += 1;
106 }
107
108 let method_entry = method_stats.entry(route.method.clone()).or_insert((0, 0));
110 method_entry.0 += 1; if covered {
112 method_entry.1 += 1; }
114
115 route_coverages.push(RouteCoverage {
116 method: route.method.clone(),
117 path: route.path.clone(),
118 operation_id: route.operation_id.clone(),
119 summary: route.summary.clone(),
120 covered,
121 hit_count,
122 status_breakdown,
123 avg_latency_seconds: avg_latency,
124 });
125 }
126
127 let total_routes = routes.len();
128 let coverage_percentage = if total_routes > 0 {
129 (covered_count as f64 / total_routes as f64) * 100.0
130 } else {
131 0.0
132 };
133
134 let method_coverage = method_stats
136 .into_iter()
137 .map(|(method, (total, covered))| {
138 let percentage = if total > 0 {
139 (covered as f64 / total as f64) * 100.0
140 } else {
141 0.0
142 };
143 (
144 method,
145 MethodCoverage {
146 total,
147 covered,
148 percentage,
149 },
150 )
151 })
152 .collect();
153
154 CoverageReport {
155 total_routes,
156 covered_routes: covered_count,
157 coverage_percentage,
158 routes: route_coverages,
159 method_coverage,
160 timestamp: chrono::Utc::now().to_rfc3339(),
161 }
162}
163
164fn extract_path_metrics(metric_families: &[MetricFamily]) -> HashMap<String, HashMap<u16, u64>> {
166 let mut path_metrics: HashMap<String, HashMap<u16, u64>> = HashMap::new();
167
168 for mf in metric_families {
170 if mf.name() == "mockforge_requests_by_path_total" {
171 for metric in mf.get_metric() {
172 let mut path = String::new();
173 let mut method = String::new();
174 let mut status = 0u16;
175
176 for label_pair in metric.get_label() {
178 match label_pair.name() {
179 "path" => path = label_pair.value().to_string(),
180 "method" => method = label_pair.value().to_string(),
181 "status" => {
182 status = label_pair.value().parse().unwrap_or(0);
183 }
184 _ => {}
185 }
186 }
187
188 let key = format!("{} {}", method, path);
189 let count = metric.get_counter().value.unwrap_or(0.0) as u64;
190
191 path_metrics.entry(key).or_default().insert(status, count);
192 }
193 }
194 }
195
196 path_metrics
197}
198
199fn get_average_latency(metric_families: &[MetricFamily], path: &str, method: &str) -> Option<f64> {
201 for mf in metric_families {
202 if mf.name() == "mockforge_average_latency_by_path_seconds" {
203 for metric in mf.get_metric() {
204 let mut metric_path = String::new();
205 let mut metric_method = String::new();
206
207 for label_pair in metric.get_label() {
208 match label_pair.name() {
209 "path" => metric_path = label_pair.value().to_string(),
210 "method" => metric_method = label_pair.value().to_string(),
211 _ => {}
212 }
213 }
214
215 if metric_path == path && metric_method == method {
216 if let Some(value) = metric.get_gauge().value {
217 return if value > 0.0 { Some(value) } else { None };
218 }
219 }
220 }
221 }
222 }
223
224 None
225}
226
227fn normalize_path(path: &str) -> String {
230 let mut segments: Vec<&str> = path.split('/').collect();
231
232 for segment in &mut segments {
233 if segment.starts_with('{') && segment.ends_with('}')
235 || is_uuid(segment)
236 || segment.parse::<i64>().is_ok()
237 || (segment.len() > 8 && segment.chars().all(|c| c.is_ascii_hexdigit()))
238 {
239 *segment = ":id";
240 }
241 }
242
243 segments.join("/")
244}
245
246fn is_uuid(s: &str) -> bool {
248 s.len() == 36 && s.chars().filter(|&c| c == '-').count() == 4
249}
250
251pub async fn get_coverage_handler(
253 State(state): State<HttpServerState>,
254 Query(params): Query<CoverageQuery>,
255) -> Json<CoverageReport> {
256 let mut report = calculate_coverage(&state.routes).await;
257
258 if let Some(method_filter) = params.method {
260 report.routes.retain(|r| r.method == method_filter);
261 report.total_routes = report.routes.len();
262 report.covered_routes = report.routes.iter().filter(|r| r.covered).count();
263 report.coverage_percentage = if report.total_routes > 0 {
264 (report.covered_routes as f64 / report.total_routes as f64) * 100.0
265 } else {
266 0.0
267 };
268 }
269
270 if let Some(path_filter) = params.path {
271 report.routes.retain(|r| r.path.contains(&path_filter));
272 report.total_routes = report.routes.len();
273 report.covered_routes = report.routes.iter().filter(|r| r.covered).count();
274 report.coverage_percentage = if report.total_routes > 0 {
275 (report.covered_routes as f64 / report.total_routes as f64) * 100.0
276 } else {
277 0.0
278 };
279 }
280
281 if params.uncovered_only.unwrap_or(false) {
282 report.routes.retain(|r| !r.covered);
283 report.total_routes = report.routes.len();
284 report.covered_routes = 0;
285 report.coverage_percentage = 0.0;
286 }
287
288 Json(report)
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn test_normalize_path() {
297 assert_eq!(normalize_path("/users/{id}"), "/users/:id");
298 assert_eq!(normalize_path("/users/123"), "/users/:id");
299 assert_eq!(normalize_path("/users/550e8400-e29b-41d4-a716-446655440000"), "/users/:id");
300 assert_eq!(normalize_path("/users/list"), "/users/list");
301 assert_eq!(
302 normalize_path("/api/v1/users/{id}/posts/{postId}"),
303 "/api/v1/users/:id/posts/:id"
304 );
305 }
306
307 #[test]
308 fn test_is_uuid() {
309 assert!(is_uuid("550e8400-e29b-41d4-a716-446655440000"));
310 assert!(!is_uuid("not-a-uuid"));
311 assert!(!is_uuid("123"));
312 }
313
314 #[tokio::test]
315 async fn test_calculate_coverage_empty() {
316 let routes = vec![];
317 let report = calculate_coverage(&routes).await;
318
319 assert_eq!(report.total_routes, 0);
320 assert_eq!(report.covered_routes, 0);
321 assert_eq!(report.coverage_percentage, 0.0);
322 }
323
324 #[tokio::test]
325 async fn test_calculate_coverage_with_routes() {
326 let routes = vec![
327 RouteInfo {
328 method: "GET".to_string(),
329 path: "/users".to_string(),
330 operation_id: Some("getUsers".to_string()),
331 summary: Some("Get all users".to_string()),
332 description: None,
333 parameters: vec![],
334 },
335 RouteInfo {
336 method: "POST".to_string(),
337 path: "/users".to_string(),
338 operation_id: Some("createUser".to_string()),
339 summary: Some("Create a user".to_string()),
340 description: None,
341 parameters: vec![],
342 },
343 ];
344
345 let report = calculate_coverage(&routes).await;
346
347 assert_eq!(report.total_routes, 2);
348 assert_eq!(report.routes.len(), 2);
349 assert!(report.coverage_percentage >= 0.0 && report.coverage_percentage <= 100.0);
351 }
352}