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