Skip to main content

a2a_protocol_server/agent_card/
dynamic_handler.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F.
3
4//! Dynamic agent card handler with HTTP caching.
5//!
6//! [`DynamicAgentCardHandler`] calls an [`AgentCardProducer`] on every request
7//! to generate a fresh [`AgentCard`]. This is useful when the card contents
8//! depend on runtime state (e.g. feature flags, authenticated context).
9//! Supports HTTP caching via `ETag` and conditional request headers (spec §8.3).
10
11use std::future::Future;
12use std::pin::Pin;
13
14use a2a_protocol_types::agent_card::AgentCard;
15use a2a_protocol_types::error::A2aResult;
16use bytes::Bytes;
17use http_body_util::Full;
18
19use crate::agent_card::caching::{format_http_date, make_etag, CacheConfig};
20use crate::agent_card::CORS_ALLOW_ALL;
21
22/// Trait for producing an [`AgentCard`] dynamically.
23///
24/// Object-safe; used behind `Arc<dyn AgentCardProducer>` or as a generic bound.
25pub trait AgentCardProducer: Send + Sync + 'static {
26    /// Produces an [`AgentCard`] for the current request.
27    ///
28    /// # Errors
29    ///
30    /// Returns an [`A2aError`](a2a_protocol_types::error::A2aError) if card generation fails.
31    fn produce<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<AgentCard>> + Send + 'a>>;
32}
33
34/// Serves a dynamically generated [`AgentCard`] as a JSON HTTP response.
35#[derive(Debug)]
36pub struct DynamicAgentCardHandler<P> {
37    producer: P,
38    cache_config: CacheConfig,
39}
40
41impl<P: AgentCardProducer> DynamicAgentCardHandler<P> {
42    /// Creates a new handler with the given producer.
43    #[must_use]
44    pub fn new(producer: P) -> Self {
45        Self {
46            producer,
47            cache_config: CacheConfig::default(),
48        }
49    }
50
51    /// Sets the `Cache-Control` max-age in seconds.
52    #[must_use]
53    pub const fn with_max_age(mut self, seconds: u32) -> Self {
54        self.cache_config = CacheConfig::with_max_age(seconds);
55        self
56    }
57
58    /// Handles an agent card request with conditional caching support.
59    ///
60    /// Serializes the produced card, computes an `ETag`, and checks
61    /// conditional request headers before returning the response.
62    #[allow(clippy::future_not_send)] // Body impl may not be Sync
63    pub async fn handle(
64        &self,
65        req: &hyper::Request<impl hyper::body::Body>,
66    ) -> hyper::Response<Full<Bytes>> {
67        // Extract conditional headers before the await point so the
68        // non-Send `impl Body` reference doesn't cross it.
69        let if_none_match = req
70            .headers()
71            .get("if-none-match")
72            .and_then(|v| v.to_str().ok())
73            .map(str::to_owned);
74        let if_modified_since = req
75            .headers()
76            .get("if-modified-since")
77            .and_then(|v| v.to_str().ok())
78            .map(str::to_owned);
79
80        match self.producer.produce().await {
81            Ok(card) => match serde_json::to_vec(&card) {
82                Ok(json) => {
83                    let etag = make_etag(&json);
84                    let last_modified = format_http_date(std::time::SystemTime::now());
85
86                    let not_modified = is_not_modified(
87                        if_none_match.as_deref(),
88                        if_modified_since.as_deref(),
89                        &etag,
90                        &last_modified,
91                    );
92
93                    if not_modified {
94                        hyper::Response::builder()
95                            .status(304)
96                            .header("etag", &etag)
97                            .header("last-modified", &last_modified)
98                            .header("cache-control", self.cache_config.header_value())
99                            .body(Full::new(Bytes::new()))
100                            .unwrap_or_else(|_| fallback_error_response())
101                    } else {
102                        hyper::Response::builder()
103                            .status(200)
104                            .header("content-type", "application/json")
105                            .header("access-control-allow-origin", CORS_ALLOW_ALL)
106                            .header("etag", &etag)
107                            .header("last-modified", &last_modified)
108                            .header("cache-control", self.cache_config.header_value())
109                            .body(Full::new(Bytes::from(json)))
110                            .unwrap_or_else(|_| fallback_error_response())
111                    }
112                }
113                Err(e) => error_response(500, &format!("serialization error: {e}")),
114            },
115            Err(e) => error_response(500, &format!("card producer error: {e}")),
116        }
117    }
118
119    /// Handles a request without conditional headers (legacy compatibility).
120    pub async fn handle_unconditional(&self) -> hyper::Response<Full<Bytes>> {
121        match self.producer.produce().await {
122            Ok(card) => match serde_json::to_vec(&card) {
123                Ok(json) => {
124                    let etag = make_etag(&json);
125                    let last_modified = format_http_date(std::time::SystemTime::now());
126                    hyper::Response::builder()
127                        .status(200)
128                        .header("content-type", "application/json")
129                        .header("access-control-allow-origin", CORS_ALLOW_ALL)
130                        .header("etag", &etag)
131                        .header("last-modified", &last_modified)
132                        .header("cache-control", self.cache_config.header_value())
133                        .body(Full::new(Bytes::from(json)))
134                        .unwrap_or_else(|_| fallback_error_response())
135                }
136                Err(e) => error_response(500, &format!("serialization error: {e}")),
137            },
138            Err(e) => error_response(500, &format!("card producer error: {e}")),
139        }
140    }
141}
142
143/// Checks whether the response should be 304 using pre-extracted header values.
144fn is_not_modified(
145    if_none_match: Option<&str>,
146    if_modified_since: Option<&str>,
147    current_etag: &str,
148    current_last_modified: &str,
149) -> bool {
150    // If-None-Match takes precedence per RFC 7232 §6.
151    if let Some(inm) = if_none_match {
152        return etag_matches(inm, current_etag);
153    }
154    if let Some(ims) = if_modified_since {
155        return ims == current_last_modified;
156    }
157    false
158}
159
160/// Weak `ETag` comparison for `If-None-Match` header values.
161fn etag_matches(header_value: &str, current: &str) -> bool {
162    let header_value = header_value.trim();
163    if header_value == "*" {
164        return true;
165    }
166    let current_bare = current.strip_prefix("W/").unwrap_or(current);
167    for candidate in header_value.split(',') {
168        let candidate = candidate.trim();
169        let candidate_bare = candidate.strip_prefix("W/").unwrap_or(candidate);
170        if candidate_bare == current_bare {
171            return true;
172        }
173    }
174    false
175}
176
177/// Builds a simple JSON error response.
178fn error_response(status: u16, message: &str) -> hyper::Response<Full<Bytes>> {
179    let body = serde_json::json!({ "error": message });
180    let bytes = serde_json::to_vec(&body).unwrap_or_default();
181    hyper::Response::builder()
182        .status(status)
183        .header("content-type", "application/json")
184        .body(Full::new(Bytes::from(bytes)))
185        .unwrap_or_else(|_| fallback_error_response())
186}
187
188/// Fallback response when the response builder itself fails (should never happen
189/// with valid static header names, but avoids panicking in production).
190fn fallback_error_response() -> hyper::Response<Full<Bytes>> {
191    hyper::Response::new(Full::new(Bytes::from_static(
192        br#"{"error":"internal server error"}"#,
193    )))
194}