Skip to main content

mockforge_recorder/
api.rs

1//! Management API for recorded requests
2
3use crate::{
4    diff::ComparisonResult,
5    har_export::export_to_har,
6    integration_testing::{IntegrationTestGenerator, IntegrationWorkflow},
7    models::RecordedExchange,
8    query::{execute_query, QueryFilter, QueryResult},
9    recorder::Recorder,
10    replay::ReplayEngine,
11    stub_mapping::{StubFormat, StubMappingConverter},
12    sync::{SyncConfig, SyncService, SyncStatus},
13    sync_snapshots::EndpointTimeline,
14    test_generation::{LlmConfig, TestFormat, TestGenerationConfig, TestGenerator},
15};
16use axum::{
17    extract::{Path, Query, State},
18    http::StatusCode,
19    response::{IntoResponse, Json, Response},
20    routing::{delete, get, post},
21    Router,
22};
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::sync::Arc;
26use tokio::sync::RwLock;
27use tracing::{debug, error};
28
29/// API state
30#[derive(Clone)]
31pub struct ApiState {
32    /// Core recorder instance
33    pub recorder: Arc<Recorder>,
34    /// Optional sync service
35    pub sync_service: Option<Arc<SyncService>>,
36    /// In-memory workflow storage
37    workflows: Arc<RwLock<HashMap<String, IntegrationWorkflow>>>,
38}
39
40/// Create the management API router
41pub fn create_api_router(
42    recorder: Arc<Recorder>,
43    sync_service: Option<Arc<SyncService>>,
44) -> Router {
45    let state = ApiState {
46        recorder,
47        sync_service,
48        workflows: Arc::new(RwLock::new(HashMap::new())),
49    };
50
51    Router::new()
52        // Query endpoints
53        .route("/api/recorder/requests", get(list_requests))
54        .route("/api/recorder/requests/{id}", get(get_request))
55        .route("/api/recorder/requests/{id}/response", get(get_response))
56        .route("/api/recorder/search", post(search_requests))
57
58        // Export endpoints
59        .route("/api/recorder/export/har", get(export_har))
60
61        // Control endpoints
62        .route("/api/recorder/status", get(get_status))
63        .route("/api/recorder/enable", post(enable_recording))
64        .route("/api/recorder/disable", post(disable_recording))
65        .route("/api/recorder/clear", delete(clear_recordings))
66
67        // Replay endpoints
68        .route("/api/recorder/replay/{id}", post(replay_request))
69        .route("/api/recorder/compare/{id}", post(compare_responses))
70
71        // Statistics endpoints
72        .route("/api/recorder/stats", get(get_statistics))
73
74        // Test generation endpoints
75        .route("/api/recorder/generate-tests", post(generate_tests))
76
77        // Integration testing endpoints
78        .route("/api/recorder/workflows", post(create_workflow))
79        .route("/api/recorder/workflows/{id}", get(get_workflow))
80        .route("/api/recorder/workflows/{id}/generate", post(generate_integration_test))
81
82        // Sync endpoints
83        .route("/api/recorder/sync/status", get(get_sync_status))
84        .route("/api/recorder/sync/config", get(get_sync_config))
85        .route("/api/recorder/sync/config", post(update_sync_config))
86        .route("/api/recorder/sync/now", post(sync_now))
87        .route("/api/recorder/sync/changes", get(get_sync_changes))
88
89        // Sync snapshot endpoints (Shadow Snapshot Mode)
90        .route("/api/recorder/sync/snapshots", get(list_snapshots))
91        .route("/api/recorder/sync/snapshots/{endpoint}", get(get_endpoint_timeline))
92        .route("/api/recorder/sync/snapshots/cycle/{cycle_id}", get(get_snapshots_by_cycle))
93
94        // Stub mapping conversion endpoints
95        .route("/api/recorder/convert/{id}", post(convert_to_stub))
96        .route("/api/recorder/convert/batch", post(convert_batch))
97
98        .with_state(state)
99}
100
101/// List recent requests
102async fn list_requests(
103    State(state): State<ApiState>,
104    Query(params): Query<ListParams>,
105) -> Result<Json<QueryResult>, ApiError> {
106    let limit = params.limit.unwrap_or(100);
107    let offset = params.offset.unwrap_or(0);
108
109    let filter = QueryFilter {
110        limit: Some(limit),
111        offset: Some(offset),
112        ..Default::default()
113    };
114
115    let result = execute_query(state.recorder.database(), filter).await?;
116    Ok(Json(result))
117}
118
119/// Get a single request by ID
120async fn get_request(
121    State(state): State<ApiState>,
122    Path(id): Path<String>,
123) -> Result<Json<RecordedExchange>, ApiError> {
124    let exchange = state
125        .recorder
126        .database()
127        .get_exchange(&id)
128        .await?
129        .ok_or_else(|| ApiError::NotFound(format!("Request {} not found", id)))?;
130
131    Ok(Json(exchange))
132}
133
134/// Get response for a request
135async fn get_response(
136    State(state): State<ApiState>,
137    Path(id): Path<String>,
138) -> Result<Json<serde_json::Value>, ApiError> {
139    let response = state
140        .recorder
141        .database()
142        .get_response(&id)
143        .await?
144        .ok_or_else(|| ApiError::NotFound(format!("Response for request {} not found", id)))?;
145
146    Ok(Json(serde_json::json!({
147        "request_id": response.request_id,
148        "status_code": response.status_code,
149        "headers": serde_json::from_str::<serde_json::Value>(&response.headers)?,
150        "body": response.body,
151        "body_encoding": response.body_encoding,
152        "size_bytes": response.size_bytes,
153        "timestamp": response.timestamp,
154    })))
155}
156
157/// Search requests with filters
158async fn search_requests(
159    State(state): State<ApiState>,
160    Json(filter): Json<QueryFilter>,
161) -> Result<Json<QueryResult>, ApiError> {
162    let result = execute_query(state.recorder.database(), filter).await?;
163    Ok(Json(result))
164}
165
166/// Export recordings to HAR format
167async fn export_har(
168    State(state): State<ApiState>,
169    Query(params): Query<ExportParams>,
170) -> Result<Response, ApiError> {
171    let limit = params.limit.unwrap_or(1000);
172
173    let filter = QueryFilter {
174        limit: Some(limit),
175        protocol: Some(crate::models::Protocol::Http), // HAR only supports HTTP
176        ..Default::default()
177    };
178
179    let result = execute_query(state.recorder.database(), filter).await?;
180    let har = export_to_har(&result.exchanges)?;
181    let har_json = serde_json::to_string_pretty(&har)?;
182
183    Ok((StatusCode::OK, [("content-type", "application/json")], har_json).into_response())
184}
185
186/// Get recording status
187async fn get_status(State(state): State<ApiState>) -> Json<StatusResponse> {
188    let enabled = state.recorder.is_enabled().await;
189    Json(StatusResponse { enabled })
190}
191
192/// Enable recording
193async fn enable_recording(State(state): State<ApiState>) -> Json<StatusResponse> {
194    state.recorder.enable().await;
195    debug!("Recording enabled via API");
196    Json(StatusResponse { enabled: true })
197}
198
199/// Disable recording
200async fn disable_recording(State(state): State<ApiState>) -> Json<StatusResponse> {
201    state.recorder.disable().await;
202    debug!("Recording disabled via API");
203    Json(StatusResponse { enabled: false })
204}
205
206/// Clear all recordings
207async fn clear_recordings(State(state): State<ApiState>) -> Result<Json<ClearResponse>, ApiError> {
208    state.recorder.database().clear_all().await?;
209    debug!("All recordings cleared via API");
210    Ok(Json(ClearResponse {
211        message: "All recordings cleared".to_string(),
212    }))
213}
214
215/// Replay a single request
216async fn replay_request(
217    State(state): State<ApiState>,
218    Path(id): Path<String>,
219) -> Result<Json<serde_json::Value>, ApiError> {
220    let engine = ReplayEngine::new((**state.recorder.database()).clone());
221    let result = engine.replay_request(&id).await?;
222
223    Ok(Json(serde_json::json!({
224        "request_id": result.request_id,
225        "success": result.success,
226        "message": result.message,
227        "original_status": result.original_status,
228        "replay_status": result.replay_status,
229    })))
230}
231
232/// Compare original response with a replayed/new response
233async fn compare_responses(
234    State(state): State<ApiState>,
235    Path(id): Path<String>,
236    Json(payload): Json<CompareRequest>,
237) -> Result<Json<ComparisonResult>, ApiError> {
238    let engine = ReplayEngine::new((**state.recorder.database()).clone());
239
240    let result = engine
241        .compare_responses(&id, payload.body.as_bytes(), payload.status_code, &payload.headers)
242        .await?;
243
244    Ok(Json(result))
245}
246
247/// Get statistics about recordings
248async fn get_statistics(
249    State(state): State<ApiState>,
250) -> Result<Json<StatisticsResponse>, ApiError> {
251    let db = state.recorder.database();
252    let stats = db.get_statistics().await?;
253
254    Ok(Json(StatisticsResponse {
255        total_requests: stats.total_requests,
256        by_protocol: stats.by_protocol,
257        by_status_code: stats.by_status_code,
258        avg_duration_ms: stats.avg_duration_ms,
259    }))
260}
261
262// Request/Response types
263
264#[derive(Debug, Deserialize)]
265struct ListParams {
266    limit: Option<i32>,
267    offset: Option<i32>,
268}
269
270#[derive(Debug, Deserialize)]
271struct ExportParams {
272    limit: Option<i32>,
273}
274
275#[derive(Debug, Deserialize)]
276struct CompareRequest {
277    status_code: i32,
278    headers: std::collections::HashMap<String, String>,
279    body: String,
280}
281
282#[derive(Debug, Serialize)]
283struct StatusResponse {
284    enabled: bool,
285}
286
287#[derive(Debug, Serialize)]
288struct ClearResponse {
289    message: String,
290}
291
292#[derive(Debug, Serialize)]
293struct StatisticsResponse {
294    total_requests: i64,
295    by_protocol: std::collections::HashMap<String, i64>,
296    by_status_code: std::collections::HashMap<i32, i64>,
297    avg_duration_ms: Option<f64>,
298}
299
300// Error handling
301
302#[derive(Debug)]
303enum ApiError {
304    Database(sqlx::Error),
305    Serialization(serde_json::Error),
306    NotFound(String),
307    InvalidInput(String),
308    Recorder(crate::RecorderError),
309}
310
311impl From<sqlx::Error> for ApiError {
312    fn from(err: sqlx::Error) -> Self {
313        ApiError::Database(err)
314    }
315}
316
317impl From<serde_json::Error> for ApiError {
318    fn from(err: serde_json::Error) -> Self {
319        ApiError::Serialization(err)
320    }
321}
322
323impl From<crate::RecorderError> for ApiError {
324    fn from(err: crate::RecorderError) -> Self {
325        ApiError::Recorder(err)
326    }
327}
328
329impl IntoResponse for ApiError {
330    fn into_response(self) -> Response {
331        let (status, message) = match self {
332            ApiError::Database(e) => {
333                error!("Database error: {}", e);
334                (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e))
335            }
336            ApiError::Serialization(e) => {
337                error!("Serialization error: {}", e);
338                (StatusCode::INTERNAL_SERVER_ERROR, format!("Serialization error: {}", e))
339            }
340            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
341            ApiError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg),
342            ApiError::Recorder(e) => {
343                error!("Recorder error: {}", e);
344                (StatusCode::INTERNAL_SERVER_ERROR, format!("Recorder error: {}", e))
345            }
346        };
347
348        (status, Json(serde_json::json!({ "error": message }))).into_response()
349    }
350}
351
352/// Test generation request
353#[derive(Debug, Serialize, Deserialize)]
354pub struct GenerateTestsRequest {
355    /// Test format to generate
356    #[serde(default = "default_format")]
357    pub format: String,
358
359    /// Filter for query
360    #[serde(flatten)]
361    pub filter: QueryFilter,
362
363    /// Test suite name
364    #[serde(default = "default_suite_name")]
365    pub suite_name: String,
366
367    /// Base URL for tests
368    pub base_url: Option<String>,
369
370    /// Use AI for test descriptions
371    #[serde(default)]
372    pub ai_descriptions: bool,
373
374    /// LLM configuration for AI descriptions
375    pub llm_config: Option<LlmConfigRequest>,
376
377    /// Include assertions
378    #[serde(default = "default_true")]
379    pub include_assertions: bool,
380
381    /// Validate response body
382    #[serde(default = "default_true")]
383    pub validate_body: bool,
384
385    /// Validate status code
386    #[serde(default = "default_true")]
387    pub validate_status: bool,
388
389    /// Validate headers
390    #[serde(default)]
391    pub validate_headers: bool,
392
393    /// Validate timing
394    #[serde(default)]
395    pub validate_timing: bool,
396
397    /// Max duration threshold for timing validation
398    pub max_duration_ms: Option<u64>,
399}
400
401fn default_format() -> String {
402    "rust_reqwest".to_string()
403}
404
405fn default_suite_name() -> String {
406    "generated_tests".to_string()
407}
408
409fn default_true() -> bool {
410    true
411}
412
413/// LLM configuration request
414#[derive(Debug, Serialize, Deserialize)]
415pub struct LlmConfigRequest {
416    /// LLM provider
417    pub provider: String,
418    /// API endpoint
419    pub api_endpoint: String,
420    /// API key
421    pub api_key: Option<String>,
422    /// Model name
423    pub model: String,
424    /// Temperature
425    #[serde(default = "default_temperature")]
426    pub temperature: f64,
427}
428
429fn default_temperature() -> f64 {
430    0.3
431}
432
433/// Generate tests from recorded requests
434async fn generate_tests(
435    State(state): State<ApiState>,
436    Json(request): Json<GenerateTestsRequest>,
437) -> Result<Json<serde_json::Value>, ApiError> {
438    debug!("Generating tests with format: {}", request.format);
439
440    // Parse test format
441    let test_format = match request.format.as_str() {
442        "rust_reqwest" => TestFormat::RustReqwest,
443        "http_file" => TestFormat::HttpFile,
444        "curl" => TestFormat::Curl,
445        "postman" => TestFormat::Postman,
446        "k6" => TestFormat::K6,
447        "python_pytest" => TestFormat::PythonPytest,
448        "javascript_jest" => TestFormat::JavaScriptJest,
449        "go_test" => TestFormat::GoTest,
450        _ => {
451            return Err(ApiError::NotFound(format!(
452                "Invalid test format: {}. Supported: rust_reqwest, http_file, curl, postman, k6, python_pytest, javascript_jest, go_test",
453                request.format
454            )));
455        }
456    };
457
458    // Convert LLM config if provided
459    let llm_config = request.llm_config.map(|cfg| LlmConfig {
460        provider: cfg.provider,
461        api_endpoint: cfg.api_endpoint,
462        api_key: cfg.api_key,
463        model: cfg.model,
464        temperature: cfg.temperature,
465    });
466
467    // Create test generation config
468    let config = TestGenerationConfig {
469        format: test_format,
470        include_assertions: request.include_assertions,
471        validate_body: request.validate_body,
472        validate_status: request.validate_status,
473        validate_headers: request.validate_headers,
474        validate_timing: request.validate_timing,
475        max_duration_ms: request.max_duration_ms,
476        suite_name: request.suite_name,
477        base_url: request.base_url,
478        ai_descriptions: request.ai_descriptions,
479        llm_config,
480        group_by_endpoint: true,
481        include_setup_teardown: true,
482        generate_fixtures: false,
483        suggest_edge_cases: false,
484        analyze_test_gaps: false,
485        deduplicate_tests: false,
486        optimize_test_order: false,
487    };
488
489    // Create test generator
490    let generator = TestGenerator::from_arc(state.recorder.database().clone(), config);
491
492    // Generate tests
493    let result = generator.generate_from_filter(request.filter).await?;
494
495    // Return result
496    Ok(Json(serde_json::json!({
497        "success": true,
498        "metadata": {
499            "suite_name": result.metadata.name,
500            "test_count": result.metadata.test_count,
501            "endpoint_count": result.metadata.endpoint_count,
502            "protocols": result.metadata.protocols,
503            "format": result.metadata.format,
504            "generated_at": result.metadata.generated_at,
505        },
506        "tests": result.tests.iter().map(|t| serde_json::json!({
507            "name": t.name,
508            "description": t.description,
509            "endpoint": t.endpoint,
510            "method": t.method,
511        })).collect::<Vec<_>>(),
512        "test_file": result.test_file,
513    })))
514}
515
516// Integration Testing Endpoints
517
518/// Create workflow request
519#[derive(Debug, Serialize, Deserialize)]
520struct CreateWorkflowRequest {
521    workflow: IntegrationWorkflow,
522}
523
524/// Create a new integration test workflow
525async fn create_workflow(
526    State(state): State<ApiState>,
527    Json(request): Json<CreateWorkflowRequest>,
528) -> Result<Json<serde_json::Value>, ApiError> {
529    let workflow = request.workflow;
530    let id = workflow.id.clone();
531
532    state.workflows.write().await.insert(id.clone(), workflow.clone());
533
534    Ok(Json(serde_json::json!({
535        "success": true,
536        "workflow": workflow,
537        "message": "Workflow created successfully"
538    })))
539}
540
541/// Get workflow by ID
542async fn get_workflow(
543    State(state): State<ApiState>,
544    Path(id): Path<String>,
545) -> Result<Json<serde_json::Value>, ApiError> {
546    let workflows = state.workflows.read().await;
547    let workflow = workflows
548        .get(&id)
549        .ok_or_else(|| ApiError::NotFound(format!("Workflow '{}' not found", id)))?;
550
551    Ok(Json(serde_json::json!({
552        "success": true,
553        "workflow": workflow
554    })))
555}
556
557/// Generate integration test request
558#[derive(Debug, Deserialize)]
559struct GenerateIntegrationTestRequest {
560    workflow: IntegrationWorkflow,
561    format: String, // "rust", "python", "javascript"
562}
563
564/// Generate integration test code from workflow
565async fn generate_integration_test(
566    State(_state): State<ApiState>,
567    Path(_id): Path<String>,
568    Json(request): Json<GenerateIntegrationTestRequest>,
569) -> Result<Json<serde_json::Value>, ApiError> {
570    let generator = IntegrationTestGenerator::new(request.workflow);
571
572    let test_code = match request.format.as_str() {
573        "rust" => generator.generate_rust_test(),
574        "python" => generator.generate_python_test(),
575        "javascript" | "js" => generator.generate_javascript_test(),
576        _ => return Err(ApiError::InvalidInput(format!("Unsupported format: {}", request.format))),
577    };
578
579    Ok(Json(serde_json::json!({
580        "success": true,
581        "format": request.format,
582        "test_code": test_code,
583        "message": "Integration test generated successfully"
584    })))
585}
586
587// Sync endpoints
588
589/// Get sync status
590async fn get_sync_status(State(state): State<ApiState>) -> Result<Json<SyncStatus>, ApiError> {
591    let sync_service = state
592        .sync_service
593        .ok_or_else(|| ApiError::NotFound("Sync service not available".to_string()))?;
594
595    let status = sync_service.get_status().await;
596    Ok(Json(status))
597}
598
599/// Get sync configuration
600async fn get_sync_config(State(state): State<ApiState>) -> Result<Json<SyncConfig>, ApiError> {
601    let sync_service = state
602        .sync_service
603        .ok_or_else(|| ApiError::NotFound("Sync service not available".to_string()))?;
604
605    let config = sync_service.get_config().await;
606    Ok(Json(config))
607}
608
609/// Update sync configuration
610async fn update_sync_config(
611    State(state): State<ApiState>,
612    Json(config): Json<SyncConfig>,
613) -> Result<Json<SyncConfig>, ApiError> {
614    let sync_service = state
615        .sync_service
616        .ok_or_else(|| ApiError::NotFound("Sync service not available".to_string()))?;
617
618    sync_service.update_config(config.clone()).await;
619    Ok(Json(config))
620}
621
622/// Trigger sync now
623async fn sync_now(State(state): State<ApiState>) -> Result<Json<serde_json::Value>, ApiError> {
624    let sync_service = state
625        .sync_service
626        .ok_or_else(|| ApiError::NotFound("Sync service not available".to_string()))?;
627
628    match sync_service.sync_now().await {
629        Ok((changes, updated)) => Ok(Json(serde_json::json!({
630            "success": true,
631            "changes_detected": changes.len(),
632            "fixtures_updated": updated,
633            "changes": changes,
634            "message": format!("Sync complete: {} changes detected, {} fixtures updated", changes.len(), updated)
635        }))),
636        Err(e) => Err(ApiError::Recorder(e)),
637    }
638}
639
640/// Get sync changes (from last sync)
641async fn get_sync_changes(
642    State(state): State<ApiState>,
643) -> Result<Json<serde_json::Value>, ApiError> {
644    let sync_service = state
645        .sync_service
646        .ok_or_else(|| ApiError::NotFound("Sync service not available".to_string()))?;
647
648    let status = sync_service.get_status().await;
649
650    Ok(Json(serde_json::json!({
651        "last_sync": status.last_sync,
652        "last_changes_detected": status.last_changes_detected,
653        "last_fixtures_updated": status.last_fixtures_updated,
654        "last_error": status.last_error,
655        "total_syncs": status.total_syncs,
656        "is_running": status.is_running,
657    })))
658}
659
660/// Convert a single recording to stub mapping
661#[derive(Debug, Deserialize)]
662struct ConvertRequest {
663    format: Option<String>, // "yaml" or "json"
664    detect_dynamic_values: Option<bool>,
665}
666
667async fn convert_to_stub(
668    State(state): State<ApiState>,
669    Path(id): Path<String>,
670    Json(req): Json<ConvertRequest>,
671) -> Result<Json<serde_json::Value>, ApiError> {
672    let exchange = state
673        .recorder
674        .database()
675        .get_exchange(&id)
676        .await?
677        .ok_or_else(|| ApiError::NotFound(format!("Request {} not found", id)))?;
678
679    let detect_dynamic = req.detect_dynamic_values.unwrap_or(true);
680    let converter = StubMappingConverter::new(detect_dynamic);
681    let stub = converter.convert(&exchange)?;
682
683    let format = match req.format.as_deref() {
684        Some("json") => StubFormat::Json,
685        Some("yaml") | None => StubFormat::Yaml,
686        _ => StubFormat::Yaml,
687    };
688
689    let content = converter.to_string(&stub, format)?;
690
691    Ok(Json(serde_json::json!({
692        "request_id": id,
693        "format": match format {
694            StubFormat::Yaml => "yaml",
695            StubFormat::Json => "json",
696        },
697        "stub": stub,
698        "content": content,
699    })))
700}
701
702/// Convert multiple recordings to stub mappings
703#[derive(Debug, Deserialize)]
704struct BatchConvertRequest {
705    request_ids: Vec<String>,
706    format: Option<String>,
707    detect_dynamic_values: Option<bool>,
708    deduplicate: Option<bool>,
709}
710
711async fn convert_batch(
712    State(state): State<ApiState>,
713    Json(req): Json<BatchConvertRequest>,
714) -> Result<Json<serde_json::Value>, ApiError> {
715    let detect_dynamic = req.detect_dynamic_values.unwrap_or(true);
716    let converter = StubMappingConverter::new(detect_dynamic);
717
718    let format = match req.format.as_deref() {
719        Some("json") => StubFormat::Json,
720        Some("yaml") | None => StubFormat::Yaml,
721        _ => StubFormat::Yaml,
722    };
723
724    let mut stubs = Vec::new();
725    let mut errors = Vec::new();
726
727    for request_id in &req.request_ids {
728        match state.recorder.database().get_exchange(request_id).await {
729            Ok(Some(exchange)) => match converter.convert(&exchange) {
730                Ok(stub) => {
731                    let content = converter.to_string(&stub, format)?;
732                    stubs.push(serde_json::json!({
733                        "request_id": request_id,
734                        "stub": stub,
735                        "content": content,
736                    }));
737                }
738                Err(e) => {
739                    errors.push(format!("Failed to convert {}: {}", request_id, e));
740                }
741            },
742            Ok(None) => {
743                errors.push(format!("Request {} not found", request_id));
744            }
745            Err(e) => {
746                errors.push(format!("Database error for {}: {}", request_id, e));
747            }
748        }
749    }
750
751    // Deduplicate if requested
752    if req.deduplicate.unwrap_or(false) {
753        // Simple deduplication based on identifier
754        let mut seen = std::collections::HashSet::new();
755        stubs.retain(|stub| {
756            if let Some(id) = stub.get("stub").and_then(|s| s.get("identifier")) {
757                if let Some(id_str) = id.as_str() {
758                    return seen.insert(id_str.to_string());
759                }
760            }
761            true
762        });
763    }
764
765    Ok(Json(serde_json::json!({
766        "total": req.request_ids.len(),
767        "converted": stubs.len(),
768        "errors": errors.len(),
769        "stubs": stubs,
770        "errors_list": errors,
771    })))
772}
773
774/// List all snapshots
775#[derive(Debug, Deserialize)]
776struct SnapshotListParams {
777    limit: Option<i32>,
778    #[allow(dead_code)]
779    offset: Option<i32>,
780}
781
782async fn list_snapshots(
783    State(state): State<ApiState>,
784    Query(params): Query<SnapshotListParams>,
785) -> Result<Json<serde_json::Value>, ApiError> {
786    let limit = params.limit.unwrap_or(100);
787    let database = state.recorder.database();
788
789    // Get all unique endpoints to list snapshots
790    // For simplicity, we'll get snapshots for all endpoints
791    // In a real implementation, you might want to paginate differently
792    let snapshots = database.get_snapshots_for_endpoint("", None, Some(limit)).await?;
793
794    Ok(Json(serde_json::json!({
795        "snapshots": snapshots,
796        "total": snapshots.len(),
797    })))
798}
799
800/// Get timeline for a specific endpoint
801#[derive(Debug, Deserialize)]
802struct TimelineParams {
803    method: Option<String>,
804    limit: Option<i32>,
805}
806
807async fn get_endpoint_timeline(
808    State(state): State<ApiState>,
809    Path(endpoint): Path<String>,
810    Query(params): Query<TimelineParams>,
811) -> Result<Json<EndpointTimeline>, ApiError> {
812    let database = state.recorder.database();
813    let limit = params.limit.unwrap_or(100);
814
815    // Axum automatically URL-decodes path parameters
816    let snapshots = database
817        .get_snapshots_for_endpoint(&endpoint, params.method.as_deref(), Some(limit))
818        .await?;
819
820    // Build timeline data
821    let mut response_time_trends = Vec::new();
822    let mut status_code_history = Vec::new();
823    let mut error_patterns = std::collections::HashMap::new();
824
825    for snapshot in &snapshots {
826        response_time_trends.push((
827            snapshot.timestamp,
828            snapshot.response_time_after.or(snapshot.response_time_before),
829        ));
830        status_code_history.push((snapshot.timestamp, snapshot.after.status_code));
831
832        // Track error patterns
833        if snapshot.after.status_code >= 400 {
834            let key = format!("{}", snapshot.after.status_code);
835            let pattern =
836                error_patterns
837                    .entry(key)
838                    .or_insert_with(|| crate::sync_snapshots::ErrorPattern {
839                        status_code: snapshot.after.status_code,
840                        message_pattern: None,
841                        occurrences: 0,
842                        first_seen: snapshot.timestamp,
843                        last_seen: snapshot.timestamp,
844                    });
845            pattern.occurrences += 1;
846            if snapshot.timestamp < pattern.first_seen {
847                pattern.first_seen = snapshot.timestamp;
848            }
849            if snapshot.timestamp > pattern.last_seen {
850                pattern.last_seen = snapshot.timestamp;
851            }
852        }
853    }
854
855    let timeline = EndpointTimeline {
856        endpoint,
857        method: params.method.unwrap_or_else(|| "ALL".to_string()),
858        snapshots,
859        response_time_trends,
860        status_code_history,
861        error_patterns: error_patterns.into_values().collect(),
862    };
863
864    Ok(Json(timeline))
865}
866
867/// Get snapshots by sync cycle ID
868async fn get_snapshots_by_cycle(
869    State(state): State<ApiState>,
870    Path(cycle_id): Path<String>,
871) -> Result<Json<serde_json::Value>, ApiError> {
872    let database = state.recorder.database();
873
874    let snapshots = database.get_snapshots_by_cycle(&cycle_id).await?;
875
876    Ok(Json(serde_json::json!({
877        "sync_cycle_id": cycle_id,
878        "snapshots": snapshots,
879        "total": snapshots.len(),
880    })))
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use crate::database::RecorderDatabase;
887    use crate::integration_testing::WorkflowSetup;
888    use crate::models::{Protocol, RecordedRequest};
889    use axum::http::StatusCode as HttpStatusCode;
890
891    async fn create_test_db() -> RecorderDatabase {
892        RecorderDatabase::new_in_memory().await.unwrap()
893    }
894
895    async fn create_test_recorder() -> Arc<Recorder> {
896        let db = create_test_db().await;
897        Arc::new(Recorder::new(db))
898    }
899
900    #[tokio::test]
901    async fn test_api_state_creation() {
902        let recorder = create_test_recorder().await;
903        let state = ApiState {
904            recorder: recorder.clone(),
905            sync_service: None,
906            workflows: Arc::new(RwLock::new(HashMap::new())),
907        };
908
909        assert!(state.sync_service.is_none());
910    }
911
912    #[tokio::test]
913    async fn test_create_api_router() {
914        let recorder = create_test_recorder().await;
915        let router = create_api_router(recorder, None);
916
917        // Router should be created successfully
918        assert!(std::mem::size_of_val(&router) > 0);
919    }
920
921    #[tokio::test]
922    async fn test_get_status_enabled() {
923        let recorder = create_test_recorder().await;
924        recorder.enable().await;
925
926        let state = ApiState {
927            recorder: recorder.clone(),
928            sync_service: None,
929            workflows: Arc::new(RwLock::new(HashMap::new())),
930        };
931
932        let response = get_status(State(state)).await;
933        assert!(response.0.enabled);
934    }
935
936    #[tokio::test]
937    async fn test_get_status_disabled() {
938        let recorder = create_test_recorder().await;
939        recorder.disable().await;
940
941        let state = ApiState {
942            recorder: recorder.clone(),
943            sync_service: None,
944            workflows: Arc::new(RwLock::new(HashMap::new())),
945        };
946
947        let response = get_status(State(state)).await;
948        assert!(!response.0.enabled);
949    }
950
951    #[tokio::test]
952    async fn test_enable_recording() {
953        let recorder = create_test_recorder().await;
954        recorder.disable().await;
955
956        let state = ApiState {
957            recorder: recorder.clone(),
958            sync_service: None,
959            workflows: Arc::new(RwLock::new(HashMap::new())),
960        };
961
962        let response = enable_recording(State(state)).await;
963        assert!(response.0.enabled);
964        assert!(recorder.is_enabled().await);
965    }
966
967    #[tokio::test]
968    async fn test_disable_recording() {
969        let recorder = create_test_recorder().await;
970        recorder.enable().await;
971
972        let state = ApiState {
973            recorder: recorder.clone(),
974            sync_service: None,
975            workflows: Arc::new(RwLock::new(HashMap::new())),
976        };
977
978        let response = disable_recording(State(state)).await;
979        assert!(!response.0.enabled);
980        assert!(!recorder.is_enabled().await);
981    }
982
983    #[tokio::test]
984    async fn test_clear_recordings() {
985        let recorder = create_test_recorder().await;
986
987        // Add a test request
988        let request = RecordedRequest {
989            id: "test-1".to_string(),
990            protocol: Protocol::Http,
991            timestamp: chrono::Utc::now(),
992            method: "GET".to_string(),
993            path: "/test".to_string(),
994            query_params: None,
995            headers: "{}".to_string(),
996            body: None,
997            body_encoding: "utf8".to_string(),
998            client_ip: None,
999            trace_id: None,
1000            span_id: None,
1001            duration_ms: None,
1002            status_code: Some(200),
1003            tags: None,
1004        };
1005        recorder.database().insert_request(&request).await.unwrap();
1006
1007        let state = ApiState {
1008            recorder: recorder.clone(),
1009            sync_service: None,
1010            workflows: Arc::new(RwLock::new(HashMap::new())),
1011        };
1012
1013        let response = clear_recordings(State(state)).await.unwrap();
1014        assert_eq!(response.0.message, "All recordings cleared");
1015    }
1016
1017    #[test]
1018    fn test_api_error_from_sqlx() {
1019        let err = sqlx::Error::RowNotFound;
1020        let api_err = ApiError::from(err);
1021
1022        match api_err {
1023            ApiError::Database(_) => {}
1024            _ => panic!("Expected Database error"),
1025        }
1026    }
1027
1028    #[test]
1029    fn test_api_error_from_serde() {
1030        let err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
1031        let api_err = ApiError::from(err);
1032
1033        match api_err {
1034            ApiError::Serialization(_) => {}
1035            _ => panic!("Expected Serialization error"),
1036        }
1037    }
1038
1039    #[test]
1040    fn test_api_error_into_response_not_found() {
1041        let err = ApiError::NotFound("Test not found".to_string());
1042        let response = err.into_response();
1043
1044        assert_eq!(response.status(), HttpStatusCode::NOT_FOUND);
1045    }
1046
1047    #[test]
1048    fn test_api_error_into_response_invalid_input() {
1049        let err = ApiError::InvalidInput("Invalid data".to_string());
1050        let response = err.into_response();
1051
1052        assert_eq!(response.status(), HttpStatusCode::BAD_REQUEST);
1053    }
1054
1055    #[test]
1056    fn test_list_params_defaults() {
1057        let params = ListParams {
1058            limit: None,
1059            offset: None,
1060        };
1061
1062        assert!(params.limit.is_none());
1063        assert!(params.offset.is_none());
1064    }
1065
1066    #[test]
1067    fn test_export_params_defaults() {
1068        let params = ExportParams { limit: None };
1069        assert!(params.limit.is_none());
1070    }
1071
1072    #[test]
1073    fn test_compare_request_creation() {
1074        let mut headers = std::collections::HashMap::new();
1075        headers.insert("content-type".to_string(), "application/json".to_string());
1076
1077        let req = CompareRequest {
1078            status_code: 200,
1079            headers,
1080            body: "test body".to_string(),
1081        };
1082
1083        assert_eq!(req.status_code, 200);
1084        assert_eq!(req.body, "test body");
1085        assert_eq!(req.headers.get("content-type").unwrap(), "application/json");
1086    }
1087
1088    #[test]
1089    fn test_status_response_serialization() {
1090        let response = StatusResponse { enabled: true };
1091        let json = serde_json::to_string(&response).unwrap();
1092
1093        assert!(json.contains("enabled"));
1094        assert!(json.contains("true"));
1095    }
1096
1097    #[test]
1098    fn test_clear_response_serialization() {
1099        let response = ClearResponse {
1100            message: "All cleared".to_string(),
1101        };
1102        let json = serde_json::to_string(&response).unwrap();
1103
1104        assert!(json.contains("All cleared"));
1105    }
1106
1107    #[test]
1108    fn test_statistics_response_creation() {
1109        let mut by_protocol = std::collections::HashMap::new();
1110        by_protocol.insert("http".to_string(), 100);
1111
1112        let mut by_status_code = std::collections::HashMap::new();
1113        by_status_code.insert(200, 80);
1114        by_status_code.insert(404, 20);
1115
1116        let response = StatisticsResponse {
1117            total_requests: 100,
1118            by_protocol,
1119            by_status_code,
1120            avg_duration_ms: Some(150.5),
1121        };
1122
1123        assert_eq!(response.total_requests, 100);
1124        assert_eq!(response.by_protocol.get("http").unwrap(), &100);
1125        assert_eq!(response.by_status_code.get(&200).unwrap(), &80);
1126        assert_eq!(response.avg_duration_ms, Some(150.5));
1127    }
1128
1129    #[test]
1130    fn test_default_format() {
1131        assert_eq!(default_format(), "rust_reqwest");
1132    }
1133
1134    #[test]
1135    fn test_default_suite_name() {
1136        assert_eq!(default_suite_name(), "generated_tests");
1137    }
1138
1139    #[test]
1140    fn test_default_true() {
1141        assert!(default_true());
1142    }
1143
1144    #[test]
1145    fn test_default_temperature() {
1146        assert_eq!(default_temperature(), 0.3);
1147    }
1148
1149    #[test]
1150    fn test_generate_tests_request_defaults() {
1151        let request = GenerateTestsRequest {
1152            format: default_format(),
1153            filter: QueryFilter::default(),
1154            suite_name: default_suite_name(),
1155            base_url: None,
1156            ai_descriptions: false,
1157            llm_config: None,
1158            include_assertions: default_true(),
1159            validate_body: default_true(),
1160            validate_status: default_true(),
1161            validate_headers: false,
1162            validate_timing: false,
1163            max_duration_ms: None,
1164        };
1165
1166        assert_eq!(request.format, "rust_reqwest");
1167        assert_eq!(request.suite_name, "generated_tests");
1168        assert!(request.include_assertions);
1169        assert!(request.validate_body);
1170        assert!(request.validate_status);
1171        assert!(!request.validate_headers);
1172    }
1173
1174    #[test]
1175    fn test_llm_config_request_creation() {
1176        let config = LlmConfigRequest {
1177            provider: "openai".to_string(),
1178            api_endpoint: "https://api.openai.com".to_string(),
1179            api_key: Some("secret".to_string()),
1180            model: "gpt-4".to_string(),
1181            temperature: default_temperature(),
1182        };
1183
1184        assert_eq!(config.provider, "openai");
1185        assert_eq!(config.model, "gpt-4");
1186        assert_eq!(config.temperature, 0.3);
1187    }
1188
1189    #[test]
1190    fn test_create_workflow_request_serialization() {
1191        let workflow = IntegrationWorkflow {
1192            id: "wf-1".to_string(),
1193            name: "Test Workflow".to_string(),
1194            description: "A test workflow".to_string(),
1195            steps: vec![],
1196            setup: WorkflowSetup::default(),
1197            cleanup: vec![],
1198            created_at: chrono::Utc::now(),
1199        };
1200
1201        let request = CreateWorkflowRequest { workflow };
1202        let json = serde_json::to_string(&request).unwrap();
1203
1204        assert!(json.contains("Test Workflow"));
1205    }
1206
1207    #[test]
1208    fn test_generate_integration_test_request_creation() {
1209        let workflow = IntegrationWorkflow {
1210            id: "wf-1".to_string(),
1211            name: "Test".to_string(),
1212            description: "Test".to_string(),
1213            steps: vec![],
1214            setup: WorkflowSetup::default(),
1215            cleanup: vec![],
1216            created_at: chrono::Utc::now(),
1217        };
1218
1219        let request = GenerateIntegrationTestRequest {
1220            workflow,
1221            format: "rust".to_string(),
1222        };
1223
1224        assert_eq!(request.format, "rust");
1225    }
1226
1227    #[test]
1228    fn test_convert_request_defaults() {
1229        let req = ConvertRequest {
1230            format: None,
1231            detect_dynamic_values: None,
1232        };
1233
1234        assert!(req.format.is_none());
1235        assert!(req.detect_dynamic_values.is_none());
1236    }
1237
1238    #[test]
1239    fn test_batch_convert_request_creation() {
1240        let request = BatchConvertRequest {
1241            request_ids: vec!["req-1".to_string(), "req-2".to_string()],
1242            format: Some("json".to_string()),
1243            detect_dynamic_values: Some(true),
1244            deduplicate: Some(false),
1245        };
1246
1247        assert_eq!(request.request_ids.len(), 2);
1248        assert_eq!(request.format, Some("json".to_string()));
1249        assert_eq!(request.detect_dynamic_values, Some(true));
1250        assert_eq!(request.deduplicate, Some(false));
1251    }
1252
1253    #[test]
1254    fn test_snapshot_list_params() {
1255        let params = SnapshotListParams {
1256            limit: Some(50),
1257            offset: Some(10),
1258        };
1259
1260        assert_eq!(params.limit, Some(50));
1261        assert_eq!(params.offset, Some(10));
1262    }
1263
1264    #[test]
1265    fn test_timeline_params() {
1266        let params = TimelineParams {
1267            method: Some("GET".to_string()),
1268            limit: Some(100),
1269        };
1270
1271        assert_eq!(params.method, Some("GET".to_string()));
1272        assert_eq!(params.limit, Some(100));
1273    }
1274
1275    #[tokio::test]
1276    async fn test_get_request_not_found() {
1277        let recorder = create_test_recorder().await;
1278        let state = ApiState {
1279            recorder: recorder.clone(),
1280            sync_service: None,
1281            workflows: Arc::new(RwLock::new(HashMap::new())),
1282        };
1283
1284        let result = get_request(State(state), Path("non-existent".to_string())).await;
1285
1286        assert!(result.is_err());
1287        match result {
1288            Err(ApiError::NotFound(_)) => {}
1289            _ => panic!("Expected NotFound error"),
1290        }
1291    }
1292
1293    #[tokio::test]
1294    async fn test_get_response_not_found() {
1295        let recorder = create_test_recorder().await;
1296        let state = ApiState {
1297            recorder: recorder.clone(),
1298            sync_service: None,
1299            workflows: Arc::new(RwLock::new(HashMap::new())),
1300        };
1301
1302        let result = get_response(State(state), Path("non-existent".to_string())).await;
1303
1304        assert!(result.is_err());
1305        match result {
1306            Err(ApiError::NotFound(_)) => {}
1307            _ => panic!("Expected NotFound error"),
1308        }
1309    }
1310
1311    #[tokio::test]
1312    async fn test_get_sync_status_no_service() {
1313        let recorder = create_test_recorder().await;
1314        let state = ApiState {
1315            recorder: recorder.clone(),
1316            sync_service: None,
1317            workflows: Arc::new(RwLock::new(HashMap::new())),
1318        };
1319
1320        let result = get_sync_status(State(state)).await;
1321
1322        assert!(result.is_err());
1323        match result {
1324            Err(ApiError::NotFound(_)) => {}
1325            _ => panic!("Expected NotFound error"),
1326        }
1327    }
1328
1329    #[tokio::test]
1330    async fn test_get_sync_config_no_service() {
1331        let recorder = create_test_recorder().await;
1332        let state = ApiState {
1333            recorder: recorder.clone(),
1334            sync_service: None,
1335            workflows: Arc::new(RwLock::new(HashMap::new())),
1336        };
1337
1338        let result = get_sync_config(State(state)).await;
1339
1340        assert!(result.is_err());
1341        match result {
1342            Err(ApiError::NotFound(_)) => {}
1343            _ => panic!("Expected NotFound error"),
1344        }
1345    }
1346
1347    #[tokio::test]
1348    async fn test_search_requests_empty() {
1349        let recorder = create_test_recorder().await;
1350        let state = ApiState {
1351            recorder: recorder.clone(),
1352            sync_service: None,
1353            workflows: Arc::new(RwLock::new(HashMap::new())),
1354        };
1355
1356        let filter = QueryFilter::default();
1357        let result = search_requests(State(state), Json(filter)).await.unwrap();
1358
1359        assert_eq!(result.0.total, 0);
1360        assert!(result.0.exchanges.is_empty());
1361    }
1362
1363    #[tokio::test]
1364    async fn test_list_requests_with_params() {
1365        let recorder = create_test_recorder().await;
1366
1367        // Add some test requests
1368        for i in 0..5 {
1369            let request = RecordedRequest {
1370                id: format!("req-{}", i),
1371                protocol: Protocol::Http,
1372                timestamp: chrono::Utc::now(),
1373                method: "GET".to_string(),
1374                path: "/test".to_string(),
1375                query_params: None,
1376                headers: "{}".to_string(),
1377                body: None,
1378                body_encoding: "utf8".to_string(),
1379                client_ip: None,
1380                trace_id: None,
1381                span_id: None,
1382                duration_ms: None,
1383                status_code: Some(200),
1384                tags: None,
1385            };
1386            recorder.database().insert_request(&request).await.unwrap();
1387        }
1388
1389        let state = ApiState {
1390            recorder: recorder.clone(),
1391            sync_service: None,
1392            workflows: Arc::new(RwLock::new(HashMap::new())),
1393        };
1394
1395        let params = ListParams {
1396            limit: Some(3),
1397            offset: Some(0),
1398        };
1399
1400        let result = list_requests(State(state), Query(params)).await.unwrap();
1401
1402        assert_eq!(result.0.exchanges.len(), 3);
1403    }
1404
1405    #[test]
1406    fn test_generate_tests_request_serialization() {
1407        let request = GenerateTestsRequest {
1408            format: "rust_reqwest".to_string(),
1409            filter: QueryFilter::default(),
1410            suite_name: "my_tests".to_string(),
1411            base_url: Some("http://localhost:8080".to_string()),
1412            ai_descriptions: true,
1413            llm_config: None,
1414            include_assertions: true,
1415            validate_body: true,
1416            validate_status: true,
1417            validate_headers: true,
1418            validate_timing: false,
1419            max_duration_ms: Some(1000),
1420        };
1421
1422        let json = serde_json::to_string(&request).unwrap();
1423
1424        assert!(json.contains("rust_reqwest"));
1425        assert!(json.contains("my_tests"));
1426        assert!(json.contains("http://localhost:8080"));
1427    }
1428}