#![allow(clippy::expect_used, clippy::unwrap_used, dead_code)]
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
#[derive(Clone)]
pub enum FaultMode {
Healthy(String),
Slow(Duration, String),
Error(u16),
Hang,
}
type Routes = Arc<Mutex<HashMap<String, FaultMode>>>;
pub struct MockNymApi {
url: String,
routes: Routes,
accept_task: tokio::task::JoinHandle<()>,
}
impl MockNymApi {
pub async fn spawn(routes: HashMap<String, FaultMode>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let routes: Routes = Arc::new(Mutex::new(routes));
let accept_routes = routes.clone();
let accept_task = tokio::spawn(async move {
loop {
let Ok((conn, _)) = listener.accept().await else {
break;
};
tokio::spawn(handle_connection(conn, accept_routes.clone()));
}
});
MockNymApi {
url: format!("http://{addr}/"),
routes,
accept_task,
}
}
pub fn url(&self) -> &str {
&self.url
}
pub fn set(&self, path: impl Into<String>, mode: FaultMode) {
self.routes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(path.into(), mode);
}
}
impl Drop for MockNymApi {
fn drop(&mut self) {
self.accept_task.abort();
}
}
async fn handle_connection(mut conn: TcpStream, routes: Routes) {
let mut buf = Vec::with_capacity(2048);
loop {
let mut chunk = [0u8; 1024];
let head_end = loop {
if let Some(pos) = find_head_end(&buf) {
break pos;
}
match conn.read(&mut chunk).await {
Ok(0) | Err(_) => return, Ok(n) => buf.extend_from_slice(&chunk[..n]),
}
};
let head = String::from_utf8_lossy(&buf[..head_end]).into_owned();
buf.drain(..head_end + 4);
let path = head
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.map(|target| target.split('?').next().unwrap_or(target).to_string())
.unwrap_or_default();
let mode = routes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(&path)
.cloned();
match mode {
Some(FaultMode::Healthy(body)) => {
if respond_json(&mut conn, 200, &body).await.is_err() {
return;
}
}
Some(FaultMode::Slow(delay, body)) => {
tokio::time::sleep(delay).await;
if respond_json(&mut conn, 200, &body).await.is_err() {
return;
}
}
Some(FaultMode::Error(status)) => {
if respond_json(&mut conn, status, "{}").await.is_err() {
return;
}
}
Some(FaultMode::Hang) => {
std::future::pending::<()>().await;
}
None => {
if respond_json(&mut conn, 404, "{}").await.is_err() {
return;
}
}
}
}
}
fn find_head_end(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|w| w == b"\r\n\r\n")
}
async fn respond_json(conn: &mut TcpStream, status: u16, body: &str) -> std::io::Result<()> {
let reason = if status == 200 { "OK" } else { "Error" };
let response = format!(
"HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
);
conn.write_all(response.as_bytes()).await
}