1use 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#[derive(Clone)]
42pub struct RuntimeRouteChaosState {
43 inner: Arc<Inner>,
44}
45
46struct Inner {
47 routes: RwLock<Vec<RouteConfig>>,
49 injector: RwLock<Option<RouteChaosInjector>>,
52}
53
54impl RuntimeRouteChaosState {
55 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 pub fn list(&self) -> Vec<RouteConfig> {
69 self.inner.routes.read().expect("route-chaos state poisoned").clone()
70 }
71
72 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 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 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 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
143pub 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
238pub 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")); 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}