1use super::{HttpClient, Request, Response};
13use crate::error::ScopeError;
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::net::TcpStream;
18use std::process::Command;
19use std::sync::atomic::{AtomicU32, Ordering};
20use std::time::Duration;
21
22pub(crate) const SIDECAR_ADDR: &str = "127.0.0.1:18789";
23const SIDECAR_URL: &str = "http://127.0.0.1:18789";
24const SPAWN_TIMEOUT: Duration = Duration::from_secs(5);
25const POLL_INTERVAL: Duration = Duration::from_millis(100);
26
27static SIDECAR_PID: AtomicU32 = AtomicU32::new(0);
29
30#[derive(Serialize)]
31struct BridgeRequest {
32 url: String,
33 method: String,
34 headers: HashMap<String, String>,
35 body: String,
36 drift: bool,
37 ghost: bool,
38 retries: i32,
39 buffer_size: u32,
40}
41
42#[derive(Deserialize)]
43struct BridgeResponse {
44 status_code: u16,
45 headers: HashMap<String, String>,
46 body: String,
47 #[serde(default)]
48 error: String,
49}
50
51pub struct GholaHttpClient {
55 client: reqwest::Client,
56 stealth: bool,
57 buffer_size: u32,
58 base_url: String,
59}
60
61impl GholaHttpClient {
62 pub fn new(stealth: bool, buffer_size: u32) -> Result<Self, ScopeError> {
64 let client = reqwest::Client::builder()
65 .timeout(Duration::from_secs(30))
66 .build()
67 .map_err(|e| {
68 ScopeError::Network(format!("failed to build ghola bridge client: {e}"))
69 })?;
70 Ok(Self {
71 client,
72 stealth,
73 buffer_size,
74 base_url: SIDECAR_URL.to_string(),
75 })
76 }
77
78 #[cfg(test)]
80 pub fn with_base_url(
81 stealth: bool,
82 buffer_size: u32,
83 base_url: &str,
84 ) -> Result<Self, ScopeError> {
85 let client = reqwest::Client::builder()
86 .timeout(Duration::from_secs(5))
87 .build()
88 .map_err(|e| {
89 ScopeError::Network(format!("failed to build ghola bridge client: {e}"))
90 })?;
91 Ok(Self {
92 client,
93 stealth,
94 buffer_size,
95 base_url: base_url.to_string(),
96 })
97 }
98
99 pub async fn ensure_ready(stealth: bool, buffer_size: u32) -> Result<Self, ScopeError> {
102 if !is_bridge_running() {
103 spawn_sidecar()?;
104 wait_for_bridge(SPAWN_TIMEOUT).await?;
105 }
106 Self::new(stealth, buffer_size)
107 }
108}
109
110#[async_trait]
111impl HttpClient for GholaHttpClient {
112 async fn send(&self, request: Request) -> Result<Response, ScopeError> {
113 let bridge_req = BridgeRequest {
114 url: request.url,
115 method: request.method,
116 headers: request.headers,
117 body: request.body.unwrap_or_default(),
118 drift: self.stealth,
119 ghost: self.stealth,
120 retries: 0,
121 buffer_size: self.buffer_size,
122 };
123
124 let resp = self
125 .client
126 .post(&self.base_url)
127 .json(&bridge_req)
128 .send()
129 .await
130 .map_err(|e| ScopeError::Network(format!("failed to reach ghola sidecar: {e}")))?;
131
132 let bridge_resp: BridgeResponse = resp
133 .json()
134 .await
135 .map_err(|e| ScopeError::Network(format!("invalid sidecar response: {e}")))?;
136
137 if !bridge_resp.error.is_empty() {
138 return Err(ScopeError::Network(format!(
139 "sidecar error: {}",
140 bridge_resp.error
141 )));
142 }
143
144 Ok(Response {
145 status_code: bridge_resp.status_code,
146 headers: bridge_resp.headers,
147 body: bridge_resp.body,
148 })
149 }
150}
151
152pub fn ghola_in_path() -> bool {
154 Command::new("ghola")
155 .arg("--help")
156 .stdout(std::process::Stdio::null())
157 .stderr(std::process::Stdio::null())
158 .status()
159 .map(|s| s.success())
160 .unwrap_or(false)
161}
162
163pub(crate) fn is_bridge_running() -> bool {
164 SIDECAR_ADDR
165 .parse()
166 .ok()
167 .and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(200)).ok())
168 .is_some()
169}
170
171const GHOLA_SPAWN_FAILED: &str = "failed to spawn ghola --serve (install it with \
173 `go install github.com/robot-accomplice/ghola/cmd/ghola@latest` or from \
174 https://github.com/robot-accomplice/ghola/releases)";
175
176fn spawn_sidecar() -> Result<(), ScopeError> {
177 let child = Command::new("ghola")
178 .arg("--serve")
179 .stdout(std::process::Stdio::null())
180 .stderr(std::process::Stdio::piped())
181 .spawn()
182 .map_err(|e| ScopeError::io_context(e, GHOLA_SPAWN_FAILED))?;
183 SIDECAR_PID.store(child.id(), Ordering::Relaxed);
184 Ok(())
185}
186
187async fn wait_for_bridge(timeout: Duration) -> Result<(), ScopeError> {
188 let start = std::time::Instant::now();
189 while start.elapsed() < timeout {
190 if is_bridge_running() {
191 return Ok(());
192 }
193 tokio::time::sleep(POLL_INTERVAL).await;
194 }
195 Err(ScopeError::Network(format!(
196 "ghola sidecar did not become ready within {timeout:?}"
197 )))
198}
199
200#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn test_bridge_request_serialization() {
210 let req = BridgeRequest {
211 url: "https://example.com".to_string(),
212 method: "GET".to_string(),
213 headers: HashMap::new(),
214 body: String::new(),
215 drift: true,
216 ghost: false,
217 retries: 3,
218 buffer_size: 4096,
219 };
220 let json = serde_json::to_string(&req).unwrap();
221 assert!(json.contains("\"drift\":true"));
222 assert!(json.contains("\"ghost\":false"));
223 assert!(json.contains("\"retries\":3"));
224 assert!(json.contains("\"buffer_size\":4096"));
225 }
226
227 #[test]
228 fn test_bridge_response_deserialization() {
229 let json = r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#;
230 let resp: BridgeResponse = serde_json::from_str(json).unwrap();
231 assert_eq!(resp.status_code, 200);
232 assert_eq!(resp.body, "ok");
233 assert!(resp.error.is_empty());
234 }
235
236 #[test]
237 fn test_bridge_response_with_error() {
238 let json = r#"{"status_code":0,"headers":{},"body":"","error":"connection refused"}"#;
239 let resp: BridgeResponse = serde_json::from_str(json).unwrap();
240 assert_eq!(resp.error, "connection refused");
241 }
242
243 #[test]
244 fn test_bridge_response_missing_error_field() {
245 let json = r#"{"status_code":200,"headers":{},"body":"data"}"#;
246 let resp: BridgeResponse = serde_json::from_str(json).unwrap();
247 assert!(resp.error.is_empty());
248 }
249
250 #[test]
251 fn test_ghola_client_creation() {
252 let client = GholaHttpClient::new(true, 4096);
253 assert!(client.is_ok());
254 }
255
256 #[test]
257 fn test_ghola_client_creation_stealth_off() {
258 let client = GholaHttpClient::new(false, 4096);
259 assert!(client.is_ok());
260 }
261
262 #[test]
263 fn test_sidecar_pid_default_zero() {
264 assert_eq!(SIDECAR_PID.load(Ordering::Relaxed), 0);
265 }
266
267 #[test]
268 fn test_is_bridge_running_returns_bool() {
269 let _ = is_bridge_running();
271 }
272
273 #[test]
274 fn test_bridge_request_full_serialization() {
275 let mut headers = HashMap::new();
276 headers.insert("Authorization".to_string(), "Bearer tk".to_string());
277 let req = BridgeRequest {
278 url: "https://api.test.com/v1".to_string(),
279 method: "POST".to_string(),
280 headers,
281 body: r#"{"data":1}"#.to_string(),
282 drift: false,
283 ghost: true,
284 retries: 0,
285 buffer_size: 8192,
286 };
287 let json = serde_json::to_string(&req).unwrap();
288 assert!(json.contains("\"method\":\"POST\""));
289 assert!(json.contains("\"ghost\":true"));
290 assert!(json.contains("\"drift\":false"));
291 assert!(json.contains("\"retries\":0"));
292 assert!(json.contains("\"buffer_size\":8192"));
293 assert!(json.contains("Authorization"));
294 }
295
296 #[test]
297 fn test_bridge_response_roundtrip() {
298 let mut headers = HashMap::new();
299 headers.insert("content-type".to_string(), "application/json".to_string());
300 let json = serde_json::json!({
301 "status_code": 201,
302 "headers": headers,
303 "body": r#"{"id":42}"#,
304 "error": ""
305 });
306 let resp: BridgeResponse = serde_json::from_value(json).unwrap();
307 assert_eq!(resp.status_code, 201);
308 assert_eq!(resp.body, r#"{"id":42}"#);
309 assert!(resp.error.is_empty());
310 assert_eq!(
311 resp.headers.get("content-type").map(String::as_str),
312 Some("application/json")
313 );
314 }
315
316 #[tokio::test]
317 async fn test_wait_for_bridge_completes() {
318 let result = wait_for_bridge(Duration::from_millis(200)).await;
319 match result {
321 Ok(()) => {} Err(e) => assert!(e.to_string().contains("did not become ready")),
323 }
324 }
325
326 #[tokio::test]
327 async fn test_send_to_mock_sidecar() {
328 let mut server = mockito::Server::new_async().await;
329 let mock = server
330 .mock("POST", "/")
331 .with_status(200)
332 .with_header("content-type", "application/json")
333 .with_body(
334 r#"{"status_code":200,"headers":{},"body":"{\"result\":\"ok\"}","error":""}"#,
335 )
336 .create_async()
337 .await;
338
339 let ghola = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
340
341 let req = Request::get("https://api.example.com/data");
342 let resp = ghola.send(req).await.unwrap();
343 assert_eq!(resp.status_code, 200);
344 assert_eq!(resp.body, r#"{"result":"ok"}"#);
345 mock.assert_async().await;
346 }
347
348 #[test]
349 fn test_ghola_in_path_returns_bool() {
350 let _ = ghola_in_path();
352 }
353
354 #[test]
355 fn test_sidecar_constants() {
356 assert_eq!(SIDECAR_ADDR, "127.0.0.1:18789");
357 assert_eq!(SIDECAR_URL, "http://127.0.0.1:18789");
358 assert_eq!(SPAWN_TIMEOUT, Duration::from_secs(5));
359 assert_eq!(POLL_INTERVAL, Duration::from_millis(100));
360 }
361
362 #[tokio::test]
363 async fn test_send_success_via_mock() {
364 let mut server = mockito::Server::new_async().await;
365 let mock = server
366 .mock("POST", "/")
367 .with_status(200)
368 .with_header("content-type", "application/json")
369 .with_body(r#"{"status_code":200,"headers":{"x-test":"yes"},"body":"{\"data\":42}","error":""}"#)
370 .create_async()
371 .await;
372
373 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
374 let req = Request::get("https://api.example.com/v1");
375 let resp = client.send(req).await.unwrap();
376
377 assert_eq!(resp.status_code, 200);
378 assert!(resp.is_success());
379 assert_eq!(resp.body, r#"{"data":42}"#);
380 assert_eq!(resp.headers.get("x-test").map(String::as_str), Some("yes"));
381 mock.assert_async().await;
382 }
383
384 #[tokio::test]
385 async fn test_send_with_stealth_off() {
386 let mut server = mockito::Server::new_async().await;
387 let mock = server
388 .mock("POST", "/")
389 .with_status(200)
390 .with_header("content-type", "application/json")
391 .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
392 .create_async()
393 .await;
394
395 let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
396 let req = Request::post_json("https://api.example.com", r#"{"q":1}"#);
397 let resp = client.send(req).await.unwrap();
398
399 assert_eq!(resp.status_code, 200);
400 assert_eq!(resp.body, "ok");
401 mock.assert_async().await;
402 }
403
404 #[tokio::test]
405 async fn test_send_bridge_error_response() {
406 let mut server = mockito::Server::new_async().await;
407 let mock = server
408 .mock("POST", "/")
409 .with_status(200)
410 .with_header("content-type", "application/json")
411 .with_body(r#"{"status_code":0,"headers":{},"body":"","error":"upstream timeout"}"#)
412 .create_async()
413 .await;
414
415 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
416 let req = Request::get("https://api.example.com");
417 let result = client.send(req).await;
418
419 assert!(result.is_err());
420 assert!(result.unwrap_err().to_string().contains("upstream timeout"));
421 mock.assert_async().await;
422 }
423
424 #[tokio::test]
425 async fn test_send_invalid_json_response() {
426 let mut server = mockito::Server::new_async().await;
427 let mock = server
428 .mock("POST", "/")
429 .with_status(200)
430 .with_body("not valid json at all")
431 .create_async()
432 .await;
433
434 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
435 let req = Request::get("https://api.example.com");
436 let result = client.send(req).await;
437
438 assert!(result.is_err());
439 assert!(
440 result
441 .unwrap_err()
442 .to_string()
443 .contains("invalid sidecar response")
444 );
445 mock.assert_async().await;
446 }
447
448 #[tokio::test]
449 async fn test_send_non_success_bridge_status() {
450 let mut server = mockito::Server::new_async().await;
451 let mock = server
452 .mock("POST", "/")
453 .with_status(200)
454 .with_header("content-type", "application/json")
455 .with_body(r#"{"status_code":429,"headers":{},"body":"rate limited","error":""}"#)
456 .create_async()
457 .await;
458
459 let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
460 let req = Request::get("https://api.example.com");
461 let resp = client.send(req).await.unwrap();
462
463 assert_eq!(resp.status_code, 429);
464 assert!(!resp.is_success());
465 assert_eq!(resp.body, "rate limited");
466 mock.assert_async().await;
467 }
468
469 #[tokio::test]
470 async fn test_send_with_custom_headers() {
471 let mut server = mockito::Server::new_async().await;
472 let mock = server
473 .mock("POST", "/")
474 .with_status(200)
475 .with_header("content-type", "application/json")
476 .with_body(r#"{"status_code":200,"headers":{},"body":"{}","error":""}"#)
477 .create_async()
478 .await;
479
480 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
481 let req = Request::get("https://api.example.com")
482 .with_header("Authorization", "Bearer token")
483 .with_header("X-Chain", "ethereum");
484 let resp = client.send(req).await.unwrap();
485
486 assert!(resp.is_success());
487 mock.assert_async().await;
488 }
489
490 #[tokio::test]
491 async fn test_send_connection_refused() {
492 let client = GholaHttpClient::with_base_url(true, 4096, "http://127.0.0.1:1").unwrap();
493 let req = Request::get("https://api.example.com");
494 let result = client.send(req).await;
495
496 assert!(result.is_err());
497 assert!(
498 result
499 .unwrap_err()
500 .to_string()
501 .contains("failed to reach ghola sidecar")
502 );
503 }
504
505 #[tokio::test]
506 async fn test_send_includes_buffer_size() {
507 let mut server = mockito::Server::new_async().await;
508 let mock = server
509 .mock("POST", "/")
510 .match_body(mockito::Matcher::PartialJsonString(
511 r#"{"buffer_size":8192}"#.to_string(),
512 ))
513 .with_status(200)
514 .with_header("content-type", "application/json")
515 .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
516 .create_async()
517 .await;
518
519 let client = GholaHttpClient::with_base_url(true, 8192, &server.url()).unwrap();
520 let req = Request::get("https://api.example.com");
521 let resp = client.send(req).await.unwrap();
522
523 assert_eq!(resp.status_code, 200);
524 mock.assert_async().await;
525 }
526
527 #[test]
528 fn test_custom_buffer_size_stored() {
529 let client = GholaHttpClient::new(true, 16384).unwrap();
530 assert_eq!(client.buffer_size, 16384);
531 }
532
533 #[test]
534 fn test_default_buffer_size() {
535 let client = GholaHttpClient::new(false, 4096).unwrap();
536 assert_eq!(client.buffer_size, 4096);
537 }
538}