use std::sync::{Arc, OnceLock};
#[derive(Debug, Clone)]
pub struct RequestInfo {
pub method: String,
pub path: String,
pub request_id: Option<String>,
}
impl RequestInfo {
pub fn capture(req: &axum::http::Request<axum::body::Body>) -> Self {
#[cfg(feature = "context")]
let request_id = crate::context::current().map(|c| c.request_id().to_string());
#[cfg(not(feature = "context"))]
let request_id = None;
Self {
method: req.method().to_string(),
path: req.uri().path().to_string(),
request_id,
}
}
pub fn synthetic(method: &str, path: &str) -> Self {
Self {
method: method.to_string(),
path: path.to_string(),
request_id: None,
}
}
}
pub trait RequestHooks: Send + Sync {
fn before(&self, info: &RequestInfo);
fn after(&self, info: &RequestInfo, status: u16);
}
static GLOBAL_HOOKS: OnceLock<Arc<dyn RequestHooks>> = OnceLock::new();
pub fn install_hooks(hooks: Arc<dyn RequestHooks>) -> bool {
GLOBAL_HOOKS.set(hooks).is_ok()
}
pub fn installed() -> Option<Arc<dyn RequestHooks>> {
GLOBAL_HOOKS.get().cloned()
}
pub async fn hooks_middleware(
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> axum::response::Response {
let Some(hooks) = installed() else {
return next.run(req).await;
};
let info = RequestInfo::capture(&req);
run_before(&*hooks, &info);
let response = next.run(req).await;
let status = response.status().as_u16();
run_after(&*hooks, &info, status);
response
}
fn run_before(hooks: &dyn RequestHooks, info: &RequestInfo) {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| hooks.before(info)));
}
fn run_after(hooks: &dyn RequestHooks, info: &RequestInfo, status: u16) {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| hooks.after(info, status)));
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use std::sync::atomic::{AtomicUsize, Ordering};
use tower::ServiceExt;
struct CountingHooks {
before: AtomicUsize,
after: AtomicUsize,
last_status: std::sync::Mutex<Option<u16>>,
}
impl RequestHooks for CountingHooks {
fn before(&self, _info: &RequestInfo) {
self.before.fetch_add(1, Ordering::SeqCst);
}
fn after(&self, _info: &RequestInfo, status: u16) {
self.after.fetch_add(1, Ordering::SeqCst);
*self.last_status.lock().unwrap() = Some(status);
}
}
struct PanickingBefore;
impl RequestHooks for PanickingBefore {
fn before(&self, _info: &RequestInfo) {
panic!("hook explosion");
}
fn after(&self, _info: &RequestInfo, _status: u16) {}
}
fn test_app() -> axum::Router {
axum::Router::new()
.route("/hooked", axum::routing::get(|| async { "ok" }))
.layer(axum::middleware::from_fn(hooks_middleware))
}
#[tokio::test]
#[serial_test::serial]
async fn hooks_fire_before_and_after() {
let hooks = Arc::new(CountingHooks {
before: AtomicUsize::new(0),
after: AtomicUsize::new(0),
last_status: std::sync::Mutex::new(None),
});
assert!(install_hooks(hooks.clone()));
assert!(!install_hooks(hooks.clone()), "second install is rejected");
let resp = test_app()
.oneshot(
axum::http::Request::builder()
.uri("/hooked")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(hooks.before.load(Ordering::SeqCst), 1);
assert_eq!(hooks.after.load(Ordering::SeqCst), 1);
assert_eq!(*hooks.last_status.lock().unwrap(), Some(200));
}
#[test]
fn panicking_before_hook_is_isolated() {
let info = RequestInfo::synthetic("GET", "/hooked");
run_before(&PanickingBefore, &info);
run_after(&PanickingBefore, &info, 200);
}
#[test]
fn request_info_capture_fields() {
let info = RequestInfo::synthetic("GET", "/x");
assert_eq!(info.method, "GET");
assert_eq!(info.path, "/x");
assert!(info.request_id.is_none());
}
}