use a2a::A2AError;
use reqwest::header::{HeaderMap, HeaderName};
pub(crate) const TARGET: &str = "a2a_client::wire";
const REDACTED: &str = "(redacted)";
const VISIBLE_HEADERS: &[&str] = &[
"a2a-version",
"accept",
"accept-encoding",
"content-length",
"content-type",
"host",
"user-agent",
];
fn is_visible(name: &HeaderName) -> bool {
VISIBLE_HEADERS.contains(&name.as_str())
}
fn render_headers(headers: &HeaderMap) -> String {
let mut rendered: Vec<String> = headers
.iter()
.map(|(name, value)| {
if is_visible(name) {
let shown = value.to_str().unwrap_or("(binary)");
format!("{name}: {shown}")
} else {
format!("{name}: {REDACTED}")
}
})
.collect();
rendered.sort();
rendered.join(", ")
}
fn render_body(body: &[u8]) -> String {
match std::str::from_utf8(body) {
Ok(text) => text.to_string(),
Err(_) => format!("({} non-UTF-8 bytes)", body.len()),
}
}
pub(crate) async fn send(
client: &reqwest::Client,
builder: reqwest::RequestBuilder,
) -> Result<reqwest::Response, A2AError> {
let request = builder
.build()
.map_err(|e| A2AError::internal(format!("HTTP request failed: {e}")))?;
if tracing::enabled!(target: TARGET, tracing::Level::DEBUG) {
let method = request.method();
let url = request.url();
let headers = render_headers(request.headers());
let body = request
.body()
.and_then(reqwest::Body::as_bytes)
.map(render_body)
.unwrap_or_default();
tracing::debug!(target: TARGET, %method, %url, %headers, %body, "A2A wire request");
}
client
.execute(request)
.await
.map_err(|e| A2AError::internal(format!("HTTP request failed: {e}")))
}
pub(crate) async fn response_text(resp: reqwest::Response) -> Result<String, reqwest::Error> {
let status = resp.status();
let text = resp.text().await?;
log_response(status, &text);
Ok(text)
}
pub(crate) fn log_response(status: reqwest::StatusCode, body: &str) {
let status = status.as_u16();
tracing::debug!(target: TARGET, %status, %body, "A2A wire response");
}
pub(crate) fn log_stream_event(event_text: &str) {
tracing::debug!(target: TARGET, event = %event_text, "A2A wire stream event");
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::{HeaderValue, InvalidHeaderValue};
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.insert(
HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(value).unwrap(),
);
}
map
}
#[test]
fn credential_headers_are_redacted_by_value_not_by_presence() {
let rendered = render_headers(&headers(&[
("authorization", "Bearer super-secret"),
("x-api-key", "key-12345"),
]));
assert!(rendered.contains("authorization:"), "{rendered}");
assert!(rendered.contains("x-api-key:"), "{rendered}");
assert!(!rendered.contains("super-secret"), "{rendered}");
assert!(!rendered.contains("key-12345"), "{rendered}");
}
#[test]
fn protocol_headers_stay_visible() {
let rendered = render_headers(&headers(&[
("a2a-version", "1.0"),
("content-type", "application/json"),
("accept", "text/event-stream"),
]));
assert!(rendered.contains("a2a-version: 1.0"), "{rendered}");
assert!(
rendered.contains("content-type: application/json"),
"{rendered}"
);
assert!(rendered.contains("accept: text/event-stream"), "{rendered}");
}
#[test]
fn an_unrecognised_header_is_redacted_rather_than_leaked() {
let rendered = render_headers(&headers(&[("x-some-future-scheme", "tenant-secret")]));
assert_eq!(rendered, format!("x-some-future-scheme: {REDACTED}"));
}
#[test]
fn headers_render_in_a_stable_order() {
let pairs = [
("x-api-key", "k"),
("a2a-version", "1.0"),
("content-type", "application/json"),
];
let forward = render_headers(&headers(&pairs));
let mut reversed = pairs;
reversed.reverse();
assert_eq!(forward, render_headers(&headers(&reversed)));
}
#[test]
fn a_non_utf8_header_value_shows_its_presence_only() -> Result<(), InvalidHeaderValue> {
let mut map = HeaderMap::new();
map.insert(
HeaderName::from_static("content-type"),
HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap(),
);
assert_eq!(render_headers(&map), "content-type: (binary)");
Ok(())
}
#[test]
fn a_non_utf8_body_is_summarised_rather_than_mangled() {
assert_eq!(render_body(&[0xff, 0xfe, 0xfd]), "(3 non-UTF-8 bytes)");
assert_eq!(render_body(b"{\"a\":1}"), "{\"a\":1}");
}
}