Skip to main content

mockforge_http/
scenarios_runtime.rs

1//! Runtime named-scenario activation API.
2//!
3//! Exposes the locally-installed scenario catalogue (from
4//! `mockforge_scenarios::ScenarioStorage`) over HTTP and lets operators
5//! activate one by name. Activation today writes the name into the
6//! consistency engine's `UnifiedState::active_scenario` for the chosen
7//! workspace; downstream consumers (the consistency middleware, X-Ray,
8//! etc.) can already observe it. Full manifest-driven application (
9//! applying personas, reality levels, fixtures from the scenario
10//! bundle) is intentionally **not** done here — it would require
11//! hooking into every behavioural subsystem and is tracked separately.
12//!
13//! ## Endpoints (mounted under `/__mockforge/api/scenarios`)
14//!
15//! - `GET    /                              — list installed scenarios
16//! - `POST   /{name}/activate?workspace=…  — set this name as active
17//! - `POST   /deactivate?workspace=…       — clear active
18//! - `GET    /active?workspace=…           — current active name (204 if none)
19//!
20//! `workspace` query param is optional; defaults to `"default"`.
21
22use 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/// Cheap-to-clone shared state.
34#[derive(Clone)]
35pub struct ScenarioRuntimeState {
36    inner: Arc<Inner>,
37}
38
39struct Inner {
40    /// Local installed-scenario index. Loaded once at startup; mutations
41    /// happen through scenario install/remove which is out of scope for
42    /// this PR (existing CLI commands handle it).
43    storage: RwLock<ScenarioStorage>,
44    /// Consistency engine — activation writes through to its
45    /// `set_active_scenario` so the existing middleware can pick it up.
46    engine: Arc<ConsistencyEngine>,
47}
48
49impl ScenarioRuntimeState {
50    /// Construct from a (loaded) scenario storage and a consistency engine.
51    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    // Defensive: make sure the scenario actually exists in local
107    // storage. We don't want to "activate" arbitrary strings — that
108    // would let typos quietly become active without surfacing.
109    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    // The consistency engine doesn't have a "clear scenario" method; we
148    // emulate it by setting an empty string. Consumers downstream check
149    // for None / empty.
150    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
177/// Build the runtime scenario API router. Mount under
178/// `/__mockforge/api/scenarios`.
179pub 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}