use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CorsConfig {
pub allowed_origins: Vec<String>,
pub allowed_methods: Vec<String>,
pub allowed_headers: Vec<String>,
}
impl CorsConfig {
pub fn validate(&self) -> Result<(), crate::config::ConfigError> {
if self.allowed_origins.is_empty() {
return Err(crate::config::ConfigError::ValidationError(
"CORS allowed_origins cannot be empty".into(),
));
}
for origin in &self.allowed_origins {
if !origin.starts_with("http://") && !origin.starts_with("https://") {
return Err(crate::config::ConfigError::ValidationError(format!(
"Invalid CORS origin: {}. Must start with http:// or https://",
origin
)));
}
let after_scheme = origin.split("://").nth(1).unwrap_or("");
if after_scheme.is_empty() {
return Err(crate::config::ConfigError::ValidationError(format!(
"Invalid CORS origin: {}. Must include host (e.g. http://example.com)",
origin
)));
}
}
Ok(())
}
}
pub fn build_cors_layer(
config: &CorsConfig,
) -> Result<tower_http::cors::CorsLayer, crate::config::ConfigError> {
use tower_http::cors::{Any, CorsLayer};
if config.allowed_origins.is_empty() {
return Err(crate::config::ConfigError::ValidationError(
"CORS allowed_origins cannot be empty. Use explicit origin list or disable CORS".into(),
));
}
for origin in &config.allowed_origins {
if !origin.starts_with("http://") && !origin.starts_with("https://") {
return Err(crate::config::ConfigError::ValidationError(format!(
"Invalid CORS origin: {}. Must start with http:// or https://",
origin
)));
}
let after_scheme = origin.split("://").nth(1).unwrap_or("");
if after_scheme.is_empty() {
return Err(crate::config::ConfigError::ValidationError(format!(
"Invalid CORS origin: {}. Must include host (e.g. http://example.com)",
origin
)));
}
}
let cors = CorsLayer::new().allow_methods(Any).allow_headers(Any);
let origins: Vec<_> = config
.allowed_origins
.iter()
.filter_map(|origin| origin.parse().ok())
.collect();
if origins.is_empty() {
return Err(crate::config::ConfigError::ValidationError(
"No valid origins found in CORS configuration".into(),
));
}
let cors = cors.allow_origin(origins);
Ok(cors)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cors_config_with_origins() {
let json = r#"{
"allowed_origins": ["http://localhost:3000", "https://example.com"],
"allowed_methods": ["GET", "POST"],
"allowed_headers": ["Content-Type", "Authorization"]
}"#;
let config: CorsConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.allowed_origins.len(), 2);
assert!(config.allowed_methods.contains(&"GET".to_string()));
assert!(config.allowed_headers.contains(&"Content-Type".to_string()));
}
#[test]
fn test_build_cors_layer_empty_origins() {
let config = CorsConfig::default();
let layer = build_cors_layer(&config);
assert!(layer.is_err());
}
#[test]
fn test_build_cors_layer_valid_origins() {
let json = r#"{"allowed_origins": ["http://localhost:3000"], "allowed_methods": [], "allowed_headers": []}"#;
let config: CorsConfig = serde_json::from_str(json).unwrap();
let layer = build_cors_layer(&config);
assert!(layer.is_ok());
}
#[test]
fn test_cors_config_validate_empty_origins() {
let config = CorsConfig {
allowed_origins: vec![],
allowed_methods: vec!["GET".to_string()],
allowed_headers: vec![],
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
fn test_cors_config_validate_invalid_origin_no_scheme() {
let config = CorsConfig {
allowed_origins: vec!["localhost:3000".to_string()],
allowed_methods: vec!["GET".to_string()],
allowed_headers: vec![],
};
let result = config.validate();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid CORS origin"));
}
#[test]
fn test_cors_config_validate_invalid_origin_http_only() {
let config = CorsConfig {
allowed_origins: vec!["http://".to_string()],
allowed_methods: vec!["GET".to_string()],
allowed_headers: vec![],
};
let result = config.validate();
assert!(
result.is_err(),
"origin without host should be rejected, got: {:?}",
result
);
}
#[test]
fn test_cors_config_validate_valid_origins() {
let config = CorsConfig {
allowed_origins: vec![
"http://localhost:3000".to_string(),
"https://example.com".to_string(),
],
allowed_methods: vec!["GET".to_string(), "POST".to_string()],
allowed_headers: vec!["Content-Type".to_string()],
};
assert!(config.validate().is_ok());
}
#[test]
fn test_cors_config_clone() {
let config = CorsConfig {
allowed_origins: vec!["http://localhost:3000".to_string()],
allowed_methods: vec!["GET".to_string()],
allowed_headers: vec!["Authorization".to_string()],
};
let cloned = config.clone();
assert_eq!(cloned.allowed_origins, config.allowed_origins);
assert_eq!(cloned.allowed_methods, config.allowed_methods);
}
#[test]
fn test_build_cors_layer_invalid_origin_format() {
let config = CorsConfig {
allowed_origins: vec!["localhost:3000".to_string()],
allowed_methods: vec!["GET".to_string()],
allowed_headers: vec![],
};
let result = build_cors_layer(&config);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid CORS origin"));
}
#[test]
fn test_build_cors_layer_no_valid_origins() {
let config = CorsConfig {
allowed_origins: vec!["http://\ninvalid".to_string()],
allowed_methods: vec!["GET".to_string()],
allowed_headers: vec![],
};
let result = build_cors_layer(&config);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No valid origins"));
}
}