Skip to main content

mockforge_http/
network_profile_runtime.rs

1//! Runtime network-profile switching API.
2//!
3//! Profiles like `mobile_3g` / `lossy_network` / `extremely_poor` come
4//! from `mockforge_core::network_profiles::NetworkProfileCatalog`. They
5//! were previously selectable only via `--network-profile` at startup,
6//! which meant a hosted-mock operator couldn't probe how their consumer
7//! behaves on a slow network without redeploying.
8//!
9//! This module exposes the catalog over HTTP and adds a middleware that
10//! applies the active profile's latency on every request. Bandwidth and
11//! loss simulation are not yet wired through this path — latency is the
12//! 90% case for "what if my upstream gets slow" testing and is the
13//! cheapest to add without per-byte accounting.
14//!
15//! ## Endpoints
16//!
17//! - `GET    /__mockforge/api/network-profiles`            — list available profiles
18//! - `GET    /__mockforge/api/network-profiles/active`     — current active profile (or 204 if none)
19//! - `POST   /__mockforge/api/network-profiles/{name}/activate`  — switch to profile
20//! - `POST   /__mockforge/api/network-profiles/deactivate`       — clear
21
22use axum::extract::{Path as AxumPath, Request, State};
23use axum::http::StatusCode;
24use axum::middleware::Next;
25use axum::response::{IntoResponse, Response};
26use axum::routing::{get, post};
27use axum::{Json, Router};
28use mockforge_core::network_profiles::{NetworkProfile, NetworkProfileCatalog};
29use serde::Serialize;
30use std::sync::{Arc, RwLock};
31
32/// Cheap-to-clone shared state holding the catalog (immutable) plus the
33/// currently active profile (mutable).
34#[derive(Clone)]
35pub struct NetworkProfileRuntimeState {
36    inner: Arc<Inner>,
37}
38
39struct Inner {
40    catalog: NetworkProfileCatalog,
41    active: RwLock<Option<NetworkProfile>>,
42}
43
44impl NetworkProfileRuntimeState {
45    /// Construct from a catalog. Active profile starts unset; the
46    /// middleware fast-paths off this.
47    pub fn new(catalog: NetworkProfileCatalog) -> Self {
48        Self {
49            inner: Arc::new(Inner {
50                catalog,
51                active: RwLock::new(None),
52            }),
53        }
54    }
55
56    /// Names + descriptions of all available profiles.
57    pub fn list(&self) -> Vec<(String, String)> {
58        self.inner.catalog.list_profiles_with_description()
59    }
60
61    /// Active profile snapshot.
62    pub fn active(&self) -> Option<NetworkProfile> {
63        self.inner.active.read().expect("network-profile state poisoned").clone()
64    }
65
66    /// Switch the active profile. Returns false when the name doesn't
67    /// match any catalog entry.
68    pub fn activate(&self, name: &str) -> bool {
69        let profile = match self.inner.catalog.get(name) {
70            Some(p) => p.clone(),
71            None => return false,
72        };
73        *self.inner.active.write().expect("network-profile state poisoned") = Some(profile);
74        true
75    }
76
77    /// Clear the active profile (return to no degradation).
78    pub fn deactivate(&self) {
79        *self.inner.active.write().expect("network-profile state poisoned") = None;
80    }
81}
82
83/// Middleware that applies the active profile's latency before passing
84/// to the next layer. Reads `NetworkProfileRuntimeState::active()` per
85/// request — a swap takes effect on the very next call.
86pub async fn network_profile_middleware(
87    State(state): State<NetworkProfileRuntimeState>,
88    req: Request,
89    next: Next,
90) -> Response {
91    if let Some(profile) = state.active() {
92        let delay = profile.latency.calculate_latency(&[]);
93        if !delay.is_zero() {
94            tokio::time::sleep(delay).await;
95        }
96    }
97    next.run(req).await
98}
99
100#[derive(Debug, Serialize)]
101struct ProfileSummary {
102    name: String,
103    description: String,
104}
105
106#[derive(Debug, Serialize)]
107struct ListResponse {
108    profiles: Vec<ProfileSummary>,
109    active: Option<String>,
110}
111
112async fn list_handler(State(state): State<NetworkProfileRuntimeState>) -> Json<ListResponse> {
113    Json(ListResponse {
114        profiles: state
115            .list()
116            .into_iter()
117            .map(|(name, description)| ProfileSummary { name, description })
118            .collect(),
119        active: state.active().map(|p| p.name),
120    })
121}
122
123async fn active_handler(State(state): State<NetworkProfileRuntimeState>) -> Response {
124    match state.active() {
125        Some(profile) => Json(profile).into_response(),
126        None => StatusCode::NO_CONTENT.into_response(),
127    }
128}
129
130async fn activate_handler(
131    State(state): State<NetworkProfileRuntimeState>,
132    AxumPath(name): AxumPath<String>,
133) -> Response {
134    if state.activate(&name) {
135        Json(serde_json::json!({ "active": name })).into_response()
136    } else {
137        (
138            StatusCode::NOT_FOUND,
139            Json(serde_json::json!({
140                "error": "profile_not_found",
141                "message": format!("No network profile named '{}'", name),
142            })),
143        )
144            .into_response()
145    }
146}
147
148async fn deactivate_handler(State(state): State<NetworkProfileRuntimeState>) -> Response {
149    state.deactivate();
150    StatusCode::NO_CONTENT.into_response()
151}
152
153/// Build the network-profile runtime API router. Mount under
154/// `/__mockforge/api/network-profiles`.
155pub fn network_profile_api_router(state: NetworkProfileRuntimeState) -> Router {
156    Router::new()
157        .route("/", get(list_handler))
158        .route("/active", get(active_handler))
159        .route("/{name}/activate", post(activate_handler))
160        .route("/deactivate", post(deactivate_handler))
161        .with_state(state)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn empty_active_until_activated() {
170        let state = NetworkProfileRuntimeState::new(NetworkProfileCatalog::new());
171        assert!(state.active().is_none());
172    }
173
174    #[test]
175    fn activate_unknown_returns_false() {
176        let state = NetworkProfileRuntimeState::new(NetworkProfileCatalog::new());
177        assert!(!state.activate("not_a_real_profile_name"));
178        assert!(state.active().is_none());
179    }
180
181    #[test]
182    fn list_includes_builtin_profiles() {
183        let state = NetworkProfileRuntimeState::new(NetworkProfileCatalog::new());
184        let names: Vec<String> = state.list().into_iter().map(|(n, _)| n).collect();
185        // Smoke test: the catalog ships with at least these.
186        assert!(names.iter().any(|n| n.contains("3g") || n.contains("3G")));
187        assert!(!names.is_empty());
188    }
189
190    #[test]
191    fn activate_then_deactivate() {
192        let state = NetworkProfileRuntimeState::new(NetworkProfileCatalog::new());
193        let any_name = state.list().first().map(|(n, _)| n.clone()).expect("catalog non-empty");
194        assert!(state.activate(&any_name));
195        assert_eq!(state.active().map(|p| p.name), Some(any_name));
196        state.deactivate();
197        assert!(state.active().is_none());
198    }
199}