use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use tokio::sync::{Notify, RwLock, mpsc};
use tokio::task::JoinHandle;
use super::transport::ClientTransport;
use crate::error::{Error, Result};
#[cfg(feature = "oauth-client")]
use super::oauth::TokenProvider;
#[derive(Debug, Clone)]
pub struct HttpClientConfig {
pub headers: HashMap<String, String>,
pub auto_sse: bool,
pub channel_capacity: usize,
pub request_timeout: Duration,
pub sse_reconnect: bool,
pub sse_reconnect_delay: Duration,
pub max_sse_reconnect_attempts: u32,
pub session_recovery: bool,
}
impl Default for HttpClientConfig {
fn default() -> Self {
Self {
headers: HashMap::new(),
auto_sse: true,
channel_capacity: 256,
request_timeout: Duration::from_secs(30),
sse_reconnect: true,
sse_reconnect_delay: Duration::from_secs(1),
max_sse_reconnect_attempts: 5,
session_recovery: true,
}
}
}
impl HttpClientConfig {
pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
self.headers.insert(
"Authorization".to_string(),
format!("Bearer {}", token.into()),
);
self
}
pub fn api_key_header(mut self, name: impl Into<String>, key: impl Into<String>) -> Self {
self.headers.insert(name.into(), key.into());
self
}
pub fn basic_auth(mut self, username: impl AsRef<str>, password: impl AsRef<str>) -> Self {
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(format!(
"{}:{}",
username.as_ref(),
password.as_ref()
));
self.headers
.insert("Authorization".to_string(), format!("Basic {}", encoded));
self
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
}
pub struct HttpClientTransport {
url: String,
client: reqwest::Client,
session_id: Option<String>,
protocol_version: Option<String>,
incoming_rx: mpsc::Receiver<String>,
incoming_tx: mpsc::Sender<String>,
sse_task: Option<JoinHandle<()>>,
last_event_id: Arc<RwLock<Option<String>>>,
sse_retry_delay: Arc<RwLock<Option<Duration>>>,
sse_reconnect_signal: Arc<Notify>,
connected: Arc<AtomicBool>,
config: HttpClientConfig,
#[cfg(feature = "oauth-client")]
token_provider: Option<Arc<dyn TokenProvider>>,
}
impl HttpClientTransport {
pub fn new(url: impl Into<String>) -> Self {
Self::with_config(url, HttpClientConfig::default())
}
pub fn with_config(url: impl Into<String>, config: HttpClientConfig) -> Self {
let (tx, rx) = mpsc::channel(config.channel_capacity);
Self {
url: url.into(),
client: reqwest::Client::new(),
session_id: None,
protocol_version: None,
incoming_rx: rx,
incoming_tx: tx,
sse_task: None,
last_event_id: Arc::new(RwLock::new(None)),
sse_retry_delay: Arc::new(RwLock::new(None)),
sse_reconnect_signal: Arc::new(Notify::new()),
connected: Arc::new(AtomicBool::new(true)),
config,
#[cfg(feature = "oauth-client")]
token_provider: None,
}
}
pub fn with_client(url: impl Into<String>, client: reqwest::Client) -> Self {
let config = HttpClientConfig::default();
let (tx, rx) = mpsc::channel(config.channel_capacity);
Self {
url: url.into(),
client,
session_id: None,
protocol_version: None,
incoming_rx: rx,
incoming_tx: tx,
sse_task: None,
last_event_id: Arc::new(RwLock::new(None)),
sse_retry_delay: Arc::new(RwLock::new(None)),
sse_reconnect_signal: Arc::new(Notify::new()),
connected: Arc::new(AtomicBool::new(true)),
config,
#[cfg(feature = "oauth-client")]
token_provider: None,
}
}
pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
self.config.headers.insert(
"Authorization".to_string(),
format!("Bearer {}", token.into()),
);
self
}
pub fn api_key(self, key: impl Into<String>) -> Self {
self.bearer_token(key)
}
pub fn api_key_header(mut self, name: impl Into<String>, key: impl Into<String>) -> Self {
self.config.headers.insert(name.into(), key.into());
self
}
pub fn basic_auth(mut self, username: impl AsRef<str>, password: impl AsRef<str>) -> Self {
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(format!(
"{}:{}",
username.as_ref(),
password.as_ref()
));
self.config
.headers
.insert("Authorization".to_string(), format!("Basic {}", encoded));
self
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.config.headers.insert(name.into(), value.into());
self
}
pub fn disable_session_recovery(mut self) -> Self {
self.config.session_recovery = false;
self
}
#[cfg(feature = "oauth-client")]
pub fn with_token_provider(mut self, provider: impl TokenProvider) -> Self {
self.token_provider = Some(Arc::new(provider));
self
}
fn start_sse_stream(&mut self) {
let url = self.url.clone();
let client = self.client.clone();
let session_id = self.session_id.clone().unwrap();
let protocol_version = self.protocol_version.clone();
let tx = self.incoming_tx.clone();
let last_event_id = self.last_event_id.clone();
let sse_retry_delay = self.sse_retry_delay.clone();
let reconnect_signal = self.sse_reconnect_signal.clone();
let connected = self.connected.clone();
let config = self.config.clone();
#[cfg(feature = "oauth-client")]
let token_provider = self.token_provider.clone();
self.sse_task = Some(tokio::spawn(async move {
sse_stream_loop(SseLoopParams {
url,
client,
session_id,
protocol_version,
tx,
last_event_id,
sse_retry_delay,
reconnect_signal,
connected,
config,
#[cfg(feature = "oauth-client")]
token_provider,
})
.await;
}));
}
}
#[async_trait]
impl ClientTransport for HttpClientTransport {
async fn send(&mut self, message: &str) -> Result<()> {
if !self.connected.load(Ordering::Acquire) {
return Err(Error::Transport("Transport closed".to_string()));
}
let mut request = self
.client
.post(&self.url)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.timeout(self.config.request_timeout);
if let Some(ref session_id) = self.session_id {
request = request.header("mcp-session-id", session_id);
}
if let Some(ref version) = self.protocol_version {
request = request.header("mcp-protocol-version", version);
}
for (key, value) in &self.config.headers {
request = request.header(key.as_str(), value.as_str());
}
#[cfg(feature = "oauth-client")]
if let Some(ref provider) = self.token_provider {
let token = provider
.get_token()
.await
.map_err(|e| Error::Transport(format!("Token provider error: {}", e)))?;
request = request.header("Authorization", format!("Bearer {}", token));
}
let request = request.body(message.to_string());
if self.session_id.is_some() {
let tx = self.incoming_tx.clone();
let connected = self.connected.clone();
let last_event_id = self.last_event_id.clone();
let sse_retry_delay = self.sse_retry_delay.clone();
let sse_reconnect_signal = self.sse_reconnect_signal.clone();
tokio::spawn(async move {
let response = match request.send().await {
Ok(r) => r,
Err(e) => {
tracing::error!(error = %e, "Background HTTP request failed");
connected.store(false, Ordering::Release);
return;
}
};
let status = response.status();
if status == reqwest::StatusCode::ACCEPTED {
return;
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
if !body.is_empty()
&& serde_json::from_str::<serde_json::Value>(&body)
.ok()
.is_some_and(|v| v.get("error").is_some())
{
let _ = tx.send(body).await;
return;
}
tracing::error!(status = %status, body = %body, "HTTP error from server");
connected.store(false, Ordering::Release);
return;
}
let is_sse = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.is_some_and(|ct| ct.contains("text/event-stream"));
if is_sse {
let mut stream = response.bytes_stream();
let mut parser = SseParser::new();
let mut had_retry = false;
let mut had_data = false;
use futures::StreamExt;
while let Some(result) = stream.next().await {
match result {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
for event in parser.feed(&text) {
if let Some(ref id) = event.id {
*last_event_id.write().await = Some(id.clone());
}
if let Some(retry_ms) = event.retry {
*sse_retry_delay.write().await =
Some(Duration::from_millis(retry_ms));
had_retry = true;
}
if !event.data.is_empty() {
had_data = true;
let _ = tx.send(event.data).await;
}
}
}
Err(e) => {
tracing::warn!(error = %e, "POST SSE stream error");
break;
}
}
}
if had_retry && !had_data {
sse_reconnect_signal.notify_one();
}
} else {
match response.text().await {
Ok(body) if !body.is_empty() => {
for msg in extract_json_messages(&body) {
let _ = tx.send(msg).await;
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to read response body");
connected.store(false, Ordering::Release);
}
_ => {}
}
}
});
return Ok(());
}
let response = request
.send()
.await
.map_err(|e| Error::Transport(format!("HTTP request failed: {}", e)))?;
let status = response.status();
let new_session_id = response
.headers()
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let new_protocol_version = response
.headers()
.get("mcp-protocol-version")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if status == reqwest::StatusCode::ACCEPTED {
if let Some(sid) = new_session_id {
self.session_id = Some(sid);
}
if let Some(pv) = new_protocol_version {
self.protocol_version = Some(pv);
}
return Ok(());
}
if !status.is_success() {
if status == reqwest::StatusCode::NOT_FOUND && self.config.session_recovery {
return Err(Error::SessionExpired);
}
let body = response.text().await.unwrap_or_default();
return Err(Error::Transport(format!(
"HTTP {} from server: {}",
status, body
)));
}
if let Some(sid) = new_session_id {
let is_new_session = self.session_id.is_none();
self.session_id = Some(sid);
if is_new_session && self.config.auto_sse {
self.start_sse_stream();
}
}
if let Some(pv) = new_protocol_version {
self.protocol_version = Some(pv);
}
let body = response
.text()
.await
.map_err(|e| Error::Transport(format!("Failed to read response: {}", e)))?;
for msg in extract_json_messages(&body) {
self.incoming_tx
.send(msg)
.await
.map_err(|_| Error::Transport("Internal channel closed".to_string()))?;
}
Ok(())
}
async fn recv(&mut self) -> Result<Option<String>> {
match self.incoming_rx.recv().await {
Some(msg) => Ok(Some(msg)),
None => {
self.connected.store(false, Ordering::Release);
Ok(None)
}
}
}
fn is_connected(&self) -> bool {
self.connected.load(Ordering::Acquire)
}
async fn close(&mut self) -> Result<()> {
self.connected.store(false, Ordering::Release);
if let Some(task) = self.sse_task.take() {
task.abort();
}
if let Some(ref session_id) = self.session_id {
let mut request = self
.client
.delete(&self.url)
.header("mcp-session-id", session_id)
.timeout(Duration::from_secs(5));
for (key, value) in &self.config.headers {
request = request.header(key.as_str(), value.as_str());
}
#[cfg(feature = "oauth-client")]
if let Some(ref provider) = self.token_provider
&& let Ok(token) = provider.get_token().await
{
request = request.header("Authorization", format!("Bearer {}", token));
}
let _ = request.send().await;
}
self.session_id = None;
Ok(())
}
async fn reset_session(&mut self) {
tracing::info!("Resetting session for re-initialization");
if let Some(task) = self.sse_task.take() {
task.abort();
}
self.session_id = None;
self.protocol_version = None;
*self.last_event_id.write().await = None;
*self.sse_retry_delay.write().await = None;
while self.incoming_rx.try_recv().is_ok() {}
}
fn supports_session_recovery(&self) -> bool {
self.config.session_recovery
}
}
struct SseLoopParams {
url: String,
client: reqwest::Client,
session_id: String,
protocol_version: Option<String>,
tx: mpsc::Sender<String>,
last_event_id: Arc<RwLock<Option<String>>>,
sse_retry_delay: Arc<RwLock<Option<Duration>>>,
reconnect_signal: Arc<Notify>,
connected: Arc<AtomicBool>,
config: HttpClientConfig,
#[cfg(feature = "oauth-client")]
token_provider: Option<Arc<dyn TokenProvider>>,
}
async fn sse_stream_loop(params: SseLoopParams) {
let SseLoopParams {
url,
client,
session_id,
protocol_version,
tx,
last_event_id,
sse_retry_delay,
reconnect_signal,
connected,
config,
#[cfg(feature = "oauth-client")]
token_provider,
} = params;
let mut reconnect_attempts = 0u32;
loop {
if !connected.load(Ordering::Acquire) {
break;
}
let mut request = client
.get(&url)
.header("Accept", "text/event-stream")
.header("mcp-session-id", &session_id);
if let Some(ref version) = protocol_version {
request = request.header("mcp-protocol-version", version);
}
for (key, value) in &config.headers {
request = request.header(key.as_str(), value.as_str());
}
#[cfg(feature = "oauth-client")]
if let Some(ref provider) = token_provider {
match provider.get_token().await {
Ok(token) => {
request = request.header("Authorization", format!("Bearer {}", token));
}
Err(e) => {
tracing::warn!(error = %e, "Token provider failed for SSE connection");
break;
}
}
}
if let Some(ref lei) = *last_event_id.read().await {
request = request.header("Last-Event-ID", lei.clone());
}
let response = match request.send().await {
Ok(r) if r.status().is_success() => {
reconnect_attempts = 0;
r
}
Ok(r) => {
tracing::warn!(status = %r.status(), "SSE connection rejected");
break;
}
Err(e) => {
tracing::warn!(error = %e, "SSE connection failed");
if !config.sse_reconnect || reconnect_attempts >= config.max_sse_reconnect_attempts
{
break;
}
reconnect_attempts += 1;
let delay = sse_retry_delay
.read()
.await
.unwrap_or(config.sse_reconnect_delay);
tokio::time::sleep(delay).await;
continue;
}
};
let mut stream = response.bytes_stream();
let mut parser = SseParser::new();
use futures::StreamExt;
loop {
tokio::select! {
chunk = stream.next() => {
match chunk {
Some(Ok(bytes)) => {
let text = String::from_utf8_lossy(&bytes);
for event in parser.feed(&text) {
if let Some(ref id) = event.id {
*last_event_id.write().await = Some(id.clone());
}
if let Some(retry_ms) = event.retry {
*sse_retry_delay.write().await = Some(Duration::from_millis(retry_ms));
}
if !event.data.is_empty() && tx.send(event.data).await.is_err() {
return; }
}
}
Some(Err(e)) => {
tracing::warn!(error = %e, "SSE stream error");
break;
}
None => {
tracing::debug!("SSE stream ended");
break;
}
}
}
_ = reconnect_signal.notified() => {
tracing::debug!("SSE reconnect signal received, closing current stream");
break;
}
}
}
if !config.sse_reconnect
|| !connected.load(Ordering::Acquire)
|| reconnect_attempts >= config.max_sse_reconnect_attempts
{
break;
}
reconnect_attempts += 1;
let delay = sse_retry_delay
.read()
.await
.unwrap_or(config.sse_reconnect_delay);
tracing::info!(
attempt = reconnect_attempts,
max = config.max_sse_reconnect_attempts,
delay_ms = delay.as_millis() as u64,
"Reconnecting SSE stream"
);
tokio::time::sleep(delay).await;
}
}
fn extract_json_messages(body: &str) -> Vec<String> {
let trimmed = body.trim();
if trimmed.is_empty() {
return Vec::new();
}
let looks_like_sse = trimmed.starts_with("event:")
|| trimmed.starts_with("data:")
|| trimmed.starts_with("id:")
|| trimmed.starts_with(':');
if looks_like_sse {
let mut parser = SseParser::new();
let events = parser.feed(body);
events.into_iter().map(|e| e.data).collect()
} else {
vec![trimmed.to_string()]
}
}
#[derive(Debug)]
struct SseEvent {
id: Option<String>,
data: String,
retry: Option<u64>,
}
struct SseParser {
buffer: String,
current_id: Option<String>,
current_data: Vec<String>,
current_retry: Option<u64>,
}
impl SseParser {
fn new() -> Self {
Self {
buffer: String::new(),
current_id: None,
current_data: Vec::new(),
current_retry: None,
}
}
fn feed(&mut self, text: &str) -> Vec<SseEvent> {
self.buffer.push_str(text);
let mut events = Vec::new();
while let Some(newline_pos) = self.buffer.find('\n') {
let line = self.buffer[..newline_pos]
.trim_end_matches('\r')
.to_string();
self.buffer = self.buffer[newline_pos + 1..].to_string();
if line.is_empty() {
if !self.current_data.is_empty() || self.current_retry.is_some() {
events.push(SseEvent {
id: self.current_id.take(),
data: self.current_data.join("\n"),
retry: self.current_retry.take(),
});
self.current_data.clear();
}
self.current_id = None;
self.current_retry = None;
} else if let Some(value) = line.strip_prefix("id:") {
let trimmed = value.trim();
if !trimmed.is_empty() {
self.current_id = Some(trimmed.to_string());
}
} else if let Some(value) = line.strip_prefix("data:") {
self.current_data.push(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("retry:") {
self.current_retry = value.trim().parse().ok();
}
}
events
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_complete_event() {
let mut parser = SseParser::new();
let events = parser.feed("id: 1\nevent: message\ndata: {\"hello\":\"world\"}\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, Some("1".to_string()));
assert_eq!(events[0].data, "{\"hello\":\"world\"}");
}
#[test]
fn test_parse_multiple_events() {
let mut parser = SseParser::new();
let events =
parser.feed("id: 1\ndata: first\n\nid: 2\ndata: second\n\nid: 3\ndata: third\n\n");
assert_eq!(events.len(), 3);
assert_eq!(events[0].data, "first");
assert_eq!(events[1].data, "second");
assert_eq!(events[2].data, "third");
assert_eq!(events[0].id, Some("1".to_string()));
assert_eq!(events[1].id, Some("2".to_string()));
assert_eq!(events[2].id, Some("3".to_string()));
}
#[test]
fn test_parse_partial_chunks() {
let mut parser = SseParser::new();
let events = parser.feed("id: 1\nda");
assert!(events.is_empty());
let events = parser.feed("ta: hello\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, Some("1".to_string()));
assert_eq!(events[0].data, "hello");
}
#[test]
fn test_parse_multiline_data() {
let mut parser = SseParser::new();
let events = parser.feed("id: 1\ndata: line1\ndata: line2\ndata: line3\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "line1\nline2\nline3");
}
#[test]
fn test_parse_comment_lines() {
let mut parser = SseParser::new();
let events = parser.feed(": keep-alive\nid: 1\ndata: hello\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "hello");
}
#[test]
fn test_parse_event_without_id() {
let mut parser = SseParser::new();
let events = parser.feed("data: no-id-event\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, None);
assert_eq!(events[0].data, "no-id-event");
}
#[test]
fn test_empty_data_no_event() {
let mut parser = SseParser::new();
let events = parser.feed("id: 1\n\n");
assert!(events.is_empty());
}
#[test]
fn test_parse_crlf_line_endings() {
let mut parser = SseParser::new();
let events = parser.feed("id: 1\r\ndata: crlf\r\n\r\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "crlf");
}
#[test]
fn test_parse_json_data() {
let mut parser = SseParser::new();
let json = r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"token":"t1","progress":50}}"#;
let input = format!("id: 42\nevent: message\ndata: {}\n\n", json);
let events = parser.feed(&input);
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, Some("42".to_string()));
let parsed: serde_json::Value = serde_json::from_str(&events[0].data).unwrap();
assert_eq!(parsed["method"], "notifications/progress");
}
#[test]
fn test_default_config() {
let config = HttpClientConfig::default();
assert!(config.auto_sse);
assert_eq!(config.channel_capacity, 256);
assert_eq!(config.request_timeout, Duration::from_secs(30));
assert!(config.sse_reconnect);
assert_eq!(config.sse_reconnect_delay, Duration::from_secs(1));
assert_eq!(config.max_sse_reconnect_attempts, 5);
assert!(config.headers.is_empty());
}
#[test]
fn test_new_transport() {
let transport = HttpClientTransport::new("http://localhost:3000");
assert_eq!(transport.url, "http://localhost:3000");
assert!(transport.session_id.is_none());
assert!(transport.protocol_version.is_none());
assert!(transport.is_connected());
}
#[test]
fn test_with_config() {
let config = HttpClientConfig {
request_timeout: Duration::from_secs(60),
sse_reconnect: false,
..Default::default()
};
let transport = HttpClientTransport::with_config("http://example.com", config);
assert_eq!(transport.url, "http://example.com");
assert_eq!(transport.config.request_timeout, Duration::from_secs(60));
assert!(!transport.config.sse_reconnect);
}
#[test]
fn test_with_client() {
let client = reqwest::Client::new();
let transport = HttpClientTransport::with_client("http://example.com", client);
assert_eq!(transport.url, "http://example.com");
assert!(transport.is_connected());
}
#[test]
fn test_bearer_token() {
let transport =
HttpClientTransport::new("http://localhost:3000").bearer_token("sk-test-token");
assert_eq!(
transport.config.headers.get("Authorization").unwrap(),
"Bearer sk-test-token"
);
}
#[test]
fn test_api_key() {
let transport = HttpClientTransport::new("http://localhost:3000").api_key("sk-api-key-123");
assert_eq!(
transport.config.headers.get("Authorization").unwrap(),
"Bearer sk-api-key-123"
);
}
#[test]
fn test_api_key_header() {
let transport =
HttpClientTransport::new("http://localhost:3000").api_key_header("X-API-Key", "my-key");
assert_eq!(transport.config.headers.get("X-API-Key").unwrap(), "my-key");
assert!(!transport.config.headers.contains_key("Authorization"));
}
#[test]
fn test_basic_auth() {
let transport =
HttpClientTransport::new("http://localhost:3000").basic_auth("admin", "secret");
let header = transport.config.headers.get("Authorization").unwrap();
assert!(header.starts_with("Basic "));
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(header.strip_prefix("Basic ").unwrap())
.unwrap();
assert_eq!(String::from_utf8(decoded).unwrap(), "admin:secret");
}
#[test]
fn test_custom_header() {
let transport = HttpClientTransport::new("http://localhost:3000")
.header("X-Custom", "value1")
.header("X-Another", "value2");
assert_eq!(transport.config.headers.get("X-Custom").unwrap(), "value1");
assert_eq!(transport.config.headers.get("X-Another").unwrap(), "value2");
}
#[test]
fn test_chaining_with_config() {
let config = HttpClientConfig {
request_timeout: Duration::from_secs(60),
..Default::default()
};
let transport =
HttpClientTransport::with_config("http://localhost:3000", config).bearer_token("tk");
assert_eq!(transport.config.request_timeout, Duration::from_secs(60));
assert_eq!(
transport.config.headers.get("Authorization").unwrap(),
"Bearer tk"
);
}
#[test]
fn test_last_auth_wins() {
let transport = HttpClientTransport::new("http://localhost:3000")
.bearer_token("token1")
.basic_auth("user", "pass");
let header = transport.config.headers.get("Authorization").unwrap();
assert!(header.starts_with("Basic "));
}
#[test]
fn test_config_bearer_token() {
let config = HttpClientConfig::default().bearer_token("tk-123");
assert_eq!(
config.headers.get("Authorization").unwrap(),
"Bearer tk-123"
);
}
#[test]
fn test_config_header() {
let config = HttpClientConfig::default().header("X-Foo", "bar");
assert_eq!(config.headers.get("X-Foo").unwrap(), "bar");
}
#[test]
fn test_config_api_key_header() {
let config = HttpClientConfig::default().api_key_header("X-Key", "secret");
assert_eq!(config.headers.get("X-Key").unwrap(), "secret");
}
#[test]
fn test_config_basic_auth() {
let config = HttpClientConfig::default().basic_auth("user", "pw");
let header = config.headers.get("Authorization").unwrap();
assert!(header.starts_with("Basic "));
}
}