Skip to main content

mockforge_http/
route_chaos_runtime.rs

1//! Runtime route-scoped chaos API.
2//!
3//! Static `route_configs` from the YAML config get baked into per-route
4//! handlers at startup. This module provides the *runtime* counterpart:
5//! a shared, mutable rule set that operators can update via HTTP without
6//! restarting the deployment. A middleware layer consults the live state
7//! on every request.
8//!
9//! ## Why additive (rather than replacing the static path)
10//!
11//! Each static route is registered as its own Axum handler that captures
12//! a clone of the initial injector — refactoring all of them to read a
13//! shared `RwLock` would touch a lot of unrelated code. Instead this
14//! middleware runs *in front of* the static handlers: if a runtime rule
15//! injects a fault, we short-circuit; otherwise we fall through. Routes
16//! that are configured both statically and at runtime have the runtime
17//! rule win — that's the surprise-minimising default for an operator
18//! who's just turned on a fault via API.
19//!
20//! ## Endpoints
21//!
22//! - `GET    /__mockforge/api/route-chaos`           — list rules
23//! - `PUT    /__mockforge/api/route-chaos`           — replace all rules
24//! - `POST   /__mockforge/api/route-chaos/route`     — add or upsert one rule
25//! - `DELETE /__mockforge/api/route-chaos/route`     — remove one rule by method+path
26
27use axum::body::Body;
28use axum::extract::{Request, State};
29use axum::http::{header::CONTENT_TYPE, HeaderName, HeaderValue, StatusCode};
30use axum::middleware::Next;
31use axum::response::{IntoResponse, Response};
32use axum::routing::{get, post};
33use axum::{Json, Router};
34use mockforge_core::config::RouteConfig;
35use mockforge_route_chaos::RouteChaosInjector;
36use serde::{Deserialize, Serialize};
37use std::sync::{Arc, RwLock};
38use tracing::warn;
39
40/// Shared, mutable set of route-chaos rules. Cheap to clone (Arc).
41#[derive(Clone)]
42pub struct RuntimeRouteChaosState {
43    inner: Arc<Inner>,
44}
45
46struct Inner {
47    /// Source-of-truth list of rules.
48    routes: RwLock<Vec<RouteConfig>>,
49    /// Rebuilt every time `routes` mutates. `None` when the rule set is
50    /// empty — saves the matcher walk on the hot path.
51    injector: RwLock<Option<RouteChaosInjector>>,
52}
53
54impl RuntimeRouteChaosState {
55    /// Construct from an initial rule set (typically empty on a hosted
56    /// mock — runtime rules supplement the static-config path).
57    pub fn new(initial: Vec<RouteConfig>) -> Self {
58        let injector = build_injector(&initial);
59        Self {
60            inner: Arc::new(Inner {
61                routes: RwLock::new(initial),
62                injector: RwLock::new(injector),
63            }),
64        }
65    }
66
67    /// Snapshot of the current rule set.
68    pub fn list(&self) -> Vec<RouteConfig> {
69        self.inner.routes.read().expect("route-chaos state poisoned").clone()
70    }
71
72    /// Replace the entire rule set atomically.
73    pub fn replace_all(&self, new_routes: Vec<RouteConfig>) -> Result<(), String> {
74        let new_injector = if new_routes.is_empty() {
75            None
76        } else {
77            Some(RouteChaosInjector::new(new_routes.clone()).map_err(|e| e.to_string())?)
78        };
79        let mut routes = self.inner.routes.write().expect("route-chaos state poisoned");
80        let mut inj = self.inner.injector.write().expect("route-chaos state poisoned");
81        *routes = new_routes;
82        *inj = new_injector;
83        Ok(())
84    }
85
86    /// Add or upsert a single rule. Matching is on (method, path) — same
87    /// (method, path) replaces the existing rule.
88    pub fn upsert(&self, route: RouteConfig) -> Result<(), String> {
89        let mut routes = self.inner.routes.write().expect("route-chaos state poisoned");
90        if let Some(existing) = routes
91            .iter_mut()
92            .find(|r| r.method.eq_ignore_ascii_case(&route.method) && r.path == route.path)
93        {
94            *existing = route;
95        } else {
96            routes.push(route);
97        }
98        let snapshot = routes.clone();
99        drop(routes);
100        let new_injector = build_injector(&snapshot);
101        let mut inj = self.inner.injector.write().expect("route-chaos state poisoned");
102        *inj = new_injector;
103        Ok(())
104    }
105
106    /// Remove a rule by (method, path). Returns whether something was
107    /// removed.
108    pub fn remove(&self, method: &str, path: &str) -> bool {
109        let mut routes = self.inner.routes.write().expect("route-chaos state poisoned");
110        let before = routes.len();
111        routes.retain(|r| !(r.method.eq_ignore_ascii_case(method) && r.path == path));
112        let removed = routes.len() != before;
113        let snapshot = routes.clone();
114        drop(routes);
115        if removed {
116            let new_injector = build_injector(&snapshot);
117            let mut inj = self.inner.injector.write().expect("route-chaos state poisoned");
118            *inj = new_injector;
119        }
120        removed
121    }
122
123    /// Snapshot of the current injector. Returns None when the rule set
124    /// is empty — the middleware fast-paths off this.
125    fn current_injector(&self) -> Option<RouteChaosInjector> {
126        self.inner.injector.read().expect("route-chaos state poisoned").clone()
127    }
128}
129
130fn build_injector(routes: &[RouteConfig]) -> Option<RouteChaosInjector> {
131    if routes.is_empty() {
132        return None;
133    }
134    match RouteChaosInjector::new(routes.to_vec()) {
135        Ok(inj) => Some(inj),
136        Err(e) => {
137            warn!(error = %e, "Failed to build runtime route-chaos injector; rules ignored");
138            None
139        }
140    }
141}
142
143/// Middleware that runs *before* the static route handlers. If a runtime
144/// rule injects a fault, return it; otherwise fall through.
145pub async fn runtime_route_chaos_middleware(
146    State(state): State<RuntimeRouteChaosState>,
147    req: Request,
148    next: Next,
149) -> Response {
150    let Some(injector) = state.current_injector() else {
151        return next.run(req).await;
152    };
153
154    use mockforge_core::priority_handler::RouteChaosInjectorTrait;
155    let method = req.method().clone();
156    let uri = req.uri().clone();
157
158    if let Some(fault) = injector.get_fault_response(&method, &uri) {
159        let status =
160            StatusCode::from_u16(fault.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
161        let body = serde_json::to_vec(&serde_json::json!({
162            "error": fault.fault_type,
163            "message": fault.error_message,
164        }))
165        .unwrap_or_default();
166        let mut resp = Response::new(Body::from(body));
167        *resp.status_mut() = status;
168        resp.headers_mut()
169            .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
170        resp.headers_mut().insert(
171            HeaderName::from_static("x-mockforge-source"),
172            HeaderValue::from_static("route-chaos-runtime"),
173        );
174        return resp;
175    }
176
177    if let Err(e) = injector.inject_latency(&method, &uri).await {
178        warn!(error = %e, "Runtime route-chaos latency injection errored; continuing");
179    }
180
181    next.run(req).await
182}
183
184#[derive(Debug, Serialize)]
185struct ListResponse {
186    rules: Vec<RouteConfig>,
187}
188
189async fn list_handler(State(state): State<RuntimeRouteChaosState>) -> Json<ListResponse> {
190    Json(ListResponse {
191        rules: state.list(),
192    })
193}
194
195#[derive(Debug, Deserialize)]
196struct ReplaceRequest {
197    rules: Vec<RouteConfig>,
198}
199
200async fn replace_handler(
201    State(state): State<RuntimeRouteChaosState>,
202    Json(req): Json<ReplaceRequest>,
203) -> Result<Json<ListResponse>, (StatusCode, String)> {
204    state.replace_all(req.rules).map_err(|e| (StatusCode::BAD_REQUEST, e))?;
205    Ok(Json(ListResponse {
206        rules: state.list(),
207    }))
208}
209
210async fn upsert_handler(
211    State(state): State<RuntimeRouteChaosState>,
212    Json(route): Json<RouteConfig>,
213) -> Result<Json<ListResponse>, (StatusCode, String)> {
214    state.upsert(route).map_err(|e| (StatusCode::BAD_REQUEST, e))?;
215    Ok(Json(ListResponse {
216        rules: state.list(),
217    }))
218}
219
220#[derive(Debug, Deserialize)]
221struct RemoveQuery {
222    method: String,
223    path: String,
224}
225
226async fn remove_handler(
227    State(state): State<RuntimeRouteChaosState>,
228    axum::extract::Query(q): axum::extract::Query<RemoveQuery>,
229) -> Response {
230    let removed = state.remove(&q.method, &q.path);
231    if removed {
232        StatusCode::NO_CONTENT.into_response()
233    } else {
234        StatusCode::NOT_FOUND.into_response()
235    }
236}
237
238/// Build the runtime route-chaos API router. Mount under
239/// `/__mockforge/api/route-chaos`.
240pub fn route_chaos_api_router(state: RuntimeRouteChaosState) -> Router {
241    Router::new()
242        .route("/", get(list_handler).put(replace_handler))
243        .route("/route", post(upsert_handler).delete(remove_handler))
244        .with_state(state)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use mockforge_core::config::RouteResponseConfig;
251
252    fn dummy_route(method: &str, path: &str) -> RouteConfig {
253        RouteConfig {
254            method: method.to_string(),
255            path: path.to_string(),
256            request: None,
257            response: RouteResponseConfig {
258                status: 200,
259                headers: Default::default(),
260                body: None,
261            },
262            fault_injection: None,
263            latency: None,
264        }
265    }
266
267    #[test]
268    fn empty_state_has_no_injector() {
269        let state = RuntimeRouteChaosState::new(Vec::new());
270        assert!(state.current_injector().is_none());
271        assert!(state.list().is_empty());
272    }
273
274    #[test]
275    fn upsert_replaces_existing_route() {
276        let state = RuntimeRouteChaosState::new(Vec::new());
277        let mut r = dummy_route("GET", "/users");
278        r.response.status = 200;
279        state.upsert(r).unwrap();
280        let mut r2 = dummy_route("GET", "/users");
281        r2.response.status = 503;
282        state.upsert(r2).unwrap();
283        let rules = state.list();
284        assert_eq!(rules.len(), 1);
285        assert_eq!(rules[0].response.status, 503);
286    }
287
288    #[test]
289    fn upsert_adds_distinct_routes() {
290        let state = RuntimeRouteChaosState::new(Vec::new());
291        state.upsert(dummy_route("GET", "/a")).unwrap();
292        state.upsert(dummy_route("POST", "/a")).unwrap();
293        state.upsert(dummy_route("GET", "/b")).unwrap();
294        assert_eq!(state.list().len(), 3);
295    }
296
297    #[test]
298    fn remove_returns_false_when_not_found() {
299        let state = RuntimeRouteChaosState::new(Vec::new());
300        state.upsert(dummy_route("GET", "/a")).unwrap();
301        assert!(!state.remove("GET", "/missing"));
302        assert_eq!(state.list().len(), 1);
303    }
304
305    #[test]
306    fn remove_strips_route_and_rebuilds() {
307        let state = RuntimeRouteChaosState::new(Vec::new());
308        state.upsert(dummy_route("GET", "/a")).unwrap();
309        state.upsert(dummy_route("GET", "/b")).unwrap();
310        assert!(state.remove("get", "/a")); // case-insensitive method
311        let rules = state.list();
312        assert_eq!(rules.len(), 1);
313        assert_eq!(rules[0].path, "/b");
314    }
315
316    #[test]
317    fn replace_all_swaps_atomically() {
318        let state = RuntimeRouteChaosState::new(vec![dummy_route("GET", "/a")]);
319        state
320            .replace_all(vec![dummy_route("POST", "/x"), dummy_route("PUT", "/y")])
321            .unwrap();
322        let rules = state.list();
323        assert_eq!(rules.len(), 2);
324        assert!(rules.iter().any(|r| r.method == "POST" && r.path == "/x"));
325        assert!(rules.iter().any(|r| r.method == "PUT" && r.path == "/y"));
326    }
327}