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