Skip to main content

a2a_protocol_server/agent_card/
static_handler.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F.
3
4//! Static agent card handler with HTTP caching.
5//!
6//! [`StaticAgentCardHandler`] serves a pre-serialized [`AgentCard`] as JSON.
7//! The card is serialized once at construction time and served as raw bytes
8//! on every request. Supports HTTP caching via `ETag`, `Last-Modified`,
9//! `Cache-Control`, and conditional request headers (spec §8.3).
10
11use a2a_protocol_types::agent_card::AgentCard;
12use bytes::Bytes;
13use http_body_util::Full;
14
15use crate::agent_card::caching::{
16    check_conditional, format_http_date, make_etag, CacheConfig, ConditionalResult,
17};
18use crate::agent_card::CORS_ALLOW_ALL;
19use crate::error::ServerResult;
20
21/// Serves a pre-serialized [`AgentCard`] as a JSON HTTP response with caching.
22#[derive(Debug, Clone)]
23pub struct StaticAgentCardHandler {
24    card_json: Bytes,
25    etag: String,
26    last_modified: String,
27    cache_config: CacheConfig,
28}
29
30impl StaticAgentCardHandler {
31    /// Creates a new handler by serializing the given [`AgentCard`] to JSON.
32    ///
33    /// Computes an `ETag` from the serialized content and records the current
34    /// time as `Last-Modified`.
35    ///
36    /// # Errors
37    ///
38    /// Returns a [`ServerError`](crate::error::ServerError) if serialization fails.
39    pub fn new(card: &AgentCard) -> ServerResult<Self> {
40        let json = serde_json::to_vec(card)?;
41        let etag = make_etag(&json);
42        Ok(Self {
43            card_json: Bytes::from(json),
44            etag,
45            last_modified: format_http_date(std::time::SystemTime::now()),
46            cache_config: CacheConfig::default(),
47        })
48    }
49
50    /// Sets the `Cache-Control` max-age in seconds.
51    #[must_use]
52    pub const fn with_max_age(mut self, seconds: u32) -> Self {
53        self.cache_config = CacheConfig::with_max_age(seconds);
54        self
55    }
56
57    /// Handles an agent card request, returning a cached response.
58    ///
59    /// Supports conditional requests via `If-None-Match` and `If-Modified-Since`
60    /// headers, returning `304 Not Modified` when appropriate.
61    #[must_use]
62    pub fn handle(
63        &self,
64        req: &hyper::Request<impl hyper::body::Body>,
65    ) -> hyper::Response<Full<Bytes>> {
66        let result = check_conditional(req, &self.etag, &self.last_modified);
67        match result {
68            ConditionalResult::NotModified => self.not_modified_response(),
69            ConditionalResult::SendFull => self.full_response(),
70        }
71    }
72
73    /// Handles a request without conditional headers (legacy compatibility).
74    #[must_use]
75    pub fn handle_unconditional(&self) -> hyper::Response<Full<Bytes>> {
76        self.full_response()
77    }
78
79    fn full_response(&self) -> hyper::Response<Full<Bytes>> {
80        hyper::Response::builder()
81            .status(200)
82            .header("content-type", "application/json")
83            .header("access-control-allow-origin", CORS_ALLOW_ALL)
84            .header("etag", &self.etag)
85            .header("last-modified", &self.last_modified)
86            .header("cache-control", self.cache_config.header_value())
87            .body(Full::new(self.card_json.clone()))
88            .unwrap_or_else(|_| hyper::Response::new(Full::new(Bytes::new())))
89    }
90
91    fn not_modified_response(&self) -> hyper::Response<Full<Bytes>> {
92        hyper::Response::builder()
93            .status(304)
94            .header("etag", &self.etag)
95            .header("last-modified", &self.last_modified)
96            .header("cache-control", self.cache_config.header_value())
97            .body(Full::new(Bytes::new()))
98            .unwrap_or_else(|_| hyper::Response::new(Full::new(Bytes::new())))
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::agent_card::caching::tests::minimal_agent_card;
106
107    #[test]
108    fn static_handler_returns_etag_and_cache_headers() {
109        let card = minimal_agent_card();
110        let handler = StaticAgentCardHandler::new(&card).unwrap();
111        let req = hyper::Request::builder()
112            .method("GET")
113            .uri("/.well-known/agent.json")
114            .body(Full::new(Bytes::new()))
115            .unwrap();
116        let resp = handler.handle(&req);
117        assert_eq!(resp.status(), 200);
118        assert!(resp.headers().contains_key("etag"));
119        assert!(resp.headers().contains_key("last-modified"));
120        assert!(resp.headers().contains_key("cache-control"));
121    }
122
123    #[test]
124    fn static_handler_304_on_matching_etag() {
125        let card = minimal_agent_card();
126        let handler = StaticAgentCardHandler::new(&card).unwrap();
127        // First request to get the etag.
128        let req1 = hyper::Request::builder()
129            .method("GET")
130            .uri("/.well-known/agent.json")
131            .body(Full::new(Bytes::new()))
132            .unwrap();
133        let resp1 = handler.handle(&req1);
134        let etag = resp1
135            .headers()
136            .get("etag")
137            .unwrap()
138            .to_str()
139            .unwrap()
140            .to_owned();
141
142        // Second request with If-None-Match.
143        let req2 = hyper::Request::builder()
144            .method("GET")
145            .uri("/.well-known/agent.json")
146            .header("if-none-match", &etag)
147            .body(Full::new(Bytes::new()))
148            .unwrap();
149        let resp2 = handler.handle(&req2);
150        assert_eq!(resp2.status(), 304);
151    }
152
153    #[test]
154    fn static_handler_200_on_mismatched_etag() {
155        let card = minimal_agent_card();
156        let handler = StaticAgentCardHandler::new(&card).unwrap();
157        let req = hyper::Request::builder()
158            .method("GET")
159            .uri("/.well-known/agent.json")
160            .header("if-none-match", "\"wrong-etag\"")
161            .body(Full::new(Bytes::new()))
162            .unwrap();
163        let resp = handler.handle(&req);
164        assert_eq!(resp.status(), 200);
165    }
166
167    #[test]
168    fn static_handler_custom_max_age() {
169        let card = minimal_agent_card();
170        let handler = StaticAgentCardHandler::new(&card)
171            .unwrap()
172            .with_max_age(7200);
173        let req = hyper::Request::builder()
174            .method("GET")
175            .uri("/.well-known/agent.json")
176            .body(Full::new(Bytes::new()))
177            .unwrap();
178        let resp = handler.handle(&req);
179        let cc = resp
180            .headers()
181            .get("cache-control")
182            .unwrap()
183            .to_str()
184            .unwrap();
185        assert!(cc.contains("max-age=7200"));
186    }
187}