1use std::sync::Mutex;
17
18use async_trait::async_trait;
19use gluonscan_core::{Chain, ChainProvider, Clock, Error, Http, Timestamp};
20use serde::Deserialize;
21
22#[derive(Debug, Clone)]
25pub enum Match {
26 Any,
28 PrimaryContains(String),
30 PrimaryIs(String),
32 BodyContains(String),
34 JsonEq {
36 pointer: String,
38 value: serde_json::Value,
40 },
41 All(Vec<Match>),
43 AnyOf(Vec<Match>),
45}
46
47impl Match {
48 pub fn primary_contains(s: impl Into<String>) -> Match {
50 Match::PrimaryContains(s.into())
51 }
52 pub fn method(s: impl Into<String>) -> Match {
54 Match::PrimaryIs(s.into())
55 }
56 pub fn body_contains(s: impl Into<String>) -> Match {
58 Match::BodyContains(s.into())
59 }
60 pub fn json_eq(pointer: impl Into<String>, value: serde_json::Value) -> Match {
62 Match::JsonEq {
63 pointer: pointer.into(),
64 value,
65 }
66 }
67 pub fn all(m: impl IntoIterator<Item = Match>) -> Match {
69 Match::All(m.into_iter().collect())
70 }
71 pub fn any_of(m: impl IntoIterator<Item = Match>) -> Match {
73 Match::AnyOf(m.into_iter().collect())
74 }
75
76 pub fn matches(&self, primary: &str, body: &str) -> bool {
78 match self {
79 Match::Any => true,
80 Match::PrimaryContains(s) => primary.contains(s.as_str()),
81 Match::PrimaryIs(s) => primary == s,
82 Match::BodyContains(s) => body.contains(s.as_str()),
83 Match::JsonEq { pointer, value } => serde_json::from_str::<serde_json::Value>(body)
84 .ok()
85 .and_then(|v| v.pointer(pointer).cloned())
86 .is_some_and(|found| &found == value),
87 Match::All(ms) => ms.iter().all(|m| m.matches(primary, body)),
88 Match::AnyOf(ms) => ms.iter().any(|m| m.matches(primary, body)),
89 }
90 }
91}
92
93#[derive(Debug, Clone)]
95pub struct Contract {
96 pub when: Match,
98 pub reply: String,
100}
101
102#[derive(Debug, Clone, Deserialize)]
105pub struct ContractSpec {
106 #[serde(default)]
108 pub primary_contains: Option<String>,
109 #[serde(default)]
111 pub method: Option<String>,
112 #[serde(default)]
114 pub body_contains: Option<String>,
115 #[serde(default)]
117 pub reply: Option<String>,
118 #[serde(default)]
120 pub reply_path: Option<String>,
121}
122
123impl ContractSpec {
124 pub fn into_contract(self, base_dir: &std::path::Path) -> std::io::Result<Contract> {
126 let mut ms = Vec::new();
127 if let Some(s) = self.primary_contains {
128 ms.push(Match::PrimaryContains(s));
129 }
130 if let Some(s) = self.method {
131 ms.push(Match::PrimaryIs(s));
132 }
133 if let Some(s) = self.body_contains {
134 ms.push(Match::BodyContains(s));
135 }
136 let when = if ms.is_empty() {
137 Match::Any
138 } else {
139 Match::All(ms)
140 };
141 let reply = match (self.reply, self.reply_path) {
142 (Some(r), _) => r,
143 (None, Some(p)) => std::fs::read_to_string(base_dir.join(p))?,
144 (None, None) => String::new(),
145 };
146 Ok(Contract { when, reply })
147 }
148}
149
150pub fn load_contracts(dir: impl AsRef<std::path::Path>) -> std::io::Result<Vec<Contract>> {
152 let dir = dir.as_ref();
153 let mut out = Vec::new();
154 for entry in std::fs::read_dir(dir)? {
155 let path = entry?.path();
156 if path.extension().and_then(|e| e.to_str()) == Some("json") {
157 let spec: ContractSpec = serde_json::from_str(&std::fs::read_to_string(&path)?)
158 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
159 out.push(spec.into_contract(dir)?);
160 }
161 }
162 Ok(out)
163}
164
165fn no_match(primary: &str, body: &str) -> Error {
166 let preview: String = body.chars().take(200).collect();
167 Error::Permanent {
168 message: format!("no contract matched request to `{primary}` with body: {preview}"),
169 }
170}
171
172type ErrorContract = (Match, Box<dyn Fn() -> Error + Send + Sync>);
175
176#[derive(Default)]
178pub struct MockHttp {
179 contracts: Vec<Contract>,
180 errors: Vec<ErrorContract>,
181 calls: Mutex<Vec<(String, String)>>,
182}
183
184impl MockHttp {
185 pub fn new() -> Self {
187 MockHttp::default()
188 }
189 pub fn on(mut self, when: Match, reply: impl Into<String>) -> Self {
191 self.contracts.push(Contract {
192 when,
193 reply: reply.into(),
194 });
195 self
196 }
197 pub fn on_err(
201 mut self,
202 when: Match,
203 error: impl Fn() -> Error + Send + Sync + 'static,
204 ) -> Self {
205 self.errors.push((when, Box::new(error)));
206 self
207 }
208 pub fn on_transient(self, when: Match) -> Self {
210 self.on_err(when, || Error::Transient {
211 message: "mock transient (HTTP 429)".into(),
212 retry_after: None,
213 })
214 }
215 pub fn from_contracts(contracts: Vec<Contract>) -> Self {
217 MockHttp {
218 contracts,
219 errors: Vec::new(),
220 calls: Mutex::new(Vec::new()),
221 }
222 }
223 pub fn calls(&self) -> Vec<(String, String)> {
225 self.calls.lock().unwrap().clone()
226 }
227
228 fn reply(&self, primary: &str, body: &str) -> Result<String, Error> {
229 if let Some((_, factory)) = self.errors.iter().find(|(w, _)| w.matches(primary, body)) {
230 return Err(factory());
231 }
232 self.contracts
233 .iter()
234 .find(|c| c.when.matches(primary, body))
235 .map(|c| c.reply.clone())
236 .ok_or_else(|| no_match(primary, body))
237 }
238}
239
240#[async_trait]
241impl Http for MockHttp {
242 async fn post(
243 &self,
244 url: &str,
245 body: String,
246 _headers: &[(&str, &str)],
247 ) -> Result<String, Error> {
248 self.calls
249 .lock()
250 .unwrap()
251 .push((url.to_string(), body.clone()));
252 self.reply(url, &body)
253 }
254
255 async fn get(&self, url: &str, _headers: &[(&str, &str)]) -> Result<String, Error> {
256 self.calls
257 .lock()
258 .unwrap()
259 .push((url.to_string(), String::new()));
260 self.reply(url, "")
261 }
262}
263
264#[derive(Default)]
266pub struct MockChainProvider {
267 contracts: Vec<Contract>,
268 calls: Mutex<Vec<(String, String)>>,
269}
270
271impl MockChainProvider {
272 pub fn new() -> Self {
274 MockChainProvider::default()
275 }
276 pub fn on(mut self, when: Match, reply: impl Into<String>) -> Self {
278 self.contracts.push(Contract {
279 when,
280 reply: reply.into(),
281 });
282 self
283 }
284 pub fn calls(&self) -> Vec<(String, String)> {
286 self.calls.lock().unwrap().clone()
287 }
288}
289
290#[async_trait]
291impl ChainProvider for MockChainProvider {
292 async fn call(&self, _chain: Chain, method: &str, params: String) -> Result<String, Error> {
293 self.calls
294 .lock()
295 .unwrap()
296 .push((method.to_string(), params.clone()));
297 self.contracts
298 .iter()
299 .find(|c| c.when.matches(method, ¶ms))
300 .map(|c| c.reply.clone())
301 .ok_or_else(|| no_match(method, ¶ms))
302 }
303}
304
305#[derive(Debug, Clone, Copy)]
307pub struct MockClock(pub i64);
308
309impl Clock for MockClock {
310 fn now(&self) -> Timestamp {
311 Timestamp(self.0)
312 }
313}