use std::sync::Arc;
use serde_json::{Map, Value};
use crate::application::bridge::{BridgeError, BridgePeer, SendFuture};
const DEFAULT_JSONRPC_PATH: &str = "/a2a";
pub struct A2aJsonRpcPeer {
base: Arc<str>,
jsonrpc_path: Arc<str>,
bearer: Option<Arc<str>>,
http: Arc<reqwest::Client>,
}
impl core::fmt::Debug for A2aJsonRpcPeer {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let bearer = if self.bearer.is_some() { "set" } else { "none" };
let http = "<reqwest::Client>";
formatter
.debug_struct("A2aJsonRpcPeer")
.field("base", &self.base)
.field("jsonrpc_path", &self.jsonrpc_path)
.field("bearer", &bearer)
.field("http", &http)
.finish()
}
}
impl A2aJsonRpcPeer {
pub fn new(base: impl Into<String>) -> Result<Self, A2aPeerError> {
let http = reqwest::Client::builder()
.user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(A2aPeerError::Build)?;
Ok(Self {
base: Arc::from(base.into()),
jsonrpc_path: Arc::from(DEFAULT_JSONRPC_PATH),
bearer: None,
http: Arc::new(http),
})
}
#[must_use]
pub fn with_jsonrpc_path(mut self, path: impl Into<String>) -> Self {
self.jsonrpc_path = Arc::from(path.into());
self
}
#[must_use]
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.bearer = Some(Arc::from(token.into()));
self
}
#[must_use]
pub fn base(&self) -> &str {
&self.base
}
#[must_use]
pub fn jsonrpc_path(&self) -> &str {
&self.jsonrpc_path
}
async fn post_envelope(
&self,
envelope: &Map<String, Value>,
) -> Result<Map<String, Value>, A2aPeerError> {
let url = format!("{}{}", self.base, self.jsonrpc_path);
let body = serde_json::to_vec(envelope).map_err(A2aPeerError::Encode)?;
let mut request = self
.http
.post(&url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json")
.body(body);
if let Some(token) = self.bearer.as_deref() {
request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
}
let response = request.send().await.map_err(A2aPeerError::Transport)?;
let status = response.status();
let body = response.text().await.map_err(A2aPeerError::Transport)?;
if !status.is_success() {
return Err(A2aPeerError::Http {
status: status.as_u16(),
body,
});
}
let parsed: Value = serde_json::from_str(&body).map_err(A2aPeerError::Decode)?;
let object = parsed.as_object().ok_or_else(|| {
A2aPeerError::Decode(serde_json::Error::io(std::io::Error::other(
"the agent's reply was not a JSON object",
)))
})?;
Ok(object.clone())
}
}
impl BridgePeer for A2aJsonRpcPeer {
fn send(
&self,
envelope: Map<String, Value>,
) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
Box::pin(async move {
self.post_envelope(&envelope)
.await
.map_err(BridgeError::from)
})
}
fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
Box::pin(async move {
let url = format!("{}{}", self.base, self.jsonrpc_path);
let body = serde_json::to_vec(&envelope).map_err(|err| BridgeError::Unreachable {
reason: format!("encode: {err}"),
})?;
let mut request = self
.http
.post(&url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
if let Some(token) = self.bearer.as_deref() {
request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
}
let response = request
.send()
.await
.map_err(|err| BridgeError::Unreachable {
reason: format!("transport: {err}"),
})?;
if response.status().is_success() {
Ok(())
} else {
Err(BridgeError::Unreachable {
reason: format!("agent returned {} for a notification", response.status()),
})
}
})
}
fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
Box::pin(async { Ok(()) })
}
}
#[derive(Debug, thiserror::Error)]
pub enum A2aPeerError {
#[error("could not build the A2A HTTP client: {0}")]
Build(reqwest::Error),
#[error("could not serialise the request envelope: {0}")]
Encode(serde_json::Error),
#[error("the agent could not be reached: {0}")]
Transport(reqwest::Error),
#[error("the agent returned {status}: {body}")]
Http {
status: u16,
body: String,
},
#[error("the agent's reply could not be read: {0}")]
Decode(serde_json::Error),
}
impl From<A2aPeerError> for BridgeError {
fn from(err: A2aPeerError) -> Self {
match err {
A2aPeerError::Build(_) | A2aPeerError::Transport(_) | A2aPeerError::Http { .. } => {
Self::Unreachable {
reason: err.to_string(),
}
}
A2aPeerError::Encode(_) | A2aPeerError::Decode(_) => Self::Close(err.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use serde_json::{Map, Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use super::{super::A2aClient, A2aJsonRpcPeer, BridgeError, BridgePeer};
#[derive(Debug)]
enum Failure {
Recv(String),
Server(String),
Peers(String),
Notify(String),
Peer(String),
}
impl core::fmt::Display for Failure {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Recv(s)
| Self::Server(s)
| Self::Peers(s)
| Self::Notify(s)
| Self::Peer(s) => f.write_str(s),
}
}
}
impl std::error::Error for Failure {}
#[derive(Default)]
struct ServerState {
received: Mutex<Vec<Map<String, Value>>>,
reply: Mutex<Option<Map<String, Value>>>,
}
impl ServerState {
fn lock_received(
&self,
) -> Result<std::sync::MutexGuard<'_, Vec<Map<String, Value>>>, Failure> {
self.received
.lock()
.map_err(|_| Failure::Peers("received mutex poisoned".to_owned()))
}
}
async fn start_server() -> Result<(String, Arc<ServerState>), Failure> {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|err| Failure::Server(format!("bind: {err}")))?;
let addr = listener
.local_addr()
.map_err(|err| Failure::Server(err.to_string()))?;
let state = Arc::new(ServerState::default());
let state_for_task = Arc::clone(&state);
tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
let state = Arc::clone(&state_for_task);
tokio::spawn(async move {
let mut buf = Vec::with_capacity(4096);
let mut tmp = [0_u8; 4096];
let request = loop {
let n = match socket.read(&mut tmp).await {
Ok(n) if n > 0 => n,
_ => return,
};
let chunk = tmp.get(..n).unwrap_or(&[]);
buf.extend_from_slice(chunk);
let Some(idx) = find_header_end(&buf) else {
continue;
};
let Some(header_bytes) = buf.get(..idx) else {
continue;
};
let header = String::from_utf8_lossy(header_bytes).into_owned();
let body_len = header
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length:")
.and_then(|value| value.trim().parse::<usize>().ok())
})
.unwrap_or(0);
let start = idx + 4;
let end = start + body_len;
if buf.len() < end {
continue;
}
let Some(body) = buf.get(start..end) else {
continue;
};
break body.to_vec();
};
let parsed: Value = match serde_json::from_slice(&request) {
Ok(value) => value,
Err(_) => return,
};
let Some(envelope) = parsed.as_object().cloned() else {
return;
};
state
.received
.lock()
.map(|mut guard| guard.push(envelope))
.ok();
let reply = state
.reply
.lock()
.ok()
.and_then(|guard| guard.clone())
.unwrap_or_else(|| {
let mut out = Map::new();
out.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
let id = parsed.get("id").cloned().unwrap_or(Value::Null);
out.insert("id".to_owned(), id);
out.insert("result".to_owned(), json!({}));
out
});
let body = serde_json::to_vec(&reply).unwrap_or_default();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.write_all(&body).await;
let _ = socket.shutdown().await;
});
}
});
Ok((format!("http://{addr}"), state))
}
fn find_header_end(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|window| window == b"\r\n\r\n")
}
#[tokio::test]
async fn send_round_trips_against_a_fake_agent() -> Result<(), Failure> {
let (base, state) = start_server().await?;
let peer = A2aJsonRpcPeer::new(&base).map_err(|err| Failure::Peer(err.to_string()))?;
let mut envelope = Map::new();
envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
envelope.insert("id".to_owned(), Value::Number(1.into()));
envelope.insert("method".to_owned(), Value::String("SendMessage".to_owned()));
envelope.insert(
"params".to_owned(),
json!({"message": {"role": "user", "parts": []}}),
);
let reply = peer
.send(envelope)
.await
.map_err(|err| Failure::Recv(err.to_string()))?;
let id = reply
.get("id")
.and_then(Value::as_i64)
.ok_or_else(|| Failure::Recv("reply had no integer id".to_owned()))?;
assert_eq!(id, 1, "the peer must preserve the caller's id");
let received = state.lock_received()?;
let only = received
.first()
.ok_or_else(|| Failure::Recv("server saw nothing".to_owned()))?;
let method = only
.get("method")
.and_then(Value::as_str)
.ok_or_else(|| Failure::Recv("server-side envelope had no method".to_owned()))?;
assert_eq!(method, "SendMessage");
drop(received);
Ok(())
}
#[tokio::test]
async fn clones_share_one_http_client() -> Result<(), Failure> {
let peer = A2aJsonRpcPeer::new("http://agent.example")
.map_err(|err| Failure::Peer(err.to_string()))?;
let a = Arc::new(peer);
let b = Arc::clone(&a);
assert!(Arc::ptr_eq(&a.http, &b.http));
Ok(())
}
#[tokio::test]
async fn bearer_token_is_attached_and_not_leaked_by_debug() -> Result<(), Failure> {
let (base, _state) = start_server().await?;
let peer = A2aJsonRpcPeer::new(&base)
.map_err(|err| Failure::Peer(err.to_string()))?
.with_bearer_token("super-secret-token");
let rendered = format!("{peer:?}");
assert!(
!rendered.contains("super-secret-token"),
"Debug output leaked the bearer token: {rendered}"
);
Ok(())
}
#[tokio::test]
async fn notify_is_a_post_with_no_expected_reply() -> Result<(), Failure> {
let (base, state) = start_server().await?;
let peer = A2aJsonRpcPeer::new(&base).map_err(|err| Failure::Peer(err.to_string()))?;
let mut envelope = Map::new();
envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
envelope.insert(
"method".to_owned(),
Value::String("notifications/initialized".to_owned()),
);
envelope.insert("params".to_owned(), json!({}));
peer.notify(envelope)
.await
.map_err(|err| Failure::Notify(err.to_string()))?;
let received = state.lock_received()?;
let only = received
.first()
.ok_or_else(|| Failure::Notify("server saw nothing".to_owned()))?;
let method = only
.get("method")
.and_then(Value::as_str)
.ok_or_else(|| Failure::Notify("server saw no method".to_owned()))?;
assert_eq!(method, "notifications/initialized");
drop(received);
Ok(())
}
#[tokio::test]
async fn an_unreachable_agent_returns_unreachable() -> Result<(), Failure> {
let peer = A2aJsonRpcPeer::new("http://127.0.0.1:1")
.map_err(|err| Failure::Peer(err.to_string()))?;
let mut envelope = Map::new();
envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
envelope.insert("id".to_owned(), Value::Number(1.into()));
envelope.insert("method".to_owned(), Value::String("SendMessage".to_owned()));
envelope.insert("params".to_owned(), json!({}));
let Err(err) = peer.send(envelope).await else {
return Err(Failure::Recv(
"send to a closed port must be Unreachable, got Ok".to_owned(),
));
};
match err {
BridgeError::Unreachable { .. } => Ok(()),
other => Err(Failure::Recv(format!("expected Unreachable, got {other}"))),
}
}
#[test]
fn the_peer_matches_a2a_client_for_default_path() -> Result<(), Failure> {
let peer = A2aJsonRpcPeer::new("http://agent:9000")
.map_err(|err| Failure::Peer(err.to_string()))?;
let client = A2aClient::new("http://agent:9000")
.map_err(|err| Failure::Peer(format!("A2aClient::new: {err}")))?;
assert_eq!(peer.jsonrpc_path(), "/a2a");
let _ = client;
Ok(())
}
}