use std::time::Duration;
use tower_http::cors::{Any, CorsLayer};
#[derive(Debug, Clone)]
pub struct CorsConfig {
pub enabled: bool,
pub allowed_origins: Option<Vec<String>>,
pub allowed_methods: Vec<String>,
pub allowed_headers: Vec<String>,
pub expose_headers: Vec<String>,
pub allow_credentials: bool,
pub max_age_secs: u64,
}
impl Default for CorsConfig {
fn default() -> Self {
Self {
enabled: true,
allowed_origins: None, allowed_methods: vec![
"GET".to_string(),
"POST".to_string(),
"PUT".to_string(),
"DELETE".to_string(),
"OPTIONS".to_string(),
],
allowed_headers: vec![
"Content-Type".to_string(),
"Authorization".to_string(),
"X-API-Key".to_string(),
],
expose_headers: vec![
"X-RateLimit-Limit".to_string(),
"X-RateLimit-Remaining".to_string(),
"X-RateLimit-Reset".to_string(),
],
allow_credentials: false,
max_age_secs: 86400, }
}
}
impl CorsConfig {
pub fn permissive() -> Self {
Self {
enabled: true,
allowed_origins: None,
allowed_methods: vec![
"GET".to_string(),
"POST".to_string(),
"PUT".to_string(),
"PATCH".to_string(),
"DELETE".to_string(),
"OPTIONS".to_string(),
"HEAD".to_string(),
],
allowed_headers: vec!["*".to_string()],
expose_headers: vec!["*".to_string()],
allow_credentials: true,
max_age_secs: 86400,
}
}
pub fn strict(origins: Vec<String>) -> Self {
Self {
enabled: true,
allowed_origins: Some(origins),
allowed_methods: vec![
"GET".to_string(),
"POST".to_string(),
"DELETE".to_string(),
],
allowed_headers: vec![
"Content-Type".to_string(),
"Authorization".to_string(),
],
expose_headers: vec![],
allow_credentials: false,
max_age_secs: 3600, }
}
pub fn disabled() -> Self {
Self {
enabled: false,
..Default::default()
}
}
pub fn is_origin_allowed(&self, origin: &str) -> bool {
if !self.enabled {
return false;
}
match &self.allowed_origins {
None => true, Some(origins) => origins.iter().any(|o| o == origin || o == "*"),
}
}
pub fn is_method_allowed(&self, method: &str) -> bool {
if !self.enabled {
return false;
}
self.allowed_methods
.iter()
.any(|m| m.eq_ignore_ascii_case(method))
}
pub fn is_header_allowed(&self, header: &str) -> bool {
if !self.enabled {
return false;
}
self.allowed_headers.iter().any(|h| {
h == "*" || h.eq_ignore_ascii_case(header)
})
}
pub fn into_layer(self) -> CorsLayer {
if !self.enabled {
return CorsLayer::new();
}
let mut layer = CorsLayer::new();
if self.allowed_origins.is_none() {
layer = layer.allow_origin(Any);
} else if let Some(origins) = &self.allowed_origins {
let origins: Vec<_> = origins
.iter()
.filter_map(|o| o.parse().ok())
.collect();
if !origins.is_empty() {
layer = layer.allow_origin(origins);
}
}
let methods: Vec<_> = self
.allowed_methods
.iter()
.filter_map(|m| m.parse().ok())
.collect();
if !methods.is_empty() {
layer = layer.allow_methods(methods);
}
if self.allowed_headers.iter().any(|h| h == "*") {
layer = layer.allow_headers(Any);
} else {
let headers: Vec<_> = self
.allowed_headers
.iter()
.filter_map(|h| h.parse().ok())
.collect();
if !headers.is_empty() {
layer = layer.allow_headers(headers);
}
}
if !self.expose_headers.is_empty() && !self.expose_headers.iter().any(|h| h == "*") {
let headers: Vec<_> = self
.expose_headers
.iter()
.filter_map(|h| h.parse().ok())
.collect();
if !headers.is_empty() {
layer = layer.expose_headers(headers);
}
}
if self.allow_credentials {
layer = layer.allow_credentials(true);
}
layer = layer.max_age(Duration::from_secs(self.max_age_secs));
layer
}
pub fn with_origin(mut self, origin: impl Into<String>) -> Self {
let origins = self.allowed_origins.get_or_insert_with(Vec::new);
origins.push(origin.into());
self
}
pub fn with_method(mut self, method: impl Into<String>) -> Self {
self.allowed_methods.push(method.into());
self
}
pub fn with_header(mut self, header: impl Into<String>) -> Self {
self.allowed_headers.push(header.into());
self
}
pub fn with_credentials(mut self, allow: bool) -> Self {
self.allow_credentials = allow;
self
}
pub fn with_max_age(mut self, secs: u64) -> Self {
self.max_age_secs = secs;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cors_config_default() {
let config = CorsConfig::default();
assert!(config.enabled);
assert!(config.allowed_origins.is_none());
assert!(config.allowed_methods.contains(&"GET".to_string()));
assert!(config.allowed_methods.contains(&"POST".to_string()));
assert!(config.allowed_headers.contains(&"Content-Type".to_string()));
assert!(!config.allow_credentials);
assert_eq!(config.max_age_secs, 86400);
}
#[test]
fn test_cors_config_permissive() {
let config = CorsConfig::permissive();
assert!(config.enabled);
assert!(config.allowed_origins.is_none());
assert!(config.allowed_headers.contains(&"*".to_string()));
assert!(config.allow_credentials);
}
#[test]
fn test_cors_config_strict() {
let origins = vec!["https://example.com".to_string()];
let config = CorsConfig::strict(origins);
assert!(config.enabled);
assert!(config.allowed_origins.is_some());
assert_eq!(
config.allowed_origins.as_ref().unwrap()[0],
"https://example.com"
);
assert!(!config.allow_credentials);
}
#[test]
fn test_cors_config_disabled() {
let config = CorsConfig::disabled();
assert!(!config.enabled);
}
#[test]
fn test_cors_into_layer() {
let config = CorsConfig::default();
let _layer = config.into_layer();
}
#[test]
fn test_cors_into_layer_disabled() {
let config = CorsConfig::disabled();
let _layer = config.into_layer();
}
#[test]
fn test_cors_is_origin_allowed() {
let config = CorsConfig::default();
assert!(config.is_origin_allowed("https://example.com"));
assert!(config.is_origin_allowed("http://localhost:3000"));
let config = CorsConfig::strict(vec![
"https://example.com".to_string(),
"https://app.example.com".to_string(),
]);
assert!(config.is_origin_allowed("https://example.com"));
assert!(config.is_origin_allowed("https://app.example.com"));
assert!(!config.is_origin_allowed("https://other.com"));
let config = CorsConfig::disabled();
assert!(!config.is_origin_allowed("https://example.com"));
}
#[test]
fn test_cors_is_method_allowed() {
let config = CorsConfig::default();
assert!(config.is_method_allowed("GET"));
assert!(config.is_method_allowed("get"));
assert!(config.is_method_allowed("POST"));
assert!(!config.is_method_allowed("PATCH"));
let config = CorsConfig::permissive();
assert!(config.is_method_allowed("PATCH"));
let config = CorsConfig::disabled();
assert!(!config.is_method_allowed("GET"));
}
#[test]
fn test_cors_is_header_allowed() {
let config = CorsConfig::default();
assert!(config.is_header_allowed("Content-Type"));
assert!(config.is_header_allowed("content-type"));
assert!(config.is_header_allowed("Authorization"));
assert!(config.is_header_allowed("X-API-Key"));
assert!(!config.is_header_allowed("X-Custom-Header"));
let config = CorsConfig::permissive();
assert!(config.is_header_allowed("X-Custom-Header"));
let config = CorsConfig::disabled();
assert!(!config.is_header_allowed("Content-Type"));
}
#[test]
fn test_cors_credentials() {
let config = CorsConfig::default();
assert!(!config.allow_credentials);
let config = CorsConfig::permissive();
assert!(config.allow_credentials);
let config = CorsConfig::default().with_credentials(true);
assert!(config.allow_credentials);
}
#[test]
fn test_cors_max_age() {
let config = CorsConfig::default();
assert_eq!(config.max_age_secs, 86400);
let config = CorsConfig::strict(vec![]);
assert_eq!(config.max_age_secs, 3600);
let config = CorsConfig::default().with_max_age(7200);
assert_eq!(config.max_age_secs, 7200);
}
#[test]
fn test_cors_builder_methods() {
let config = CorsConfig::default()
.with_origin("https://custom.com")
.with_method("PATCH")
.with_header("X-Custom")
.with_credentials(true)
.with_max_age(1800);
assert!(config.allowed_origins.is_some());
assert!(config
.allowed_origins
.as_ref()
.unwrap()
.contains(&"https://custom.com".to_string()));
assert!(config.allowed_methods.contains(&"PATCH".to_string()));
assert!(config.allowed_headers.contains(&"X-Custom".to_string()));
assert!(config.allow_credentials);
assert_eq!(config.max_age_secs, 1800);
}
#[test]
fn test_cors_into_layer_with_origins() {
let config = CorsConfig::strict(vec!["https://example.com".to_string()]);
let _layer = config.into_layer();
}
#[test]
fn test_cors_into_layer_permissive() {
let config = CorsConfig::permissive();
let _layer = config.into_layer();
}
}