use crate::error::{Error, Result};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Patch,
Delete,
}
impl HttpMethod {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Patch => "PATCH",
HttpMethod::Delete => "DELETE",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpRequest {
pub method: HttpMethod,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<String>,
}
impl HttpRequest {
pub fn get(url: impl Into<String>) -> Self {
Self {
method: HttpMethod::Get,
url: url.into(),
headers: Vec::new(),
body: None,
}
}
pub fn post(url: impl Into<String>) -> Self {
Self {
method: HttpMethod::Post,
url: url.into(),
headers: Vec::new(),
body: None,
}
}
pub fn new(method: HttpMethod, url: impl Into<String>) -> Self {
Self {
method,
url: url.into(),
headers: Vec::new(),
body: None,
}
}
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
#[must_use]
pub fn with_body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: String,
}
impl HttpResponse {
pub fn new(status: u16, body: impl Into<String>) -> Self {
Self {
status,
headers: Vec::new(),
body: body.into(),
}
}
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
#[must_use]
pub fn retry_after(&self) -> Option<std::time::Duration> {
let raw = self.header("Retry-After")?.trim();
let seconds: f64 = raw.parse().ok()?;
if seconds.is_finite() && seconds >= 0.0 {
Some(std::time::Duration::from_secs_f64(seconds))
} else {
None
}
}
#[must_use]
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
}
pub trait HttpTransport: Send + Sync {
fn execute(&self, request: &HttpRequest) -> Result<HttpResponse>;
}
pub trait WsTransport: Send + Sync {
fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>>;
}
pub trait WsConnection: Send {
fn send(&mut self, text: &str) -> Result<()>;
fn recv(&mut self) -> Result<Option<String>>;
fn is_connected(&self) -> bool;
fn close(&mut self) -> Result<()>;
}
#[derive(Debug, Default)]
pub struct MockHttpTransport {
responses: Mutex<VecDeque<Result<HttpResponse>>>,
requests: Mutex<Vec<HttpRequest>>,
}
impl MockHttpTransport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn push_response(&self, response: HttpResponse) {
self.responses.lock().unwrap().push_back(Ok(response));
}
pub fn push_json(&self, status: u16, body: impl Into<String>) {
self.push_response(HttpResponse::new(status, body));
}
pub fn push_error(&self, error: Error) {
self.responses.lock().unwrap().push_back(Err(error));
}
#[must_use]
pub fn recorded_requests(&self) -> Vec<HttpRequest> {
self.requests.lock().unwrap().clone()
}
}
impl HttpTransport for MockHttpTransport {
fn execute(&self, request: &HttpRequest) -> Result<HttpResponse> {
self.requests.lock().unwrap().push(request.clone());
self.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| Err(Error::Network("mock: no queued response".to_string())))
}
}
#[derive(Debug)]
enum ScriptedConnection {
Open(Vec<Result<Option<String>>>),
Refused,
OpenButUnsendable,
}
#[derive(Debug, Default)]
pub struct MockWsTransport {
scripts: Mutex<VecDeque<ScriptedConnection>>,
connected_urls: Mutex<Vec<String>>,
sent: Arc<Mutex<Vec<String>>>,
}
impl MockWsTransport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn push_connection(&self, frames: Vec<Result<Option<String>>>) {
self.scripts
.lock()
.unwrap()
.push_back(ScriptedConnection::Open(frames));
}
pub fn push_refused_connection(&self) {
self.scripts
.lock()
.unwrap()
.push_back(ScriptedConnection::Refused);
}
pub fn push_unsendable_connection(&self) {
self.scripts
.lock()
.unwrap()
.push_back(ScriptedConnection::OpenButUnsendable);
}
#[must_use]
pub fn connected_urls(&self) -> Vec<String> {
self.connected_urls.lock().unwrap().clone()
}
#[must_use]
pub fn sent(&self) -> Vec<String> {
self.sent.lock().unwrap().clone()
}
}
impl WsTransport for MockWsTransport {
fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>> {
self.connected_urls.lock().unwrap().push(url.to_string());
let script = self
.scripts
.lock()
.unwrap()
.pop_front()
.unwrap_or(ScriptedConnection::Open(Vec::new()));
let (frames, unsendable) = match script {
ScriptedConnection::Open(frames) => (frames, false),
ScriptedConnection::OpenButUnsendable => (Vec::new(), true),
ScriptedConnection::Refused => {
return Err(Error::Network("mock: connection refused".to_string()))
}
};
Ok(Box::new(MockWsConnection {
incoming: frames.into(),
sent: Arc::clone(&self.sent),
closed: false,
unsendable,
}))
}
}
#[derive(Debug)]
pub(crate) struct MockWsConnection {
incoming: VecDeque<Result<Option<String>>>,
sent: Arc<Mutex<Vec<String>>>,
closed: bool,
unsendable: bool,
}
impl WsConnection for MockWsConnection {
fn send(&mut self, text: &str) -> Result<()> {
if self.closed || self.unsendable {
return Err(Error::NotConnected);
}
self.sent.lock().unwrap().push(text.to_string());
Ok(())
}
fn recv(&mut self) -> Result<Option<String>> {
match self.incoming.pop_front() {
Some(Ok(None)) => {
self.closed = true;
Ok(None)
}
Some(other) => other,
None => Ok(None),
}
}
fn is_connected(&self) -> bool {
!self.closed
}
fn close(&mut self) -> Result<()> {
self.closed = true;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn http_method_strings() {
assert_eq!(HttpMethod::Get.as_str(), "GET");
assert_eq!(HttpMethod::Post.as_str(), "POST");
assert_eq!(HttpMethod::Put.as_str(), "PUT");
assert_eq!(HttpMethod::Patch.as_str(), "PATCH");
assert_eq!(HttpMethod::Delete.as_str(), "DELETE");
}
#[test]
fn request_builder() {
let req = HttpRequest::get("https://api/x")
.with_header("X-KEY", "abc")
.with_body("payload");
assert_eq!(req.method, HttpMethod::Get);
assert_eq!(req.url, "https://api/x");
assert_eq!(req.headers, vec![("X-KEY".to_string(), "abc".to_string())]);
assert_eq!(req.body.as_deref(), Some("payload"));
let post = HttpRequest::post("https://api/y");
assert_eq!(post.method, HttpMethod::Post);
let del = HttpRequest::new(HttpMethod::Delete, "https://api/z");
assert_eq!(del.method, HttpMethod::Delete);
}
#[test]
fn response_success_classification() {
assert!(HttpResponse::new(200, "{}").is_success());
assert!(HttpResponse::new(204, "").is_success());
assert!(!HttpResponse::new(400, "{}").is_success());
assert!(!HttpResponse::new(500, "{}").is_success());
}
#[test]
fn a_header_is_found_whatever_its_case() {
let response = HttpResponse::new(429, "{}").with_header("retry-after", "3");
assert_eq!(response.header("Retry-After"), Some("3"));
assert_eq!(response.header("RETRY-AFTER"), Some("3"));
assert_eq!(response.header("X-Absent"), None);
}
#[test]
fn retry_after_reads_whole_and_fractional_seconds() {
let whole = HttpResponse::new(429, "{}").with_header("Retry-After", "3");
assert_eq!(whole.retry_after(), Some(Duration::from_secs(3)));
let fractional = HttpResponse::new(429, "{}").with_header("Retry-After", " 2.5 ");
assert_eq!(fractional.retry_after(), Some(Duration::from_millis(2500)));
}
#[test]
fn retry_after_declines_what_it_cannot_read() {
assert_eq!(HttpResponse::new(429, "{}").retry_after(), None);
let date = HttpResponse::new(429, "{}")
.with_header("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT");
assert_eq!(date.retry_after(), None);
let junk = HttpResponse::new(429, "{}").with_header("Retry-After", "soon");
assert_eq!(junk.retry_after(), None);
let negative = HttpResponse::new(429, "{}").with_header("Retry-After", "-1");
assert_eq!(negative.retry_after(), None);
}
#[test]
fn mock_http_returns_queued_responses_fifo_and_records() {
let http = MockHttpTransport::new();
http.push_json(200, "{\"ok\":1}");
http.push_error(Error::Timeout);
let first = http.execute(&HttpRequest::get("https://api/a")).unwrap();
assert_eq!(first.body, "{\"ok\":1}");
let second = http.execute(&HttpRequest::post("https://api/b"));
assert_eq!(second.unwrap_err(), Error::Timeout);
let third = http.execute(&HttpRequest::get("https://api/c"));
assert!(matches!(third.unwrap_err(), Error::Network(_)));
let recorded = http.recorded_requests();
assert_eq!(recorded.len(), 3);
assert_eq!(recorded[0].url, "https://api/a");
assert_eq!(recorded[1].method, HttpMethod::Post);
}
#[test]
fn mock_ws_replays_frames_then_closes() {
let ws = MockWsTransport::new();
ws.push_connection(vec![
Ok(Some("frame-1".to_string())),
Ok(Some("frame-2".to_string())),
]);
let mut conn = ws.connect("wss://stream/x").unwrap();
conn.send("subscribe").unwrap();
assert_eq!(conn.recv().unwrap(), Some("frame-1".to_string()));
assert_eq!(conn.recv().unwrap(), Some("frame-2".to_string()));
assert_eq!(conn.recv().unwrap(), None);
assert_eq!(ws.connected_urls(), vec!["wss://stream/x".to_string()]);
assert_eq!(ws.sent(), vec!["subscribe".to_string()]);
}
#[test]
fn mock_ws_surfaces_read_errors_and_blocks_send_after_close() {
let ws = MockWsTransport::new();
ws.push_connection(vec![Err(Error::Network("reset".to_string()))]);
let mut conn = ws.connect("wss://stream/y").unwrap();
assert!(matches!(conn.recv().unwrap_err(), Error::Network(_)));
conn.close().unwrap();
assert_eq!(conn.send("late").unwrap_err(), Error::NotConnected);
}
#[test]
fn mock_ws_connect_without_script_is_immediately_closed() {
let ws = MockWsTransport::new();
let mut conn = ws.connect("wss://stream/empty").unwrap();
assert_eq!(conn.recv().unwrap(), None);
}
#[test]
fn mock_transports_render_debug() {
assert!(format!("{:?}", MockHttpTransport::new()).starts_with("MockHttpTransport"));
assert!(format!("{:?}", MockWsTransport::new()).starts_with("MockWsTransport"));
let conn = MockWsConnection {
incoming: VecDeque::new(),
sent: Arc::new(Mutex::new(Vec::new())),
closed: false,
unsendable: false,
};
assert!(format!("{conn:?}").starts_with("MockWsConnection"));
}
}