mockforge_http/
scenarios_runtime.rs1use axum::extract::{Path as AxumPath, Query, State};
23use axum::http::StatusCode;
24use axum::response::{IntoResponse, Response};
25use axum::routing::{get, post};
26use axum::{Json, Router};
27use mockforge_core::consistency::ConsistencyEngine;
28use mockforge_scenarios::ScenarioStorage;
29use serde::{Deserialize, Serialize};
30use std::sync::Arc;
31use tokio::sync::RwLock;
32
33#[derive(Clone)]
35pub struct ScenarioRuntimeState {
36 inner: Arc<Inner>,
37}
38
39struct Inner {
40 storage: RwLock<ScenarioStorage>,
44 engine: Arc<ConsistencyEngine>,
47}
48
49impl ScenarioRuntimeState {
50 pub fn new(storage: ScenarioStorage, engine: Arc<ConsistencyEngine>) -> Self {
52 Self {
53 inner: Arc::new(Inner {
54 storage: RwLock::new(storage),
55 engine,
56 }),
57 }
58 }
59}
60
61#[derive(Debug, Serialize)]
62struct ScenarioSummary {
63 name: String,
64 version: String,
65 source: String,
66 installed_at: u64,
67 description: String,
68}
69
70#[derive(Debug, Serialize)]
71struct ListResponse {
72 scenarios: Vec<ScenarioSummary>,
73}
74
75async fn list_handler(State(state): State<ScenarioRuntimeState>) -> Json<ListResponse> {
76 let storage = state.inner.storage.read().await;
77 let scenarios = storage
78 .list()
79 .into_iter()
80 .map(|s| ScenarioSummary {
81 name: s.name.clone(),
82 version: s.version.clone(),
83 source: s.source.clone(),
84 installed_at: s.installed_at,
85 description: s.manifest.description.clone(),
86 })
87 .collect();
88 Json(ListResponse { scenarios })
89}
90
91#[derive(Debug, Deserialize)]
92struct WorkspaceQuery {
93 #[serde(default)]
94 workspace: Option<String>,
95}
96
97fn workspace_or_default(q: &WorkspaceQuery) -> String {
98 q.workspace.clone().unwrap_or_else(|| "default".to_string())
99}
100
101async fn activate_handler(
102 State(state): State<ScenarioRuntimeState>,
103 AxumPath(name): AxumPath<String>,
104 Query(q): Query<WorkspaceQuery>,
105) -> Response {
106 let exists = {
110 let storage = state.inner.storage.read().await;
111 storage.get_latest(&name).is_some()
112 };
113 if !exists {
114 return (
115 StatusCode::NOT_FOUND,
116 Json(serde_json::json!({
117 "error": "scenario_not_found",
118 "message": format!("No installed scenario named '{}'", name),
119 })),
120 )
121 .into_response();
122 }
123
124 let workspace = workspace_or_default(&q);
125 if let Err(e) = state.inner.engine.set_active_scenario(&workspace, name.clone()).await {
126 return (
127 StatusCode::INTERNAL_SERVER_ERROR,
128 Json(serde_json::json!({
129 "error": "activate_failed",
130 "message": e.to_string(),
131 })),
132 )
133 .into_response();
134 }
135 Json(serde_json::json!({
136 "active": name,
137 "workspace": workspace,
138 }))
139 .into_response()
140}
141
142async fn deactivate_handler(
143 State(state): State<ScenarioRuntimeState>,
144 Query(q): Query<WorkspaceQuery>,
145) -> Response {
146 let workspace = workspace_or_default(&q);
147 if let Err(e) = state.inner.engine.set_active_scenario(&workspace, String::new()).await {
151 return (
152 StatusCode::INTERNAL_SERVER_ERROR,
153 Json(serde_json::json!({
154 "error": "deactivate_failed",
155 "message": e.to_string(),
156 })),
157 )
158 .into_response();
159 }
160 StatusCode::NO_CONTENT.into_response()
161}
162
163async fn active_handler(
164 State(state): State<ScenarioRuntimeState>,
165 Query(q): Query<WorkspaceQuery>,
166) -> Response {
167 let workspace = workspace_or_default(&q);
168 let unified = state.inner.engine.get_state(&workspace).await;
169 match unified.and_then(|s| s.active_scenario) {
170 Some(name) if !name.is_empty() => {
171 Json(serde_json::json!({ "active": name, "workspace": workspace })).into_response()
172 }
173 _ => StatusCode::NO_CONTENT.into_response(),
174 }
175}
176
177pub fn scenarios_api_router(state: ScenarioRuntimeState) -> Router {
180 Router::new()
181 .route("/", get(list_handler))
182 .route("/active", get(active_handler))
183 .route("/deactivate", post(deactivate_handler))
184 .route("/{name}/activate", post(activate_handler))
185 .with_state(state)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn workspace_default_when_unset() {
194 assert_eq!(workspace_or_default(&WorkspaceQuery { workspace: None }), "default");
195 }
196
197 #[test]
198 fn workspace_uses_explicit_value() {
199 assert_eq!(
200 workspace_or_default(&WorkspaceQuery {
201 workspace: Some("billing-team".to_string())
202 }),
203 "billing-team"
204 );
205 }
206}