Skip to main content

a2a_rs/adapter/transport/http/
client.rs

1//! HTTP client adapter for the A2A protocol using ConnectRPC
2
3use 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
57/// HTTP client for interacting with the A2A protocol via ConnectRPC
58pub struct HttpClient {
59    /// Base URL of the A2A API
60    base_url: String,
61    /// reqwest Client for standard GET operations like agent card
62    client: Client,
63    /// ConnectRPC Client
64    connect_client: A2aServiceClient<connectrpc::client::HttpClient>,
65    /// Authorization token, if any
66    auth_token: Option<String>,
67    /// Timeout in seconds
68    timeout: u64,
69}
70
71impl HttpClient {
72    /// Create a new HTTP client with the given base URL.
73    ///
74    /// # Panics
75    ///
76    /// If `base_url` is not a valid `http::Uri`. Use [`try_new`](Self::try_new)
77    /// whenever the URL came from outside the program — a CLI flag, a config
78    /// file, or an agent card.
79    pub fn new(base_url: String) -> Self {
80        Self::try_new(base_url).expect("Invalid base URL")
81    }
82
83    /// Create a new HTTP client, reporting an unusable base URL rather than
84    /// panicking.
85    ///
86    /// `http::Uri` is stricter than the URL parsers callers tend to validate
87    /// with: `reqwest::Url` accepts an IDN host like `http://münchen.de` and
88    /// normalizes it to punycode, while `http::Uri` rejects the raw bytes. A
89    /// caller that checked with the former and built with `new` would panic on
90    /// a URL it had just declared valid.
91    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    /// Create a new HTTP client with authentication.
103    ///
104    /// # Panics
105    ///
106    /// As [`new`](Self::new); see [`try_with_auth`](Self::try_with_auth).
107    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    /// Create an authenticated HTTP client, reporting an unusable base URL
112    /// rather than panicking. See [`try_new`](Self::try_new).
113    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    /// The ConnectRPC transport and base config for `base_url`, TLS-enabled for
126    /// `https`. Shared so the authenticated and anonymous constructors cannot
127    /// drift on which scheme gets a TLS stack.
128    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    /// Set the timeout for requests
159    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    /// Get the headers for a request (used for reqwest)
170    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    /// Get the base URL of the client
188    pub fn base_url(&self) -> &str {
189        &self.base_url
190    }
191
192    /// Fetch the agent card from the agent's `/agent-card` endpoint (plain HTTP GET)
193    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    /// Fetch the extended agent card using ConnectRPC
244    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        // Left empty when the caller passed `None`: the wire treats an absent
281        // task id as "server assigns one".
282        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        // ConnectRPC has no SSE `id:` field, so ids ride in the update's
487        // metadata — but only for a client that asks, since that changes the
488        // payload. See the `resume` module.
489        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}