use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use reqwest::Client as HttpClient;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::adapters::secondary::a2a::A2aMethod;
use crate::domain::a2a::agent_card::AgentCard;
use crate::domain::a2a::extended_agent_card::{
GetExtendedAgentCardRequest, GetExtendedAgentCardResponse,
};
use crate::domain::a2a::list_tasks::{ListTasksRequest, ListTasksResponse};
use crate::domain::a2a::push_notification_methods::{
CreateTaskPushNotificationConfigRequest, CreateTaskPushNotificationConfigResponse,
DeleteTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigRequest,
GetTaskPushNotificationConfigResponse, ListTaskPushNotificationConfigsRequest,
ListTaskPushNotificationConfigsResponse,
};
use crate::domain::a2a::send_message::{SendMessageRequest, SendMessageResponse};
use crate::domain::a2a::streaming_events::StreamingEvent;
use crate::domain::a2a::task_lifecycle::{
CancelTaskRequest, CancelTaskResponse, GetTaskRequest, GetTaskResponse,
};
use crate::domain::a2a::well_known::AGENT_CARD_WELL_KNOWN_PATH;
#[derive(Debug, thiserror::Error)]
pub enum A2aClientError {
#[error("the peer could not be reached: {0}")]
Io(#[from] reqwest::Error),
#[error("the peer answered with a JSON-RPC error {code}: {message}")]
JsonRpc {
code: i64,
message: String,
},
#[error("the response could not be parsed: {0}")]
Parse(#[from] serde_json::Error),
#[error("the SSE stream ended: {0}")]
StreamEnded(String),
}
#[derive(Debug)]
pub struct A2aClient {
base: Arc<str>,
card_path: Arc<str>,
jsonrpc_path: Arc<str>,
streaming_path: Arc<str>,
bearer: Option<Arc<str>>,
http: HttpClient,
next_id: Arc<AtomicI64>,
}
impl Clone for A2aClient {
fn clone(&self) -> Self {
Self {
base: Arc::clone(&self.base),
card_path: Arc::clone(&self.card_path),
jsonrpc_path: Arc::clone(&self.jsonrpc_path),
streaming_path: Arc::clone(&self.streaming_path),
bearer: self.bearer.as_ref().map(Arc::clone),
http: self.http.clone(),
next_id: Arc::clone(&self.next_id),
}
}
}
impl A2aClient {
pub fn new(peer: impl Into<String>) -> Result<Self, A2aClientError> {
let http = HttpClient::builder()
.user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")))
.build()?;
Ok(Self {
base: Arc::from(peer.into()),
card_path: Arc::from(AGENT_CARD_WELL_KNOWN_PATH),
jsonrpc_path: Arc::from("/a2a"),
streaming_path: Arc::from("/a2a/stream"),
bearer: None,
http,
next_id: Arc::new(AtomicI64::new(1)),
})
}
pub fn with_paths(
peer: impl Into<String>,
card_path: impl Into<String>,
jsonrpc_path: impl Into<String>,
streaming_path: impl Into<String>,
) -> Result<Self, A2aClientError> {
let http = HttpClient::builder()
.user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")))
.build()?;
Ok(Self {
base: Arc::from(peer.into()),
card_path: Arc::from(card_path.into()),
jsonrpc_path: Arc::from(jsonrpc_path.into()),
streaming_path: Arc::from(streaming_path.into()),
bearer: None,
http,
next_id: Arc::new(AtomicI64::new(1)),
})
}
#[must_use]
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.bearer = Some(Arc::from(token.into()));
self
}
pub async fn card(&self) -> Result<AgentCard, A2aClientError> {
let url = format!("{}{}", self.base, self.card_path);
let mut request = self
.http
.get(&url)
.header(reqwest::header::ACCEPT, "application/json");
if let Some(token) = self.bearer.as_deref() {
request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
}
let response = request.send().await?.error_for_status()?;
let body = response.text().await?;
let card: AgentCard = serde_json::from_str(&body)?;
Ok(card)
}
pub async fn send_message(
&self,
request: SendMessageRequest,
) -> Result<SendMessageResponse, A2aClientError> {
let params = serde_json::to_value(&request)?;
self.post_json_rpc(A2aMethod::SendMessage, params).await
}
pub async fn get_task(
&self,
request: GetTaskRequest,
) -> Result<GetTaskResponse, A2aClientError> {
let params = serde_json::to_value(&request)?;
self.post_json_rpc(A2aMethod::GetTask, params).await
}
pub async fn list_tasks(
&self,
request: ListTasksRequest,
) -> Result<ListTasksResponse, A2aClientError> {
let params = serde_json::to_value(&request)?;
self.post_json_rpc(A2aMethod::ListTasks, params).await
}
pub async fn cancel_task(
&self,
request: CancelTaskRequest,
) -> Result<CancelTaskResponse, A2aClientError> {
let params = serde_json::to_value(&request)?;
self.post_json_rpc(A2aMethod::CancelTask, params).await
}
pub async fn send_streaming_message(
&self,
request: SendMessageRequest,
) -> Result<Vec<StreamingEvent>, A2aClientError> {
let params = serde_json::to_value(&request)?;
self.post_sse_collect(A2aMethod::SendStreamingMessage, params)
.await
}
pub async fn subscribe_to_task(
&self,
task_id: String,
) -> Result<Vec<StreamingEvent>, A2aClientError> {
let params = json!({ "id": task_id });
self.post_sse_collect(A2aMethod::SubscribeToTask, params)
.await
}
pub async fn create_push_notification_config(
&self,
request: CreateTaskPushNotificationConfigRequest,
) -> Result<CreateTaskPushNotificationConfigResponse, A2aClientError> {
let params = serde_json::to_value(&request)?;
self.post_json_rpc(A2aMethod::CreateTaskPushNotificationConfig, params)
.await
}
pub async fn get_push_notification_config(
&self,
request: GetTaskPushNotificationConfigRequest,
) -> Result<GetTaskPushNotificationConfigResponse, A2aClientError> {
let params = serde_json::to_value(&request)?;
self.post_json_rpc(A2aMethod::GetTaskPushNotificationConfig, params)
.await
}
pub async fn list_push_notification_configs(
&self,
request: ListTaskPushNotificationConfigsRequest,
) -> Result<ListTaskPushNotificationConfigsResponse, A2aClientError> {
let params = serde_json::to_value(&request)?;
self.post_json_rpc(A2aMethod::ListTaskPushNotificationConfigs, params)
.await
}
pub async fn delete_push_notification_config(
&self,
request: DeleteTaskPushNotificationConfigRequest,
) -> Result<(), A2aClientError> {
let params = serde_json::to_value(&request)?;
let _: Value = self
.post_json_rpc(A2aMethod::DeleteTaskPushNotificationConfig, params)
.await?;
Ok(())
}
pub async fn get_extended_agent_card(
&self,
request: GetExtendedAgentCardRequest,
) -> Result<GetExtendedAgentCardResponse, A2aClientError> {
let params = serde_json::to_value(&request)?;
self.post_json_rpc(A2aMethod::GetExtendedAgentCard, params)
.await
}
async fn post_json_rpc<T: DeserializeOwned>(
&self,
method: A2aMethod,
params: Value,
) -> Result<T, A2aClientError> {
let url = format!("{}{}", self.base, self.jsonrpc_path);
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let envelope = json!({
"jsonrpc": "2.0",
"id": id,
"method": method.as_str(),
"params": params,
});
let body = serde_json::to_vec(&envelope)?;
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?.error_for_status()?;
let body = response.text().await?;
let envelope: Value = serde_json::from_str(&body)?;
Self::extract_result(&envelope)
}
async fn post_sse_collect(
&self,
method: A2aMethod,
params: Value,
) -> Result<Vec<StreamingEvent>, A2aClientError> {
let url = format!("{}{}", self.base, self.streaming_path);
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let envelope = json!({
"jsonrpc": "2.0",
"id": id,
"method": method.as_str(),
"params": params,
});
let body = serde_json::to_vec(&envelope)?;
let mut request = self
.http
.post(&url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "text/event-stream")
.body(body);
if let Some(token) = self.bearer.as_deref() {
request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
}
let response = request.send().await?.error_for_status()?;
let body = response.text().await?;
Self::parse_sse_blocks(&body)
}
fn extract_result<T: DeserializeOwned>(envelope: &Value) -> Result<T, A2aClientError> {
if let Some(error) = envelope.get("error") {
let code = error.get("code").and_then(Value::as_i64).unwrap_or(0);
let message = error
.get("message")
.and_then(Value::as_str)
.unwrap_or("unspecified")
.to_owned();
return Err(A2aClientError::JsonRpc { code, message });
}
let Some(result) = envelope.get("result") else {
return Err(A2aClientError::Parse(serde_json::Error::io(
std::io::Error::other("missing `result` member in the JSON-RPC envelope"),
)));
};
Ok(serde_json::from_value(result.clone())?)
}
fn parse_sse_blocks(body: &str) -> Result<Vec<StreamingEvent>, A2aClientError> {
let mut events = Vec::new();
for block in body.split("\n\n") {
for line in block.lines() {
let Some(data) = line.strip_prefix("data: ") else {
continue;
};
let envelope: Value = match serde_json::from_str(data) {
Ok(value) => value,
Err(error) => {
return Err(A2aClientError::StreamEnded(format!(
"a `data:` line was not well-formed JSON: {error}"
)));
}
};
let event: StreamingEvent = Self::extract_result(&envelope)?;
events.push(event);
}
}
Ok(events)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_envelope() -> Value {
json!({ "jsonrpc": "2.0" })
}
#[test]
fn extract_result_pulls_typed_value() {
let envelope = json!({
"jsonrpc": "2.0",
"id": 1,
"result": { "name": "alpha" }
});
let value: Result<serde_json::Value, A2aClientError> = A2aClient::extract_result(&envelope);
match value {
Ok(value) => assert_eq!(value, json!({ "name": "alpha" })),
Err(error) => unreachable!("a `result` member produces a typed value, got {error}"),
}
}
#[test]
fn extract_result_promotes_error_member_to_typed_error() {
let envelope = json!({
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32601, "message": "Method not found" }
});
match A2aClient::extract_result::<serde_json::Value>(&envelope) {
Ok(value) => {
unreachable!("the error member surfaces as `JsonRpc`, got Ok({value})")
}
Err(A2aClientError::JsonRpc { code, message }) => {
assert_eq!(code, -32601);
assert_eq!(message, "Method not found");
}
Err(other) => unreachable!("the error member surfaces as `JsonRpc`, got {other}"),
}
}
#[test]
fn extract_result_without_result_is_a_parse_error() {
match A2aClient::extract_result::<serde_json::Value>(&empty_envelope()) {
Ok(value) => {
unreachable!("an envelope without `result` is a parse error, got Ok({value})")
}
Err(A2aClientError::Parse(_)) => {}
Err(other) => {
unreachable!("an envelope without `result` is a parse error, got {other}")
}
}
}
#[test]
fn parse_sse_blocks_skips_lines_without_data_prefix() {
let body = "event: ping\n\n: heartbeat\n\n";
match A2aClient::parse_sse_blocks(body) {
Ok(events) => assert!(events.is_empty()),
Err(error) => {
unreachable!("lines without `data:` are skipped, not errors, got {error}")
}
}
}
#[test]
fn with_bearer_token_is_carried_on_a_clone() -> Result<(), A2aClientError> {
let original = A2aClient::new("http://peer")?.with_bearer_token("secret");
let cloned = original.clone();
assert_eq!(cloned.card_path, original.card_path);
Ok(())
}
#[test]
fn without_bearer_token_the_authorization_header_is_absent() -> Result<(), A2aClientError> {
let client = A2aClient::new("http://peer")?;
match &client.bearer {
None => {}
Some(_) => unreachable!("a fresh `A2aClient::new` has no bearer token"),
}
Ok(())
}
#[test]
fn get_extended_agent_card_request_serialises_as_empty_object()
-> Result<(), Box<dyn std::error::Error>> {
let request = GetExtendedAgentCardRequest::new();
let value = serde_json::to_value(&request)?;
let object = value
.as_object()
.ok_or("the extended-card request must serialise to a JSON object")?;
if !object.is_empty() {
return Err(format!("expected `{{}}`, got {value}").into());
}
Ok(())
}
}