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, WorkflowSetup},
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    test_generation::{LlmConfig, TestFormat, TestGenerationConfig, TestGenerator},
14};
15use axum::{
16    extract::{Path, Query, State},
17    http::StatusCode,
18    response::{IntoResponse, Json, Response},
19    routing::{delete, get, post},
20    Router,
21};
22use serde::{Deserialize, Serialize};
23use std::sync::Arc;
24use tracing::{debug, error};
25
26/// API state
27#[derive(Clone)]
28pub struct ApiState {
29    pub recorder: Arc<Recorder>,
30    pub sync_service: Option<Arc<SyncService>>,
31}
32
33/// Create the management API router
34pub fn create_api_router(
35    recorder: Arc<Recorder>,
36    sync_service: Option<Arc<SyncService>>,
37) -> Router {
38    let state = ApiState {
39        recorder,
40        sync_service,
41    };
42
43    Router::new()
44        // Query endpoints
45        .route("/api/recorder/requests", get(list_requests))
46        .route("/api/recorder/requests/:id", get(get_request))
47        .route("/api/recorder/requests/:id/response", get(get_response))
48        .route("/api/recorder/search", post(search_requests))
49
50        // Export endpoints
51        .route("/api/recorder/export/har", get(export_har))
52
53        // Control endpoints
54        .route("/api/recorder/status", get(get_status))
55        .route("/api/recorder/enable", post(enable_recording))
56        .route("/api/recorder/disable", post(disable_recording))
57        .route("/api/recorder/clear", delete(clear_recordings))
58
59        // Replay endpoints
60        .route("/api/recorder/replay/:id", post(replay_request))
61        .route("/api/recorder/compare/:id", post(compare_responses))
62
63        // Statistics endpoints
64        .route("/api/recorder/stats", get(get_statistics))
65
66        // Test generation endpoints
67        .route("/api/recorder/generate-tests", post(generate_tests))
68
69        // Integration testing endpoints
70        .route("/api/recorder/workflows", post(create_workflow))
71        .route("/api/recorder/workflows/:id", get(get_workflow))
72        .route("/api/recorder/workflows/:id/generate", post(generate_integration_test))
73
74        // Sync endpoints
75        .route("/api/recorder/sync/status", get(get_sync_status))
76        .route("/api/recorder/sync/config", get(get_sync_config))
77        .route("/api/recorder/sync/config", post(update_sync_config))
78        .route("/api/recorder/sync/now", post(sync_now))
79        .route("/api/recorder/sync/changes", get(get_sync_changes))
80
81        // Stub mapping conversion endpoints
82        .route("/api/recorder/convert/:id", post(convert_to_stub))
83        .route("/api/recorder/convert/batch", post(convert_batch))
84
85        .with_state(state)
86}
87
88/// List recent requests
89async fn list_requests(
90    State(state): State<ApiState>,
91    Query(params): Query<ListParams>,
92) -> Result<Json<QueryResult>, ApiError> {
93    let limit = params.limit.unwrap_or(100);
94    let offset = params.offset.unwrap_or(0);
95
96    let filter = QueryFilter {
97        limit: Some(limit),
98        offset: Some(offset),
99        ..Default::default()
100    };
101
102    let result = execute_query(state.recorder.database(), filter).await?;
103    Ok(Json(result))
104}
105
106/// Get a single request by ID
107async fn get_request(
108    State(state): State<ApiState>,
109    Path(id): Path<String>,
110) -> Result<Json<RecordedExchange>, ApiError> {
111    let exchange = state
112        .recorder
113        .database()
114        .get_exchange(&id)
115        .await?
116        .ok_or_else(|| ApiError::NotFound(format!("Request {} not found", id)))?;
117
118    Ok(Json(exchange))
119}
120
121/// Get response for a request
122async fn get_response(
123    State(state): State<ApiState>,
124    Path(id): Path<String>,
125) -> Result<Json<serde_json::Value>, ApiError> {
126    let response = state
127        .recorder
128        .database()
129        .get_response(&id)
130        .await?
131        .ok_or_else(|| ApiError::NotFound(format!("Response for request {} not found", id)))?;
132
133    Ok(Json(serde_json::json!({
134        "request_id": response.request_id,
135        "status_code": response.status_code,
136        "headers": serde_json::from_str::<serde_json::Value>(&response.headers)?,
137        "body": response.body,
138        "body_encoding": response.body_encoding,
139        "size_bytes": response.size_bytes,
140        "timestamp": response.timestamp,
141    })))
142}
143
144/// Search requests with filters
145async fn search_requests(
146    State(state): State<ApiState>,
147    Json(filter): Json<QueryFilter>,
148) -> Result<Json<QueryResult>, ApiError> {
149    let result = execute_query(state.recorder.database(), filter).await?;
150    Ok(Json(result))
151}
152
153/// Export recordings to HAR format
154async fn export_har(
155    State(state): State<ApiState>,
156    Query(params): Query<ExportParams>,
157) -> Result<Response, ApiError> {
158    let limit = params.limit.unwrap_or(1000);
159
160    let filter = QueryFilter {
161        limit: Some(limit),
162        protocol: Some(crate::models::Protocol::Http), // HAR only supports HTTP
163        ..Default::default()
164    };
165
166    let result = execute_query(state.recorder.database(), filter).await?;
167    let har = export_to_har(&result.exchanges)?;
168    let har_json = serde_json::to_string_pretty(&har)?;
169
170    Ok((StatusCode::OK, [("content-type", "application/json")], har_json).into_response())
171}
172
173/// Get recording status
174async fn get_status(State(state): State<ApiState>) -> Json<StatusResponse> {
175    let enabled = state.recorder.is_enabled().await;
176    Json(StatusResponse { enabled })
177}
178
179/// Enable recording
180async fn enable_recording(State(state): State<ApiState>) -> Json<StatusResponse> {
181    state.recorder.enable().await;
182    debug!("Recording enabled via API");
183    Json(StatusResponse { enabled: true })
184}
185
186/// Disable recording
187async fn disable_recording(State(state): State<ApiState>) -> Json<StatusResponse> {
188    state.recorder.disable().await;
189    debug!("Recording disabled via API");
190    Json(StatusResponse { enabled: false })
191}
192
193/// Clear all recordings
194async fn clear_recordings(State(state): State<ApiState>) -> Result<Json<ClearResponse>, ApiError> {
195    state.recorder.database().clear_all().await?;
196    debug!("All recordings cleared via API");
197    Ok(Json(ClearResponse {
198        message: "All recordings cleared".to_string(),
199    }))
200}
201
202/// Replay a single request
203async fn replay_request(
204    State(state): State<ApiState>,
205    Path(id): Path<String>,
206) -> Result<Json<serde_json::Value>, ApiError> {
207    let engine = ReplayEngine::new((**state.recorder.database()).clone());
208    let result = engine.replay_request(&id).await?;
209
210    Ok(Json(serde_json::json!({
211        "request_id": result.request_id,
212        "success": result.success,
213        "message": result.message,
214        "original_status": result.original_status,
215        "replay_status": result.replay_status,
216    })))
217}
218
219/// Compare original response with a replayed/new response
220async fn compare_responses(
221    State(state): State<ApiState>,
222    Path(id): Path<String>,
223    Json(payload): Json<CompareRequest>,
224) -> Result<Json<ComparisonResult>, ApiError> {
225    let engine = ReplayEngine::new((**state.recorder.database()).clone());
226
227    let result = engine
228        .compare_responses(&id, payload.body.as_bytes(), payload.status_code, &payload.headers)
229        .await?;
230
231    Ok(Json(result))
232}
233
234/// Get statistics about recordings
235async fn get_statistics(
236    State(state): State<ApiState>,
237) -> Result<Json<StatisticsResponse>, ApiError> {
238    let db = state.recorder.database();
239    let stats = db.get_statistics().await?;
240
241    Ok(Json(StatisticsResponse {
242        total_requests: stats.total_requests,
243        by_protocol: stats.by_protocol,
244        by_status_code: stats.by_status_code,
245        avg_duration_ms: stats.avg_duration_ms,
246    }))
247}
248
249// Request/Response types
250
251#[derive(Debug, Deserialize)]
252struct ListParams {
253    limit: Option<i32>,
254    offset: Option<i32>,
255}
256
257#[derive(Debug, Deserialize)]
258struct ExportParams {
259    limit: Option<i32>,
260}
261
262#[derive(Debug, Deserialize)]
263struct CompareRequest {
264    status_code: i32,
265    headers: std::collections::HashMap<String, String>,
266    body: String,
267}
268
269#[derive(Debug, Serialize)]
270struct StatusResponse {
271    enabled: bool,
272}
273
274#[derive(Debug, Serialize)]
275struct ClearResponse {
276    message: String,
277}
278
279#[derive(Debug, Serialize)]
280struct StatisticsResponse {
281    total_requests: i64,
282    by_protocol: std::collections::HashMap<String, i64>,
283    by_status_code: std::collections::HashMap<i32, i64>,
284    avg_duration_ms: Option<f64>,
285}
286
287// Error handling
288
289#[derive(Debug)]
290enum ApiError {
291    Database(sqlx::Error),
292    Serialization(serde_json::Error),
293    NotFound(String),
294    InvalidInput(String),
295    Recorder(crate::RecorderError),
296}
297
298impl From<sqlx::Error> for ApiError {
299    fn from(err: sqlx::Error) -> Self {
300        ApiError::Database(err)
301    }
302}
303
304impl From<serde_json::Error> for ApiError {
305    fn from(err: serde_json::Error) -> Self {
306        ApiError::Serialization(err)
307    }
308}
309
310impl From<crate::RecorderError> for ApiError {
311    fn from(err: crate::RecorderError) -> Self {
312        ApiError::Recorder(err)
313    }
314}
315
316impl IntoResponse for ApiError {
317    fn into_response(self) -> Response {
318        let (status, message) = match self {
319            ApiError::Database(e) => {
320                error!("Database error: {}", e);
321                (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e))
322            }
323            ApiError::Serialization(e) => {
324                error!("Serialization error: {}", e);
325                (StatusCode::INTERNAL_SERVER_ERROR, format!("Serialization error: {}", e))
326            }
327            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
328            ApiError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg),
329            ApiError::Recorder(e) => {
330                error!("Recorder error: {}", e);
331                (StatusCode::INTERNAL_SERVER_ERROR, format!("Recorder error: {}", e))
332            }
333        };
334
335        (status, Json(serde_json::json!({ "error": message }))).into_response()
336    }
337}
338
339/// Test generation request
340#[derive(Debug, Deserialize)]
341pub struct GenerateTestsRequest {
342    /// Test format to generate
343    #[serde(default = "default_format")]
344    pub format: String,
345
346    /// Filter for query
347    #[serde(flatten)]
348    pub filter: QueryFilter,
349
350    /// Test suite name
351    #[serde(default = "default_suite_name")]
352    pub suite_name: String,
353
354    /// Base URL for tests
355    pub base_url: Option<String>,
356
357    /// Use AI for test descriptions
358    #[serde(default)]
359    pub ai_descriptions: bool,
360
361    /// LLM configuration for AI descriptions
362    pub llm_config: Option<LlmConfigRequest>,
363
364    /// Include assertions
365    #[serde(default = "default_true")]
366    pub include_assertions: bool,
367
368    /// Validate response body
369    #[serde(default = "default_true")]
370    pub validate_body: bool,
371
372    /// Validate status code
373    #[serde(default = "default_true")]
374    pub validate_status: bool,
375
376    /// Validate headers
377    #[serde(default)]
378    pub validate_headers: bool,
379
380    /// Validate timing
381    #[serde(default)]
382    pub validate_timing: bool,
383
384    /// Max duration threshold for timing validation
385    pub max_duration_ms: Option<u64>,
386}
387
388fn default_format() -> String {
389    "rust_reqwest".to_string()
390}
391
392fn default_suite_name() -> String {
393    "generated_tests".to_string()
394}
395
396fn default_true() -> bool {
397    true
398}
399
400/// LLM configuration request
401#[derive(Debug, Deserialize)]
402pub struct LlmConfigRequest {
403    /// LLM provider
404    pub provider: String,
405    /// API endpoint
406    pub api_endpoint: String,
407    /// API key
408    pub api_key: Option<String>,
409    /// Model name
410    pub model: String,
411    /// Temperature
412    #[serde(default = "default_temperature")]
413    pub temperature: f64,
414}
415
416fn default_temperature() -> f64 {
417    0.3
418}
419
420/// Generate tests from recorded requests
421async fn generate_tests(
422    State(state): State<ApiState>,
423    Json(request): Json<GenerateTestsRequest>,
424) -> Result<Json<serde_json::Value>, ApiError> {
425    debug!("Generating tests with format: {}", request.format);
426
427    // Parse test format
428    let test_format = match request.format.as_str() {
429        "rust_reqwest" => TestFormat::RustReqwest,
430        "http_file" => TestFormat::HttpFile,
431        "curl" => TestFormat::Curl,
432        "postman" => TestFormat::Postman,
433        "k6" => TestFormat::K6,
434        "python_pytest" => TestFormat::PythonPytest,
435        "javascript_jest" => TestFormat::JavaScriptJest,
436        "go_test" => TestFormat::GoTest,
437        _ => {
438            return Err(ApiError::NotFound(format!(
439                "Invalid test format: {}. Supported: rust_reqwest, http_file, curl, postman, k6, python_pytest, javascript_jest, go_test",
440                request.format
441            )));
442        }
443    };
444
445    // Convert LLM config if provided
446    let llm_config = request.llm_config.map(|cfg| LlmConfig {
447        provider: cfg.provider,
448        api_endpoint: cfg.api_endpoint,
449        api_key: cfg.api_key,
450        model: cfg.model,
451        temperature: cfg.temperature,
452    });
453
454    // Create test generation config
455    let config = TestGenerationConfig {
456        format: test_format,
457        include_assertions: request.include_assertions,
458        validate_body: request.validate_body,
459        validate_status: request.validate_status,
460        validate_headers: request.validate_headers,
461        validate_timing: request.validate_timing,
462        max_duration_ms: request.max_duration_ms,
463        suite_name: request.suite_name,
464        base_url: request.base_url,
465        ai_descriptions: request.ai_descriptions,
466        llm_config,
467        group_by_endpoint: true,
468        include_setup_teardown: true,
469        generate_fixtures: false,
470        suggest_edge_cases: false,
471        analyze_test_gaps: false,
472        deduplicate_tests: false,
473        optimize_test_order: false,
474    };
475
476    // Create test generator
477    let generator = TestGenerator::from_arc(state.recorder.database().clone(), config);
478
479    // Generate tests
480    let result = generator.generate_from_filter(request.filter).await?;
481
482    // Return result
483    Ok(Json(serde_json::json!({
484        "success": true,
485        "metadata": {
486            "suite_name": result.metadata.name,
487            "test_count": result.metadata.test_count,
488            "endpoint_count": result.metadata.endpoint_count,
489            "protocols": result.metadata.protocols,
490            "format": result.metadata.format,
491            "generated_at": result.metadata.generated_at,
492        },
493        "tests": result.tests.iter().map(|t| serde_json::json!({
494            "name": t.name,
495            "description": t.description,
496            "endpoint": t.endpoint,
497            "method": t.method,
498        })).collect::<Vec<_>>(),
499        "test_file": result.test_file,
500    })))
501}
502
503// Integration Testing Endpoints
504
505/// Create workflow request
506#[derive(Debug, Deserialize)]
507struct CreateWorkflowRequest {
508    workflow: IntegrationWorkflow,
509}
510
511/// Create a new integration test workflow
512async fn create_workflow(
513    State(_state): State<ApiState>,
514    Json(request): Json<CreateWorkflowRequest>,
515) -> Result<Json<serde_json::Value>, ApiError> {
516    // For now, just return the workflow with success
517    // In a full implementation, this would store in a database
518    Ok(Json(serde_json::json!({
519        "success": true,
520        "workflow": request.workflow,
521        "message": "Workflow created successfully"
522    })))
523}
524
525/// Get workflow by ID
526async fn get_workflow(
527    State(_state): State<ApiState>,
528    Path(id): Path<String>,
529) -> Result<Json<serde_json::Value>, ApiError> {
530    // Mock workflow for demonstration
531    // In a full implementation, this would fetch from database
532    let workflow = IntegrationWorkflow {
533        id: id.clone(),
534        name: "Sample Workflow".to_string(),
535        description: "A sample integration test workflow".to_string(),
536        steps: vec![],
537        setup: WorkflowSetup::default(),
538        cleanup: vec![],
539        created_at: chrono::Utc::now(),
540    };
541
542    Ok(Json(serde_json::json!({
543        "success": true,
544        "workflow": workflow
545    })))
546}
547
548/// Generate integration test request
549#[derive(Debug, Deserialize)]
550struct GenerateIntegrationTestRequest {
551    workflow: IntegrationWorkflow,
552    format: String, // "rust", "python", "javascript"
553}
554
555/// Generate integration test code from workflow
556async fn generate_integration_test(
557    State(_state): State<ApiState>,
558    Path(_id): Path<String>,
559    Json(request): Json<GenerateIntegrationTestRequest>,
560) -> Result<Json<serde_json::Value>, ApiError> {
561    let generator = IntegrationTestGenerator::new(request.workflow);
562
563    let test_code = match request.format.as_str() {
564        "rust" => generator.generate_rust_test(),
565        "python" => generator.generate_python_test(),
566        "javascript" | "js" => generator.generate_javascript_test(),
567        _ => return Err(ApiError::InvalidInput(format!("Unsupported format: {}", request.format))),
568    };
569
570    Ok(Json(serde_json::json!({
571        "success": true,
572        "format": request.format,
573        "test_code": test_code,
574        "message": "Integration test generated successfully"
575    })))
576}
577
578// Sync endpoints
579
580/// Get sync status
581async fn get_sync_status(State(state): State<ApiState>) -> Result<Json<SyncStatus>, ApiError> {
582    let sync_service = state
583        .sync_service
584        .ok_or_else(|| ApiError::NotFound("Sync service not available".to_string()))?;
585
586    let status = sync_service.get_status().await;
587    Ok(Json(status))
588}
589
590/// Get sync configuration
591async fn get_sync_config(State(state): State<ApiState>) -> Result<Json<SyncConfig>, ApiError> {
592    let sync_service = state
593        .sync_service
594        .ok_or_else(|| ApiError::NotFound("Sync service not available".to_string()))?;
595
596    let config = sync_service.get_config().await;
597    Ok(Json(config))
598}
599
600/// Update sync configuration
601async fn update_sync_config(
602    State(state): State<ApiState>,
603    Json(config): Json<SyncConfig>,
604) -> Result<Json<SyncConfig>, ApiError> {
605    let sync_service = state
606        .sync_service
607        .ok_or_else(|| ApiError::NotFound("Sync service not available".to_string()))?;
608
609    sync_service.update_config(config.clone()).await;
610    Ok(Json(config))
611}
612
613/// Trigger sync now
614async fn sync_now(State(state): State<ApiState>) -> Result<Json<serde_json::Value>, ApiError> {
615    let sync_service = state
616        .sync_service
617        .ok_or_else(|| ApiError::NotFound("Sync service not available".to_string()))?;
618
619    match sync_service.sync_now().await {
620        Ok((changes, updated)) => Ok(Json(serde_json::json!({
621            "success": true,
622            "changes_detected": changes.len(),
623            "fixtures_updated": updated,
624            "changes": changes,
625            "message": format!("Sync complete: {} changes detected, {} fixtures updated", changes.len(), updated)
626        }))),
627        Err(e) => Err(ApiError::Recorder(e)),
628    }
629}
630
631/// Get sync changes (from last sync)
632async fn get_sync_changes(
633    State(state): State<ApiState>,
634) -> Result<Json<serde_json::Value>, ApiError> {
635    let sync_service = state
636        .sync_service
637        .ok_or_else(|| ApiError::NotFound("Sync service not available".to_string()))?;
638
639    let status = sync_service.get_status().await;
640
641    Ok(Json(serde_json::json!({
642        "last_sync": status.last_sync,
643        "last_changes_detected": status.last_changes_detected,
644        "last_fixtures_updated": status.last_fixtures_updated,
645        "last_error": status.last_error,
646        "total_syncs": status.total_syncs,
647        "is_running": status.is_running,
648    })))
649}
650
651/// Convert a single recording to stub mapping
652#[derive(Debug, Deserialize)]
653struct ConvertRequest {
654    format: Option<String>, // "yaml" or "json"
655    detect_dynamic_values: Option<bool>,
656}
657
658async fn convert_to_stub(
659    State(state): State<ApiState>,
660    Path(id): Path<String>,
661    Json(req): Json<ConvertRequest>,
662) -> Result<Json<serde_json::Value>, ApiError> {
663    let exchange = state
664        .recorder
665        .database()
666        .get_exchange(&id)
667        .await?
668        .ok_or_else(|| ApiError::NotFound(format!("Request {} not found", id)))?;
669
670    let detect_dynamic = req.detect_dynamic_values.unwrap_or(true);
671    let converter = StubMappingConverter::new(detect_dynamic);
672    let stub = converter.convert(&exchange)?;
673
674    let format = match req.format.as_deref() {
675        Some("json") => StubFormat::Json,
676        Some("yaml") | None => StubFormat::Yaml,
677        _ => StubFormat::Yaml,
678    };
679
680    let content = converter.to_string(&stub, format)?;
681
682    Ok(Json(serde_json::json!({
683        "request_id": id,
684        "format": match format {
685            StubFormat::Yaml => "yaml",
686            StubFormat::Json => "json",
687        },
688        "stub": stub,
689        "content": content,
690    })))
691}
692
693/// Convert multiple recordings to stub mappings
694#[derive(Debug, Deserialize)]
695struct BatchConvertRequest {
696    request_ids: Vec<String>,
697    format: Option<String>,
698    detect_dynamic_values: Option<bool>,
699    deduplicate: Option<bool>,
700}
701
702async fn convert_batch(
703    State(state): State<ApiState>,
704    Json(req): Json<BatchConvertRequest>,
705) -> Result<Json<serde_json::Value>, ApiError> {
706    let detect_dynamic = req.detect_dynamic_values.unwrap_or(true);
707    let converter = StubMappingConverter::new(detect_dynamic);
708
709    let format = match req.format.as_deref() {
710        Some("json") => StubFormat::Json,
711        Some("yaml") | None => StubFormat::Yaml,
712        _ => StubFormat::Yaml,
713    };
714
715    let mut stubs = Vec::new();
716    let mut errors = Vec::new();
717
718    for request_id in &req.request_ids {
719        match state.recorder.database().get_exchange(request_id).await {
720            Ok(Some(exchange)) => match converter.convert(&exchange) {
721                Ok(stub) => {
722                    let content = converter.to_string(&stub, format)?;
723                    stubs.push(serde_json::json!({
724                        "request_id": request_id,
725                        "stub": stub,
726                        "content": content,
727                    }));
728                }
729                Err(e) => {
730                    errors.push(format!("Failed to convert {}: {}", request_id, e));
731                }
732            },
733            Ok(None) => {
734                errors.push(format!("Request {} not found", request_id));
735            }
736            Err(e) => {
737                errors.push(format!("Database error for {}: {}", request_id, e));
738            }
739        }
740    }
741
742    // Deduplicate if requested
743    if req.deduplicate.unwrap_or(false) {
744        // Simple deduplication based on identifier
745        let mut seen = std::collections::HashSet::new();
746        stubs.retain(|stub| {
747            if let Some(id) = stub.get("stub").and_then(|s| s.get("identifier")) {
748                if let Some(id_str) = id.as_str() {
749                    return seen.insert(id_str.to_string());
750                }
751            }
752            true
753        });
754    }
755
756    Ok(Json(serde_json::json!({
757        "total": req.request_ids.len(),
758        "converted": stubs.len(),
759        "errors": errors.len(),
760        "stubs": stubs,
761        "errors_list": errors,
762    })))
763}