Skip to main content

mockforge_http/handlers/
semantic_drift.rs

1//! HTTP handlers for semantic drift incidents
2//!
3//! This module provides endpoints for managing semantic drift incidents.
4
5use axum::{
6    extract::{Path, Query, State},
7    http::StatusCode,
8    response::Json,
9};
10use mockforge_core::ai_contract_diff::{ContractDiffAnalyzer, ContractDiffConfig};
11use mockforge_core::incidents::semantic_manager::{SemanticIncident, SemanticIncidentManager};
12use mockforge_core::openapi::OpenApiSpec;
13use serde::{Deserialize, Serialize};
14use std::sync::Arc;
15
16#[cfg(feature = "database")]
17use chrono::{DateTime, Utc};
18#[cfg(feature = "database")]
19use uuid::Uuid;
20
21use crate::database::Database;
22
23/// Helper function to map database row to SemanticIncident
24#[cfg(feature = "database")]
25fn map_row_to_semantic_incident(
26    row: &sqlx::postgres::PgRow,
27) -> Result<SemanticIncident, sqlx::Error> {
28    use mockforge_core::ai_contract_diff::semantic_analyzer::SemanticChangeType;
29    use mockforge_core::incidents::types::{IncidentSeverity, IncidentStatus};
30    use sqlx::Row;
31
32    let id: uuid::Uuid = row.try_get("id")?;
33    let workspace_id: Option<uuid::Uuid> = row.try_get("workspace_id").ok();
34    let endpoint: String = row.try_get("endpoint")?;
35    let method: String = row.try_get("method")?;
36    let semantic_change_type_str: String = row.try_get("semantic_change_type")?;
37    let severity_str: String = row.try_get("severity")?;
38    let status_str: String = row.try_get("status")?;
39    let semantic_confidence: f64 = row.try_get("semantic_confidence")?;
40    let soft_breaking_score: f64 = row.try_get("soft_breaking_score")?;
41    let llm_analysis: serde_json::Value = row.try_get("llm_analysis").unwrap_or_default();
42    let before_semantic_state: serde_json::Value =
43        row.try_get("before_semantic_state").unwrap_or_default();
44    let after_semantic_state: serde_json::Value =
45        row.try_get("after_semantic_state").unwrap_or_default();
46    let details_json: serde_json::Value = row.try_get("details").unwrap_or_default();
47    let related_drift_incident_id: Option<uuid::Uuid> =
48        row.try_get("related_drift_incident_id").ok();
49    let contract_diff_id: Option<String> = row.try_get("contract_diff_id").ok();
50    let external_ticket_id: Option<String> = row.try_get("external_ticket_id").ok();
51    let external_ticket_url: Option<String> = row.try_get("external_ticket_url").ok();
52    let detected_at: DateTime<Utc> = row.try_get("detected_at")?;
53    let created_at: DateTime<Utc> = row.try_get("created_at")?;
54    let acknowledged_at: Option<DateTime<Utc>> = row.try_get("acknowledged_at").ok();
55    let resolved_at: Option<DateTime<Utc>> = row.try_get("resolved_at").ok();
56    let closed_at: Option<DateTime<Utc>> = row.try_get("closed_at").ok();
57    let updated_at: DateTime<Utc> = row.try_get("updated_at")?;
58
59    // Parse semantic change type
60    let semantic_change_type = match semantic_change_type_str.as_str() {
61        "description_change" => SemanticChangeType::DescriptionChange,
62        "enum_narrowing" => SemanticChangeType::EnumNarrowing,
63        "nullability_change" => SemanticChangeType::NullableChange,
64        "error_code_removed" => SemanticChangeType::ErrorCodeRemoved,
65        "meaning_shift" => SemanticChangeType::MeaningShift,
66        _ => SemanticChangeType::MeaningShift, // Default fallback
67    };
68
69    // Parse severity
70    let severity = match severity_str.as_str() {
71        "low" => IncidentSeverity::Low,
72        "medium" => IncidentSeverity::Medium,
73        "high" => IncidentSeverity::High,
74        "critical" => IncidentSeverity::Critical,
75        _ => IncidentSeverity::Medium, // Default fallback
76    };
77
78    // Parse status
79    let status = match status_str.as_str() {
80        "open" => IncidentStatus::Open,
81        "acknowledged" => IncidentStatus::Acknowledged,
82        "resolved" => IncidentStatus::Resolved,
83        "closed" => IncidentStatus::Closed,
84        _ => IncidentStatus::Open, // Default fallback
85    };
86
87    Ok(SemanticIncident {
88        id: id.to_string(),
89        workspace_id: workspace_id.map(|u| u.to_string()),
90        endpoint,
91        method,
92        semantic_change_type,
93        severity,
94        status,
95        semantic_confidence,
96        soft_breaking_score,
97        llm_analysis,
98        before_semantic_state,
99        after_semantic_state,
100        details: details_json,
101        related_drift_incident_id: related_drift_incident_id.map(|u| u.to_string()),
102        contract_diff_id,
103        external_ticket_id,
104        external_ticket_url,
105        detected_at: detected_at.timestamp(),
106        created_at: created_at.timestamp(),
107        acknowledged_at: acknowledged_at.map(|dt| dt.timestamp()),
108        resolved_at: resolved_at.map(|dt| dt.timestamp()),
109        closed_at: closed_at.map(|dt| dt.timestamp()),
110        updated_at: updated_at.timestamp(),
111    })
112}
113
114/// State for semantic drift handlers
115#[derive(Clone)]
116pub struct SemanticDriftState {
117    /// Semantic incident manager
118    pub manager: Arc<SemanticIncidentManager>,
119    /// Database connection (optional)
120    pub database: Option<Database>,
121}
122
123/// Query parameters for listing semantic incidents
124#[derive(Debug, Deserialize)]
125pub struct ListSemanticIncidentsQuery {
126    /// Workspace ID filter
127    pub workspace_id: Option<String>,
128    /// Endpoint filter
129    pub endpoint: Option<String>,
130    /// Method filter
131    pub method: Option<String>,
132    /// Status filter
133    pub status: Option<String>,
134    /// Limit results
135    pub limit: Option<usize>,
136}
137
138/// Response for semantic incident list
139#[derive(Debug, Serialize)]
140pub struct SemanticIncidentListResponse {
141    /// Incidents
142    pub incidents: Vec<SemanticIncident>,
143    /// Total count
144    pub total: usize,
145}
146
147/// List semantic drift incidents
148///
149/// GET /api/v1/semantic-drift/incidents
150pub async fn list_semantic_incidents(
151    State(state): State<SemanticDriftState>,
152    Query(params): Query<ListSemanticIncidentsQuery>,
153) -> Result<Json<SemanticIncidentListResponse>, StatusCode> {
154    // Try database first, fallback to in-memory
155    #[cfg(feature = "database")]
156    if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
157        let mut query = String::from(
158            "SELECT id, workspace_id, endpoint, method, semantic_change_type, severity, status,
159             semantic_confidence, soft_breaking_score, llm_analysis, before_semantic_state,
160             after_semantic_state, details, related_drift_incident_id, contract_diff_id,
161             external_ticket_id, external_ticket_url, detected_at, created_at, acknowledged_at,
162             resolved_at, closed_at, updated_at
163             FROM semantic_drift_incidents WHERE 1=1",
164        );
165
166        let mut bind_index = 1;
167
168        if let Some(ws_id) = &params.workspace_id {
169            query.push_str(&format!(" AND workspace_id = ${}", bind_index));
170            bind_index += 1;
171        }
172
173        if let Some(ep) = &params.endpoint {
174            query.push_str(&format!(" AND endpoint = ${}", bind_index));
175            bind_index += 1;
176        }
177
178        if let Some(m) = &params.method {
179            query.push_str(&format!(" AND method = ${}", bind_index));
180            bind_index += 1;
181        }
182
183        if let Some(status_str) = &params.status {
184            query.push_str(&format!(" AND status = ${}", bind_index));
185            bind_index += 1;
186        }
187
188        let limit = params.limit.unwrap_or(100);
189        query.push_str(&format!(" ORDER BY detected_at DESC LIMIT {}", limit));
190
191        // Execute query - use fetch_all for SELECT queries
192        let rows = sqlx::query(&query).fetch_all(pool).await.map_err(|e| {
193            tracing::error!("Failed to query semantic incidents: {}", e);
194            StatusCode::INTERNAL_SERVER_ERROR
195        })?;
196
197        // Map rows to SemanticIncident
198        let mut incidents = Vec::new();
199        for row in rows {
200            match map_row_to_semantic_incident(&row) {
201                Ok(incident) => incidents.push(incident),
202                Err(e) => {
203                    tracing::warn!("Failed to map semantic incident row: {}", e);
204                    continue;
205                }
206            }
207        }
208        if !incidents.is_empty() {
209            return Ok(Json(SemanticIncidentListResponse {
210                total: incidents.len(),
211                incidents,
212            }));
213        }
214        // Fall through to in-memory manager if no database results
215    }
216
217    // Fallback to in-memory manager
218    let status = params.status.as_deref().and_then(|s| match s {
219        "open" => Some(mockforge_core::incidents::types::IncidentStatus::Open),
220        "acknowledged" => Some(mockforge_core::incidents::types::IncidentStatus::Acknowledged),
221        "resolved" => Some(mockforge_core::incidents::types::IncidentStatus::Resolved),
222        "closed" => Some(mockforge_core::incidents::types::IncidentStatus::Closed),
223        _ => None,
224    });
225
226    let incidents = state
227        .manager
228        .list_incidents(
229            params.workspace_id.as_deref(),
230            params.endpoint.as_deref(),
231            params.method.as_deref(),
232            status,
233            params.limit,
234        )
235        .await;
236
237    Ok(Json(SemanticIncidentListResponse {
238        total: incidents.len(),
239        incidents,
240    }))
241}
242
243/// Get a specific semantic incident
244///
245/// GET /api/v1/semantic-drift/incidents/{id}
246pub async fn get_semantic_incident(
247    State(state): State<SemanticDriftState>,
248    Path(id): Path<String>,
249) -> Result<Json<SemanticIncident>, StatusCode> {
250    // Try database first
251    #[cfg(feature = "database")]
252    if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
253        let row = sqlx::query("SELECT * FROM semantic_drift_incidents WHERE id = $1")
254            .bind(&id)
255            .fetch_optional(pool)
256            .await
257            .map_err(|e| {
258                tracing::error!("Failed to query semantic incident: {}", e);
259                StatusCode::INTERNAL_SERVER_ERROR
260            })?;
261
262        if let Some(row) = row {
263            match map_row_to_semantic_incident(&row) {
264                Ok(incident) => return Ok(Json(incident)),
265                Err(e) => {
266                    tracing::warn!("Failed to map semantic incident: {}", e);
267                    // Fall through to in-memory
268                }
269            }
270        }
271    }
272
273    // Fallback to in-memory manager
274    match state.manager.get_incident(&id).await {
275        Some(incident) => Ok(Json(incident)),
276        None => Err(StatusCode::NOT_FOUND),
277    }
278}
279
280/// Request to analyze semantic drift
281#[derive(Debug, Deserialize)]
282pub struct AnalyzeSemanticDriftRequest {
283    /// Before spec (OpenAPI YAML/JSON)
284    pub before_spec: String,
285    /// After spec (OpenAPI YAML/JSON)
286    pub after_spec: String,
287    /// Endpoint path
288    pub endpoint: String,
289    /// HTTP method
290    pub method: String,
291    /// Workspace ID (optional)
292    pub workspace_id: Option<String>,
293}
294
295/// Analyze semantic drift between two specs
296///
297/// POST /api/v1/semantic-drift/analyze
298pub async fn analyze_semantic_drift(
299    State(state): State<SemanticDriftState>,
300    Json(request): Json<AnalyzeSemanticDriftRequest>,
301) -> Result<Json<serde_json::Value>, StatusCode> {
302    // Parse specs
303    let before_spec = OpenApiSpec::from_string(&request.before_spec, None)
304        .map_err(|_| StatusCode::BAD_REQUEST)?;
305    let after_spec =
306        OpenApiSpec::from_string(&request.after_spec, None).map_err(|_| StatusCode::BAD_REQUEST)?;
307
308    // Create analyzer
309    let config = ContractDiffConfig::default();
310    let analyzer =
311        ContractDiffAnalyzer::new(config).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
312
313    // Run semantic analysis
314    let semantic_result = analyzer
315        .compare_specs(&before_spec, &after_spec, &request.endpoint, &request.method)
316        .await
317        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
318
319    if let Some(result) = semantic_result {
320        // Create semantic incident if threshold met
321        if result.semantic_confidence >= 0.65 {
322            let incident = state
323                .manager
324                .create_incident(
325                    &result,
326                    request.endpoint.clone(),
327                    request.method.clone(),
328                    request.workspace_id.clone(),
329                    None, // related_drift_incident_id
330                    None, // contract_diff_id
331                )
332                .await;
333
334            // Store in database if available
335            #[cfg(feature = "database")]
336            if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
337                if let Err(e) = store_semantic_incident(pool, &incident).await {
338                    tracing::warn!("Failed to store semantic incident in database: {}", e);
339                }
340            }
341
342            return Ok(Json(serde_json::json!({
343                "success": true,
344                "semantic_drift_detected": true,
345                "incident_id": incident.id,
346                "semantic_confidence": result.semantic_confidence,
347                "soft_breaking_score": result.soft_breaking_score,
348                "change_type": format!("{:?}", result.change_type),
349            })));
350        }
351    }
352
353    Ok(Json(serde_json::json!({
354        "success": true,
355        "semantic_drift_detected": false,
356        "message": "No significant semantic drift detected"
357    })))
358}
359
360/// Store semantic incident in database
361#[cfg(feature = "database")]
362async fn store_semantic_incident(
363    pool: &sqlx::PgPool,
364    incident: &SemanticIncident,
365) -> Result<(), sqlx::Error> {
366    let id = Uuid::parse_str(&incident.id).unwrap_or_else(|_| Uuid::new_v4());
367    let workspace_uuid = incident.workspace_id.as_ref().and_then(|id| Uuid::parse_str(id).ok());
368    let related_uuid = incident
369        .related_drift_incident_id
370        .as_ref()
371        .and_then(|id| Uuid::parse_str(id).ok());
372
373    sqlx::query(
374        r#"
375        INSERT INTO semantic_drift_incidents (
376            id, workspace_id, endpoint, method, semantic_change_type, severity, status,
377            semantic_confidence, soft_breaking_score, llm_analysis, before_semantic_state,
378            after_semantic_state, details, related_drift_incident_id, contract_diff_id,
379            external_ticket_id, external_ticket_url, detected_at, created_at, updated_at
380        ) VALUES (
381            $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20
382        )
383        ON CONFLICT (id) DO UPDATE SET
384            status = EXCLUDED.status,
385            acknowledged_at = EXCLUDED.acknowledged_at,
386            resolved_at = EXCLUDED.resolved_at,
387            closed_at = EXCLUDED.closed_at,
388            updated_at = EXCLUDED.updated_at
389        "#,
390    )
391    .bind(id)
392    .bind(workspace_uuid)
393    .bind(&incident.endpoint)
394    .bind(&incident.method)
395    .bind(format!("{:?}", incident.semantic_change_type))
396    .bind(format!("{:?}", incident.severity))
397    .bind(format!("{:?}", incident.status))
398    .bind(incident.semantic_confidence)
399    .bind(incident.soft_breaking_score)
400    .bind(&incident.llm_analysis)
401    .bind(&incident.before_semantic_state)
402    .bind(&incident.after_semantic_state)
403    .bind(&incident.details)
404    .bind(related_uuid)
405    .bind(incident.contract_diff_id.as_deref())
406    .bind(incident.external_ticket_id.as_deref())
407    .bind(incident.external_ticket_url.as_deref())
408    .bind(DateTime::<Utc>::from_timestamp(incident.detected_at, 0).unwrap_or_else(Utc::now))
409    .bind(DateTime::<Utc>::from_timestamp(incident.created_at, 0).unwrap_or_else(Utc::now))
410    .bind(DateTime::<Utc>::from_timestamp(incident.updated_at, 0).unwrap_or_else(Utc::now))
411    .execute(pool)
412    .await?;
413
414    Ok(())
415}
416
417/// Create router for semantic drift endpoints
418pub fn semantic_drift_router(state: SemanticDriftState) -> axum::Router {
419    use axum::routing::{get, post};
420    use axum::Router;
421
422    Router::new()
423        .route("/api/v1/semantic-drift/incidents", get(list_semantic_incidents))
424        .route("/api/v1/semantic-drift/incidents/{id}", get(get_semantic_incident))
425        .route("/api/v1/semantic-drift/analyze", post(analyze_semantic_drift))
426        .with_state(state)
427}