Skip to main content

a2a_protocol_client/
discovery.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F.
3
4//! Agent card discovery with HTTP caching.
5//!
6//! A2A agents publish their [`AgentCard`] at a well-known URL. This module
7//! provides helpers to fetch and parse the card.
8//!
9//! The default discovery path is `/.well-known/agent.json` appended to
10//! the agent's base URL.
11//!
12//! Per spec §8.3, the client supports HTTP caching via `ETag` and
13//! `If-None-Match` / `If-Modified-Since` conditional request headers.
14
15use std::sync::Arc;
16use std::time::Duration;
17
18use http_body_util::{BodyExt, Full};
19use hyper::body::Bytes;
20use hyper::header;
21#[cfg(not(feature = "tls-rustls"))]
22use hyper_util::client::legacy::connect::HttpConnector;
23#[cfg(not(feature = "tls-rustls"))]
24use hyper_util::client::legacy::Client;
25#[cfg(not(feature = "tls-rustls"))]
26use hyper_util::rt::TokioExecutor;
27use tokio::sync::RwLock;
28
29use a2a_protocol_types::AgentCard;
30
31use crate::error::{ClientError, ClientResult};
32
33/// The standard well-known path for agent card discovery.
34pub const AGENT_CARD_PATH: &str = "/.well-known/agent.json";
35
36// ── Public API ────────────────────────────────────────────────────────────────
37
38/// Fetches the [`AgentCard`] from the standard well-known path.
39///
40/// Appends `/.well-known/agent.json` to `base_url` and performs an
41/// HTTP GET.
42///
43/// # Errors
44///
45/// - [`ClientError::InvalidEndpoint`] — `base_url` is malformed.
46/// - [`ClientError::HttpClient`] — connection error.
47/// - [`ClientError::UnexpectedStatus`] — server returned a non-200 status.
48/// - [`ClientError::Serialization`] — response body is not a valid
49///   [`AgentCard`].
50pub async fn resolve_agent_card(base_url: &str) -> ClientResult<AgentCard> {
51    trace_info!(base_url, "resolving agent card");
52    let url = build_card_url(base_url, AGENT_CARD_PATH)?;
53    fetch_card(&url, None).await
54}
55
56/// Fetches the [`AgentCard`] from a custom path.
57///
58/// Unlike [`resolve_agent_card`], this function appends `path` (not the
59/// standard well-known path) to `base_url`.
60///
61/// # Errors
62///
63/// Same conditions as [`resolve_agent_card`].
64pub async fn resolve_agent_card_with_path(base_url: &str, path: &str) -> ClientResult<AgentCard> {
65    let url = build_card_url(base_url, path)?;
66    fetch_card(&url, None).await
67}
68
69/// Fetches the [`AgentCard`] from an absolute URL.
70///
71/// The URL must be a complete `http://` or `https://` URL pointing directly
72/// at the agent card JSON resource.
73///
74/// # Errors
75///
76/// Same conditions as [`resolve_agent_card`].
77pub async fn fetch_card_from_url(url: &str) -> ClientResult<AgentCard> {
78    fetch_card(url, None).await
79}
80
81// ── Cached Discovery ─────────────────────────────────────────────────────────
82
83/// Cached entry for an agent card, holding the card and its `ETag`.
84#[derive(Debug, Clone)]
85struct CachedCard {
86    card: AgentCard,
87    etag: Option<String>,
88    last_modified: Option<String>,
89}
90
91/// A caching agent card resolver.
92///
93/// Stores the last fetched card and uses conditional HTTP requests
94/// (`If-None-Match`, `If-Modified-Since`) to avoid unnecessary re-downloads
95/// (spec §8.3).
96#[derive(Debug, Clone)]
97pub struct CachingCardResolver {
98    url: String,
99    cache: Arc<RwLock<Option<CachedCard>>>,
100}
101
102impl CachingCardResolver {
103    /// Creates a new resolver for the given agent card URL.
104    #[must_use]
105    pub fn new(base_url: &str) -> Self {
106        let url = build_card_url(base_url, AGENT_CARD_PATH).unwrap_or_default();
107        Self {
108            url,
109            cache: Arc::new(RwLock::new(None)),
110        }
111    }
112
113    /// Creates a new resolver with a custom path.
114    #[must_use]
115    pub fn with_path(base_url: &str, path: &str) -> Self {
116        let url = build_card_url(base_url, path).unwrap_or_default();
117        Self {
118            url,
119            cache: Arc::new(RwLock::new(None)),
120        }
121    }
122
123    /// Resolves the agent card, using a cached version if valid.
124    ///
125    /// Sends conditional request headers when a cached card exists. On `304`,
126    /// returns the cached card. On `200`, updates the cache and returns the
127    /// new card.
128    ///
129    /// # Errors
130    ///
131    /// Same conditions as [`resolve_agent_card`].
132    pub async fn resolve(&self) -> ClientResult<AgentCard> {
133        trace_info!(url = %self.url, "resolving agent card (cached)");
134        let cached = self.cache.read().await.clone();
135        let (card, etag, last_modified) =
136            fetch_card_with_metadata(&self.url, cached.as_ref()).await?;
137
138        // Update cache with new metadata.
139        {
140            let mut guard = self.cache.write().await;
141            *guard = Some(CachedCard {
142                card: card.clone(),
143                etag,
144                last_modified,
145            });
146        }
147
148        Ok(card)
149    }
150
151    /// Clears the internal cache.
152    pub async fn invalidate(&self) {
153        let mut cache = self.cache.write().await;
154        *cache = None;
155    }
156}
157
158// ── internals ─────────────────────────────────────────────────────────────────
159
160fn build_card_url(base_url: &str, path: &str) -> ClientResult<String> {
161    if base_url.is_empty() {
162        return Err(ClientError::InvalidEndpoint(
163            "base URL must not be empty".into(),
164        ));
165    }
166    if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
167        return Err(ClientError::InvalidEndpoint(format!(
168            "base URL must start with http:// or https://: {base_url}"
169        )));
170    }
171    let base = base_url.trim_end_matches('/');
172    let path = if path.starts_with('/') {
173        path.to_owned()
174    } else {
175        format!("/{path}")
176    };
177    Ok(format!("{base}{path}"))
178}
179
180async fn fetch_card(url: &str, cached: Option<&CachedCard>) -> ClientResult<AgentCard> {
181    let (card, _, _) = fetch_card_with_metadata(url, cached).await?;
182    Ok(card)
183}
184
185async fn fetch_card_with_metadata(
186    url: &str,
187    cached: Option<&CachedCard>,
188) -> ClientResult<(AgentCard, Option<String>, Option<String>)> {
189    #[cfg(not(feature = "tls-rustls"))]
190    let client: Client<HttpConnector, Full<Bytes>> =
191        Client::builder(TokioExecutor::new()).build_http::<Full<Bytes>>();
192
193    #[cfg(feature = "tls-rustls")]
194    let client = crate::tls::build_https_client();
195
196    let mut builder = hyper::Request::builder()
197        .method(hyper::Method::GET)
198        .uri(url)
199        .header(header::ACCEPT, "application/json");
200
201    // Add conditional request headers if we have cached data.
202    if let Some(cached) = cached {
203        if let Some(ref etag) = cached.etag {
204            builder = builder.header("if-none-match", etag.as_str());
205        }
206        if let Some(ref lm) = cached.last_modified {
207            builder = builder.header("if-modified-since", lm.as_str());
208        }
209    }
210
211    let req = builder
212        .body(Full::new(Bytes::new()))
213        .map_err(|e| ClientError::Transport(e.to_string()))?;
214
215    let resp = tokio::time::timeout(Duration::from_secs(30), client.request(req))
216        .await
217        .map_err(|_| ClientError::Transport("agent card fetch timed out".into()))?
218        .map_err(|e| ClientError::HttpClient(e.to_string()))?;
219
220    let status = resp.status();
221
222    // 304 Not Modified — return cached card with existing metadata.
223    if status == hyper::StatusCode::NOT_MODIFIED {
224        if let Some(cached) = cached {
225            return Ok((
226                cached.card.clone(),
227                cached.etag.clone(),
228                cached.last_modified.clone(),
229            ));
230        }
231        // No cached card but got 304 — shouldn't happen, fall through to error.
232    }
233
234    // Extract caching headers before consuming the response body.
235    let etag = resp
236        .headers()
237        .get("etag")
238        .and_then(|v| v.to_str().ok())
239        .map(str::to_owned);
240    let last_modified = resp
241        .headers()
242        .get("last-modified")
243        .and_then(|v| v.to_str().ok())
244        .map(str::to_owned);
245
246    let body_bytes = resp.collect().await.map_err(ClientError::Http)?.to_bytes();
247
248    if !status.is_success() {
249        let body_str = String::from_utf8_lossy(&body_bytes).into_owned();
250        return Err(ClientError::UnexpectedStatus {
251            status: status.as_u16(),
252            body: body_str,
253        });
254    }
255
256    let card =
257        serde_json::from_slice::<AgentCard>(&body_bytes).map_err(ClientError::Serialization)?;
258    Ok((card, etag, last_modified))
259}
260
261// ── Tests ─────────────────────────────────────────────────────────────────────
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn build_card_url_standard() {
269        let url = build_card_url("http://localhost:8080", AGENT_CARD_PATH).unwrap();
270        assert_eq!(url, "http://localhost:8080/.well-known/agent.json");
271    }
272
273    #[test]
274    fn build_card_url_trailing_slash() {
275        let url = build_card_url("http://localhost:8080/", AGENT_CARD_PATH).unwrap();
276        assert_eq!(url, "http://localhost:8080/.well-known/agent.json");
277    }
278
279    #[test]
280    fn build_card_url_custom_path() {
281        let url = build_card_url("http://localhost:8080", "/api/card.json").unwrap();
282        assert_eq!(url, "http://localhost:8080/api/card.json");
283    }
284
285    #[test]
286    fn build_card_url_rejects_empty() {
287        assert!(build_card_url("", AGENT_CARD_PATH).is_err());
288    }
289
290    #[test]
291    fn build_card_url_rejects_non_http() {
292        assert!(build_card_url("ftp://example.com", AGENT_CARD_PATH).is_err());
293    }
294
295    #[test]
296    fn caching_resolver_new() {
297        let resolver = CachingCardResolver::new("http://localhost:8080");
298        assert_eq!(resolver.url, "http://localhost:8080/.well-known/agent.json");
299    }
300
301    #[test]
302    fn caching_resolver_with_path() {
303        let resolver = CachingCardResolver::with_path("http://localhost:8080", "/custom/card.json");
304        assert_eq!(resolver.url, "http://localhost:8080/custom/card.json");
305    }
306
307    #[tokio::test]
308    async fn caching_resolver_invalidate() {
309        let resolver = CachingCardResolver::new("http://localhost:8080");
310        // Cache should start empty.
311        assert!(resolver.cache.read().await.is_none());
312        resolver.invalidate().await;
313        assert!(resolver.cache.read().await.is_none());
314    }
315}