1use crate::error::HttpError;
4use crate::transport::{OutRequest, OutResponse, Transport};
5use bytes::Bytes;
6use http::{HeaderMap, Method, StatusCode};
7use sova_core::extend::BoxFuture;
8use std::sync::Mutex;
9
10#[derive(Debug, Clone)]
11pub enum StubBody {
12 Empty,
13 Bytes(Bytes),
14 Json(serde_json::Value),
15}
16
17#[derive(Debug, Clone)]
18pub enum StubOutcome {
19 Response {
20 status: StatusCode,
21 headers: HeaderMap,
22 body: StubBody,
23 },
24 Fail(String),
25}
26
27#[derive(Debug, Clone)]
28struct Stub {
29 method: Option<Method>,
30 url_pattern: String,
31 outcome: StubOutcome,
32}
33
34#[derive(Debug, Default)]
35struct FakeInner {
36 stubs: Vec<Stub>,
37 sent: Vec<OutRequest>,
38}
39
40#[derive(Clone, Default)]
42pub struct FakeTransport {
43 inner: std::sync::Arc<Mutex<FakeInner>>,
44}
45
46impl FakeTransport {
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 pub fn get(self, url: impl Into<String>, body: impl Into<StubBody>) -> Self {
52 self.stub(Some(Method::GET), url, StubOutcome::Response {
53 status: StatusCode::OK,
54 headers: HeaderMap::new(),
55 body: body.into(),
56 })
57 }
58
59 pub fn post(self, url: impl Into<String>, status: u16) -> Self {
60 self.stub(
61 Some(Method::POST),
62 url,
63 StubOutcome::Response {
64 status: StatusCode::from_u16(status).unwrap_or(StatusCode::CREATED),
65 headers: HeaderMap::new(),
66 body: StubBody::Empty,
67 },
68 )
69 }
70
71 pub fn respond(
72 self,
73 method: Method,
74 url: impl Into<String>,
75 status: u16,
76 body: impl Into<StubBody>,
77 ) -> Self {
78 self.stub(
79 Some(method),
80 url,
81 StubOutcome::Response {
82 status: StatusCode::from_u16(status).unwrap_or(StatusCode::OK),
83 headers: HeaderMap::new(),
84 body: body.into(),
85 },
86 )
87 }
88
89 pub fn fail(self, url: impl Into<String>, msg: impl Into<String>) -> Self {
90 self.stub(None, url, StubOutcome::Fail(msg.into()))
91 }
92
93 fn stub(self, method: Option<Method>, url: impl Into<String>, outcome: StubOutcome) -> Self {
94 self.inner.lock().unwrap().stubs.push(Stub {
95 method,
96 url_pattern: url.into(),
97 outcome,
98 });
99 self
100 }
101
102 pub fn assert_sent(&self, method: Method, url: &str) {
103 let g = self.inner.lock().unwrap();
104 assert!(
105 g.sent
106 .iter()
107 .any(|r| r.method == method && url_matches(&r.url, url)),
108 "expected {method} {url}, sent: {:?}",
109 g.sent
110 .iter()
111 .map(|r| format!("{} {}", r.method, r.url))
112 .collect::<Vec<_>>()
113 );
114 }
115
116 pub fn assert_sent_count(&self, n: usize) {
117 let g = self.inner.lock().unwrap();
118 assert_eq!(g.sent.len(), n, "sent {:?}", g.sent.len());
119 }
120
121 pub fn assert_not_sent(&self, url: &str) {
122 let g = self.inner.lock().unwrap();
123 assert!(
124 !g.sent.iter().any(|r| url_matches(&r.url, url)),
125 "unexpected call to {url}"
126 );
127 }
128
129 pub fn sent(&self) -> Vec<OutRequest> {
130 self.inner.lock().unwrap().sent.clone()
131 }
132}
133
134impl From<serde_json::Value> for StubBody {
135 fn from(v: serde_json::Value) -> Self {
136 Self::Json(v)
137 }
138}
139
140impl From<&'static str> for StubBody {
141 fn from(s: &'static str) -> Self {
142 Self::Bytes(Bytes::from_static(s.as_bytes()))
143 }
144}
145
146impl From<String> for StubBody {
147 fn from(s: String) -> Self {
148 Self::Bytes(Bytes::from(s))
149 }
150}
151
152fn url_matches(actual: &str, pattern: &str) -> bool {
153 if let Some(prefix) = pattern.strip_suffix('*') {
154 actual.starts_with(prefix)
155 } else {
156 actual == pattern
157 }
158}
159
160fn stub_to_response(outcome: &StubOutcome) -> Result<OutResponse, HttpError> {
161 match outcome {
162 StubOutcome::Fail(m) => {
163 if m.to_ascii_lowercase().contains("timeout") {
164 Err(HttpError::Timeout)
165 } else {
166 Err(HttpError::Other(m.clone()))
167 }
168 }
169 StubOutcome::Response {
170 status,
171 headers,
172 body,
173 } => {
174 let bytes = match body {
175 StubBody::Empty => Bytes::new(),
176 StubBody::Bytes(b) => b.clone(),
177 StubBody::Json(v) => Bytes::from(serde_json::to_vec(v).unwrap_or_default()),
178 };
179 Ok(OutResponse::new(*status, headers.clone(), bytes))
180 }
181 }
182}
183
184impl Transport for FakeTransport {
185 fn send(&self, req: OutRequest) -> BoxFuture<Result<OutResponse, HttpError>> {
186 let this = self.clone();
187 Box::pin(async move {
188 let mut g = this.inner.lock().unwrap();
189 g.sent.push(req.clone());
190 let outcome = g
191 .stubs
192 .iter()
193 .find(|s| {
194 s.method.as_ref().map(|m| *m == req.method).unwrap_or(true)
195 && url_matches(&req.url, &s.url_pattern)
196 })
197 .map(|s| s.outcome.clone());
198 drop(g);
199 match outcome {
200 Some(o) => stub_to_response(&o),
201 None => Err(HttpError::Other(format!(
202 "no fake stub for {} {}",
203 req.method, req.url
204 ))),
205 }
206 })
207 }
208}