protocol_droid/adapters/secondary/a2a/
jsonrpc_peer.rs1use std::sync::Arc;
36
37use serde_json::{Map, Value};
38
39use crate::application::bridge::{BridgeError, BridgePeer, SendFuture};
40
41const DEFAULT_JSONRPC_PATH: &str = "/a2a";
47
48pub struct A2aJsonRpcPeer {
50 base: Arc<str>,
52 jsonrpc_path: Arc<str>,
54 bearer: Option<Arc<str>>,
56 http: Arc<reqwest::Client>,
59}
60
61impl core::fmt::Debug for A2aJsonRpcPeer {
77 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
78 let bearer = if self.bearer.is_some() { "set" } else { "none" };
79 let http = "<reqwest::Client>";
80 formatter
81 .debug_struct("A2aJsonRpcPeer")
82 .field("base", &self.base)
83 .field("jsonrpc_path", &self.jsonrpc_path)
84 .field("bearer", &bearer)
85 .field("http", &http)
86 .finish()
87 }
88}
89
90impl A2aJsonRpcPeer {
91 pub fn new(base: impl Into<String>) -> Result<Self, A2aPeerError> {
99 let http = reqwest::Client::builder()
100 .user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")))
101 .build()
102 .map_err(A2aPeerError::Build)?;
103 Ok(Self {
104 base: Arc::from(base.into()),
105 jsonrpc_path: Arc::from(DEFAULT_JSONRPC_PATH),
106 bearer: None,
107 http: Arc::new(http),
108 })
109 }
110
111 #[must_use]
116 pub fn with_jsonrpc_path(mut self, path: impl Into<String>) -> Self {
117 self.jsonrpc_path = Arc::from(path.into());
118 self
119 }
120
121 #[must_use]
129 pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
130 self.bearer = Some(Arc::from(token.into()));
131 self
132 }
133
134 #[must_use]
136 pub fn base(&self) -> &str {
137 &self.base
138 }
139
140 #[must_use]
142 pub fn jsonrpc_path(&self) -> &str {
143 &self.jsonrpc_path
144 }
145
146 async fn post_envelope(
155 &self,
156 envelope: &Map<String, Value>,
157 ) -> Result<Map<String, Value>, A2aPeerError> {
158 let url = format!("{}{}", self.base, self.jsonrpc_path);
159 let body = serde_json::to_vec(envelope).map_err(A2aPeerError::Encode)?;
160 let mut request = self
161 .http
162 .post(&url)
163 .header(reqwest::header::CONTENT_TYPE, "application/json")
164 .header(reqwest::header::ACCEPT, "application/json")
165 .body(body);
166 if let Some(token) = self.bearer.as_deref() {
167 request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
168 }
169 let response = request.send().await.map_err(A2aPeerError::Transport)?;
170 let status = response.status();
171 let body = response.text().await.map_err(A2aPeerError::Transport)?;
172 if !status.is_success() {
173 return Err(A2aPeerError::Http {
174 status: status.as_u16(),
175 body,
176 });
177 }
178 let parsed: Value = serde_json::from_str(&body).map_err(A2aPeerError::Decode)?;
179 let object = parsed.as_object().ok_or_else(|| {
180 A2aPeerError::Decode(serde_json::Error::io(std::io::Error::other(
181 "the agent's reply was not a JSON object",
182 )))
183 })?;
184 Ok(object.clone())
185 }
186}
187
188impl BridgePeer for A2aJsonRpcPeer {
189 fn send(
190 &self,
191 envelope: Map<String, Value>,
192 ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
193 Box::pin(async move {
194 self.post_envelope(&envelope)
195 .await
196 .map_err(BridgeError::from)
197 })
198 }
199
200 fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
201 Box::pin(async move {
202 let url = format!("{}{}", self.base, self.jsonrpc_path);
209 let body = serde_json::to_vec(&envelope).map_err(|err| BridgeError::Unreachable {
210 reason: format!("encode: {err}"),
211 })?;
212 let mut request = self
213 .http
214 .post(&url)
215 .header(reqwest::header::CONTENT_TYPE, "application/json")
216 .body(body);
217 if let Some(token) = self.bearer.as_deref() {
218 request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
219 }
220 let response = request
221 .send()
222 .await
223 .map_err(|err| BridgeError::Unreachable {
224 reason: format!("transport: {err}"),
225 })?;
226 if response.status().is_success() {
227 Ok(())
228 } else {
229 Err(BridgeError::Unreachable {
230 reason: format!("agent returned {} for a notification", response.status()),
231 })
232 }
233 })
234 }
235
236 fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
237 Box::pin(async { Ok(()) })
244 }
245}
246
247#[derive(Debug, thiserror::Error)]
249pub enum A2aPeerError {
250 #[error("could not build the A2A HTTP client: {0}")]
252 Build(reqwest::Error),
253
254 #[error("could not serialise the request envelope: {0}")]
256 Encode(serde_json::Error),
257
258 #[error("the agent could not be reached: {0}")]
260 Transport(reqwest::Error),
261
262 #[error("the agent returned {status}: {body}")]
264 Http {
265 status: u16,
267 body: String,
269 },
270
271 #[error("the agent's reply could not be read: {0}")]
273 Decode(serde_json::Error),
274}
275
276impl From<A2aPeerError> for BridgeError {
277 fn from(err: A2aPeerError) -> Self {
278 match err {
279 A2aPeerError::Build(_) | A2aPeerError::Transport(_) | A2aPeerError::Http { .. } => {
280 Self::Unreachable {
281 reason: err.to_string(),
282 }
283 }
284 A2aPeerError::Encode(_) | A2aPeerError::Decode(_) => Self::Close(err.to_string()),
285 }
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use std::sync::{Arc, Mutex};
292
293 use serde_json::{Map, Value, json};
294 use tokio::io::{AsyncReadExt, AsyncWriteExt};
295 use tokio::net::TcpListener;
296
297 use super::{super::A2aClient, A2aJsonRpcPeer, BridgeError, BridgePeer};
298
299 #[derive(Debug)]
301 enum Failure {
302 Recv(String),
303 Server(String),
304 Peers(String),
305 Notify(String),
306 Peer(String),
307 }
308
309 impl core::fmt::Display for Failure {
310 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
311 match self {
312 Self::Recv(s)
313 | Self::Server(s)
314 | Self::Peers(s)
315 | Self::Notify(s)
316 | Self::Peer(s) => f.write_str(s),
317 }
318 }
319 }
320
321 impl std::error::Error for Failure {}
322
323 #[derive(Default)]
325 struct ServerState {
326 received: Mutex<Vec<Map<String, Value>>>,
328 reply: Mutex<Option<Map<String, Value>>>,
331 }
332
333 impl ServerState {
334 fn lock_received(
335 &self,
336 ) -> Result<std::sync::MutexGuard<'_, Vec<Map<String, Value>>>, Failure> {
337 self.received
338 .lock()
339 .map_err(|_| Failure::Peers("received mutex poisoned".to_owned()))
340 }
341 }
342
343 async fn start_server() -> Result<(String, Arc<ServerState>), Failure> {
354 let listener = TcpListener::bind("127.0.0.1:0")
355 .await
356 .map_err(|err| Failure::Server(format!("bind: {err}")))?;
357 let addr = listener
358 .local_addr()
359 .map_err(|err| Failure::Server(err.to_string()))?;
360 let state = Arc::new(ServerState::default());
361 let state_for_task = Arc::clone(&state);
362 tokio::spawn(async move {
363 loop {
364 let Ok((mut socket, _)) = listener.accept().await else {
365 return;
366 };
367 let state = Arc::clone(&state_for_task);
368 tokio::spawn(async move {
369 let mut buf = Vec::with_capacity(4096);
370 let mut tmp = [0_u8; 4096];
371 let request = loop {
372 let n = match socket.read(&mut tmp).await {
373 Ok(n) if n > 0 => n,
374 _ => return,
375 };
376 let chunk = tmp.get(..n).unwrap_or(&[]);
377 buf.extend_from_slice(chunk);
378 let Some(idx) = find_header_end(&buf) else {
379 continue;
380 };
381 let Some(header_bytes) = buf.get(..idx) else {
382 continue;
383 };
384 let header = String::from_utf8_lossy(header_bytes).into_owned();
385 let body_len = header
386 .lines()
387 .find_map(|line| {
388 line.to_ascii_lowercase()
389 .strip_prefix("content-length:")
390 .and_then(|value| value.trim().parse::<usize>().ok())
391 })
392 .unwrap_or(0);
393 let start = idx + 4;
394 let end = start + body_len;
395 if buf.len() < end {
396 continue;
397 }
398 let Some(body) = buf.get(start..end) else {
399 continue;
400 };
401 break body.to_vec();
402 };
403 let parsed: Value = match serde_json::from_slice(&request) {
404 Ok(value) => value,
405 Err(_) => return,
406 };
407 let Some(envelope) = parsed.as_object().cloned() else {
408 return;
409 };
410 state
411 .received
412 .lock()
413 .map(|mut guard| guard.push(envelope))
414 .ok();
415 let reply = state
416 .reply
417 .lock()
418 .ok()
419 .and_then(|guard| guard.clone())
420 .unwrap_or_else(|| {
421 let mut out = Map::new();
422 out.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
423 let id = parsed.get("id").cloned().unwrap_or(Value::Null);
424 out.insert("id".to_owned(), id);
425 out.insert("result".to_owned(), json!({}));
426 out
427 });
428 let body = serde_json::to_vec(&reply).unwrap_or_default();
429 let response = format!(
430 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
431 body.len()
432 );
433 let _ = socket.write_all(response.as_bytes()).await;
434 let _ = socket.write_all(&body).await;
435 let _ = socket.shutdown().await;
436 });
437 }
438 });
439 Ok((format!("http://{addr}"), state))
440 }
441
442 fn find_header_end(buf: &[u8]) -> Option<usize> {
443 buf.windows(4).position(|window| window == b"\r\n\r\n")
444 }
445
446 #[tokio::test]
449 async fn send_round_trips_against_a_fake_agent() -> Result<(), Failure> {
450 let (base, state) = start_server().await?;
451 let peer = A2aJsonRpcPeer::new(&base).map_err(|err| Failure::Peer(err.to_string()))?;
452
453 let mut envelope = Map::new();
454 envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
455 envelope.insert("id".to_owned(), Value::Number(1.into()));
456 envelope.insert("method".to_owned(), Value::String("SendMessage".to_owned()));
457 envelope.insert(
458 "params".to_owned(),
459 json!({"message": {"role": "user", "parts": []}}),
460 );
461
462 let reply = peer
463 .send(envelope)
464 .await
465 .map_err(|err| Failure::Recv(err.to_string()))?;
466
467 let id = reply
468 .get("id")
469 .and_then(Value::as_i64)
470 .ok_or_else(|| Failure::Recv("reply had no integer id".to_owned()))?;
471 assert_eq!(id, 1, "the peer must preserve the caller's id");
472
473 let received = state.lock_received()?;
474 let only = received
475 .first()
476 .ok_or_else(|| Failure::Recv("server saw nothing".to_owned()))?;
477 let method = only
478 .get("method")
479 .and_then(Value::as_str)
480 .ok_or_else(|| Failure::Recv("server-side envelope had no method".to_owned()))?;
481 assert_eq!(method, "SendMessage");
482 drop(received);
483 Ok(())
484 }
485
486 #[tokio::test]
490 async fn clones_share_one_http_client() -> Result<(), Failure> {
491 let peer = A2aJsonRpcPeer::new("http://agent.example")
492 .map_err(|err| Failure::Peer(err.to_string()))?;
493 let a = Arc::new(peer);
494 let b = Arc::clone(&a);
495 assert!(Arc::ptr_eq(&a.http, &b.http));
497 Ok(())
498 }
499
500 #[tokio::test]
505 async fn bearer_token_is_attached_and_not_leaked_by_debug() -> Result<(), Failure> {
506 let (base, _state) = start_server().await?;
507 let peer = A2aJsonRpcPeer::new(&base)
508 .map_err(|err| Failure::Peer(err.to_string()))?
509 .with_bearer_token("super-secret-token");
510 let rendered = format!("{peer:?}");
511 assert!(
512 !rendered.contains("super-secret-token"),
513 "Debug output leaked the bearer token: {rendered}"
514 );
515 Ok(())
516 }
517
518 #[tokio::test]
521 async fn notify_is_a_post_with_no_expected_reply() -> Result<(), Failure> {
522 let (base, state) = start_server().await?;
523 let peer = A2aJsonRpcPeer::new(&base).map_err(|err| Failure::Peer(err.to_string()))?;
524
525 let mut envelope = Map::new();
526 envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
527 envelope.insert(
528 "method".to_owned(),
529 Value::String("notifications/initialized".to_owned()),
530 );
531 envelope.insert("params".to_owned(), json!({}));
532
533 peer.notify(envelope)
534 .await
535 .map_err(|err| Failure::Notify(err.to_string()))?;
536
537 let received = state.lock_received()?;
538 let only = received
539 .first()
540 .ok_or_else(|| Failure::Notify("server saw nothing".to_owned()))?;
541 let method = only
542 .get("method")
543 .and_then(Value::as_str)
544 .ok_or_else(|| Failure::Notify("server saw no method".to_owned()))?;
545 assert_eq!(method, "notifications/initialized");
546 drop(received);
547 Ok(())
548 }
549
550 #[tokio::test]
555 async fn an_unreachable_agent_returns_unreachable() -> Result<(), Failure> {
556 let peer = A2aJsonRpcPeer::new("http://127.0.0.1:1")
559 .map_err(|err| Failure::Peer(err.to_string()))?;
560 let mut envelope = Map::new();
561 envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
562 envelope.insert("id".to_owned(), Value::Number(1.into()));
563 envelope.insert("method".to_owned(), Value::String("SendMessage".to_owned()));
564 envelope.insert("params".to_owned(), json!({}));
565
566 let Err(err) = peer.send(envelope).await else {
567 return Err(Failure::Recv(
568 "send to a closed port must be Unreachable, got Ok".to_owned(),
569 ));
570 };
571 match err {
572 BridgeError::Unreachable { .. } => Ok(()),
573 other => Err(Failure::Recv(format!("expected Unreachable, got {other}"))),
574 }
575 }
576
577 #[test]
582 fn the_peer_matches_a2a_client_for_default_path() -> Result<(), Failure> {
583 let peer = A2aJsonRpcPeer::new("http://agent:9000")
584 .map_err(|err| Failure::Peer(err.to_string()))?;
585 let client = A2aClient::new("http://agent:9000")
586 .map_err(|err| Failure::Peer(format!("A2aClient::new: {err}")))?;
587 assert_eq!(peer.jsonrpc_path(), "/a2a");
588 let _ = client;
592 Ok(())
593 }
594}