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