Skip to main content

mockforge_http/
contract_diff_api.rs

1//! Contract-diff retrieval API.
2//!
3//! The `contract_diff_middleware` already captures every incoming
4//! request into the global `CaptureManager`. Until now there was no
5//! HTTP surface to read those captures back out or to run the diff
6//! analyser against the deployment's OpenAPI spec — the audit's PR-3
7//! gap.
8//!
9//! ## Endpoints (mounted under `/__mockforge/api/contract-diff`)
10//!
11//! - `GET    /captures?limit=<n>`       → recent captures (default 100, capped 1000)
12//! - `GET    /captures/{id}`            → single capture
13//! - `POST   /analyze/{id}`             → analyse one capture against the spec
14//! - `POST   /analyze`                  → analyse the most recent N captures (defaults to 50)
15//! - `DELETE /captures`                 → wipe the in-memory store
16//! - `GET    /statistics`               → simple counters from CaptureManager
17//!
18//! Analysis loads the OpenAPI spec from the path the server was started
19//! with (`spec_path` carried in this module's state). A redeploy with a
20//! new spec naturally takes effect on the next request.
21
22use axum::extract::{Path as AxumPath, Query, State};
23use axum::http::StatusCode;
24use axum::response::{IntoResponse, Response};
25use axum::routing::{get, post};
26use axum::{Json, Router};
27use mockforge_core::ai_contract_diff::{ContractDiffAnalyzer, ContractDiffConfig};
28use mockforge_core::openapi::OpenApiSpec;
29use mockforge_core::request_capture::get_global_capture_manager;
30use serde::Deserialize;
31use std::sync::Arc;
32
33/// Shared state for the contract-diff API. Cloneable.
34#[derive(Clone)]
35pub struct ContractDiffApiState {
36    /// Path to the OpenAPI spec the deployment was started with.
37    /// `None` means analysis endpoints will return a friendly 503 —
38    /// captures are still listable.
39    pub spec_path: Option<String>,
40}
41
42impl ContractDiffApiState {
43    /// Construct from the spec path the server was started with. Pass
44    /// `None` to disable the analysis endpoints (captures still list).
45    pub fn new(spec_path: Option<String>) -> Self {
46        Self { spec_path }
47    }
48}
49
50#[derive(Debug, Deserialize)]
51struct CapturesQuery {
52    #[serde(default)]
53    limit: Option<usize>,
54}
55
56async fn list_captures_handler(Query(q): Query<CapturesQuery>) -> Response {
57    let Some(manager) = get_global_capture_manager() else {
58        return capture_manager_unavailable();
59    };
60    let limit = q.limit.unwrap_or(100).min(1000);
61    let captures = manager.get_recent_captures(Some(limit)).await;
62    let payload: Vec<serde_json::Value> = captures
63        .into_iter()
64        .map(|(request, metadata)| {
65            serde_json::json!({
66                "id": metadata.id,
67                "captured_at": metadata.captured_at,
68                "source": metadata.source,
69                "analyzed": metadata.analyzed,
70                "request": request,
71            })
72        })
73        .collect();
74    Json(serde_json::json!({
75        "count": payload.len(),
76        "captures": payload,
77    }))
78    .into_response()
79}
80
81async fn get_capture_handler(AxumPath(id): AxumPath<String>) -> Response {
82    let Some(manager) = get_global_capture_manager() else {
83        return capture_manager_unavailable();
84    };
85    match manager.get_capture(&id).await {
86        Some((request, metadata)) => Json(serde_json::json!({
87            "id": metadata.id,
88            "captured_at": metadata.captured_at,
89            "source": metadata.source,
90            "analyzed": metadata.analyzed,
91            "request": request,
92        }))
93        .into_response(),
94        None => (
95            StatusCode::NOT_FOUND,
96            Json(serde_json::json!({
97                "error": "capture_not_found",
98                "message": format!("No capture with id '{}'", id),
99            })),
100        )
101            .into_response(),
102    }
103}
104
105async fn delete_captures_handler() -> Response {
106    let Some(manager) = get_global_capture_manager() else {
107        return capture_manager_unavailable();
108    };
109    manager.clear_captures().await;
110    StatusCode::NO_CONTENT.into_response()
111}
112
113async fn statistics_handler() -> Response {
114    let Some(manager) = get_global_capture_manager() else {
115        return capture_manager_unavailable();
116    };
117    let stats = manager.get_statistics().await;
118    Json(stats).into_response()
119}
120
121#[derive(Debug, Deserialize)]
122struct AnalyzeAllQuery {
123    #[serde(default)]
124    limit: Option<usize>,
125}
126
127async fn analyze_one_handler(
128    State(state): State<Arc<ContractDiffApiState>>,
129    AxumPath(id): AxumPath<String>,
130) -> Response {
131    let Some(manager) = get_global_capture_manager() else {
132        return capture_manager_unavailable();
133    };
134    let Some(spec_path) = state.spec_path.as_ref() else {
135        return spec_unavailable();
136    };
137
138    let (request, _metadata) = match manager.get_capture(&id).await {
139        Some(c) => c,
140        None => {
141            return (
142                StatusCode::NOT_FOUND,
143                Json(serde_json::json!({
144                    "error": "capture_not_found",
145                    "message": format!("No capture with id '{}'", id),
146                })),
147            )
148                .into_response();
149        }
150    };
151
152    let spec = match OpenApiSpec::from_file(spec_path).await {
153        Ok(s) => s,
154        Err(e) => return spec_load_failed(&e.to_string()),
155    };
156
157    let analyzer = match ContractDiffAnalyzer::new(ContractDiffConfig::default()) {
158        Ok(a) => a,
159        Err(e) => return analyzer_init_failed(&e.to_string()),
160    };
161
162    match analyzer.analyze(&request, &spec).await {
163        Ok(result) => Json(result).into_response(),
164        Err(e) => analyzer_failed(&e.to_string()),
165    }
166}
167
168async fn analyze_all_handler(
169    State(state): State<Arc<ContractDiffApiState>>,
170    Query(q): Query<AnalyzeAllQuery>,
171) -> Response {
172    let Some(manager) = get_global_capture_manager() else {
173        return capture_manager_unavailable();
174    };
175    let Some(spec_path) = state.spec_path.as_ref() else {
176        return spec_unavailable();
177    };
178
179    let limit = q.limit.unwrap_or(50).min(500);
180    let captures = manager.get_recent_captures(Some(limit)).await;
181    if captures.is_empty() {
182        return Json(serde_json::json!({ "results": [], "analyzed": 0 })).into_response();
183    }
184
185    let spec = match OpenApiSpec::from_file(spec_path).await {
186        Ok(s) => s,
187        Err(e) => return spec_load_failed(&e.to_string()),
188    };
189    let analyzer = match ContractDiffAnalyzer::new(ContractDiffConfig::default()) {
190        Ok(a) => a,
191        Err(e) => return analyzer_init_failed(&e.to_string()),
192    };
193
194    let mut results = Vec::with_capacity(captures.len());
195    for (request, metadata) in &captures {
196        match analyzer.analyze(request, &spec).await {
197            Ok(result) => {
198                results.push(serde_json::json!({
199                    "capture_id": metadata.id,
200                    "ok": true,
201                    "result": result,
202                }));
203            }
204            Err(e) => {
205                results.push(serde_json::json!({
206                    "capture_id": metadata.id,
207                    "ok": false,
208                    "error": e.to_string(),
209                }));
210            }
211        }
212    }
213
214    Json(serde_json::json!({
215        "analyzed": results.len(),
216        "results": results,
217    }))
218    .into_response()
219}
220
221fn capture_manager_unavailable() -> Response {
222    (
223        StatusCode::SERVICE_UNAVAILABLE,
224        Json(serde_json::json!({
225            "error": "capture_manager_not_initialised",
226            "message": "Request capture is not enabled on this deployment",
227        })),
228    )
229        .into_response()
230}
231
232fn spec_unavailable() -> Response {
233    (
234        StatusCode::SERVICE_UNAVAILABLE,
235        Json(serde_json::json!({
236            "error": "no_openapi_spec",
237            "message": "Analysis requires the deployment to be running with an OpenAPI spec",
238        })),
239    )
240        .into_response()
241}
242
243fn spec_load_failed(err: &str) -> Response {
244    (
245        StatusCode::INTERNAL_SERVER_ERROR,
246        Json(serde_json::json!({
247            "error": "spec_load_failed",
248            "message": err,
249        })),
250    )
251        .into_response()
252}
253
254fn analyzer_init_failed(err: &str) -> Response {
255    (
256        StatusCode::INTERNAL_SERVER_ERROR,
257        Json(serde_json::json!({
258            "error": "analyzer_init_failed",
259            "message": err,
260        })),
261    )
262        .into_response()
263}
264
265fn analyzer_failed(err: &str) -> Response {
266    (
267        StatusCode::INTERNAL_SERVER_ERROR,
268        Json(serde_json::json!({
269            "error": "analyze_failed",
270            "message": err,
271        })),
272    )
273        .into_response()
274}
275
276/// Build the contract-diff API router. Mount under
277/// `/__mockforge/api/contract-diff`.
278pub fn contract_diff_api_router(state: Arc<ContractDiffApiState>) -> Router {
279    Router::new()
280        .route("/captures", get(list_captures_handler).delete(delete_captures_handler))
281        .route("/captures/{id}", get(get_capture_handler))
282        .route("/statistics", get(statistics_handler))
283        .route("/analyze", post(analyze_all_handler))
284        .route("/analyze/{id}", post(analyze_one_handler))
285        .with_state(state)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn state_holds_optional_spec_path() {
294        let s = ContractDiffApiState::new(None);
295        assert!(s.spec_path.is_none());
296        let s = ContractDiffApiState::new(Some("/tmp/spec.yaml".to_string()));
297        assert_eq!(s.spec_path.as_deref(), Some("/tmp/spec.yaml"));
298    }
299}