mockforge_ui/handlers/
contract_diff.rs

1//! Contract diff API handlers
2//!
3//! This module provides API endpoints for:
4//! - Manual request upload
5//! - Programmatic request submission
6//! - Retrieving captured requests
7//! - Triggering contract diff analysis
8
9use axum::{
10    extract::{Path, Query},
11    http::StatusCode,
12    response::{IntoResponse, Response},
13    Json,
14};
15use mockforge_core::{
16    ai_contract_diff::{CapturedRequest, ContractDiffAnalyzer, ContractDiffConfig},
17    openapi::OpenApiSpec,
18    request_capture::{get_global_capture_manager, CaptureQuery},
19    Error,
20};
21use serde::Deserialize;
22use serde_json::json;
23use std::collections::HashMap;
24
25/// Helper to convert Error to HTTP response
26fn error_response(error: Error) -> Response {
27    (
28        StatusCode::INTERNAL_SERVER_ERROR,
29        Json(json!({
30            "success": false,
31            "error": error.to_string()
32        })),
33    )
34        .into_response()
35}
36
37/// Upload a request manually for contract diff analysis
38pub async fn upload_request(Json(payload): Json<UploadRequestPayload>) -> impl IntoResponse {
39    let request = CapturedRequest::new(&payload.method, &payload.path, "manual_upload")
40        .with_headers(payload.headers.unwrap_or_default())
41        .with_query_params(payload.query_params.unwrap_or_default());
42
43    let request = if let Some(body) = payload.body {
44        request.with_body(body)
45    } else {
46        request
47    };
48
49    let request = if let Some(status_code) = payload.status_code {
50        request.with_response(status_code, payload.response_body)
51    } else {
52        request
53    };
54
55    // Capture the request
56    let capture_manager = match get_global_capture_manager() {
57        Some(manager) => manager,
58        None => {
59            return (
60                StatusCode::INTERNAL_SERVER_ERROR,
61                Json(json!({
62                    "success": false,
63                    "error": "Capture manager not initialized"
64                })),
65            )
66                .into_response();
67        }
68    };
69
70    match capture_manager.capture(request).await {
71        Ok(capture_id) => (
72            StatusCode::OK,
73            Json(json!({
74                "success": true,
75                "capture_id": capture_id,
76                "message": "Request captured successfully"
77            })),
78        )
79            .into_response(),
80        Err(e) => (
81            StatusCode::INTERNAL_SERVER_ERROR,
82            Json(json!({
83                "success": false,
84                "error": e.to_string()
85            })),
86        )
87            .into_response(),
88    }
89}
90
91/// Submit a request programmatically via API
92pub async fn submit_request(Json(payload): Json<SubmitRequestPayload>) -> impl IntoResponse {
93    let request = CapturedRequest::new(&payload.method, &payload.path, "api_endpoint")
94        .with_headers(payload.headers.unwrap_or_default())
95        .with_query_params(payload.query_params.unwrap_or_default());
96
97    let request = if let Some(body) = payload.body {
98        request.with_body(body)
99    } else {
100        request
101    };
102
103    let request = if let Some(status_code) = payload.status_code {
104        request.with_response(status_code, payload.response_body)
105    } else {
106        request
107    };
108
109    // Capture the request
110    let capture_manager = match get_global_capture_manager() {
111        Some(manager) => manager,
112        None => {
113            return (
114                StatusCode::INTERNAL_SERVER_ERROR,
115                Json(json!({
116                    "success": false,
117                    "error": "Capture manager not initialized"
118                })),
119            )
120                .into_response();
121        }
122    };
123
124    match capture_manager.capture(request).await {
125        Ok(capture_id) => (
126            StatusCode::OK,
127            Json(json!({
128                "success": true,
129                "capture_id": capture_id,
130                "message": "Request submitted successfully"
131            })),
132        )
133            .into_response(),
134        Err(e) => error_response(e),
135    }
136}
137
138/// Get captured requests with optional filters
139pub async fn get_captured_requests(
140    Query(params): Query<HashMap<String, String>>,
141) -> impl IntoResponse {
142    let capture_manager = match get_global_capture_manager() {
143        Some(manager) => manager,
144        None => {
145            return (
146                StatusCode::INTERNAL_SERVER_ERROR,
147                Json(json!({
148                    "success": false,
149                    "error": "Capture manager not initialized"
150                })),
151            )
152                .into_response();
153        }
154    };
155
156    let query = CaptureQuery {
157        source: params.get("source").cloned(),
158        method: params.get("method").cloned(),
159        path_pattern: params.get("path_pattern").cloned(),
160        analyzed: params.get("analyzed").and_then(|s| s.parse().ok()),
161        limit: params.get("limit").and_then(|s| s.parse().ok()),
162        offset: params.get("offset").and_then(|s| s.parse().ok()),
163        ..Default::default()
164    };
165
166    let captures = capture_manager.query_captures(query).await;
167
168    (
169        StatusCode::OK,
170        Json(json!({
171            "success": true,
172            "count": captures.len(),
173            "captures": captures.iter().map(|(req, meta)| json!({
174                "id": meta.id,
175                "method": req.method,
176                "path": req.path,
177                "source": meta.source,
178                "captured_at": meta.captured_at,
179                "analyzed": meta.analyzed,
180                "query_params": req.query_params,
181                "headers": req.headers,
182            })).collect::<Vec<_>>()
183        })),
184    )
185        .into_response()
186}
187
188/// Get a specific captured request by ID
189pub async fn get_captured_request(Path(capture_id): Path<String>) -> impl IntoResponse {
190    let capture_manager = match get_global_capture_manager() {
191        Some(manager) => manager,
192        None => {
193            return (
194                StatusCode::INTERNAL_SERVER_ERROR,
195                Json(json!({
196                    "success": false,
197                    "error": "Capture manager not initialized"
198                })),
199            )
200                .into_response();
201        }
202    };
203
204    let (request, metadata) = match capture_manager.get_capture(&capture_id).await {
205        Some(result) => result,
206        None => {
207            return (
208                StatusCode::NOT_FOUND,
209                Json(json!({
210                    "success": false,
211                    "error": format!("Capture not found: {}", capture_id)
212                })),
213            )
214                .into_response();
215        }
216    };
217
218    (
219        StatusCode::OK,
220        Json(json!({
221            "success": true,
222            "capture": {
223                "id": metadata.id,
224                "method": request.method,
225                "path": request.path,
226                "source": metadata.source,
227                "captured_at": metadata.captured_at,
228                "analyzed": metadata.analyzed,
229                "contract_id": metadata.contract_id,
230                "analysis_result_id": metadata.analysis_result_id,
231                "query_params": request.query_params,
232                "headers": request.headers,
233                "body": request.body,
234                "status_code": request.status_code,
235                "response_body": request.response_body,
236                "user_agent": request.user_agent,
237                "metadata": request.metadata,
238            }
239        })),
240    )
241        .into_response()
242}
243
244/// Analyze a captured request against a contract specification
245pub async fn analyze_captured_request(
246    Path(capture_id): Path<String>,
247    Json(payload): Json<AnalyzeRequestPayload>,
248) -> impl IntoResponse {
249    let capture_manager = match get_global_capture_manager() {
250        Some(manager) => manager,
251        None => {
252            return (
253                StatusCode::INTERNAL_SERVER_ERROR,
254                Json(json!({
255                    "success": false,
256                    "error": "Capture manager not initialized"
257                })),
258            )
259                .into_response();
260        }
261    };
262
263    // Get the captured request
264    let (request, _metadata) = match capture_manager.get_capture(&capture_id).await {
265        Some(result) => result,
266        None => {
267            return (
268                StatusCode::NOT_FOUND,
269                Json(json!({
270                    "success": false,
271                    "error": format!("Capture not found: {}", capture_id)
272                })),
273            )
274                .into_response();
275        }
276    };
277
278    // Load the contract specification
279    let spec = match if let Some(spec_path) = &payload.spec_path {
280        OpenApiSpec::from_file(spec_path).await
281    } else if let Some(spec_content) = &payload.spec_content {
282        // Try to detect format from content
283        let format = if spec_content.trim_start().starts_with('{') {
284            None // JSON
285        } else {
286            Some("yaml") // YAML
287        };
288        OpenApiSpec::from_string(spec_content, format)
289    } else {
290        return (
291            StatusCode::BAD_REQUEST,
292            Json(json!({
293                "success": false,
294                "error": "Either spec_path or spec_content must be provided"
295            })),
296        )
297            .into_response();
298    } {
299        Ok(spec) => spec,
300        Err(e) => return error_response(e),
301    };
302
303    // Create contract diff analyzer
304    let config = payload.config.unwrap_or_else(ContractDiffConfig::default);
305    let analyzer = match ContractDiffAnalyzer::new(config) {
306        Ok(analyzer) => analyzer,
307        Err(e) => return error_response(e),
308    };
309
310    // Analyze the request
311    let result = match analyzer.analyze(&request, &spec).await {
312        Ok(result) => result,
313        Err(e) => return error_response(e),
314    };
315
316    // Mark as analyzed
317    let analysis_result_id = uuid::Uuid::new_v4().to_string();
318    let contract_id = payload.contract_id.unwrap_or_else(|| "default".to_string());
319    if let Err(e) = capture_manager
320        .mark_analyzed(&capture_id, &contract_id, &analysis_result_id)
321        .await
322    {
323        return error_response(e);
324    }
325
326    (
327        StatusCode::OK,
328        Json(json!({
329            "success": true,
330            "analysis_result_id": analysis_result_id,
331            "result": result
332        })),
333    )
334        .into_response()
335}
336
337/// Get capture statistics
338pub async fn get_capture_statistics() -> impl IntoResponse {
339    let capture_manager = match get_global_capture_manager() {
340        Some(manager) => manager,
341        None => {
342            return (
343                StatusCode::INTERNAL_SERVER_ERROR,
344                Json(json!({
345                    "success": false,
346                    "error": "Capture manager not initialized"
347                })),
348            )
349                .into_response();
350        }
351    };
352
353    let stats = capture_manager.get_statistics().await;
354
355    (
356        StatusCode::OK,
357        Json(json!({
358            "success": true,
359            "statistics": stats
360        })),
361    )
362        .into_response()
363}
364
365/// Generate patch file for correction proposals
366pub async fn generate_patch_file(
367    Path(capture_id): Path<String>,
368    Json(payload): Json<GeneratePatchPayload>,
369) -> impl IntoResponse {
370    let capture_manager = match get_global_capture_manager() {
371        Some(manager) => manager,
372        None => {
373            return (
374                StatusCode::INTERNAL_SERVER_ERROR,
375                Json(json!({
376                    "success": false,
377                    "error": "Capture manager not initialized"
378                })),
379            )
380                .into_response();
381        }
382    };
383
384    // Get the captured request
385    let (request, _metadata) = match capture_manager.get_capture(&capture_id).await {
386        Some(result) => result,
387        None => {
388            return (
389                StatusCode::NOT_FOUND,
390                Json(json!({
391                    "success": false,
392                    "error": format!("Capture not found: {}", capture_id)
393                })),
394            )
395                .into_response();
396        }
397    };
398
399    // Load the contract specification
400    let spec = match if let Some(spec_path) = &payload.spec_path {
401        OpenApiSpec::from_file(spec_path).await
402    } else if let Some(spec_content) = &payload.spec_content {
403        let format = if spec_content.trim_start().starts_with('{') {
404            None // JSON
405        } else {
406            Some("yaml") // YAML
407        };
408        OpenApiSpec::from_string(spec_content, format)
409    } else {
410        return (
411            StatusCode::BAD_REQUEST,
412            Json(json!({
413                "success": false,
414                "error": "Either spec_path or spec_content must be provided"
415            })),
416        )
417            .into_response();
418    } {
419        Ok(spec) => spec,
420        Err(e) => return error_response(e),
421    };
422
423    // Create contract diff analyzer
424    let config = payload.config.unwrap_or_else(ContractDiffConfig::default);
425    let analyzer = match ContractDiffAnalyzer::new(config) {
426        Ok(analyzer) => analyzer,
427        Err(e) => return error_response(e),
428    };
429
430    // Analyze the request
431    let result = match analyzer.analyze(&request, &spec).await {
432        Ok(result) => result,
433        Err(e) => return error_response(e),
434    };
435
436    // Generate patch file
437    if result.corrections.is_empty() {
438        return (
439            StatusCode::BAD_REQUEST,
440            Json(json!({
441                "success": false,
442                "error": "No corrections available to generate patch"
443            })),
444        )
445            .into_response();
446    }
447
448    let spec_version = if spec.spec.info.version.is_empty() {
449        "1.0.0".to_string()
450    } else {
451        spec.spec.info.version.clone()
452    };
453    let patch_file = analyzer.generate_patch_file(&result.corrections, &spec_version);
454
455    (
456        StatusCode::OK,
457        Json(json!({
458            "success": true,
459            "patch_file": patch_file,
460            "corrections_count": result.corrections.len()
461        })),
462    )
463        .into_response()
464}
465
466/// Request payload for patch generation
467#[derive(Debug, Deserialize)]
468pub struct GeneratePatchPayload {
469    /// Path to contract specification file
470    pub spec_path: Option<String>,
471
472    /// Contract specification content (OpenAPI YAML/JSON)
473    pub spec_content: Option<String>,
474
475    /// Contract diff configuration
476    pub config: Option<ContractDiffConfig>,
477}
478
479/// Request payload for manual upload
480#[derive(Debug, Deserialize)]
481pub struct UploadRequestPayload {
482    pub method: String,
483    pub path: String,
484    pub headers: Option<HashMap<String, String>>,
485    pub query_params: Option<HashMap<String, String>>,
486    pub body: Option<serde_json::Value>,
487    pub status_code: Option<u16>,
488    pub response_body: Option<serde_json::Value>,
489}
490
491/// Request payload for programmatic submission
492#[derive(Debug, Deserialize)]
493pub struct SubmitRequestPayload {
494    pub method: String,
495    pub path: String,
496    pub headers: Option<HashMap<String, String>>,
497    pub query_params: Option<HashMap<String, String>>,
498    pub body: Option<serde_json::Value>,
499    pub status_code: Option<u16>,
500    pub response_body: Option<serde_json::Value>,
501}
502
503/// Request payload for analysis
504#[derive(Debug, Deserialize)]
505pub struct AnalyzeRequestPayload {
506    /// Path to contract specification file
507    pub spec_path: Option<String>,
508
509    /// Contract specification content (OpenAPI YAML/JSON)
510    pub spec_content: Option<String>,
511
512    /// Contract ID for tracking
513    pub contract_id: Option<String>,
514
515    /// Contract diff configuration
516    pub config: Option<ContractDiffConfig>,
517}