a2a_rs/adapter/transport/
negotiation.rs1use async_trait::async_trait;
19
20#[cfg(feature = "http-client")]
21use crate::domain::PROTOCOL_BINDING_CONNECTRPC;
22#[cfg(feature = "jsonrpc-client")]
23use crate::domain::PROTOCOL_BINDING_JSONRPC;
24use crate::domain::{A2AError, AgentCard, AgentInterface};
25use crate::port::Transport;
26
27#[derive(Clone, Default)]
34pub struct ClientConfig {
35 auth_token: Option<String>,
36 timeout_secs: Option<u64>,
37}
38
39impl std::fmt::Debug for ClientConfig {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.debug_struct("ClientConfig")
46 .field(
47 "auth_token",
48 match &self.auth_token {
49 Some(_) => &"<redacted>",
50 None => &"None",
51 },
52 )
53 .field("timeout_secs", &self.timeout_secs)
54 .finish()
55 }
56}
57
58impl ClientConfig {
59 pub fn new() -> Self {
61 Self::default()
62 }
63
64 pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
66 self.auth_token = Some(token.into());
67 self
68 }
69
70 pub fn with_timeout(mut self, secs: u64) -> Self {
72 self.timeout_secs = Some(secs);
73 self
74 }
75
76 pub fn auth_token(&self) -> Option<&str> {
78 self.auth_token.as_deref()
79 }
80
81 pub fn timeout_secs(&self) -> Option<u64> {
83 self.timeout_secs
84 }
85}
86
87#[async_trait]
89pub trait TransportFactory: Send + Sync {
90 fn protocol(&self) -> &str;
93
94 async fn create(
98 &self,
99 card: &AgentCard,
100 iface: &AgentInterface,
101 config: &ClientConfig,
102 ) -> Result<Box<dyn Transport>, A2AError>;
103}
104
105#[cfg(feature = "jsonrpc-client")]
107fn jsonrpc_client(url: String, config: &ClientConfig) -> super::jsonrpc_client::JsonRpcClient {
108 use super::jsonrpc_client::JsonRpcClient;
109
110 let mut client = match config.auth_token() {
111 Some(token) => JsonRpcClient::with_auth(url, token.to_string()),
112 None => JsonRpcClient::new(url),
113 };
114 if let Some(secs) = config.timeout_secs() {
115 client = client.with_timeout(secs);
116 }
117 client
118}
119
120#[cfg(feature = "http-client")]
123fn connect_rpc_client(
124 url: String,
125 config: &ClientConfig,
126) -> Result<super::http::HttpClient, A2AError> {
127 use super::http::HttpClient;
128
129 let mut client = match config.auth_token() {
130 Some(token) => HttpClient::try_with_auth(url, token.to_string())?,
131 None => HttpClient::try_new(url)?,
132 };
133 if let Some(secs) = config.timeout_secs() {
134 client = client.with_timeout(secs);
135 }
136 Ok(client)
137}
138
139#[cfg(feature = "jsonrpc-client")]
141pub struct JsonRpcTransportFactory;
142
143#[cfg(feature = "jsonrpc-client")]
144#[async_trait]
145impl TransportFactory for JsonRpcTransportFactory {
146 fn protocol(&self) -> &str {
147 PROTOCOL_BINDING_JSONRPC
148 }
149
150 async fn create(
151 &self,
152 _card: &AgentCard,
153 iface: &AgentInterface,
154 config: &ClientConfig,
155 ) -> Result<Box<dyn Transport>, A2AError> {
156 Ok(Box::new(jsonrpc_client(iface.url.clone(), config)))
157 }
158}
159
160#[cfg(feature = "http-client")]
162pub struct ConnectRpcTransportFactory;
163
164#[cfg(feature = "http-client")]
165#[async_trait]
166impl TransportFactory for ConnectRpcTransportFactory {
167 fn protocol(&self) -> &str {
168 PROTOCOL_BINDING_CONNECTRPC
169 }
170
171 async fn create(
172 &self,
173 _card: &AgentCard,
174 iface: &AgentInterface,
175 config: &ClientConfig,
176 ) -> Result<Box<dyn Transport>, A2AError> {
177 Ok(Box::new(connect_rpc_client(iface.url.clone(), config)?))
181 }
182}
183
184#[derive(Default)]
187pub struct TransportNegotiator {
188 factories: Vec<Box<dyn TransportFactory>>,
189}
190
191impl TransportNegotiator {
192 pub fn new() -> Self {
194 Self::default()
195 }
196
197 pub fn with(mut self, factory: impl TransportFactory + 'static) -> Self {
199 self.factories.push(Box::new(factory));
200 self
201 }
202
203 pub fn supported(&self) -> impl Iterator<Item = &str> {
205 self.factories.iter().map(|f| f.protocol())
206 }
207
208 pub async fn negotiate(&self, card: &AgentCard) -> Result<Box<dyn Transport>, A2AError> {
211 self.negotiate_with(card, &ClientConfig::default()).await
212 }
213
214 pub async fn negotiate_with(
217 &self,
218 card: &AgentCard,
219 config: &ClientConfig,
220 ) -> Result<Box<dyn Transport>, A2AError> {
221 for factory in &self.factories {
222 for iface in &card.supported_interfaces {
223 if iface.protocol_binding == factory.protocol()
224 && version_compatible(&iface.protocol_version)
225 {
226 match factory.create(card, iface, config).await {
227 Ok(transport) => return Ok(transport),
228 Err(_err) => continue,
229 }
230 }
231 }
232 }
233 Err(A2AError::UnsupportedOperation(format!(
234 "no compatible transport: client supports [{}], card offers [{}]",
235 self.supported().collect::<Vec<_>>().join(", "),
236 card.supported_interfaces
237 .iter()
238 .map(|i| i.protocol_binding.as_str())
239 .collect::<Vec<_>>()
240 .join(", "),
241 )))
242 }
243}
244
245fn version_compatible(version: &str) -> bool {
248 version.is_empty() || version.split('.').next() == Some("1")
249}
250
251pub fn default_registry() -> TransportNegotiator {
257 #[allow(unused_mut)]
258 let mut negotiator = TransportNegotiator::new();
259 #[cfg(feature = "http-client")]
260 {
261 negotiator = negotiator.with(ConnectRpcTransportFactory);
262 }
263 #[cfg(feature = "jsonrpc-client")]
264 {
265 negotiator = negotiator.with(JsonRpcTransportFactory);
266 }
267 negotiator
268}
269
270#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
275pub async fn connect(
276 base_url: &str,
277 negotiator: &TransportNegotiator,
278) -> Result<Box<dyn Transport>, A2AError> {
279 connect_with(base_url, negotiator, &ClientConfig::default()).await
280}
281
282#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
289pub async fn connect_with(
290 base_url: &str,
291 negotiator: &TransportNegotiator,
292 config: &ClientConfig,
293) -> Result<Box<dyn Transport>, A2AError> {
294 let card = fetch_agent_card_with(base_url, config).await?;
295 negotiator.negotiate_with(&card, config).await
296}
297
298#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
309pub async fn auto_connect(base_url: &str) -> Result<Box<dyn Transport>, A2AError> {
310 auto_connect_with(base_url, &ClientConfig::default()).await
311}
312
313#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
320pub async fn auto_connect_with(
321 base_url: &str,
322 config: &ClientConfig,
323) -> Result<Box<dyn Transport>, A2AError> {
324 reqwest::Url::parse(base_url)
327 .map_err(|e| A2AError::InvalidParams(format!("invalid url {base_url}: {e}")))?;
328
329 match connect_with(base_url, &default_registry(), config).await {
330 Ok(transport) => Ok(transport),
331 Err(_) => direct_transport(base_url, config),
333 }
334}
335
336#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
344fn direct_transport(base_url: &str, config: &ClientConfig) -> Result<Box<dyn Transport>, A2AError> {
345 #[cfg(feature = "http-client")]
346 {
347 Ok(Box::new(connect_rpc_client(base_url.to_string(), config)?))
348 }
349 #[cfg(all(not(feature = "http-client"), feature = "jsonrpc-client"))]
350 {
351 Ok(Box::new(jsonrpc_client(base_url.to_string(), config)))
352 }
353}
354
355#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
357pub async fn fetch_agent_card(base_url: &str) -> Result<AgentCard, A2AError> {
358 fetch_agent_card_with(base_url, &ClientConfig::default()).await
359}
360
361#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
364pub async fn fetch_agent_card_with(
365 base_url: &str,
366 config: &ClientConfig,
367) -> Result<AgentCard, A2AError> {
368 use crate::adapter::error::HttpClientError;
369
370 let client = reqwest::Client::new();
371 let base = base_url.trim_end_matches('/');
372 for path in [".well-known/agent-card.json", "agent-card"] {
373 let url = format!("{base}/{path}");
374 let mut request = client.get(&url);
375 if let Some(token) = config.auth_token() {
376 request = request.bearer_auth(token);
377 }
378 if let Some(secs) = config.timeout_secs() {
379 request = request.timeout(std::time::Duration::from_secs(secs));
380 }
381 let resp = request.send().await.map_err(HttpClientError::Reqwest)?;
382 if resp.status().is_success() {
383 return resp
384 .json::<AgentCard>()
385 .await
386 .map_err(|e| A2AError::Internal(format!("Failed to parse agent card JSON: {e}")));
387 }
388 }
389 Err(A2AError::Internal(format!(
390 "Agent card not found at {base_url}"
391 )))
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn rejects_v2_interface() {
400 assert!(version_compatible("1.0"));
401 assert!(version_compatible("")); assert!(!version_compatible("2.0"));
403 }
404
405 #[test]
409 fn debug_redacts_the_token() {
410 let rendered = format!("{:?}", ClientConfig::new().with_auth_token("s3cret"));
411 assert!(!rendered.contains("s3cret"), "token leaked: {rendered}");
412 assert!(rendered.contains("redacted"), "{rendered}");
413
414 let rendered = format!("{:?}", ClientConfig::new());
416 assert!(!rendered.contains("redacted"), "{rendered}");
417 }
418
419 #[cfg(feature = "http-client")]
424 #[tokio::test]
425 async fn idn_url_is_an_error_not_a_panic() {
426 assert!(
427 reqwest::Url::parse("http://münchen.de").is_ok(),
428 "premise: the up-front validation accepts this"
429 );
430 match auto_connect("http://münchen.de").await {
431 Err(A2AError::InvalidParams(_)) => {}
432 Err(other) => panic!("wrong error: {other:?}"),
433 Ok(_) => panic!("expected an error for a url http::Uri cannot represent"),
434 }
435 }
436
437 #[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
438 #[tokio::test]
439 async fn auto_connect_rejects_malformed_url() {
440 match auto_connect("not-a-url").await {
442 Err(A2AError::InvalidParams(_)) => {}
443 Err(other) => panic!("wrong error: {other:?}"),
444 Ok(_) => panic!("expected an error for a malformed url"),
445 }
446 }
447
448 #[cfg(feature = "http-client")]
452 #[tokio::test]
453 async fn auto_connect_falls_back_to_direct_connectrpc() {
454 let transport = auto_connect("http://127.0.0.1:1")
455 .await
456 .expect("fallback yields a direct transport");
457 assert_eq!(transport.protocol(), "CONNECTRPC");
458 }
459
460 #[cfg(all(not(feature = "http-client"), feature = "jsonrpc-client"))]
461 #[tokio::test]
462 async fn auto_connect_falls_back_to_direct_jsonrpc() {
463 let transport = auto_connect("http://127.0.0.1:1")
464 .await
465 .expect("fallback yields a direct transport");
466 assert_eq!(transport.protocol(), "JSONRPC");
467 }
468}