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