a2a_protocol_server/agent_card/
caching.rs1use std::fmt::Write;
10use std::time::SystemTime;
11
12#[must_use]
19pub fn make_etag(data: &[u8]) -> String {
20 let hash = fnv1a(data);
21 format!("W/\"{hash:016x}\"")
22}
23
24fn fnv1a(data: &[u8]) -> u64 {
26 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
27 for &byte in data {
28 hash ^= u64::from(byte);
29 hash = hash.wrapping_mul(0x0100_0000_01b3);
30 }
31 hash
32}
33
34#[must_use]
38pub fn format_http_date(time: SystemTime) -> String {
39 let dur = time
40 .duration_since(SystemTime::UNIX_EPOCH)
41 .unwrap_or_default();
42 let secs = dur.as_secs();
43
44 let days = secs / 86400;
46 let day_secs = secs % 86400;
47 let hours = day_secs / 3600;
48 let minutes = (day_secs % 3600) / 60;
49 let seconds = day_secs % 60;
50
51 #[allow(clippy::cast_possible_wrap)]
53 let (year, month, day) = civil_from_days(days as i64);
54
55 let dow = ((days + 4) % 7) as usize;
57 let day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
58 let month_names = [
59 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
60 ];
61
62 let mut buf = String::with_capacity(29);
63 let _ = write!(
64 buf,
65 "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
66 day_names[dow],
67 day,
68 month_names[month as usize - 1],
69 year,
70 hours,
71 minutes,
72 seconds
73 );
74 buf
75}
76
77#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
79fn civil_from_days(days: i64) -> (i64, u32, u32) {
80 let z = days + 719_468;
81 let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
82 let doe = (z - era * 146_097) as u32;
83 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
84 let y = i64::from(yoe) + era * 400;
85 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
86 let mp = (5 * doy + 2) / 153;
87 let d = doy - (153 * mp + 2) / 5 + 1;
88 let m = if mp < 10 { mp + 3 } else { mp - 9 };
89 let y = if m <= 2 { y + 1 } else { y };
90 (y, m, d)
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum ConditionalResult {
98 NotModified,
100 SendFull,
102}
103
104#[must_use]
109pub fn check_conditional(
110 req: &hyper::Request<impl hyper::body::Body>,
111 current_etag: &str,
112 current_last_modified: &str,
113) -> ConditionalResult {
114 if let Some(inm) = req.headers().get("if-none-match") {
116 if let Ok(inm_str) = inm.to_str() {
117 if etag_matches(inm_str, current_etag) {
118 return ConditionalResult::NotModified;
119 }
120 return ConditionalResult::SendFull;
122 }
123 }
124
125 if let Some(ims) = req.headers().get("if-modified-since") {
127 if let Ok(ims_str) = ims.to_str() {
128 if ims_str == current_last_modified {
129 return ConditionalResult::NotModified;
130 }
131 }
132 }
133
134 ConditionalResult::SendFull
135}
136
137fn etag_matches(header_value: &str, current: &str) -> bool {
143 let header_value = header_value.trim();
144 if header_value == "*" {
145 return true;
146 }
147 let current_bare = current.strip_prefix("W/").unwrap_or(current);
149
150 for candidate in header_value.split(',') {
151 let candidate = candidate.trim();
152 let candidate_bare = candidate.strip_prefix("W/").unwrap_or(candidate);
153 if candidate_bare == current_bare {
154 return true;
155 }
156 }
157 false
158}
159
160#[derive(Debug, Clone, Copy)]
164pub struct CacheConfig {
165 pub max_age: u32,
167}
168
169impl CacheConfig {
170 #[must_use]
172 pub const fn with_max_age(max_age: u32) -> Self {
173 Self { max_age }
174 }
175
176 #[must_use]
178 pub fn header_value(&self) -> String {
179 format!("public, max-age={}", self.max_age)
180 }
181}
182
183impl Default for CacheConfig {
184 fn default() -> Self {
185 Self { max_age: 3600 }
187 }
188}
189
190#[cfg(test)]
193pub(crate) mod tests {
194 use super::*;
195 use a2a_protocol_types::agent_card::{
196 AgentCapabilities, AgentCard, AgentInterface, AgentSkill,
197 };
198 use bytes::Bytes;
199 use http_body_util::Full;
200
201 pub fn minimal_agent_card() -> AgentCard {
203 AgentCard {
204 name: "Test Agent".into(),
205 description: "A test agent".into(),
206 version: "1.0.0".into(),
207 supported_interfaces: vec![AgentInterface {
208 url: "https://agent.example.com/rpc".into(),
209 protocol_binding: "JSONRPC".into(),
210 protocol_version: "1.0.0".into(),
211 tenant: None,
212 }],
213 default_input_modes: vec!["text/plain".into()],
214 default_output_modes: vec!["text/plain".into()],
215 skills: vec![AgentSkill {
216 id: "echo".into(),
217 name: "Echo".into(),
218 description: "Echoes input".into(),
219 tags: vec!["echo".into()],
220 examples: None,
221 input_modes: None,
222 output_modes: None,
223 security_requirements: None,
224 }],
225 capabilities: AgentCapabilities::none(),
226 provider: None,
227 icon_url: None,
228 documentation_url: None,
229 security_schemes: None,
230 security_requirements: None,
231 signatures: None,
232 }
233 }
234
235 #[test]
236 fn make_etag_deterministic() {
237 let data = b"hello world";
238 let etag1 = make_etag(data);
239 let etag2 = make_etag(data);
240 assert_eq!(etag1, etag2);
241 assert!(etag1.starts_with("W/\""));
242 assert!(etag1.ends_with('"'));
243 }
244
245 #[test]
246 fn make_etag_different_for_different_data() {
247 let etag1 = make_etag(b"hello");
248 let etag2 = make_etag(b"world");
249 assert_ne!(etag1, etag2);
250 }
251
252 #[test]
253 fn format_http_date_epoch() {
254 let epoch = SystemTime::UNIX_EPOCH;
255 let date = format_http_date(epoch);
256 assert_eq!(date, "Thu, 01 Jan 1970 00:00:00 GMT");
257 }
258
259 #[test]
260 fn etag_matches_exact() {
261 assert!(etag_matches("W/\"abc\"", "W/\"abc\""));
262 }
263
264 #[test]
265 fn etag_matches_wildcard() {
266 assert!(etag_matches("*", "W/\"abc\""));
267 }
268
269 #[test]
270 fn etag_matches_comma_list() {
271 assert!(etag_matches("W/\"aaa\", W/\"bbb\", W/\"ccc\"", "W/\"bbb\""));
272 }
273
274 #[test]
275 fn etag_no_match() {
276 assert!(!etag_matches("W/\"xxx\"", "W/\"yyy\""));
277 }
278
279 #[test]
280 fn check_conditional_if_none_match_hit() {
281 let req = hyper::Request::builder()
282 .header("if-none-match", "W/\"abc\"")
283 .body(Full::new(Bytes::new()))
284 .unwrap();
285 assert_eq!(
286 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
287 ConditionalResult::NotModified,
288 );
289 }
290
291 #[test]
292 fn check_conditional_if_none_match_miss() {
293 let req = hyper::Request::builder()
294 .header("if-none-match", "W/\"xyz\"")
295 .body(Full::new(Bytes::new()))
296 .unwrap();
297 assert_eq!(
298 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
299 ConditionalResult::SendFull,
300 );
301 }
302
303 #[test]
304 fn check_conditional_if_modified_since_match() {
305 let lm = "Thu, 01 Jan 2026 00:00:00 GMT";
306 let req = hyper::Request::builder()
307 .header("if-modified-since", lm)
308 .body(Full::new(Bytes::new()))
309 .unwrap();
310 assert_eq!(
311 check_conditional(&req, "W/\"abc\"", lm),
312 ConditionalResult::NotModified,
313 );
314 }
315
316 #[test]
317 fn check_conditional_no_headers() {
318 let req = hyper::Request::builder()
319 .body(Full::new(Bytes::new()))
320 .unwrap();
321 assert_eq!(
322 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
323 ConditionalResult::SendFull,
324 );
325 }
326
327 #[test]
328 fn cache_config_default() {
329 let c = CacheConfig::default();
330 assert_eq!(c.header_value(), "public, max-age=3600");
331 }
332
333 #[test]
334 fn cache_config_custom() {
335 let c = CacheConfig::with_max_age(600);
336 assert_eq!(c.header_value(), "public, max-age=600");
337 }
338}