mockforge_http/
management.rs

1/// Management API for MockForge
2///
3/// Provides REST endpoints for controlling mocks, server configuration,
4/// and integration with developer tools (VS Code extension, CI/CD, etc.)
5use axum::{
6    extract::{Path, Query, State},
7    http::StatusCode,
8    response::{IntoResponse, Json},
9    routing::{delete, get, post, put},
10    Router,
11};
12use mockforge_core::openapi::OpenApiSpec;
13#[cfg(feature = "smtp")]
14use mockforge_smtp::EmailSearchFilters;
15use serde::{Deserialize, Serialize};
16use std::sync::Arc;
17use tokio::sync::RwLock;
18use tracing::*;
19
20/// Mock configuration representation
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct MockConfig {
23    pub id: String,
24    pub name: String,
25    pub method: String,
26    pub path: String,
27    pub response: MockResponse,
28    pub enabled: bool,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub latency_ms: Option<u64>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub status_code: Option<u16>,
33}
34
35/// Mock response configuration
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct MockResponse {
38    pub body: serde_json::Value,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub headers: Option<std::collections::HashMap<String, String>>,
41}
42
43/// Server statistics
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ServerStats {
46    pub uptime_seconds: u64,
47    pub total_requests: u64,
48    pub active_mocks: usize,
49    pub enabled_mocks: usize,
50    pub registered_routes: usize,
51}
52
53/// Server configuration info
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ServerConfig {
56    pub version: String,
57    pub port: u16,
58    pub has_openapi_spec: bool,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub spec_path: Option<String>,
61}
62
63/// Shared state for the management API
64#[derive(Clone)]
65pub struct ManagementState {
66    pub mocks: Arc<RwLock<Vec<MockConfig>>>,
67    pub spec: Option<Arc<OpenApiSpec>>,
68    pub spec_path: Option<String>,
69    pub port: u16,
70    pub start_time: std::time::Instant,
71    pub request_counter: Arc<RwLock<u64>>,
72    #[cfg(feature = "smtp")]
73    pub smtp_registry: Option<Arc<mockforge_smtp::SmtpSpecRegistry>>,
74    #[cfg(feature = "mqtt")]
75    pub mqtt_broker: Option<Arc<mockforge_mqtt::MqttBroker>>,
76}
77
78impl ManagementState {
79    pub fn new(spec: Option<Arc<OpenApiSpec>>, spec_path: Option<String>, port: u16) -> Self {
80        Self {
81            mocks: Arc::new(RwLock::new(Vec::new())),
82            spec,
83            spec_path,
84            port,
85            start_time: std::time::Instant::now(),
86            request_counter: Arc::new(RwLock::new(0)),
87            #[cfg(feature = "smtp")]
88            smtp_registry: None,
89            #[cfg(feature = "mqtt")]
90            mqtt_broker: None,
91        }
92    }
93
94    #[cfg(feature = "smtp")]
95    pub fn with_smtp_registry(
96        mut self,
97        smtp_registry: Arc<mockforge_smtp::SmtpSpecRegistry>,
98    ) -> Self {
99        self.smtp_registry = Some(smtp_registry);
100        self
101    }
102
103    #[cfg(feature = "mqtt")]
104    pub fn with_mqtt_broker(mut self, mqtt_broker: Arc<mockforge_mqtt::MqttBroker>) -> Self {
105        self.mqtt_broker = Some(mqtt_broker);
106        self
107    }
108}
109
110/// List all mocks
111async fn list_mocks(State(state): State<ManagementState>) -> Json<serde_json::Value> {
112    let mocks = state.mocks.read().await;
113    Json(serde_json::json!({
114        "mocks": *mocks,
115        "total": mocks.len(),
116        "enabled": mocks.iter().filter(|m| m.enabled).count()
117    }))
118}
119
120/// Get a specific mock by ID
121async fn get_mock(
122    State(state): State<ManagementState>,
123    Path(id): Path<String>,
124) -> Result<Json<MockConfig>, StatusCode> {
125    let mocks = state.mocks.read().await;
126    mocks
127        .iter()
128        .find(|m| m.id == id)
129        .cloned()
130        .map(Json)
131        .ok_or(StatusCode::NOT_FOUND)
132}
133
134/// Create a new mock
135async fn create_mock(
136    State(state): State<ManagementState>,
137    Json(mut mock): Json<MockConfig>,
138) -> Result<Json<MockConfig>, StatusCode> {
139    let mut mocks = state.mocks.write().await;
140
141    // Generate ID if not provided
142    if mock.id.is_empty() {
143        mock.id = uuid::Uuid::new_v4().to_string();
144    }
145
146    // Check for duplicate ID
147    if mocks.iter().any(|m| m.id == mock.id) {
148        return Err(StatusCode::CONFLICT);
149    }
150
151    info!("Creating mock: {} {} {}", mock.method, mock.path, mock.id);
152    mocks.push(mock.clone());
153    Ok(Json(mock))
154}
155
156/// Update an existing mock
157async fn update_mock(
158    State(state): State<ManagementState>,
159    Path(id): Path<String>,
160    Json(updated_mock): Json<MockConfig>,
161) -> Result<Json<MockConfig>, StatusCode> {
162    let mut mocks = state.mocks.write().await;
163
164    let position = mocks.iter().position(|m| m.id == id).ok_or(StatusCode::NOT_FOUND)?;
165
166    info!("Updating mock: {}", id);
167    mocks[position] = updated_mock.clone();
168    Ok(Json(updated_mock))
169}
170
171/// Delete a mock
172async fn delete_mock(
173    State(state): State<ManagementState>,
174    Path(id): Path<String>,
175) -> Result<StatusCode, StatusCode> {
176    let mut mocks = state.mocks.write().await;
177
178    let position = mocks.iter().position(|m| m.id == id).ok_or(StatusCode::NOT_FOUND)?;
179
180    info!("Deleting mock: {}", id);
181    mocks.remove(position);
182    Ok(StatusCode::NO_CONTENT)
183}
184
185/// Get server statistics
186async fn get_stats(State(state): State<ManagementState>) -> Json<ServerStats> {
187    let mocks = state.mocks.read().await;
188    let request_count = *state.request_counter.read().await;
189
190    Json(ServerStats {
191        uptime_seconds: state.start_time.elapsed().as_secs(),
192        total_requests: request_count,
193        active_mocks: mocks.len(),
194        enabled_mocks: mocks.iter().filter(|m| m.enabled).count(),
195        registered_routes: mocks.len(), // This could be enhanced with actual route registry info
196    })
197}
198
199/// Get server configuration
200async fn get_config(State(state): State<ManagementState>) -> Json<ServerConfig> {
201    Json(ServerConfig {
202        version: env!("CARGO_PKG_VERSION").to_string(),
203        port: state.port,
204        has_openapi_spec: state.spec.is_some(),
205        spec_path: state.spec_path.clone(),
206    })
207}
208
209/// Health check endpoint
210async fn health_check() -> Json<serde_json::Value> {
211    Json(serde_json::json!({
212        "status": "healthy",
213        "service": "mockforge-management",
214        "timestamp": chrono::Utc::now().to_rfc3339()
215    }))
216}
217
218/// Export format for mock configurations
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "lowercase")]
221pub enum ExportFormat {
222    Json,
223    Yaml,
224}
225
226/// Export mocks in specified format
227async fn export_mocks(
228    State(state): State<ManagementState>,
229    axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
230) -> Result<(StatusCode, String), StatusCode> {
231    let mocks = state.mocks.read().await;
232
233    let format = params
234        .get("format")
235        .map(|f| match f.as_str() {
236            "yaml" | "yml" => ExportFormat::Yaml,
237            _ => ExportFormat::Json,
238        })
239        .unwrap_or(ExportFormat::Json);
240
241    match format {
242        ExportFormat::Json => serde_json::to_string_pretty(&*mocks)
243            .map(|json| (StatusCode::OK, json))
244            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR),
245        ExportFormat::Yaml => serde_yaml::to_string(&*mocks)
246            .map(|yaml| (StatusCode::OK, yaml))
247            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR),
248    }
249}
250
251/// Import mocks from JSON/YAML
252async fn import_mocks(
253    State(state): State<ManagementState>,
254    Json(mocks): Json<Vec<MockConfig>>,
255) -> impl IntoResponse {
256    let mut current_mocks = state.mocks.write().await;
257    current_mocks.clear();
258    current_mocks.extend(mocks);
259    Json(serde_json::json!({ "status": "imported", "count": current_mocks.len() }))
260}
261
262#[cfg(feature = "smtp")]
263/// List SMTP emails in mailbox
264async fn list_smtp_emails(State(state): State<ManagementState>) -> impl IntoResponse {
265    if let Some(ref smtp_registry) = state.smtp_registry {
266        match smtp_registry.get_emails() {
267            Ok(emails) => (StatusCode::OK, Json(serde_json::json!(emails))),
268            Err(e) => (
269                StatusCode::INTERNAL_SERVER_ERROR,
270                Json(serde_json::json!({
271                    "error": "Failed to retrieve emails",
272                    "message": e.to_string()
273                })),
274            ),
275        }
276    } else {
277        (
278            StatusCode::NOT_IMPLEMENTED,
279            Json(serde_json::json!({
280                "error": "SMTP mailbox management not available",
281                "message": "SMTP server is not enabled or registry not available."
282            })),
283        )
284    }
285}
286
287/// Get specific SMTP email
288#[cfg(feature = "smtp")]
289async fn get_smtp_email(
290    State(state): State<ManagementState>,
291    Path(id): Path<String>,
292) -> impl IntoResponse {
293    if let Some(ref smtp_registry) = state.smtp_registry {
294        match smtp_registry.get_email_by_id(&id) {
295            Ok(Some(email)) => (StatusCode::OK, Json(serde_json::json!(email))),
296            Ok(None) => (
297                StatusCode::NOT_FOUND,
298                Json(serde_json::json!({
299                    "error": "Email not found",
300                    "id": id
301                })),
302            ),
303            Err(e) => (
304                StatusCode::INTERNAL_SERVER_ERROR,
305                Json(serde_json::json!({
306                    "error": "Failed to retrieve email",
307                    "message": e.to_string()
308                })),
309            ),
310        }
311    } else {
312        (
313            StatusCode::NOT_IMPLEMENTED,
314            Json(serde_json::json!({
315                "error": "SMTP mailbox management not available",
316                "message": "SMTP server is not enabled or registry not available."
317            })),
318        )
319    }
320}
321
322/// Clear SMTP mailbox
323#[cfg(feature = "smtp")]
324async fn clear_smtp_mailbox(State(state): State<ManagementState>) -> impl IntoResponse {
325    if let Some(ref smtp_registry) = state.smtp_registry {
326        match smtp_registry.clear_mailbox() {
327            Ok(()) => (
328                StatusCode::OK,
329                Json(serde_json::json!({
330                    "message": "Mailbox cleared successfully"
331                })),
332            ),
333            Err(e) => (
334                StatusCode::INTERNAL_SERVER_ERROR,
335                Json(serde_json::json!({
336                    "error": "Failed to clear mailbox",
337                    "message": e.to_string()
338                })),
339            ),
340        }
341    } else {
342        (
343            StatusCode::NOT_IMPLEMENTED,
344            Json(serde_json::json!({
345                "error": "SMTP mailbox management not available",
346                "message": "SMTP server is not enabled or registry not available."
347            })),
348        )
349    }
350}
351
352/// Export SMTP mailbox
353#[cfg(feature = "smtp")]
354async fn export_smtp_mailbox(
355    Query(params): Query<std::collections::HashMap<String, String>>,
356) -> impl IntoResponse {
357    let format = params.get("format").unwrap_or(&"json".to_string()).clone();
358    (
359        StatusCode::NOT_IMPLEMENTED,
360        Json(serde_json::json!({
361            "error": "SMTP mailbox management not available via HTTP API",
362            "message": "SMTP server runs separately from HTTP server. Use CLI commands to access mailbox.",
363            "requested_format": format
364        })),
365    )
366}
367
368/// Search SMTP emails
369#[cfg(feature = "smtp")]
370async fn search_smtp_emails(
371    State(state): State<ManagementState>,
372    Query(params): Query<std::collections::HashMap<String, String>>,
373) -> impl IntoResponse {
374    if let Some(ref smtp_registry) = state.smtp_registry {
375        let filters = EmailSearchFilters {
376            sender: params.get("sender").cloned(),
377            recipient: params.get("recipient").cloned(),
378            subject: params.get("subject").cloned(),
379            body: params.get("body").cloned(),
380            since: params
381                .get("since")
382                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
383                .map(|dt| dt.with_timezone(&chrono::Utc)),
384            until: params
385                .get("until")
386                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
387                .map(|dt| dt.with_timezone(&chrono::Utc)),
388            use_regex: params.get("regex").map(|s| s == "true").unwrap_or(false),
389            case_sensitive: params.get("case_sensitive").map(|s| s == "true").unwrap_or(false),
390        };
391
392        match smtp_registry.search_emails(filters) {
393            Ok(emails) => (StatusCode::OK, Json(serde_json::json!(emails))),
394            Err(e) => (
395                StatusCode::INTERNAL_SERVER_ERROR,
396                Json(serde_json::json!({
397                    "error": "Failed to search emails",
398                    "message": e.to_string()
399                })),
400            ),
401        }
402    } else {
403        (
404            StatusCode::NOT_IMPLEMENTED,
405            Json(serde_json::json!({
406                "error": "SMTP mailbox management not available",
407                "message": "SMTP server is not enabled or registry not available."
408            })),
409        )
410    }
411}
412
413/// MQTT broker statistics
414#[cfg(feature = "mqtt")]
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct MqttBrokerStats {
417    pub connected_clients: usize,
418    pub active_topics: usize,
419    pub retained_messages: usize,
420    pub total_subscriptions: usize,
421}
422
423/// MQTT management handlers
424#[cfg(feature = "mqtt")]
425async fn get_mqtt_stats(State(state): State<ManagementState>) -> impl IntoResponse {
426    if let Some(broker) = &state.mqtt_broker {
427        let connected_clients = broker.get_connected_clients().await.len();
428        let active_topics = broker.get_active_topics().await.len();
429        let stats = broker.get_topic_stats().await;
430
431        let broker_stats = MqttBrokerStats {
432            connected_clients,
433            active_topics,
434            retained_messages: stats.retained_messages,
435            total_subscriptions: stats.total_subscriptions,
436        };
437
438        Json(broker_stats).into_response()
439    } else {
440        (StatusCode::SERVICE_UNAVAILABLE, "MQTT broker not available").into_response()
441    }
442}
443
444#[cfg(feature = "mqtt")]
445async fn get_mqtt_clients(State(state): State<ManagementState>) -> impl IntoResponse {
446    if let Some(broker) = &state.mqtt_broker {
447        let clients = broker.get_connected_clients().await;
448        Json(serde_json::json!({
449            "clients": clients
450        }))
451        .into_response()
452    } else {
453        (StatusCode::SERVICE_UNAVAILABLE, "MQTT broker not available").into_response()
454    }
455}
456
457#[cfg(feature = "mqtt")]
458async fn get_mqtt_topics(State(state): State<ManagementState>) -> impl IntoResponse {
459    if let Some(broker) = &state.mqtt_broker {
460        let topics = broker.get_active_topics().await;
461        Json(serde_json::json!({
462            "topics": topics
463        }))
464        .into_response()
465    } else {
466        (StatusCode::SERVICE_UNAVAILABLE, "MQTT broker not available").into_response()
467    }
468}
469
470#[cfg(feature = "mqtt")]
471async fn disconnect_mqtt_client(
472    State(state): State<ManagementState>,
473    Path(client_id): Path<String>,
474) -> impl IntoResponse {
475    if let Some(broker) = &state.mqtt_broker {
476        match broker.disconnect_client(&client_id).await {
477            Ok(_) => {
478                (StatusCode::OK, format!("Client '{}' disconnected", client_id)).into_response()
479            }
480            Err(e) => {
481                (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to disconnect client: {}", e))
482                    .into_response()
483            }
484        }
485    } else {
486        (StatusCode::SERVICE_UNAVAILABLE, "MQTT broker not available").into_response()
487    }
488}
489
490/// Build the management API router
491pub fn management_router(state: ManagementState) -> Router {
492    let router = Router::new()
493        .route("/health", get(health_check))
494        .route("/stats", get(get_stats))
495        .route("/config", get(get_config))
496        .route("/mocks", get(list_mocks))
497        .route("/mocks", post(create_mock))
498        .route("/mocks/{id}", get(get_mock))
499        .route("/mocks/{id}", put(update_mock))
500        .route("/mocks/{id}", delete(delete_mock))
501        .route("/export", get(export_mocks))
502        .route("/import", post(import_mocks));
503
504    #[cfg(feature = "smtp")]
505    let router = router
506        .route("/smtp/mailbox", get(list_smtp_emails))
507        .route("/smtp/mailbox", delete(clear_smtp_mailbox))
508        .route("/smtp/mailbox/{id}", get(get_smtp_email))
509        .route("/smtp/mailbox/export", get(export_smtp_mailbox))
510        .route("/smtp/mailbox/search", get(search_smtp_emails));
511
512    #[cfg(not(feature = "smtp"))]
513    let router = router;
514
515    #[cfg(feature = "mqtt")]
516    let router = router
517        .route("/mqtt/stats", get(get_mqtt_stats))
518        .route("/mqtt/clients", get(get_mqtt_clients))
519        .route("/mqtt/topics", get(get_mqtt_topics))
520        .route("/mqtt/clients/{client_id}", delete(disconnect_mqtt_client));
521
522    #[cfg(not(feature = "mqtt"))]
523    let router = router;
524
525    router.with_state(state)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[tokio::test]
533    async fn test_create_and_get_mock() {
534        let state = ManagementState::new(None, None, 3000);
535
536        let mock = MockConfig {
537            id: "test-1".to_string(),
538            name: "Test Mock".to_string(),
539            method: "GET".to_string(),
540            path: "/test".to_string(),
541            response: MockResponse {
542                body: serde_json::json!({"message": "test"}),
543                headers: None,
544            },
545            enabled: true,
546            latency_ms: None,
547            status_code: Some(200),
548        };
549
550        // Create mock
551        {
552            let mut mocks = state.mocks.write().await;
553            mocks.push(mock.clone());
554        }
555
556        // Get mock
557        let mocks = state.mocks.read().await;
558        let found = mocks.iter().find(|m| m.id == "test-1");
559        assert!(found.is_some());
560        assert_eq!(found.unwrap().name, "Test Mock");
561    }
562
563    #[tokio::test]
564    async fn test_server_stats() {
565        let state = ManagementState::new(None, None, 3000);
566
567        // Add some mocks
568        {
569            let mut mocks = state.mocks.write().await;
570            mocks.push(MockConfig {
571                id: "1".to_string(),
572                name: "Mock 1".to_string(),
573                method: "GET".to_string(),
574                path: "/test1".to_string(),
575                response: MockResponse {
576                    body: serde_json::json!({}),
577                    headers: None,
578                },
579                enabled: true,
580                latency_ms: None,
581                status_code: Some(200),
582            });
583            mocks.push(MockConfig {
584                id: "2".to_string(),
585                name: "Mock 2".to_string(),
586                method: "POST".to_string(),
587                path: "/test2".to_string(),
588                response: MockResponse {
589                    body: serde_json::json!({}),
590                    headers: None,
591                },
592                enabled: false,
593                latency_ms: None,
594                status_code: Some(201),
595            });
596        }
597
598        let mocks = state.mocks.read().await;
599        assert_eq!(mocks.len(), 2);
600        assert_eq!(mocks.iter().filter(|m| m.enabled).count(), 1);
601    }
602}