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 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
163fn 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
171fn spawn_sidecar() -> Result<(), ScopeError> {
172 let child = Command::new("ghola")
173 .arg("--serve")
174 .stdout(std::process::Stdio::null())
175 .stderr(std::process::Stdio::piped())
176 .spawn()
177 .map_err(|e| {
178 ScopeError::Network(format!(
179 "failed to spawn ghola --serve: {e}\n \
180 Install: go install github.com/robot-accomplice/ghola/cmd/ghola@latest\n \
181 Or download from: https://github.com/robot-accomplice/ghola/releases"
182 ))
183 })?;
184 SIDECAR_PID.store(child.id(), Ordering::Relaxed);
185 Ok(())
186}
187
188async fn wait_for_bridge(timeout: Duration) -> Result<(), ScopeError> {
189 let start = std::time::Instant::now();
190 while start.elapsed() < timeout {
191 if is_bridge_running() {
192 return Ok(());
193 }
194 tokio::time::sleep(POLL_INTERVAL).await;
195 }
196 Err(ScopeError::Network(format!(
197 "ghola sidecar did not become ready within {timeout:?}"
198 )))
199}
200
201#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn test_bridge_request_serialization() {
211 let req = BridgeRequest {
212 url: "https://example.com".to_string(),
213 method: "GET".to_string(),
214 headers: HashMap::new(),
215 body: String::new(),
216 drift: true,
217 ghost: false,
218 retries: 3,
219 buffer_size: 4096,
220 };
221 let json = serde_json::to_string(&req).unwrap();
222 assert!(json.contains("\"drift\":true"));
223 assert!(json.contains("\"ghost\":false"));
224 assert!(json.contains("\"retries\":3"));
225 assert!(json.contains("\"buffer_size\":4096"));
226 }
227
228 #[test]
229 fn test_bridge_response_deserialization() {
230 let json = r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#;
231 let resp: BridgeResponse = serde_json::from_str(json).unwrap();
232 assert_eq!(resp.status_code, 200);
233 assert_eq!(resp.body, "ok");
234 assert!(resp.error.is_empty());
235 }
236
237 #[test]
238 fn test_bridge_response_with_error() {
239 let json = r#"{"status_code":0,"headers":{},"body":"","error":"connection refused"}"#;
240 let resp: BridgeResponse = serde_json::from_str(json).unwrap();
241 assert_eq!(resp.error, "connection refused");
242 }
243
244 #[test]
245 fn test_bridge_response_missing_error_field() {
246 let json = r#"{"status_code":200,"headers":{},"body":"data"}"#;
247 let resp: BridgeResponse = serde_json::from_str(json).unwrap();
248 assert!(resp.error.is_empty());
249 }
250
251 #[test]
252 fn test_ghola_client_creation() {
253 let client = GholaHttpClient::new(true, 4096);
254 assert!(client.is_ok());
255 }
256
257 #[test]
258 fn test_ghola_client_creation_stealth_off() {
259 let client = GholaHttpClient::new(false, 4096);
260 assert!(client.is_ok());
261 }
262
263 #[test]
264 fn test_sidecar_pid_default_zero() {
265 assert_eq!(SIDECAR_PID.load(Ordering::Relaxed), 0);
266 }
267
268 #[test]
269 fn test_is_bridge_running_returns_bool() {
270 let result = is_bridge_running();
271 assert!(result == true || result == false);
272 }
273
274 #[test]
275 fn test_bridge_request_full_serialization() {
276 let mut headers = HashMap::new();
277 headers.insert("Authorization".to_string(), "Bearer tk".to_string());
278 let req = BridgeRequest {
279 url: "https://api.test.com/v1".to_string(),
280 method: "POST".to_string(),
281 headers,
282 body: r#"{"data":1}"#.to_string(),
283 drift: false,
284 ghost: true,
285 retries: 0,
286 buffer_size: 8192,
287 };
288 let json = serde_json::to_string(&req).unwrap();
289 assert!(json.contains("\"method\":\"POST\""));
290 assert!(json.contains("\"ghost\":true"));
291 assert!(json.contains("\"drift\":false"));
292 assert!(json.contains("\"retries\":0"));
293 assert!(json.contains("\"buffer_size\":8192"));
294 assert!(json.contains("Authorization"));
295 }
296
297 #[test]
298 fn test_bridge_response_roundtrip() {
299 let mut headers = HashMap::new();
300 headers.insert("content-type".to_string(), "application/json".to_string());
301 let json = serde_json::json!({
302 "status_code": 201,
303 "headers": headers,
304 "body": r#"{"id":42}"#,
305 "error": ""
306 });
307 let resp: BridgeResponse = serde_json::from_value(json).unwrap();
308 assert_eq!(resp.status_code, 201);
309 assert_eq!(resp.body, r#"{"id":42}"#);
310 assert!(resp.error.is_empty());
311 assert_eq!(
312 resp.headers.get("content-type").map(String::as_str),
313 Some("application/json")
314 );
315 }
316
317 #[tokio::test]
318 async fn test_wait_for_bridge_completes() {
319 let result = wait_for_bridge(Duration::from_millis(200)).await;
320 match result {
322 Ok(()) => {} Err(e) => assert!(e.to_string().contains("did not become ready")),
324 }
325 }
326
327 #[tokio::test]
328 async fn test_send_to_mock_sidecar() {
329 let mut server = mockito::Server::new_async().await;
330 let mock = server
331 .mock("POST", "/")
332 .with_status(200)
333 .with_header("content-type", "application/json")
334 .with_body(
335 r#"{"status_code":200,"headers":{},"body":"{\"result\":\"ok\"}","error":""}"#,
336 )
337 .create_async()
338 .await;
339
340 let ghola = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
341
342 let req = Request::get("https://api.example.com/data");
343 let resp = ghola.send(req).await.unwrap();
344 assert_eq!(resp.status_code, 200);
345 assert_eq!(resp.body, r#"{"result":"ok"}"#);
346 mock.assert_async().await;
347 }
348
349 #[test]
350 fn test_ghola_in_path_returns_bool() {
351 let result = ghola_in_path();
352 assert!(result == true || result == false);
353 }
354
355 #[test]
356 fn test_sidecar_constants() {
357 assert_eq!(SIDECAR_ADDR, "127.0.0.1:18789");
358 assert_eq!(SIDECAR_URL, "http://127.0.0.1:18789");
359 assert_eq!(SPAWN_TIMEOUT, Duration::from_secs(5));
360 assert_eq!(POLL_INTERVAL, Duration::from_millis(100));
361 }
362
363 #[tokio::test]
364 async fn test_send_success_via_mock() {
365 let mut server = mockito::Server::new_async().await;
366 let mock = server
367 .mock("POST", "/")
368 .with_status(200)
369 .with_header("content-type", "application/json")
370 .with_body(r#"{"status_code":200,"headers":{"x-test":"yes"},"body":"{\"data\":42}","error":""}"#)
371 .create_async()
372 .await;
373
374 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
375 let req = Request::get("https://api.example.com/v1");
376 let resp = client.send(req).await.unwrap();
377
378 assert_eq!(resp.status_code, 200);
379 assert!(resp.is_success());
380 assert_eq!(resp.body, r#"{"data":42}"#);
381 assert_eq!(resp.headers.get("x-test").map(String::as_str), Some("yes"));
382 mock.assert_async().await;
383 }
384
385 #[tokio::test]
386 async fn test_send_with_stealth_off() {
387 let mut server = mockito::Server::new_async().await;
388 let mock = server
389 .mock("POST", "/")
390 .with_status(200)
391 .with_header("content-type", "application/json")
392 .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
393 .create_async()
394 .await;
395
396 let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
397 let req = Request::post_json("https://api.example.com", r#"{"q":1}"#);
398 let resp = client.send(req).await.unwrap();
399
400 assert_eq!(resp.status_code, 200);
401 assert_eq!(resp.body, "ok");
402 mock.assert_async().await;
403 }
404
405 #[tokio::test]
406 async fn test_send_bridge_error_response() {
407 let mut server = mockito::Server::new_async().await;
408 let mock = server
409 .mock("POST", "/")
410 .with_status(200)
411 .with_header("content-type", "application/json")
412 .with_body(r#"{"status_code":0,"headers":{},"body":"","error":"upstream timeout"}"#)
413 .create_async()
414 .await;
415
416 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
417 let req = Request::get("https://api.example.com");
418 let result = client.send(req).await;
419
420 assert!(result.is_err());
421 assert!(result.unwrap_err().to_string().contains("upstream timeout"));
422 mock.assert_async().await;
423 }
424
425 #[tokio::test]
426 async fn test_send_invalid_json_response() {
427 let mut server = mockito::Server::new_async().await;
428 let mock = server
429 .mock("POST", "/")
430 .with_status(200)
431 .with_body("not valid json at all")
432 .create_async()
433 .await;
434
435 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
436 let req = Request::get("https://api.example.com");
437 let result = client.send(req).await;
438
439 assert!(result.is_err());
440 assert!(result
441 .unwrap_err()
442 .to_string()
443 .contains("invalid sidecar response"));
444 mock.assert_async().await;
445 }
446
447 #[tokio::test]
448 async fn test_send_non_success_bridge_status() {
449 let mut server = mockito::Server::new_async().await;
450 let mock = server
451 .mock("POST", "/")
452 .with_status(200)
453 .with_header("content-type", "application/json")
454 .with_body(r#"{"status_code":429,"headers":{},"body":"rate limited","error":""}"#)
455 .create_async()
456 .await;
457
458 let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
459 let req = Request::get("https://api.example.com");
460 let resp = client.send(req).await.unwrap();
461
462 assert_eq!(resp.status_code, 429);
463 assert!(!resp.is_success());
464 assert_eq!(resp.body, "rate limited");
465 mock.assert_async().await;
466 }
467
468 #[tokio::test]
469 async fn test_send_with_custom_headers() {
470 let mut server = mockito::Server::new_async().await;
471 let mock = server
472 .mock("POST", "/")
473 .with_status(200)
474 .with_header("content-type", "application/json")
475 .with_body(r#"{"status_code":200,"headers":{},"body":"{}","error":""}"#)
476 .create_async()
477 .await;
478
479 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
480 let req = Request::get("https://api.example.com")
481 .with_header("Authorization", "Bearer token")
482 .with_header("X-Chain", "ethereum");
483 let resp = client.send(req).await.unwrap();
484
485 assert!(resp.is_success());
486 mock.assert_async().await;
487 }
488
489 #[tokio::test]
490 async fn test_send_connection_refused() {
491 let client = GholaHttpClient::with_base_url(true, 4096, "http://127.0.0.1:1").unwrap();
492 let req = Request::get("https://api.example.com");
493 let result = client.send(req).await;
494
495 assert!(result.is_err());
496 assert!(result
497 .unwrap_err()
498 .to_string()
499 .contains("failed to reach ghola sidecar"));
500 }
501
502 #[tokio::test]
503 async fn test_send_includes_buffer_size() {
504 let mut server = mockito::Server::new_async().await;
505 let mock = server
506 .mock("POST", "/")
507 .match_body(mockito::Matcher::PartialJsonString(
508 r#"{"buffer_size":8192}"#.to_string(),
509 ))
510 .with_status(200)
511 .with_header("content-type", "application/json")
512 .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
513 .create_async()
514 .await;
515
516 let client = GholaHttpClient::with_base_url(true, 8192, &server.url()).unwrap();
517 let req = Request::get("https://api.example.com");
518 let resp = client.send(req).await.unwrap();
519
520 assert_eq!(resp.status_code, 200);
521 mock.assert_async().await;
522 }
523
524 #[test]
525 fn test_custom_buffer_size_stored() {
526 let client = GholaHttpClient::new(true, 16384).unwrap();
527 assert_eq!(client.buffer_size, 16384);
528 }
529
530 #[test]
531 fn test_default_buffer_size() {
532 let client = GholaHttpClient::new(false, 4096).unwrap();
533 assert_eq!(client.buffer_size, 4096);
534 }
535}