a2a_protocol_server/dispatch/
cors.rs1use std::convert::Infallible;
14
15use bytes::Bytes;
16use http_body_util::combinators::BoxBody;
17use http_body_util::{BodyExt, Full};
18
19#[derive(Debug, Clone)]
33pub struct CorsConfig {
34 pub allow_origin: String,
36 pub allow_methods: String,
38 pub allow_headers: String,
40 pub max_age_secs: u32,
42}
43
44impl CorsConfig {
45 #[must_use]
47 pub fn new(allow_origin: impl Into<String>) -> Self {
48 Self {
49 allow_origin: allow_origin.into(),
50 allow_methods: "GET, POST, PUT, DELETE, OPTIONS".into(),
51 allow_headers: "content-type, authorization, a2a-version, a2a-extensions, \
59 x-a2a-notification-token"
60 .into(),
61 max_age_secs: 86400,
62 }
63 }
64
65 #[must_use]
70 pub fn permissive() -> Self {
71 Self::new("*")
72 }
73
74 pub fn apply_headers<B>(&self, resp: &mut hyper::Response<B>) {
76 let headers = resp.headers_mut();
77 if let Ok(v) = self.allow_origin.parse() {
80 headers.insert("access-control-allow-origin", v);
81 }
82 if let Ok(v) = self.allow_methods.parse() {
83 headers.insert("access-control-allow-methods", v);
84 }
85 if let Ok(v) = self.allow_headers.parse() {
86 headers.insert("access-control-allow-headers", v);
87 }
88 if let Ok(v) = self.max_age_secs.to_string().parse() {
89 headers.insert("access-control-max-age", v);
90 }
91 }
92
93 #[must_use]
95 pub fn preflight_response(&self) -> hyper::Response<BoxBody<Bytes, Infallible>> {
96 let mut resp = hyper::Response::builder()
97 .status(204)
98 .body(Full::new(Bytes::new()).boxed())
99 .unwrap_or_else(|_| hyper::Response::new(Full::new(Bytes::new()).boxed()));
100 self.apply_headers(&mut resp);
101 resp
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn new_sets_origin_and_defaults() {
111 let cors = CorsConfig::new("https://example.com");
112
113 assert_eq!(cors.allow_origin, "https://example.com");
114 assert_eq!(
115 cors.allow_methods, "GET, POST, PUT, DELETE, OPTIONS",
116 "default methods should include common HTTP verbs"
117 );
118 assert_eq!(
119 cors.allow_headers,
120 "content-type, authorization, a2a-version, a2a-extensions, x-a2a-notification-token",
121 "default headers should include content-type, authorization, and the canonical x-a2a-notification-token"
122 );
123 assert_eq!(
124 cors.max_age_secs, 86400,
125 "default max-age should be 24 hours"
126 );
127 }
128
129 #[test]
130 fn new_accepts_string_and_str() {
131 let from_str = CorsConfig::new("https://a.com");
132 let from_string = CorsConfig::new(String::from("https://b.com"));
133
134 assert_eq!(from_str.allow_origin, "https://a.com");
135 assert_eq!(from_string.allow_origin, "https://b.com");
136 }
137
138 #[test]
139 fn permissive_allows_all_origins() {
140 let cors = CorsConfig::permissive();
141 assert_eq!(
142 cors.allow_origin, "*",
143 "permissive config should use wildcard origin"
144 );
145 }
146
147 #[test]
148 fn apply_headers_sets_all_cors_headers() {
149 let cors = CorsConfig::new("https://app.example.com");
150 let mut resp = hyper::Response::new(Full::new(Bytes::new()).boxed());
151 cors.apply_headers(&mut resp);
152
153 let headers = resp.headers();
154 assert_eq!(
155 headers.get("access-control-allow-origin").unwrap(),
156 "https://app.example.com"
157 );
158 assert_eq!(
159 headers.get("access-control-allow-methods").unwrap(),
160 "GET, POST, PUT, DELETE, OPTIONS"
161 );
162 assert_eq!(
163 headers.get("access-control-allow-headers").unwrap(),
164 "content-type, authorization, a2a-version, a2a-extensions, x-a2a-notification-token"
165 );
166 assert_eq!(headers.get("access-control-max-age").unwrap(), "86400");
167 }
168
169 #[test]
170 fn apply_headers_with_custom_config() {
171 let mut cors = CorsConfig::new("https://custom.dev");
172 cors.allow_methods = "POST, OPTIONS".into();
173 cors.allow_headers = "content-type".into();
174 cors.max_age_secs = 3600;
175
176 let mut resp = hyper::Response::new(Full::new(Bytes::new()).boxed());
177 cors.apply_headers(&mut resp);
178
179 let headers = resp.headers();
180 assert_eq!(
181 headers.get("access-control-allow-origin").unwrap(),
182 "https://custom.dev"
183 );
184 assert_eq!(
185 headers.get("access-control-allow-methods").unwrap(),
186 "POST, OPTIONS",
187 "custom methods should be applied"
188 );
189 assert_eq!(
190 headers.get("access-control-allow-headers").unwrap(),
191 "content-type",
192 "custom headers should be applied"
193 );
194 assert_eq!(
195 headers.get("access-control-max-age").unwrap(),
196 "3600",
197 "custom max-age should be applied"
198 );
199 }
200
201 #[test]
202 fn apply_headers_overwrites_existing_cors_headers() {
203 let cors = CorsConfig::new("https://second.com");
204 let mut resp = hyper::Response::builder()
205 .header("access-control-allow-origin", "https://first.com")
206 .body(Full::new(Bytes::new()).boxed())
207 .unwrap();
208
209 cors.apply_headers(&mut resp);
210
211 assert_eq!(
212 resp.headers().get("access-control-allow-origin").unwrap(),
213 "https://second.com",
214 "apply_headers should overwrite pre-existing CORS headers"
215 );
216 }
217
218 #[test]
219 fn preflight_response_returns_204_no_content() {
220 let cors = CorsConfig::permissive();
221 let resp = cors.preflight_response();
222
223 assert_eq!(
224 resp.status().as_u16(),
225 204,
226 "preflight response should have 204 No Content status"
227 );
228 }
229
230 #[test]
231 fn preflight_response_includes_cors_headers() {
232 let cors = CorsConfig::new("https://preflight.test");
233 let resp = cors.preflight_response();
234
235 let headers = resp.headers();
236 assert_eq!(
237 headers.get("access-control-allow-origin").unwrap(),
238 "https://preflight.test"
239 );
240 assert!(
241 headers.get("access-control-allow-methods").is_some(),
242 "preflight response must include allow-methods header"
243 );
244 assert!(
245 headers.get("access-control-allow-headers").is_some(),
246 "preflight response must include allow-headers header"
247 );
248 assert!(
249 headers.get("access-control-max-age").is_some(),
250 "preflight response must include max-age header"
251 );
252 }
253
254 #[test]
255 fn config_is_cloneable() {
256 let cors = CorsConfig::new("https://clone.test");
257 let cloned = cors.clone();
258 assert_eq!(cors.allow_origin, cloned.allow_origin);
259 assert_eq!(cors.allow_methods, cloned.allow_methods);
260 assert_eq!(cors.allow_headers, cloned.allow_headers);
261 assert_eq!(cors.max_age_secs, cloned.max_age_secs);
262 }
263
264 #[test]
265 fn max_age_zero_is_valid() {
266 let mut cors = CorsConfig::permissive();
267 cors.max_age_secs = 0;
268
269 let mut resp = hyper::Response::new(Full::new(Bytes::new()).boxed());
270 cors.apply_headers(&mut resp);
271
272 assert_eq!(
273 resp.headers().get("access-control-max-age").unwrap(),
274 "0",
275 "max-age of 0 should be set correctly"
276 );
277 }
278}