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