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    /// Total number of routes for this HTTP method
58    pub total: usize,
59    /// Number of routes that have been covered (called at least once)
60    pub covered: usize,
61    /// Coverage percentage (0.0 to 100.0)
62    pub percentage: f64,
63}
64
65/// Query parameters for coverage endpoint
66#[derive(Debug, Deserialize)]
67pub struct CoverageQuery {
68    /// Filter by HTTP method (e.g., "GET", "POST")
69    pub method: Option<String>,
70    /// Filter by path pattern (e.g., "/users")
71    pub path: Option<String>,
72    /// Only show uncovered routes
73    pub uncovered_only: Option<bool>,
74}
75
76/// Calculate coverage for all routes
77pub async fn calculate_coverage(routes: &[RouteInfo]) -> CoverageReport {
78    let metrics_registry = get_global_registry();
79
80    // Gather metrics from Prometheus
81    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        // Check if this route has been hit
93        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        // Get average latency if available
101        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        // Update method stats
112        let method_entry = method_stats.entry(route.method.clone()).or_insert((0, 0));
113        method_entry.0 += 1; // total
114        if covered {
115            method_entry.1 += 1; // covered
116        }
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    // Build method coverage breakdown
138    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
167/// Extract path-based metrics from Prometheus metric families
168fn 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    // Find the requests_by_path_total metric
172    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                // Extract labels
180                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
202/// Get average latency for a specific route
203fn 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
230/// Normalize path to match metrics normalization
231/// This must match the logic in mockforge-observability/src/prometheus/metrics.rs
232fn normalize_path(path: &str) -> String {
233    let mut segments: Vec<&str> = path.split('/').collect();
234
235    for segment in &mut segments {
236        // Replace path parameters like {id} with :id
237        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
249/// Check if a string is a UUID
250fn is_uuid(s: &str) -> bool {
251    s.len() == 36 && s.chars().filter(|&c| c == '-').count() == 4
252}
253
254/// Handler for the coverage endpoint
255pub 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    // Apply filters
262    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        // Coverage will be 0% since no metrics have been recorded in this test
353        assert!(report.coverage_percentage >= 0.0 && report.coverage_percentage <= 100.0);
354    }
355}