1use crate::error::{Error, Result};
12use std::collections::VecDeque;
13use std::sync::{Arc, Mutex};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum HttpMethod {
18 Get,
20 Post,
22 Put,
24 Patch,
26 Delete,
28}
29
30impl HttpMethod {
31 #[must_use]
33 pub fn as_str(self) -> &'static str {
34 match self {
35 HttpMethod::Get => "GET",
36 HttpMethod::Post => "POST",
37 HttpMethod::Put => "PUT",
38 HttpMethod::Patch => "PATCH",
39 HttpMethod::Delete => "DELETE",
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct HttpRequest {
47 pub method: HttpMethod,
49 pub url: String,
51 pub headers: Vec<(String, String)>,
53 pub body: Option<String>,
55}
56
57impl HttpRequest {
58 pub fn get(url: impl Into<String>) -> Self {
60 Self {
61 method: HttpMethod::Get,
62 url: url.into(),
63 headers: Vec::new(),
64 body: None,
65 }
66 }
67
68 pub fn post(url: impl Into<String>) -> Self {
70 Self {
71 method: HttpMethod::Post,
72 url: url.into(),
73 headers: Vec::new(),
74 body: None,
75 }
76 }
77
78 pub fn new(method: HttpMethod, url: impl Into<String>) -> Self {
80 Self {
81 method,
82 url: url.into(),
83 headers: Vec::new(),
84 body: None,
85 }
86 }
87
88 #[must_use]
90 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
91 self.headers.push((name.into(), value.into()));
92 self
93 }
94
95 #[must_use]
97 pub fn with_body(mut self, body: impl Into<String>) -> Self {
98 self.body = Some(body.into());
99 self
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct HttpResponse {
106 pub status: u16,
108 pub headers: Vec<(String, String)>,
116 pub body: String,
118}
119
120impl HttpResponse {
121 pub fn new(status: u16, body: impl Into<String>) -> Self {
123 Self {
124 status,
125 headers: Vec::new(),
126 body: body.into(),
127 }
128 }
129
130 #[must_use]
132 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
133 self.headers.push((name.into(), value.into()));
134 self
135 }
136
137 #[must_use]
143 pub fn header(&self, name: &str) -> Option<&str> {
144 self.headers
145 .iter()
146 .find(|(key, _)| key.eq_ignore_ascii_case(name))
147 .map(|(_, value)| value.as_str())
148 }
149
150 #[must_use]
159 pub fn retry_after(&self) -> Option<std::time::Duration> {
160 let raw = self.header("Retry-After")?.trim();
161 let seconds: f64 = raw.parse().ok()?;
162 if seconds.is_finite() && seconds >= 0.0 {
163 Some(std::time::Duration::from_secs_f64(seconds))
164 } else {
165 None
166 }
167 }
168
169 #[must_use]
171 pub fn is_success(&self) -> bool {
172 (200..300).contains(&self.status)
173 }
174}
175
176pub trait HttpTransport: Send + Sync {
179 fn execute(&self, request: &HttpRequest) -> Result<HttpResponse>;
187}
188
189pub trait WsTransport: Send + Sync {
191 fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>>;
197}
198
199pub trait WsConnection: Send {
202 fn send(&mut self, text: &str) -> Result<()>;
209
210 fn recv(&mut self) -> Result<Option<String>>;
218
219 fn is_connected(&self) -> bool;
222
223 fn close(&mut self) -> Result<()>;
229}
230
231#[derive(Debug, Default)]
240pub struct MockHttpTransport {
241 responses: Mutex<VecDeque<Result<HttpResponse>>>,
242 requests: Mutex<Vec<HttpRequest>>,
243}
244
245impl MockHttpTransport {
246 #[must_use]
248 pub fn new() -> Self {
249 Self::default()
250 }
251
252 pub fn push_response(&self, response: HttpResponse) {
254 self.responses.lock().unwrap().push_back(Ok(response));
255 }
256
257 pub fn push_json(&self, status: u16, body: impl Into<String>) {
259 self.push_response(HttpResponse::new(status, body));
260 }
261
262 pub fn push_error(&self, error: Error) {
264 self.responses.lock().unwrap().push_back(Err(error));
265 }
266
267 #[must_use]
269 pub fn recorded_requests(&self) -> Vec<HttpRequest> {
270 self.requests.lock().unwrap().clone()
271 }
272}
273
274impl HttpTransport for MockHttpTransport {
275 fn execute(&self, request: &HttpRequest) -> Result<HttpResponse> {
276 self.requests.lock().unwrap().push(request.clone());
277 self.responses
278 .lock()
279 .unwrap()
280 .pop_front()
281 .unwrap_or_else(|| Err(Error::Network("mock: no queued response".to_string())))
282 }
283}
284
285#[derive(Debug)]
294enum ScriptedConnection {
295 Open(Vec<Result<Option<String>>>),
297 Refused,
299 OpenButUnsendable,
302}
303
304#[derive(Debug, Default)]
315pub struct MockWsTransport {
316 scripts: Mutex<VecDeque<ScriptedConnection>>,
317 connected_urls: Mutex<Vec<String>>,
318 sent: Arc<Mutex<Vec<String>>>,
319}
320
321impl MockWsTransport {
322 #[must_use]
324 pub fn new() -> Self {
325 Self::default()
326 }
327
328 pub fn push_connection(&self, frames: Vec<Result<Option<String>>>) {
332 self.scripts
333 .lock()
334 .unwrap()
335 .push_back(ScriptedConnection::Open(frames));
336 }
337
338 pub fn push_refused_connection(&self) {
340 self.scripts
341 .lock()
342 .unwrap()
343 .push_back(ScriptedConnection::Refused);
344 }
345
346 pub fn push_unsendable_connection(&self) {
353 self.scripts
354 .lock()
355 .unwrap()
356 .push_back(ScriptedConnection::OpenButUnsendable);
357 }
358
359 #[must_use]
361 pub fn connected_urls(&self) -> Vec<String> {
362 self.connected_urls.lock().unwrap().clone()
363 }
364
365 #[must_use]
367 pub fn sent(&self) -> Vec<String> {
368 self.sent.lock().unwrap().clone()
369 }
370}
371
372impl WsTransport for MockWsTransport {
373 fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>> {
374 self.connected_urls.lock().unwrap().push(url.to_string());
375 let script = self
376 .scripts
377 .lock()
378 .unwrap()
379 .pop_front()
380 .unwrap_or(ScriptedConnection::Open(Vec::new()));
381 let (frames, unsendable) = match script {
382 ScriptedConnection::Open(frames) => (frames, false),
383 ScriptedConnection::OpenButUnsendable => (Vec::new(), true),
384 ScriptedConnection::Refused => {
385 return Err(Error::Network("mock: connection refused".to_string()))
386 }
387 };
388 Ok(Box::new(MockWsConnection {
389 incoming: frames.into(),
390 sent: Arc::clone(&self.sent),
391 closed: false,
392 unsendable,
393 }))
394 }
395}
396
397#[derive(Debug)]
403pub(crate) struct MockWsConnection {
404 incoming: VecDeque<Result<Option<String>>>,
405 sent: Arc<Mutex<Vec<String>>>,
406 closed: bool,
407 unsendable: bool,
410}
411
412impl WsConnection for MockWsConnection {
413 fn send(&mut self, text: &str) -> Result<()> {
414 if self.closed || self.unsendable {
415 return Err(Error::NotConnected);
416 }
417 self.sent.lock().unwrap().push(text.to_string());
418 Ok(())
419 }
420
421 fn recv(&mut self) -> Result<Option<String>> {
422 match self.incoming.pop_front() {
425 Some(Ok(None)) => {
426 self.closed = true;
427 Ok(None)
428 }
429 Some(other) => other,
430 None => Ok(None),
431 }
432 }
433
434 fn is_connected(&self) -> bool {
435 !self.closed
436 }
437
438 fn close(&mut self) -> Result<()> {
439 self.closed = true;
440 Ok(())
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use std::time::Duration;
448
449 #[test]
450 fn http_method_strings() {
451 assert_eq!(HttpMethod::Get.as_str(), "GET");
452 assert_eq!(HttpMethod::Post.as_str(), "POST");
453 assert_eq!(HttpMethod::Put.as_str(), "PUT");
454 assert_eq!(HttpMethod::Patch.as_str(), "PATCH");
455 assert_eq!(HttpMethod::Delete.as_str(), "DELETE");
456 }
457
458 #[test]
459 fn request_builder() {
460 let req = HttpRequest::get("https://api/x")
461 .with_header("X-KEY", "abc")
462 .with_body("payload");
463 assert_eq!(req.method, HttpMethod::Get);
464 assert_eq!(req.url, "https://api/x");
465 assert_eq!(req.headers, vec![("X-KEY".to_string(), "abc".to_string())]);
466 assert_eq!(req.body.as_deref(), Some("payload"));
467
468 let post = HttpRequest::post("https://api/y");
469 assert_eq!(post.method, HttpMethod::Post);
470 let del = HttpRequest::new(HttpMethod::Delete, "https://api/z");
471 assert_eq!(del.method, HttpMethod::Delete);
472 }
473
474 #[test]
475 fn response_success_classification() {
476 assert!(HttpResponse::new(200, "{}").is_success());
477 assert!(HttpResponse::new(204, "").is_success());
478 assert!(!HttpResponse::new(400, "{}").is_success());
479 assert!(!HttpResponse::new(500, "{}").is_success());
480 }
481
482 #[test]
483 fn a_header_is_found_whatever_its_case() {
484 let response = HttpResponse::new(429, "{}").with_header("retry-after", "3");
485 assert_eq!(response.header("Retry-After"), Some("3"));
486 assert_eq!(response.header("RETRY-AFTER"), Some("3"));
487 assert_eq!(response.header("X-Absent"), None);
488 }
489
490 #[test]
491 fn retry_after_reads_whole_and_fractional_seconds() {
492 let whole = HttpResponse::new(429, "{}").with_header("Retry-After", "3");
493 assert_eq!(whole.retry_after(), Some(Duration::from_secs(3)));
494 let fractional = HttpResponse::new(429, "{}").with_header("Retry-After", " 2.5 ");
495 assert_eq!(fractional.retry_after(), Some(Duration::from_millis(2500)));
496 }
497
498 #[test]
499 fn retry_after_declines_what_it_cannot_read() {
500 assert_eq!(HttpResponse::new(429, "{}").retry_after(), None);
504 let date = HttpResponse::new(429, "{}")
505 .with_header("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT");
506 assert_eq!(date.retry_after(), None);
507 let junk = HttpResponse::new(429, "{}").with_header("Retry-After", "soon");
508 assert_eq!(junk.retry_after(), None);
509 let negative = HttpResponse::new(429, "{}").with_header("Retry-After", "-1");
510 assert_eq!(negative.retry_after(), None);
511 }
512
513 #[test]
514 fn mock_http_returns_queued_responses_fifo_and_records() {
515 let http = MockHttpTransport::new();
516 http.push_json(200, "{\"ok\":1}");
517 http.push_error(Error::Timeout);
518
519 let first = http.execute(&HttpRequest::get("https://api/a")).unwrap();
520 assert_eq!(first.body, "{\"ok\":1}");
521 let second = http.execute(&HttpRequest::post("https://api/b"));
522 assert_eq!(second.unwrap_err(), Error::Timeout);
523
524 let third = http.execute(&HttpRequest::get("https://api/c"));
526 assert!(matches!(third.unwrap_err(), Error::Network(_)));
527
528 let recorded = http.recorded_requests();
529 assert_eq!(recorded.len(), 3);
530 assert_eq!(recorded[0].url, "https://api/a");
531 assert_eq!(recorded[1].method, HttpMethod::Post);
532 }
533
534 #[test]
535 fn mock_ws_replays_frames_then_closes() {
536 let ws = MockWsTransport::new();
537 ws.push_connection(vec![
538 Ok(Some("frame-1".to_string())),
539 Ok(Some("frame-2".to_string())),
540 ]);
541
542 let mut conn = ws.connect("wss://stream/x").unwrap();
543 conn.send("subscribe").unwrap();
544 assert_eq!(conn.recv().unwrap(), Some("frame-1".to_string()));
545 assert_eq!(conn.recv().unwrap(), Some("frame-2".to_string()));
546 assert_eq!(conn.recv().unwrap(), None);
548
549 assert_eq!(ws.connected_urls(), vec!["wss://stream/x".to_string()]);
550 assert_eq!(ws.sent(), vec!["subscribe".to_string()]);
551 }
552
553 #[test]
554 fn mock_ws_surfaces_read_errors_and_blocks_send_after_close() {
555 let ws = MockWsTransport::new();
556 ws.push_connection(vec![Err(Error::Network("reset".to_string()))]);
557 let mut conn = ws.connect("wss://stream/y").unwrap();
558 assert!(matches!(conn.recv().unwrap_err(), Error::Network(_)));
559
560 conn.close().unwrap();
561 assert_eq!(conn.send("late").unwrap_err(), Error::NotConnected);
562 }
563
564 #[test]
565 fn mock_ws_connect_without_script_is_immediately_closed() {
566 let ws = MockWsTransport::new();
567 let mut conn = ws.connect("wss://stream/empty").unwrap();
568 assert_eq!(conn.recv().unwrap(), None);
569 }
570
571 #[test]
574 fn mock_transports_render_debug() {
575 assert!(format!("{:?}", MockHttpTransport::new()).starts_with("MockHttpTransport"));
576 assert!(format!("{:?}", MockWsTransport::new()).starts_with("MockWsTransport"));
577
578 let conn = MockWsConnection {
582 incoming: VecDeque::new(),
583 sent: Arc::new(Mutex::new(Vec::new())),
584 closed: false,
585 unsendable: false,
586 };
587 assert!(format!("{conn:?}").starts_with("MockWsConnection"));
588 }
589}