a2a_protocol_client/transport/rest/
mod.rs1pub(crate) mod error_frame;
38mod query;
39pub(crate) mod request;
40mod routing;
41mod streaming;
42
43use std::collections::HashMap;
44use std::future::Future;
45use std::pin::Pin;
46use std::sync::Arc;
47use std::time::Duration;
48
49#[cfg(not(feature = "tls-rustls"))]
50use http_body_util::Full;
51#[cfg(not(feature = "tls-rustls"))]
52use hyper::body::Bytes;
53#[cfg(not(feature = "tls-rustls"))]
54use hyper_util::client::legacy::Client;
55#[cfg(not(feature = "tls-rustls"))]
56use hyper_util::client::legacy::connect::HttpConnector;
57#[cfg(not(feature = "tls-rustls"))]
58use hyper_util::rt::TokioExecutor;
59
60use crate::error::{ClientError, ClientResult};
61use crate::streaming::EventStream;
62use crate::transport::Transport;
63
64#[cfg(not(feature = "tls-rustls"))]
67type HttpClient = Client<HttpConnector, Full<Bytes>>;
68
69#[cfg(feature = "tls-rustls")]
70type HttpClient = crate::tls::HttpsClient;
71
72#[derive(Clone, Debug)]
79pub struct RestTransport {
80 inner: Arc<Inner>,
81}
82
83#[derive(Debug, Clone)]
84struct Inner {
85 client: HttpClient,
86 base_url: String,
87 request_timeout: Duration,
88 stream_connect_timeout: Duration,
89 max_response_size: usize,
90}
91
92impl RestTransport {
93 pub fn new(base_url: impl Into<String>) -> ClientResult<Self> {
99 Self::with_timeout(base_url, Duration::from_secs(30))
100 }
101
102 pub fn with_timeout(
108 base_url: impl Into<String>,
109 request_timeout: Duration,
110 ) -> ClientResult<Self> {
111 Self::with_timeouts(base_url, request_timeout, request_timeout)
112 }
113
114 pub fn with_timeouts(
122 base_url: impl Into<String>,
123 request_timeout: Duration,
124 stream_connect_timeout: Duration,
125 ) -> ClientResult<Self> {
126 Self::with_all_timeouts(
127 base_url,
128 request_timeout,
129 stream_connect_timeout,
130 Duration::from_secs(10),
131 )
132 }
133
134 pub fn with_all_timeouts(
143 base_url: impl Into<String>,
144 request_timeout: Duration,
145 stream_connect_timeout: Duration,
146 connection_timeout: Duration,
147 ) -> ClientResult<Self> {
148 let base_url = base_url.into();
149 if base_url.is_empty()
150 || (!base_url.starts_with("http://") && !base_url.starts_with("https://"))
151 {
152 return Err(ClientError::InvalidEndpoint(format!(
153 "invalid base URL: {base_url}"
154 )));
155 }
156
157 #[cfg(not(feature = "tls-rustls"))]
158 let client = {
159 let connector = crate::transport::connector::http_connector(connection_timeout);
160 Client::builder(TokioExecutor::new())
161 .pool_idle_timeout(Duration::from_secs(90))
162 .build(connector)
163 };
164
165 #[cfg(feature = "tls-rustls")]
166 let client = crate::tls::build_https_client_with_connect_timeout(
167 crate::tls::default_tls_config(),
168 connection_timeout,
169 );
170
171 Ok(Self {
172 inner: Arc::new(Inner {
173 client,
174 base_url: base_url.trim_end_matches('/').to_owned(),
175 request_timeout,
176 stream_connect_timeout,
177 max_response_size: super::DEFAULT_MAX_RESPONSE_SIZE,
178 }),
179 })
180 }
181
182 #[must_use]
188 pub fn with_max_response_size(mut self, max_bytes: usize) -> Self {
189 Arc::make_mut(&mut self.inner).max_response_size = max_bytes;
190 self
191 }
192
193 #[must_use]
195 pub fn base_url(&self) -> &str {
196 &self.inner.base_url
197 }
198}
199
200impl Transport for RestTransport {
201 fn send_request<'a>(
202 &'a self,
203 method: &'a str,
204 params: serde_json::Value,
205 extra_headers: &'a HashMap<String, String>,
206 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>> {
207 Box::pin(self.execute_request(method, params, extra_headers))
208 }
209
210 fn send_streaming_request<'a>(
211 &'a self,
212 method: &'a str,
213 params: serde_json::Value,
214 extra_headers: &'a HashMap<String, String>,
215 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
216 Box::pin(self.execute_streaming_request(method, params, extra_headers))
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn rest_transport_rejects_invalid_url() {
226 assert!(RestTransport::new("not-a-url").is_err());
227 }
228
229 #[test]
230 fn rest_transport_stores_base_url() {
231 let t = RestTransport::new("http://localhost:9090").unwrap();
232 assert_eq!(t.base_url(), "http://localhost:9090");
233 }
234
235 #[tokio::test]
237 async fn send_request_via_trait_delegation() {
238 use http_body_util::Full;
239 use hyper::body::Bytes;
240
241 let response_body = r#"{"status":"ok","data":42}"#;
242 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
243 let addr = listener.local_addr().unwrap();
244
245 tokio::spawn(async move {
246 loop {
247 let (stream, _) = listener.accept().await.unwrap();
248 let io = hyper_util::rt::TokioIo::new(stream);
249 let body = response_body.to_owned();
250 tokio::spawn(async move {
251 let service = hyper::service::service_fn(move |_req| {
252 let body = body.clone();
253 async move {
254 Ok::<_, hyper::Error>(
255 hyper::Response::builder()
256 .status(200)
257 .header("content-type", "application/json")
258 .body(Full::new(Bytes::from(body)))
259 .unwrap(),
260 )
261 }
262 });
263 let _ = hyper_util::server::conn::auto::Builder::new(
264 hyper_util::rt::TokioExecutor::new(),
265 )
266 .serve_connection(io, service)
267 .await;
268 });
269 }
270 });
271
272 let url = format!("http://127.0.0.1:{}", addr.port());
273 let transport = RestTransport::new(&url).unwrap();
274 let dyn_transport: &dyn crate::transport::Transport = &transport;
275 let result = dyn_transport
276 .send_request("SendMessage", serde_json::json!({}), &HashMap::new())
277 .await;
278 assert!(result.is_ok(), "send_request via trait should succeed");
279 }
280
281 #[tokio::test]
285 async fn a_stalled_error_body_is_bounded_by_the_connect_timeout() {
286 let connect = Duration::from_millis(600);
287 let addr = crate::transport::test_support::spawn_stalling_server(
288 "HTTP/1.1 500 Internal Server Error",
289 connect * 2 / 3,
290 )
291 .await;
292 let url = format!("http://127.0.0.1:{}", addr.port());
293 let transport =
294 RestTransport::with_timeouts(&url, Duration::from_secs(30), connect).unwrap();
295
296 let started = std::time::Instant::now();
297 let result = transport
298 .execute_streaming_request(
299 "SendStreamingMessage",
300 serde_json::json!({}),
301 &HashMap::new(),
302 )
303 .await;
304 let elapsed = started.elapsed();
305 assert!(matches!(result, Err(ClientError::Timeout(_))), "{result:?}");
306 assert!(
307 elapsed >= connect && elapsed < connect * 3 / 2,
308 "one budget, not two: took {elapsed:?} against {connect:?}"
309 );
310 }
311
312 #[tokio::test]
317 async fn with_timeout_bounds_requests_and_streams_alike() {
318 let bound = Duration::from_millis(600);
319 let addr =
320 crate::transport::test_support::spawn_stalling_server("HTTP/1.1 200 OK", bound * 2 / 3)
321 .await;
322 let transport =
323 RestTransport::with_timeout(format!("http://127.0.0.1:{}", addr.port()), bound)
324 .unwrap();
325
326 let started = std::time::Instant::now();
327 let unary = transport
328 .execute_request("GetTask", serde_json::json!({"id": "t1"}), &HashMap::new())
329 .await;
330 let unary_took = started.elapsed();
331 assert!(matches!(unary, Err(ClientError::Timeout(_))), "{unary:?}");
332 assert!(
333 unary_took < bound * 3 / 2,
334 "unary took {unary_took:?} against {bound:?}"
335 );
336
337 let started = std::time::Instant::now();
338 let stream = transport
339 .execute_streaming_request(
340 "SendStreamingMessage",
341 serde_json::json!({}),
342 &HashMap::new(),
343 )
344 .await;
345 let stream_took = started.elapsed();
346 assert!(matches!(stream, Err(ClientError::Timeout(_))), "{stream:?}");
347 assert!(
348 stream_took < bound * 3 / 2,
349 "stream took {stream_took:?} against {bound:?}"
350 );
351 }
352
353 #[tokio::test]
355 async fn send_streaming_request_via_trait_delegation() {
356 use http_body_util::Full;
357 use hyper::body::Bytes;
358
359 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
360 let addr = listener.local_addr().unwrap();
361
362 tokio::spawn(async move {
363 loop {
364 let (stream, _) = listener.accept().await.unwrap();
365 let io = hyper_util::rt::TokioIo::new(stream);
366 tokio::spawn(async move {
367 let service = hyper::service::service_fn(|_req| async {
368 let sse_body = "data: {\"hello\":\"world\"}\n\n";
369 Ok::<_, hyper::Error>(
370 hyper::Response::builder()
371 .status(200)
372 .header("content-type", "text/event-stream")
373 .body(Full::new(Bytes::from(sse_body)))
374 .unwrap(),
375 )
376 });
377 let _ = hyper_util::server::conn::auto::Builder::new(
378 hyper_util::rt::TokioExecutor::new(),
379 )
380 .serve_connection(io, service)
381 .await;
382 });
383 }
384 });
385
386 let url = format!("http://127.0.0.1:{}", addr.port());
387 let transport = RestTransport::new(&url).unwrap();
388 let dyn_transport: &dyn crate::transport::Transport = &transport;
389 let result = dyn_transport
390 .send_streaming_request(
391 "SendStreamingMessage",
392 serde_json::json!({}),
393 &HashMap::new(),
394 )
395 .await;
396 assert!(
397 result.is_ok(),
398 "send_streaming_request via trait should succeed"
399 );
400 }
401}