1use async_trait::async_trait;
4use futures::stream::Stream;
5use reqwest::{
6 Client,
7 header::{HeaderMap, HeaderValue},
8};
9use std::{pin::Pin, sync::Arc, time::Duration};
10
11#[cfg(feature = "tracing")]
12use tracing::{debug, instrument};
13
14use crate::{
15 adapter::error::HttpClientError,
16 adapter::transport::codec::stream_response_to_item,
17 domain::{
18 A2AError, AgentCard, ListTasksParams, ListTasksResult, Message, SendCompletion, Task,
19 TaskPushNotificationConfig,
20 generated::{
21 A2aServiceClient, CancelTaskRequest, DeleteTaskPushNotificationConfigRequest,
22 GetExtendedAgentCardRequest, GetTaskPushNotificationConfigRequest, GetTaskRequest,
23 ListTaskPushNotificationConfigsRequest, ListTasksRequest, SendMessageConfiguration,
24 SendMessageRequest, SubscribeToTaskRequest, TaskState, send_message_response,
25 },
26 },
27 port::{StreamEvent, Transport},
28};
29
30use crate::adapter::transport::resume;
31
32fn map_connect_err(err: connectrpc::ConnectError) -> A2AError {
33 let code = match err.code {
34 connectrpc::ErrorCode::NotFound => crate::domain::error::TASK_NOT_FOUND,
35 connectrpc::ErrorCode::Unimplemented => crate::domain::error::METHOD_NOT_FOUND,
36 connectrpc::ErrorCode::InvalidArgument => crate::domain::error::INVALID_PARAMS,
37 connectrpc::ErrorCode::Internal => crate::domain::error::INTERNAL_ERROR,
38 connectrpc::ErrorCode::FailedPrecondition => {
39 crate::domain::error::AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED
40 }
41 _ => {
42 let code_val = err.code as i32;
43 if code_val != 0 {
44 code_val
45 } else {
46 crate::domain::error::INTERNAL_ERROR
47 }
48 }
49 };
50 A2AError::JsonRpc {
51 code,
52 message: err.message.clone().unwrap_or_default(),
53 data: None,
54 }
55}
56
57pub struct HttpClient {
59 base_url: String,
61 client: Client,
63 connect_client: A2aServiceClient<connectrpc::client::HttpClient>,
65 auth_token: Option<String>,
67 timeout: u64,
69}
70
71impl HttpClient {
72 pub fn new(base_url: String) -> Self {
80 Self::try_new(base_url).expect("Invalid base URL")
81 }
82
83 pub fn try_new(base_url: String) -> Result<Self, A2AError> {
92 let (transport, config) = Self::transport_for(&base_url)?;
93 Ok(Self {
94 base_url,
95 client: Client::new(),
96 connect_client: A2aServiceClient::new(transport, config),
97 auth_token: None,
98 timeout: 30,
99 })
100 }
101
102 pub fn with_auth(base_url: String, auth_token: String) -> Self {
108 Self::try_with_auth(base_url, auth_token).expect("Invalid base URL")
109 }
110
111 pub fn try_with_auth(base_url: String, auth_token: String) -> Result<Self, A2AError> {
114 let (transport, config) = Self::transport_for(&base_url)?;
115 let config = config.default_header("authorization", format!("Bearer {}", auth_token));
116 Ok(Self {
117 base_url,
118 client: Client::new(),
119 connect_client: A2aServiceClient::new(transport, config),
120 auth_token: Some(auth_token),
121 timeout: 30,
122 })
123 }
124
125 fn transport_for(
129 base_url: &str,
130 ) -> Result<
131 (
132 connectrpc::client::HttpClient,
133 connectrpc::client::ClientConfig,
134 ),
135 A2AError,
136 > {
137 let uri = base_url
138 .parse::<http::Uri>()
139 .map_err(|e| A2AError::InvalidParams(format!("invalid base url {base_url}: {e}")))?;
140
141 let transport = if uri.scheme_str() == Some("https") {
142 let _ = rustls::crypto::ring::default_provider().install_default();
143 let mut root_store = rustls::RootCertStore::empty();
144 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
145 let tls_config = rustls::ClientConfig::builder()
146 .with_root_certificates(root_store)
147 .with_no_client_auth();
148 connectrpc::client::HttpClient::with_tls(Arc::new(tls_config))
149 } else {
150 connectrpc::client::HttpClient::plaintext()
151 };
152
153 let config =
154 connectrpc::client::ClientConfig::new(uri).default_timeout(Duration::from_secs(30));
155 Ok((transport, config))
156 }
157
158 pub fn with_timeout(mut self, timeout: u64) -> Self {
160 self.timeout = timeout;
161 *self.connect_client.config_mut() = self
162 .connect_client
163 .config()
164 .clone()
165 .default_timeout(Duration::from_secs(timeout));
166 self
167 }
168
169 fn get_headers(&self) -> Result<HeaderMap, A2AError> {
171 let mut headers = HeaderMap::new();
172 headers.insert(
173 reqwest::header::CONTENT_TYPE,
174 HeaderValue::from_static("application/json"),
175 );
176
177 if let Some(token) = &self.auth_token {
178 let auth_value = HeaderValue::from_str(&format!("Bearer {}", token)).map_err(|e| {
179 A2AError::Internal(format!("Invalid auth token for HTTP header: {}", e))
180 })?;
181 headers.insert(reqwest::header::AUTHORIZATION, auth_value);
182 }
183
184 Ok(headers)
185 }
186
187 pub fn base_url(&self) -> &str {
189 &self.base_url
190 }
191
192 pub async fn get_agent_card(&self) -> Result<AgentCard, A2AError> {
194 let url = if self.base_url.ends_with('/') {
195 format!("{}agent-card", self.base_url)
196 } else {
197 match reqwest::Url::parse(&self.base_url) {
198 Ok(parsed) => {
199 if !parsed.path().ends_with('/') {
200 match parsed.join("/agent-card") {
201 Ok(resolved) => resolved.to_string(),
202 Err(_) => format!("{}/agent-card", self.base_url),
203 }
204 } else {
205 match parsed.join("agent-card") {
206 Ok(resolved) => resolved.to_string(),
207 Err(_) => format!("{}/agent-card", self.base_url),
208 }
209 }
210 }
211 Err(_) => format!("{}/agent-card", self.base_url),
212 }
213 };
214
215 #[cfg(feature = "tracing")]
216 debug!("Fetching agent card from URL: {}", url);
217
218 let response = self
219 .client
220 .get(&url)
221 .headers(self.get_headers()?)
222 .timeout(Duration::from_secs(self.timeout))
223 .send()
224 .await
225 .map_err(HttpClientError::Reqwest)?;
226
227 if response.status().is_success() {
228 let card: AgentCard = response.json().await.map_err(|e| {
229 A2AError::Internal(format!("Failed to parse agent card JSON: {}", e))
230 })?;
231 Ok(card)
232 } else {
233 let status = response.status();
234 let body = response.text().await.unwrap_or_default();
235 Err(HttpClientError::Response {
236 status: status.as_u16(),
237 message: body,
238 }
239 .into())
240 }
241 }
242
243 pub async fn get_extended_agent_card(
245 &self,
246 tenant: Option<String>,
247 ) -> Result<AgentCard, A2AError> {
248 let request = GetExtendedAgentCardRequest {
249 tenant: tenant.unwrap_or_default(),
250 ..Default::default()
251 };
252 let response = self
253 .connect_client
254 .get_extended_agent_card(request)
255 .await
256 .map_err(map_connect_err)?;
257 Ok(response.into_owned())
258 }
259}
260
261#[async_trait]
262impl Transport for HttpClient {
263 fn protocol(&self) -> &str {
264 "CONNECTRPC"
265 }
266
267 #[cfg_attr(
268 feature = "tracing",
269 instrument(skip(self, message), fields(task_id, session_id, history_length))
270 )]
271 async fn send_task_message(
272 &self,
273 task_id: Option<&str>,
274 message: &Message,
275 session_id: Option<&str>,
276 history_length: Option<u32>,
277 completion: SendCompletion,
278 ) -> Result<Task, A2AError> {
279 let mut msg = message.clone();
280 if let Some(id) = task_id {
283 msg.task_id = id.to_string();
284 }
285 if let Some(sid) = session_id {
286 msg.context_id = sid.to_string();
287 }
288
289 let config = SendMessageConfiguration {
290 history_length: history_length.map(|l| l as i32),
291 return_immediately: completion.return_immediately(),
292 ..Default::default()
293 };
294
295 let request = SendMessageRequest {
296 message: ::buffa::MessageField::some(msg),
297 configuration: ::buffa::MessageField::some(config),
298 ..Default::default()
299 };
300
301 let response = self
302 .connect_client
303 .send_message(request)
304 .await
305 .map_err(map_connect_err)?;
306 let owned_response = response.into_owned();
307
308 match owned_response.payload {
309 Some(send_message_response::Payload::Task(task)) => Ok(*task),
310 _ => Err(A2AError::Internal(
311 "Expected task in SendMessageResponse payload".to_string(),
312 )),
313 }
314 }
315
316 #[cfg_attr(
317 feature = "tracing",
318 instrument(skip(self), fields(task_id, history_length))
319 )]
320 async fn get_task(&self, task_id: &str, history_length: Option<u32>) -> Result<Task, A2AError> {
321 let request = GetTaskRequest {
322 id: task_id.to_string(),
323 history_length: history_length.map(|l| l as i32),
324 ..Default::default()
325 };
326 let response = self
327 .connect_client
328 .get_task(request)
329 .await
330 .map_err(map_connect_err)?;
331 Ok(response.into_owned())
332 }
333
334 #[cfg_attr(feature = "tracing", instrument(skip(self), fields(task_id)))]
335 async fn cancel_task(&self, task_id: &str) -> Result<Task, A2AError> {
336 let request = CancelTaskRequest {
337 id: task_id.to_string(),
338 ..Default::default()
339 };
340 let response = self
341 .connect_client
342 .cancel_task(request)
343 .await
344 .map_err(map_connect_err)?;
345 Ok(response.into_owned())
346 }
347
348 async fn set_task_push_notification(
349 &self,
350 config: &TaskPushNotificationConfig,
351 ) -> Result<TaskPushNotificationConfig, A2AError> {
352 let request = config.clone();
353 let response = self
354 .connect_client
355 .create_task_push_notification_config(request)
356 .await
357 .map_err(map_connect_err)?;
358 Ok(response.into_owned())
359 }
360
361 async fn get_task_push_notification(
362 &self,
363 task_id: &str,
364 ) -> Result<TaskPushNotificationConfig, A2AError> {
365 let request = ListTaskPushNotificationConfigsRequest {
366 task_id: task_id.to_string(),
367 ..Default::default()
368 };
369 let response = self
370 .connect_client
371 .list_task_push_notification_configs(request)
372 .await
373 .map_err(map_connect_err)?;
374 let configs = response.into_owned().configs;
375 if let Some(config) = configs.into_iter().next() {
376 Ok(config)
377 } else {
378 Err(A2AError::TaskNotFound(format!(
379 "No push notification config found for task {}",
380 task_id
381 )))
382 }
383 }
384
385 #[cfg_attr(feature = "tracing", instrument(skip(self, params)))]
386 async fn list_tasks(&self, params: &ListTasksParams) -> Result<ListTasksResult, A2AError> {
387 let mut request = ListTasksRequest {
388 context_id: params.context_id.clone().unwrap_or_default(),
389 status: ::buffa::EnumValue::from(
390 params.status.unwrap_or(TaskState::TASK_STATE_UNSPECIFIED),
391 ),
392 page_size: params.page_size,
393 page_token: params.page_token.clone().unwrap_or_default(),
394 history_length: params.history_length,
395 include_artifacts: params.include_artifacts,
396 ..Default::default()
397 };
398 if let Some(ref t_str) = params.status_timestamp_after
399 && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(t_str)
400 {
401 let utc_dt = dt.with_timezone(&chrono::Utc);
402 request.status_timestamp_after =
403 ::buffa::MessageField::some(::buffa_types::google::protobuf::Timestamp {
404 seconds: utc_dt.timestamp(),
405 nanos: utc_dt.timestamp_subsec_nanos() as i32,
406 ..Default::default()
407 });
408 }
409
410 let response = self
411 .connect_client
412 .list_tasks(request)
413 .await
414 .map_err(map_connect_err)?;
415 let owned = response.into_owned();
416 Ok(ListTasksResult {
417 tasks: owned.tasks,
418 total_size: owned.total_size,
419 page_size: owned.page_size,
420 next_page_token: owned.next_page_token,
421 })
422 }
423
424 async fn list_push_notification_configs(
425 &self,
426 task_id: &str,
427 ) -> Result<Vec<TaskPushNotificationConfig>, A2AError> {
428 let request = ListTaskPushNotificationConfigsRequest {
429 task_id: task_id.to_string(),
430 ..Default::default()
431 };
432 let response = self
433 .connect_client
434 .list_task_push_notification_configs(request)
435 .await
436 .map_err(map_connect_err)?;
437 Ok(response.into_owned().configs)
438 }
439
440 async fn get_push_notification_config(
441 &self,
442 task_id: &str,
443 config_id: &str,
444 ) -> Result<TaskPushNotificationConfig, A2AError> {
445 let request = GetTaskPushNotificationConfigRequest {
446 task_id: task_id.to_string(),
447 id: config_id.to_string(),
448 ..Default::default()
449 };
450 let response = self
451 .connect_client
452 .get_task_push_notification_config(request)
453 .await
454 .map_err(map_connect_err)?;
455 Ok(response.into_owned())
456 }
457
458 async fn delete_push_notification_config(
459 &self,
460 task_id: &str,
461 config_id: &str,
462 ) -> Result<(), A2AError> {
463 let request = DeleteTaskPushNotificationConfigRequest {
464 task_id: task_id.to_string(),
465 id: config_id.to_string(),
466 ..Default::default()
467 };
468 self.connect_client
469 .delete_task_push_notification_config(request)
470 .await
471 .map_err(map_connect_err)?;
472 Ok(())
473 }
474
475 async fn subscribe_to_task(
476 &self,
477 task_id: &str,
478 _history_length: Option<u32>,
479 last_event_id: Option<&str>,
480 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, A2AError>> + Send>>, A2AError> {
481 let request = SubscribeToTaskRequest {
482 id: task_id.to_string(),
483 ..Default::default()
484 };
485
486 let mut options =
490 connectrpc::client::CallOptions::default().with_header(resume::EVENT_IDS_HEADER, "1");
491 if let Some(id) = last_event_id {
492 options = options
493 .try_with_header("last-event-id", id)
494 .map_err(map_connect_err)?;
495 }
496
497 let stream = self
498 .connect_client
499 .subscribe_to_task_with_options(request, options)
500 .await
501 .map_err(map_connect_err)?;
502
503 let mapped = futures::stream::unfold(stream, |mut s| async move {
504 match s.message().await {
505 Ok(Some(view)) => {
506 let resp = view.to_owned_message();
507 if let Some(mut item) = stream_response_to_item(resp) {
508 let event_id = resume::take_event_id(&mut item);
509 Some((Ok(StreamEvent::new(event_id, item)), s))
510 } else {
511 Some((
512 Err(A2AError::Internal(
513 "Empty or unhandled stream response payload".to_string(),
514 )),
515 s,
516 ))
517 }
518 }
519 Ok(None) => None,
520 Err(e) => Some((Err(map_connect_err(e)), s)),
521 }
522 });
523
524 Ok(Box::pin(mapped))
525 }
526}