use async_trait::async_trait;
#[cfg(feature = "http-client")]
use crate::domain::PROTOCOL_BINDING_CONNECTRPC;
#[cfg(feature = "jsonrpc-client")]
use crate::domain::PROTOCOL_BINDING_JSONRPC;
use crate::domain::{A2AError, AgentCard, AgentInterface};
use crate::port::Transport;
#[derive(Clone, Default)]
pub struct ClientConfig {
auth_token: Option<String>,
timeout_secs: Option<u64>,
}
impl std::fmt::Debug for ClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientConfig")
.field(
"auth_token",
match &self.auth_token {
Some(_) => &"<redacted>",
None => &"None",
},
)
.field("timeout_secs", &self.timeout_secs)
.finish()
}
}
impl ClientConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
self.auth_token = Some(token.into());
self
}
pub fn with_timeout(mut self, secs: u64) -> Self {
self.timeout_secs = Some(secs);
self
}
pub fn auth_token(&self) -> Option<&str> {
self.auth_token.as_deref()
}
pub fn timeout_secs(&self) -> Option<u64> {
self.timeout_secs
}
}
#[async_trait]
pub trait TransportFactory: Send + Sync {
fn protocol(&self) -> &str;
async fn create(
&self,
card: &AgentCard,
iface: &AgentInterface,
config: &ClientConfig,
) -> Result<Box<dyn Transport>, A2AError>;
}
#[cfg(feature = "jsonrpc-client")]
fn jsonrpc_client(url: String, config: &ClientConfig) -> super::jsonrpc_client::JsonRpcClient {
use super::jsonrpc_client::JsonRpcClient;
let mut client = match config.auth_token() {
Some(token) => JsonRpcClient::with_auth(url, token.to_string()),
None => JsonRpcClient::new(url),
};
if let Some(secs) = config.timeout_secs() {
client = client.with_timeout(secs);
}
client
}
#[cfg(feature = "http-client")]
fn connect_rpc_client(
url: String,
config: &ClientConfig,
) -> Result<super::http::HttpClient, A2AError> {
use super::http::HttpClient;
let mut client = match config.auth_token() {
Some(token) => HttpClient::try_with_auth(url, token.to_string())?,
None => HttpClient::try_new(url)?,
};
if let Some(secs) = config.timeout_secs() {
client = client.with_timeout(secs);
}
Ok(client)
}
#[cfg(feature = "jsonrpc-client")]
pub struct JsonRpcTransportFactory;
#[cfg(feature = "jsonrpc-client")]
#[async_trait]
impl TransportFactory for JsonRpcTransportFactory {
fn protocol(&self) -> &str {
PROTOCOL_BINDING_JSONRPC
}
async fn create(
&self,
_card: &AgentCard,
iface: &AgentInterface,
config: &ClientConfig,
) -> Result<Box<dyn Transport>, A2AError> {
Ok(Box::new(jsonrpc_client(iface.url.clone(), config)))
}
}
#[cfg(feature = "http-client")]
pub struct ConnectRpcTransportFactory;
#[cfg(feature = "http-client")]
#[async_trait]
impl TransportFactory for ConnectRpcTransportFactory {
fn protocol(&self) -> &str {
PROTOCOL_BINDING_CONNECTRPC
}
async fn create(
&self,
_card: &AgentCard,
iface: &AgentInterface,
config: &ClientConfig,
) -> Result<Box<dyn Transport>, A2AError> {
Ok(Box::new(connect_rpc_client(iface.url.clone(), config)?))
}
}
#[derive(Default)]
pub struct TransportNegotiator {
factories: Vec<Box<dyn TransportFactory>>,
}
impl TransportNegotiator {
pub fn new() -> Self {
Self::default()
}
pub fn with(mut self, factory: impl TransportFactory + 'static) -> Self {
self.factories.push(Box::new(factory));
self
}
pub fn supported(&self) -> impl Iterator<Item = &str> {
self.factories.iter().map(|f| f.protocol())
}
pub async fn negotiate(&self, card: &AgentCard) -> Result<Box<dyn Transport>, A2AError> {
self.negotiate_with(card, &ClientConfig::default()).await
}
pub async fn negotiate_with(
&self,
card: &AgentCard,
config: &ClientConfig,
) -> Result<Box<dyn Transport>, A2AError> {
for factory in &self.factories {
for iface in &card.supported_interfaces {
if iface.protocol_binding == factory.protocol()
&& version_compatible(&iface.protocol_version)
{
match factory.create(card, iface, config).await {
Ok(transport) => return Ok(transport),
Err(_err) => continue,
}
}
}
}
Err(A2AError::UnsupportedOperation(format!(
"no compatible transport: client supports [{}], card offers [{}]",
self.supported().collect::<Vec<_>>().join(", "),
card.supported_interfaces
.iter()
.map(|i| i.protocol_binding.as_str())
.collect::<Vec<_>>()
.join(", "),
)))
}
}
fn version_compatible(version: &str) -> bool {
version.is_empty() || version.split('.').next() == Some("1")
}
pub fn default_registry() -> TransportNegotiator {
#[allow(unused_mut)]
let mut negotiator = TransportNegotiator::new();
#[cfg(feature = "http-client")]
{
negotiator = negotiator.with(ConnectRpcTransportFactory);
}
#[cfg(feature = "jsonrpc-client")]
{
negotiator = negotiator.with(JsonRpcTransportFactory);
}
negotiator
}
#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
pub async fn connect(
base_url: &str,
negotiator: &TransportNegotiator,
) -> Result<Box<dyn Transport>, A2AError> {
connect_with(base_url, negotiator, &ClientConfig::default()).await
}
#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
pub async fn connect_with(
base_url: &str,
negotiator: &TransportNegotiator,
config: &ClientConfig,
) -> Result<Box<dyn Transport>, A2AError> {
let card = fetch_agent_card_with(base_url, config).await?;
negotiator.negotiate_with(&card, config).await
}
#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
pub async fn auto_connect(base_url: &str) -> Result<Box<dyn Transport>, A2AError> {
auto_connect_with(base_url, &ClientConfig::default()).await
}
#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
pub async fn auto_connect_with(
base_url: &str,
config: &ClientConfig,
) -> Result<Box<dyn Transport>, A2AError> {
reqwest::Url::parse(base_url)
.map_err(|e| A2AError::InvalidParams(format!("invalid url {base_url}: {e}")))?;
match connect_with(base_url, &default_registry(), config).await {
Ok(transport) => Ok(transport),
Err(_) => direct_transport(base_url, config),
}
}
#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
fn direct_transport(base_url: &str, config: &ClientConfig) -> Result<Box<dyn Transport>, A2AError> {
#[cfg(feature = "http-client")]
{
Ok(Box::new(connect_rpc_client(base_url.to_string(), config)?))
}
#[cfg(all(not(feature = "http-client"), feature = "jsonrpc-client"))]
{
Ok(Box::new(jsonrpc_client(base_url.to_string(), config)))
}
}
#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
pub async fn fetch_agent_card(base_url: &str) -> Result<AgentCard, A2AError> {
fetch_agent_card_with(base_url, &ClientConfig::default()).await
}
#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
pub async fn fetch_agent_card_with(
base_url: &str,
config: &ClientConfig,
) -> Result<AgentCard, A2AError> {
use crate::adapter::error::HttpClientError;
let client = reqwest::Client::new();
let base = base_url.trim_end_matches('/');
for path in [".well-known/agent-card.json", "agent-card"] {
let url = format!("{base}/{path}");
let mut request = client.get(&url);
if let Some(token) = config.auth_token() {
request = request.bearer_auth(token);
}
if let Some(secs) = config.timeout_secs() {
request = request.timeout(std::time::Duration::from_secs(secs));
}
let resp = request.send().await.map_err(HttpClientError::Reqwest)?;
if resp.status().is_success() {
return resp
.json::<AgentCard>()
.await
.map_err(|e| A2AError::Internal(format!("Failed to parse agent card JSON: {e}")));
}
}
Err(A2AError::Internal(format!(
"Agent card not found at {base_url}"
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_v2_interface() {
assert!(version_compatible("1.0"));
assert!(version_compatible("")); assert!(!version_compatible("2.0"));
}
#[test]
fn debug_redacts_the_token() {
let rendered = format!("{:?}", ClientConfig::new().with_auth_token("s3cret"));
assert!(!rendered.contains("s3cret"), "token leaked: {rendered}");
assert!(rendered.contains("redacted"), "{rendered}");
let rendered = format!("{:?}", ClientConfig::new());
assert!(!rendered.contains("redacted"), "{rendered}");
}
#[cfg(feature = "http-client")]
#[tokio::test]
async fn idn_url_is_an_error_not_a_panic() {
assert!(
reqwest::Url::parse("http://münchen.de").is_ok(),
"premise: the up-front validation accepts this"
);
match auto_connect("http://münchen.de").await {
Err(A2AError::InvalidParams(_)) => {}
Err(other) => panic!("wrong error: {other:?}"),
Ok(_) => panic!("expected an error for a url http::Uri cannot represent"),
}
}
#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
#[tokio::test]
async fn auto_connect_rejects_malformed_url() {
match auto_connect("not-a-url").await {
Err(A2AError::InvalidParams(_)) => {}
Err(other) => panic!("wrong error: {other:?}"),
Ok(_) => panic!("expected an error for a malformed url"),
}
}
#[cfg(feature = "http-client")]
#[tokio::test]
async fn auto_connect_falls_back_to_direct_connectrpc() {
let transport = auto_connect("http://127.0.0.1:1")
.await
.expect("fallback yields a direct transport");
assert_eq!(transport.protocol(), "CONNECTRPC");
}
#[cfg(all(not(feature = "http-client"), feature = "jsonrpc-client"))]
#[tokio::test]
async fn auto_connect_falls_back_to_direct_jsonrpc() {
let transport = auto_connect("http://127.0.0.1:1")
.await
.expect("fallback yields a direct transport");
assert_eq!(transport.protocol(), "JSONRPC");
}
}