mockforge_http/
state_machine_api.rs

1//! State machine API handlers
2//!
3//! Provides REST endpoints for managing scenario state machines, including
4//! CRUD operations, execution, and import/export functionality.
5
6use axum::{
7    extract::{Path, State},
8    http::StatusCode,
9    Json,
10};
11use mockforge_core::intelligent_behavior::{rules::StateMachine, visual_layout::VisualLayout};
12use mockforge_scenarios::ScenarioManifest;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::HashMap;
16use tracing::error;
17
18// Re-export ManagementState for use in handlers
19use crate::management::ManagementState;
20
21// ===== Request/Response Types =====
22
23/// Request to create or update a state machine
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct StateMachineRequest {
26    /// State machine definition
27    pub state_machine: StateMachine,
28    /// Optional visual layout
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub visual_layout: Option<VisualLayout>,
31}
32
33/// Request to execute a state transition
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct TransitionRequest {
36    /// Resource ID to transition
37    pub resource_id: String,
38    /// Target state
39    pub to_state: String,
40    /// Optional context variables for condition evaluation
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub context: Option<HashMap<String, Value>>,
43}
44
45/// Request to create a state instance
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct CreateInstanceRequest {
48    /// Resource ID
49    pub resource_id: String,
50    /// Resource type (must match a state machine resource_type)
51    pub resource_type: String,
52}
53
54/// Response for state machine operations
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct StateMachineResponse {
57    /// State machine definition
58    pub state_machine: StateMachine,
59    /// Optional visual layout
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub visual_layout: Option<VisualLayout>,
62}
63
64/// Response for listing state machines
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct StateMachineListResponse {
67    /// List of state machines
68    pub state_machines: Vec<StateMachineInfo>,
69    /// Total count
70    pub total: usize,
71}
72
73/// Information about a state machine
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct StateMachineInfo {
76    /// Resource type
77    pub resource_type: String,
78    /// Number of states
79    pub state_count: usize,
80    /// Number of transitions
81    pub transition_count: usize,
82    /// Number of sub-scenarios
83    pub sub_scenario_count: usize,
84    /// Whether it has a visual layout
85    pub has_visual_layout: bool,
86}
87
88/// Response for state instance operations
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct StateInstanceResponse {
91    /// Resource ID
92    pub resource_id: String,
93    /// Current state
94    pub current_state: String,
95    /// Resource type
96    pub resource_type: String,
97    /// State history count
98    pub history_count: usize,
99    /// State data
100    pub state_data: HashMap<String, Value>,
101}
102
103/// Response for listing state instances
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct StateInstanceListResponse {
106    /// List of instances
107    pub instances: Vec<StateInstanceResponse>,
108    /// Total count
109    pub total: usize,
110}
111
112/// Response for next states query
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct NextStatesResponse {
115    /// List of possible next states
116    pub next_states: Vec<String>,
117}
118
119/// Response for import/export operations
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ImportExportResponse {
122    /// State machines
123    pub state_machines: Vec<StateMachine>,
124    /// Visual layouts by resource type
125    pub visual_layouts: HashMap<String, VisualLayout>,
126}
127
128// ===== Handlers =====
129
130/// List all state machines
131pub async fn list_state_machines(
132    State(state): State<ManagementState>,
133) -> Result<Json<StateMachineListResponse>, StatusCode> {
134    let manager = state.state_machine_manager.read().await;
135
136    // Get all state machines
137    let machines = manager.list_state_machines().await;
138
139    // Check visual layouts separately for each state machine
140    // We need to check if a visual layout exists for each state machine
141    let mut state_machine_list = Vec::new();
142    for (resource_type, sm) in machines.iter() {
143        let has_visual_layout = manager.get_visual_layout(resource_type).await.is_some();
144        state_machine_list.push(StateMachineInfo {
145            resource_type: resource_type.clone(),
146            state_count: sm.states.len(),
147            transition_count: sm.transitions.len(),
148            sub_scenario_count: sm.sub_scenarios.len(),
149            has_visual_layout,
150        });
151    }
152
153    Ok(Json(StateMachineListResponse {
154        state_machines: state_machine_list.clone(),
155        total: state_machine_list.len(),
156    }))
157}
158
159/// Get a state machine by resource type
160pub async fn get_state_machine(
161    State(state): State<ManagementState>,
162    Path(resource_type): Path<String>,
163) -> Result<Json<StateMachineResponse>, StatusCode> {
164    let manager = state.state_machine_manager.read().await;
165
166    let state_machine =
167        manager.get_state_machine(&resource_type).await.ok_or(StatusCode::NOT_FOUND)?;
168
169    let visual_layout = manager.get_visual_layout(&resource_type).await;
170
171    // Convert types from mockforge-scenarios' dependency version to local version
172    // by serializing and deserializing through JSON
173    let state_machine_json =
174        serde_json::to_value(&state_machine).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
175    let state_machine: StateMachine = serde_json::from_value(state_machine_json)
176        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
177
178    let visual_layout: Option<VisualLayout> = visual_layout
179        .map(|layout| {
180            let layout_json =
181                serde_json::to_value(&layout).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
182            serde_json::from_value(layout_json).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
183        })
184        .transpose()
185        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
186
187    Ok(Json(StateMachineResponse {
188        state_machine,
189        visual_layout,
190    }))
191}
192
193/// Create or update a state machine
194pub async fn create_state_machine(
195    State(state): State<ManagementState>,
196    Json(request): Json<StateMachineRequest>,
197) -> Result<Json<StateMachineResponse>, StatusCode> {
198    let manager = state.state_machine_manager.write().await;
199
200    // Convert types from local version to mockforge-scenarios' dependency version
201    // by serializing and deserializing through JSON
202    // The ScenarioManifest uses types from mockforge-scenarios' mockforge-core dependency (0.2.9)
203    // We need to convert our local StateMachine to that version
204    let state_machine_json = serde_json::to_value(&request.state_machine)
205        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
206
207    // Create manifest with JSON values - serde will deserialize into the correct types
208    // We need to provide all required fields for ScenarioManifest
209    let mut manifest_json = serde_json::json!({
210        "manifest_version": "1.0",
211        "name": "api",
212        "version": "1.0.0",
213        "title": "API State Machine",
214        "description": "State machine created via API",
215        "author": "api",
216        "category": "other",
217        "compatibility": {
218            "min_version": "0.1.0",
219            "max_version": null
220        },
221        "files": [],
222        "state_machines": [state_machine_json],
223        "state_machine_graphs": {}
224    });
225
226    if let Some(layout) = &request.visual_layout {
227        let layout_json =
228            serde_json::to_value(layout).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
229        manifest_json["state_machine_graphs"][&request.state_machine.resource_type] = layout_json;
230    }
231
232    let manifest: ScenarioManifest =
233        serde_json::from_value(manifest_json).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
234
235    // Validate the first state machine from manifest
236    if let Some(sm) = manifest.state_machines.first() {
237        if let Err(e) = manager.validate_state_machine(sm) {
238            error!("Invalid state machine: {}", e);
239            return Err(StatusCode::BAD_REQUEST);
240        }
241    }
242
243    if let Err(e) = manager.load_from_manifest(&manifest).await {
244        error!("Failed to load state machine: {}", e);
245        return Err(StatusCode::INTERNAL_SERVER_ERROR);
246    }
247
248    // Visual layout is already set in the manifest, no need to set separately
249
250    // Broadcast WebSocket event
251    if let Some(ref ws_tx) = state.ws_broadcast {
252        let event = crate::management_ws::MockEvent::state_machine_updated(
253            request.state_machine.resource_type.clone(),
254            request.state_machine.clone(),
255        );
256        let _ = ws_tx.send(event);
257    }
258
259    // Get state machine and layout back after loading (returns version from mockforge-scenarios' dependency)
260    let state_machine_from_manager = manager
261        .get_state_machine(&request.state_machine.resource_type)
262        .await
263        .ok_or(StatusCode::NOT_FOUND)?;
264    let visual_layout_from_manager =
265        manager.get_visual_layout(&request.state_machine.resource_type).await;
266
267    // Convert back to local types
268    let state_machine_json = serde_json::to_value(&state_machine_from_manager)
269        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
270    let state_machine: StateMachine = serde_json::from_value(state_machine_json)
271        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
272
273    let visual_layout: Option<VisualLayout> = visual_layout_from_manager
274        .map(|layout| {
275            let layout_json =
276                serde_json::to_value(&layout).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
277            serde_json::from_value(layout_json).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
278        })
279        .transpose()
280        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
281
282    Ok(Json(StateMachineResponse {
283        state_machine,
284        visual_layout,
285    }))
286}
287
288/// Delete a state machine
289pub async fn delete_state_machine(
290    State(state): State<ManagementState>,
291    Path(resource_type): Path<String>,
292) -> Result<StatusCode, StatusCode> {
293    let manager = state.state_machine_manager.write().await;
294
295    // Delete the state machine
296    let deleted = manager.delete_state_machine(&resource_type).await;
297
298    if !deleted {
299        return Err(StatusCode::NOT_FOUND);
300    }
301
302    // Broadcast WebSocket event
303    if let Some(ref ws_tx) = state.ws_broadcast {
304        let event = crate::management_ws::MockEvent::state_machine_deleted(resource_type);
305        let _ = ws_tx.send(event);
306    }
307
308    Ok(StatusCode::NO_CONTENT)
309}
310
311/// List all state instances
312pub async fn list_instances(
313    State(state): State<ManagementState>,
314) -> Result<Json<StateInstanceListResponse>, StatusCode> {
315    let manager = state.state_machine_manager.read().await;
316
317    let instances = manager.list_instances().await;
318
319    let instance_responses: Vec<StateInstanceResponse> = instances
320        .iter()
321        .map(|i| StateInstanceResponse {
322            resource_id: i.resource_id.clone(),
323            current_state: i.current_state.clone(),
324            resource_type: i.resource_type.clone(),
325            history_count: i.state_history.len(),
326            state_data: i.state_data.clone(),
327        })
328        .collect();
329
330    Ok(Json(StateInstanceListResponse {
331        instances: instance_responses,
332        total: instances.len(),
333    }))
334}
335
336/// Get a state instance by resource ID
337pub async fn get_instance(
338    State(state): State<ManagementState>,
339    Path(resource_id): Path<String>,
340) -> Result<Json<StateInstanceResponse>, StatusCode> {
341    let manager = state.state_machine_manager.read().await;
342
343    let instance = manager.get_instance(&resource_id).await.ok_or(StatusCode::NOT_FOUND)?;
344
345    Ok(Json(StateInstanceResponse {
346        resource_id: instance.resource_id,
347        current_state: instance.current_state,
348        resource_type: instance.resource_type,
349        history_count: instance.state_history.len(),
350        state_data: instance.state_data,
351    }))
352}
353
354/// Create a new state instance
355pub async fn create_instance(
356    State(state): State<ManagementState>,
357    Json(request): Json<CreateInstanceRequest>,
358) -> Result<Json<StateInstanceResponse>, StatusCode> {
359    let manager = state.state_machine_manager.write().await;
360
361    if let Err(e) = manager.create_instance(&request.resource_id, &request.resource_type).await {
362        error!("Failed to create instance: {}", e);
363        return Err(StatusCode::BAD_REQUEST);
364    }
365
366    let instance = manager
367        .get_instance(&request.resource_id)
368        .await
369        .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
370
371    // Broadcast WebSocket event
372    if let Some(ref ws_tx) = state.ws_broadcast {
373        let event = crate::management_ws::MockEvent::state_instance_created(
374            instance.resource_id.clone(),
375            instance.resource_type.clone(),
376            instance.current_state.clone(),
377        );
378        let _ = ws_tx.send(event);
379    }
380
381    Ok(Json(StateInstanceResponse {
382        resource_id: instance.resource_id,
383        current_state: instance.current_state,
384        resource_type: instance.resource_type,
385        history_count: instance.state_history.len(),
386        state_data: instance.state_data,
387    }))
388}
389
390/// Execute a state transition
391pub async fn execute_transition(
392    State(state): State<ManagementState>,
393    Json(request): Json<TransitionRequest>,
394) -> Result<Json<StateInstanceResponse>, StatusCode> {
395    let manager = state.state_machine_manager.write().await;
396
397    if let Err(e) = manager
398        .execute_transition(&request.resource_id, &request.to_state, request.context)
399        .await
400    {
401        error!("Failed to execute transition: {}", e);
402        return Err(StatusCode::BAD_REQUEST);
403    }
404
405    let instance = manager.get_instance(&request.resource_id).await.ok_or(StatusCode::NOT_FOUND)?;
406
407    // Get the previous state from history if available
408    let from_state = instance
409        .state_history
410        .last()
411        .map(|h| h.from_state.clone())
412        .unwrap_or_else(|| instance.current_state.clone());
413
414    // Broadcast WebSocket event
415    if let Some(ref ws_tx) = state.ws_broadcast {
416        let event = crate::management_ws::MockEvent::state_transitioned(
417            instance.resource_id.clone(),
418            instance.resource_type.clone(),
419            from_state,
420            instance.current_state.clone(),
421            instance.state_data.clone(),
422        );
423        let _ = ws_tx.send(event);
424    }
425
426    Ok(Json(StateInstanceResponse {
427        resource_id: instance.resource_id,
428        current_state: instance.current_state,
429        resource_type: instance.resource_type,
430        history_count: instance.state_history.len(),
431        state_data: instance.state_data,
432    }))
433}
434
435/// Get next possible states for a resource
436pub async fn get_next_states(
437    State(state): State<ManagementState>,
438    Path(resource_id): Path<String>,
439) -> Result<Json<NextStatesResponse>, StatusCode> {
440    let manager = state.state_machine_manager.read().await;
441
442    let next_states =
443        manager.get_next_states(&resource_id).await.map_err(|_| StatusCode::NOT_FOUND)?;
444
445    Ok(Json(NextStatesResponse { next_states }))
446}
447
448/// Get current state of a resource
449pub async fn get_current_state(
450    State(state): State<ManagementState>,
451    Path(resource_id): Path<String>,
452) -> Result<Json<serde_json::Value>, StatusCode> {
453    let manager = state.state_machine_manager.read().await;
454
455    let current_state =
456        manager.get_current_state(&resource_id).await.ok_or(StatusCode::NOT_FOUND)?;
457
458    Ok(Json(serde_json::json!({
459        "resource_id": resource_id,
460        "current_state": current_state
461    })))
462}
463
464/// Export state machines as JSON
465pub async fn export_state_machines(
466    State(state): State<ManagementState>,
467) -> Result<Json<ImportExportResponse>, StatusCode> {
468    let manager = state.state_machine_manager.read().await;
469
470    // Export all state machines and visual layouts (returns versions from mockforge-scenarios' dependency)
471    let (state_machines_from_manager, visual_layouts_from_manager) = manager.export_all().await;
472
473    // Convert to local types by serializing and deserializing
474    let state_machines: Vec<StateMachine> = state_machines_from_manager
475        .into_iter()
476        .map(|sm| {
477            let json = serde_json::to_value(&sm).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
478            serde_json::from_value(json).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
479        })
480        .collect::<Result<Vec<_>, StatusCode>>()?;
481
482    let visual_layouts: HashMap<String, VisualLayout> = visual_layouts_from_manager
483        .into_iter()
484        .map(|(k, v)| {
485            let json = serde_json::to_value(&v).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
486            let layout: VisualLayout =
487                serde_json::from_value(json).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
488            Ok((k, layout))
489        })
490        .collect::<Result<HashMap<_, _>, StatusCode>>()?;
491
492    Ok(Json(ImportExportResponse {
493        state_machines,
494        visual_layouts,
495    }))
496}
497
498/// Import state machines from JSON
499pub async fn import_state_machines(
500    State(state): State<ManagementState>,
501    Json(request): Json<ImportExportResponse>,
502) -> Result<StatusCode, StatusCode> {
503    let manager = state.state_machine_manager.write().await;
504
505    // Create manifest from JSON to let serde handle type conversion
506    // We need to provide all required fields for ScenarioManifest
507    let manifest_json = serde_json::json!({
508        "manifest_version": "1.0",
509        "name": "imported",
510        "version": "1.0.0",
511        "title": "Imported State Machines",
512        "description": "State machines imported via API",
513        "author": "api",
514        "category": "other",
515        "compatibility": {
516            "min_version": "0.1.0",
517            "max_version": null
518        },
519        "files": [],
520        "state_machines": request.state_machines,
521        "state_machine_graphs": request.visual_layouts
522    });
523
524    let manifest: ScenarioManifest =
525        serde_json::from_value(manifest_json).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
526
527    if let Err(e) = manager.load_from_manifest(&manifest).await {
528        error!("Failed to import state machines: {}", e);
529        return Err(StatusCode::BAD_REQUEST);
530    }
531
532    // Visual layouts are already set in the manifest, no need to set separately
533
534    Ok(StatusCode::CREATED)
535}
536
537/// Create the state machine API router
538///
539/// This function creates routes that use ManagementState, so they can be
540/// nested within the management router.
541pub fn create_state_machine_routes() -> axum::Router<ManagementState> {
542    use axum::{
543        routing::{delete, get, post, put},
544        Router,
545    };
546
547    Router::new()
548        // State machine CRUD
549        .route("/", get(list_state_machines))
550        .route("/", post(create_state_machine))
551        .route("/{resource_type}", get(get_state_machine))
552        .route("/{resource_type}", put(create_state_machine))
553        .route("/{resource_type}", delete(delete_state_machine))
554
555        // State instance operations
556        .route("/instances", get(list_instances))
557        .route("/instances", post(create_instance))
558        .route("/instances/{resource_id}", get(get_instance))
559        .route("/instances/{resource_id}/state", get(get_current_state))
560        .route("/instances/{resource_id}/next-states", get(get_next_states))
561        .route("/instances/{resource_id}/transition", post(execute_transition))
562
563        // Import/Export
564        .route("/export", get(export_state_machines))
565        .route("/import", post(import_state_machines))
566}