a2a_protocol_client/
discovery.rs1use 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
33pub const AGENT_CARD_PATH: &str = "/.well-known/agent.json";
35
36pub 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
56pub 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
69pub async fn fetch_card_from_url(url: &str) -> ClientResult<AgentCard> {
78 fetch_card(url, None).await
79}
80
81#[derive(Debug, Clone)]
85struct CachedCard {
86 card: AgentCard,
87 etag: Option<String>,
88 last_modified: Option<String>,
89}
90
91#[derive(Debug, Clone)]
97pub struct CachingCardResolver {
98 url: String,
99 cache: Arc<RwLock<Option<CachedCard>>>,
100}
101
102impl CachingCardResolver {
103 #[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 #[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 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 {
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 pub async fn invalidate(&self) {
153 let mut cache = self.cache.write().await;
154 *cache = None;
155 }
156}
157
158fn 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 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 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 }
233
234 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#[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 assert!(resolver.cache.read().await.is_none());
312 resolver.invalidate().await;
313 assert!(resolver.cache.read().await.is_none());
314 }
315}