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)]
38#[non_exhaustive]
39pub struct CorsConfig {
40 pub allow_origin: String,
42 pub allow_methods: String,
44 pub allow_headers: String,
46 pub max_age_secs: u32,
48}
49
50impl CorsConfig {
51 #[must_use]
53 pub fn new(allow_origin: impl Into<String>) -> Self {
54 Self {
55 allow_origin: allow_origin.into(),
56 allow_methods: "GET, POST, PUT, DELETE, OPTIONS".into(),
57 allow_headers: "content-type, authorization, a2a-version, a2a-extensions, \
71 last-event-id, x-a2a-notification-token"
72 .into(),
73 max_age_secs: 86400,
74 }
75 }
76
77 #[must_use]
82 pub fn permissive() -> Self {
83 Self::new("*")
84 }
85
86 #[must_use]
88 pub fn with_allow_origin(mut self, origin: impl Into<String>) -> Self {
89 self.allow_origin = origin.into();
90 self
91 }
92
93 #[must_use]
95 pub fn with_allow_methods(mut self, methods: impl Into<String>) -> Self {
96 self.allow_methods = methods.into();
97 self
98 }
99
100 #[must_use]
104 pub fn with_allow_headers(mut self, headers: impl Into<String>) -> Self {
105 self.allow_headers = headers.into();
106 self
107 }
108
109 #[must_use]
111 pub const fn with_max_age_secs(mut self, secs: u32) -> Self {
112 self.max_age_secs = secs;
113 self
114 }
115
116 pub fn apply_headers<B>(&self, resp: &mut hyper::Response<B>) {
118 let headers = resp.headers_mut();
119 if let Ok(v) = self.allow_origin.parse() {
122 headers.insert("access-control-allow-origin", v);
123 }
124 if let Ok(v) = self.allow_methods.parse() {
125 headers.insert("access-control-allow-methods", v);
126 }
127 if let Ok(v) = self.allow_headers.parse() {
128 headers.insert("access-control-allow-headers", v);
129 }
130 if let Ok(v) = self.max_age_secs.to_string().parse() {
131 headers.insert("access-control-max-age", v);
132 }
133 }
134
135 #[must_use]
137 pub fn preflight_response(&self) -> hyper::Response<BoxBody<Bytes, Infallible>> {
138 let mut resp = hyper::Response::builder()
139 .status(204)
140 .body(Full::new(Bytes::new()).boxed())
141 .unwrap_or_else(|_| hyper::Response::new(Full::new(Bytes::new()).boxed()));
142 self.apply_headers(&mut resp);
143 resp
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn new_sets_origin_and_defaults() {
153 let cors = CorsConfig::new("https://example.com");
154
155 assert_eq!(cors.allow_origin, "https://example.com");
156 assert_eq!(
157 cors.allow_methods, "GET, POST, PUT, DELETE, OPTIONS",
158 "default methods should include common HTTP verbs"
159 );
160 assert_eq!(
161 cors.allow_headers,
162 "content-type, authorization, a2a-version, a2a-extensions, last-event-id, \
163 x-a2a-notification-token",
164 "default headers should include content-type, authorization, the protocol \
165 headers a client sends on every request, last-event-id so a cross-origin \
166 client can resume a stream, and the canonical x-a2a-notification-token"
167 );
168 assert_eq!(
169 cors.max_age_secs, 86400,
170 "default max-age should be 24 hours"
171 );
172 }
173
174 #[test]
175 fn new_accepts_string_and_str() {
176 let from_str = CorsConfig::new("https://a.com");
177 let from_string = CorsConfig::new(String::from("https://b.com"));
178
179 assert_eq!(from_str.allow_origin, "https://a.com");
180 assert_eq!(from_string.allow_origin, "https://b.com");
181 }
182
183 #[test]
184 fn permissive_allows_all_origins() {
185 let cors = CorsConfig::permissive();
186 assert_eq!(
187 cors.allow_origin, "*",
188 "permissive config should use wildcard origin"
189 );
190 }
191
192 #[test]
193 fn apply_headers_sets_all_cors_headers() {
194 let cors = CorsConfig::new("https://app.example.com");
195 let mut resp = hyper::Response::new(Full::new(Bytes::new()).boxed());
196 cors.apply_headers(&mut resp);
197
198 let headers = resp.headers();
199 assert_eq!(
200 headers.get("access-control-allow-origin").unwrap(),
201 "https://app.example.com"
202 );
203 assert_eq!(
204 headers.get("access-control-allow-methods").unwrap(),
205 "GET, POST, PUT, DELETE, OPTIONS"
206 );
207 assert_eq!(
208 headers.get("access-control-allow-headers").unwrap(),
209 "content-type, authorization, a2a-version, a2a-extensions, last-event-id, \
210 x-a2a-notification-token"
211 );
212 assert_eq!(headers.get("access-control-max-age").unwrap(), "86400");
213 }
214
215 #[test]
216 fn apply_headers_with_custom_config() {
217 let mut cors = CorsConfig::new("https://custom.dev");
218 cors.allow_methods = "POST, OPTIONS".into();
219 cors.allow_headers = "content-type".into();
220 cors.max_age_secs = 3600;
221
222 let mut resp = hyper::Response::new(Full::new(Bytes::new()).boxed());
223 cors.apply_headers(&mut resp);
224
225 let headers = resp.headers();
226 assert_eq!(
227 headers.get("access-control-allow-origin").unwrap(),
228 "https://custom.dev"
229 );
230 assert_eq!(
231 headers.get("access-control-allow-methods").unwrap(),
232 "POST, OPTIONS",
233 "custom methods should be applied"
234 );
235 assert_eq!(
236 headers.get("access-control-allow-headers").unwrap(),
237 "content-type",
238 "custom headers should be applied"
239 );
240 assert_eq!(
241 headers.get("access-control-max-age").unwrap(),
242 "3600",
243 "custom max-age should be applied"
244 );
245 }
246
247 #[test]
248 fn apply_headers_overwrites_existing_cors_headers() {
249 let cors = CorsConfig::new("https://second.com");
250 let mut resp = hyper::Response::builder()
251 .header("access-control-allow-origin", "https://first.com")
252 .body(Full::new(Bytes::new()).boxed())
253 .unwrap();
254
255 cors.apply_headers(&mut resp);
256
257 assert_eq!(
258 resp.headers().get("access-control-allow-origin").unwrap(),
259 "https://second.com",
260 "apply_headers should overwrite pre-existing CORS headers"
261 );
262 }
263
264 #[test]
265 fn preflight_response_returns_204_no_content() {
266 let cors = CorsConfig::permissive();
267 let resp = cors.preflight_response();
268
269 assert_eq!(
270 resp.status().as_u16(),
271 204,
272 "preflight response should have 204 No Content status"
273 );
274 }
275
276 #[test]
277 fn preflight_response_includes_cors_headers() {
278 let cors = CorsConfig::new("https://preflight.test");
279 let resp = cors.preflight_response();
280
281 let headers = resp.headers();
282 assert_eq!(
283 headers.get("access-control-allow-origin").unwrap(),
284 "https://preflight.test"
285 );
286 assert!(
287 headers.get("access-control-allow-methods").is_some(),
288 "preflight response must include allow-methods header"
289 );
290 assert!(
291 headers.get("access-control-allow-headers").is_some(),
292 "preflight response must include allow-headers header"
293 );
294 assert!(
295 headers.get("access-control-max-age").is_some(),
296 "preflight response must include max-age header"
297 );
298 }
299
300 #[test]
301 fn config_is_cloneable() {
302 let cors = CorsConfig::new("https://clone.test");
303 let cloned = cors.clone();
304 assert_eq!(cors.allow_origin, cloned.allow_origin);
305 assert_eq!(cors.allow_methods, cloned.allow_methods);
306 assert_eq!(cors.allow_headers, cloned.allow_headers);
307 assert_eq!(cors.max_age_secs, cloned.max_age_secs);
308 }
309
310 #[test]
311 fn max_age_zero_is_valid() {
312 let mut cors = CorsConfig::permissive();
313 cors.max_age_secs = 0;
314
315 let mut resp = hyper::Response::new(Full::new(Bytes::new()).boxed());
316 cors.apply_headers(&mut resp);
317
318 assert_eq!(
319 resp.headers().get("access-control-max-age").unwrap(),
320 "0",
321 "max-age of 0 should be set correctly"
322 );
323 }
324
325 #[test]
328 fn every_setter_sets_its_field() {
329 let cors = CorsConfig::permissive()
330 .with_allow_origin("https://example.com")
331 .with_allow_methods("GET")
332 .with_allow_headers("x-custom")
333 .with_max_age_secs(7);
334 assert_eq!(cors.allow_origin, "https://example.com");
335 assert_eq!(cors.allow_methods, "GET");
336 assert_eq!(cors.allow_headers, "x-custom");
337 assert_eq!(cors.max_age_secs, 7);
338 }
339}