Skip to main content

a2a_protocol_server/agent_card/
caching.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F.
3
4//! HTTP caching utilities for agent card responses (spec §8.3).
5//!
6//! Provides `ETag` generation, `Last-Modified` formatting, conditional
7//! request checking, and `Cache-Control` configuration.
8
9use std::fmt::Write;
10use std::time::SystemTime;
11
12// ── ETag ─────────────────────────────────────────────────────────────────────
13
14/// Generates a weak `ETag` from the given bytes using a simple FNV-1a hash.
15///
16/// The hash is fast to compute and sufficient for cache validation of
17/// relatively short agent card JSON payloads.
18#[must_use]
19pub fn make_etag(data: &[u8]) -> String {
20    let hash = fnv1a(data);
21    format!("W/\"{hash:016x}\"")
22}
23
24/// FNV-1a 64-bit hash (non-cryptographic, fast, good distribution).
25fn 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// ── Last-Modified ────────────────────────────────────────────────────────────
35
36/// Formats a [`SystemTime`] as an HTTP-date (RFC 7231 §7.1.1.1).
37#[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    // Simplified HTTP-date formatter (IMF-fixdate).
45    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    // Civil date from days since epoch (algorithm from Howard Hinnant).
52    #[allow(clippy::cast_possible_wrap)]
53    let (year, month, day) = civil_from_days(days as i64);
54
55    // Day of week: Jan 1 1970 was a Thursday (4).
56    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/// Converts days since Unix epoch to (year, month, day).
78#[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// ── Conditional Requests ─────────────────────────────────────────────────────
94
95/// Result of checking conditional request headers.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum ConditionalResult {
98    /// The client's cache is still valid; respond with 304.
99    NotModified,
100    /// The client needs the full response.
101    SendFull,
102}
103
104/// Checks `If-None-Match` and `If-Modified-Since` headers against the
105/// current `ETag` and `Last-Modified` values.
106///
107/// Per RFC 7232, `If-None-Match` takes precedence over `If-Modified-Since`.
108#[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    // Check If-None-Match first (takes precedence per RFC 7232 §6).
115    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            // If-None-Match was present but didn't match; skip If-Modified-Since.
121            return ConditionalResult::SendFull;
122        }
123    }
124
125    // Check If-Modified-Since (only when If-None-Match is absent).
126    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
137/// Checks whether any `ETag` in an `If-None-Match` header value matches
138/// the current `ETag`.
139///
140/// Handles `*`, single `ETag` values, and comma-separated lists. Comparison
141/// is performed using weak comparison (RFC 7232 §2.3.2).
142fn etag_matches(header_value: &str, current: &str) -> bool {
143    let header_value = header_value.trim();
144    if header_value == "*" {
145        return true;
146    }
147    // Strip W/ prefix for weak comparison.
148    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// ── Cache-Control config ─────────────────────────────────────────────────────
161
162/// Configuration for `Cache-Control` headers on agent card responses.
163#[derive(Debug, Clone, Copy)]
164pub struct CacheConfig {
165    /// `max-age` value in seconds.
166    pub max_age: u32,
167}
168
169impl CacheConfig {
170    /// Creates a config with the given `max-age`.
171    #[must_use]
172    pub const fn with_max_age(max_age: u32) -> Self {
173        Self { max_age }
174    }
175
176    /// Returns the `Cache-Control` header value.
177    #[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        // Default: 1 hour.
186        Self { max_age: 3600 }
187    }
188}
189
190// ── Tests ────────────────────────────────────────────────────────────────────
191
192#[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    /// Helper to build a minimal agent card for tests.
202    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}