use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Json},
routing::{get, post},
Router,
};
use serde_json::json;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
#[derive(Debug, Clone)]
pub enum ResponseMode {
Success { result_count: usize },
Empty,
Error(u16),
SlowThenSuccess(Duration),
}
impl Default for ResponseMode {
fn default() -> Self {
ResponseMode::Success { result_count: 5 }
}
}
pub struct MockUpstream {
base_url: String,
shutdown_tx: Option<oneshot::Sender<()>>,
server_handle: Option<JoinHandle<()>>,
}
impl MockUpstream {
pub async fn start() -> (Self, String) {
Self::start_with_mode(ResponseMode::default()).await
}
pub async fn start_with_mode(mode: ResponseMode) -> (Self, String) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind to port 0 should succeed");
let addr: SocketAddr = listener.local_addr().expect("local_addr");
let base_url = format!("http://{addr}");
let app = Router::new()
.route("/query", post(handle_query))
.route("/health", get(handle_health))
.with_state(mode);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let server_handle = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
let server = MockUpstream {
base_url: base_url.clone(),
shutdown_tx: Some(shutdown_tx),
server_handle: Some(server_handle),
};
(server, base_url)
}
pub fn url(&self) -> &str {
&self.base_url
}
pub async fn stop(mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.server_handle.take() {
let _ = handle.await;
}
}
}
async fn handle_health() -> impl IntoResponse {
(StatusCode::OK, "ok")
}
async fn handle_query(State(mode): State<ResponseMode>) -> impl IntoResponse {
match mode {
ResponseMode::Success { result_count } => {
let results: Vec<serde_json::Value> = (0..result_count)
.map(|i| {
json!({
"id": format!("mock-doc-{i}"),
"score": 1.0 - (i as f32) * 0.01,
"content": format!("mock content for doc {i}"),
"metadata": null,
"upstream_id": "mock",
})
})
.collect();
(
StatusCode::OK,
Json(json!({
"results": results,
"cache_status": "Miss",
"took_ms": 1u64,
})),
)
}
ResponseMode::Empty => (
StatusCode::OK,
Json(json!({
"results": [],
"cache_status": "Miss",
"took_ms": 1u64,
})),
),
ResponseMode::Error(status) => (
StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
Json(json!({"error": "mock upstream error"})),
),
ResponseMode::SlowThenSuccess(d) => {
tokio::time::sleep(d).await;
(
StatusCode::OK,
Json(json!({
"results": [{"id": "late", "score": 1.0, "content": "late"}],
"cache_status": "Miss",
"took_ms": d.as_millis() as u64,
})),
)
}
}
}