gemini_genai_rs/transport/
ws.rs1use async_trait::async_trait;
9
10#[async_trait]
22pub trait Transport: Send + 'static {
23 type Error: std::error::Error + Send + Sync + 'static;
25
26 async fn connect(
28 &mut self,
29 url: &str,
30 headers: Vec<(String, String)>,
31 ) -> Result<(), Self::Error>;
32
33 async fn send(&mut self, data: Vec<u8>) -> Result<(), Self::Error>;
35
36 async fn recv(&mut self) -> Result<Option<Vec<u8>>, Self::Error>;
38
39 async fn close(&mut self) -> Result<(), Self::Error>;
41
42 fn close_reason(&self) -> Option<String> {
56 None
57 }
58}
59
60use futures_util::{SinkExt, StreamExt};
65use tokio_tungstenite::tungstenite::Message;
66use tokio_tungstenite::tungstenite::client::IntoClientRequest;
67
68type WsStream =
69 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
70
71const READ_BUFFER_SIZE: usize = 16 * 1024;
79
80pub struct TungsteniteTransport {
82 ws_write: Option<futures_util::stream::SplitSink<WsStream, Message>>,
83 ws_read: Option<futures_util::stream::SplitStream<WsStream>>,
84 close_reason: Option<String>,
87}
88
89impl TungsteniteTransport {
90 pub fn new() -> Self {
92 Self {
93 ws_write: None,
94 ws_read: None,
95 close_reason: None,
96 }
97 }
98}
99
100impl Default for TungsteniteTransport {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106#[derive(Debug, thiserror::Error)]
108pub enum TungsteniteError {
109 #[error("Not connected")]
111 NotConnected,
112
113 #[error("WebSocket error: {0}")]
115 WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
116
117 #[error("Request error: {0}")]
119 Request(String),
120
121 #[error(
130 "cannot dial {url}: this build of gemini-genai-rs has no TLS backend; \
131 enable the `tls-native` (default) or `tls-rustls` feature"
132 )]
133 NoTlsBackend {
134 url: String,
136 },
137}
138
139pub const HAS_TLS_BACKEND: bool = cfg!(any(feature = "tls-native", feature = "tls-rustls"));
141
142#[async_trait]
143impl Transport for TungsteniteTransport {
144 type Error = TungsteniteError;
145
146 async fn connect(
147 &mut self,
148 url: &str,
149 headers: Vec<(String, String)>,
150 ) -> Result<(), Self::Error> {
151 if !HAS_TLS_BACKEND && url.trim_start().to_ascii_lowercase().starts_with("wss://") {
152 return Err(TungsteniteError::NoTlsBackend {
153 url: url.to_string(),
154 });
155 }
156
157 let mut request = url
158 .into_client_request()
159 .map_err(|e| TungsteniteError::Request(e.to_string()))?;
160
161 for (name, value) in headers {
162 let header_name: tokio_tungstenite::tungstenite::http::HeaderName =
163 name.parse().map_err(
164 |e: tokio_tungstenite::tungstenite::http::header::InvalidHeaderName| {
165 TungsteniteError::Request(format!("invalid header name: {e}"))
166 },
167 )?;
168 let header_value: tokio_tungstenite::tungstenite::http::HeaderValue =
169 value.parse().map_err(
170 |e: tokio_tungstenite::tungstenite::http::header::InvalidHeaderValue| {
171 TungsteniteError::Request(format!("invalid header value: {e}"))
172 },
173 )?;
174 request.headers_mut().insert(header_name, header_value);
175 }
176
177 let config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
178 .read_buffer_size(READ_BUFFER_SIZE);
179 let (ws_stream, _response) =
182 tokio_tungstenite::connect_async_with_config(request, Some(config), true).await?;
183 let (ws_write, ws_read) = ws_stream.split();
184 self.ws_write = Some(ws_write);
185 self.ws_read = Some(ws_read);
186 Ok(())
187 }
188
189 async fn send(&mut self, data: Vec<u8>) -> Result<(), Self::Error> {
190 let ws_write = self
191 .ws_write
192 .as_mut()
193 .ok_or(TungsteniteError::NotConnected)?;
194 let text = String::from_utf8(data)
196 .map_err(|e| TungsteniteError::Request(format!("invalid UTF-8: {e}")))?;
197 ws_write.send(Message::Text(text.into())).await?;
198 Ok(())
199 }
200
201 async fn recv(&mut self) -> Result<Option<Vec<u8>>, Self::Error> {
202 let ws_read = self
203 .ws_read
204 .as_mut()
205 .ok_or(TungsteniteError::NotConnected)?;
206 loop {
207 match ws_read.next().await {
208 Some(Ok(Message::Text(t))) => return Ok(Some(bytes::Bytes::from(t).into())),
209 Some(Ok(Message::Binary(b))) => return Ok(Some(b.into())),
211 Some(Ok(Message::Close(frame))) => {
212 if let Some(ref cf) = frame {
213 tracing::warn!(code = %cf.code, reason = %cf.reason, "WebSocket close frame received");
214 self.close_reason = Some(if cf.reason.is_empty() {
218 format!("server closed the connection ({})", cf.code)
219 } else {
220 format!("server closed the connection ({}): {}", cf.code, cf.reason)
221 });
222 }
223 return Ok(None);
224 }
225 Some(Ok(Message::Ping(_) | Message::Pong(_))) => continue,
227 Some(Ok(Message::Frame(_))) => continue,
229 Some(Err(e)) => return Err(e.into()),
230 None => return Ok(None),
231 }
232 }
233 }
234
235 async fn close(&mut self) -> Result<(), Self::Error> {
236 if let Some(ref mut ws_write) = self.ws_write {
237 ws_write.send(Message::Close(None)).await?;
238 }
239 self.ws_write = None;
240 self.ws_read = None;
241 Ok(())
242 }
243
244 fn close_reason(&self) -> Option<String> {
245 self.close_reason.clone()
246 }
247}
248
249pub struct MockTransport {
260 sent: Vec<Vec<u8>>,
261 recv_queue: std::collections::VecDeque<Vec<u8>>,
262 connected: bool,
264 close_reason: Option<String>,
266}
267
268impl MockTransport {
269 pub fn new() -> Self {
271 Self {
272 sent: Vec::new(),
273 recv_queue: std::collections::VecDeque::new(),
274 connected: false,
275 close_reason: None,
276 }
277 }
278
279 pub fn script_recv(&mut self, data: Vec<u8>) {
281 self.recv_queue.push_back(data);
282 }
283
284 pub fn script_close_reason(&mut self, reason: impl Into<String>) {
287 self.close_reason = Some(reason.into());
288 }
289
290 pub fn take_sent(&mut self) -> Vec<Vec<u8>> {
292 std::mem::take(&mut self.sent)
293 }
294}
295
296impl Default for MockTransport {
297 fn default() -> Self {
298 Self::new()
299 }
300}
301
302#[derive(Debug, thiserror::Error)]
304pub enum MockTransportError {
305 #[error("Not connected")]
307 NotConnected,
308
309 #[error("Mock error: {0}")]
311 Custom(String),
312}
313
314#[async_trait]
315impl Transport for MockTransport {
316 type Error = MockTransportError;
317
318 async fn connect(
319 &mut self,
320 _url: &str,
321 _headers: Vec<(String, String)>,
322 ) -> Result<(), Self::Error> {
323 self.connected = true;
324 Ok(())
325 }
326
327 async fn send(&mut self, data: Vec<u8>) -> Result<(), Self::Error> {
328 if !self.connected {
329 return Err(MockTransportError::NotConnected);
330 }
331 self.sent.push(data);
332 Ok(())
333 }
334
335 async fn recv(&mut self) -> Result<Option<Vec<u8>>, Self::Error> {
336 if !self.connected {
337 return Err(MockTransportError::NotConnected);
338 }
339 tokio::task::yield_now().await;
342
343 if let Some(data) = self.recv_queue.pop_front() {
344 return Ok(Some(data));
345 }
346
347 std::future::pending().await
352 }
353
354 async fn close(&mut self) -> Result<(), Self::Error> {
355 self.connected = false;
356 Ok(())
357 }
358
359 fn close_reason(&self) -> Option<String> {
360 self.close_reason.clone()
361 }
362}
363
364#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
376 fn a_transport_that_says_nothing_reports_nothing() {
377 assert_eq!(MockTransport::new().close_reason(), None);
378 }
379
380 #[test]
389 fn a_stated_close_reason_survives_to_the_caller() {
390 let mut transport = MockTransport::new();
391 transport.script_close_reason("server closed the connection (1011): quota exceeded");
392 assert_eq!(
393 transport.close_reason().as_deref(),
394 Some("server closed the connection (1011): quota exceeded")
395 );
396 }
397
398 #[cfg(not(any(feature = "tls-native", feature = "tls-rustls")))]
402 #[tokio::test]
403 async fn wss_without_tls_backend_fails_before_dialing() {
404 let mut transport = TungsteniteTransport::new();
405 let err = transport
406 .connect("wss://example.invalid/ws", vec![])
407 .await
408 .expect_err("no TLS backend compiled in");
409 assert!(matches!(err, TungsteniteError::NoTlsBackend { .. }));
410 let text = err.to_string();
411 assert!(text.contains("tls-native"), "{text}");
412 assert!(text.contains("tls-rustls"), "{text}");
413 }
414
415 #[test]
416 fn has_tls_backend_tracks_features() {
417 assert_eq!(
418 HAS_TLS_BACKEND,
419 cfg!(any(feature = "tls-native", feature = "tls-rustls"))
420 );
421 }
422
423 #[tokio::test]
424 async fn mock_transport_round_trip() {
425 let mut transport = MockTransport::new();
426 transport.script_recv(br#"{"setupComplete":{}}"#.to_vec());
427
428 transport
429 .connect("wss://example.com", vec![])
430 .await
431 .unwrap();
432 transport.send(b"hello".to_vec()).await.unwrap();
433 let data = transport.recv().await.unwrap();
434 assert!(data.is_some());
435 let text = String::from_utf8(data.unwrap()).unwrap();
436 assert!(text.contains("setupComplete"));
437 }
438
439 #[tokio::test]
440 async fn mock_transport_records_sent() {
441 let mut transport = MockTransport::new();
442 transport
443 .connect("wss://example.com", vec![])
444 .await
445 .unwrap();
446 transport.send(b"msg1".to_vec()).await.unwrap();
447 transport.send(b"msg2".to_vec()).await.unwrap();
448 let sent = transport.take_sent();
449 assert_eq!(sent.len(), 2);
450 assert_eq!(sent[0], b"msg1");
451 }
452
453 #[tokio::test]
454 async fn mock_transport_recv_pends_when_queue_empty() {
455 let mut transport = MockTransport::new();
456 transport
457 .connect("wss://example.com", vec![])
458 .await
459 .unwrap();
460 let result =
462 tokio::time::timeout(std::time::Duration::from_millis(50), transport.recv()).await;
463 assert!(result.is_err(), "recv should pend when queue is empty");
464 }
465
466 #[tokio::test]
467 async fn mock_transport_recv_errors_when_not_connected() {
468 let mut transport = MockTransport::new();
469 let result = transport.recv().await;
471 assert!(result.is_err());
472 }
473
474 #[tokio::test]
475 async fn mock_transport_not_connected_error() {
476 let mut transport = MockTransport::new();
477 let result = transport.send(b"data".to_vec()).await;
478 assert!(result.is_err());
479 }
480
481 #[test]
482 fn transport_trait_is_object_safe_check() {
483 fn _assert_transport<T: Transport>() {}
486 _assert_transport::<MockTransport>();
487 }
488}