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(
108 url: String,
109 config: &ClientConfig,
110) -> Result<super::jsonrpc_client::JsonRpcClient, A2AError> {
111 use super::jsonrpc_client::JsonRpcClient;
112
113 let mut client = match config.auth_token() {
114 Some(token) => JsonRpcClient::try_with_auth(url, token.to_string())?,
115 None => JsonRpcClient::try_new(url)?,
116 };
117 if let Some(secs) = config.timeout_secs() {
118 client = client.with_timeout(secs);
119 }
120 Ok(client)
121}
122
123#[cfg(feature = "http-client")]
126fn connect_rpc_client(
127 url: String,
128 config: &ClientConfig,
129) -> Result<super::http::HttpClient, A2AError> {
130 use super::http::HttpClient;
131
132 let mut client = match config.auth_token() {
133 Some(token) => HttpClient::try_with_auth(url, token.to_string())?,
134 None => HttpClient::try_new(url)?,
135 };
136 if let Some(secs) = config.timeout_secs() {
137 client = client.with_timeout(secs);
138 }
139 Ok(client)
140}
141
142#[cfg(feature = "jsonrpc-client")]
144pub struct JsonRpcTransportFactory;
145
146#[cfg(feature = "jsonrpc-client")]
147#[async_trait]
148impl TransportFactory for JsonRpcTransportFactory {
149 fn protocol(&self) -> &str {
150 PROTOCOL_BINDING_JSONRPC
151 }
152
153 async fn create(
154 &self,
155 _card: &AgentCard,
156 iface: &AgentInterface,
157 config: &ClientConfig,
158 ) -> Result<Box<dyn Transport>, A2AError> {
159 Ok(Box::new(jsonrpc_client(iface.url.clone(), config)?))
160 }
161}
162
163#[cfg(feature = "http-client")]
165pub struct ConnectRpcTransportFactory;
166
167#[cfg(feature = "http-client")]
168#[async_trait]
169impl TransportFactory for ConnectRpcTransportFactory {
170 fn protocol(&self) -> &str {
171 PROTOCOL_BINDING_CONNECTRPC
172 }
173
174 async fn create(
175 &self,
176 _card: &AgentCard,
177 iface: &AgentInterface,
178 config: &ClientConfig,
179 ) -> Result<Box<dyn Transport>, A2AError> {
180 Ok(Box::new(connect_rpc_client(iface.url.clone(), config)?))
184 }
185}
186
187#[derive(Default)]
190pub struct TransportNegotiator {
191 factories: Vec<Box<dyn TransportFactory>>,
192}
193
194impl TransportNegotiator {
195 pub fn new() -> Self {
197 Self::default()
198 }
199
200 pub fn with(mut self, factory: impl TransportFactory + 'static) -> Self {
202 self.factories.push(Box::new(factory));
203 self
204 }
205
206 pub fn supported(&self) -> impl Iterator<Item = &str> {
208 self.factories.iter().map(|f| f.protocol())
209 }
210
211 pub async fn negotiate(&self, card: &AgentCard) -> Result<Box<dyn Transport>, A2AError> {
214 self.negotiate_with(card, &ClientConfig::default()).await
215 }
216
217 pub async fn negotiate_with(
220 &self,
221 card: &AgentCard,
222 config: &ClientConfig,
223 ) -> Result<Box<dyn Transport>, A2AError> {
224 self.select(card, config)
225 .await
226 .map(|(transport, _iface)| transport)
227 }
228
229 async fn select<'c>(
234 &self,
235 card: &'c AgentCard,
236 config: &ClientConfig,
237 ) -> Result<(Box<dyn Transport>, &'c AgentInterface), A2AError> {
238 for factory in &self.factories {
239 for iface in &card.supported_interfaces {
240 if iface.protocol_binding == factory.protocol()
241 && version_compatible(&iface.protocol_version)
242 {
243 match factory.create(card, iface, config).await {
244 Ok(transport) => return Ok((transport, iface)),
245 Err(_err) => continue,
246 }
247 }
248 }
249 }
250 Err(A2AError::UnsupportedOperation(format!(
251 "no compatible transport: client supports [{}], card offers [{}]",
252 self.supported().collect::<Vec<_>>().join(", "),
253 card.supported_interfaces
254 .iter()
255 .map(|i| i.protocol_binding.as_str())
256 .collect::<Vec<_>>()
257 .join(", "),
258 )))
259 }
260}
261
262fn version_compatible(version: &str) -> bool {
265 version.is_empty() || version.split('.').next() == Some("1")
266}
267
268pub fn default_registry() -> TransportNegotiator {
274 #[allow(unused_mut)]
275 let mut negotiator = TransportNegotiator::new();
276 #[cfg(feature = "http-client")]
277 {
278 negotiator = negotiator.with(ConnectRpcTransportFactory);
279 }
280 #[cfg(feature = "jsonrpc-client")]
281 {
282 negotiator = negotiator.with(JsonRpcTransportFactory);
283 }
284 negotiator
285}
286
287#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
292pub async fn connect(
293 base_url: &str,
294 negotiator: &TransportNegotiator,
295) -> Result<Box<dyn Transport>, A2AError> {
296 connect_with(base_url, negotiator, &ClientConfig::default()).await
297}
298
299#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
318pub async fn connect_with(
319 base_url: &str,
320 negotiator: &TransportNegotiator,
321 config: &ClientConfig,
322) -> Result<Box<dyn Transport>, A2AError> {
323 let card = fetch_agent_card_with(base_url, config).await?;
324 let (transport, iface) = negotiator.select(&card, config).await?;
325 if !same_origin(base_url, &iface.url) {
326 #[cfg(feature = "tracing")]
327 tracing::warn!(
328 card_url = %base_url,
329 interface_url = %iface.url,
330 protocol = %iface.protocol_binding,
331 "the agent card advertises its interface at a different origin than it was fetched from; requests go to the advertised URL"
332 );
333 }
334 Ok(transport)
335}
336
337fn same_origin(a: &str, b: &str) -> bool {
342 match (reqwest::Url::parse(a), reqwest::Url::parse(b)) {
343 (Ok(a), Ok(b)) => {
344 a.scheme() == b.scheme()
345 && a.host_str() == b.host_str()
346 && a.port_or_known_default() == b.port_or_known_default()
347 }
348 _ => false,
349 }
350}
351
352#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
369pub async fn auto_connect(base_url: &str) -> Result<Box<dyn Transport>, A2AError> {
370 auto_connect_with(base_url, &ClientConfig::default()).await
371}
372
373#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
380pub async fn auto_connect_with(
381 base_url: &str,
382 config: &ClientConfig,
383) -> Result<Box<dyn Transport>, A2AError> {
384 reqwest::Url::parse(base_url)
387 .map_err(|e| A2AError::InvalidParams(format!("invalid url {base_url}: {e}")))?;
388
389 match connect_with(base_url, &default_registry(), config).await {
390 Ok(transport) => Ok(transport),
391 Err(_) => direct_transport(base_url, config),
393 }
394}
395
396#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
404fn direct_transport(base_url: &str, config: &ClientConfig) -> Result<Box<dyn Transport>, A2AError> {
405 #[cfg(feature = "http-client")]
406 {
407 Ok(Box::new(connect_rpc_client(base_url.to_string(), config)?))
408 }
409 #[cfg(all(not(feature = "http-client"), feature = "jsonrpc-client"))]
410 {
411 Ok(Box::new(jsonrpc_client(base_url.to_string(), config)?))
412 }
413}
414
415#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
417pub async fn fetch_agent_card(base_url: &str) -> Result<AgentCard, A2AError> {
418 fetch_agent_card_with(base_url, &ClientConfig::default()).await
419}
420
421#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
424pub async fn fetch_agent_card_with(
425 base_url: &str,
426 config: &ClientConfig,
427) -> Result<AgentCard, A2AError> {
428 use crate::adapter::error::HttpClientError;
429
430 let client = crate::adapter::error::http_client()?;
431 let base = base_url.trim_end_matches('/');
432 for path in [".well-known/agent-card.json", "agent-card"] {
433 let url = format!("{base}/{path}");
434 let mut request = client.get(&url);
435 if let Some(token) = config.auth_token() {
436 request = request.bearer_auth(token);
437 }
438 if let Some(secs) = config.timeout_secs() {
439 request = request.timeout(std::time::Duration::from_secs(secs));
440 }
441 let resp = request.send().await.map_err(HttpClientError::Reqwest)?;
442 if resp.status().is_success() {
443 return resp
444 .json::<AgentCard>()
445 .await
446 .map_err(|e| A2AError::Internal(format!("Failed to parse agent card JSON: {e}")));
447 }
448 }
449 Err(A2AError::Internal(format!(
450 "Agent card not found at {base_url}"
451 )))
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
461 fn same_origin_compares_scheme_host_and_port_only() {
462 assert!(same_origin(
463 "http://127.0.0.1:8081",
464 "http://127.0.0.1:8081/jsonrpc"
465 ));
466 assert!(same_origin(
467 "https://agent.example",
468 "https://agent.example:443/"
469 ));
470 assert!(!same_origin(
471 "http://127.0.0.1:8081",
472 "http://agent.internal:8081"
473 ));
474 assert!(!same_origin(
475 "http://127.0.0.1:8081",
476 "http://127.0.0.1:8082"
477 ));
478 assert!(!same_origin(
479 "http://127.0.0.1:8081",
480 "https://127.0.0.1:8081"
481 ));
482 assert!(!same_origin("http://127.0.0.1:8081", "not a url"));
483 }
484
485 #[test]
486 fn rejects_v2_interface() {
487 assert!(version_compatible("1.0"));
488 assert!(version_compatible("")); assert!(!version_compatible("2.0"));
490 }
491
492 #[test]
496 fn debug_redacts_the_token() {
497 let rendered = format!("{:?}", ClientConfig::new().with_auth_token("s3cret"));
498 assert!(!rendered.contains("s3cret"), "token leaked: {rendered}");
499 assert!(rendered.contains("redacted"), "{rendered}");
500
501 let rendered = format!("{:?}", ClientConfig::new());
503 assert!(!rendered.contains("redacted"), "{rendered}");
504 }
505
506 #[cfg(feature = "http-client")]
511 #[tokio::test]
512 async fn idn_url_is_an_error_not_a_panic() {
513 assert!(
514 reqwest::Url::parse("http://münchen.de").is_ok(),
515 "premise: the up-front validation accepts this"
516 );
517 match auto_connect("http://münchen.de").await {
518 Err(A2AError::InvalidParams(_)) => {}
519 Err(other) => panic!("wrong error: {other:?}"),
520 Ok(_) => panic!("expected an error for a url http::Uri cannot represent"),
521 }
522 }
523
524 #[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
525 #[tokio::test]
526 async fn auto_connect_rejects_malformed_url() {
527 match auto_connect("not-a-url").await {
529 Err(A2AError::InvalidParams(_)) => {}
530 Err(other) => panic!("wrong error: {other:?}"),
531 Ok(_) => panic!("expected an error for a malformed url"),
532 }
533 }
534
535 #[cfg(feature = "http-client")]
539 #[tokio::test]
540 async fn auto_connect_falls_back_to_direct_connectrpc() {
541 let transport = auto_connect("http://127.0.0.1:1")
542 .await
543 .expect("fallback yields a direct transport");
544 assert_eq!(transport.protocol(), "CONNECTRPC");
545 }
546
547 #[cfg(all(not(feature = "http-client"), feature = "jsonrpc-client"))]
548 #[tokio::test]
549 async fn auto_connect_falls_back_to_direct_jsonrpc() {
550 let transport = auto_connect("http://127.0.0.1:1")
551 .await
552 .expect("fallback yields a direct transport");
553 assert_eq!(transport.protocol(), "JSONRPC");
554 }
555}