Skip to main content

mockforge_http/handlers/
contract_health.rs

1//! Unified contract health timeline
2//!
3//! This module provides a unified endpoint that combines:
4//! - Structural drift incidents
5//! - Semantic drift incidents
6//! - Threat assessments
7//! - Forecast predictions
8
9use axum::{
10    extract::{Query, State},
11    http::StatusCode,
12    response::Json,
13};
14use serde::{Deserialize, Serialize};
15use std::sync::Arc;
16
17#[cfg(feature = "database")]
18use chrono::{DateTime, Utc};
19
20/// State for contract health handlers
21#[derive(Clone)]
22pub struct ContractHealthState {
23    /// Incident manager for structural incidents
24    pub incident_manager: Arc<mockforge_core::incidents::IncidentManager>,
25    /// Semantic incident manager
26    pub semantic_manager: Arc<mockforge_core::incidents::SemanticIncidentManager>,
27    /// Database connection (optional)
28    #[cfg(feature = "database")]
29    pub database: Option<crate::database::Database>,
30}
31
32/// Query parameters for timeline
33#[derive(Debug, Deserialize)]
34pub struct TimelineQuery {
35    /// Workspace ID filter
36    pub workspace_id: Option<String>,
37    /// Endpoint filter
38    pub endpoint: Option<String>,
39    /// Method filter
40    pub method: Option<String>,
41    /// Start date (ISO 8601)
42    pub start_date: Option<String>,
43    /// End date (ISO 8601)
44    pub end_date: Option<String>,
45    /// Filter by type: "structural", "semantic", "threat", "forecast", or "all"
46    pub event_type: Option<String>,
47    /// Limit results
48    pub limit: Option<usize>,
49}
50
51/// Timeline event
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(tag = "type")]
54pub enum TimelineEvent {
55    /// Structural drift incident
56    #[serde(rename = "structural_drift")]
57    StructuralDrift {
58        /// Incident ID
59        id: String,
60        /// Endpoint path
61        endpoint: String,
62        /// HTTP method
63        method: String,
64        /// Type of incident
65        incident_type: String,
66        /// Severity level
67        severity: String,
68        /// Current status
69        status: String,
70        /// Detection timestamp
71        detected_at: i64,
72        /// Additional details
73        details: serde_json::Value,
74    },
75    /// Semantic drift incident
76    #[serde(rename = "semantic_drift")]
77    SemanticDrift {
78        /// Incident ID
79        id: String,
80        /// Endpoint path
81        endpoint: String,
82        /// HTTP method
83        method: String,
84        /// Type of semantic change
85        change_type: String,
86        /// Severity level
87        severity: String,
88        /// Current status
89        status: String,
90        /// Semantic confidence score
91        semantic_confidence: f64,
92        /// Soft-breaking score
93        soft_breaking_score: f64,
94        /// Detection timestamp
95        detected_at: i64,
96        /// Additional details
97        details: serde_json::Value,
98    },
99    /// Threat assessment
100    #[serde(rename = "threat_assessment")]
101    ThreatAssessment {
102        /// Assessment ID
103        id: String,
104        /// Endpoint path (optional for workspace-level)
105        endpoint: Option<String>,
106        /// HTTP method (optional for workspace-level)
107        method: Option<String>,
108        /// Threat level
109        threat_level: String,
110        /// Threat score (0.0-1.0)
111        threat_score: f64,
112        /// Assessment timestamp
113        assessed_at: i64,
114        /// Number of findings
115        findings_count: usize,
116    },
117    /// Forecast prediction
118    #[serde(rename = "forecast")]
119    Forecast {
120        /// Forecast ID
121        id: String,
122        /// Endpoint path
123        endpoint: String,
124        /// HTTP method
125        method: String,
126        /// Forecast window in days
127        window_days: u32,
128        /// Change probability (0.0-1.0)
129        change_probability: f64,
130        /// Break probability (0.0-1.0)
131        break_probability: f64,
132        /// Next expected change timestamp (optional)
133        next_expected_change: Option<i64>,
134        /// Confidence score (0.0-1.0)
135        confidence: f64,
136        /// Prediction timestamp
137        predicted_at: i64,
138    },
139}
140
141/// Timeline response
142#[derive(Debug, Serialize)]
143pub struct TimelineResponse {
144    /// Timeline events
145    pub events: Vec<TimelineEvent>,
146    /// Total count
147    pub total: usize,
148}
149
150/// Get unified contract health timeline
151///
152/// GET /api/v1/contract-health/timeline
153pub async fn get_timeline(
154    State(state): State<ContractHealthState>,
155    Query(params): Query<TimelineQuery>,
156) -> Result<Json<TimelineResponse>, StatusCode> {
157    let mut events = Vec::new();
158
159    let event_type_filter = params.event_type.as_deref().unwrap_or("all");
160
161    // Get structural drift incidents
162    if event_type_filter == "all" || event_type_filter == "structural" {
163        let query = mockforge_core::incidents::types::IncidentQuery {
164            workspace_id: params.workspace_id.clone(),
165            endpoint: params.endpoint.clone(),
166            method: params.method.clone(),
167            ..mockforge_core::incidents::types::IncidentQuery::default()
168        };
169
170        let incidents = state.incident_manager.query_incidents(query).await;
171        for incident in incidents {
172            events.push(TimelineEvent::StructuralDrift {
173                id: incident.id,
174                endpoint: incident.endpoint,
175                method: incident.method,
176                incident_type: format!("{:?}", incident.incident_type),
177                severity: format!("{:?}", incident.severity),
178                status: format!("{:?}", incident.status),
179                detected_at: incident.detected_at,
180                details: incident.details,
181            });
182        }
183    }
184
185    // Get semantic drift incidents
186    if event_type_filter == "all" || event_type_filter == "semantic" {
187        let status = None; // Get all statuses for timeline
188        let semantic_incidents = state
189            .semantic_manager
190            .list_incidents(
191                params.workspace_id.as_deref(),
192                params.endpoint.as_deref(),
193                params.method.as_deref(),
194                status,
195                params.limit,
196            )
197            .await;
198
199        for incident in semantic_incidents {
200            events.push(TimelineEvent::SemanticDrift {
201                id: incident.id,
202                endpoint: incident.endpoint,
203                method: incident.method,
204                change_type: format!("{:?}", incident.semantic_change_type),
205                severity: format!("{:?}", incident.severity),
206                status: format!("{:?}", incident.status),
207                semantic_confidence: incident.semantic_confidence,
208                soft_breaking_score: incident.soft_breaking_score,
209                detected_at: incident.detected_at,
210                details: incident.details,
211            });
212        }
213    }
214
215    // Add threat assessments and forecasts from database
216    #[cfg(feature = "database")]
217    {
218        use sqlx::Row;
219        if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
220            // Query threat assessments
221            if let Ok(ta_rows) = sqlx::query(
222            "SELECT id, workspace_id, service_id, service_name, endpoint, method, aggregation_level,
223             threat_level, threat_score, threat_categories, findings, remediation_suggestions, assessed_at
224             FROM contract_threat_assessments
225             WHERE workspace_id = $1 OR workspace_id IS NULL
226             ORDER BY assessed_at DESC LIMIT 50"
227        )
228        .bind(params.workspace_id.as_deref())
229        .fetch_all(pool)
230        .await
231        {
232            use mockforge_foundation::threat_modeling_types::ThreatLevel;
233            for row in ta_rows {
234                let id: uuid::Uuid = match row.try_get("id") {
235                    Ok(id) => id,
236                    Err(_) => continue,
237                };
238                let threat_level_str: String = match row.try_get("threat_level") {
239                    Ok(s) => s,
240                    Err(_) => continue,
241                };
242                let threat_score: f64 = match row.try_get("threat_score") {
243                    Ok(s) => s,
244                    Err(_) => continue,
245                };
246                let assessed_at: DateTime<Utc> = match row.try_get("assessed_at") {
247                    Ok(dt) => dt,
248                    Err(_) => continue,
249                };
250                let endpoint: Option<String> = row.try_get("endpoint").ok();
251                let method: Option<String> = row.try_get("method").ok();
252
253                let threat_level = match threat_level_str.as_str() {
254                    "low" => ThreatLevel::Low,
255                    "medium" => ThreatLevel::Medium,
256                    "high" => ThreatLevel::High,
257                    "critical" => ThreatLevel::Critical,
258                    _ => continue,
259                };
260
261                // Count findings from the findings JSON field
262                let findings_count = row.try_get::<serde_json::Value, _>("findings")
263                    .ok()
264                    .and_then(|v| v.as_array().map(|arr| arr.len()))
265                    .unwrap_or(0);
266
267                events.push(TimelineEvent::ThreatAssessment {
268                    id: id.to_string(),
269                    endpoint,
270                    method,
271                    threat_level: format!("{:?}", threat_level),
272                    threat_score,
273                    assessed_at: assessed_at.timestamp(),
274                    findings_count,
275                });
276            }
277        }
278
279            // Query forecasts
280            if let Ok(forecast_rows) = sqlx::query(
281                "SELECT id, service_id, service_name, endpoint, method, forecast_window_days,
282             predicted_change_probability, predicted_break_probability, next_expected_change_date,
283             confidence, predicted_at
284             FROM api_change_forecasts
285             WHERE workspace_id = $1 OR workspace_id IS NULL
286             ORDER BY predicted_at DESC LIMIT 50",
287            )
288            .bind(params.workspace_id.as_deref())
289            .fetch_all(pool)
290            .await
291            {
292                use sqlx::Row;
293                for row in forecast_rows {
294                    let id: uuid::Uuid = match row.try_get("id") {
295                        Ok(id) => id,
296                        Err(_) => continue,
297                    };
298                    let endpoint: String = match row.try_get("endpoint") {
299                        Ok(e) => e,
300                        Err(_) => continue,
301                    };
302                    let method: String = match row.try_get("method") {
303                        Ok(m) => m,
304                        Err(_) => continue,
305                    };
306                    let forecast_window_days: i32 = match row.try_get("forecast_window_days") {
307                        Ok(d) => d,
308                        Err(_) => continue,
309                    };
310                    let predicted_change_probability: f64 =
311                        match row.try_get("predicted_change_probability") {
312                            Ok(p) => p,
313                            Err(_) => continue,
314                        };
315                    let predicted_break_probability: f64 =
316                        match row.try_get("predicted_break_probability") {
317                            Ok(p) => p,
318                            Err(_) => continue,
319                        };
320                    let next_expected_change_date: Option<DateTime<Utc>> =
321                        row.try_get("next_expected_change_date").ok();
322                    let predicted_at: DateTime<Utc> = match row.try_get("predicted_at") {
323                        Ok(dt) => dt,
324                        Err(_) => continue,
325                    };
326                    let confidence: f64 = match row.try_get("confidence") {
327                        Ok(c) => c,
328                        Err(_) => continue,
329                    };
330
331                    events.push(TimelineEvent::Forecast {
332                        id: id.to_string(),
333                        endpoint,
334                        method,
335                        window_days: forecast_window_days as u32,
336                        change_probability: predicted_change_probability,
337                        break_probability: predicted_break_probability,
338                        next_expected_change: next_expected_change_date.map(|d| d.timestamp()),
339                        confidence,
340                        predicted_at: predicted_at.timestamp(),
341                    });
342                }
343            }
344        }
345    }
346
347    // Sort by timestamp (most recent first)
348    events.sort_by_key(|e| {
349        std::cmp::Reverse(match e {
350            TimelineEvent::StructuralDrift { detected_at, .. } => *detected_at,
351            TimelineEvent::SemanticDrift { detected_at, .. } => *detected_at,
352            TimelineEvent::ThreatAssessment { assessed_at, .. } => *assessed_at,
353            TimelineEvent::Forecast { predicted_at, .. } => *predicted_at,
354        })
355    });
356
357    // Apply limit
358    let total = events.len();
359    if let Some(limit) = params.limit {
360        events.truncate(limit);
361    }
362
363    Ok(Json(TimelineResponse { events, total }))
364}
365
366/// Create router for contract health endpoints
367pub fn contract_health_router(state: ContractHealthState) -> axum::Router {
368    use axum::routing::get;
369    use axum::Router;
370
371    Router::new()
372        .route("/api/v1/contract-health/timeline", get(get_timeline))
373        .with_state(state)
374}