use axum::http::{HeaderName, HeaderValue, Method, Request, header};
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
use tower_http::request_id::{
MakeRequestId, PropagateRequestIdLayer, RequestId, SetRequestIdLayer,
};
use tower_http::set_header::SetResponseHeaderLayer;
pub use axum::middleware::*;
pub static X_REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");
#[derive(Debug, Clone)]
pub struct CorsConfig {
pub allow_origins: AllowOrigin,
pub allow_methods: AllowMethods,
pub allow_headers: AllowHeaders,
}
impl Default for CorsConfig {
fn default() -> Self {
Self {
allow_origins: AllowOrigin::any(),
allow_methods: AllowMethods::list([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
]),
allow_headers: AllowHeaders::list([
header::CONTENT_TYPE,
header::AUTHORIZATION,
header::ACCEPT,
X_REQUEST_ID.clone(),
]),
}
}
}
pub fn cors() -> CorsLayer {
cors_with_config(CorsConfig::default())
}
pub fn cors_with_config(config: CorsConfig) -> CorsLayer {
CorsLayer::new()
.allow_origin(config.allow_origins)
.allow_methods(config.allow_methods)
.allow_headers(config.allow_headers)
}
#[derive(Clone, Copy)]
pub struct UuidRequestId;
impl MakeRequestId for UuidRequestId {
fn make_request_id<B>(&mut self, _request: &Request<B>) -> Option<RequestId> {
let id = uuid::Uuid::new_v4().to_string();
Some(RequestId::new(HeaderValue::from_str(&id).ok()?))
}
}
pub fn request_id() -> (SetRequestIdLayer<UuidRequestId>, PropagateRequestIdLayer) {
(
SetRequestIdLayer::new(X_REQUEST_ID.clone(), UuidRequestId),
PropagateRequestIdLayer::new(X_REQUEST_ID.clone()),
)
}
#[cfg(feature = "compression")]
pub fn compression() -> tower_http::compression::CompressionLayer {
tower_http::compression::CompressionLayer::new()
}
pub fn timeout(duration: std::time::Duration) -> tower_http::timeout::TimeoutLayer {
tower_http::timeout::TimeoutLayer::with_status_code(
axum::http::StatusCode::REQUEST_TIMEOUT,
duration,
)
}
#[expect(
clippy::expect_used,
reason = "caller-provided directive is validated at the API boundary"
)]
pub fn cache_control(directive: &str) -> SetResponseHeaderLayer<HeaderValue> {
let value = HeaderValue::from_str(directive).expect("valid Cache-Control header value");
SetResponseHeaderLayer::if_not_present(header::CACHE_CONTROL, value)
}
pub fn no_cache() -> SetResponseHeaderLayer<HeaderValue> {
cache_control("no-cache, no-store, must-revalidate")
}
pub fn no_store() -> SetResponseHeaderLayer<HeaderValue> {
cache_control("no-store")
}
#[cfg(feature = "decompression")]
pub fn decompression() -> tower_http::decompression::RequestDecompressionLayer {
tower_http::decompression::RequestDecompressionLayer::new()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::body::Body;
use axum::http::{self, StatusCode};
use axum::routing::get;
use tower::ServiceExt;
async fn ok_handler() -> &'static str {
"ok"
}
#[tokio::test]
async fn cors_allows_cross_origin_with_default_config() {
let app = Router::new().route("/", get(ok_handler)).layer(cors());
let request = http::Request::builder()
.uri("/")
.header(header::ORIGIN, "http://example.com")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response
.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.is_some()
);
}
#[tokio::test]
async fn cors_can_be_customized_with_specific_origins() {
let config = CorsConfig {
allow_origins: AllowOrigin::exact("http://allowed.com".parse().unwrap()),
..CorsConfig::default()
};
let app = Router::new()
.route("/", get(ok_handler))
.layer(cors_with_config(config));
let request = http::Request::builder()
.uri("/")
.header(header::ORIGIN, "http://allowed.com")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.unwrap(),
"http://allowed.com"
);
}
#[tokio::test]
async fn request_id_adds_header_to_response() {
let (set_id, propagate_id) = request_id();
let app = Router::new()
.route("/", get(ok_handler))
.layer(propagate_id)
.layer(set_id);
let request = http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let rid = response
.headers()
.get("x-request-id")
.expect("x-request-id header should be present");
let parsed = uuid::Uuid::parse_str(rid.to_str().unwrap());
assert!(parsed.is_ok(), "x-request-id should be a valid UUID");
}
#[tokio::test]
async fn request_id_preserves_existing_header() {
let existing_id = "my-custom-request-id-123";
let (set_id, propagate_id) = request_id();
let app = Router::new()
.route("/", get(ok_handler))
.layer(propagate_id)
.layer(set_id);
let request = http::Request::builder()
.uri("/")
.header("x-request-id", existing_id)
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-request-id")
.unwrap()
.to_str()
.unwrap(),
existing_id
);
}
#[tokio::test]
async fn middleware_composes_with_app_layer() {
let (set_id, propagate_id) = request_id();
let app = crate::App::new()
.routes(Router::new().route("/", get(ok_handler)))
.layer(cors())
.layer(propagate_id)
.layer(set_id)
.build();
let request = http::Request::builder()
.uri("/")
.header(header::ORIGIN, "http://example.com")
.body(Body::empty())
.unwrap();
let response = ServiceExt::<http::Request<Body>>::oneshot(app, request)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response
.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.is_some()
);
assert!(response.headers().get("x-request-id").is_some());
}
#[tokio::test]
async fn axum_from_fn_is_accessible() {
async fn noop_middleware(
request: http::Request<Body>,
next: axum::middleware::Next,
) -> axum::response::Response {
next.run(request).await
}
let _layer = from_fn::<_, ()>(noop_middleware);
}
#[tokio::test]
async fn cache_control_sets_header_on_response() {
let app = Router::new()
.route("/", get(ok_handler))
.layer(cache_control("max-age=3600"));
let request = http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.unwrap()
.to_str()
.unwrap(),
"max-age=3600"
);
}
#[tokio::test]
async fn cache_control_does_not_overwrite_handler_set_header() {
async fn handler_with_cache() -> impl axum::response::IntoResponse {
([(header::CACHE_CONTROL, "private, max-age=60")], "ok")
}
let app = Router::new()
.route("/", get(handler_with_cache))
.layer(no_cache());
let request = http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.unwrap()
.to_str()
.unwrap(),
"private, max-age=60"
);
}
#[tokio::test]
async fn no_cache_sets_full_directive() {
let app = Router::new().route("/", get(ok_handler)).layer(no_cache());
let request = http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.unwrap()
.to_str()
.unwrap(),
"no-cache, no-store, must-revalidate"
);
}
}