Skip to main content

barbacane_wasm/
http_client.rs

1//! HTTP client for outbound requests from WASM plugins.
2//!
3//! Provides connection pooling, TLS, timeouts, and circuit breaker support.
4
5use std::collections::HashMap;
6use std::hash::{Hash, Hasher};
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11use parking_lot::RwLock;
12use reqwest::{Certificate, Client, Identity};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use crate::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig, CircuitState};
17use barbacane_plugin_sdk::types::base64_body;
18
19/// TLS configuration for upstream mTLS connections.
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct TlsConfig {
22    /// Path to PEM-encoded client certificate.
23    #[serde(default)]
24    pub client_cert: Option<PathBuf>,
25    /// Path to PEM-encoded client private key.
26    #[serde(default)]
27    pub client_key: Option<PathBuf>,
28    /// Path to PEM-encoded CA certificate for server verification.
29    #[serde(default)]
30    pub ca: Option<PathBuf>,
31}
32
33impl TlsConfig {
34    /// Returns true if any TLS configuration is specified.
35    pub fn is_configured(&self) -> bool {
36        self.client_cert.is_some() || self.client_key.is_some() || self.ca.is_some()
37    }
38
39    /// Validate that if client_cert is set, client_key must also be set (and vice versa).
40    pub fn validate(&self) -> Result<(), TlsConfigError> {
41        match (&self.client_cert, &self.client_key) {
42            (Some(_), None) => Err(TlsConfigError::MissingClientKey),
43            (None, Some(_)) => Err(TlsConfigError::MissingClientCert),
44            _ => Ok(()),
45        }
46    }
47
48    /// Create a cache key for this TLS configuration.
49    fn cache_key(&self) -> TlsCacheKey {
50        TlsCacheKey {
51            client_cert: self.client_cert.clone(),
52            client_key: self.client_key.clone(),
53            ca: self.ca.clone(),
54        }
55    }
56}
57
58/// TLS configuration errors.
59#[derive(Debug, Error)]
60pub enum TlsConfigError {
61    #[error("client_cert specified but client_key is missing")]
62    MissingClientKey,
63    #[error("client_key specified but client_cert is missing")]
64    MissingClientCert,
65    #[error("failed to read certificate file: {0}")]
66    ReadCertificate(#[source] std::io::Error),
67    #[error("failed to read key file: {0}")]
68    ReadKey(#[source] std::io::Error),
69    #[error("failed to read CA file: {0}")]
70    ReadCa(#[source] std::io::Error),
71    #[error("failed to parse PEM identity: {0}")]
72    ParseIdentity(#[source] reqwest::Error),
73    #[error("failed to parse CA certificate: {0}")]
74    ParseCaCert(#[source] reqwest::Error),
75}
76
77/// Cache key for TLS-configured clients.
78#[derive(Debug, Clone, PartialEq, Eq)]
79struct TlsCacheKey {
80    client_cert: Option<PathBuf>,
81    client_key: Option<PathBuf>,
82    ca: Option<PathBuf>,
83}
84
85impl Hash for TlsCacheKey {
86    fn hash<H: Hasher>(&self, state: &mut H) {
87        self.client_cert.hash(state);
88        self.client_key.hash(state);
89        self.ca.hash(state);
90    }
91}
92
93/// HTTP client with connection pooling and circuit breaker support.
94#[derive(Clone)]
95pub struct HttpClient {
96    /// Default client (no mTLS).
97    client: Client,
98    /// Cached clients with specific TLS configurations.
99    tls_clients: Arc<RwLock<HashMap<TlsCacheKey, Client>>>,
100    /// Base config for creating new clients.
101    base_config: HttpClientConfig,
102    circuit_breakers: Arc<RwLock<HashMap<String, CircuitBreaker>>>,
103    default_timeout: Duration,
104    allow_plaintext: bool,
105}
106
107impl HttpClient {
108    /// Create a new HTTP client.
109    pub fn new(config: HttpClientConfig) -> Result<Self, HttpClientError> {
110        let client = Client::builder()
111            .pool_max_idle_per_host(config.pool_max_idle_per_host)
112            .pool_idle_timeout(config.pool_idle_timeout)
113            .connect_timeout(config.connect_timeout)
114            .timeout(config.default_timeout)
115            .build()
116            .map_err(HttpClientError::BuildError)?;
117
118        let default_timeout = config.default_timeout;
119        let allow_plaintext = config.allow_plaintext;
120
121        Ok(Self {
122            client,
123            tls_clients: Arc::new(RwLock::new(HashMap::new())),
124            base_config: config,
125            circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
126            default_timeout,
127            allow_plaintext,
128        })
129    }
130
131    /// Get or create a client with the specified TLS configuration.
132    fn get_or_create_tls_client(&self, tls_config: &TlsConfig) -> Result<Client, HttpClientError> {
133        let cache_key = tls_config.cache_key();
134
135        // Check if we already have a client for this config
136        {
137            let clients = self.tls_clients.read();
138            if let Some(client) = clients.get(&cache_key) {
139                return Ok(client.clone());
140            }
141        }
142
143        // Create a new client with TLS config
144        let client = self.build_tls_client(tls_config)?;
145
146        // Cache it
147        {
148            let mut clients = self.tls_clients.write();
149            clients.insert(cache_key, client.clone());
150        }
151
152        Ok(client)
153    }
154
155    /// Build a new client with the specified TLS configuration.
156    fn build_tls_client(&self, tls_config: &TlsConfig) -> Result<Client, HttpClientError> {
157        tls_config.validate().map_err(HttpClientError::TlsConfig)?;
158
159        let mut builder = Client::builder()
160            .pool_max_idle_per_host(self.base_config.pool_max_idle_per_host)
161            .pool_idle_timeout(self.base_config.pool_idle_timeout)
162            .connect_timeout(self.base_config.connect_timeout)
163            .timeout(self.base_config.default_timeout);
164
165        // Add client certificate (mTLS)
166        if let (Some(cert_path), Some(key_path)) = (&tls_config.client_cert, &tls_config.client_key)
167        {
168            let cert_pem = std::fs::read(cert_path)
169                .map_err(|e| HttpClientError::TlsConfig(TlsConfigError::ReadCertificate(e)))?;
170            let key_pem = std::fs::read(key_path)
171                .map_err(|e| HttpClientError::TlsConfig(TlsConfigError::ReadKey(e)))?;
172
173            // Combine cert and key for Identity::from_pem
174            let mut pem = cert_pem;
175            pem.extend_from_slice(&key_pem);
176
177            let identity = Identity::from_pem(&pem)
178                .map_err(|e| HttpClientError::TlsConfig(TlsConfigError::ParseIdentity(e)))?;
179
180            builder = builder.identity(identity);
181        }
182
183        // Add custom CA certificate
184        if let Some(ca_path) = &tls_config.ca {
185            let ca_pem = std::fs::read(ca_path)
186                .map_err(|e| HttpClientError::TlsConfig(TlsConfigError::ReadCa(e)))?;
187
188            let ca_cert = Certificate::from_pem(&ca_pem)
189                .map_err(|e| HttpClientError::TlsConfig(TlsConfigError::ParseCaCert(e)))?;
190
191            builder = builder.add_root_certificate(ca_cert);
192        }
193
194        builder.build().map_err(HttpClientError::BuildError)
195    }
196
197    /// Make an HTTP request.
198    pub async fn call(&self, request: HttpRequest) -> Result<HttpResponse, HttpClientError> {
199        self.call_with_tls(request, None).await
200    }
201
202    /// Send a streaming HTTP request and return the raw upstream response.
203    ///
204    /// Applies the same URL validation, plaintext checks, and circuit breaker
205    /// as `call`, but returns the `reqwest::Response` directly so the caller
206    /// can stream the response body chunk by chunk (e.g. via `bytes_stream()`).
207    ///
208    /// The circuit breaker is only updated on connection-level errors; success
209    /// recording is left to the caller after streaming completes.
210    pub async fn stream_raw(
211        &self,
212        request: HttpRequest,
213    ) -> Result<reqwest::Response, HttpClientError> {
214        let url = request
215            .url
216            .parse::<reqwest::Url>()
217            .map_err(|e| HttpClientError::InvalidUrl(e.to_string()))?;
218
219        if url.scheme() == "http" && !self.allow_plaintext {
220            return Err(HttpClientError::PlaintextNotAllowed);
221        }
222
223        let host = url
224            .host_str()
225            .ok_or_else(|| HttpClientError::InvalidUrl("missing host".into()))?
226            .to_string();
227
228        let circuit_state = self.get_circuit_state(&host);
229        if circuit_state == crate::circuit_breaker::CircuitState::Open {
230            return Err(HttpClientError::CircuitOpen(host));
231        }
232
233        let method = request
234            .method
235            .parse::<reqwest::Method>()
236            .map_err(|e| HttpClientError::InvalidMethod(e.to_string()))?;
237
238        let timeout = request.timeout.unwrap_or(self.default_timeout);
239
240        let mut req_builder = self.client.request(method, url).timeout(timeout);
241
242        for (key, value) in &request.headers {
243            req_builder = req_builder.header(key.as_str(), value.as_str());
244        }
245
246        if let Some(body) = request.body {
247            req_builder = req_builder.body(body);
248        }
249
250        match req_builder.send().await {
251            Ok(response) => Ok(response),
252            Err(e) => {
253                self.record_failure(&host);
254                if e.is_timeout() {
255                    Err(HttpClientError::Timeout)
256                } else if e.is_connect() {
257                    Err(HttpClientError::ConnectionFailed(e.to_string()))
258                } else {
259                    Err(HttpClientError::RequestFailed(e.to_string()))
260                }
261            }
262        }
263    }
264
265    /// Make an HTTP request with optional TLS configuration for mTLS.
266    pub async fn call_with_tls(
267        &self,
268        request: HttpRequest,
269        tls_config: Option<&TlsConfig>,
270    ) -> Result<HttpResponse, HttpClientError> {
271        // Validate URL scheme
272        let url = request
273            .url
274            .parse::<reqwest::Url>()
275            .map_err(|e| HttpClientError::InvalidUrl(e.to_string()))?;
276
277        if url.scheme() == "http" && !self.allow_plaintext {
278            return Err(HttpClientError::PlaintextNotAllowed);
279        }
280
281        // Extract host for circuit breaker
282        let host = url
283            .host_str()
284            .ok_or_else(|| HttpClientError::InvalidUrl("missing host".into()))?
285            .to_string();
286
287        // Check circuit breaker
288        let circuit_state = self.get_circuit_state(&host);
289        if circuit_state == CircuitState::Open {
290            return Err(HttpClientError::CircuitOpen(host));
291        }
292
293        // Get the appropriate client (default or TLS-configured)
294        let client = match tls_config {
295            Some(tls) if tls.is_configured() => self.get_or_create_tls_client(tls)?,
296            _ => self.client.clone(),
297        };
298
299        // Build request
300        let method = request
301            .method
302            .parse::<reqwest::Method>()
303            .map_err(|e| HttpClientError::InvalidMethod(e.to_string()))?;
304
305        let timeout = request.timeout.unwrap_or(self.default_timeout);
306
307        let mut req_builder = client.request(method, url).timeout(timeout);
308
309        // Add headers
310        for (key, value) in &request.headers {
311            req_builder = req_builder.header(key.as_str(), value.as_str());
312        }
313
314        // Add body
315        if let Some(body) = request.body {
316            req_builder = req_builder.body(body);
317        }
318
319        // Execute request
320        let result = req_builder.send().await;
321
322        match result {
323            Ok(response) => {
324                // Record success
325                self.record_success(&host);
326
327                let status = response.status().as_u16();
328                let headers: HashMap<String, String> = response
329                    .headers()
330                    .iter()
331                    .filter_map(|(k, v)| {
332                        v.to_str()
333                            .ok()
334                            .map(|v| (k.as_str().to_lowercase(), v.to_string()))
335                    })
336                    .collect();
337
338                let body = response
339                    .bytes()
340                    .await
341                    .map_err(HttpClientError::ResponseReadError)?;
342
343                Ok(HttpResponse {
344                    status,
345                    headers,
346                    body: Some(body.to_vec()),
347                })
348            }
349            Err(e) => {
350                // Record failure
351                self.record_failure(&host);
352
353                if e.is_timeout() {
354                    Err(HttpClientError::Timeout)
355                } else if e.is_connect() {
356                    Err(HttpClientError::ConnectionFailed(e.to_string()))
357                } else {
358                    Err(HttpClientError::RequestFailed(e.to_string()))
359                }
360            }
361        }
362    }
363
364    /// Configure circuit breaker for a host.
365    pub fn configure_circuit_breaker(&self, host: &str, config: CircuitBreakerConfig) {
366        let mut breakers = self.circuit_breakers.write();
367        breakers.insert(host.to_string(), CircuitBreaker::new(config));
368    }
369
370    fn get_circuit_state(&self, host: &str) -> CircuitState {
371        let breakers = self.circuit_breakers.read();
372        breakers
373            .get(host)
374            .map(|cb| cb.state())
375            .unwrap_or(CircuitState::Closed)
376    }
377
378    fn record_success(&self, host: &str) {
379        let mut breakers = self.circuit_breakers.write();
380        if let Some(cb) = breakers.get_mut(host) {
381            cb.record_success();
382        }
383    }
384
385    fn record_failure(&self, host: &str) {
386        let mut breakers = self.circuit_breakers.write();
387        if let Some(cb) = breakers.get_mut(host) {
388            cb.record_failure();
389        }
390    }
391}
392
393/// Configuration for the HTTP client.
394#[derive(Debug, Clone)]
395pub struct HttpClientConfig {
396    /// Maximum idle connections per host.
397    pub pool_max_idle_per_host: usize,
398    /// Idle connection timeout.
399    pub pool_idle_timeout: Duration,
400    /// Connection timeout.
401    pub connect_timeout: Duration,
402    /// Default request timeout.
403    pub default_timeout: Duration,
404    /// Allow plaintext HTTP (development only).
405    pub allow_plaintext: bool,
406}
407
408impl Default for HttpClientConfig {
409    fn default() -> Self {
410        Self {
411            pool_max_idle_per_host: 10,
412            pool_idle_timeout: Duration::from_secs(90),
413            connect_timeout: Duration::from_secs(10),
414            default_timeout: Duration::from_secs(30),
415            allow_plaintext: false,
416        }
417    }
418}
419
420/// HTTP request from WASM plugin.
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct HttpRequest {
423    /// HTTP method (GET, POST, etc.)
424    pub method: String,
425    /// Full URL including scheme and host.
426    pub url: String,
427    /// Request headers.
428    #[serde(default)]
429    pub headers: HashMap<String, String>,
430    /// Request body (optional, base64-encoded in JSON for WASM transport).
431    #[serde(default, with = "base64_body")]
432    pub body: Option<Vec<u8>>,
433    /// Request timeout (optional, uses client default).
434    #[serde(default, with = "option_duration_serde")]
435    pub timeout: Option<Duration>,
436}
437
438/// HTTP response to WASM plugin.
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct HttpResponse {
441    /// HTTP status code.
442    pub status: u16,
443    /// Response headers.
444    pub headers: HashMap<String, String>,
445    /// Response body (optional, base64-encoded in JSON for WASM transport).
446    #[serde(default, with = "base64_body")]
447    pub body: Option<Vec<u8>>,
448}
449
450impl HttpResponse {
451    /// Create an error response.
452    pub fn error(status: u16, error_type: &str, title: &str, detail: &str) -> Self {
453        let body = serde_json::json!({
454            "type": error_type,
455            "title": title,
456            "status": status,
457            "detail": detail
458        });
459
460        let mut headers = HashMap::new();
461        headers.insert(
462            "content-type".to_string(),
463            "application/problem+json".to_string(),
464        );
465
466        Self {
467            status,
468            headers,
469            body: Some(body.to_string().into_bytes()),
470        }
471    }
472}
473
474/// HTTP client errors.
475#[derive(Debug, Error)]
476pub enum HttpClientError {
477    #[error("failed to build HTTP client: {0}")]
478    BuildError(#[source] reqwest::Error),
479
480    #[error("invalid URL: {0}")]
481    InvalidUrl(String),
482
483    #[error("invalid HTTP method: {0}")]
484    InvalidMethod(String),
485
486    #[error("plaintext HTTP not allowed")]
487    PlaintextNotAllowed,
488
489    #[error("circuit breaker open for host: {0}")]
490    CircuitOpen(String),
491
492    #[error("request timeout")]
493    Timeout,
494
495    #[error("connection failed: {0}")]
496    ConnectionFailed(String),
497
498    #[error("request failed: {0}")]
499    RequestFailed(String),
500
501    #[error("failed to read response: {0}")]
502    ResponseReadError(#[source] reqwest::Error),
503
504    #[error("TLS configuration error: {0}")]
505    TlsConfig(#[source] TlsConfigError),
506}
507
508/// Custom serde for Option<Duration> in seconds.
509mod option_duration_serde {
510    use serde::{Deserialize, Deserializer, Serialize, Serializer};
511    use std::time::Duration;
512
513    pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
514    where
515        S: Serializer,
516    {
517        match duration {
518            Some(d) => d.as_secs_f64().serialize(serializer),
519            None => serializer.serialize_none(),
520        }
521    }
522
523    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
524    where
525        D: Deserializer<'de>,
526    {
527        let opt: Option<f64> = Option::deserialize(deserializer)?;
528        Ok(opt.map(Duration::from_secs_f64))
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn test_config_default() {
538        let config = HttpClientConfig::default();
539        assert_eq!(config.pool_max_idle_per_host, 10);
540        assert_eq!(config.default_timeout, Duration::from_secs(30));
541        assert!(!config.allow_plaintext);
542    }
543
544    #[test]
545    fn test_error_response() {
546        let resp = HttpResponse::error(
547            502,
548            "urn:barbacane:error:upstream-unavailable",
549            "Bad Gateway",
550            "Failed to connect to upstream",
551        );
552
553        assert_eq!(resp.status, 502);
554        assert_eq!(
555            resp.headers.get("content-type"),
556            Some(&"application/problem+json".to_string())
557        );
558    }
559
560    #[test]
561    fn test_tls_config_default() {
562        let tls = TlsConfig::default();
563        assert!(tls.client_cert.is_none());
564        assert!(tls.client_key.is_none());
565        assert!(tls.ca.is_none());
566        assert!(!tls.is_configured());
567    }
568
569    #[test]
570    fn test_tls_config_is_configured() {
571        let mut tls = TlsConfig::default();
572        assert!(!tls.is_configured());
573
574        tls.client_cert = Some(PathBuf::from("/path/to/cert.pem"));
575        assert!(tls.is_configured());
576
577        tls.client_cert = None;
578        tls.ca = Some(PathBuf::from("/path/to/ca.pem"));
579        assert!(tls.is_configured());
580    }
581
582    #[test]
583    fn test_tls_config_validate_success() {
584        // Empty config is valid
585        let tls = TlsConfig::default();
586        assert!(tls.validate().is_ok());
587
588        // CA only is valid
589        let tls = TlsConfig {
590            client_cert: None,
591            client_key: None,
592            ca: Some(PathBuf::from("/path/to/ca.pem")),
593        };
594        assert!(tls.validate().is_ok());
595
596        // Both cert and key is valid
597        let tls = TlsConfig {
598            client_cert: Some(PathBuf::from("/path/to/cert.pem")),
599            client_key: Some(PathBuf::from("/path/to/key.pem")),
600            ca: None,
601        };
602        assert!(tls.validate().is_ok());
603    }
604
605    #[test]
606    fn test_tls_config_validate_missing_key() {
607        let tls = TlsConfig {
608            client_cert: Some(PathBuf::from("/path/to/cert.pem")),
609            client_key: None,
610            ca: None,
611        };
612        let err = tls.validate().unwrap_err();
613        assert!(matches!(err, TlsConfigError::MissingClientKey));
614    }
615
616    #[test]
617    fn test_tls_config_validate_missing_cert() {
618        let tls = TlsConfig {
619            client_cert: None,
620            client_key: Some(PathBuf::from("/path/to/key.pem")),
621            ca: None,
622        };
623        let err = tls.validate().unwrap_err();
624        assert!(matches!(err, TlsConfigError::MissingClientCert));
625    }
626
627    #[test]
628    fn test_tls_config_serde() {
629        let json = r#"{
630            "client_cert": "/etc/certs/client.crt",
631            "client_key": "/etc/certs/client.key",
632            "ca": "/etc/certs/ca.crt"
633        }"#;
634
635        let tls: TlsConfig = serde_json::from_str(json).unwrap();
636        assert_eq!(
637            tls.client_cert,
638            Some(PathBuf::from("/etc/certs/client.crt"))
639        );
640        assert_eq!(tls.client_key, Some(PathBuf::from("/etc/certs/client.key")));
641        assert_eq!(tls.ca, Some(PathBuf::from("/etc/certs/ca.crt")));
642    }
643
644    #[test]
645    fn test_tls_config_serde_partial() {
646        let json = r#"{"ca": "/etc/certs/ca.crt"}"#;
647
648        let tls: TlsConfig = serde_json::from_str(json).unwrap();
649        assert!(tls.client_cert.is_none());
650        assert!(tls.client_key.is_none());
651        assert_eq!(tls.ca, Some(PathBuf::from("/etc/certs/ca.crt")));
652    }
653
654    // ── stream_raw validation ─────────────────────────────────────────────────
655
656    #[tokio::test]
657    async fn stream_raw_rejects_invalid_url() {
658        let client = HttpClient::new(HttpClientConfig::default()).expect("client");
659        let req = HttpRequest {
660            method: "GET".into(),
661            url: "not a url".into(),
662            headers: Default::default(),
663            body: None,
664            timeout: None,
665        };
666        assert!(matches!(
667            client.stream_raw(req).await,
668            Err(HttpClientError::InvalidUrl(_))
669        ));
670    }
671
672    #[tokio::test]
673    async fn stream_raw_rejects_plaintext_when_disallowed() {
674        let config = HttpClientConfig {
675            allow_plaintext: false,
676            ..Default::default()
677        };
678        let client = HttpClient::new(config).expect("client");
679        let req = HttpRequest {
680            method: "GET".into(),
681            url: "http://example.com/api".into(),
682            headers: Default::default(),
683            body: None,
684            timeout: None,
685        };
686        assert!(matches!(
687            client.stream_raw(req).await,
688            Err(HttpClientError::PlaintextNotAllowed)
689        ));
690    }
691
692    #[tokio::test]
693    async fn stream_raw_rejects_invalid_method() {
694        let config = HttpClientConfig {
695            allow_plaintext: true,
696            ..Default::default()
697        };
698        let client = HttpClient::new(config).expect("client");
699        let req = HttpRequest {
700            method: "NOT A METHOD!!!".into(),
701            url: "http://127.0.0.1:1/".into(),
702            headers: Default::default(),
703            body: None,
704            timeout: None,
705        };
706        assert!(matches!(
707            client.stream_raw(req).await,
708            Err(HttpClientError::InvalidMethod(_))
709        ));
710    }
711
712    #[tokio::test]
713    async fn stream_raw_connection_refused() {
714        let config = HttpClientConfig {
715            allow_plaintext: true,
716            ..Default::default()
717        };
718        let client = HttpClient::new(config).expect("client");
719        let req = HttpRequest {
720            method: "GET".into(),
721            url: "http://127.0.0.1:1/".into(), // port 1: connection refused
722            headers: Default::default(),
723            body: None,
724            timeout: None,
725        };
726        let err = client.stream_raw(req).await.unwrap_err();
727        assert!(
728            matches!(
729                err,
730                HttpClientError::ConnectionFailed(_) | HttpClientError::RequestFailed(_)
731            ),
732            "expected network error, got: {err:?}"
733        );
734    }
735
736    #[tokio::test]
737    async fn stream_raw_timeout() {
738        use tokio::net::TcpListener;
739
740        // Bind a listener but never accept — the client will time out.
741        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
742        let addr = listener.local_addr().expect("local_addr");
743
744        let config = HttpClientConfig {
745            allow_plaintext: true,
746            ..Default::default()
747        };
748        let client = HttpClient::new(config).expect("client");
749        let req = HttpRequest {
750            method: "GET".into(),
751            url: format!("http://{addr}/slow"),
752            headers: Default::default(),
753            body: None,
754            timeout: Some(Duration::from_millis(50)),
755        };
756        let err = client.stream_raw(req).await.unwrap_err();
757        assert!(
758            matches!(err, HttpClientError::Timeout),
759            "expected Timeout, got: {err:?}"
760        );
761
762        drop(listener);
763    }
764
765    #[tokio::test]
766    async fn stream_raw_successful_request() {
767        use tokio::io::AsyncWriteExt;
768        use tokio::net::TcpListener;
769
770        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
771        let addr = listener.local_addr().expect("local_addr");
772
773        // Spawn a minimal HTTP server that returns a 200.
774        tokio::spawn(async move {
775            let (mut socket, _) = listener.accept().await.expect("accept");
776            let mut buf = [0u8; 1024];
777            let _ = tokio::io::AsyncReadExt::read(&mut socket, &mut buf).await;
778            let response = "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok";
779            socket.write_all(response.as_bytes()).await.expect("write");
780            socket.shutdown().await.expect("shutdown");
781        });
782
783        let config = HttpClientConfig {
784            allow_plaintext: true,
785            ..Default::default()
786        };
787        let client = HttpClient::new(config).expect("client");
788        let req = HttpRequest {
789            method: "GET".into(),
790            url: format!("http://{addr}/"),
791            headers: Default::default(),
792            body: None,
793            timeout: None,
794        };
795        let resp = client
796            .stream_raw(req)
797            .await
798            .expect("stream_raw should succeed");
799        assert_eq!(resp.status(), 200);
800        let body = resp.text().await.expect("body");
801        assert_eq!(body, "ok");
802    }
803
804    #[test]
805    fn test_tls_cache_key_equality() {
806        let tls1 = TlsConfig {
807            client_cert: Some(PathBuf::from("/path/to/cert.pem")),
808            client_key: Some(PathBuf::from("/path/to/key.pem")),
809            ca: None,
810        };
811        let tls2 = TlsConfig {
812            client_cert: Some(PathBuf::from("/path/to/cert.pem")),
813            client_key: Some(PathBuf::from("/path/to/key.pem")),
814            ca: None,
815        };
816        let tls3 = TlsConfig {
817            client_cert: Some(PathBuf::from("/other/cert.pem")),
818            client_key: Some(PathBuf::from("/path/to/key.pem")),
819            ca: None,
820        };
821
822        assert_eq!(tls1.cache_key(), tls2.cache_key());
823        assert_ne!(tls1.cache_key(), tls3.cache_key());
824    }
825
826    // ── base64 body serde (host ↔ WASM plugin compatibility) ─────────────
827
828    /// Verify that HttpRequest serialized by a WASM plugin (base64 body)
829    /// deserializes correctly on the host side.
830    #[test]
831    fn http_request_base64_body_roundtrip() {
832        let binary_body: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE, 0x00, 0x01];
833        let req = HttpRequest {
834            method: "POST".into(),
835            url: "https://example.com/upload".into(),
836            headers: Default::default(),
837            body: Some(binary_body.clone()),
838            timeout: None,
839        };
840
841        let json = serde_json::to_string(&req).unwrap();
842        // Body must be base64-encoded in JSON, not raw bytes
843        assert!(
844            !json.contains("\\u0089"),
845            "body should be base64-encoded, not escaped unicode"
846        );
847
848        let decoded: HttpRequest = serde_json::from_str(&json).unwrap();
849        assert_eq!(decoded.body.unwrap(), binary_body);
850    }
851
852    /// Verify that HttpResponse serialized by the host (base64 body)
853    /// deserializes correctly on the plugin side.
854    #[test]
855    fn http_response_base64_body_roundtrip() {
856        let binary_body: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE, 0x00, 0x01];
857        let resp = HttpResponse {
858            status: 200,
859            headers: Default::default(),
860            body: Some(binary_body.clone()),
861        };
862
863        let json = serde_json::to_string(&resp).unwrap();
864        let decoded: HttpResponse = serde_json::from_str(&json).unwrap();
865        assert_eq!(decoded.body.unwrap(), binary_body);
866    }
867
868    /// Verify None body serializes as null and deserializes back.
869    #[test]
870    fn http_request_null_body_roundtrip() {
871        let req = HttpRequest {
872            method: "GET".into(),
873            url: "https://example.com".into(),
874            headers: Default::default(),
875            body: None,
876            timeout: None,
877        };
878
879        let json = serde_json::to_string(&req).unwrap();
880        assert!(json.contains(r#""body":null"#));
881
882        let decoded: HttpRequest = serde_json::from_str(&json).unwrap();
883        assert!(decoded.body.is_none());
884    }
885
886    /// Simulate what a WASM plugin sends: manually construct the JSON with
887    /// a base64 string body and verify the host deserializes it correctly.
888    #[test]
889    fn http_request_deserialize_from_plugin_json() {
890        use base64::Engine;
891        let raw_bytes: Vec<u8> = vec![0x00, 0x01, 0x80, 0xFF];
892        let b64 = base64::engine::general_purpose::STANDARD.encode(&raw_bytes);
893
894        let json = format!(
895            r#"{{
896                "method": "POST",
897                "url": "https://example.com/api",
898                "body": "{b64}"
899            }}"#
900        );
901
902        let req: HttpRequest = serde_json::from_str(&json).unwrap();
903        assert_eq!(req.body.unwrap(), raw_bytes);
904    }
905
906    /// Simulate what the host sends back: manually construct JSON with
907    /// base64 body and verify plugin-side deserialization.
908    #[test]
909    fn http_response_deserialize_from_host_json() {
910        use base64::Engine;
911        let raw_bytes: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A];
912        let b64 = base64::engine::general_purpose::STANDARD.encode(&raw_bytes);
913
914        let json = format!(
915            r#"{{
916                "status": 200,
917                "headers": {{}},
918                "body": "{b64}"
919            }}"#
920        );
921
922        let resp: HttpResponse = serde_json::from_str(&json).unwrap();
923        assert_eq!(resp.body.unwrap(), raw_bytes);
924    }
925}