mockforge_http/
coverage.rs

1//! Mock Coverage Tracking
2//!
3//! This module provides API coverage tracking functionality, allowing users to see
4//! which endpoints from their OpenAPI spec have been exercised during testing.
5//! This is analogous to code coverage but for API surface area.
6use 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/// Coverage information for a single route
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RouteCoverage {
19    /// HTTP method (GET, POST, etc.)
20    pub method: String,
21    /// Route path template
22    pub path: String,
23    /// Operation ID from OpenAPI spec
24    pub operation_id: Option<String>,
25    /// Operation summary
26    pub summary: Option<String>,
27    /// Whether this route has been called
28    pub covered: bool,
29    /// Number of times this route has been called
30    pub hit_count: u64,
31    /// Breakdown by status code
32    pub status_breakdown: HashMap<u16, u64>,
33    /// Average latency in seconds (if called)
34    pub avg_latency_seconds: Option<f64>,
35}
36
37/// Overall coverage report
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CoverageReport {
40    /// Total number of routes defined in the spec
41    pub total_routes: usize,
42    /// Number of routes that have been called
43    pub covered_routes: usize,
44    /// Coverage percentage (0.0 to 100.0)
45    pub coverage_percentage: f64,
46    /// Individual route coverage details
47    pub routes: Vec<RouteCoverage>,
48    /// Coverage breakdown by HTTP method
49    pub method_coverage: HashMap<String, MethodCoverage>,
50    /// Timestamp of the report
51    pub timestamp: String,
52}
53
54/// Coverage statistics for a specific HTTP method
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct MethodCoverage {
57    pub total: usize,
58    pub covered: usize,
59    pub percentage: f64,
60}
61
62/// Query parameters for coverage endpoint
63#[derive(Debug, Deserialize)]
64pub struct CoverageQuery {
65    /// Filter by HTTP method (e.g., "GET", "POST")
66    pub method: Option<String>,
67    /// Filter by path pattern (e.g., "/users")
68    pub path: Option<String>,
69    /// Only show uncovered routes
70    pub uncovered_only: Option<bool>,
71}
72
73/// Calculate coverage for all routes
74pub async fn calculate_coverage(routes: &[RouteInfo]) -> CoverageReport {
75    let metrics_registry = get_global_registry();
76
77    // Gather metrics from Prometheus
78    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        // Check if this route has been hit
90        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        // Get average latency if available
98        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        // Update method stats
109        let method_entry = method_stats.entry(route.method.clone()).or_insert((0, 0));
110        method_entry.0 += 1; // total
111        if covered {
112            method_entry.1 += 1; // covered
113        }
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    // Build method coverage breakdown
135    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
164/// Extract path-based metrics from Prometheus metric families
165fn 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    // Find the requests_by_path_total metric
169    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                // Extract labels
177                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
199/// Get average latency for a specific route
200fn 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
227/// Normalize path to match metrics normalization
228/// This must match the logic in mockforge-observability/src/prometheus/metrics.rs
229fn normalize_path(path: &str) -> String {
230    let mut segments: Vec<&str> = path.split('/').collect();
231
232    for segment in &mut segments {
233        // Replace path parameters like {id} with :id
234        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
246/// Check if a string is a UUID
247fn is_uuid(s: &str) -> bool {
248    s.len() == 36 && s.chars().filter(|&c| c == '-').count() == 4
249}
250
251/// Handler for the coverage endpoint
252pub 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    // Apply filters
259    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        // Coverage will be 0% since no metrics have been recorded in this test
350        assert!(report.coverage_percentage >= 0.0 && report.coverage_percentage <= 100.0);
351    }
352}