use std::sync::{Arc, Mutex};
use axum::Router;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, Method, StatusCode, Uri};
use axum::response::IntoResponse;
use serde_json::Value;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use crate::inference::error::InferenceError;
#[derive(Debug, Clone)]
pub struct CapturedRequest {
pub method: String,
pub path: String,
pub headers: Vec<(String, String)>,
pub body: Option<Value>,
}
impl CapturedRequest {
pub fn header(&self, name: &str) -> Option<&str> {
let lower = name.to_ascii_lowercase();
self.headers
.iter()
.find(|(k, _)| *k == lower)
.map(|(_, v)| v.as_str())
}
}
type CaptureBuf = Arc<Mutex<Vec<CapturedRequest>>>;
pub struct MockInferenceServer {
url: String,
captured: CaptureBuf,
shutdown: Option<oneshot::Sender<()>>,
handle: JoinHandle<()>,
}
impl MockInferenceServer {
pub async fn spawn(status: u16, body: Value) -> Result<Self, InferenceError> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| InferenceError::Provider(format!("mock bind failed: {e}")))?;
let addr = listener
.local_addr()
.map_err(|e| InferenceError::Provider(format!("mock addr failed: {e}")))?;
let url = format!("http://{addr}");
let status = StatusCode::from_u16(status)
.map_err(|e| InferenceError::Provider(format!("invalid mock status: {e}")))?;
let captured: CaptureBuf = Arc::new(Mutex::new(Vec::new()));
let app =
Router::new()
.fallback(canned_handler)
.with_state((status, body, captured.clone()));
let (tx, rx) = oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
Ok(Self {
url,
captured,
shutdown: Some(tx),
handle,
})
}
pub fn url(&self) -> &str {
&self.url
}
pub fn requests(&self) -> Vec<CapturedRequest> {
self.captured
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone()
}
pub fn last_request(&self) -> Option<CapturedRequest> {
self.captured
.lock()
.unwrap_or_else(|p| p.into_inner())
.last()
.cloned()
}
}
impl Drop for MockInferenceServer {
fn drop(&mut self) {
if let Some(tx) = self.shutdown.take() {
let _ = tx.send(());
}
self.handle.abort();
}
}
async fn canned_handler(
State((status, body, captured)): State<(StatusCode, Value, CaptureBuf)>,
method: Method,
uri: Uri,
headers: HeaderMap,
raw: Bytes,
) -> impl IntoResponse {
let recorded = CapturedRequest {
method: method.to_string(),
path: uri.path().to_string(),
headers: headers
.iter()
.map(|(k, v)| {
(
k.as_str().to_ascii_lowercase(),
v.to_str().unwrap_or_default().to_string(),
)
})
.collect(),
body: serde_json::from_slice::<Value>(&raw).ok(),
};
captured
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(recorded);
(status, axum::Json(body))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn mock_serves_canned_body() {
let body = json!({
"id": "gen-mock",
"choices": [{"message": {"role": "assistant", "content": "mocked"},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}
});
let server = MockInferenceServer::spawn(200, body.clone())
.await
.expect("spawn");
let got: Value = reqwest::Client::new()
.post(format!("{}/v1/chat/completions", server.url()))
.send()
.await
.expect("send")
.json()
.await
.expect("json");
assert_eq!(got["id"], "gen-mock");
assert_eq!(got["choices"][0]["message"]["content"], "mocked");
}
#[tokio::test]
async fn mock_captures_request_body() {
let server = MockInferenceServer::spawn(200, json!({"id": "x", "choices": []}))
.await
.expect("spawn");
reqwest::Client::new()
.post(format!("{}/v1/chat/completions", server.url()))
.header("Authorization", "Bearer sk-test") .json(&json!({"model": "m", "messages": []}))
.send()
.await
.expect("send");
let req = server.last_request().expect("one request captured");
assert_eq!(req.method, "POST");
assert_eq!(req.path, "/v1/chat/completions");
assert_eq!(req.header("authorization"), Some("Bearer sk-test")); assert_eq!(req.body.expect("json body")["model"], "m");
}
}