Skip to main content

api_tools/server/axum/layers/
cors.rs

1//! CORS layer for Axum
2
3use axum::http::{HeaderName, HeaderValue, Method};
4use tower_http::cors::{AllowOrigin, Any, CorsLayer};
5
6/// CORS configuration
7///
8/// # Example
9///
10/// ```rust
11/// use axum::http::{header, HeaderName, HeaderValue, Method};
12/// use api_tools::server::axum::layers::cors::CorsConfig;
13///
14/// let cors_config = CorsConfig {
15///     allow_origin: "*",
16///     allow_methods: vec![Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE],
17///     allow_headers: vec![header::AUTHORIZATION, header::ACCEPT, header::CONTENT_TYPE, header::ORIGIN],
18/// };
19/// ```
20pub struct CorsConfig<'a> {
21    pub allow_origin: &'a str,
22    pub allow_methods: Vec<Method>,
23    pub allow_headers: Vec<HeaderName>,
24}
25
26/// CORS layer
27///
28/// This function creates a CORS layer for Axum with the specified configuration.
29///
30/// # Example
31///
32/// ```rust
33/// use axum::http::{header, HeaderName, HeaderValue, Method};
34/// use api_tools::server::axum::layers::cors::{cors, CorsConfig};
35///
36/// let cors_config = CorsConfig {
37///     allow_origin: "*",
38///     allow_methods: vec![Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE],
39///     allow_headers: vec![header::AUTHORIZATION, header::ACCEPT, header::CONTENT_TYPE, header::ORIGIN],
40/// };
41///
42/// let layer = cors(cors_config);
43/// ```
44pub fn cors(config: CorsConfig) -> CorsLayer {
45    let allow_origin = config.allow_origin;
46
47    let layer = CorsLayer::new()
48        .allow_methods(config.allow_methods)
49        .allow_headers(config.allow_headers);
50
51    if allow_origin == "*" {
52        layer.allow_origin(Any)
53    } else {
54        let origins = allow_origin
55            .split(',')
56            .filter(|url| *url != "*" && !url.is_empty())
57            .filter_map(|url| url.parse().ok())
58            .collect::<Vec<HeaderValue>>();
59
60        if origins.is_empty() {
61            layer.allow_origin(Any)
62        } else {
63            layer
64                .allow_origin(AllowOrigin::predicate(move |origin: &HeaderValue, _| {
65                    origins.contains(origin)
66                }))
67                .allow_credentials(true)
68        }
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use axum::body::Body;
76    use axum::http::{Request, StatusCode, header};
77    use axum::response::Response;
78    use std::convert::Infallible;
79    use tower::{ServiceBuilder, ServiceExt};
80
81    fn ok_response() -> Response {
82        Response::builder().status(StatusCode::OK).body(Body::empty()).unwrap()
83    }
84
85    fn default_methods() -> Vec<Method> {
86        vec![Method::GET, Method::POST]
87    }
88
89    fn default_headers() -> Vec<HeaderName> {
90        vec![header::CONTENT_TYPE]
91    }
92
93    #[tokio::test]
94    async fn cors_with_wildcard_origin_allows_any() {
95        let layer = cors(CorsConfig {
96            allow_origin: "*",
97            allow_methods: default_methods(),
98            allow_headers: default_headers(),
99        });
100        let svc = ServiceBuilder::new()
101            .layer(layer)
102            .service(tower::service_fn(|_req: Request<Body>| async {
103                Ok::<_, Infallible>(ok_response())
104            }));
105
106        let req = Request::builder()
107            .method(Method::GET)
108            .uri("/")
109            .header(header::ORIGIN, "https://example.com")
110            .body(Body::empty())
111            .unwrap();
112        let resp = svc.oneshot(req).await.unwrap();
113
114        assert_eq!(resp.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), "*");
115    }
116
117    #[tokio::test]
118    async fn cors_whitelist_allows_authorized_origin() {
119        let layer = cors(CorsConfig {
120            allow_origin: "https://allowed.com,https://other.com",
121            allow_methods: default_methods(),
122            allow_headers: default_headers(),
123        });
124        let svc = ServiceBuilder::new()
125            .layer(layer)
126            .service(tower::service_fn(|_req: Request<Body>| async {
127                Ok::<_, Infallible>(ok_response())
128            }));
129
130        let req = Request::builder()
131            .method(Method::GET)
132            .uri("/")
133            .header(header::ORIGIN, "https://allowed.com")
134            .body(Body::empty())
135            .unwrap();
136        let resp = svc.oneshot(req).await.unwrap();
137
138        assert_eq!(
139            resp.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
140            "https://allowed.com",
141        );
142        assert_eq!(
143            resp.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(),
144            "true",
145        );
146    }
147
148    #[tokio::test]
149    async fn cors_whitelist_omits_allow_origin_for_unauthorized_origin() {
150        let layer = cors(CorsConfig {
151            allow_origin: "https://allowed.com",
152            allow_methods: default_methods(),
153            allow_headers: default_headers(),
154        });
155        let svc = ServiceBuilder::new()
156            .layer(layer)
157            .service(tower::service_fn(|_req: Request<Body>| async {
158                Ok::<_, Infallible>(ok_response())
159            }));
160
161        let req = Request::builder()
162            .method(Method::GET)
163            .uri("/")
164            .header(header::ORIGIN, "https://forbidden.com")
165            .body(Body::empty())
166            .unwrap();
167        let resp = svc.oneshot(req).await.unwrap();
168
169        // tower-http does not block the request — it simply omits the
170        // Access-Control-Allow-Origin header, leaving the browser to enforce.
171        assert!(resp.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).is_none());
172    }
173
174    /// An empty `allow_origin` produces no parseable origins, so we fall back
175    /// to `Any` rather than rejecting everything (existing behavior).
176    #[tokio::test]
177    async fn cors_empty_origin_string_falls_back_to_any() {
178        let layer = cors(CorsConfig {
179            allow_origin: "",
180            allow_methods: default_methods(),
181            allow_headers: default_headers(),
182        });
183        let svc = ServiceBuilder::new()
184            .layer(layer)
185            .service(tower::service_fn(|_req: Request<Body>| async {
186                Ok::<_, Infallible>(ok_response())
187            }));
188
189        let req = Request::builder()
190            .method(Method::GET)
191            .uri("/")
192            .header(header::ORIGIN, "https://anywhere.com")
193            .body(Body::empty())
194            .unwrap();
195        let resp = svc.oneshot(req).await.unwrap();
196
197        assert_eq!(resp.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), "*");
198    }
199}