1use 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#[derive(Clone)]
35pub struct ContractDiffApiState {
36 pub spec_path: Option<String>,
40}
41
42impl ContractDiffApiState {
43 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
276pub 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}