use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{HeaderValue, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use mockforge_core::config::RouteConfig;
use mockforge_route_chaos::RouteChaosInjector;
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
use tracing::warn;
#[derive(Clone)]
pub struct RuntimeRouteChaosState {
inner: Arc<Inner>,
}
struct Inner {
routes: RwLock<Vec<RouteConfig>>,
injector: RwLock<Option<RouteChaosInjector>>,
}
impl RuntimeRouteChaosState {
pub fn new(initial: Vec<RouteConfig>) -> Self {
let injector = build_injector(&initial);
Self {
inner: Arc::new(Inner {
routes: RwLock::new(initial),
injector: RwLock::new(injector),
}),
}
}
pub fn list(&self) -> Vec<RouteConfig> {
self.inner.routes.read().expect("route-chaos state poisoned").clone()
}
pub fn replace_all(&self, new_routes: Vec<RouteConfig>) -> Result<(), String> {
let new_injector = if new_routes.is_empty() {
None
} else {
Some(RouteChaosInjector::new(new_routes.clone()).map_err(|e| e.to_string())?)
};
let mut routes = self.inner.routes.write().expect("route-chaos state poisoned");
let mut inj = self.inner.injector.write().expect("route-chaos state poisoned");
*routes = new_routes;
*inj = new_injector;
Ok(())
}
pub fn upsert(&self, route: RouteConfig) -> Result<(), String> {
let mut routes = self.inner.routes.write().expect("route-chaos state poisoned");
if let Some(existing) = routes
.iter_mut()
.find(|r| r.method.eq_ignore_ascii_case(&route.method) && r.path == route.path)
{
*existing = route;
} else {
routes.push(route);
}
let snapshot = routes.clone();
drop(routes);
let new_injector = build_injector(&snapshot);
let mut inj = self.inner.injector.write().expect("route-chaos state poisoned");
*inj = new_injector;
Ok(())
}
pub fn remove(&self, method: &str, path: &str) -> bool {
let mut routes = self.inner.routes.write().expect("route-chaos state poisoned");
let before = routes.len();
routes.retain(|r| !(r.method.eq_ignore_ascii_case(method) && r.path == path));
let removed = routes.len() != before;
let snapshot = routes.clone();
drop(routes);
if removed {
let new_injector = build_injector(&snapshot);
let mut inj = self.inner.injector.write().expect("route-chaos state poisoned");
*inj = new_injector;
}
removed
}
fn current_injector(&self) -> Option<RouteChaosInjector> {
self.inner.injector.read().expect("route-chaos state poisoned").clone()
}
}
fn build_injector(routes: &[RouteConfig]) -> Option<RouteChaosInjector> {
if routes.is_empty() {
return None;
}
match RouteChaosInjector::new(routes.to_vec()) {
Ok(inj) => Some(inj),
Err(e) => {
warn!(error = %e, "Failed to build runtime route-chaos injector; rules ignored");
None
}
}
}
pub async fn runtime_route_chaos_middleware(
State(state): State<RuntimeRouteChaosState>,
req: Request,
next: Next,
) -> Response {
let Some(injector) = state.current_injector() else {
return next.run(req).await;
};
use mockforge_core::priority_handler::RouteChaosInjectorTrait;
let method = req.method().clone();
let uri = req.uri().clone();
if let Some(fault) = injector.get_fault_response(&method, &uri) {
let status =
StatusCode::from_u16(fault.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = serde_json::to_vec(&serde_json::json!({
"error": fault.fault_type,
"message": fault.error_message,
}))
.unwrap_or_default();
let mut resp = Response::new(Body::from(body));
*resp.status_mut() = status;
resp.headers_mut()
.insert(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
resp.headers_mut().insert(
axum::http::HeaderName::from_static("x-mockforge-source"),
HeaderValue::from_static("route-chaos-runtime"),
);
return resp;
}
if let Err(e) = injector.inject_latency(&method, &uri).await {
warn!(error = %e, "Runtime route-chaos latency injection errored; continuing");
}
next.run(req).await
}
#[derive(Debug, Serialize)]
struct ListResponse {
rules: Vec<RouteConfig>,
}
async fn list_handler(State(state): State<RuntimeRouteChaosState>) -> Json<ListResponse> {
Json(ListResponse {
rules: state.list(),
})
}
#[derive(Debug, Deserialize)]
struct ReplaceRequest {
rules: Vec<RouteConfig>,
}
async fn replace_handler(
State(state): State<RuntimeRouteChaosState>,
Json(req): Json<ReplaceRequest>,
) -> Result<Json<ListResponse>, (StatusCode, String)> {
state.replace_all(req.rules).map_err(|e| (StatusCode::BAD_REQUEST, e))?;
Ok(Json(ListResponse {
rules: state.list(),
}))
}
async fn upsert_handler(
State(state): State<RuntimeRouteChaosState>,
Json(route): Json<RouteConfig>,
) -> Result<Json<ListResponse>, (StatusCode, String)> {
state.upsert(route).map_err(|e| (StatusCode::BAD_REQUEST, e))?;
Ok(Json(ListResponse {
rules: state.list(),
}))
}
#[derive(Debug, Deserialize)]
struct RemoveQuery {
method: String,
path: String,
}
async fn remove_handler(
State(state): State<RuntimeRouteChaosState>,
axum::extract::Query(q): axum::extract::Query<RemoveQuery>,
) -> Response {
let removed = state.remove(&q.method, &q.path);
if removed {
StatusCode::NO_CONTENT.into_response()
} else {
StatusCode::NOT_FOUND.into_response()
}
}
pub fn route_chaos_api_router(state: RuntimeRouteChaosState) -> Router {
Router::new()
.route("/", get(list_handler).put(replace_handler))
.route("/route", post(upsert_handler).delete(remove_handler))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
use mockforge_core::config::RouteResponseConfig;
fn dummy_route(method: &str, path: &str) -> RouteConfig {
RouteConfig {
method: method.to_string(),
path: path.to_string(),
request: None,
response: RouteResponseConfig {
status: 200,
headers: Default::default(),
body: None,
},
fault_injection: None,
latency: None,
}
}
#[test]
fn empty_state_has_no_injector() {
let state = RuntimeRouteChaosState::new(Vec::new());
assert!(state.current_injector().is_none());
assert!(state.list().is_empty());
}
#[test]
fn upsert_replaces_existing_route() {
let state = RuntimeRouteChaosState::new(Vec::new());
let mut r = dummy_route("GET", "/users");
r.response.status = 200;
state.upsert(r).unwrap();
let mut r2 = dummy_route("GET", "/users");
r2.response.status = 503;
state.upsert(r2).unwrap();
let rules = state.list();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].response.status, 503);
}
#[test]
fn upsert_adds_distinct_routes() {
let state = RuntimeRouteChaosState::new(Vec::new());
state.upsert(dummy_route("GET", "/a")).unwrap();
state.upsert(dummy_route("POST", "/a")).unwrap();
state.upsert(dummy_route("GET", "/b")).unwrap();
assert_eq!(state.list().len(), 3);
}
#[test]
fn remove_returns_false_when_not_found() {
let state = RuntimeRouteChaosState::new(Vec::new());
state.upsert(dummy_route("GET", "/a")).unwrap();
assert!(!state.remove("GET", "/missing"));
assert_eq!(state.list().len(), 1);
}
#[test]
fn remove_strips_route_and_rebuilds() {
let state = RuntimeRouteChaosState::new(Vec::new());
state.upsert(dummy_route("GET", "/a")).unwrap();
state.upsert(dummy_route("GET", "/b")).unwrap();
assert!(state.remove("get", "/a")); let rules = state.list();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].path, "/b");
}
#[test]
fn replace_all_swaps_atomically() {
let state = RuntimeRouteChaosState::new(vec![dummy_route("GET", "/a")]);
state
.replace_all(vec![dummy_route("POST", "/x"), dummy_route("PUT", "/y")])
.unwrap();
let rules = state.list();
assert_eq!(rules.len(), 2);
assert!(rules.iter().any(|r| r.method == "POST" && r.path == "/x"));
assert!(rules.iter().any(|r| r.method == "PUT" && r.path == "/y"));
}
}