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
71pub struct TungsteniteTransport {
73 ws_write: Option<futures_util::stream::SplitSink<WsStream, Message>>,
74 ws_read: Option<futures_util::stream::SplitStream<WsStream>>,
75 close_reason: Option<String>,
78}
79
80impl TungsteniteTransport {
81 pub fn new() -> Self {
83 Self {
84 ws_write: None,
85 ws_read: None,
86 close_reason: None,
87 }
88 }
89}
90
91impl Default for TungsteniteTransport {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97#[derive(Debug, thiserror::Error)]
99pub enum TungsteniteError {
100 #[error("Not connected")]
102 NotConnected,
103
104 #[error("WebSocket error: {0}")]
106 WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
107
108 #[error("Request error: {0}")]
110 Request(String),
111
112 #[error(
121 "cannot dial {url}: this build of gemini-genai-rs has no TLS backend; \
122 enable the `tls-native` (default) or `tls-rustls` feature"
123 )]
124 NoTlsBackend {
125 url: String,
127 },
128}
129
130pub const HAS_TLS_BACKEND: bool = cfg!(any(feature = "tls-native", feature = "tls-rustls"));
132
133#[async_trait]
134impl Transport for TungsteniteTransport {
135 type Error = TungsteniteError;
136
137 async fn connect(
138 &mut self,
139 url: &str,
140 headers: Vec<(String, String)>,
141 ) -> Result<(), Self::Error> {
142 if !HAS_TLS_BACKEND && url.trim_start().to_ascii_lowercase().starts_with("wss://") {
143 return Err(TungsteniteError::NoTlsBackend {
144 url: url.to_string(),
145 });
146 }
147
148 let mut request = url
149 .into_client_request()
150 .map_err(|e| TungsteniteError::Request(e.to_string()))?;
151
152 for (name, value) in headers {
153 let header_name: tokio_tungstenite::tungstenite::http::HeaderName =
154 name.parse().map_err(
155 |e: tokio_tungstenite::tungstenite::http::header::InvalidHeaderName| {
156 TungsteniteError::Request(format!("invalid header name: {e}"))
157 },
158 )?;
159 let header_value: tokio_tungstenite::tungstenite::http::HeaderValue =
160 value.parse().map_err(
161 |e: tokio_tungstenite::tungstenite::http::header::InvalidHeaderValue| {
162 TungsteniteError::Request(format!("invalid header value: {e}"))
163 },
164 )?;
165 request.headers_mut().insert(header_name, header_value);
166 }
167
168 let (ws_stream, _response) = tokio_tungstenite::connect_async(request).await?;
169 let (ws_write, ws_read) = ws_stream.split();
170 self.ws_write = Some(ws_write);
171 self.ws_read = Some(ws_read);
172 Ok(())
173 }
174
175 async fn send(&mut self, data: Vec<u8>) -> Result<(), Self::Error> {
176 let ws_write = self
177 .ws_write
178 .as_mut()
179 .ok_or(TungsteniteError::NotConnected)?;
180 let text = String::from_utf8(data)
182 .map_err(|e| TungsteniteError::Request(format!("invalid UTF-8: {e}")))?;
183 ws_write.send(Message::Text(text)).await?;
184 Ok(())
185 }
186
187 async fn recv(&mut self) -> Result<Option<Vec<u8>>, Self::Error> {
188 let ws_read = self
189 .ws_read
190 .as_mut()
191 .ok_or(TungsteniteError::NotConnected)?;
192 loop {
193 match ws_read.next().await {
194 Some(Ok(Message::Text(t))) => return Ok(Some(t.into_bytes())),
195 Some(Ok(Message::Binary(b))) => return Ok(Some(b)),
197 Some(Ok(Message::Close(frame))) => {
198 if let Some(ref cf) = frame {
199 tracing::warn!(code = %cf.code, reason = %cf.reason, "WebSocket close frame received");
200 self.close_reason = Some(if cf.reason.is_empty() {
204 format!("server closed the connection ({})", cf.code)
205 } else {
206 format!("server closed the connection ({}): {}", cf.code, cf.reason)
207 });
208 }
209 return Ok(None);
210 }
211 Some(Ok(Message::Ping(_) | Message::Pong(_))) => continue,
213 Some(Ok(Message::Frame(_))) => continue,
215 Some(Err(e)) => return Err(e.into()),
216 None => return Ok(None),
217 }
218 }
219 }
220
221 async fn close(&mut self) -> Result<(), Self::Error> {
222 if let Some(ref mut ws_write) = self.ws_write {
223 ws_write.send(Message::Close(None)).await?;
224 }
225 self.ws_write = None;
226 self.ws_read = None;
227 Ok(())
228 }
229
230 fn close_reason(&self) -> Option<String> {
231 self.close_reason.clone()
232 }
233}
234
235pub struct MockTransport {
246 sent: Vec<Vec<u8>>,
247 recv_queue: std::collections::VecDeque<Vec<u8>>,
248 connected: bool,
250 close_reason: Option<String>,
252}
253
254impl MockTransport {
255 pub fn new() -> Self {
257 Self {
258 sent: Vec::new(),
259 recv_queue: std::collections::VecDeque::new(),
260 connected: false,
261 close_reason: None,
262 }
263 }
264
265 pub fn script_recv(&mut self, data: Vec<u8>) {
267 self.recv_queue.push_back(data);
268 }
269
270 pub fn script_close_reason(&mut self, reason: impl Into<String>) {
273 self.close_reason = Some(reason.into());
274 }
275
276 pub fn take_sent(&mut self) -> Vec<Vec<u8>> {
278 std::mem::take(&mut self.sent)
279 }
280}
281
282impl Default for MockTransport {
283 fn default() -> Self {
284 Self::new()
285 }
286}
287
288#[derive(Debug, thiserror::Error)]
290pub enum MockTransportError {
291 #[error("Not connected")]
293 NotConnected,
294
295 #[error("Mock error: {0}")]
297 Custom(String),
298}
299
300#[async_trait]
301impl Transport for MockTransport {
302 type Error = MockTransportError;
303
304 async fn connect(
305 &mut self,
306 _url: &str,
307 _headers: Vec<(String, String)>,
308 ) -> Result<(), Self::Error> {
309 self.connected = true;
310 Ok(())
311 }
312
313 async fn send(&mut self, data: Vec<u8>) -> Result<(), Self::Error> {
314 if !self.connected {
315 return Err(MockTransportError::NotConnected);
316 }
317 self.sent.push(data);
318 Ok(())
319 }
320
321 async fn recv(&mut self) -> Result<Option<Vec<u8>>, Self::Error> {
322 if !self.connected {
323 return Err(MockTransportError::NotConnected);
324 }
325 tokio::task::yield_now().await;
328
329 if let Some(data) = self.recv_queue.pop_front() {
330 return Ok(Some(data));
331 }
332
333 std::future::pending().await
338 }
339
340 async fn close(&mut self) -> Result<(), Self::Error> {
341 self.connected = false;
342 Ok(())
343 }
344
345 fn close_reason(&self) -> Option<String> {
346 self.close_reason.clone()
347 }
348}
349
350#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
362 fn a_transport_that_says_nothing_reports_nothing() {
363 assert_eq!(MockTransport::new().close_reason(), None);
364 }
365
366 #[test]
375 fn a_stated_close_reason_survives_to_the_caller() {
376 let mut transport = MockTransport::new();
377 transport.script_close_reason("server closed the connection (1011): quota exceeded");
378 assert_eq!(
379 transport.close_reason().as_deref(),
380 Some("server closed the connection (1011): quota exceeded")
381 );
382 }
383
384 #[cfg(not(any(feature = "tls-native", feature = "tls-rustls")))]
388 #[tokio::test]
389 async fn wss_without_tls_backend_fails_before_dialing() {
390 let mut transport = TungsteniteTransport::new();
391 let err = transport
392 .connect("wss://example.invalid/ws", vec![])
393 .await
394 .expect_err("no TLS backend compiled in");
395 assert!(matches!(err, TungsteniteError::NoTlsBackend { .. }));
396 let text = err.to_string();
397 assert!(text.contains("tls-native"), "{text}");
398 assert!(text.contains("tls-rustls"), "{text}");
399 }
400
401 #[test]
402 fn has_tls_backend_tracks_features() {
403 assert_eq!(
404 HAS_TLS_BACKEND,
405 cfg!(any(feature = "tls-native", feature = "tls-rustls"))
406 );
407 }
408
409 #[tokio::test]
410 async fn mock_transport_round_trip() {
411 let mut transport = MockTransport::new();
412 transport.script_recv(br#"{"setupComplete":{}}"#.to_vec());
413
414 transport
415 .connect("wss://example.com", vec![])
416 .await
417 .unwrap();
418 transport.send(b"hello".to_vec()).await.unwrap();
419 let data = transport.recv().await.unwrap();
420 assert!(data.is_some());
421 let text = String::from_utf8(data.unwrap()).unwrap();
422 assert!(text.contains("setupComplete"));
423 }
424
425 #[tokio::test]
426 async fn mock_transport_records_sent() {
427 let mut transport = MockTransport::new();
428 transport
429 .connect("wss://example.com", vec![])
430 .await
431 .unwrap();
432 transport.send(b"msg1".to_vec()).await.unwrap();
433 transport.send(b"msg2".to_vec()).await.unwrap();
434 let sent = transport.take_sent();
435 assert_eq!(sent.len(), 2);
436 assert_eq!(sent[0], b"msg1");
437 }
438
439 #[tokio::test]
440 async fn mock_transport_recv_pends_when_queue_empty() {
441 let mut transport = MockTransport::new();
442 transport
443 .connect("wss://example.com", vec![])
444 .await
445 .unwrap();
446 let result =
448 tokio::time::timeout(std::time::Duration::from_millis(50), transport.recv()).await;
449 assert!(result.is_err(), "recv should pend when queue is empty");
450 }
451
452 #[tokio::test]
453 async fn mock_transport_recv_errors_when_not_connected() {
454 let mut transport = MockTransport::new();
455 let result = transport.recv().await;
457 assert!(result.is_err());
458 }
459
460 #[tokio::test]
461 async fn mock_transport_not_connected_error() {
462 let mut transport = MockTransport::new();
463 let result = transport.send(b"data".to_vec()).await;
464 assert!(result.is_err());
465 }
466
467 #[test]
468 fn transport_trait_is_object_safe_check() {
469 fn _assert_transport<T: Transport>() {}
472 _assert_transport::<MockTransport>();
473 }
474}