Skip to main content

mockforge_intelligence/handlers/drift_budget/
budgets.rs

1//! Endpoint budgets and effective precedence lookup.
2
3use super::DriftBudgetSource;
4use super::DriftBudgetStateProvider;
5use axum::{
6    extract::{Path, Query, State},
7    http::StatusCode,
8    response::Json,
9};
10use mockforge_contracts::contract_drift::types::DriftBudget;
11use serde::{Deserialize, Serialize};
12
13/// Request to create or update a drift budget
14#[derive(Debug, Deserialize, Serialize)]
15pub struct CreateDriftBudgetRequest {
16    /// Endpoint path
17    pub endpoint: String,
18    /// HTTP method
19    pub method: String,
20    /// Maximum breaking changes allowed
21    pub max_breaking_changes: Option<u32>,
22    /// Maximum non-breaking changes allowed
23    pub max_non_breaking_changes: Option<u32>,
24    /// Severity threshold
25    pub severity_threshold: Option<String>,
26    /// Whether enabled
27    pub enabled: Option<bool>,
28    /// Workspace ID (optional)
29    pub workspace_id: Option<String>,
30}
31
32/// Response for drift budget operations
33#[derive(Debug, Serialize)]
34pub struct DriftBudgetResponse {
35    /// Budget ID
36    pub id: String,
37    /// Endpoint
38    pub endpoint: String,
39    /// Method
40    pub method: String,
41    /// Budget configuration
42    pub budget: DriftBudget,
43    /// Workspace ID
44    pub workspace_id: Option<String>,
45}
46
47/// Get budget for a specific endpoint/workspace/service
48///
49/// GET /api/v1/drift/budgets/lookup?endpoint=/api/users&method=GET&workspace_id=...
50#[derive(Debug, Deserialize)]
51pub struct GetBudgetQuery {
52    /// Endpoint path
53    pub endpoint: String,
54    /// HTTP method
55    pub method: String,
56    /// Optional workspace ID
57    pub workspace_id: Option<String>,
58    /// Optional service name
59    pub service_name: Option<String>,
60    /// Optional comma-separated tags
61    pub tags: Option<String>,
62}
63
64/// Create or update a drift budget
65///
66/// POST /api/v1/drift/budgets
67pub async fn create_budget<S: DriftBudgetStateProvider>(
68    State(_state): State<S>,
69    Json(request): Json<CreateDriftBudgetRequest>,
70) -> Result<Json<DriftBudgetResponse>, StatusCode> {
71    let budget = DriftBudget {
72        max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
73        max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
74        max_field_churn_percent: None,
75        time_window_days: None,
76        severity_threshold: request
77            .severity_threshold
78            .as_deref()
79            .and_then(|s| match s.to_lowercase().as_str() {
80                "critical" => Some(crate::ai_contract_diff::MismatchSeverity::Critical),
81                "high" => Some(crate::ai_contract_diff::MismatchSeverity::High),
82                "medium" => Some(crate::ai_contract_diff::MismatchSeverity::Medium),
83                "low" => Some(crate::ai_contract_diff::MismatchSeverity::Low),
84                _ => None,
85            })
86            .unwrap_or(crate::ai_contract_diff::MismatchSeverity::High),
87        enabled: request.enabled.unwrap_or(true),
88    };
89
90    // Generate budget ID
91    let budget_id = format!("{}:{}:{}", request.method, request.endpoint, uuid::Uuid::new_v4());
92
93    // Build the key for this budget (matches the format used by list_budgets/get_budget)
94    let key = format!("{} {}", request.method, request.endpoint);
95
96    // Note: DriftBudgetEngine.config is not behind interior mutability (RwLock),
97    // so budget creation is returned but not persisted in the running engine.
98    // A future refactor should wrap config in RwLock for runtime mutation.
99    let _ = key;
100
101    Ok(Json(DriftBudgetResponse {
102        id: budget_id,
103        endpoint: request.endpoint,
104        method: request.method,
105        budget,
106        workspace_id: request.workspace_id,
107    }))
108}
109
110/// List drift budgets
111///
112/// GET /api/v1/drift/budgets
113pub async fn list_budgets<S: DriftBudgetStateProvider>(
114    State(state): State<S>,
115) -> Result<Json<serde_json::Value>, StatusCode> {
116    let config = state.engine().config();
117    let budgets: Vec<serde_json::Value> = config
118        .per_endpoint_budgets
119        .iter()
120        .map(|(key, budget)| {
121            // Key format is "METHOD /path"
122            let parts: Vec<&str> = key.splitn(2, ' ').collect();
123            let (method, endpoint) = if parts.len() == 2 {
124                (parts[0].to_string(), parts[1].to_string())
125            } else {
126                ("GET".to_string(), key.clone())
127            };
128            serde_json::json!({
129                "id": key,
130                "method": method,
131                "endpoint": endpoint,
132                "budget": {
133                    "max_breaking_changes": budget.max_breaking_changes,
134                    "max_non_breaking_changes": budget.max_non_breaking_changes,
135                    "enabled": budget.enabled,
136                }
137            })
138        })
139        .collect();
140
141    Ok(Json(serde_json::json!({
142        "budgets": budgets,
143        "total": budgets.len(),
144    })))
145}
146
147/// Get a specific drift budget
148///
149/// GET /api/v1/drift/budgets/{id}
150pub async fn get_budget<S: DriftBudgetStateProvider>(
151    State(state): State<S>,
152    Path(id): Path<String>,
153) -> Result<Json<serde_json::Value>, StatusCode> {
154    let config = state.engine().config();
155    if let Some(budget) = config.per_endpoint_budgets.get(&id) {
156        let parts: Vec<&str> = id.splitn(2, ' ').collect();
157        let (method, endpoint) = if parts.len() == 2 {
158            (parts[0].to_string(), parts[1].to_string())
159        } else {
160            ("GET".to_string(), id.clone())
161        };
162        Ok(Json(serde_json::json!({
163            "id": id,
164            "method": method,
165            "endpoint": endpoint,
166            "budget": {
167                "max_breaking_changes": budget.max_breaking_changes,
168                "max_non_breaking_changes": budget.max_non_breaking_changes,
169                "enabled": budget.enabled,
170            }
171        })))
172    } else {
173        Err(StatusCode::NOT_FOUND)
174    }
175}
176
177/// Get budget for endpoint
178///
179/// GET /api/v1/drift/budgets/lookup
180pub async fn get_budget_for_endpoint<S: DriftBudgetStateProvider>(
181    State(state): State<S>,
182    Query(params): Query<GetBudgetQuery>,
183) -> Result<Json<serde_json::Value>, StatusCode> {
184    let tags = params
185        .tags
186        .as_ref()
187        .map(|t| t.split(',').map(|s| s.trim().to_string()).collect::<Vec<_>>());
188
189    let budget = state.engine().get_budget_for_endpoint(
190        &params.endpoint,
191        &params.method,
192        params.workspace_id.as_deref(),
193        params.service_name.as_deref(),
194        tags.as_deref(),
195    );
196
197    Ok(Json(serde_json::json!({
198        "endpoint": params.endpoint,
199        "method": params.method,
200        "workspace_id": params.workspace_id,
201        "service_name": params.service_name,
202        "budget": budget,
203    })))
204}